From 80db452485a0c8da38d75c23920e3c1f257fcc51 Mon Sep 17 00:00:00 2001 From: Matthieu FRONTON Date: Sun, 15 Feb 2026 02:02:05 +0100 Subject: [PATCH 1/4] fix(cli): auto-continue max iterations in --yolo mode When running in non-interactive mode with --yolo (e.g. `cagent exec --yolo`), reaching max_iterations triggers a stdin prompt that fails immediately with "Failed to read input, exiting..." since stdin is piped or closed. This fix proposal makes --yolo consistent: it now auto-approves iteration extensions just like it auto-approves tool calls. A safety cap of 5 extensions (+50 iterations) prevents infinite loops. TL;DR : in --yolo mode, cagent now auto-approve the "max-iterations" continuation (up to 5 times) instead of prompting stdin (which fails immediately in non-interactive/piped contexts) Signed-off-by: Matthieu FRONTON --- pkg/cli/runner.go | 42 ++++++++++++++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/pkg/cli/runner.go b/pkg/cli/runner.go index 6be776c22..32d225c23 100644 --- a/pkg/cli/runner.go +++ b/pkg/cli/runner.go @@ -31,6 +31,10 @@ func (e RuntimeError) Unwrap() error { return e.Err } +// maxAutoExtensions is the maximum number of times --yolo mode will +// auto-continue when max iterations is reached, to prevent infinite loops. +const maxAutoExtensions = 5 + // Config holds configuration for running an agent in CLI mode type Config struct { AppName string @@ -60,6 +64,8 @@ func Run(ctx context.Context, out *Printer, cfg Config, rt runtime.Runtime, sess var lastErr error oneLoop := func(text string, rd io.Reader) error { + autoExtensions := 0 + userInput := strings.TrimSpace(text) if userInput == "" { return nil @@ -153,16 +159,32 @@ func Run(ctx context.Context, out *Printer, cfg Config, rt runtime.Runtime, sess out.PrintError(lastErr) } case *runtime.MaxIterationsReachedEvent: - result := out.PromptMaxIterationsContinue(ctx, e.MaxIterations) - switch result { - case ConfirmationApprove: - rt.Resume(ctx, runtime.ResumeApprove()) - case ConfirmationReject: - rt.Resume(ctx, runtime.ResumeReject("")) - return nil - case ConfirmationAbort: - rt.Resume(ctx, runtime.ResumeReject("")) - return nil + if cfg.AutoApprove { + autoExtensions++ + if autoExtensions <= maxAutoExtensions { + slog.Info("Auto-extending iterations in yolo mode", + "extension", autoExtensions, + "max_extensions", maxAutoExtensions, + "current_max", e.MaxIterations) + rt.Resume(ctx, runtime.ResumeApprove()) + } else { + slog.Warn("Max auto-extensions reached in yolo mode, stopping", + "total_extensions", autoExtensions) + rt.Resume(ctx, runtime.ResumeReject("")) + return nil + } + } else { + result := out.PromptMaxIterationsContinue(ctx, e.MaxIterations) + switch result { + case ConfirmationApprove: + rt.Resume(ctx, runtime.ResumeApprove()) + case ConfirmationReject: + rt.Resume(ctx, runtime.ResumeReject("")) + return nil + case ConfirmationAbort: + rt.Resume(ctx, runtime.ResumeReject("")) + return nil + } } case *runtime.ElicitationRequestEvent: serverURL, ok := e.Meta["cagent/server_url"].(string) From 7ed808f7dc6a8c994b9746c71b8e5261acc93ff3 Mon Sep 17 00:00:00 2001 From: Matthieu FRONTON Date: Sun, 15 Feb 2026 02:02:05 +0100 Subject: [PATCH 2/4] fix(cli): handle max iterations in JSON output mode The JSON event loop (`--json`) did not handle MaxIterationsReachedEvent at all, causing the runtime to hang on resumeChan indefinitely. Apply the same auto-continue logic as the normal output path: auto-approve in --yolo mode (with safety cap), reject otherwise. Signed-off-by: Matthieu FRONTON --- pkg/cli/runner.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/pkg/cli/runner.go b/pkg/cli/runner.go index 32d225c23..9ceca8957 100644 --- a/pkg/cli/runner.go +++ b/pkg/cli/runner.go @@ -80,6 +80,25 @@ func Run(ctx context.Context, out *Printer, cfg Config, rt runtime.Runtime, sess if !cfg.AutoApprove { rt.Resume(ctx, runtime.ResumeReject("")) } + case *runtime.MaxIterationsReachedEvent: + if cfg.AutoApprove { + autoExtensions++ + if autoExtensions <= maxAutoExtensions { + slog.Info("Auto-extending iterations in yolo mode (json)", + "extension", autoExtensions, + "max_extensions", maxAutoExtensions, + "current_max", e.MaxIterations) + rt.Resume(ctx, runtime.ResumeApprove()) + } else { + slog.Warn("Max auto-extensions reached in yolo mode (json), stopping", + "total_extensions", autoExtensions) + rt.Resume(ctx, runtime.ResumeReject("")) + return nil + } + } else { + rt.Resume(ctx, runtime.ResumeReject("")) + return nil + } case *runtime.ErrorEvent: return fmt.Errorf("%s", e.Error) } From ea08bccd99d932808e716df2fb1f6f9d515e5281 Mon Sep 17 00:00:00 2001 From: Matthieu FRONTON Date: Sun, 15 Feb 2026 02:02:05 +0100 Subject: [PATCH 3/4] test(cli): add tests for max iterations auto-continue Cover --yolo auto-continue behavior for MaxIterationsReachedEvent in both normal and JSON output modes, including safety cap enforcement. Signed-off-by: Matthieu FRONTON --- pkg/cli/runner_test.go | 205 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 pkg/cli/runner_test.go diff --git a/pkg/cli/runner_test.go b/pkg/cli/runner_test.go new file mode 100644 index 000000000..f92bc4c31 --- /dev/null +++ b/pkg/cli/runner_test.go @@ -0,0 +1,205 @@ +package cli + +import ( + "bytes" + "context" + "sync" + "testing" + + "gotest.tools/v3/assert" + + "github.com/docker/cagent/pkg/runtime" + "github.com/docker/cagent/pkg/session" + "github.com/docker/cagent/pkg/sessiontitle" + "github.com/docker/cagent/pkg/tools" + mcptools "github.com/docker/cagent/pkg/tools/mcp" +) + +// mockRuntime implements runtime.Runtime for testing the CLI runner. +// It emits pre-configured events from RunStream and records Resume calls. +type mockRuntime struct { + events []runtime.Event + + mu sync.Mutex + resumes []runtime.ResumeRequest +} + +func (m *mockRuntime) CurrentAgentName() string { return "test" } +func (m *mockRuntime) CurrentAgentInfo(context.Context) runtime.CurrentAgentInfo { + return runtime.CurrentAgentInfo{Name: "test"} +} +func (m *mockRuntime) SetCurrentAgent(string) error { return nil } +func (m *mockRuntime) CurrentAgentTools(context.Context) ([]tools.Tool, error) { return nil, nil } +func (m *mockRuntime) EmitStartupInfo(context.Context, chan runtime.Event) {} +func (m *mockRuntime) ResetStartupInfo() {} +func (m *mockRuntime) Run(context.Context, *session.Session) ([]session.Message, error) { + return nil, nil +} + +func (m *mockRuntime) ResumeElicitation(context.Context, tools.ElicitationAction, map[string]any) error { + return nil +} +func (m *mockRuntime) SessionStore() session.Store { return nil } +func (m *mockRuntime) Summarize(context.Context, *session.Session, string, chan runtime.Event) {} +func (m *mockRuntime) PermissionsInfo() *runtime.PermissionsInfo { return nil } +func (m *mockRuntime) CurrentAgentSkillsEnabled() bool { return false } +func (m *mockRuntime) CurrentMCPPrompts(context.Context) map[string]mcptools.PromptInfo { + return nil +} + +func (m *mockRuntime) ExecuteMCPPrompt(context.Context, string, map[string]string) (string, error) { + return "", nil +} +func (m *mockRuntime) UpdateSessionTitle(context.Context, *session.Session, string) error { return nil } +func (m *mockRuntime) TitleGenerator() *sessiontitle.Generator { return nil } +func (m *mockRuntime) Close() error { return nil } +func (m *mockRuntime) RegenerateTitle(context.Context, *session.Session, chan runtime.Event) {} + +func (m *mockRuntime) Resume(_ context.Context, req runtime.ResumeRequest) { + m.mu.Lock() + defer m.mu.Unlock() + m.resumes = append(m.resumes, req) +} + +func (m *mockRuntime) RunStream(_ context.Context, _ *session.Session) <-chan runtime.Event { + ch := make(chan runtime.Event, len(m.events)) + for _, e := range m.events { + ch <- e + } + close(ch) + return ch +} + +func (m *mockRuntime) getResumes() []runtime.ResumeRequest { + m.mu.Lock() + defer m.mu.Unlock() + result := make([]runtime.ResumeRequest, len(m.resumes)) + copy(result, m.resumes) + return result +} + +func maxIterEvent(maxIter int) *runtime.MaxIterationsReachedEvent { + return &runtime.MaxIterationsReachedEvent{ + Type: "max_iterations_reached", + MaxIterations: maxIter, + } +} + +func TestMaxIterationsAutoApproveInYoloMode(t *testing.T) { + t.Parallel() + + rt := &mockRuntime{ + events: []runtime.Event{maxIterEvent(60)}, + } + + var buf bytes.Buffer + out := NewPrinter(&buf) + sess := session.New() + cfg := Config{AutoApprove: true} + + err := Run(t.Context(), out, cfg, rt, sess, []string{"hello"}) + assert.NilError(t, err) + + resumes := rt.getResumes() + assert.Equal(t, len(resumes), 1) + assert.Equal(t, resumes[0].Type, runtime.ResumeTypeApprove) +} + +func TestMaxIterationsAutoApproveSafetyCap(t *testing.T) { + t.Parallel() + + // Emit maxAutoExtensions+1 events to trigger the safety cap + events := make([]runtime.Event, maxAutoExtensions+1) + for i := range events { + events[i] = maxIterEvent(60 + i*10) + } + + rt := &mockRuntime{events: events} + + var buf bytes.Buffer + out := NewPrinter(&buf) + sess := session.New() + cfg := Config{AutoApprove: true} + + err := Run(t.Context(), out, cfg, rt, sess, []string{"hello"}) + assert.NilError(t, err) + + resumes := rt.getResumes() + assert.Equal(t, len(resumes), maxAutoExtensions+1) + + // First maxAutoExtensions should be approved + for i := range maxAutoExtensions { + assert.Equal(t, resumes[i].Type, runtime.ResumeTypeApprove, + "extension %d should be approved", i+1) + } + // Last one should be rejected (safety cap) + assert.Equal(t, resumes[maxAutoExtensions].Type, runtime.ResumeTypeReject, + "extension beyond cap should be rejected") +} + +func TestMaxIterationsAutoApproveJSONMode(t *testing.T) { + t.Parallel() + + rt := &mockRuntime{ + events: []runtime.Event{maxIterEvent(60)}, + } + + var buf bytes.Buffer + out := NewPrinter(&buf) + sess := session.New() + cfg := Config{AutoApprove: true, OutputJSON: true} + + err := Run(t.Context(), out, cfg, rt, sess, []string{"hello"}) + assert.NilError(t, err) + + resumes := rt.getResumes() + assert.Equal(t, len(resumes), 1) + assert.Equal(t, resumes[0].Type, runtime.ResumeTypeApprove) +} + +func TestMaxIterationsRejectInJSONModeWithoutYolo(t *testing.T) { + t.Parallel() + + rt := &mockRuntime{ + events: []runtime.Event{maxIterEvent(60)}, + } + + var buf bytes.Buffer + out := NewPrinter(&buf) + sess := session.New() + cfg := Config{AutoApprove: false, OutputJSON: true} + + err := Run(t.Context(), out, cfg, rt, sess, []string{"hello"}) + assert.NilError(t, err) + + resumes := rt.getResumes() + assert.Equal(t, len(resumes), 1) + assert.Equal(t, resumes[0].Type, runtime.ResumeTypeReject) +} + +func TestMaxIterationsSafetyCapJSONMode(t *testing.T) { + t.Parallel() + + events := make([]runtime.Event, maxAutoExtensions+1) + for i := range events { + events[i] = maxIterEvent(60 + i*10) + } + + rt := &mockRuntime{events: events} + + var buf bytes.Buffer + out := NewPrinter(&buf) + sess := session.New() + cfg := Config{AutoApprove: true, OutputJSON: true} + + err := Run(t.Context(), out, cfg, rt, sess, []string{"hello"}) + assert.NilError(t, err) + + resumes := rt.getResumes() + assert.Equal(t, len(resumes), maxAutoExtensions+1) + + for i := range maxAutoExtensions { + assert.Equal(t, resumes[i].Type, runtime.ResumeTypeApprove) + } + assert.Equal(t, resumes[maxAutoExtensions].Type, runtime.ResumeTypeReject) +} From 363438529a5d1b38cb68ace6b832b7eaba7335c5 Mon Sep 17 00:00:00 2001 From: Matthieu FRONTON Date: Sun, 15 Feb 2026 02:02:05 +0100 Subject: [PATCH 4/4] refactor(cli): extract handleMaxIterationsAutoApprove helper Deduplicate MaxIterationsReachedEvent logic between JSON and normal output modes into a single helper with a ternary return type (continue/stop/prompt). Signed-off-by: Matthieu FRONTON --- pkg/cli/runner.go | 70 +++++++++++++++++++++++++++-------------------- 1 file changed, 40 insertions(+), 30 deletions(-) diff --git a/pkg/cli/runner.go b/pkg/cli/runner.go index 9ceca8957..cf367af01 100644 --- a/pkg/cli/runner.go +++ b/pkg/cli/runner.go @@ -35,6 +35,35 @@ func (e RuntimeError) Unwrap() error { // auto-continue when max iterations is reached, to prevent infinite loops. const maxAutoExtensions = 5 +// maxIterAction describes what the caller should do after a MaxIterationsReachedEvent. +type maxIterAction int + +const ( + maxIterContinue maxIterAction = iota // auto-approved, keep running + maxIterStop // safety cap reached, caller should stop + maxIterPrompt // not in yolo mode, caller should prompt the user +) + +// handleMaxIterationsAutoApprove decides whether to auto-extend iterations in +// --yolo mode. Returns maxIterContinue (approved), maxIterStop (cap reached), +// or maxIterPrompt (not in auto-approve mode, caller should ask the user). +func handleMaxIterationsAutoApprove(autoApprove bool, autoExtensions *int, maxIter int) maxIterAction { + if !autoApprove { + return maxIterPrompt + } + *autoExtensions++ + if *autoExtensions <= maxAutoExtensions { + slog.Info("Auto-extending iterations in yolo mode", + "extension", *autoExtensions, + "max_extensions", maxAutoExtensions, + "current_max", maxIter) + return maxIterContinue + } + slog.Warn("Max auto-extensions reached in yolo mode, stopping", + "total_extensions", *autoExtensions) + return maxIterStop +} + // Config holds configuration for running an agent in CLI mode type Config struct { AppName string @@ -81,21 +110,10 @@ func Run(ctx context.Context, out *Printer, cfg Config, rt runtime.Runtime, sess rt.Resume(ctx, runtime.ResumeReject("")) } case *runtime.MaxIterationsReachedEvent: - if cfg.AutoApprove { - autoExtensions++ - if autoExtensions <= maxAutoExtensions { - slog.Info("Auto-extending iterations in yolo mode (json)", - "extension", autoExtensions, - "max_extensions", maxAutoExtensions, - "current_max", e.MaxIterations) - rt.Resume(ctx, runtime.ResumeApprove()) - } else { - slog.Warn("Max auto-extensions reached in yolo mode (json), stopping", - "total_extensions", autoExtensions) - rt.Resume(ctx, runtime.ResumeReject("")) - return nil - } - } else { + switch handleMaxIterationsAutoApprove(cfg.AutoApprove, &autoExtensions, e.MaxIterations) { + case maxIterContinue: + rt.Resume(ctx, runtime.ResumeApprove()) + default: // maxIterStop or maxIterPrompt (no interactive prompt in JSON mode) rt.Resume(ctx, runtime.ResumeReject("")) return nil } @@ -178,21 +196,13 @@ func Run(ctx context.Context, out *Printer, cfg Config, rt runtime.Runtime, sess out.PrintError(lastErr) } case *runtime.MaxIterationsReachedEvent: - if cfg.AutoApprove { - autoExtensions++ - if autoExtensions <= maxAutoExtensions { - slog.Info("Auto-extending iterations in yolo mode", - "extension", autoExtensions, - "max_extensions", maxAutoExtensions, - "current_max", e.MaxIterations) - rt.Resume(ctx, runtime.ResumeApprove()) - } else { - slog.Warn("Max auto-extensions reached in yolo mode, stopping", - "total_extensions", autoExtensions) - rt.Resume(ctx, runtime.ResumeReject("")) - return nil - } - } else { + switch handleMaxIterationsAutoApprove(cfg.AutoApprove, &autoExtensions, e.MaxIterations) { + case maxIterContinue: + rt.Resume(ctx, runtime.ResumeApprove()) + case maxIterStop: + rt.Resume(ctx, runtime.ResumeReject("")) + return nil + case maxIterPrompt: result := out.PromptMaxIterationsContinue(ctx, e.MaxIterations) switch result { case ConfirmationApprove: