Repository navigation
feat(core): stop a run that keeps repeating the same tool call - #489
Conversation
createToolLoopGuard() sees each tool call (name plus validated arguments) and answers ok, warn or halt. It looks for the same call several times in a row and for a short cycle of calls (up to three) that repeats unchanged. A call with different arguments breaks both patterns, and there is no total per tool. Thresholds are named constants: warn at 3 repeats, halt at 5. Every call counts, including calls the host blocked after a warning. Refs OpenCoworkAI#487
Wire the detector into the beforeToolCall hook that agent.ts already uses for cancellation and the done repair limit. warn blocks the call; pi returns the reason as the error tool result, so no message is injected. halt records the stop, blocks every later call, and reuses the done repair limit's end of run: the turn_end listener throws GENERATION_INCOMPLETE before pi drains its steering and follow-up queues, and the outer boundary skips recovery and rethrows the error with the workspace files kept. Queued messages stay recoverable through ActiveRunMessages.close(). done is left to its own repair limit. The tests run a real pi Agent with a scripted model stream and cover warn, halt, a halt inside one response, a cross-call cycle, a long design that must not trip, done, and the reset on a delivered user message. Closes OpenCoworkAI#487
There was a problem hiding this comment.
Findings
No Blocker, Major, or Minor findings.
The detector (packages/core/src/tool-loop-guard.ts) is a pure function with no new dependencies, and the wiring in packages/core/src/agent.ts:1355 uses the existing pi beforeToolCall hook plus the existing turn_end stop path instead of reaching into pi internals, which is consistent with hard constraint 7. The loop-guard behavior matches the PR description and is exercised by both unit tests (packages/core/src/tool-loop-guard.test.ts) and real-Agent integration tests (packages/core/src/agent.native-lifecycle.test.ts).
I traced the thresholds and trimming for off-by-ones: HISTORY_LIMIT = TOOL_LOOP_HALT_REPEATS * TOOL_LOOP_MAX_CYCLE_LENGTH = 15 is exactly enough to observe 5 repeats of a length-3 cycle, trailingRepeats never indexes out of range (the while condition keeps block - repeats * length >= 0), and the verdict sequence [ok, ok, warn, warn, halt] is consistent with >= WARN / >= HALT. I did not find a correctness defect in the detector or the halt path.
Questions
- I could not fetch issue #487 in this run, so I validated only against the PR body's stated behavior. Does #487 name any additional surface (e.g. a diagnostics/report path) that this PR is expected to cover beyond the core loop guard?
Summary
- Review mode: initial
- Scope: the diff matches the PR's stated scope (core detector + wiring + tests + core
patchchangeset). No new prod dependency, no license concern, no release/distribution path touched. - The guard compares tool name plus order-independent args (
canonicalJson), counts warn-blocked calls so a model that ignores the warning still reaches halt, exemptsdoneso its own repair limit still reports verifier errors, and resets when a queued user message is delivered (agent.native-lifecycle.test.ts). Each of the interesting failure paths (warn does not run the tool, in-response calls after the halt are blocked, queued messages endnot-delivered, files are kept, the run reaches idle) is asserted. - No security, data-loss, or silent-fallback concerns: a halt throws
GENERATION_INCOMPLETE(user-visible),onCompletestill persists the transcript, workspace files are untouched, and blocked warn/halt reasons surface as error tool results for the model. - Residual observations (not blocking, no fix required for merge):
- A repeating cycle of 4+ distinct calls is intentionally not detected (
TOOL_LOOP_MAX_CYCLE_LENGTH = 3), so such a loop would still run to the generation timeout / token budget. Fine to keep as a deliberate scope limit, but a reasonable follow-up issue if 4+ call loops are observed in practice. - Because
doneis exempt from the guard, identical non-donecalls separated only bydoneare treated as a consecutive streak. This is usually desirable (identicalview/previewresults are redundant), so I would not change it; the "…in a row" phrasing can just read slightly loosely in that one case. - Each
checkserializes the full validated args (including any largefile_text) into a history key; bounded to 15 entries, so only a minor transient allocation cost on large writes.
- A repeating cycle of 4+ distinct calls is intentionally not detected (
Testing
- Not run (automation). The PR adds 13 detector unit tests and 7 integration tests over a real pi
Agentwith a scripted stream. Coverage of warn/halt, in-response ordering after a halt, 2- and 3-call cycles, thedoneexemption, key-order/array-order significance, and reset-on-delivery is proportionate to the change.
Open-CoDesign Bot
|
Thanks. #487 names no other surface. It asks for the detector, the |
Summary
Closes #487. A run could repeat the same tool call until the generation timeout or the token budget ended it. This adds a detector in core and wires it into the
beforeToolCalloption of theAgentthatpackages/core/src/agent.tscreates, the hook that already handles cancellation and the done repair limit. (Thetool_callin AGENTS.md is the pi-coding-agent extension event; core uses pi-agent-core'sAgentdirectly.)Why this is design-specific (hard constraint 7). pi-agent-core and pi-coding-agent 0.72.1 provide
beforeToolCall, aterminateflag on tool results, andshouldStopAfterTurnin the low-level loop config, whichAgentdoes not expose. They have no turn cap and no repeated-call detection. Only the wiring goes through pi's hooks. The loop behavior below is read fromagent-loop.tsat v0.72.1.What counts as a repeat
BeforeToolCallContext.args), compared as order-independent canonical JSON. Key order does not hide a repeat; array order and the tool name still matter.doneis not counted. It has its own cap, whose message lists the remaining verifier errors; counting it here would stop three identicaldonecalls before that report.warn
The call is blocked with
{ block: true, reason }. pi turns that into an error tool result whose text is the reason, so the tool does not run and no message is injected. The reason says the call was repeated and tells the model to change approach or stop and say what blocks it.halt, end to end
toolLoop.haltholds the repeating tool names and the count; it is logged at error level. The halting call is blocked with a reason that says to stop.beforeToolCallblocks, including later calls in the same response. pi prepares all calls of a parallel response before running any, and a call prepared before the halting one either breaks the pattern (so there is no halt) or belongs to it and already got its own warning.turn_endlistener throwsGENERATION_INCOMPLETE, at the same place the done repair limit does it, becauseAgentdoes not exposeshouldStopAfterTurn. pi catches the throw and ends the loop at that event, before it polls the steering and follow-up queues, so no further model request is made.runHalted()(the done repair limit or the loop halt) makes the recovery loop and the provider-error mapping skip the failure message pi recorded.onCompletestill persists the transcript, thengenerateViaAgentrethrowsGENERATION_INCOMPLETEwith copy that says the model repeated a tool call. Workspace files are untouched.finallyingenerateViaAgentclosesActiveRunMessages, which clears the queues and marks pending messagesnot-deliveredso the user can recover them.Shared with the done repair limit: the
turn_endthrow, the recovery and error-mapping skip (generalized fromrepairLimitReached()torunHalted()), and theGENERATION_INCOMPLETEcode. New: the detector, its state, and the texts.Two commits: the detector with its unit tests, then the wiring, integration tests and changeset.
Type of change
Linked issue
Closes #487
Checklist
pnpm lint && pnpm typecheck && pnpm testpasses locallypnpm changeset) if user-visiblePrinciples
GENERATION_INCOMPLETE.beforeToolCalland the existingturn_endstop. No pi internals.agent.ts).runHalted()predicate ends the run for both the done repair limit and the loop.Checks
pnpm -r typecheck,pnpm lint,CI=1 pnpm test: all passed; core 588 tests, shared 327, desktop 181 files / 2,640 tests.tool-loop-guard.test.ts(13): thresholds, the streak, two- and three-call cycles, a longer cycle that is ignored, a changed argument breaking either pattern, key order, array order, tool name, 60 different edits around a repeated preview, and reset.agent.native-lifecycle.test.ts(+7) runs a real piAgentwith a scripted model stream and no network:GENERATION_INCOMPLETEis thrown (not success, cancellation or a provider error), the files written earlier are kept, and a steering and a follow-up message queued before the stop end asnot-delivered;donecalls still end with the done repair limit and three verifier runs;turn_endthrow, the final rethrow, the provider-error skip, the reset on delivery, thedoneexemption; in the detector, the key sort, the tool name in the key, the>=at the halt threshold, and the history length.runHalted()withrepairLimitReached()in the recovery loop. The stop message is never classified as a transport error, so the loop would break anyway. I kept it so a halted run is never retried by construction.