fix(agent): make same-call repeat guard progress-aware #21

Merged
steve merged 2 commits from fix/progress-aware-same-call-guard into main 2026-07-18 23:22:10 +00:00
Owner

Problem

The maxSameCallRepeats loop guard counted identical (name + arguments) tool calls across a run and tripped ErrToolLoop once any signature exceeded the ceiling — regardless of whether each call made progress.

This killed legitimate polling of long-running background jobs. A poller like mort's code_exec_poll must be called with identical arguments (same job_id), so a render/encode that needed more than N polls was guillotined mid-flight — even though every poll returned an advancing result (elapsed_ms/status moving forward).

Live example (mort run 264cff66): a musical fanned out 5 songs, submitted the final ffmpeg mux as a background code_exec_submit job, and polled it. The job was still progressing (elapsed 64s → 143s → 206s → 269s, status: running) when poll #4 tripped the guard and salvaged a partial. The user got audio-only stems instead of the finished MP4.

Fix

Only count an identical call toward the trip when its result is unchanged from the previous identical call. A call whose result keeps changing is progress and resets its count; a genuinely stuck call returning the same output still trips.

  • Cannot trip more than before — only less (strictly fewer false positives).
  • Covers every idempotent poller with no per-tool configuration.
  • Matches the progress-over-usage thesis behind the stall-detection work: guards should measure lack of progress, not raw repetition.

The consecutive-error guard (error-loops) and a run critic (distinct-but-unproductive loops) still cover the cases this loosens.

Tests

  • TestSameCallRepeatGuard (existing) still passes: identical call + identical result trips.
  • TestSameCallRepeatGuardProgressAware (new): identical polls with an advancing result run well past the limit without tripping; a frozen identical result still trips.
  • Full go test ./... green.
## Problem The `maxSameCallRepeats` loop guard counted identical `(name + arguments)` tool calls across a run and tripped `ErrToolLoop` once any signature exceeded the ceiling — **regardless of whether each call made progress.** This killed legitimate **polling of long-running background jobs.** A poller like mort's `code_exec_poll` *must* be called with identical arguments (same `job_id`), so a render/encode that needed more than N polls was guillotined mid-flight — even though every poll returned an *advancing* result (`elapsed_ms`/`status` moving forward). Live example (mort run `264cff66`): a musical fanned out 5 songs, submitted the final ffmpeg mux as a background `code_exec_submit` job, and polled it. The job was still progressing (`elapsed 64s → 143s → 206s → 269s, status: running`) when poll #4 tripped the guard and salvaged a partial. The user got audio-only stems instead of the finished MP4. ## Fix Only count an identical call toward the trip when its **result is unchanged** from the previous identical call. A call whose result keeps changing is *progress* and resets its count; a genuinely stuck call returning the same output still trips. - Cannot trip **more** than before — only less (strictly fewer false positives). - Covers **every** idempotent poller with **no per-tool configuration**. - Matches the progress-over-usage thesis behind the stall-detection work: guards should measure lack of progress, not raw repetition. The consecutive-error guard (error-loops) and a run critic (distinct-but-unproductive loops) still cover the cases this loosens. ## Tests - `TestSameCallRepeatGuard` (existing) still passes: identical call **+ identical result** trips. - `TestSameCallRepeatGuardProgressAware` (new): identical polls with an **advancing** result run well past the limit without tripping; a **frozen** identical result still trips. - Full `go test ./...` green.
steve added 1 commit 2026-07-18 22:57:21 +00:00
fix(agent): make same-call repeat guard progress-aware
CI / Tidy (pull_request) Successful in 9m25s
Gadfly review (reusable) / review (pull_request) Successful in 9m48s
Adversarial Review (Gadfly) / review (pull_request) Successful in 9m48s
CI / Build & Test (pull_request) Successful in 10m33s
68bf7157d3
The maxSameCallRepeats guard counted identical (name+arguments) tool calls
across a run and tripped ErrToolLoop past the ceiling — regardless of whether
each call made progress. This killed legitimate polling of long-running
background jobs: code_exec_poll must be called with identical args (same
job_id), so a render/encode that needs more than N polls was guillotined
mid-flight even as each poll returned an advancing result (elapsed/status
moving forward).

Only count an identical call toward the trip when its RESULT is unchanged
from the previous identical call. A call whose result keeps changing is
progress and resets its count; a genuinely stuck call returning the same
output still trips. This can never trip more than before, only less, and
covers every idempotent poller with no per-tool configuration — matching the
progress-over-usage thesis behind the stall-detection work.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01HgEuVfZJN9mhRhzEsMEVog

🪰 Gadfly — live review status

5/5 reviewers finished · updated 2026-07-18 23:07:09Z

claude-code/sonnet · claude-code — done

  • security — No material issues found
  • correctness — Minor issues
  • maintainability — Minor issues
  • performance — No material issues found
  • error-handling — Minor issues

glm-5.2:cloud · ollama-cloud — done

  • security — No material issues found
  • correctness — Minor issues
  • maintainability — Minor issues
  • performance — No material issues found
  • error-handling — No material issues found

kimi-k2.6:cloud · ollama-cloud — done

  • security — No material issues found
  • correctness — No material issues found
  • maintainability — Minor issues
  • performance — No material issues found
  • error-handling — No material issues found

opencode/glm-5.2:cloud · opencode — done

  • security — No material issues found
  • correctness — No material issues found
  • maintainability — Minor issues
  • performance — Minor issues
  • error-handling — No material issues found

opencode/kimi-k2.6:cloud · opencode — done

  • security — No material issues found
  • correctness — No material issues found
  • maintainability — No material issues found
  • performance — No material issues found
  • error-handling — No material issues found

Live status board. Findings are posted in each model's own comment. Advisory only — does not block merge.

<!-- gadfly-status-board --> ## 🪰 Gadfly — live review status 5/5 reviewers finished · updated 2026-07-18 23:07:09Z #### `claude-code/sonnet` · claude-code — ✅ done - ✅ **security** — No material issues found - ✅ **correctness** — Minor issues - ✅ **maintainability** — Minor issues - ✅ **performance** — No material issues found - ✅ **error-handling** — Minor issues #### `glm-5.2:cloud` · ollama-cloud — ✅ done - ✅ **security** — No material issues found - ✅ **correctness** — Minor issues - ✅ **maintainability** — Minor issues - ✅ **performance** — No material issues found - ✅ **error-handling** — No material issues found #### `kimi-k2.6:cloud` · ollama-cloud — ✅ done - ✅ **security** — No material issues found - ✅ **correctness** — No material issues found - ✅ **maintainability** — Minor issues - ✅ **performance** — No material issues found - ✅ **error-handling** — No material issues found #### `opencode/glm-5.2:cloud` · opencode — ✅ done - ✅ **security** — No material issues found - ✅ **correctness** — No material issues found - ✅ **maintainability** — Minor issues - ✅ **performance** — Minor issues - ✅ **error-handling** — No material issues found #### `opencode/kimi-k2.6:cloud` · opencode — ✅ done - ✅ **security** — No material issues found - ✅ **correctness** — No material issues found - ✅ **maintainability** — No material issues found - ✅ **performance** — No material issues found - ✅ **error-handling** — No material issues found <sub>Live status board. Findings are posted in each model's own comment. Advisory only — does not block merge.</sub>
gitea-actions bot reviewed 2026-07-18 23:07:09 +00:00
gitea-actions bot left a comment

🪰 Gadfly consensus review — 5 inline findings on changed lines. See the consensus comment for the full ranked summary.

Advisory only — does not block merge.

<!-- gadfly-inline-review --> 🪰 **Gadfly consensus review** — 5 inline findings on changed lines. See the consensus comment for the full ranked summary. <sub>Advisory only — does not block merge.</sub>
agent/agent.go Outdated
@@ -279,1 +284,4 @@
// background job whose progress advances) resets the count instead of
// tripping the guard — a changing result is progress, not a stuck loop.
callCounts := make(map[string]int)
lastResults := make(map[string]string)

🟡 callCounts and lastResults are two parallel maps keyed by the same signature that should be merged

maintainability, performance · flagged by 2 models

  • agent/agent.go:286-287 / 369-389callCounts and lastResults are two separate maps keyed by the same sig, always read and written together in lockstep (agent.go:379-384). This is the classic "two maps that must stay in sync" smell; merging them into one map[string]struct{ count int; lastResult string } would remove the duplicate lookup per call and make the pairing structurally guaranteed rather than convention-guaranteed. Verified by reading the full guard loop — every access t…

🪰 Gadfly · advisory

🟡 **callCounts and lastResults are two parallel maps keyed by the same signature that should be merged** _maintainability, performance · flagged by 2 models_ - `agent/agent.go:286-287` / `369-389` — `callCounts` and `lastResults` are two separate maps keyed by the same `sig`, always read and written together in lockstep (`agent.go:379-384`). This is the classic "two maps that must stay in sync" smell; merging them into one `map[string]struct{ count int; lastResult string }` would remove the duplicate lookup per call and make the pairing structurally guaranteed rather than convention-guaranteed. Verified by reading the full guard loop — every access t… <sub>🪰 Gadfly · advisory</sub>
agent/agent.go Outdated
@@ -356,6 +357,37 @@ func (a *Agent) Run(ctx context.Context, input string, opts ...RunOption) (*Resu
a.notify(rc, step)
msgs = append(msgs, llm.ToolResultsMessage(results...))
// Same-call repeat guard (progress-aware). An identical (name+arguments)

🟡 Progress-aware guard semantics explained redundantly in three separate comments

maintainability · flagged by 2 models

  • agent/agent.go:127-133, agent/agent.go:281-287, agent/agent.go:360-368 — The progress-aware semantics of the same-call guard are explained in near-identical prose three separate times: the WithToolErrorLimits doc comment, the callCounts/lastResults declaration comment, and again immediately above the guard block itself. Verified by reading all three locations — they restate the same "result changes ⇒ progress ⇒ resets count" idea with only wording variations. Three copies of the sa…

🪰 Gadfly · advisory

🟡 **Progress-aware guard semantics explained redundantly in three separate comments** _maintainability · flagged by 2 models_ - `agent/agent.go:127-133`, `agent/agent.go:281-287`, `agent/agent.go:360-368` — The progress-aware semantics of the same-call guard are explained in near-identical prose three separate times: the `WithToolErrorLimits` doc comment, the `callCounts`/`lastResults` declaration comment, and again immediately above the guard block itself. Verified by reading all three locations — they restate the same "result changes ⇒ progress ⇒ resets count" idea with only wording variations. Three copies of the sa… <sub>🪰 Gadfly · advisory</sub>
agent/agent.go Outdated
@@ -359,0 +370,4 @@
for i, call := range resp.ToolCalls {
sig := call.Name + "\x00" + string(call.Arguments)
resKey := ""
if i < len(results) {

🟡 Defensive i < len(results) branch is dead code that contradicts the invariant the preceding comment asserts

error-handling, maintainability · flagged by 4 models

  • agent/agent.go:373 — the if i < len(results) { … } branch is defensive dead code that contradicts the invariant the preceding comment (lines 366-368) asserts. Verified against the actual loop above: the ctx.Err() path does a full return before reaching the guard (lines 337-340), the unknown-tool path appends an error result before continue (lines 342-348), and ExecuteTool (llm/tool.go:156) always returns exactly one ToolResult (panic recovered into an error result, nil handler…

🪰 Gadfly · advisory

🟡 **Defensive `i < len(results)` branch is dead code that contradicts the invariant the preceding comment asserts** _error-handling, maintainability · flagged by 4 models_ - `agent/agent.go:373` — the `if i < len(results) { … }` branch is defensive dead code that contradicts the invariant the preceding comment (lines 366-368) asserts. Verified against the actual loop above: the `ctx.Err()` path does a full `return` before reaching the guard (lines 337-340), the unknown-tool path appends an error result before `continue` (lines 342-348), and `ExecuteTool` (`llm/tool.go:156`) always returns exactly one `ToolResult` (panic recovered into an error result, nil handler… <sub>🪰 Gadfly · advisory</sub>
@@ -359,0 +376,4 @@
}
resKey += results[i].Content
}
if prev, seen := lastResults[sig]; seen && prev == resKey {

🟠 Progress check is exact-string equality on the full tool result, so any incidental non-progress field (timestamp, request id, sequence nonce) that changes per-call defeats the guard for genuinely stuck non-erroring polls, which the consecutive-error guard does not cover

correctness, error-handling · flagged by 2 models

Finding 1 (agent/agent.go:379): Confirmed. ExecuteTool (llm/tool.go:190) does a raw json.Marshal of whatever the handler returns with zero normalization, and the guard at agent.go:371-384 does byte-for-byte string comparison of the full encoded Content. A poll response with a jittery field (timestamp, elapsed, request id) alongside an unchanged status will produce a different resKey every call, so callCounts[sig] never accumulates. I confirmed there's no run-critic implementation…

🪰 Gadfly · advisory

🟠 **Progress check is exact-string equality on the full tool result, so any incidental non-progress field (timestamp, request id, sequence nonce) that changes per-call defeats the guard for genuinely stuck non-erroring polls, which the consecutive-error guard does not cover** _correctness, error-handling · flagged by 2 models_ **Finding 1** (agent/agent.go:379): Confirmed. `ExecuteTool` (llm/tool.go:190) does a raw `json.Marshal` of whatever the handler returns with zero normalization, and the guard at agent.go:371-384 does byte-for-byte string comparison of the full encoded `Content`. A poll response with a jittery field (timestamp, elapsed, request id) alongside an unchanged `status` will produce a different `resKey` every call, so `callCounts[sig]` never accumulates. I confirmed there's no run-critic implementation… <sub>🪰 Gadfly · advisory</sub>
@@ -176,0 +178,4 @@
// RESULT keeps changing — canonically polling a long-running background job
// whose progress advances — do not trip the repeat guard even well past the
// limit; but identical calls returning an unchanged result still trip it.
func TestSameCallRepeatGuardProgressAware(t *testing.T) {

🟡 New progress-aware test duplicates poll-tool/agent scaffolding; a shared helper (cf. adderToolbox) would reduce copy-paste

maintainability · flagged by 1 model

  • agent/hooks_test.go:181-223 — duplicated fixture setup. TestSameCallRepeatGuardProgressAware rebuilds polling/fp and frozen/fp2 with near-identical scaffolding (toolCallReply("c", "poll", ...) + WithToolErrorLimits(0, 3) + WithMaxSteps(20)). A small table-driven helper or shared newPollAgent(t, handler) would cut ~15 lines of copy-paste and match the style of the existing adderToolbox(t) helper used throughout this file. Small; not blocking.

🪰 Gadfly · advisory

🟡 **New progress-aware test duplicates poll-tool/agent scaffolding; a shared helper (cf. adderToolbox) would reduce copy-paste** _maintainability · flagged by 1 model_ - **`agent/hooks_test.go:181-223` — duplicated fixture setup.** `TestSameCallRepeatGuardProgressAware` rebuilds `polling`/`fp` and `frozen`/`fp2` with near-identical scaffolding (`toolCallReply("c", "poll", ...)` + `WithToolErrorLimits(0, 3)` + `WithMaxSteps(20)`). A small table-driven helper or shared `newPollAgent(t, handler)` would cut ~15 lines of copy-paste and match the style of the existing `adderToolbox(t)` helper used throughout this file. Small; not blocking. <sub>🪰 Gadfly · advisory</sub>

🪰 Gadfly review — consensus across 5 models

Verdict: Minor issues · 7 findings (4 with multi-model agreement)

Finding Where Models Lens
🟡 Defensive 'i < len(results)' branch is dead code that contradicts the invariant the preceding comment asserts agent/agent.go:373 4/5 error-handling, maintainability
🟠 Progress check is exact-string equality on the full tool result, so any incidental non-progress field (timestamp, request id, sequence nonce) that changes per-call defeats the guard for genuinely stuck non-erroring polls, which the consecutive-error guard does not cover agent/agent.go:379 2/5 correctness, error-handling
🟡 callCounts and lastResults are two parallel maps keyed by the same signature that should be merged agent/agent.go:286 2/5 maintainability, performance
🟡 Progress-aware guard semantics explained redundantly in three separate comments agent/agent.go:360 2/5 maintainability
3 single-model findings (lower confidence)
Finding Where Model Lens
🟡 WithToolErrorLimits doc text not updated to describe the new progress-aware (result-unchanged) behavior README.md:373 claude-code/sonnet maintainability
🟡 Error message no longer matches progress-aware guard semantics agent/agent.go:393 kimi-k2.6:cloud maintainability
🟡 New progress-aware test duplicates poll-tool/agent scaffolding; a shared helper (cf. adderToolbox) would reduce copy-paste agent/hooks_test.go:181 opencode/glm-5.2:cloud maintainability
Per-model detail
claude-code/sonnet (claude-code) — Minor issues

Verdict: Minor issues — 5 reviewers: security, correctness, maintainability, performance, error-handling

🔒 Security — No material issues found

VERDICT: No material issues found

I reviewed this change specifically through the security lens (authn/authz, injection, SSRF, deserialization, secret leakage, input validation, insecure defaults).

What the diff actually does: it changes an in-process loop guard in agent/agent.go's Run() from counting raw repeated (name+arguments) tool-call signatures to counting them only when the tool's result is also unchanged, tracked via two maps (callCounts, lastResults) that are declared fresh inside Run() (agent/agent.go:286-287) and never persisted or shared across runs/goroutines — no cross-session leakage.

Checked and ruled out:

  • Untrusted data handling: call.Arguments (json.RawMessage, model-controlled) and results[i].Content (tool-controlled) are only ever used as map-key material (string concatenation with a \x00 separator) — never parsed, evaluated, templated, or passed to a shell/SQL/HTTP call. No injection vector.
  • Resource-exhaustion / cost-DoS via guard bypass: the new logic does let a tool whose output varies trivially (e.g., an adversarial or compromised tool backend that appends a nonce/timestamp) evade the same-call guard indefinitely. However, I confirmed (agent/agent.go:107-108, 156, 298) the outer maxSteps loop bound (DefaultMaxSteps, or WithMaxSteps/WithMaxStepsFunc) still hard-caps total steps regardless of this guard, so worst-case cost/iteration count is unchanged from before — this guard was always a soft early-exit, not the resource ceiling. Not a new exploitable boundary.
  • Map growth: signatures are already attacker-influenceable pre-existing behavior (callCounts existed before this PR); adding lastResults roughly doubles per-signature memory but doesn't introduce a new growth vector, and is still bounded by maxSteps steps total.
  • No new external calls, deserialization, credential handling, or default-permission changes anywhere in the diff.

Nothing in this diff touches auth, network boundaries, secrets, or untrusted-input parsing in a new way. Clean from a security standpoint.

🎯 Correctness — Minor issues

Verdict: Minor issues

I read agent/agent.go lines 253–417, llm/tool.go lines 140–199 (ExecuteTool), and agent/hooks_test.go, and independently re-traced the same invariants the draft claims.

Verified correct:

  • results[i] pairs with resp.ToolCalls[i]: the per-call loop (agent/agent.go:336–353) checks ctx.Err() first each iteration and returns fully on cancellation; otherwise every call appends exactly one ToolResult (unknown-tool branch appends then continues, known-tool branch always appends ExecuteTool's return). So when the guard loop (agent/agent.go:369–389) runs, len(results) == len(resp.ToolCalls) always holds and the i < len(results) check is dead but harmless.
  • Threshold semantics unchanged: callCounts[sig]++/reset then > a.maxSameCallRepeats trips on the (max+1)th occurrence, matching the pre-existing logic.
  • ExecuteTool (llm/tool.go:182–197) marshals non-string/non-raw results via json.Marshal, which sorts map keys — the fake polling tool's map[string]any output serializes deterministically, so the new tests aren't relying on flaky ordering.
  • Error/success namespacing ("e\x00"+Content vs Content, agent/agent.go:374–377) correctly treats an error→success transition as "changed."

Finding — progress signal is exact-string equality on the whole result body, a fragile proxy for "did this job actually advance" (agent/agent.go:371–384). The guard resets its count only when results[i].Content is byte-identical to the previous identical-signature call's content, and increments otherwise. Confirmed against ExecuteTool: any incidental non-progress field a poller includes on every response (timestamp, trace id, monotonic counter) makes Content differ every call even if the job's actual status is frozen — this is a genuinely stuck poll that the old guard would have caught but the new one never trips on. I confirmed maxConsecutiveToolErrors (agent/agent.go:396–412) doesn't backstop this: it only counts steps where all results are IsError, and a stuck-but-still-"successful" poll never sets IsError. The commit message's own framing — "This can never trip more than before, only less" — is accurate only for identical-content stuck loops; it doesn't acknowledge this false-negative surface for real-world pollers with any non-static metadata. This is a real, verifiable gap in the guard's domain correctness (a plausible accepted tradeoff, not a hard bug), so it's worth flagging explicitly.

No other logic bugs found: initial-count seeding, cross-step signature tracking, sig construction (unchanged: Name + "\x00" + Arguments), and the guard-disable path (maxSameCallRepeats == 0) all check out against the original semantics.

🧹 Code cleanliness & maintainability — Minor issues

VERDICT: Minor issues

  • agent/agent.go:127-133, agent/agent.go:281-287, agent/agent.go:360-368 — The progress-aware semantics of the same-call guard are explained in near-identical prose three separate times: the WithToolErrorLimits doc comment, the callCounts/lastResults declaration comment, and again immediately above the guard block itself. Verified by reading all three locations — they restate the same "result changes ⇒ progress ⇒ resets count" idea with only wording variations. Three copies of the same explanation is a maintenance liability: a future tweak to this logic (e.g. changing what counts as "unchanged") requires remembering to update all three, and they will drift. Keep one clear explanation (e.g. on the field declaration or the block) and trim the other two to a one-line pointer.

  • agent/agent.go:286-287 / 369-389callCounts and lastResults are two separate maps keyed by the same sig, always read and written together in lockstep (agent.go:379-384). This is the classic "two maps that must stay in sync" smell; merging them into one map[string]struct{ count int; lastResult string } would remove the duplicate lookup per call and make the pairing structurally guaranteed rather than convention-guaranteed. Verified by reading the full guard loop — every access to one map is immediately paired with an access to the other, with no case where they're used independently.

  • agent/agent.go:373if i < len(results) { ... } guards indexing into results, but the comment directly above (agent.go:366-368) explicitly documents the invariant that results[i] always pairs with resp.ToolCalls[i] (every call appends exactly one result; the only early exit is a full function return on ctx cancellation before this point is reached). Verified against the results-building loop at agent.go:336-353: ExecuteTool always appends, and the unknown-tool branch appends-then-continues, and the ctx.Err() check does a full return out of Run (not just a loop break), so there's no path that reaches the guard loop with results shorter than resp.ToolCalls. The bounds check is therefore dead code that contradicts the invariant the surrounding comment asserts — either trust the invariant and drop the check, or the comment is overclaiming and should be softened.

  • README.md:373-375 (and the same wording in docs/adr/0014-conversion-driven-extensions.md:33-34) — Still describes WithToolErrorLimits as providing circuit breakers for "identical repeated calls" without mentioning that a call is now only counted when its result is unchanged. This is now an incomplete description of the behavior this PR changes, and CLAUDE.md's house rule explicitly requires README to "match reality in the same commit that changes behavior." Verified by reading both doc locations — neither mentions the progress/result-comparison exception introduced here.

Performance — No material issues found

VERDICT: No material issues found

I read the full modified Run loop in agent/agent.go:256-416 and traced the guard logic end-to-end.

  • The new same-call guard loop (agent/agent.go:361-386) is a second O(len(resp.ToolCalls)) pass over the same slice already walked once in the dispatch loop above it. Tool-call counts per step are small (bounded by what the model requests in one turn), so this is not a hot-loop concern.
  • callCounts/lastResults are keyed by call signature and only grow with the number of distinct signatures seen, which is itself bounded by maxSteps (agent/agent.go:289-296, 298). For the motivating polling case (identical job_id args every call), the signature is constant, so the map stays at one entry and each poll's stored Content simply overwrites the previous one rather than accumulating — confirmed by reading lastResults[sig] = resKey at agent/agent.go:383.
  • lastResults does hold a copy of the previous result's Content string (llm/tool.go:76-77, an arbitrary-size serialized string) per unique signature for the life of the run, where before only an int was stored. This is a small, run-bounded memory increase (one string per distinct signature, not per call), not unbounded growth, so I'm not flagging it as material — noting it here only because it's the one real behavioral delta I verified, not because it rises to a reportable issue.

No N+1 patterns, no unbounded accumulation across steps, no quadratic behavior, and no new blocking calls were introduced by this change.

🧯 Error handling & edge cases — Minor issues

Both findings check out against the actual code.

Finding 1 (agent/agent.go:379): Confirmed. ExecuteTool (llm/tool.go:190) does a raw json.Marshal of whatever the handler returns with zero normalization, and the guard at agent.go:371-384 does byte-for-byte string comparison of the full encoded Content. A poll response with a jittery field (timestamp, elapsed, request id) alongside an unchanged status will produce a different resKey every call, so callCounts[sig] never accumulates. I confirmed there's no run-critic implementation anywhere in the tree (grep -rln critic only hits agent/hooks_test.go:34 and agent/finalize_test.go:98, both unrelated), and DefaultMaxSteps = 10 at agent/agent.go:23, so this heuristic really is the load-bearing defense at higher step ceilings. The finding is accurate.

Finding 2 (agent/agent.go:373): Confirmed as dead code under the current invariant. Tracing the first loop (agent/agent.go:336-353): every iteration either appends an error result via continue or appends via ExecuteTool — both paths guarantee exactly one results entry per resp.ToolCalls entry — and the only early exit (ctx.Err(), line 337-340) returns from Run entirely before the guard loop is ever reached. So whenever the guard loop executes, i < len(results) is always true; it's a genuine defensive/dead branch, correctly characterized as trivial.

Both findings survive verification unchanged.


VERDICT: Minor issues

  • agent/agent.go:379 (progress-aware repeat guard) — the "progress" signal is a raw string-equality check on ToolResult.Content (llm/tool.go:190, JSON-marshaled). Any tool whose response includes an incidental non-deterministic field alongside the real status — a timestamp, checked_at, request id, elapsed-since-epoch, retry counter, etc. (very common in real polling APIs, and exactly the shape of the job-poller this PR is designed for) — will produce a different resKey on every call even when the actual job status never advances (e.g. status: "queued" forever). Because lastResults[sig] only tracks the immediately-previous encoding, any incidental byte change resets callCounts[sig] to 1 and the guard never trips. This directly contradicts the guarantee stated in the docstring/PR description ("a genuinely stuck call returning the same output still trips") — a genuinely stuck-but-noisy call will not trip.

    • Verified: ExecuteTool (llm/tool.go:156-198) JSON-marshals whatever the handler returns verbatim into Content, with no normalization; the guard (agent/agent.go:369-389) compares that full string byte-for-byte with no field-level allowance.
    • Verified there is no other backstop in this codebase for this scenario today: grep -rln "critic" across the repo turns up no run-critic implementation (agent/finalize_test.go:98 and agent/hooks_test.go:34 are unrelated hits). The only remaining backstop is the coarse maxSteps ceiling (DefaultMaxSteps = 10, agent/agent.go:23), and callers exercising the very use case this PR targets (long-running job polling) necessarily raise that ceiling well above 10 (the PR's own test uses WithMaxSteps(20)), so a defeated guard can run substantially longer before the run is cut off.
    • Suggested fix: scope the equality check to a normalized/whitelisted subset the tool declares as "progress," or at minimum document this as a known limitation so callers understand the guard is best-effort against noisy tool output, not just against genuinely-frozen output as currently claimed.
  • agent/agent.go:372-378 — the if i < len(results) guard is dead code given the invariant the surrounding comment asserts (every call appends exactly one result), but if that invariant is ever violated by a future change, a mismatch silently degrades to an empty resKey ("", colliding with any other call whose content happens to be empty) rather than surfacing the bug loudly. Low impact today since the invariant currently holds (verified by reading the full tool-execution loop at agent/agent.go:334-353), but worth a comment noting this is a defensive invariant check rather than expected runtime behavior.

glm-5.2:cloud (ollama-cloud) — Minor issues

Verdict: Minor issues — 5 reviewers: security, correctness, maintainability, performance, error-handling

🔒 Security — No material issues found

VERDICT: No material issues found

Reviewed through the security lens. The change touches the agent's internal loop-guard accounting; from a security standpoint it's clean:

  • No taint/injection surface: sig = call.Name + "\x00" + string(call.Arguments) and resKey are used only as in-memory map keys/strings (no SQL, command, path, or template interpolation). Verified at agent/agent.go:371-377.
  • No credential/secret leakage: lastResults/callCounts are per-Run locals (declared at agent/agent.go:280-285), never persisted, logged, or returned; only repeatTripped (a tool name) flows into the error string at agent/agent.go:393-394. Tool result content is compared/stored in-memory but never emitted.
  • No authz/authn changes: the guard is post-execution accounting; it doesn't gate tool execution or alter authorization. Tools still execute via ExecuteTool/unknown-tool path before the guard runs (agent/agent.go:341-353).
  • No unsafe deserialization: call.Arguments is only stringified into a key, not decoded.
  • Guard-weakening is by-design: the progress-aware logic means a tool whose result varies each call (e.g. embedded timestamp/nonce) no longer trips the repeat guard. That is the explicit intent of the PR, and total runaway is still bounded by maxSteps plus the consecutive-error guard — so this is not an unbounded-DoS regression introduced inadvertently. Not flagging intentional behavior as a security bug.

The index-alignment assumption (results[i]resp.ToolCalls[i]) holds per my read: every branch in the execution loop appends exactly one result (unknown tool appends+continue, ExecuteTool appends one), and the only early exit is a full return on ctx.Err() — which is a correctness concern outside my lens, not security.

🎯 Correctness — Minor issues

VERDICT: Minor issues

  • agent/agent.go:379-384 — oscillating/flapping identical-args results escape the progress-aware repeat guard (medium, verified). I re-read the guard block (lines 369-389) and traced the semantics. lastResults[sig] only compares each call's result to the immediately preceding identical-args call. A poller whose identical-args calls alternate between two (or more) fixed result values (e.g. status flapping runningqueued, no net advancement) produces a resKey that differs from its predecessor on every call, so the else branch resets callCounts[sig] = 1 each iteration and callCounts[sig] > a.maxSameCallRepeats never holds. Trace: call1=A→count=1, call2=B→reset to 1, call3=A→reset to 1, call4=B→reset to 1 … never trips. The original callCounts[sig]++ guard counted raw repetition and did catch this; the new one does not. Such a non-advancing flapper now loops until maxSteps instead of being killed by the repeat guard. I grepped the whole repo for a run-critic / "unproductive loop" backstop (critic|RunCritic|unproductive) and found only documentation references (docs/mort-migration.md, docs/adr/0014-…, progress.md) plus the unrelated test comment in agent/hooks_test.go:34 — no actual code path that would catch this loosened case. Absent a wired-in critic backstop, this is a genuine coverage regression. Suggested fix: count a call toward the trip when its result repeats any recently-seen result for that signature (e.g. a small bounded set of recent distinct results per sig, tripping when the set stops growing), rather than only comparing against the single previous result.
🧹 Code cleanliness & maintainability — Minor issues

VERDICT: Minor issues

  • agent/agent.go:373 — the if i < len(results) { … } branch is defensive dead code that contradicts the invariant the preceding comment (lines 366-368) asserts. Verified against the actual loop above: the ctx.Err() path does a full return before reaching the guard (lines 337-340), the unknown-tool path appends an error result before continue (lines 342-348), and ExecuteTool (llm/tool.go:156) always returns exactly one ToolResult (panic recovered into an error result, nil handler → error result, normal return). So len(results) == len(resp.ToolCalls) is guaranteed here, making the else path where resKey stays "" unreachable. Defensive code that muddies the very invariant the comment documents. Fix: drop the if i < len(results) guard and index results[i] directly, or keep it but drop the comment claim that the lengths always align — pick one.
  • agent/agent.go:375 — the "e\x00" error-sentinel prefix is a magic collision-avoidance trick (so an error result with content foo isn't treated as identical to a non-error foo) with no comment at the point of use; the block-level comment above (lines 360-368) explains the progress-aware comparison but never mentions the sentinel. A one-line inline note (// "e\x00" prefixes error results so they never compare equal to a non-error with the same content) would keep the local intent clear at the subtle part of the comparison.

Neither blocks; both are low-churn readability fixes.

Performance — No material issues found

Verdict: No material issues found

Through the performance lens, the change moves the repeat guard from a cheap pre-execution signature count to a post-execution pass that stores and compares each call's result content (agent/agent.go:369-388). I verified the cost shape against the surrounding code:

  • The new lastResults map retains one entry per distinct (name+arguments) signature, holding the last result Content string. It is bounded by the number of distinct signatures, mirroring the pre-existing callCounts map; it is not an accumulating-per-step structure (entries are overwritten, not appended). agent/agent.go:286-287
  • Retained content is a strict subset of what the run already holds: every results[i].Content is appended into msgs and step.Results on every step (agent/agent.go:355-358), so the transcript — not this guard — is the dominant memory consumer. The guard adds no new asymptotic dimension.
  • The prev == resKey comparison is O(len(Content)) per tool call per step, and the resKey += results[i].Content concatenation allocates only on the error path ("e\x00"+content); on the success path resKey is just the content string. Both are per-step work, not per-token, and are negligible next to the model.Generate round-trip that precedes every step (agent/agent.go:313). No N+1, no quadratic blowup, no hot-loop regression.
  • I confirmed ToolResult.Content is a plain string and ExecuteTool JSON-marshals structured returns once (llm/tool.go:71-82, 182-197), so the guard is not re-encoding or re-serializing results — it reads an already-built string.

No efficiency regression worth flagging in this lane.

🧯 Error handling & edge cases — No material issues found

I've verified the change through the error-handling & edge-case lens. Key verifications:

  • Index alignment (results[i]resp.ToolCalls[i]): Confirmed at agent/agent.go:336-353. Both the unknown-tool branch (appends an error result then continue) and the ExecuteTool branch append exactly one result per call iteration, so len(results) == len(resp.ToolCalls) when the guard loop runs. The i < len(results) guard is defensive but always true; no out-of-bounds panic is possible.
  • Panic safety: ExecuteTool (llm/tool.go:156-198) recovers panics and converts errors/nil/unencodable values into IsError results, so results[i] is always populated — no nil-deref in the guard's results[i].IsError / .Content access.
  • Empty/zero content: A tool legitimately returning "" (or "null") yields resKey = "" (or "null"); repeated identical empties correctly increment the count and trip — that's the intended "stuck" behavior, not a bug.
  • Error results: An error with empty Content gives resKey = "e\x00"; an advancing error message changes resKey and resets the count, but the consecutive-error guard (agent.go:396-412) still catches all-error steps. The two guards compose correctly and the ordering (repeat guard before error guard) doesn't swallow either.
  • First-seen / multi-call-per-step: seen=false on first occurrence correctly seeds callCounts=1; multiple identical calls within one step each increment, which is the correct semantics.
  • Negative/zero maxSameCallRepeats: > 0 gate disables the guard, unchanged from prior behavior.
  • Map state: callCounts/lastResults are declared per-Run, no cross-run leakage.

The one behavioral edge case — a loop whose result varies without real progress (e.g., alternating A/B/A/B), which the old guard caught but the new one does not — is explicitly a design trade-off called out in the PR and deferred to a separate "run critic." That's a deliberate scope decision, not an unhandled edge case in this code.

No material issues found through the error-handling & edge-case lens.

kimi-k2.6:cloud (ollama-cloud) — Minor issues

Verdict: Minor issues — 5 reviewers: security, correctness, maintainability, performance, error-handling

🔒 Security — No material issues found

No material issues found

I reviewed the change through the Security lens and found no authn/authz gaps, injection vectors, SSRF risks, unsafe deserialization, secret leakage, input-validation bypasses, or insecure defaults introduced by this diff.

Key security-relevant observations (all confirmed safe):

  • agent/agent.go:371 — The signature call.Name + "\x00" + string(call.Arguments) is constructed from model-controlled input, but it is only ever used as an in-memory map key; it is never passed to external systems, and the tool-name portion must match a registered tool before execution proceeds.
  • agent/agent.go:372–378 — The result key resKey incorporates results[i].Content (tool output). This is stored only in the local lastResults map scoped to the Run call and is never logged or returned in error messages. The only data exposed when the guard trips is the tool name (repeatTripped), not arguments or results (agent/agent.go:393).
  • agent/agent.go:379–384 — The guard compares previous and current results to decide whether to increment the repeat counter. This weakens the guard (by design), but the run remains bounded by maxSteps and the separate consecutive-error guard, so it cannot be used to create an unbounded loop or DoS.
🎯 Correctness — No material issues found

No material issues found

The progress-aware repeat guard is correctly implemented:

  • The 1:1 pairing between resp.ToolCalls[i] and results[i] is guaranteed by the preceding loop, where every tool call appends exactly one result (unknown tools append an error result before continue). The defensive i < len(results) check is unnecessary but harmless.
  • The resKey correctly distinguishes error results from success results with the same string content via the "e\x00" prefix.
  • The reset semantics (callCounts[sig] = 1 when the result changes) accurately track consecutive identical results, so a genuinely stuck loop still trips while an advancing poller does not.
  • The trip threshold (callCounts[sig] > a.maxSameCallRepeats) preserves the original behavior: a limit of 3 allows up to 3 identical results and trips on the 4th, matching existing test expectations.
🧹 Code cleanliness & maintainability — Minor issues

Minor issues

  • agent/agent.go:373 The if i < len(results) guard is unnecessary: the preceding tool-execution loop (lines 336-353) appends exactly one result per resp.ToolCalls entry, so len(results) == len(resp.ToolCalls) is guaranteed. The defensive check contradicts the invariant the code's own comment (lines 366-367) claims, and creates a silent skip path if someone later breaks that invariant. Drop the if and rely on the pairing; add an explicit if len(results) != len(resp.ToolCalls) { panic(...) } if you want a loud invariant.

  • agent/agent.go:393 The error message "called identically more than %d times" is now misleading: the guard trips only on identical calls that also return an unchanged result. Update the message to reflect the new semantics, e.g. "called identically with unchanged result more than %d times", so logs and debugging don't confuse operators.

Performance — No material issues found

No material issues found

Through the performance lens, the change is clean:

  • Allocation overhead: The new lastResults map and resKey string add only O(1) work per tool call. For non-error results, resKey simply aliases results[i].Content with no extra byte copy; for errors, it allocates a small "e\x00"+content string, but error payloads are typically tiny. The map stores one string header per unique signature—bounded by the number of distinct tools in a run.
  • Hot-path impact: The guard check was already doing a string-key construction (sig) per call; the additional map lookup/store is negligible compared to model generation and tool execution.
  • Memory bounds: Both maps are scoped to a single Run() and bounded by unique call signatures, so there is no unbounded growth.

The fix correctly loosens the guard without introducing any measurable efficiency regression.

🧯 Error handling & edge cases — No material issues found

No material issues found

Through the error-handling & edge-cases lens the change is clean. The progress-aware guard correctly pairs each ToolCall with its corresponding ToolResult by index, and the 1:1 append invariant in the execution loop guarantees the pairing is safe.

opencode/glm-5.2:cloud (opencode) — Minor issues

Verdict: Minor issues — 5 reviewers: security, correctness, maintainability, performance, error-handling

🔒 Security — No material issues found

VERDICT: No material issues found

Reviewed through the security lens. The change is purely internal loop-guard accounting; it introduces no new tainted-input flows, no injection surface, no authn/authz changes, and no secret handling.

Verified by reading agent/agent.go:300-417:

  • sig and resKey are built from call.Name, call.Arguments, and results[i].Content — all untrusted (model/tool output), but they are used only as in-memory map keys for equality comparison. No string interpolation into commands, queries, paths, or templates; no injection vector.
  • The loosened guard (only trip on unchanged result) cannot create a resource-exhaustion DoS beyond pre-existing exposure: the run is still bounded by maxSteps (agent.go:416), and callCounts/lastResults are keyed by distinct (name+args) signatures, not by per-call output, so memory growth is bounded by the number of distinct signatures in a run, not by result size. Result Content is stored as a map value (not a key), so a single huge result is one string per signature — same footprint the run already carries in msgs.
  • The i < len(results) guard correctly handles the only early-exit path (ctx cancellation at agent.go:337-340), where results is shorter than resp.ToolCalls; those entries get an empty resKey and are counted without panic. No out-of-bounds or uninitialized-data concern.
  • No credentials, PII, or secret material is touched; no new logging of tool arguments/results beyond what the run already retained in msgs.

Nothing in my lane to flag.

🎯 Correctness — No material issues found

I verified the diff against the checked-out code by reading agent/agent.go:250-417, the full ExecuteTool/ToolResult definitions in llm/tool.go:60-199, the test file, and the toolCallReply/adderToolbox helpers.

Alignment claim (the load-bearing correctness assumption): the new guard iterates for i, call := range resp.ToolCalls and reads results[i]. The comment asserts "every call appends exactly one result." I traced the call-execution loop above the guard: the unknown-tool branch does results = append(results, ...) then continue; the known branch does results = append(results, ExecuteTool(...)). The only other early exit is the ctx.Err() return at line 337-340, which returns from Run before the guard runs. So when the guard executes, len(results) == len(resp.ToolCalls) and results[i] is genuinely the paired result. The defensive if i < len(results) is dead-but-harmless. Verified safe.

resKey design: resKey = "e\x00" + Content for error results, Content otherwise. This correctly disambiguates a success whose text coincidentally equals an error's text, so an error→success transition (or vice versa) resets the count rather than being treated as "unchanged." Correct.

"Cannot trip more than before" claim: the new condition trips only when prev == resKey (seen AND identical result). The old condition tripped on any identical (name+args) repeat. Every old trip is still reachable (frozen-result calls still increment the same way), and advancing-result calls now reset. Claim holds — strictly fewer trips. Verified.

Tests: TestSameCallRepeatGuardProgressAware covers the advancing case (6 polls past limit 3, completes with "done") and the frozen case (still trips). TestSameCallRepeatGuard (frozen add(1,1) returning the same sum) still trips because the result content {"sum":2} is identical each call. Both consistent with the new semantics.

One minor semantic caveat I considered and judged non-blocking: a call that alternates between two stable result states (e.g., success-then-error-then-success with the same payload) now resets its count each step and never trips this guard, whereas the old code would have tripped it. This is a genuine behavioral loosening, but it is strictly "less than before" (consistent with the PR's stated thesis), and the alternating-error case is partially covered by the separate consecutive-error guard when all results in a step are errors. Not a material correctness regression.

Verdict: No material issues found

  • Index alignment between resp.ToolCalls[i] and results[i] verified by reading the call-execution loop (agent/agent.go:336-353): exactly one result is appended per call on every path that reaches the guard; the only skip-path is a full return on ctx.Err(). The if i < len(results) branch is dead but harmless.
  • resKey error-prefix ("e\x00") correctly distinguishes error vs. non-error results with identical content (agent/agent.go:374-378), so transitions across the error boundary reset the count as intended.
  • The "strictly fewer trips" invariant holds: the new trip condition is a strict subset of the old (requires prev == resKey in addition to identical args), and the existing TestSameCallRepeatGuard (frozen identical result) still trips under the new logic.
  • Minor (non-blocking) behavioral note: a call alternating between two stable result states (e.g., success↔error) now escapes this guard, where the old code would have tripped it. This is consistent with the PR's stated "progress-over-repetition" thesis and remains bounded by the consecutive-error guard for all-error steps; not a regression against the PR's stated contract.
🧹 Code cleanliness & maintainability — Minor issues

Review

Verdict: Minor issues

  • agent/agent.go:360-368 — guard comment block is long and partially duplicative. The 9-line pre-amble restates the PR description; the house style (CLAUDE.md) asks for // Why: comments where rationale isn't obvious, and the only non-obvious part is the results[i] pairing invariant. The "canonically polling a long-running background job" sentence duplicates the doc comment on WithToolErrorLimits (agent.go:130-131). Trimming to the pairing invariant + "Why: a changing result is progress" would be cleaner. Trivial.

  • agent/hooks_test.go:181-223 — duplicated fixture setup. TestSameCallRepeatGuardProgressAware rebuilds polling/fp and frozen/fp2 with near-identical scaffolding (toolCallReply("c", "poll", ...) + WithToolErrorLimits(0, 3) + WithMaxSteps(20)). A small table-driven helper or shared newPollAgent(t, handler) would cut ~15 lines of copy-paste and match the style of the existing adderToolbox(t) helper used throughout this file. Small; not blocking.

  • agent/agent.go:372-378resKey uses an undocumented "e\x00" error discriminator. Consistent with the existing sig construction (line 371) and a reasonable way to key error results apart from success content, but it is magic and undocumented — a one-line // Why: noting that error results are keyed apart from success content would help future readers. Trivial.

No dead code, no leaky abstractions, no naming problems. The change cleanly replaces the old guard in place and follows the file's existing structure; lastResults and callCounts are per-Run locals so there is no cross-run leak, and the progress semantics (a changing result resets the count) are correct.

Performance — Minor issues

Verified against the actual code. lastResults := make(map[string]string) (agent.go:287) is keyed by sig and stores the full resKey (which is results[i].Content, optionally prefixed with "e\x00"). The loop only writes/overwrites entries (lastResults[sig] = resKey) and never deletes entries for distinct sigs, so distinct-signature accumulation is monotonic across the run — confirmed against llm/tool.go:77 that Content is an arbitrary string with no size bound. The prior code stored only map[string]int counts (one int per distinct sig), so this is a genuine memory regression proportional to run length × distinct-sig count × avg result size. Finding survives.

Minor issues

  • agent/agent.go:287 — The new lastResults map (map[string]string) retains the full results[i].Content for every distinct (name+arguments) signature for the entire run. Previously callCounts only held one int per distinct sig. For a long agent run that fans out many distinct tool calls returning large payloads (e.g. reading many multi-KB files at distinct paths, or many distinct code_exec invocations), each distinct sig now pins its full result content in memory until run end. Growth is bounded by run length × distinct-sig count × avg content size, which can reach MB-scale in heavy mort-style workloads, where the old code only accumulated ints. The loop only overwrites an existing sig's entry, never removing entries for distinct sigs, so distinct-sig accumulation is monotonic across the run. Suggested fix: cap lastResults size (evict oldest sigs past N entries, or only retain entries for sigs seen within a sliding window), or store a cheap hash (e.g. fnv of resKey) instead of the full content string so per-sig memory is O(1) rather than O(result size).
🧯 Error handling & edge cases — No material issues found

VERDICT: No material issues found

I traced every unhappy path the new progress-aware guard introduces against the actual checked-out code in agent/agent.go:269-413 and llm/tool.go:142-199:

  • Index/length safety: The guard's if i < len(results) defensive check at agent.go:373 — I verified the only way len(results) < len(resp.ToolCalls) is the ctx.Err() early return at agent.go:337-340, which returns before reaching the guard. Unknown tools append an error result then continue (agent.go:343-348), and ExecuteTool never panics (deferred recover at llm/tool.go:158-163). So i < len(results) is always true here; the check is harmless belt-and-suspenders, not a bug.
  • No swallowed errors / missing cleanup: All early returns in the surrounding loop still set result.Messages = msgs and propagate the real error (ctx.Err(), model err, ErrToolLoop). The guard only short-circuits via the existing repeatTripped != "" path at agent.go:391-395, which already does the right cleanup.
  • Empty/zero/negative inputs: maxSameCallRepeats > 0 gate means 0 and negatives disable the guard (same as before). Empty resp.ToolCalls returns at agent.go:323 before the guard runs. Empty result Content just yields resKey == "" (or "e\x00"), a stable key — no panic, no miscount.
  • Duplicate identical calls within one step: Handled correctly. Advancing results reset callCounts[sig] = 1 each call; frozen identical results accumulate callCounts and trip when they exceed the ceiling — same effective ceiling as the pre-patch per-call counter, never stricter.
  • resKey determinism: For non-string/non-RawMessage returns, ExecuteTool routes through json.Marshal (llm/tool.go:190-196), whose map-key ordering has been deterministic since Go 1.12. So a {"status":"running","elapsed":N} result produces a stable, comparable string — no flaky collisions that would falsely "advance" a frozen poller or falsely "freeze" an advancing one.
  • Map-growth: lastResults grows with distinct signatures for the run lifetime, mirroring the pre-existing callCounts. Not a new error-handling defect.
  • Test coverage of the unhappy paths: TestSameCallRepeatGuardProgressAware (agent/hooks_test.go:181-223) exercises both the advancing (no trip) and frozen (trip) cases; the existing TestSameCallRepeatGuard still covers the identical-result trip.

The error message string at agent.go:393 ("called identically more than %d times") is now slightly imprecise (it's "identically with unchanged result"), but that's a wording nit outside my lens and not a mis-handled error.

opencode/kimi-k2.6:cloud (opencode) — No material issues found

Verdict: No material issues found — 5 reviewers: security, correctness, maintainability, performance, error-handling

🔒 Security — No material issues found

Verdict: No material issues found

Reviewed through the Security lens. Traced all tainted inputs:

  • resp.ToolCalls[i].Name / .Arguments (model-provided, untrusted) → concatenated into sig with a \x00 delimiter and used solely as a Go map key. No injection surface.
  • results[i].Content / .IsError (tool output) → built into resKey and used solely for string-equality comparison and map storage. No deserialization, command execution, or external transmission.
  • repeatTripped (derived from call.Name) → fed through %q into fmt.Errorf. Properly quoted; no format-string or log-injection path.

No authn/authz changes, no new network calls, no credential logging, no unsafe defaults, and no missing validation that would let an attacker bypass a security boundary. The guard loosening is a functional/correctness change; it does not open a trust boundary.

🎯 Correctness — No material issues found

No material issues found

I read agent/agent.go and agent/hooks_test.go in full, plus llm/tool.go to verify ToolResult and ExecuteTool semantics. I also traced the guard logic against the TestSameCallRepeatGuard and TestSameCallRepeatGuardProgressAware test cases.

The change is logically correct:

  1. Result-key construction (resKey) correctly distinguishes error vs. success via the "e\x00" prefix, so an identical call that flips from error to success (or vice-versa) resets its count rather than accumulating across the state change.
  2. Count semantics are preserved: callCounts[sig] starts at 1 for the first occurrence of a given (sig, resKey) pair, so a limit of N still trips on the (N+1)st identical call with an unchanged result — same threshold as before.
  3. Ordering invariant (len(results) == len(resp.ToolCalls)) holds because every iteration of the inner loop appends exactly one result (either an "unknown tool" error result or the output of ExecuteTool), and the guard runs only after that loop completes. The defensive if i < len(results) is therefore always true in normal operation.
  4. No false positives introduced — the new behavior is strictly a subset of the old behavior. A genuinely stuck loop still trips; a progressing poller does not.
  5. Tests cover both paths: advancing results run past the limit, frozen results still trip.

One observation that stays within my lane: the if i < len(results) guard at agent/agent.go:373 is technically unreachable given the invariant stated in the comment directly above it. It is harmless, but if the invariant were ever broken by a future edit, the code would silently mis-count instead of failing loudly. I mention this only as a note; it does not change my verdict.

🧹 Code cleanliness & maintainability — No material issues found

No material issues found

Minor issues

  • agent/agent.go:286callCounts is a stale name. It used to count every identical (name, arguments) call; after the fix it counts only identical calls whose result is also unchanged. A reader debugging a loop trip will see callCounts[sig]++ and think it increments on every repeat call, not every stuck repeat call. Rename to sameResultStreak or unchangedResultCount to match the new semantics.

  • agent/agent.go:373 — The i < len(results) guard is unnecessary and creates confusion. The preceding loop appends exactly one results entry per resp.ToolCalls entry (unknown tools append an error result before continue, known tools append ExecuteTool's result, and the only early return is full context cancellation). By the time the repeat-guard loop runs, len(results) == len(resp.ToolCalls) is guaranteed. The bounds check makes a future reader doubt that invariant. Remove it; if defensiveness is desired, use an explicit panic assertion instead of a silent skip.

Performance — No material issues found

No material issues found.

From a performance lens, the change is clean:

  • No hot-path regressions: the added work (string key construction, two map lookups, one comparison) runs once per tool call after tool execution, not inside the model.Generate loop or any blocking I/O path. The dominant cost by orders of magnitude is the LLM round-trip, so this bookkeeping is negligible.
  • Memory growth is bounded per run: lastResults stores exactly one previous result string per distinct (name, arguments) signature seen in the run. For the intended polling use case this is O(1) extra memory; even in the worst case (many distinct signatures) it is bounded by the step limit and tool-call count, with no unbounded accumulation across runs (both maps are local to Run).
  • No extra allocations in the disabled case: when maxSameCallRepeats == 0 the new block is skipped entirely.

One minor, non-blocking observation: resKey is built as "e\x00" + results[i].Content for errors, which copies the full Content string into a new allocation. If a tool returns an extremely large error payload, that copy is retained in lastResults for the signature. This is a necessary trade-off to enable progress-aware comparison, and it only affects runs with the guard enabled.

🧯 Error handling & edge cases — No material issues found

No material issues found.

I examined the diff through the error-handling & edge cases lens and verified the actual checked-out code. The progress-aware same-call repeat guard is sound:

  • Index pairing invariant: results[i] is accessed after the execution loop that appends exactly one result per resp.ToolCalls element (unknown tools append an error result before continue; the only early exit is a full return on context cancellation). I confirmed this by reading the execution loop at agent/agent.go:336-353.
  • Nil/null results: ExecuteTool in llm/tool.go serializes nil handler returns to the string "null", so repeated nil returns correctly compare as identical and still count toward the guard. Panics are recovered into IsError results with stable content strings.
  • Empty collections: Empty Content (e.g., a tool returning "") produces resKey = ""; repeated empty strings compare equal and count as repeating, which is correct.
  • Map key ordering: json.Marshal in ExecuteTool sorts map keys, so map[string]any results are deterministically serialized; the string comparison in resKey is safe.
  • Off-by-one / boundary: The new code resets callCounts[sig] = 1 on any result change, then checks > maxSameCallRepeats. When the result never changes, the count progression is identical to the old behavior (verified algebraically), so existing tests remain valid.
  • Defensive i < len(results): This check is redundant given the invariant but harmless; it does not mask a real bug because the invariant is enforced by the immediately preceding loop structure.

The guard correctly distinguishes advancing results (polling) from frozen results (stuck loops), with no unhandled edge cases in error handling, nil safety, or boundary conditions.

Automated adversarial review by Gadfly — consensus across the model swarm. Advisory only — does not block merge.

<!-- gadfly-consensus --> ## 🪰 Gadfly review — consensus across 5 models **Verdict: Minor issues** · 7 findings (4 with multi-model agreement) | | Finding | Where | Models | Lens | |--|--|--|--|--| | 🟡 | Defensive 'i < len(results)' branch is dead code that contradicts the invariant the preceding comment asserts | `agent/agent.go:373` | 4/5 | error-handling, maintainability | | 🟠 | Progress check is exact-string equality on the full tool result, so any incidental non-progress field (timestamp, request id, sequence nonce) that changes per-call defeats the guard for genuinely stuck non-erroring polls, which the consecutive-error guard does not cover | `agent/agent.go:379` | 2/5 | correctness, error-handling | | 🟡 | callCounts and lastResults are two parallel maps keyed by the same signature that should be merged | `agent/agent.go:286` | 2/5 | maintainability, performance | | 🟡 | Progress-aware guard semantics explained redundantly in three separate comments | `agent/agent.go:360` | 2/5 | maintainability | <details><summary>3 single-model findings (lower confidence)</summary> | | Finding | Where | Model | Lens | |--|--|--|--|--| | 🟡 | WithToolErrorLimits doc text not updated to describe the new progress-aware (result-unchanged) behavior | `README.md:373` | claude-code/sonnet | maintainability | | 🟡 | Error message no longer matches progress-aware guard semantics | `agent/agent.go:393` | kimi-k2.6:cloud | maintainability | | 🟡 | New progress-aware test duplicates poll-tool/agent scaffolding; a shared helper (cf. adderToolbox) would reduce copy-paste | `agent/hooks_test.go:181` | opencode/glm-5.2:cloud | maintainability | </details> <details><summary>Per-model detail</summary> <details><summary><b>claude-code/sonnet</b> (claude-code) — Minor issues</summary> **Verdict: Minor issues** — 5 reviewers: security, correctness, maintainability, performance, error-handling <details><summary><b>🔒 Security</b> — No material issues found</summary> **VERDICT: No material issues found** I reviewed this change specifically through the security lens (authn/authz, injection, SSRF, deserialization, secret leakage, input validation, insecure defaults). What the diff actually does: it changes an in-process loop guard in `agent/agent.go`'s `Run()` from counting raw repeated `(name+arguments)` tool-call signatures to counting them only when the tool's *result* is also unchanged, tracked via two maps (`callCounts`, `lastResults`) that are declared fresh inside `Run()` (agent/agent.go:286-287) and never persisted or shared across runs/goroutines — no cross-session leakage. Checked and ruled out: - **Untrusted data handling**: `call.Arguments` (json.RawMessage, model-controlled) and `results[i].Content` (tool-controlled) are only ever used as map-key material (string concatenation with a `\x00` separator) — never parsed, evaluated, templated, or passed to a shell/SQL/HTTP call. No injection vector. - **Resource-exhaustion / cost-DoS via guard bypass**: the new logic does let a tool whose output varies trivially (e.g., an adversarial or compromised tool backend that appends a nonce/timestamp) evade the *same-call* guard indefinitely. However, I confirmed (agent/agent.go:107-108, 156, 298) the outer `maxSteps` loop bound (`DefaultMaxSteps`, or `WithMaxSteps`/`WithMaxStepsFunc`) still hard-caps total steps regardless of this guard, so worst-case cost/iteration count is unchanged from before — this guard was always a soft early-exit, not the resource ceiling. Not a new exploitable boundary. - **Map growth**: signatures are already attacker-influenceable pre-existing behavior (`callCounts` existed before this PR); adding `lastResults` roughly doubles per-signature memory but doesn't introduce a new growth vector, and is still bounded by `maxSteps` steps total. - No new external calls, deserialization, credential handling, or default-permission changes anywhere in the diff. Nothing in this diff touches auth, network boundaries, secrets, or untrusted-input parsing in a new way. Clean from a security standpoint. </details> <details><summary><b>🎯 Correctness</b> — Minor issues</summary> ## Verdict: Minor issues I read `agent/agent.go` lines 253–417, `llm/tool.go` lines 140–199 (`ExecuteTool`), and `agent/hooks_test.go`, and independently re-traced the same invariants the draft claims. **Verified correct:** - `results[i]` pairs with `resp.ToolCalls[i]`: the per-call loop (agent/agent.go:336–353) checks `ctx.Err()` first each iteration and returns fully on cancellation; otherwise every call appends exactly one `ToolResult` (unknown-tool branch appends then `continue`s, known-tool branch always appends `ExecuteTool`'s return). So when the guard loop (agent/agent.go:369–389) runs, `len(results) == len(resp.ToolCalls)` always holds and the `i < len(results)` check is dead but harmless. - Threshold semantics unchanged: `callCounts[sig]++`/reset then `> a.maxSameCallRepeats` trips on the `(max+1)`th occurrence, matching the pre-existing logic. - `ExecuteTool` (llm/tool.go:182–197) marshals non-string/non-raw results via `json.Marshal`, which sorts map keys — the fake polling tool's `map[string]any` output serializes deterministically, so the new tests aren't relying on flaky ordering. - Error/success namespacing (`"e\x00"+Content` vs `Content`, agent/agent.go:374–377) correctly treats an error→success transition as "changed." **Finding — progress signal is exact-string equality on the whole result body, a fragile proxy for "did this job actually advance"** (agent/agent.go:371–384). The guard resets its count only when `results[i].Content` is byte-identical to the previous identical-signature call's content, and increments otherwise. Confirmed against `ExecuteTool`: any incidental non-progress field a poller includes on every response (timestamp, trace id, monotonic counter) makes `Content` differ every call even if the job's actual `status` is frozen — this is a genuinely stuck poll that the old guard would have caught but the new one never trips on. I confirmed `maxConsecutiveToolErrors` (agent/agent.go:396–412) doesn't backstop this: it only counts steps where *all* results are `IsError`, and a stuck-but-still-"successful" poll never sets `IsError`. The commit message's own framing — "This can never trip more than before, only less" — is accurate only for identical-content stuck loops; it doesn't acknowledge this false-negative surface for real-world pollers with any non-static metadata. This is a real, verifiable gap in the guard's domain correctness (a plausible accepted tradeoff, not a hard bug), so it's worth flagging explicitly. No other logic bugs found: initial-count seeding, cross-step signature tracking, `sig` construction (unchanged: `Name + "\x00" + Arguments`), and the guard-disable path (`maxSameCallRepeats == 0`) all check out against the original semantics. </details> <details><summary><b>🧹 Code cleanliness & maintainability</b> — Minor issues</summary> **VERDICT: Minor issues** - `agent/agent.go:127-133`, `agent/agent.go:281-287`, `agent/agent.go:360-368` — The progress-aware semantics of the same-call guard are explained in near-identical prose three separate times: the `WithToolErrorLimits` doc comment, the `callCounts`/`lastResults` declaration comment, and again immediately above the guard block itself. Verified by reading all three locations — they restate the same "result changes ⇒ progress ⇒ resets count" idea with only wording variations. Three copies of the same explanation is a maintenance liability: a future tweak to this logic (e.g. changing what counts as "unchanged") requires remembering to update all three, and they will drift. Keep one clear explanation (e.g. on the field declaration or the block) and trim the other two to a one-line pointer. - `agent/agent.go:286-287` / `369-389` — `callCounts` and `lastResults` are two separate maps keyed by the same `sig`, always read and written together in lockstep (`agent.go:379-384`). This is the classic "two maps that must stay in sync" smell; merging them into one `map[string]struct{ count int; lastResult string }` would remove the duplicate lookup per call and make the pairing structurally guaranteed rather than convention-guaranteed. Verified by reading the full guard loop — every access to one map is immediately paired with an access to the other, with no case where they're used independently. - `agent/agent.go:373` — `if i < len(results) { ... }` guards indexing into `results`, but the comment directly above (`agent.go:366-368`) explicitly documents the invariant that `results[i]` always pairs with `resp.ToolCalls[i]` (every call appends exactly one result; the only early exit is a full function return on ctx cancellation before this point is reached). Verified against the results-building loop at `agent.go:336-353`: `ExecuteTool` always appends, and the unknown-tool branch appends-then-`continue`s, and the `ctx.Err()` check does a full `return` out of `Run` (not just a loop break), so there's no path that reaches the guard loop with `results` shorter than `resp.ToolCalls`. The bounds check is therefore dead code that contradicts the invariant the surrounding comment asserts — either trust the invariant and drop the check, or the comment is overclaiming and should be softened. - `README.md:373-375` (and the same wording in `docs/adr/0014-conversion-driven-extensions.md:33-34`) — Still describes `WithToolErrorLimits` as providing circuit breakers for "identical repeated calls" without mentioning that a call is now only counted when its *result* is unchanged. This is now an incomplete description of the behavior this PR changes, and CLAUDE.md's house rule explicitly requires README to "match reality in the same commit that changes behavior." Verified by reading both doc locations — neither mentions the progress/result-comparison exception introduced here. </details> <details><summary><b>⚡ Performance</b> — No material issues found</summary> **VERDICT: No material issues found** I read the full modified `Run` loop in `agent/agent.go:256-416` and traced the guard logic end-to-end. - The new same-call guard loop (`agent/agent.go:361-386`) is a second `O(len(resp.ToolCalls))` pass over the same slice already walked once in the dispatch loop above it. Tool-call counts per step are small (bounded by what the model requests in one turn), so this is not a hot-loop concern. - `callCounts`/`lastResults` are keyed by call signature and only grow with the number of *distinct* signatures seen, which is itself bounded by `maxSteps` (`agent/agent.go:289-296`, `298`). For the motivating polling case (identical `job_id` args every call), the signature is constant, so the map stays at one entry and each poll's stored `Content` simply overwrites the previous one rather than accumulating — confirmed by reading `lastResults[sig] = resKey` at `agent/agent.go:383`. - `lastResults` does hold a copy of the *previous* result's `Content` string (`llm/tool.go:76-77`, an arbitrary-size serialized string) per unique signature for the life of the run, where before only an `int` was stored. This is a small, run-bounded memory increase (one string per distinct signature, not per call), not unbounded growth, so I'm not flagging it as material — noting it here only because it's the one real behavioral delta I verified, not because it rises to a reportable issue. No N+1 patterns, no unbounded accumulation across steps, no quadratic behavior, and no new blocking calls were introduced by this change. </details> <details><summary><b>🧯 Error handling & edge cases</b> — Minor issues</summary> Both findings check out against the actual code. **Finding 1** (agent/agent.go:379): Confirmed. `ExecuteTool` (llm/tool.go:190) does a raw `json.Marshal` of whatever the handler returns with zero normalization, and the guard at agent.go:371-384 does byte-for-byte string comparison of the full encoded `Content`. A poll response with a jittery field (timestamp, elapsed, request id) alongside an unchanged `status` will produce a different `resKey` every call, so `callCounts[sig]` never accumulates. I confirmed there's no run-critic implementation anywhere in the tree (`grep -rln critic` only hits `agent/hooks_test.go:34` and `agent/finalize_test.go:98`, both unrelated), and `DefaultMaxSteps = 10` at agent/agent.go:23, so this heuristic really is the load-bearing defense at higher step ceilings. The finding is accurate. **Finding 2** (agent/agent.go:373): Confirmed as dead code under the current invariant. Tracing the first loop (agent/agent.go:336-353): every iteration either appends an error result via `continue` or appends via `ExecuteTool` — both paths guarantee exactly one `results` entry per `resp.ToolCalls` entry — and the only early exit (`ctx.Err()`, line 337-340) returns from `Run` entirely before the guard loop is ever reached. So whenever the guard loop executes, `i < len(results)` is always true; it's a genuine defensive/dead branch, correctly characterized as trivial. Both findings survive verification unchanged. --- VERDICT: Minor issues - `agent/agent.go:379` (progress-aware repeat guard) — the "progress" signal is a raw string-equality check on `ToolResult.Content` (`llm/tool.go:190`, JSON-marshaled). Any tool whose response includes an incidental non-deterministic field alongside the real status — a timestamp, `checked_at`, request id, elapsed-since-epoch, retry counter, etc. (very common in real polling APIs, and exactly the shape of the job-poller this PR is designed for) — will produce a different `resKey` on every call even when the actual job status never advances (e.g. `status: "queued"` forever). Because `lastResults[sig]` only tracks the immediately-previous encoding, any incidental byte change resets `callCounts[sig]` to 1 and the guard never trips. This directly contradicts the guarantee stated in the docstring/PR description ("a genuinely stuck call returning the same output still trips") — a genuinely stuck-but-noisy call will not trip. - Verified: `ExecuteTool` (`llm/tool.go:156-198`) JSON-marshals whatever the handler returns verbatim into `Content`, with no normalization; the guard (`agent/agent.go:369-389`) compares that full string byte-for-byte with no field-level allowance. - Verified there is no other backstop in this codebase for this scenario today: `grep -rln "critic"` across the repo turns up no run-critic implementation (`agent/finalize_test.go:98` and `agent/hooks_test.go:34` are unrelated hits). The only remaining backstop is the coarse `maxSteps` ceiling (`DefaultMaxSteps = 10`, `agent/agent.go:23`), and callers exercising the very use case this PR targets (long-running job polling) necessarily raise that ceiling well above 10 (the PR's own test uses `WithMaxSteps(20)`), so a defeated guard can run substantially longer before the run is cut off. - Suggested fix: scope the equality check to a normalized/whitelisted subset the tool declares as "progress," or at minimum document this as a known limitation so callers understand the guard is best-effort against noisy tool output, not just against genuinely-frozen output as currently claimed. - `agent/agent.go:372-378` — the `if i < len(results)` guard is dead code given the invariant the surrounding comment asserts (every call appends exactly one result), but if that invariant is ever violated by a future change, a mismatch silently degrades to an empty `resKey` (`""`, colliding with any other call whose content happens to be empty) rather than surfacing the bug loudly. Low impact today since the invariant currently holds (verified by reading the full tool-execution loop at `agent/agent.go:334-353`), but worth a comment noting this is a defensive invariant check rather than expected runtime behavior. </details> </details> <details><summary><b>glm-5.2:cloud</b> (ollama-cloud) — Minor issues</summary> **Verdict: Minor issues** — 5 reviewers: security, correctness, maintainability, performance, error-handling <details><summary><b>🔒 Security</b> — No material issues found</summary> **VERDICT: No material issues found** Reviewed through the security lens. The change touches the agent's internal loop-guard accounting; from a security standpoint it's clean: - **No taint/injection surface**: `sig = call.Name + "\x00" + string(call.Arguments)` and `resKey` are used only as in-memory map keys/strings (no SQL, command, path, or template interpolation). Verified at `agent/agent.go:371-377`. - **No credential/secret leakage**: `lastResults`/`callCounts` are per-`Run` locals (declared at `agent/agent.go:280-285`), never persisted, logged, or returned; only `repeatTripped` (a tool *name*) flows into the error string at `agent/agent.go:393-394`. Tool result *content* is compared/stored in-memory but never emitted. - **No authz/authn changes**: the guard is post-execution accounting; it doesn't gate tool execution or alter authorization. Tools still execute via `ExecuteTool`/unknown-tool path before the guard runs (`agent/agent.go:341-353`). - **No unsafe deserialization**: `call.Arguments` is only stringified into a key, not decoded. - **Guard-weakening is by-design**: the progress-aware logic means a tool whose result varies each call (e.g. embedded timestamp/nonce) no longer trips the repeat guard. That is the explicit intent of the PR, and total runaway is still bounded by `maxSteps` plus the consecutive-error guard — so this is not an unbounded-DoS regression introduced inadvertently. Not flagging intentional behavior as a security bug. The index-alignment assumption (`results[i]` ↔ `resp.ToolCalls[i]`) holds per my read: every branch in the execution loop appends exactly one result (unknown tool appends+`continue`, `ExecuteTool` appends one), and the only early exit is a full `return` on `ctx.Err()` — which is a correctness concern outside my lens, not security. </details> <details><summary><b>🎯 Correctness</b> — Minor issues</summary> **VERDICT: Minor issues** - **`agent/agent.go:379-384` — oscillating/flapping identical-args results escape the progress-aware repeat guard (medium, verified).** I re-read the guard block (lines 369-389) and traced the semantics. `lastResults[sig]` only compares each call's result to the *immediately preceding* identical-args call. A poller whose identical-args calls alternate between two (or more) fixed result values (e.g. status flapping `running`↔`queued`, no net advancement) produces a `resKey` that differs from its predecessor on every call, so the `else` branch resets `callCounts[sig] = 1` each iteration and `callCounts[sig] > a.maxSameCallRepeats` never holds. Trace: call1=A→count=1, call2=B→reset to 1, call3=A→reset to 1, call4=B→reset to 1 … never trips. The original `callCounts[sig]++` guard counted raw repetition and *did* catch this; the new one does not. Such a non-advancing flapper now loops until `maxSteps` instead of being killed by the repeat guard. I grepped the whole repo for a run-critic / "unproductive loop" backstop (`critic|RunCritic|unproductive`) and found only documentation references (`docs/mort-migration.md`, `docs/adr/0014-…`, `progress.md`) plus the unrelated test comment in `agent/hooks_test.go:34` — no actual code path that would catch this loosened case. Absent a wired-in critic backstop, this is a genuine coverage regression. Suggested fix: count a call toward the trip when its result repeats *any* recently-seen result for that signature (e.g. a small bounded set of recent distinct results per sig, tripping when the set stops growing), rather than only comparing against the single previous result. </details> <details><summary><b>🧹 Code cleanliness & maintainability</b> — Minor issues</summary> **VERDICT: Minor issues** - `agent/agent.go:373` — the `if i < len(results) { … }` branch is defensive dead code that contradicts the invariant the preceding comment (lines 366-368) asserts. Verified against the actual loop above: the `ctx.Err()` path does a full `return` before reaching the guard (lines 337-340), the unknown-tool path appends an error result before `continue` (lines 342-348), and `ExecuteTool` (`llm/tool.go:156`) always returns exactly one `ToolResult` (panic recovered into an error result, nil handler → error result, normal return). So `len(results) == len(resp.ToolCalls)` is guaranteed here, making the `else` path where `resKey` stays `""` unreachable. Defensive code that muddies the very invariant the comment documents. Fix: drop the `if i < len(results)` guard and index `results[i]` directly, or keep it but drop the comment claim that the lengths always align — pick one. - `agent/agent.go:375` — the `"e\x00"` error-sentinel prefix is a magic collision-avoidance trick (so an error result with content `foo` isn't treated as identical to a non-error `foo`) with no comment at the point of use; the block-level comment above (lines 360-368) explains the progress-aware comparison but never mentions the sentinel. A one-line inline note (`// "e\x00" prefixes error results so they never compare equal to a non-error with the same content`) would keep the local intent clear at the subtle part of the comparison. Neither blocks; both are low-churn readability fixes. </details> <details><summary><b>⚡ Performance</b> — No material issues found</summary> **Verdict: No material issues found** Through the performance lens, the change moves the repeat guard from a cheap pre-execution signature count to a post-execution pass that stores and compares each call's result content (`agent/agent.go:369-388`). I verified the cost shape against the surrounding code: - The new `lastResults` map retains one entry per distinct `(name+arguments)` signature, holding the last result `Content` string. It is bounded by the number of distinct signatures, mirroring the pre-existing `callCounts` map; it is not an accumulating-per-step structure (entries are overwritten, not appended). `agent/agent.go:286-287` - Retained content is a strict subset of what the run already holds: every `results[i].Content` is appended into `msgs` and `step.Results` on every step (`agent/agent.go:355-358`), so the transcript — not this guard — is the dominant memory consumer. The guard adds no new asymptotic dimension. - The `prev == resKey` comparison is `O(len(Content))` per tool call per step, and the `resKey += results[i].Content` concatenation allocates only on the error path (`"e\x00"+content`); on the success path `resKey` is just the content string. Both are per-step work, not per-token, and are negligible next to the `model.Generate` round-trip that precedes every step (`agent/agent.go:313`). No N+1, no quadratic blowup, no hot-loop regression. - I confirmed `ToolResult.Content` is a plain `string` and `ExecuteTool` JSON-marshals structured returns once (`llm/tool.go:71-82, 182-197`), so the guard is not re-encoding or re-serializing results — it reads an already-built string. No efficiency regression worth flagging in this lane. </details> <details><summary><b>🧯 Error handling & edge cases</b> — No material issues found</summary> I've verified the change through the error-handling & edge-case lens. Key verifications: - **Index alignment** (`results[i]` ↔ `resp.ToolCalls[i]`): Confirmed at `agent/agent.go:336-353`. Both the unknown-tool branch (appends an error result then `continue`) and the `ExecuteTool` branch append exactly one result per call iteration, so `len(results) == len(resp.ToolCalls)` when the guard loop runs. The `i < len(results)` guard is defensive but always true; no out-of-bounds panic is possible. - **Panic safety**: `ExecuteTool` (`llm/tool.go:156-198`) recovers panics and converts errors/nil/unencodable values into `IsError` results, so `results[i]` is always populated — no nil-deref in the guard's `results[i].IsError` / `.Content` access. - **Empty/zero content**: A tool legitimately returning `""` (or `"null"`) yields `resKey = ""` (or `"null"`); repeated identical empties correctly increment the count and trip — that's the intended "stuck" behavior, not a bug. - **Error results**: An error with empty `Content` gives `resKey = "e\x00"`; an advancing error message changes `resKey` and resets the count, but the consecutive-error guard (`agent.go:396-412`) still catches all-error steps. The two guards compose correctly and the ordering (repeat guard before error guard) doesn't swallow either. - **First-seen / multi-call-per-step**: `seen=false` on first occurrence correctly seeds `callCounts=1`; multiple identical calls within one step each increment, which is the correct semantics. - **Negative/zero `maxSameCallRepeats`**: `> 0` gate disables the guard, unchanged from prior behavior. - **Map state**: `callCounts`/`lastResults` are declared per-`Run`, no cross-run leakage. The one behavioral edge case — a loop whose result *varies* without real progress (e.g., alternating A/B/A/B), which the old guard caught but the new one does not — is explicitly a design trade-off called out in the PR and deferred to a separate "run critic." That's a deliberate scope decision, not an unhandled edge case in this code. No material issues found through the error-handling & edge-case lens. </details> </details> <details><summary><b>kimi-k2.6:cloud</b> (ollama-cloud) — Minor issues</summary> **Verdict: Minor issues** — 5 reviewers: security, correctness, maintainability, performance, error-handling <details><summary><b>🔒 Security</b> — No material issues found</summary> **No material issues found** I reviewed the change through the **Security** lens and found no authn/authz gaps, injection vectors, SSRF risks, unsafe deserialization, secret leakage, input-validation bypasses, or insecure defaults introduced by this diff. Key security-relevant observations (all confirmed safe): - `agent/agent.go:371` — The signature `call.Name + "\x00" + string(call.Arguments)` is constructed from model-controlled input, but it is only ever used as an in-memory map key; it is never passed to external systems, and the tool-name portion must match a registered tool before execution proceeds. - `agent/agent.go:372–378` — The result key `resKey` incorporates `results[i].Content` (tool output). This is stored only in the local `lastResults` map scoped to the `Run` call and is never logged or returned in error messages. The only data exposed when the guard trips is the tool name (`repeatTripped`), not arguments or results (`agent/agent.go:393`). - `agent/agent.go:379–384` — The guard compares previous and current results to decide whether to increment the repeat counter. This weakens the guard (by design), but the run remains bounded by `maxSteps` and the separate consecutive-error guard, so it cannot be used to create an unbounded loop or DoS. </details> <details><summary><b>🎯 Correctness</b> — No material issues found</summary> **No material issues found** The progress-aware repeat guard is correctly implemented: - The 1:1 pairing between `resp.ToolCalls[i]` and `results[i]` is guaranteed by the preceding loop, where every tool call appends exactly one result (unknown tools append an error result before `continue`). The defensive `i < len(results)` check is unnecessary but harmless. - The `resKey` correctly distinguishes error results from success results with the same string content via the `"e\x00"` prefix. - The reset semantics (`callCounts[sig] = 1` when the result changes) accurately track consecutive identical results, so a genuinely stuck loop still trips while an advancing poller does not. - The trip threshold (`callCounts[sig] > a.maxSameCallRepeats`) preserves the original behavior: a limit of 3 allows up to 3 identical results and trips on the 4th, matching existing test expectations. </details> <details><summary><b>🧹 Code cleanliness & maintainability</b> — Minor issues</summary> Minor issues - `agent/agent.go:373` The `if i < len(results)` guard is unnecessary: the preceding tool-execution loop (lines 336-353) appends exactly one result per `resp.ToolCalls` entry, so `len(results) == len(resp.ToolCalls)` is guaranteed. The defensive check contradicts the invariant the code's own comment (lines 366-367) claims, and creates a silent skip path if someone later breaks that invariant. Drop the `if` and rely on the pairing; add an explicit `if len(results) != len(resp.ToolCalls) { panic(...) }` if you want a loud invariant. - `agent/agent.go:393` The error message `"called identically more than %d times"` is now misleading: the guard trips only on identical calls that also return an *unchanged* result. Update the message to reflect the new semantics, e.g. `"called identically with unchanged result more than %d times"`, so logs and debugging don't confuse operators. </details> <details><summary><b>⚡ Performance</b> — No material issues found</summary> **No material issues found** Through the performance lens, the change is clean: - **Allocation overhead**: The new `lastResults` map and `resKey` string add only O(1) work per tool call. For non-error results, `resKey` simply aliases `results[i].Content` with no extra byte copy; for errors, it allocates a small `"e\x00"+content` string, but error payloads are typically tiny. The map stores one string header per unique signature—bounded by the number of distinct tools in a run. - **Hot-path impact**: The guard check was already doing a string-key construction (`sig`) per call; the additional map lookup/store is negligible compared to model generation and tool execution. - **Memory bounds**: Both maps are scoped to a single `Run()` and bounded by unique call signatures, so there is no unbounded growth. The fix correctly loosens the guard without introducing any measurable efficiency regression. </details> <details><summary><b>🧯 Error handling & edge cases</b> — No material issues found</summary> **No material issues found** Through the error-handling & edge-cases lens the change is clean. The progress-aware guard correctly pairs each `ToolCall` with its corresponding `ToolResult` by index, and the 1:1 append invariant in the execution loop guarantees the pairing is safe. </details> </details> <details><summary><b>opencode/glm-5.2:cloud</b> (opencode) — Minor issues</summary> **Verdict: Minor issues** — 5 reviewers: security, correctness, maintainability, performance, error-handling <details><summary><b>🔒 Security</b> — No material issues found</summary> VERDICT: No material issues found Reviewed through the security lens. The change is purely internal loop-guard accounting; it introduces no new tainted-input flows, no injection surface, no authn/authz changes, and no secret handling. Verified by reading `agent/agent.go:300-417`: - `sig` and `resKey` are built from `call.Name`, `call.Arguments`, and `results[i].Content` — all untrusted (model/tool output), but they are used **only** as in-memory map keys for equality comparison. No string interpolation into commands, queries, paths, or templates; no injection vector. - The loosened guard (only trip on unchanged result) cannot create a resource-exhaustion DoS beyond pre-existing exposure: the run is still bounded by `maxSteps` (`agent.go:416`), and `callCounts`/`lastResults` are keyed by distinct `(name+args)` signatures, not by per-call output, so memory growth is bounded by the number of distinct signatures in a run, not by result size. Result `Content` is stored as a map value (not a key), so a single huge result is one string per signature — same footprint the run already carries in `msgs`. - The `i < len(results)` guard correctly handles the only early-exit path (ctx cancellation at `agent.go:337-340`), where `results` is shorter than `resp.ToolCalls`; those entries get an empty `resKey` and are counted without panic. No out-of-bounds or uninitialized-data concern. - No credentials, PII, or secret material is touched; no new logging of tool arguments/results beyond what the run already retained in `msgs`. Nothing in my lane to flag. </details> <details><summary><b>🎯 Correctness</b> — No material issues found</summary> I verified the diff against the checked-out code by reading `agent/agent.go:250-417`, the full `ExecuteTool`/`ToolResult` definitions in `llm/tool.go:60-199`, the test file, and the `toolCallReply`/`adderToolbox` helpers. **Alignment claim (the load-bearing correctness assumption):** the new guard iterates `for i, call := range resp.ToolCalls` and reads `results[i]`. The comment asserts "every call appends exactly one result." I traced the call-execution loop above the guard: the unknown-tool branch does `results = append(results, ...)` then `continue`; the known branch does `results = append(results, ExecuteTool(...))`. The only other early exit is the `ctx.Err()` return at line 337-340, which returns from `Run` before the guard runs. So when the guard executes, `len(results) == len(resp.ToolCalls)` and `results[i]` is genuinely the paired result. The defensive `if i < len(results)` is dead-but-harmless. Verified safe. **resKey design:** `resKey = "e\x00" + Content` for error results, `Content` otherwise. This correctly disambiguates a success whose text coincidentally equals an error's text, so an error→success transition (or vice versa) resets the count rather than being treated as "unchanged." Correct. **"Cannot trip more than before" claim:** the new condition trips only when `prev == resKey` (seen AND identical result). The old condition tripped on any identical (name+args) repeat. Every old trip is still reachable (frozen-result calls still increment the same way), and advancing-result calls now reset. Claim holds — strictly fewer trips. Verified. **Tests:** `TestSameCallRepeatGuardProgressAware` covers the advancing case (6 polls past limit 3, completes with "done") and the frozen case (still trips). `TestSameCallRepeatGuard` (frozen `add(1,1)` returning the same sum) still trips because the result content `{"sum":2}` is identical each call. Both consistent with the new semantics. One minor semantic caveat I considered and judged non-blocking: a call that *alternates* between two stable result states (e.g., success-then-error-then-success with the same payload) now resets its count each step and never trips this guard, whereas the old code would have tripped it. This is a genuine behavioral loosening, but it is strictly "less than before" (consistent with the PR's stated thesis), and the alternating-error case is partially covered by the separate consecutive-error guard when all results in a step are errors. Not a material correctness regression. **Verdict: No material issues found** - Index alignment between `resp.ToolCalls[i]` and `results[i]` verified by reading the call-execution loop (`agent/agent.go:336-353`): exactly one result is appended per call on every path that reaches the guard; the only skip-path is a full `return` on `ctx.Err()`. The `if i < len(results)` branch is dead but harmless. - `resKey` error-prefix (`"e\x00"`) correctly distinguishes error vs. non-error results with identical content (`agent/agent.go:374-378`), so transitions across the error boundary reset the count as intended. - The "strictly fewer trips" invariant holds: the new trip condition is a strict subset of the old (requires `prev == resKey` in addition to identical args), and the existing `TestSameCallRepeatGuard` (frozen identical result) still trips under the new logic. - Minor (non-blocking) behavioral note: a call alternating between two stable result states (e.g., success↔error) now escapes this guard, where the old code would have tripped it. This is consistent with the PR's stated "progress-over-repetition" thesis and remains bounded by the consecutive-error guard for all-error steps; not a regression against the PR's stated contract. </details> <details><summary><b>🧹 Code cleanliness & maintainability</b> — Minor issues</summary> ## Review **Verdict: Minor issues** - **`agent/agent.go:360-368` — guard comment block is long and partially duplicative.** The 9-line pre-amble restates the PR description; the house style (CLAUDE.md) asks for `// Why:` comments *where rationale isn't obvious*, and the only non-obvious part is the `results[i]` pairing invariant. The "canonically polling a long-running background job" sentence duplicates the doc comment on `WithToolErrorLimits` (agent.go:130-131). Trimming to the pairing invariant + "Why: a changing result is progress" would be cleaner. Trivial. - **`agent/hooks_test.go:181-223` — duplicated fixture setup.** `TestSameCallRepeatGuardProgressAware` rebuilds `polling`/`fp` and `frozen`/`fp2` with near-identical scaffolding (`toolCallReply("c", "poll", ...)` + `WithToolErrorLimits(0, 3)` + `WithMaxSteps(20)`). A small table-driven helper or shared `newPollAgent(t, handler)` would cut ~15 lines of copy-paste and match the style of the existing `adderToolbox(t)` helper used throughout this file. Small; not blocking. - **`agent/agent.go:372-378` — `resKey` uses an undocumented `"e\x00"` error discriminator.** Consistent with the existing `sig` construction (line 371) and a reasonable way to key error results apart from success content, but it is magic and undocumented — a one-line `// Why:` noting that error results are keyed apart from success content would help future readers. Trivial. No dead code, no leaky abstractions, no naming problems. The change cleanly replaces the old guard in place and follows the file's existing structure; `lastResults` and `callCounts` are per-`Run` locals so there is no cross-run leak, and the progress semantics (a changing result resets the count) are correct. </details> <details><summary><b>⚡ Performance</b> — Minor issues</summary> Verified against the actual code. `lastResults := make(map[string]string)` (agent.go:287) is keyed by `sig` and stores the full `resKey` (which is `results[i].Content`, optionally prefixed with `"e\x00"`). The loop only writes/overwrites entries (`lastResults[sig] = resKey`) and never deletes entries for distinct sigs, so distinct-signature accumulation is monotonic across the run — confirmed against `llm/tool.go:77` that `Content` is an arbitrary string with no size bound. The prior code stored only `map[string]int` counts (one int per distinct sig), so this is a genuine memory regression proportional to run length × distinct-sig count × avg result size. Finding survives. Minor issues - `agent/agent.go:287` — The new `lastResults` map (`map[string]string`) retains the full `results[i].Content` for every distinct `(name+arguments)` signature for the entire run. Previously `callCounts` only held one `int` per distinct sig. For a long agent run that fans out many *distinct* tool calls returning large payloads (e.g. reading many multi-KB files at distinct paths, or many distinct `code_exec` invocations), each distinct sig now pins its full result content in memory until run end. Growth is bounded by run length × distinct-sig count × avg content size, which can reach MB-scale in heavy mort-style workloads, where the old code only accumulated ints. The loop only overwrites an existing sig's entry, never removing entries for distinct sigs, so distinct-sig accumulation is monotonic across the run. Suggested fix: cap `lastResults` size (evict oldest sigs past N entries, or only retain entries for sigs seen within a sliding window), or store a cheap hash (e.g. fnv of `resKey`) instead of the full content string so per-sig memory is O(1) rather than O(result size). </details> <details><summary><b>🧯 Error handling & edge cases</b> — No material issues found</summary> VERDICT: No material issues found I traced every unhappy path the new progress-aware guard introduces against the actual checked-out code in `agent/agent.go:269-413` and `llm/tool.go:142-199`: - **Index/length safety**: The guard's `if i < len(results)` defensive check at `agent.go:373` — I verified the only way `len(results) < len(resp.ToolCalls)` is the `ctx.Err()` early return at `agent.go:337-340`, which `return`s before reaching the guard. Unknown tools append an error result then `continue` (`agent.go:343-348`), and `ExecuteTool` never panics (deferred recover at `llm/tool.go:158-163`). So `i < len(results)` is always true here; the check is harmless belt-and-suspenders, not a bug. - **No swallowed errors / missing cleanup**: All early returns in the surrounding loop still set `result.Messages = msgs` and propagate the real error (`ctx.Err()`, model `err`, `ErrToolLoop`). The guard only short-circuits via the existing `repeatTripped != ""` path at `agent.go:391-395`, which already does the right cleanup. - **Empty/zero/negative inputs**: `maxSameCallRepeats > 0` gate means `0` and negatives disable the guard (same as before). Empty `resp.ToolCalls` returns at `agent.go:323` before the guard runs. Empty result `Content` just yields `resKey == ""` (or `"e\x00"`), a stable key — no panic, no miscount. - **Duplicate identical calls within one step**: Handled correctly. Advancing results reset `callCounts[sig] = 1` each call; frozen identical results accumulate `callCounts` and trip when they exceed the ceiling — same effective ceiling as the pre-patch per-call counter, never stricter. - **resKey determinism**: For non-string/non-RawMessage returns, `ExecuteTool` routes through `json.Marshal` (`llm/tool.go:190-196`), whose map-key ordering has been deterministic since Go 1.12. So a `{"status":"running","elapsed":N}` result produces a stable, comparable string — no flaky collisions that would falsely "advance" a frozen poller or falsely "freeze" an advancing one. - **Map-growth**: `lastResults` grows with distinct signatures for the run lifetime, mirroring the pre-existing `callCounts`. Not a new error-handling defect. - **Test coverage of the unhappy paths**: `TestSameCallRepeatGuardProgressAware` (`agent/hooks_test.go:181-223`) exercises both the advancing (no trip) and frozen (trip) cases; the existing `TestSameCallRepeatGuard` still covers the identical-result trip. The error message string at `agent.go:393` (`"called identically more than %d times"`) is now slightly imprecise (it's "identically *with unchanged result*"), but that's a wording nit outside my lens and not a mis-handled error. </details> </details> <details><summary><b>opencode/kimi-k2.6:cloud</b> (opencode) — No material issues found</summary> **Verdict: No material issues found** — 5 reviewers: security, correctness, maintainability, performance, error-handling <details><summary><b>🔒 Security</b> — No material issues found</summary> **Verdict:** No material issues found Reviewed through the **Security** lens. Traced all tainted inputs: - `resp.ToolCalls[i].Name` / `.Arguments` (model-provided, untrusted) → concatenated into `sig` with a `\x00` delimiter and used solely as a Go map key. No injection surface. - `results[i].Content` / `.IsError` (tool output) → built into `resKey` and used solely for string-equality comparison and map storage. No deserialization, command execution, or external transmission. - `repeatTripped` (derived from `call.Name`) → fed through `%q` into `fmt.Errorf`. Properly quoted; no format-string or log-injection path. No authn/authz changes, no new network calls, no credential logging, no unsafe defaults, and no missing validation that would let an attacker bypass a security boundary. The guard loosening is a functional/correctness change; it does not open a trust boundary. </details> <details><summary><b>🎯 Correctness</b> — No material issues found</summary> **No material issues found** I read `agent/agent.go` and `agent/hooks_test.go` in full, plus `llm/tool.go` to verify `ToolResult` and `ExecuteTool` semantics. I also traced the guard logic against the `TestSameCallRepeatGuard` and `TestSameCallRepeatGuardProgressAware` test cases. The change is logically correct: 1. **Result-key construction** (`resKey`) correctly distinguishes error vs. success via the `"e\x00"` prefix, so an identical call that flips from error to success (or vice-versa) resets its count rather than accumulating across the state change. 2. **Count semantics** are preserved: `callCounts[sig]` starts at 1 for the first occurrence of a given `(sig, resKey)` pair, so a limit of `N` still trips on the `(N+1)`st identical call with an unchanged result — same threshold as before. 3. **Ordering invariant** (`len(results) == len(resp.ToolCalls)`) holds because every iteration of the inner loop appends exactly one result (either an "unknown tool" error result or the output of `ExecuteTool`), and the guard runs only after that loop completes. The defensive `if i < len(results)` is therefore always true in normal operation. 4. **No false positives introduced** — the new behavior is strictly a subset of the old behavior. A genuinely stuck loop still trips; a progressing poller does not. 5. **Tests cover both paths**: advancing results run past the limit, frozen results still trip. One observation that stays within my lane: the `if i < len(results)` guard at `agent/agent.go:373` is technically unreachable given the invariant stated in the comment directly above it. It is harmless, but if the invariant were ever broken by a future edit, the code would silently mis-count instead of failing loudly. I mention this only as a note; it does not change my verdict. </details> <details><summary><b>🧹 Code cleanliness & maintainability</b> — No material issues found</summary> No material issues found **Minor issues** - `agent/agent.go:286` — `callCounts` is a stale name. It used to count every identical `(name, arguments)` call; after the fix it counts only identical calls whose **result is also unchanged**. A reader debugging a loop trip will see `callCounts[sig]++` and think it increments on every repeat call, not every *stuck* repeat call. Rename to `sameResultStreak` or `unchangedResultCount` to match the new semantics. - `agent/agent.go:373` — The `i < len(results)` guard is unnecessary and creates confusion. The preceding loop appends **exactly one** `results` entry per `resp.ToolCalls` entry (unknown tools append an error result before `continue`, known tools append `ExecuteTool`'s result, and the only early return is full context cancellation). By the time the repeat-guard loop runs, `len(results) == len(resp.ToolCalls)` is guaranteed. The bounds check makes a future reader doubt that invariant. Remove it; if defensiveness is desired, use an explicit `panic` assertion instead of a silent skip. </details> <details><summary><b>⚡ Performance</b> — No material issues found</summary> No material issues found. From a performance lens, the change is clean: - **No hot-path regressions:** the added work (string key construction, two map lookups, one comparison) runs once per tool call *after* tool execution, not inside the model.Generate loop or any blocking I/O path. The dominant cost by orders of magnitude is the LLM round-trip, so this bookkeeping is negligible. - **Memory growth is bounded per run:** `lastResults` stores exactly one previous result string per distinct `(name, arguments)` signature seen in the run. For the intended polling use case this is O(1) extra memory; even in the worst case (many distinct signatures) it is bounded by the step limit and tool-call count, with no unbounded accumulation across runs (both maps are local to `Run`). - **No extra allocations in the disabled case:** when `maxSameCallRepeats == 0` the new block is skipped entirely. One minor, non-blocking observation: `resKey` is built as `"e\x00" + results[i].Content` for errors, which copies the full `Content` string into a new allocation. If a tool returns an extremely large error payload, that copy is retained in `lastResults` for the signature. This is a necessary trade-off to enable progress-aware comparison, and it only affects runs with the guard enabled. </details> <details><summary><b>🧯 Error handling & edge cases</b> — No material issues found</summary> No material issues found. I examined the diff through the **error-handling & edge cases** lens and verified the actual checked-out code. The progress-aware same-call repeat guard is sound: - **Index pairing invariant**: `results[i]` is accessed after the execution loop that appends exactly one result per `resp.ToolCalls` element (unknown tools append an error result before `continue`; the only early exit is a full `return` on context cancellation). I confirmed this by reading the execution loop at `agent/agent.go:336-353`. - **Nil/null results**: `ExecuteTool` in `llm/tool.go` serializes `nil` handler returns to the string `"null"`, so repeated nil returns correctly compare as identical and still count toward the guard. Panics are recovered into `IsError` results with stable content strings. - **Empty collections**: Empty `Content` (e.g., a tool returning `""`) produces `resKey = ""`; repeated empty strings compare equal and count as repeating, which is correct. - **Map key ordering**: `json.Marshal` in `ExecuteTool` sorts map keys, so `map[string]any` results are deterministically serialized; the string comparison in `resKey` is safe. - **Off-by-one / boundary**: The new code resets `callCounts[sig] = 1` on any result change, then checks `> maxSameCallRepeats`. When the result never changes, the count progression is identical to the old behavior (verified algebraically), so existing tests remain valid. - **Defensive `i < len(results)`**: This check is redundant given the invariant but harmless; it does not mask a real bug because the invariant is enforced by the immediately preceding loop structure. The guard correctly distinguishes advancing results (polling) from frozen results (stuck loops), with no unhandled edge cases in error handling, nil safety, or boundary conditions. </details> </details> </details> <sub>Automated adversarial review by Gadfly — consensus across the model swarm. Advisory only — does not block merge.</sub>
steve added 1 commit 2026-07-18 23:11:40 +00:00
review(agent): address gadfly on progress-aware guard
CI / Build & Test (pull_request) Successful in 9m43s
CI / Tidy (pull_request) Successful in 9m24s
9922166d7a
Merge the parallel callCounts/lastResults maps into one repeatState struct
map (removes the "two maps in sync" smell + double lookup), drop the dead
i<len(results) bounds branch that contradicted the documented index invariant,
and note in-code that exact-string result equality is a deliberate err-toward-
not-tripping choice (a hung job whose poll reports a ticking field is left to
MaxRuntime / the job ceiling rather than risking a false kill of real progress).
No behavior change; guard tests still green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01HgEuVfZJN9mhRhzEsMEVog
steve merged commit a941f5ff4a into main 2026-07-18 23:22:10 +00:00
steve deleted branch fix/progress-aware-same-call-guard 2026-07-18 23:22:10 +00:00
Sign in to join this conversation.
No Reviewers
No labels
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: steve/majordomo#21