fix(cli): auto-continue max iterations in --yolo mode#1737
fix(cli): auto-continue max iterations in --yolo mode#1737frntn wants to merge 4 commits intodocker:mainfrom
Conversation
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 <m@tthieu.fr>
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 <m@tthieu.fr>
Cover --yolo auto-continue behavior for MaxIterationsReachedEvent in both normal and JSON output modes, including safety cap enforcement. Signed-off-by: Matthieu FRONTON <m@tthieu.fr>
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 <m@tthieu.fr>
|
I'm not really sure of this.. The max iterations feature was designed exactly for this purpose, to not iterate forever. Auto approving that confirmation prompt silently when using IMHO a better approach is just to set |
|
Thanks for the review @krissetto I had the same initial thought, but while running benchmarks on multiple coding agents using harbor framework (including cagent, which turns out to be one of my fav) I realized two things :
Bonus: This PR also fixes the JSON output mode, which was completely missing handling for MaxIterationsReachedEvent and caused a runtime hang. Does that make more sense to you? |
|
@frntn I do agree it could be nice, just not totally sure its the right move. If we go with it, we need to document the behavior properly too both in the --yolo and --max-iteration sections to make it very clear to users. Agree we need to handle the To be clear, I'm not strongly against the change. Let me stew on it just a bit and get back to you |
|
Maybe using a proper wording in the description like "soft-limit" and "hard-limit" can help |
|
agree with the wording |
|
/review |
There was a problem hiding this comment.
Review Summary
- autoExtensions counter resets on every message - The safety cap of 5 auto-extensions can be bypassed in multi-turn conversations
- Missing Resume call in JSON mode - When AutoApprove is true for ToolCallConfirmationEvent, no Resume is sent, causing the runtime to hang
One additional MEDIUM severity issue about silent rejection in JSON mode.
All issues are in the newly added code.
| var lastErr error | ||
|
|
||
| oneLoop := func(text string, rd io.Reader) error { | ||
| autoExtensions := 0 |
There was a problem hiding this comment.
🔴 HIGH SEVERITY: Safety cap can be bypassed in multi-turn mode
The autoExtensions counter is declared inside oneLoop(), which gets reset to 0 on every call. This defeats the purpose of the safety cap:
- In multi-turn mode (line 280-281),
oneLoop()is called once per message - Each call gets a fresh counter starting at 0
- If a user provides 10 messages, each can use up to 5 auto-extensions = 50 total extensions instead of 5
Fix: Move autoExtensions := 0 outside of oneLoop() to the Run() function level, so the counter persists across all messages in the session:
func Run(ctx context.Context, out *Printer, cfg Config, rt runtime.Runtime, sess *session.Session, userMessages []string) error {
// ... existing code ...
var lastErr error
autoExtensions := 0 // <-- Move here
oneLoop := func(text string, rd io.Reader) error {
// Remove: autoExtensions := 0
// ... rest of function ...
}
}This ensures the safety cap applies to the entire session, not per-message.
| @@ -74,6 +109,14 @@ func Run(ctx context.Context, out *Printer, cfg Config, rt runtime.Runtime, sess | |||
| if !cfg.AutoApprove { | |||
There was a problem hiding this comment.
🔴 HIGH SEVERITY: Missing Resume call causes runtime to hang
When cfg.AutoApprove is true in JSON mode, this code does not call rt.Resume() at all. This leaves the runtime waiting indefinitely for a confirmation that never comes.
The current logic:
- If
!cfg.AutoApprove: callsrt.Resume(ctx, runtime.ResumeReject("")) - If
cfg.AutoApprove: does nothing ❌
Fix: Add an else branch to approve when AutoApprove is enabled:
case *runtime.ToolCallConfirmationEvent:
if !cfg.AutoApprove {
rt.Resume(ctx, runtime.ResumeReject(""))
} else {
rt.Resume(ctx, runtime.ResumeApprove()) // <-- Add this
}This mirrors the MaxIterationsReachedEvent handling pattern on lines 112-119 and ensures consistency with non-JSON mode (lines 152-167).
| switch handleMaxIterationsAutoApprove(cfg.AutoApprove, &autoExtensions, e.MaxIterations) { | ||
| case maxIterContinue: | ||
| rt.Resume(ctx, runtime.ResumeApprove()) | ||
| default: // maxIterStop or maxIterPrompt (no interactive prompt in JSON mode) |
There was a problem hiding this comment.
When not in yolo mode or when the safety cap is reached, this code rejects and returns with no visible indication to the user consuming the JSON output.
The problem:
- User sees
MaxIterationsReachedEventin JSON output - Runtime calls
rt.Resume(ctx, runtime.ResumeReject(""))silently - Process terminates with no event showing what action was taken
- Resume actions are sent through an internal channel, not emitted as events
Impact: JSON API consumers cannot determine why execution stopped or what decision was made. The comment acknowledges "no interactive prompt in JSON mode" but doesn't address the lack of visibility.
Consider: Emitting a custom event before rejecting to inform JSON consumers of the decision, similar to how interactive mode shows clear feedback.
Problem
When running
cagent exec --yoloin non-interactive mode (stdin closed/piped),hitting the max iterations limit triggers a stdin prompt that immediately fails
with "Failed to read input, exiting...".
This is inconsistent:
--yoloauto-approves tool calls but not iteration extensions.Fix
--yolomode (capped at 5 to prevent infinite loops)MaxIterationsReachedEventin JSON output mode (was completely missing, causing runtime hang)handleMaxIterationsAutoApprovehelper to avoid duplication between normal and JSON pathsTest plan
golangci-lint run ./pkg/cli/...→ 0 issuesgo test ./pkg/cli/...→ 16/16 PASS