Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions pkg/runtime/loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,24 @@ func (r *LocalRuntime) RunStream(ctx context.Context, sess *session.Session) <-c
r.executeNotificationHooks(ctx, a, sess.ID, "warning", maxIterMsg)
r.executeOnUserInputHooks(ctx, sess.ID, "max iterations reached")

// In non-interactive mode (e.g. MCP server), auto-stop instead of
// blocking forever waiting for user input.
if sess.ToolsApproved {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 HIGH SEVERITY: Auto-stop logic incorrectly triggers in interactive sessions with tools approved

The auto-stop logic checks only sess.ToolsApproved to determine non-interactive mode, but this flag is set in two distinct scenarios:

  1. MCP Server Mode (pkg/mcp/server.go:145): ToolsApproved is set to true for non-interactive MCP tool calls
  2. Interactive TUI Mode (pkg/cli/runner.go:119): ToolsApproved is set to true when users select "ConfirmationApproveSession" to approve all tools

The Problem:
In interactive TUI sessions where users have approved tools, this auto-stop will prevent them from manually resuming after max iterations. The stream exits with return, making it impossible for users to continue the conversation interactively as they would expect in TUI mode.

Recommendation:
The condition should distinguish between truly non-interactive contexts (MCP server, API mode) and interactive TUI sessions with tools approved. Consider:

  • Adding a dedicated sess.NonInteractive flag, or
  • Checking sess.ToolsApproved && !r.isInteractiveMode(), or
  • Using a different session property that specifically indicates MCP/API server mode

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, sounds valid. This flag indeed can also be set mid-session when a user selects "approve all tools" in the TUI, not just in non-interactive modes.

However, I think the practical impact is limited:

  • In the CLI/TUI, the MaxIterationsReachedEvent is emitted before this early return. The outer runner receives it and would attempt rt.Resume(), but the loop has already exited. The result is the stream closes cleanly - the user just can't extend iterations. This is a behavior change but not a hang or crash.

  • The current behavior without this patch is that MCP/A2A/eval sessions block forever on r.resumeChan since there's no consumer to call rt.Resume(). I believe this is the actual bug.

I guess to properly distinguish non-interactive contexts, we'd need something like a sess.NonInteractive runtime option set only in MCP server, A2A, and evaluation modes.

@derekmisler, what are your thoughts on this? Should I update the code to introduce sess.NonInteractive and use it instead?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great analysis from both sides here. The bot's review is valid — sess.ToolsApproved isn't a reliable proxy for non-interactive mode since it gets set to true in two distinct paths:

  1. Non-interactive (MCP, A2A, eval) — set at session creation
  2. Interactive TUI — set mid-session at pkg/cli/runner.go:168 when a user presses "A" (approve all)

So yes, a dedicated sess.NonInteractive flag is the right call. It should be set only in the truly non-interactive session creation paths:

  • pkg/mcp/server.go (MCP server)
  • pkg/a2a/adapter.go (A2A adapter)
  • pkg/evaluation/save.go (eval framework)

I agree the practical impact today is limited — an interactive user who hit max iterations would just lose the ability to extend by 10 more iterations rather than crash — but it's worth fixing properly since that extend-and-continue flow is the designed UX for interactive sessions.

@tdabasinskas would you mind updating the PR with the NonInteractive approach? Should be pretty contained — new field + option on Session, set it in those three places, and swap the check in loop.go. Thanks for the contribution!

slog.Debug("Auto-stopping after max iterations (non-interactive)", "agent", a.Name())

assistantMessage := chat.Message{
Role: chat.MessageRoleAssistant,
Content: fmt.Sprintf(
"Execution stopped after reaching the configured max_iterations limit (%d).",
runtimeMaxIterations,
),
CreatedAt: time.Now().Format(time.RFC3339),
}

addAgentMessage(sess, a, &assistantMessage, events)
return
}

// Wait for user decision (resume / reject)
select {
case req := <-r.resumeChan:
Expand Down
Loading