Closes two gaps surfaced by a live fan-out smoke in a downstream host (mort). Both are additive and backward-compatible; suggested release: v0.1.10.
1. Single-loop max-steps salvage (opt-in)
A single-loop run that exhausts its step budget (ErrMaxSteps / ErrToolLoop) returned empty Output + a hard error, discarding all the reasoning it produced — unlike the phased path, which already salvages a partial transcript and continues.
New Defaults.SalvageSingleLoopMaxSteps (default false): on budget exhaustion with empty Output, reconstruct a best-effort answer from the step narration (reusing salvagePhaseTranscript) and downgrade the run to a successful partial result with a note. Off by default so a structured-output host (e.g. a bounded review swarm) keeps its clean hard error, where partial prose masquerading as a valid answer would be worse than a loud failure; an interactive host (research) sets it true.
The kernel holds the full majordomo *agent.Result (with per-step Response narration) right at the single-loop error point, so salvage reuses the existing phased machinery with no new plumbing.
2. Enforce MaxToolCalls
RunnableAgent bounded a run by MaxIterations (steps) + MaxRuntime only — but one step can dispatch several tool calls, so a host's "max tool calls" cap had nowhere to land.
New RunnableAgent.MaxToolCalls (<= 0 = unlimited, backward-compatible): the step observer counts executed calls and, once the cap is reached, stepCeilingOption drives the step ceiling down to force the loop to exit at the next boundary; the resulting ErrMaxSteps is relabeled to the new exported ErrMaxToolCalls sentinel. A cap hit is treated as budget exhaustion, so salvage recovers its partial reasoning too. Enforced single-loop only for now (phased is a documented follow-up). Also fixes RunStateAccessor to report the real tool-call cap (was hardcoded 0).
isPhaseBudgetExhaustion → shared isBudgetExhaustion (now also matches ErrMaxToolCalls), so the phased and single-loop paths never drift on what counts as exhaustion.
Safety / compatibility
New optional DTO field, new opt-in Defaults flag, new exported sentinel — a run that sets none reproduces prior behavior exactly (stepCeilingOption with maxCalls<=0 and no critic == the old WithMaxSteps(base)).
No new dependencies; go mod tidy clean; whole suite green (go test ./...).
Tests (run/salvage_test.go)
salvage on (recovers narration) / off (clean ErrMaxSteps) / no-prose (still errors); MaxToolCalls forces exit + surfaces ErrMaxToolCalls; cap + salvage recovers; MaxToolCalls<=0 unbounded (regression); cap overrides a critic's raised ceiling.
Closes two gaps surfaced by a live fan-out smoke in a downstream host (mort). Both are additive and backward-compatible; **suggested release: v0.1.10**.
## 1. Single-loop max-steps salvage (opt-in)
A single-loop run that exhausts its step budget (`ErrMaxSteps` / `ErrToolLoop`) returned **empty Output + a hard error**, discarding all the reasoning it produced — unlike the phased path, which already salvages a partial transcript and continues.
New `Defaults.SalvageSingleLoopMaxSteps` (**default false**): on budget exhaustion with empty Output, reconstruct a best-effort answer from the step narration (reusing `salvagePhaseTranscript`) and downgrade the run to a successful **partial** result with a note. Off by default so a structured-output host (e.g. a bounded review swarm) keeps its clean hard error, where partial prose masquerading as a valid answer would be worse than a loud failure; an interactive host (research) sets it true.
The kernel holds the full majordomo `*agent.Result` (with per-step `Response` narration) right at the single-loop error point, so salvage reuses the existing phased machinery with **no new plumbing**.
## 2. Enforce `MaxToolCalls`
`RunnableAgent` bounded a run by `MaxIterations` (steps) + `MaxRuntime` only — but one step can dispatch several tool calls, so a host's "max tool calls" cap had nowhere to land.
New `RunnableAgent.MaxToolCalls` (**`<= 0` = unlimited**, backward-compatible): the step observer counts executed calls and, once the cap is reached, `stepCeilingOption` drives the step ceiling down to force the loop to exit at the next boundary; the resulting `ErrMaxSteps` is relabeled to the new exported **`ErrMaxToolCalls`** sentinel. A cap hit is treated as budget exhaustion, so salvage recovers its partial reasoning too. Enforced **single-loop only** for now (phased is a documented follow-up). Also fixes `RunStateAccessor` to report the real tool-call cap (was hardcoded `0`).
`isPhaseBudgetExhaustion` → shared **`isBudgetExhaustion`** (now also matches `ErrMaxToolCalls`), so the phased and single-loop paths never drift on what counts as exhaustion.
## Safety / compatibility
- New optional DTO field, new opt-in `Defaults` flag, new exported sentinel — a run that sets none reproduces prior behavior **exactly** (`stepCeilingOption` with `maxCalls<=0` and no critic == the old `WithMaxSteps(base)`).
- No new dependencies; `go mod tidy` clean; whole suite green (`go test ./...`).
## Tests (`run/salvage_test.go`)
salvage on (recovers narration) / off (clean `ErrMaxSteps`) / no-prose (still errors); `MaxToolCalls` forces exit + surfaces `ErrMaxToolCalls`; cap + salvage recovers; `MaxToolCalls<=0` unbounded (regression); cap overrides a critic's raised ceiling.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01Q1eJh5NVR11z6RzyxLANMK
Two gaps surfaced by a live fan-out smoke in a downstream host (mort):
1) A single-loop run that exhausts its step budget (ErrMaxSteps / ErrToolLoop)
returned empty Output + a hard error, discarding all the reasoning it had
produced — unlike the phased path, which already salvages a partial
transcript and continues. Add opt-in single-loop salvage
(Defaults.SalvageSingleLoopMaxSteps, default off): on budget exhaustion with
empty Output, reconstruct a best-effort answer from the step narration
(reusing salvagePhaseTranscript) and downgrade the run to a successful
partial result. Off by default so a structured-output host keeps its clean
hard error; an interactive host opts in.
2) RunnableAgent carried no tool-call ceiling — MaxIterations bounds STEPS, but
one step can dispatch several tool calls, so a host's "max tool calls" cap
had nowhere to land. Add RunnableAgent.MaxToolCalls (<=0 = unlimited,
backward-compatible): the step observer counts executed calls and, once the
cap is reached, stepCeilingOption forces the loop to exit; the resulting
ErrMaxSteps is relabeled to the new ErrMaxToolCalls sentinel. Enforced
single-loop only for now (phased is a follow-up). A cap hit is budget
exhaustion, so salvage recovers its partial reasoning too.
isPhaseBudgetExhaustion becomes the shared isBudgetExhaustion (now also matching
ErrMaxToolCalls) so the phased and single-loop paths never drift, and the
RunStateAccessor now reports the real tool-call cap (was hardcoded 0).
Additive + backward-compatible: new optional DTO field, new opt-in Defaults
flag, new exported sentinel; unlimited/off reproduce prior behavior exactly. No
new dependencies (go mod tidy clean). Tests cover salvage on/off/no-prose, cap
enforcement + sentinel, cap+salvage, unlimited no-op, and cap-overrides-critic.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Q1eJh5NVR11z6RzyxLANMK
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-17 19:45:16Z
#### `claude-code/opus` · claude-code — ✅ done
- ✅ **security** — No material issues found
- ✅ **correctness** — No material issues found
- ✅ **maintainability** — Minor issues
- ✅ **performance** — No material issues found
- ✅ **error-handling** — Minor issues
#### `claude-code/sonnet` · claude-code — ✅ 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
#### `deepseek-v4-pro:cloud` · ollama-cloud — ✅ 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** — No material issues found
- ✅ **maintainability** — No material issues found
- ✅ **performance** — No material issues found
- ✅ **error-handling** — Minor issues
#### `kimi-k2.6:cloud` · ollama-cloud — ✅ done
- ✅ **security** — No material issues found
- ✅ **correctness** — Blocking 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>
🪰Gadfly consensus review — 7 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** — 7 inline findings on changed lines. See the consensus comment for the full ranked summary.
<sub>Advisory only — does not block merge.</sub>
🟡 *stepCeilingOption couples to executor locals via two bare int params; cap logic split across three sites
maintainability · flagged by 2 models
run/critic.go:130 — stepCeilingOption takes two raw *int into executor-loop locals. The method now reaches into toolCallsSeen/stepsSeen (declared at executor.go:393) via pointers, while the crossing detection + capHit logging live back in the step observer (executor.go:421-435) and the relabel lives further down. So MaxToolCalls enforcement is spread across three sites coupled by shared mutable state. It's thoroughly commented and the same-goroutine invariant is stated, s…
🪰 Gadfly · advisory
🟡 **stepCeilingOption couples to executor locals via two bare *int params; cap logic split across three sites**
_maintainability · flagged by 2 models_
- **`run/critic.go:130` — `stepCeilingOption` takes two raw `*int` into executor-loop locals.** The method now reaches into `toolCallsSeen`/`stepsSeen` (declared at `executor.go:393`) via pointers, while the crossing detection + `capHit` logging live back in the step observer (`executor.go:421-435`) and the relabel lives further down. So `MaxToolCalls` enforcement is spread across three sites coupled by shared mutable state. It's thoroughly commented and the same-goroutine invariant is stated, s…
<sub>🪰 Gadfly · advisory</sub>
🔴Resumed runs bypass MaxToolCalls cap because toolCallsSeen is not restored from checkpoint
correctness · flagged by 1 model
run/executor.go:393 — Resumed runs bypass the MaxToolCalls cap. toolCallsSeen is declared as a local int at line 393 and is initialized to 0 on every Executor.Run invocation. When a run is resumed from a checkpoint (lines 522–528), toolCallsSeen is not restored from RunCheckpointState (which only persists Messages and Iteration). Consequently, a resumed execution starts counting tool calls from zero, allowing the total across the original + resumed execution to exceed…
🪰 Gadfly · advisory
🔴 **Resumed runs bypass MaxToolCalls cap because toolCallsSeen is not restored from checkpoint**
_correctness · flagged by 1 model_
- **`run/executor.go:393`** — Resumed runs bypass the `MaxToolCalls` cap. `toolCallsSeen` is declared as a local `int` at line 393 and is initialized to 0 on every `Executor.Run` invocation. When a run is resumed from a checkpoint (lines 522–528), `toolCallsSeen` is **not** restored from `RunCheckpointState` (which only persists `Messages` and `Iteration`). Consequently, a resumed execution starts counting tool calls from zero, allowing the total across the original + resumed execution to exceed…
<sub>🪰 Gadfly · advisory</sub>
🟡toolCallsSeen/stepsSeen accumulated unconditionally for phased runs where they are never read
maintainability · flagged by 1 model
🪰 Gadfly · advisory
🟡 **toolCallsSeen/stepsSeen accumulated unconditionally for phased runs where they are never read**
_maintainability · flagged by 1 model_
<sub>🪰 Gadfly · advisory</sub>
🟡Single-loop salvage downgrades ErrToolLoop (tool-error guard, a stuck run) to a successful partial result
error-handling · flagged by 1 model
run/executor.go:626 / run/phases.go:286 — ErrToolLoop gets downgraded to a "successful partial" by single-loop salvage.isBudgetExhaustion includes agent.ErrToolLoop, which is the tool-error guard (consecutive tool errors / repeated identical calls) — a signal of a stuck/malfunctioning run, not merely a spent step budget. With SalvageSingleLoopMaxSteps on, such a run is now relabeled runErr = nil, status ok, and delivered as a partial answer. This is opt-in and mirrors th…
🪰 Gadfly · advisory
🟡 **Single-loop salvage downgrades ErrToolLoop (tool-error guard, a stuck run) to a successful partial result**
_error-handling · flagged by 1 model_
- **`run/executor.go:626` / `run/phases.go:286` — `ErrToolLoop` gets downgraded to a "successful partial" by single-loop salvage.** `isBudgetExhaustion` includes `agent.ErrToolLoop`, which is the tool-error guard (consecutive tool errors / repeated identical calls) — a signal of a *stuck/malfunctioning* run, not merely a spent step budget. With `SalvageSingleLoopMaxSteps` on, such a run is now relabeled `runErr = nil`, status `ok`, and delivered as a partial answer. This is opt-in and mirrors th…
<sub>🪰 Gadfly · advisory</sub>
🟠Salvage note says 'step budget' even for tool-call-cap exhaustion
correctness, error-handling, maintainability · flagged by 4 models
run/executor.go:631 — Misleading salvage note for tool-call exhaustion (verified). When the single-loop salvage path fires for an ErrMaxToolCalls exit, the appended note hardcodes "reached its step budget before composing a final answer". But the actual cause was the tool-call budget, not the step budget. The condition at line 626–628 gates on isBudgetExhaustion(runErr), which matches both ErrMaxSteps and ErrMaxToolCalls (the latter was relabeled at line 535–537). A host that…
🪰 Gadfly · advisory
🟠 **Salvage note says 'step budget' even for tool-call-cap exhaustion**
_correctness, error-handling, maintainability · flagged by 4 models_
- **`run/executor.go:631` — Misleading salvage note for tool-call exhaustion (verified).** When the single-loop salvage path fires for an `ErrMaxToolCalls` exit, the appended note hardcodes `"reached its step budget before composing a final answer"`. But the actual cause was the *tool-call* budget, not the step budget. The condition at line 626–628 gates on `isBudgetExhaustion(runErr)`, which matches both `ErrMaxSteps` and `ErrMaxToolCalls` (the latter was relabeled at line 535–537). A host that…
<sub>🪰 Gadfly · advisory</sub>
⚪isBudgetExhaustion is cross-file shared but defined in phases.go, one of its two consumers
maintainability · flagged by 1 model
🪰 Gadfly · advisory
⚪ **isBudgetExhaustion is cross-file shared but defined in phases.go, one of its two consumers**
_maintainability · flagged by 1 model_
<sub>🪰 Gadfly · advisory</sub>
⚪ErrMaxToolCalls is a budget sentinel, not a port/seam definition, misplaced in ports.go
maintainability · flagged by 1 model
🪰 Gadfly · advisory
⚪ **ErrMaxToolCalls is a budget sentinel, not a port/seam definition, misplaced in ports.go**
_maintainability · flagged by 1 model_
<sub>🪰 Gadfly · advisory</sub>
I've verified the key paths. Let me confirm the salvage source only pulls model narration (not tool outputs or system prompt) and check there's no pre-delivery security control being bypassed.
The verification is conclusive:
salvagePhaseTranscript (run/phases.go:301-327) concatenates only step.Response.Text() — the model's own narration — never tool results, system prompt, or raw untrusted input. So the salvage path introduces no new leakage of privileged data.
The salvaged Output skips ResultSchema and FinalGuard (both gated on runErr == nil), but those are delivery/validation seams, not security controls — no authz/redaction gate is bypassed.
MaxToolCalls == 0 (unlimited) is a resource bound only; MaxIterations and MaxRuntime still bound the run, so the default doesn't remove any DoS ceiling.
New LogEvent payloads carry only the wrapped sentinel error string and integer counts — no secrets.
VERDICT: No material issues found
Reviewed through the Security lens (authn/authz, injection, SSRF, deserialization, secret leakage, input validation, insecure defaults, tainted-data flow).
This change is internal control-flow over model-produced narration and integer step/tool-call counters. There is no untrusted-input → dangerous-sink path: no SQL/command/path/template construction, no network egress, no deserialization of external data, no credential handling.
Salvage delivers un-guarded model narration, but that's not a new security boundary — verified salvagePhaseTranscript reads only assistant Response.Text(), and salvage is opt-in (SalvageSingleLoopMaxSteps default false), off precisely for structured-output/review hosts. No system-prompt or tool-output content is exposed that the normal success path wouldn't already deliver.
Insecure-default check:MaxToolCalls <= 0 == unlimited is backward-compatible and does not disable the still-present MaxIterations/MaxRuntime bounds, so it introduces no unbounded-execution/DoS regression.
Log payloads (single_loop_salvaged, max_tool_calls_reached) contain only the sentinel error string and call counts — no secret/credential leakage.
Nothing in my lane is materially wrong here.
🎯 Correctness — No material issues found
I've verified the core mechanism against the executus code. I could not reach the majordomo module source through the sandbox, but the step-ceiling contract this PR relies on (WithMaxStepsFunc polled each iteration, stepIdx < ceiling) is the same one the pre-existing critic path already depends on, so it's an established invariant rather than a new assumption.
VERDICT: No material issues found
I traced the tool-call cap arithmetic and the salvage/relabel control flow carefully:
Cap off-by-one is correct. For 1 call/step: cap=N stops after exactly N calls. Trace for cap=2 (executor.go:396-435, critic.go:130-145): step0→toolCallsSeen=1, step1→toolCallsSeen=2 sets capHit; the next poll returns stepsSeen=2, so stepIdx 2 < 2 fails → ErrMaxSteps → relabeled. cap=1 yields exactly 1 call. Verified against TestMaxToolCalls_ForcesExitAndSurfacesSentinel.
Relabel is sound.capHit && errors.Is(runErr, agent.ErrMaxSteps) (executor.go:535) can only be true on a cap-driven exit: a capped step carries n>0 tool calls so it never doubles as a final-answer turn, and when capHit is set the ceiling func returns stepsSeen (≤ base), so the driven-down ceiling always fires at/before the natural one. Natural step exhaustion (cap not reached) keeps plain ErrMaxSteps — confirmed capHit stays false when toolCallsSeen < maxCalls.
Backward-compat no-op holds. With maxCalls<=0 and b==nil, stepCeilingOption falls through to return base — equivalent to the old WithMaxSteps(base), and the critic path already exercised WithMaxStepsFunc for the base fallback. MaxToolCalls gating (len(ra.Phases)==0) keeps phased runs inert; phased uses its own ceiling, so the accumulating counters are harmless there.
isBudgetExhaustion / salvage correctly matches ErrMaxToolCalls, and salvage is reachable only on runErr != nil (ResultSchema/FinalGuard are runErr==nil-gated). RunStateAccessor now reports the real cap (runengine.go:151). Imports (fmt/errors/strings) all present.
Two sub-material notes (not in scope to block):
Trivial (wording):executor.go:631 — when salvage fires after a tool-call-cap exit (ErrMaxToolCalls), the appended note still says "reached its step budget". Slightly misleading to the end user; the tool-call cap isn't a step budget. The LogEvent records the true error, so it's cosmetic.
Outside my lens (durability, low confidence): on a checkpoint-resume the toolCallsSeen counter starts at 0, so the "TOTAL … across the whole run" wording on MaxToolCalls (agent.go:40) isn't literally honored across a resume — but MaxIterations resets the same way on resume, so this is consistent with existing bounding semantics, not a regression this PR introduces.
🧹 Code cleanliness & maintainability — Minor issues
All three findings are confirmed against the actual code:
salvagePhaseTranscript (phases.go:301) has exactly two call sites — phases.go:252 and executor.go:629 — so the Phase in the name is indeed now stale for the shared single-loop path.
executor.go:631 hardcodes the "step budget" note, and the enclosing block at line 627 gates on isBudgetExhaustion, which now includes ErrMaxToolCalls — so the note is reachable for a tool-call-cap exit.
stepCeilingOption (critic.go:130) takes two bare *int into toolCallsSeen/stepsSeen (executor.go:393), with the crossing/capHit logging in the observer (421-435) and the relabel further down — the cross-site mutable coupling is real.
All findings survive.
VERDICT: Minor issues
The change is clean, well-factored, and heavily documented. It reuses salvagePhaseTranscript and folds the two exhaustion predicates into one shared isBudgetExhaustion rather than duplicating logic — good moves for maintainability. The NewRunStateAccessor fix (real maxCalls instead of hardcoded 0) is a genuine correctness-of-reporting cleanup, and its signature already had the param, so no ripple. Within my lens I found only small nits:
run/phases.go:301 — salvagePhaseTranscript name is now stale. The function is no longer phase-specific: run/executor.go:629 calls it for single-loop salvage, and the shared-predicate doc at run/phases.go:284 even says "Shared by the phased pipeline and the single-loop salvage." The Phase in the name now misleads a reader into thinking it's phased-only. Low-churn fix: rename to salvageTranscript (it's unexported, so a mechanical rename across phases.go/executor.go). Verified by grep: only these two call sites. Confidence: high; severity: trivial.
run/executor.go:631 — salvage note says "step budget" even for a tool-call-cap exit. The same block handles ErrMaxToolCalls (via isBudgetExhaustion), but the user-facing note is hardcoded "reached its step budget before composing a final answer". For a run that exhausted MaxToolCalls (not steps), that wording is inaccurate. Minor because it's best-effort prose, but since the PR's whole point is to distinguish the two causes (new ErrMaxToolCalls sentinel), the note undercuts that distinction. Verified: the block is reached for both sentinels. Confidence: high; severity: small.
run/critic.go:130 — stepCeilingOption takes two raw *int into executor-loop locals. The method now reaches into toolCallsSeen/stepsSeen (declared at executor.go:393) via pointers, while the crossing detection + capHit logging live back in the step observer (executor.go:421-435) and the relabel lives further down. So MaxToolCalls enforcement is spread across three sites coupled by shared mutable state. It's thoroughly commented and the same-goroutine invariant is stated, so it works and isn't blocking — but a small struct (e.g. a toolBudget{seen, steps int} passed by pointer, or a closure built in executor.go) would make the coupling explicit and read better than two bare *int params. Confidence: medium (design judgment); severity: small.
Not flagged (checked and fine): the maxStepsOption→stepCeilingOption rename correctly preserves the b == nil behavior via the inner if b != nil guard; the isPhaseBudgetExhaustion→isBudgetExhaustion rename hits all call sites (phases.go:251, phases.go case at 159, executor.go:627); tests are clear and well-named.
⚡ Performance — No material issues found
VERDICT: No material issues found
I reviewed this change strictly through the Performance lens and verified the hot-path implications against the checked-out code.
What I checked:
Per-step tool-call accounting (run/executor.go:391 step observer: toolCallsSeen += n, stepsSeen = s.Index + 1, one comparison + one-shot capHit flag). This is O(1) per step and runs on majordomo's existing Run goroutine — no new goroutine, no locking, no allocation. The LogEvent fires at most once (guarded by !capHit). No regression.
stepCeilingOption now always uses WithMaxStepsFunc (run/critic.go:130-145), whereas the no-critic case previously used the static WithMaxSteps(base). The closure is invoked once per step boundary and does only a couple of int derefs/comparisons. Steps map to LLM round-trips (seconds each), so this is immeasurable overhead — a micro-optimization at most, not a material regression, and I'm deliberately not flagging it as such.
salvagePhaseTranscript (run/phases.go:301-327), now reused by the single-loop salvage path. Verified it uses strings.Builder (no quadratic += concat), iterates each step's narration once, and tail-trims to the bounded maxSalvageBytes = 8000. It runs at most once, only on the terminal error path — not in any loop. No unbounded growth.
isBudgetExhaustion (run/phases.go:286) adds one more errors.Is — constant-time, called on error paths only.
No N+1, no work added inside the agent loop's hot path, no unnecessary copies/allocations, no unbounded accumulation, and no blocking calls introduced. The tool-call cap itself is a performance win (bounds total work). Nothing material in my lane.
🧯 Error handling & edge cases — Minor issues
Both findings verify against the actual code. isBudgetExhaustion (phases.go:286-289) includes agent.ErrToolLoop, and the single-loop salvage block (executor.go:626-637) does relabel runErr = nil and deliver the partial. The hardcoded "reached its step budget" note is at executor.go:631. Both survive.
VERDICT: Minor issues
run/executor.go:626 / run/phases.go:286 — ErrToolLoop gets downgraded to a "successful partial" by single-loop salvage.isBudgetExhaustion includes agent.ErrToolLoop, which is the tool-error guard (consecutive tool errors / repeated identical calls) — a signal of a stuck/malfunctioning run, not merely a spent step budget. With SalvageSingleLoopMaxSteps on, such a run is now relabeled runErr = nil, status ok, and delivered as a partial answer. This is opt-in and mirrors the pre-existing phased behavior (the old isPhaseBudgetExhaustion already included ErrToolLoop), so it's defensible for an interactive host — but the unhappy-path semantics are worth a conscious call-out: a genuinely broken tool loop is presented as success. Low severity given the opt-in gate; flagging so it's an intentional choice, not an accident.
run/executor.go:631 — salvage note hardcodes "reached its step budget" for every exhaustion cause, including an ErrMaxToolCalls cap. When the exit was the tool-call cap (or ErrToolLoop), the user-facing note still says "reached its step budget before composing a final answer," which misdescribes the actual cause. Trivial/cosmetic — behavior is correct, only the disclaimer wording is imprecise. Suggest wording it generically (e.g. "reached its budget") or branching on the cause.
Nothing blocking in the error-handling / edge-case lens: nil-safety, the forced-exit accounting, empty-output guards, and error preservation are all handled carefully and are covered by the new tests.
The changes introduce a new MaxToolCalls cap, a SalvageSingleLoopMaxSteps opt-in flag, and a renamed/extended isBudgetExhaustion function. Through the security lens:
Audit log data: rec.LogEvent("single_loop_salvaged", map[string]any{"error": runErr.Error()}) at executor.go:633 — runErr at that point is one of the pre-defined sentinels (ErrMaxToolCalls, ErrMaxSteps, ErrToolLoop), all hardcoded strings derived from the system, not user input. No credential or user-data leakage risk.
MaxToolCalls field: Comes from RunnableAgent, which is a host-controlled DTO, not a user-supplied field at a system boundary. maxCalls is only ever interpolated as %d into an error string — no injection surface.
Salvaged transcript in output: salvagePhaseTranscript writes LLM response text into runRes.Output. This is the expected behavior of the package (the entire run loop processes and returns LLM output). The single-loop salvage path does not feed this into the phase template engine — it writes directly to runRes.Output after the template path has already completed.
isBudgetExhaustion adding ErrMaxToolCalls: This function is also called from the phased pipeline path. Verified (executor.go:480, 516–518) that stepCeilingOption is only wired inside the len(ra.Phases) == 0 branch, and the ErrMaxToolCalls relabeling (executor.go:535–537) only runs in that same branch — so ErrMaxToolCalls cannot propagate to the phased path under normal code flow.
Pointer parameters in stepCeilingOption: &toolCallsSeen and &stepsSeen are pointers to local ints read by the WithMaxStepsFunc closure. The PR asserts (and the step observer architecture confirms) these run on the same majordomo goroutine, so no race. No authn/authz or injection vector here.
🎯 Correctness — No material issues found
I've now traced every decision path through the critical new code. My analysis is complete.
Verdict: No material issues found
I examined the following in detail before forming this conclusion:
stepCeilingOption ceiling value (critic.go:131–144)
The function returns *steps (= s.Index + 1 of the capping step) when the cap fires. stepsSeen is set BEFORE the cap check inside the observer, so at poll time *steps >= 1. majordomo's loop check stepIdx < ceiling with stepIdx = completedSteps and ceiling = completedSteps correctly evaluates to false, stopping the loop. The return 1 fallback (steps == nil || *steps == 0) is unreachable in practice because toolCallsSeen > 0 requires at least one completed step, which means stepsSeen >= 1. Verified by tracing scenarios with 1–3 tool calls per step.
n counting and tool-call accumulation (executor.go:408–425) n = min(len(calls), len(s.Results)) is the pairwise zip count — exactly matching the audit recording loop above it. toolCallsSeen += n and stepsSeen = s.Index + 1 are both set before the cap detection check, so the cap-crossing observation is always based on the just-completed step. ✓
Relabeling and errors.Is chain (executor.go:535–536) fmt.Errorf("%w (max %d)", ErrMaxToolCalls, maxCalls) wraps ErrMaxToolCalls. After relabeling: errors.Is(runErr, ErrMaxToolCalls) = true; errors.Is(runErr, agent.ErrMaxSteps) = false (chain ends at ErrMaxToolCalls, which does not itself wrap ErrMaxSteps). isBudgetExhaustion correctly matches the relabeled error via the ErrMaxToolCalls branch. ✓
Phased-pipeline isolation capHit is gated on len(ra.Phases) == 0; stepCeilingOption is only wired in the single-loop path; the relabeling block is inside the if len(ra.Phases) == 0 scope. Phases can never produce ErrMaxToolCalls; its inclusion in isBudgetExhaustion is forward-compatible. ✓
Salvage ordering
The salvage block runs after ResultSchema and FinalGuard (both gated on runErr == nil), so they can never fire on a cap/budget error. The salvage condition isBudgetExhaustion(runErr) && runRes != nil && Output == "" is ordered correctly. ✓
RunStateAccessor fix (executor.go:218)
Was hardcoded 0; now passes maxCalls (the resolved ra.MaxToolCalls). When maxCalls == 0 (unlimited), this reproduces the old value. ✓
No-critic/no-cap case stepCeilingOption now always returns WithMaxStepsFunc instead of WithMaxSteps for the nil-critic path. The two are functionally equivalent for a constant-valued function, and the pre-existing critic tests rely on WithMaxStepsFunc already, confirming majordomo treats them identically. ✓
🧹 Code cleanliness & maintainability — Minor issues
All four findings are confirmed against the actual code. Here is the corrected review:
Verdict: Minor issues
Findings
1. Dead accumulation of toolCallsSeen/stepsSeen outside the phase guard — executor.go:425-426
toolCallsSeen += n and stepsSeen = s.Index + 1 run unconditionally inside stepObserver for both single-loop and phased runs. The comment at lines 422–424 says "gate the crossing detection on Phases", but only the capHit detection is gated (line 427) — the accumulation itself runs for every phased-run step too. stepCeilingOption (the only reader) is called exclusively inside if len(ra.Phases) == 0 (executor.go:518); the phased path uses agent.WithMaxSteps instead. So for phased runs, both variables are accumulated but never read.
Fix: move both accumulation lines inside the if len(ra.Phases) == 0 guard so the code matches the comment.
Severity: small — harmless dead work today, but the mismatch between comment and code will mislead anyone who later extends phased-run cap enforcement.
2. Misleading nil guards on non-nullable params in stepCeilingOption — critic.go:132-135
The closure checks toolCalls != nil && steps != nil before using them. There is exactly one call site (executor.go:518), which always passes &toolCallsSeen and &stepsSeen (addresses of stack-allocated ints, never nil). The nil guards suggest these are optional/nullable, but passing nil when maxCalls > 0 would silently disable the cap with no diagnostic. This is inconsistent with the rest of the file's nil-safety pattern (recordStep/recordToolStart nil-check the receiver, not the parameters).
Fix: remove the toolCalls != nil && steps != nil guards and, if defensive intent is desired, replace with a precondition comment or a panic ("contract: non-nil when maxCalls > 0"). The nil receiver (b == nil) is correct and should stay.
Severity: small — creates false API surface.
3. isBudgetExhaustion lives in phases.go but is a cross-file shared helper — phases.go:281
Its own doc comment says "Shared by the phased pipeline and the single-loop salvage in executor.go". It is called in executor.go (line 627) and twice in phases.go (lines 159, 251). Defining it in phases.go — a file named for one consumer — puts a package-level function in a misleading home.
Fix: move to executor.go (nearest to ErrMaxToolCalls wrapping) or to ports.go next to ErrMaxToolCalls. Same package, no compile impact, but the wrong home invites re-duplication.
Severity: trivial.
4. ErrMaxToolCalls sentinel in ports.go is not a port/seam definition — ports.go:25
ports.go defines Ports, seam interfaces, and ErrCriticKill (which belongs because it's the cancellation cause wrapping a Critic interface signal). ErrMaxToolCalls is an internal budget-enforcement sentinel with no connection to any seam. Its doc comment even references isBudgetExhaustion, a private helper. Placing it with the seam definitions is a loose grouping.
Fix: move to executor.go near the cap-relabeling logic, or to a small budget.go alongside the cap enforcement. Trivial change, purely organizational.
Severity: trivial.
⚡ Performance — No material issues found
I now have everything I need for a performance verdict. The key questions were:
Does the switch from WithMaxSteps to WithMaxStepsFunc for the unlimited case add meaningful overhead?
Does the per-step tool-call accounting add hot-path cost?
stepCeilingOption now always returns WithMaxStepsFunc — even when maxCalls <= 0 and b == nil. Previously this returned the static WithMaxSteps(base). At step granularity (each step = one full LLM round-trip, hundreds of milliseconds minimum), the extra function-pointer call and two pointer dereferences per iteration are completely dominated by network latency. Not a material regression.
toolCallsSeen += n and stepsSeen = s.Index + 1 run unconditionally in the step observer regardless of whether maxCalls > 0, meaning the counters accumulate even when they can never be used to trip the cap. Two integer assignments per step — trivially cheap relative to the rest of the observer body and the LLM call itself.
map[string]any{...} in LogEvent only fires when capHit is set, i.e. at most once per run. Not hot.
isBudgetExhaustion now has one extra errors.Is chain item. This only runs on the error path. Not hot.
No other allocations or quadratic behaviors introduced.
Verdict: No material issues found
The PR adds two integer counters and widens the WithMaxStepsFunc closure to handle the unlimited/no-critic case. Every new computation is O(1) per step and orders of magnitude cheaper than the LLM round-trips that dominate each step. There is no hot-loop regression, no new allocation on the happy path, and no unbounded growth.
toolCallsSeen/stepsSeen accumulate even when maxCalls <= 0 (they'll never be read by the ceiling function in that case). Verified at executor.go:425–426. Two int writes per step — cannot justify calling this material.
🧯 Error handling & edge cases — No material issues found
No material issues found
Examined through the error handling & edge cases lens: I read executor.go (single-loop path, step observer, salvage block, result construction), critic.go (the new stepCeilingOption), phases.go (isBudgetExhaustion, salvagePhaseTranscript), and the new test file. Key checks:
Nil safety on runRes: The salvage block correctly guards runRes != nil before calling salvagePhaseTranscript. runAgent wraps ag.Run() and can return (nil, ErrMaxSteps); the guard handles this without a panic. No issue.
Error chain semantics:runErr = fmt.Errorf("%w (max %d)", ErrMaxToolCalls, maxCalls) preserves the ErrMaxToolCalls sentinel via %w, so errors.Is(runErr, ErrMaxToolCalls) is true and isBudgetExhaustion correctly fires. errors.Is(relabeled, agent.ErrMaxSteps) is false, so the relabeling is clean. Verified in phases.go:286-290.
capHit false-positive risk:capHit is set only when toolCallsSeen >= maxCalls (step observer, single-loop only via len(ra.Phases)==0 gate). The relabeling capHit && errors.Is(runErr, agent.ErrMaxSteps) can only fire if the tool-call threshold was genuinely crossed — the two conditions together correctly pinpoint a cap-triggered exit. No false positive path found.
stepCeilingOption nil receiver: Called as critic.stepCeilingOption(...) where critic may be a nil *criticBinding. The method checks if b != nil before accessing b.h, so a nil receiver is safe. Confirmed at critic.go:130-145.
Ordering: relabeling → ResultSchema → FinalGuard → salvage: The relabeling runs immediately after runAgent; ResultSchema and FinalGuard are gated on runErr == nil and therefore skip on budget errors; salvage runs after both. runErr.Error() in the log call is evaluated before runErr = nil, so no nil dereference. Confirmed at executor.go:669-675 — result construction reads runRes.Output and runErr after the salvage block.
Plain int counters (toolCallsSeen, stepsSeen): Only accessed from the step observer goroutine and stepCeilingOption (both called on majordomo's Run goroutine). No atomics needed; no race path introduced.
isBudgetExhaustion in phases: Now includes ErrMaxToolCalls, which can never surface from the phased path today (capHit is gated on len(ra.Phases)==0). The addition is dead code currently but harmless and correct.
fakeCriticHandle/fakeCritic in tests: Defined in critic_test.go; reused by salvage_test.go — no missing type.
Nothing concerning in the unhappy paths introduced by this diff.
deepseek-v4-pro:cloud (ollama-cloud) — Minor issues
The diff is additive resource-limiting and error-recovery plumbing. Through the security lens:
No new injection vectors: The salvage path concatenates model-generated step narration (already produced by the model and present in the transcript) with a hardcoded note string. No user-controlled input flows into the reconstructed output. The salvagePhaseTranscript function is pre-existing and unchanged.
No authn/authz gaps: MaxToolCalls is a host-configured integer cap; SalvageSingleLoopMaxSteps is a host-configured boolean. Neither introduces a privilege or access-control decision.
No secret/credential leakage: The audit log events (max_tool_calls_reached, single_loop_salvaged) record only integer counters and sentinel error strings (ErrMaxToolCalls, ErrMaxSteps, ErrToolLoop) — no model output, tool arguments, or user data is logged.
No unsafe deserialization or SSRF: No new parsing, network calls, or external resource fetches are introduced.
No insecure defaults: MaxToolCalls <= 0 = unlimited (backward-compatible), and SalvageSingleLoopMaxSteps defaults to false (preserving the prior hard-error behavior). Both are safe zero-values.
Positive security impact: MaxToolCalls is itself a resource-limiting control, reducing the blast radius of a runaway tool-calling loop.
🎯 Correctness — Minor issues
The finding is confirmed. Let me verify the exact line numbers and the full chain:
Line 535-537: When capHit && errors.Is(runErr, agent.ErrMaxSteps), runErr is relabeled to wrap ErrMaxToolCalls.
Line 626-628: The salvage gate calls isBudgetExhaustion(runErr), which matches ErrMaxToolCalls (via errors.Is(err, ErrMaxToolCalls) at phases.go:289).
Line 631: The note unconditionally says "reached its step budget before composing a final answer" — even when the actual cause was the tool-call cap.
The test TestMaxToolCalls_SalvagedWhenEnabled (salvage_test.go:183-199) confirms the salvage path fires for tool-call exhaustion, but only asserts "partial finding" in the output, not the note text — so the misleading note is real and untested.
The finding survives verification.
Verdict: Minor issues
run/executor.go:631 — Misleading salvage note for tool-call exhaustion (verified). When the single-loop salvage path fires for an ErrMaxToolCalls exit, the appended note hardcodes "reached its step budget before composing a final answer". But the actual cause was the tool-call budget, not the step budget. The condition at line 626–628 gates on isBudgetExhaustion(runErr), which matches both ErrMaxSteps and ErrMaxToolCalls (the latter was relabeled at line 535–537). A host that delivers res.Output to the user will show a note blaming the wrong budget. The fix: either make the note generic ("reached its budget limit…") or branch on errors.Is(runErr, ErrMaxToolCalls) to say "reached its tool-call budget…".
🧹 Code cleanliness & maintainability — Minor issues
Both findings are confirmed against the actual code. The salvage note at executor.go:631 is indeed hardcoded to say "step budget" regardless of which of the three exhaustion sentinels fired, and salvagePhaseTranscript is now called from the single-loop path despite its phase-specific name and doc comment.
Verdict: Minor issues
run/executor.go:631 — Salvage note text is misleading for non-step-budget exhaustion. The hardcoded note "reached its step budget before composing a final answer" is attached to the salvaged output regardless of which exhaustion cause fired. isBudgetExhaustion matches three sentinels: ErrMaxSteps (step budget — accurate), ErrToolLoop (tool-error guard — not a step budget), and the new ErrMaxToolCalls (tool-call cap — not a step budget). For the latter two the user sees "step budget" when the real cause was something else. The phased path has the same pre-existing inaccuracy for ErrToolLoop, but this diff adds a new mismatch for ErrMaxToolCalls. The note should reflect the actual cause (e.g., branch on runErr to say "tool-call budget" vs "step budget" vs "tool-error guard"). Verified by reading executor.go:626-637 and phases.go:286-290.
run/phases.go:301 — salvagePhaseTranscript name is now misleading. The function was written for the phased pipeline (its doc comment says "reconstructs a best-effort phase output"), but this diff now calls it from the single-loop salvage path in executor.go:629. A reader encountering the call site has to chase the definition to realize it's not phase-specific. Consider renaming to salvageTranscript (or similar) since it's now shared across both paths. Verified by reading phases.go:297-327 and executor.go:629.
⚡ Performance — No material issues found
Verdict: No material issues found
The changes are performance-neutral. I verified the hot paths by reading the full files:
stepCeilingOption closure (run/critic.go:130-144): Called once per step by majordomo. The closure body is O(1) — two integer comparisons, two nil checks, two pointer dereferences. The old maxStepsOption was also called per step (when a critic was bound). The only micro-regression is that the no-critic + no-cap case now uses WithMaxStepsFunc instead of the static WithMaxSteps(base), adding ~two nil checks per step. In the context of LLM calls taking seconds, this is negligible.
Step observer additions (run/executor.go:425-435): toolCallsSeen += n, stepsSeen = s.Index + 1, and a conditional cap-hit check — all O(1) per step, no allocations, no blocking.
Salvage path (run/executor.go:626-637): Runs once at end-of-run on the error path only. salvagePhaseTranscript iterates res.Steps (bounded by MaxIterations, default 12) and uses strings.Builder — efficient and bounded.
isBudgetExhaustion (run/phases.go:286-289): Called per-phase (not per-step), O(1) errors.Is checks.
RunStateAccessor (run/runengine.go:131): Now receives the real maxCalls instead of hardcoded 0 — a field assignment, no runtime cost change.
No N+1 queries, no unnecessary allocations in hot paths, no unbounded growth, no blocking calls, no quadratic behavior introduced.
🧯 Error handling & edge cases — Minor issues
The finding is confirmed. The salvage note at executor.go:631 is hardcoded to say "step budget" regardless of which budget was exhausted. The isBudgetExhaustion function (phases.go:286-289) matches ErrMaxToolCalls, so the salvage path fires for tool-call cap exhaustion too — but the user-facing text still blames the step budget. This is a real, though small, UX issue.
Verdict: Minor issues
run/executor.go:630-631 — Salvage note text is misleading for ErrMaxToolCalls (verified by reading executor.go:626-637 and phases.go:286-289). The hardcoded note says "reached its step budget" regardless of which budget was exhausted. When the run hits MaxToolCalls (relabeled at line 536), isBudgetExhaustion matches it (line 289), so the salvage path fires — but the user-facing text still blames the step budget. A host or user reading the salvaged output sees "step budget" when the real cause was the tool-call cap, which is confusing. The fix is to vary the note based on the error: e.g., check errors.Is(runErr, ErrMaxToolCalls) and say "tool-call budget" instead.
This is a small UX edge-case in the error path; functionally the salvage and downgrade work correctly either way.
Salvage bypasses ResultSchema validation (intentional, default-off): At executor.go:626-636, the single-loop salvage path runs only when runErr != nil, while ResultSchema validation at executor.go:544 is gated on runErr == nil — so a salvaged partial answer is delivered as a successful result (runErr = nil) without ever passing schema validation. If a host were to set both SalvageSingleLoopMaxSteps: true and inv.ResultSchema, unvalidated model narration would be marked "ok" and handed to the host as a valid answer. This is a documented design tradeoff (salvage is meant for interactive research hosts; structured-output hosts keep it off, and the flag defaults to false), so it is not a vulnerability — but there is no guard preventing the two from being combined. Verified by reading executor.go:544 and executor.go:626. Severity: trivial / out-of-lane-adjacent; no enforcement gap worth blocking on.
MaxToolCalls is host-controlled config, not untrusted input:RunnableAgent.MaxToolCalls (agent.go:40) is set by the host, with <= 0 meaning unlimited. No untrusted-user input reaches it; negative values are handled as a no-op rather than misinterpreted as a ceiling. No validation gap.
No injection / credential surface: Salvage concatenates the model's own step narration (salvagePhaseTranscript, phases.go:301) plus a fixed English note into Output; no template expansion, no shell/SQL/path interpolation. The logged runErr.Error() in single_loop_salvaged is a budget-error sentinel string, not user/model-supplied free text — minimal leakage surface. Verified.
ErrMaxToolCalls relabel (executor.go:535):capHit && errors.Is(runErr, agent.ErrMaxSteps) could mislabel a genuine step-ceiling exhaustion that merely coincided with crossing the tool-call count as a tool-cap exit. That is a correctness/status-reporting concern (another lens), not a security one — no authz/authn or injection implication.
🎯 Correctness — No material issues found
VERDICT: No material issues found
I reviewed the change through the correctness lens, focusing on the step-ceiling / cap-relabel logic, the salvage downgrade, and the shared exhaustion classifier.
stepCeilingOption cap-hit math (run/critic.go:130): When the cap is reached the function returns *steps (= s.Index + 1, the completed-step count). With majordomo's stepIdx < ceiling check, the next iteration has stepIdx == ceiling and exits with ErrMaxSteps. The <= 0 clamp to 1 correctly avoids majordomo falling back to its static ceiling when no step has completed yet. The priority ordering (cap → critic → base) is sound, and the maxCalls <= 0 path is a true no-op (WithMaxSteps(base) equivalent). Verified by reading critic.go:113-145 and the observer counters in executor.go:393-436.
Relabel condition (run/executor.go:535): capHit && errors.Is(runErr, agent.ErrMaxSteps). capHit is gated on len(ra.Phases) == 0 (single-loop only), set once and sticky. A cap-triggered exit always comes back as ErrMaxSteps (the driven-down ceiling), and a final-answer turn carries zero tool calls so it can't spuriously set capHit. The wrapped error preserves ErrMaxToolCalls via %w, so isBudgetExhaustion and errors.Is(..., ErrMaxToolCalls) both match. Verified.
Salvage downgrade (run/executor.go:626): Gated on SalvageSingleLoopMaxSteps && isBudgetExhaustion(runErr) && runRes != nil && Output empty. It runs only after a budget error (ResultSchema/FinalGuard are gated on runErr == nil, so neither fired), mirroring the phased path. runErr = nil correctly downgrades to a delivered partial result. Verified against phases.go:286-289 (shared isBudgetExhaustion) and runOnePhase salvage.
RunStateAccessor fix (run/runengine.go:151, executor.go:218): Now threads maxCalls through instead of the hardcoded 0. Correct.
Trivial (not blocking): the salvaged note hardcodes "reached its step budget" (executor.go:631) even when the actual cause was ErrMaxToolCalls (tool-call budget). Cosmetic wording mismatch only; no behavioral impact.
No correctness bugs found within this lens.
🧹 Code cleanliness & maintainability — No material issues found
Verdict: No material issues found
I reviewed the diff through the cleanliness/maintainability lens and verified the touched code in its checked-out state.
stepCeilingOption (critic.go:130) cleanly replaces maxStepsOption and is the only caller of the ceiling option now (confirmed by grep: no remaining maxStepsOption references). The priority doc-comment is accurate and matches the implementation.
isBudgetExhaustion (phases.go:281) properly generalizes the old isPhaseBudgetExhaustion; all three call sites (phases.go:159, phases.go:251, executor.go:627) and the ports.go doc reference were updated consistently — no stale name left behind (grep confirms zero remaining isPhaseBudgetExhaustion).
The single-loop tool-call accounting (toolCallsSeen/stepsSeen/capHit) is declared once at executor.go:393 and threaded only where needed (the single-loop stepCeilingOption call at executor.go:518). The phased path intentionally uses a plain WithMaxSteps(maxIter) (phases.go:239) and the gate len(ra.Phases) == 0 in the cap-hit log (executor.go:427) keeps the shared stepObserver from emitting misleading audit events on phased runs — consistent with the documented "single-loop only" scope.
New exported sentinel ErrMaxToolCalls (ports.go) sits alongside ErrCriticKill following the existing pattern; the isBudgetExhaustion membership keeps the two exhaustion paths from drifting.
The inline salvage note string (executor.go:631) is a one-off magic string, but it's used in exactly one place and mirrors the phased-path salvage style, so it's not a meaningful duplication.
Nothing in my lane is materially wrong.
⚡ Performance — No material issues found
VERDICT: No material issues found
Through the performance lens, this change is clean:
No new hot-path allocations or copies. The tool-call budget accounting adds two local ints and a bool to the step observer closure (executor.go:393-394); stepCeilingOption passes pointers to them. The closure is already per-run, not per-step, and the per-step work is += n and a comparison — no new slicing or map churn. The capHit flag gates the LogEvent (a single map allocation) to fire exactly once per run, not per step.
No quadratic/unbounded growth introduced.salvagePhaseTranscript is reused unchanged; it already bounds output to maxSalvageBytes with a tail-trim (phases.go:318-325). The single-loop salvage path calls it once at the end of the run, so there's no repeated or accumulating reconstruction.
WithMaxStepsFunc is invoked once per step by majordomo (the ceiling poll), and the new branch is a constant-time int comparison before the existing critic poll — strictly additive, no extra iteration over steps or tool calls. The *steps/*toolCalls reads are pointer dereferences, not scans.
No missing pagination/limits or blocking calls on the hot path. The MaxToolCalls cap actually tightens bounding (it can only exit the loop sooner), so it is a performance improvement on runs that would otherwise run to MaxIterations while burning tool calls.
I verified the step-observer accounting loop (executor.go:396-436), the stepCeilingOption constant-time branches (critic.go:130-145), and the bounded salvagePhaseTranscript (phases.go:301-327). The agent.WithMaxStepsFunc symbol is referenced throughout the existing codebase and I could not locate its declaration inside the sandbox (the agent package source isn't checked out here), so I could not confirm the per-step invocation frequency directly — but the diff's own comments and the existing critic-driven dynamic ceiling (WithMaxStepsFunc already in use pre-PR) confirm this is the established per-step polling pattern, so the additive branch adds no per-step cost of concern.
No material performance issues in my lane.
🧯 Error handling & edge cases — Minor issues
Verdict: Minor issues
Through the error-handling & edge-case lens, I verified the new unhappy paths in executor.go and phases.go. The relabel (%w wrap of ErrMaxToolCalls at line 536), the shared isBudgetExhaustion matching it via errors.Is, the capHit gating on len(ra.Phases)==0 (so phased runs don't mis-log), and the stepsSeen = s.Index+1 ceiling math all check out — the loop exits on the next iteration and ErrMaxSteps is correctly relabeled. salvagePhaseTranscript is nil-safe, so a nil runRes is handled. Cancellation is not mis-salvaged (a cap hit + context cancel leaves runErr as a cancel error, which isBudgetExhaustion rejects).
One real edge-case mismatch:
run/executor.go:631 — salvage note is wrong for tool-call-cap exits. The single-loop salvage hardcodes the note "reached its step budget before composing a final answer" regardless of which budget triggered it. When the exit was ErrMaxToolCalls (relabeled at executor.go:536), the delivered partial Output now tells the user/auditor the run hit its step budget, when it actually hit its tool-call cap. This is user-facing prose emitted on a specific error path, and it's misleading precisely in the new MaxToolCalls case the PR adds. Verified: the salvage block (lines 626–637) runs after the relabel and uses a single fixed string; runErr.Error() is logged (line 633) but the Output note is unconditional. Suggested fix: branch the note on errors.Is(runErr, ErrMaxToolCalls) (check before runErr = nil), e.g. "reached its tool-call budget..." vs "reached its step budget...".
No other edge-case issues found in my lane: nil/empty guards are present, the <= 0 unlimited path is inert, the clamp-to-1 in stepCeilingOption is defensive (and unreachable in practice since stepsSeen is always ≥1 after a completed step), and no errors are swallowed — runErr = nil only happens after a non-empty salvage is actually produced.
kimi-k2.6:cloud (ollama-cloud) — Blocking issues found
No security-relevant changes to authn/authz, injection surfaces, deserialization, or secret handling.
SalvageSingleLoopMaxSteps is opt-in (default false) and only affects error-to-success downgrading when enabled; no new trust boundary is crossed.
MaxToolCalls <= 0 is explicitly unlimited, preserving backward compatibility. No integer underflow risk — the check is maxCalls > 0.
Error wrapping fmt.Errorf("%w (max %d)", ErrMaxToolCalls, maxCalls) is safe; maxCalls is an int, not user-controlled untrusted data.
The stepCeilingOption reads counters via pointers from the same goroutine (documented at run/executor.go:389-392); no data race or atomic issue.
isBudgetExhaustion correctly includes ErrMaxToolCalls alongside existing sentinels; no logic gap.
Audit events ("max_tool_calls_reached", "single_loop_salvaged") emit only numeric caps and error strings — no credential or PII leakage.
🎯 Correctness — Blocking issues found
Blocking issues found
run/executor.go:393 — Resumed runs bypass the MaxToolCalls cap. toolCallsSeen is declared as a local int at line 393 and is initialized to 0 on every Executor.Run invocation. When a run is resumed from a checkpoint (lines 522–528), toolCallsSeen is not restored from RunCheckpointState (which only persists Messages and Iteration). Consequently, a resumed execution starts counting tool calls from zero, allowing the total across the original + resumed execution to exceed ra.MaxToolCalls. This is a material violation of the cap contract. Fix: Persist the cumulative tool-call count in RunCheckpointState and restore it into toolCallsSeen when resume != nil.
run/executor.go:630–631 — The salvage note hard-codes “reached its step budget” even when the exhaustion cause was ErrMaxToolCalls. This is semantically incorrect and will mislead users about which budget was exhausted. Fix: Vary the note text based on the actual error (e.g. runErr) so a tool-call cap hit says “tool-call budget”.
run/executor.go:585 — The FinalGuard nudge round uses agent.WithMaxSteps(finalNudgeMaxSteps) directly and does not include stepCeilingOption, so the MaxToolCalls cap is not enforced during nudge-round tool dispatches. The shared obs still increments toolCallsSeen, but without the dynamic ceiling the nudge round can push the total past the cap. Fix: Compose the tool-call ceiling into the nudge-round options (or explicitly document the nudge round as exempt).
🧹 Code cleanliness & maintainability — No material issues found
No material issues found
run/executor.go:631 — The hardcoded salvage note says "reached its step budget before composing a final answer", but isBudgetExhaustion now matches ErrMaxToolCalls (tool-call cap) and ErrToolLoop (tool-error guard) as well. When the run exhausts its tool-call budget, telling the user/host it hit its step budget is misleading and will confuse downstream consumers that surface this text. The note should be parameterized to match the actual cause, or at least generically say "reached its budget".
Verified by reading executor.go lines 626–637 and phases.go lines 286–290; isBudgetExhaustion matches three distinct sentinels, but the downstream message only names one of them.
⚡ Performance — No material issues found
No material issues found
The PR's changes are additive and performance-neutral:
Per-step overhead is trivial. The step observer gains two integer increments (toolCallsSeen += n, stepsSeen = s.Index + 1) and a single bounded boolean check per step. The stepCeilingOption closure adds a few integer comparisons per step-ceiling poll. Neither introduces allocations, locks, or unbounded work on the hot path.
Salvage runs only on error. The single-loop salvage path (salvagePhaseTranscript) is gated by SalvageSingleLoopMaxStepsand budget exhaustion and empty Output. It executes at most once per run, on a cold path, and reuses the existing phased-runner truncation logic (maxSalvageBytes = 8000). No new unbounded growth or repeated large allocations.
No blocking calls added. All new logic is in-memory integer arithmetic, pointer dereferences, and conditional logging. No I/O, no serialization, no quadratic behavior.
No new goroutine coordination. The comment correctly notes that toolCallsSeen/stepsSeen live on majordomo's Run goroutine, so plain ints suffice without atomics.
🧯 Error handling & edge cases — No material issues found
No material issues found
Reviewed the tool-call budget enforcement (stepCeilingOption), salvage path (SalvageSingleLoopMaxSteps), and error relabeling (ErrMaxToolCalls) through the error-handling & edge-cases lens.
Verified stepCeilingOption guards against nil pointers (toolCalls != nil, steps != nil), handles the maxCalls <= 0 unlimited case, and clamps the pre-step ceiling to 1 when stepsSeen == 0 (defensive, though unreachable in practice since the observer fires after step completion).
Confirmed the salvage path is properly gated: it requires runRes != nil, isBudgetExhaustion(runErr), non-empty runRes.Output (after trim), and non-empty salvaged text before downgrading the error to nil.
Confirmed isBudgetExhaustion correctly unwraps the wrapped ErrMaxToolCalls created via fmt.Errorf("%w ...", ErrMaxToolCalls, ...).
Checked that capHit is only set for single-loop runs (len(ra.Phases) == 0), preventing misleading audit events on phased runs.
The toolCallsSeen and stepsSeen counters are plain ints; the PR documents they live on the same goroutine as the ceiling poll. No atomics are needed under that invariant, and the code makes no claims beyond single-threaded use.
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: Blocking issues found** · 9 findings (3 with multi-model agreement)
| | Finding | Where | Models | Lens |
|--|--|--|--|--|
| 🟠 | Salvage note says 'step budget' even for tool-call-cap exhaustion | `run/executor.go:630` | 4/5 | correctness, error-handling, maintainability |
| 🟡 | stepCeilingOption couples to executor locals via two bare *int params; cap logic split across three sites | `run/critic.go:130` | 2/5 | maintainability |
| ⚪ | salvagePhaseTranscript name is stale now that it's shared with the single-loop salvage path | `run/phases.go:301` | 2/5 | maintainability |
<details><summary>6 single-model findings (lower confidence)</summary>
| | Finding | Where | Model | Lens |
|--|--|--|--|--|
| 🔴 | Resumed runs bypass MaxToolCalls cap because toolCallsSeen is not restored from checkpoint | `run/executor.go:393` | kimi-k2.6:cloud | correctness |
| 🟠 | FinalGuard nudge round does not enforce MaxToolCalls ceiling | `run/executor.go:585` | kimi-k2.6:cloud | correctness |
| 🟡 | toolCallsSeen/stepsSeen accumulated unconditionally for phased runs where they are never read | `run/executor.go:425` | claude-code/sonnet | maintainability |
| 🟡 | Single-loop salvage downgrades ErrToolLoop (tool-error guard, a stuck run) to a successful partial result | `run/executor.go:626` | claude-code/opus | error-handling |
| ⚪ | isBudgetExhaustion is cross-file shared but defined in phases.go, one of its two consumers | `run/phases.go:281` | claude-code/sonnet | maintainability |
| ⚪ | ErrMaxToolCalls is a budget sentinel, not a port/seam definition, misplaced in ports.go | `run/ports.go:25` | claude-code/sonnet | maintainability |
</details>
<details><summary>Per-model detail</summary>
<details><summary><b>claude-code/opus</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>
I've verified the key paths. Let me confirm the salvage source only pulls model narration (not tool outputs or system prompt) and check there's no pre-delivery security control being bypassed.
The verification is conclusive:
- `salvagePhaseTranscript` (`run/phases.go:301-327`) concatenates only `step.Response.Text()` — the model's own narration — never tool results, system prompt, or raw untrusted input. So the salvage path introduces no new leakage of privileged data.
- The salvaged Output skips `ResultSchema` and `FinalGuard` (both gated on `runErr == nil`), but those are delivery/validation seams, not security controls — no authz/redaction gate is bypassed.
- `MaxToolCalls == 0` (unlimited) is a resource bound only; `MaxIterations` and `MaxRuntime` still bound the run, so the default doesn't remove any DoS ceiling.
- New `LogEvent` payloads carry only the wrapped sentinel error string and integer counts — no secrets.
## VERDICT: No material issues found
Reviewed through the **Security** lens (authn/authz, injection, SSRF, deserialization, secret leakage, input validation, insecure defaults, tainted-data flow).
- This change is internal control-flow over model-produced narration and integer step/tool-call counters. There is no untrusted-input → dangerous-sink path: no SQL/command/path/template construction, no network egress, no deserialization of external data, no credential handling.
- **Salvage delivers un-guarded model narration, but that's not a new security boundary** — verified `salvagePhaseTranscript` reads only assistant `Response.Text()`, and salvage is opt-in (`SalvageSingleLoopMaxSteps` default false), off precisely for structured-output/review hosts. No system-prompt or tool-output content is exposed that the normal success path wouldn't already deliver.
- **Insecure-default check:** `MaxToolCalls <= 0 == unlimited` is backward-compatible and does not disable the still-present `MaxIterations`/`MaxRuntime` bounds, so it introduces no unbounded-execution/DoS regression.
- **Log payloads** (`single_loop_salvaged`, `max_tool_calls_reached`) contain only the sentinel error string and call counts — no secret/credential leakage.
Nothing in my lane is materially wrong here.
</details>
<details><summary><b>🎯 Correctness</b> — No material issues found</summary>
I've verified the core mechanism against the executus code. I could not reach the majordomo module source through the sandbox, but the step-ceiling contract this PR relies on (`WithMaxStepsFunc` polled each iteration, `stepIdx < ceiling`) is the same one the pre-existing critic path already depends on, so it's an established invariant rather than a new assumption.
## VERDICT: No material issues found
I traced the tool-call cap arithmetic and the salvage/relabel control flow carefully:
- **Cap off-by-one is correct.** For 1 call/step: cap=N stops after exactly N calls. Trace for cap=2 (`executor.go:396-435`, `critic.go:130-145`): step0→`toolCallsSeen=1`, step1→`toolCallsSeen=2` sets `capHit`; the next poll returns `stepsSeen=2`, so `stepIdx 2 < 2` fails → `ErrMaxSteps` → relabeled. cap=1 yields exactly 1 call. Verified against `TestMaxToolCalls_ForcesExitAndSurfacesSentinel`.
- **Relabel is sound.** `capHit && errors.Is(runErr, agent.ErrMaxSteps)` (`executor.go:535`) can only be true on a cap-driven exit: a capped step carries `n>0` tool calls so it never doubles as a final-answer turn, and when `capHit` is set the ceiling func returns `stepsSeen` (≤ base), so the driven-down ceiling always fires at/before the natural one. Natural step exhaustion (cap not reached) keeps plain `ErrMaxSteps` — confirmed `capHit` stays false when `toolCallsSeen < maxCalls`.
- **Backward-compat no-op holds.** With `maxCalls<=0` and `b==nil`, `stepCeilingOption` falls through to `return base` — equivalent to the old `WithMaxSteps(base)`, and the critic path already exercised `WithMaxStepsFunc` for the base fallback. `MaxToolCalls` gating (`len(ra.Phases)==0`) keeps phased runs inert; phased uses its own ceiling, so the accumulating counters are harmless there.
- **`isBudgetExhaustion` / salvage** correctly matches `ErrMaxToolCalls`, and salvage is reachable only on `runErr != nil` (ResultSchema/FinalGuard are `runErr==nil`-gated). `RunStateAccessor` now reports the real cap (`runengine.go:151`). Imports (`fmt`/`errors`/`strings`) all present.
Two sub-material notes (not in scope to block):
- **Trivial (wording):** `executor.go:631` — when salvage fires after a *tool-call-cap* exit (`ErrMaxToolCalls`), the appended note still says "reached its **step budget**". Slightly misleading to the end user; the tool-call cap isn't a step budget. The `LogEvent` records the true error, so it's cosmetic.
- **Outside my lens (durability, low confidence):** on a checkpoint-resume the `toolCallsSeen` counter starts at 0, so the "TOTAL … across the whole run" wording on `MaxToolCalls` (`agent.go:40`) isn't literally honored across a resume — but `MaxIterations` resets the same way on resume, so this is consistent with existing bounding semantics, not a regression this PR introduces.
</details>
<details><summary><b>🧹 Code cleanliness & maintainability</b> — Minor issues</summary>
All three findings are confirmed against the actual code:
1. `salvagePhaseTranscript` (phases.go:301) has exactly two call sites — phases.go:252 and executor.go:629 — so the `Phase` in the name is indeed now stale for the shared single-loop path.
2. executor.go:631 hardcodes the "step budget" note, and the enclosing block at line 627 gates on `isBudgetExhaustion`, which now includes `ErrMaxToolCalls` — so the note is reachable for a tool-call-cap exit.
3. `stepCeilingOption` (critic.go:130) takes two bare `*int` into `toolCallsSeen`/`stepsSeen` (executor.go:393), with the crossing/`capHit` logging in the observer (421-435) and the relabel further down — the cross-site mutable coupling is real.
All findings survive.
## VERDICT: Minor issues
The change is clean, well-factored, and heavily documented. It reuses `salvagePhaseTranscript` and folds the two exhaustion predicates into one shared `isBudgetExhaustion` rather than duplicating logic — good moves for maintainability. The `NewRunStateAccessor` fix (real `maxCalls` instead of hardcoded `0`) is a genuine correctness-of-reporting cleanup, and its signature already had the param, so no ripple. Within my lens I found only small nits:
- **`run/phases.go:301` — `salvagePhaseTranscript` name is now stale.** The function is no longer phase-specific: `run/executor.go:629` calls it for single-loop salvage, and the shared-predicate doc at `run/phases.go:284` even says "Shared by the phased pipeline and the single-loop salvage." The `Phase` in the name now misleads a reader into thinking it's phased-only. Low-churn fix: rename to `salvageTranscript` (it's unexported, so a mechanical rename across `phases.go`/`executor.go`). Verified by grep: only these two call sites. *Confidence: high; severity: trivial.*
- **`run/executor.go:631` — salvage note says "step budget" even for a tool-call-cap exit.** The same block handles `ErrMaxToolCalls` (via `isBudgetExhaustion`), but the user-facing note is hardcoded `"reached its step budget before composing a final answer"`. For a run that exhausted `MaxToolCalls` (not steps), that wording is inaccurate. Minor because it's best-effort prose, but since the PR's whole point is to distinguish the two causes (new `ErrMaxToolCalls` sentinel), the note undercuts that distinction. Verified: the block is reached for both sentinels. *Confidence: high; severity: small.*
- **`run/critic.go:130` — `stepCeilingOption` takes two raw `*int` into executor-loop locals.** The method now reaches into `toolCallsSeen`/`stepsSeen` (declared at `executor.go:393`) via pointers, while the crossing detection + `capHit` logging live back in the step observer (`executor.go:421-435`) and the relabel lives further down. So `MaxToolCalls` enforcement is spread across three sites coupled by shared mutable state. It's thoroughly commented and the same-goroutine invariant is stated, so it works and isn't blocking — but a small struct (e.g. a `toolBudget{seen, steps int}` passed by pointer, or a closure built in `executor.go`) would make the coupling explicit and read better than two bare `*int` params. *Confidence: medium (design judgment); severity: small.*
Not flagged (checked and fine): the `maxStepsOption`→`stepCeilingOption` rename correctly preserves the `b == nil` behavior via the inner `if b != nil` guard; the `isPhaseBudgetExhaustion`→`isBudgetExhaustion` rename hits all call sites (`phases.go:251`, `phases.go` case at 159, `executor.go:627`); tests are clear and well-named.
</details>
<details><summary><b>⚡ Performance</b> — No material issues found</summary>
## VERDICT: No material issues found
I reviewed this change strictly through the **Performance** lens and verified the hot-path implications against the checked-out code.
What I checked:
- **Per-step tool-call accounting** (`run/executor.go:391` step observer: `toolCallsSeen += n`, `stepsSeen = s.Index + 1`, one comparison + one-shot `capHit` flag). This is O(1) per step and runs on majordomo's existing Run goroutine — no new goroutine, no locking, no allocation. The `LogEvent` fires at most once (guarded by `!capHit`). No regression.
- **`stepCeilingOption` now always uses `WithMaxStepsFunc`** (`run/critic.go:130-145`), whereas the no-critic case previously used the static `WithMaxSteps(base)`. The closure is invoked once per step boundary and does only a couple of int derefs/comparisons. Steps map to LLM round-trips (seconds each), so this is immeasurable overhead — a micro-optimization at most, not a material regression, and I'm deliberately not flagging it as such.
- **`salvagePhaseTranscript`** (`run/phases.go:301-327`), now reused by the single-loop salvage path. Verified it uses `strings.Builder` (no quadratic `+=` concat), iterates each step's narration once, and tail-trims to the bounded `maxSalvageBytes = 8000`. It runs at most once, only on the terminal error path — not in any loop. No unbounded growth.
- **`isBudgetExhaustion`** (`run/phases.go:286`) adds one more `errors.Is` — constant-time, called on error paths only.
No N+1, no work added inside the agent loop's hot path, no unnecessary copies/allocations, no unbounded accumulation, and no blocking calls introduced. The tool-call cap itself is a performance *win* (bounds total work). Nothing material in my lane.
</details>
<details><summary><b>🧯 Error handling & edge cases</b> — Minor issues</summary>
Both findings verify against the actual code. `isBudgetExhaustion` (phases.go:286-289) includes `agent.ErrToolLoop`, and the single-loop salvage block (executor.go:626-637) does relabel `runErr = nil` and deliver the partial. The hardcoded "reached its step budget" note is at executor.go:631. Both survive.
## VERDICT: Minor issues
- **`run/executor.go:626` / `run/phases.go:286` — `ErrToolLoop` gets downgraded to a "successful partial" by single-loop salvage.** `isBudgetExhaustion` includes `agent.ErrToolLoop`, which is the tool-error guard (consecutive tool errors / repeated identical calls) — a signal of a *stuck/malfunctioning* run, not merely a spent step budget. With `SalvageSingleLoopMaxSteps` on, such a run is now relabeled `runErr = nil`, status `ok`, and delivered as a partial answer. This is opt-in and mirrors the pre-existing phased behavior (the old `isPhaseBudgetExhaustion` already included `ErrToolLoop`), so it's defensible for an interactive host — but the unhappy-path semantics are worth a conscious call-out: a genuinely broken tool loop is presented as success. Low severity given the opt-in gate; flagging so it's an intentional choice, not an accident.
- **`run/executor.go:631` — salvage note hardcodes "reached its step budget" for every exhaustion cause, including an `ErrMaxToolCalls` cap.** When the exit was the tool-call cap (or `ErrToolLoop`), the user-facing note still says "reached its step budget before composing a final answer," which misdescribes the actual cause. Trivial/cosmetic — behavior is correct, only the disclaimer wording is imprecise. Suggest wording it generically (e.g. "reached its budget") or branching on the cause.
Nothing blocking in the error-handling / edge-case lens: nil-safety, the forced-exit accounting, empty-output guards, and error preservation are all handled carefully and are covered by the new tests.
</details>
</details>
<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**
The changes introduce a new `MaxToolCalls` cap, a `SalvageSingleLoopMaxSteps` opt-in flag, and a renamed/extended `isBudgetExhaustion` function. Through the security lens:
- **Audit log data**: `rec.LogEvent("single_loop_salvaged", map[string]any{"error": runErr.Error()})` at `executor.go:633` — `runErr` at that point is one of the pre-defined sentinels (`ErrMaxToolCalls`, `ErrMaxSteps`, `ErrToolLoop`), all hardcoded strings derived from the system, not user input. No credential or user-data leakage risk.
- **`MaxToolCalls` field**: Comes from `RunnableAgent`, which is a host-controlled DTO, not a user-supplied field at a system boundary. `maxCalls` is only ever interpolated as `%d` into an error string — no injection surface.
- **Salvaged transcript in output**: `salvagePhaseTranscript` writes LLM response text into `runRes.Output`. This is the expected behavior of the package (the entire run loop processes and returns LLM output). The single-loop salvage path does **not** feed this into the phase template engine — it writes directly to `runRes.Output` after the template path has already completed.
- **`isBudgetExhaustion` adding `ErrMaxToolCalls`**: This function is also called from the phased pipeline path. Verified (`executor.go:480, 516–518`) that `stepCeilingOption` is only wired inside the `len(ra.Phases) == 0` branch, and the `ErrMaxToolCalls` relabeling (`executor.go:535–537`) only runs in that same branch — so `ErrMaxToolCalls` cannot propagate to the phased path under normal code flow.
- **Pointer parameters in `stepCeilingOption`**: `&toolCallsSeen` and `&stepsSeen` are pointers to local ints read by the `WithMaxStepsFunc` closure. The PR asserts (and the step observer architecture confirms) these run on the same majordomo goroutine, so no race. No authn/authz or injection vector here.
</details>
<details><summary><b>🎯 Correctness</b> — No material issues found</summary>
I've now traced every decision path through the critical new code. My analysis is complete.
---
**Verdict: No material issues found**
I examined the following in detail before forming this conclusion:
**`stepCeilingOption` ceiling value (`critic.go:131–144`)**
The function returns `*steps` (= `s.Index + 1` of the capping step) when the cap fires. `stepsSeen` is set BEFORE the cap check inside the observer, so at poll time `*steps >= 1`. majordomo's loop check `stepIdx < ceiling` with `stepIdx = completedSteps` and `ceiling = completedSteps` correctly evaluates to `false`, stopping the loop. The `return 1` fallback (`steps == nil || *steps == 0`) is unreachable in practice because `toolCallsSeen > 0` requires at least one completed step, which means `stepsSeen >= 1`. Verified by tracing scenarios with 1–3 tool calls per step.
**`n` counting and tool-call accumulation (`executor.go:408–425`)**
`n = min(len(calls), len(s.Results))` is the pairwise zip count — exactly matching the audit recording loop above it. `toolCallsSeen += n` and `stepsSeen = s.Index + 1` are both set before the cap detection check, so the cap-crossing observation is always based on the just-completed step. ✓
**Relabeling and `errors.Is` chain (`executor.go:535–536`)**
`fmt.Errorf("%w (max %d)", ErrMaxToolCalls, maxCalls)` wraps `ErrMaxToolCalls`. After relabeling: `errors.Is(runErr, ErrMaxToolCalls)` = true; `errors.Is(runErr, agent.ErrMaxSteps)` = false (chain ends at `ErrMaxToolCalls`, which does not itself wrap `ErrMaxSteps`). `isBudgetExhaustion` correctly matches the relabeled error via the `ErrMaxToolCalls` branch. ✓
**Phased-pipeline isolation**
`capHit` is gated on `len(ra.Phases) == 0`; `stepCeilingOption` is only wired in the single-loop path; the relabeling block is inside the `if len(ra.Phases) == 0` scope. Phases can never produce `ErrMaxToolCalls`; its inclusion in `isBudgetExhaustion` is forward-compatible. ✓
**Salvage ordering**
The salvage block runs after `ResultSchema` and `FinalGuard` (both gated on `runErr == nil`), so they can never fire on a cap/budget error. The salvage condition `isBudgetExhaustion(runErr) && runRes != nil && Output == ""` is ordered correctly. ✓
**`RunStateAccessor` fix (`executor.go:218`)**
Was hardcoded `0`; now passes `maxCalls` (the resolved `ra.MaxToolCalls`). When `maxCalls == 0` (unlimited), this reproduces the old value. ✓
**No-critic/no-cap case**
`stepCeilingOption` now always returns `WithMaxStepsFunc` instead of `WithMaxSteps` for the nil-critic path. The two are functionally equivalent for a constant-valued function, and the pre-existing critic tests rely on `WithMaxStepsFunc` already, confirming majordomo treats them identically. ✓
</details>
<details><summary><b>🧹 Code cleanliness & maintainability</b> — Minor issues</summary>
All four findings are confirmed against the actual code. Here is the corrected review:
---
**Verdict: Minor issues**
---
### Findings
**1. Dead accumulation of `toolCallsSeen`/`stepsSeen` outside the phase guard — `executor.go:425-426`**
`toolCallsSeen += n` and `stepsSeen = s.Index + 1` run unconditionally inside `stepObserver` for both single-loop and phased runs. The comment at lines 422–424 says "gate the crossing detection on Phases", but only the `capHit` detection is gated (line 427) — the accumulation itself runs for every phased-run step too. `stepCeilingOption` (the only reader) is called exclusively inside `if len(ra.Phases) == 0` (executor.go:518); the phased path uses `agent.WithMaxSteps` instead. So for phased runs, both variables are accumulated but never read.
**Fix**: move both accumulation lines inside the `if len(ra.Phases) == 0` guard so the code matches the comment.
```go
if len(ra.Phases) == 0 {
toolCallsSeen += n
stepsSeen = s.Index + 1
if maxCalls > 0 && toolCallsSeen >= maxCalls && !capHit {
...
}
}
```
Severity: small — harmless dead work today, but the mismatch between comment and code will mislead anyone who later extends phased-run cap enforcement.
---
**2. Misleading nil guards on non-nullable params in `stepCeilingOption` — `critic.go:132-135`**
The closure checks `toolCalls != nil && steps != nil` before using them. There is exactly one call site (`executor.go:518`), which always passes `&toolCallsSeen` and `&stepsSeen` (addresses of stack-allocated ints, never nil). The nil guards suggest these are optional/nullable, but passing `nil` when `maxCalls > 0` would silently disable the cap with no diagnostic. This is inconsistent with the rest of the file's nil-safety pattern (`recordStep`/`recordToolStart` nil-check the receiver, not the parameters).
**Fix**: remove the `toolCalls != nil && steps != nil` guards and, if defensive intent is desired, replace with a precondition comment or a panic ("contract: non-nil when maxCalls > 0"). The nil receiver (`b == nil`) is correct and should stay.
Severity: small — creates false API surface.
---
**3. `isBudgetExhaustion` lives in `phases.go` but is a cross-file shared helper — `phases.go:281`**
Its own doc comment says "Shared by the phased pipeline and the single-loop salvage in executor.go". It is called in executor.go (line 627) and twice in phases.go (lines 159, 251). Defining it in phases.go — a file named for one consumer — puts a package-level function in a misleading home.
**Fix**: move to `executor.go` (nearest to `ErrMaxToolCalls` wrapping) or to `ports.go` next to `ErrMaxToolCalls`. Same package, no compile impact, but the wrong home invites re-duplication.
Severity: trivial.
---
**4. `ErrMaxToolCalls` sentinel in `ports.go` is not a port/seam definition — `ports.go:25`**
`ports.go` defines Ports, seam interfaces, and `ErrCriticKill` (which belongs because it's the cancellation cause wrapping a Critic interface signal). `ErrMaxToolCalls` is an internal budget-enforcement sentinel with no connection to any seam. Its doc comment even references `isBudgetExhaustion`, a private helper. Placing it with the seam definitions is a loose grouping.
**Fix**: move to `executor.go` near the cap-relabeling logic, or to a small `budget.go` alongside the cap enforcement. Trivial change, purely organizational.
Severity: trivial.
---
</details>
<details><summary><b>⚡ Performance</b> — No material issues found</summary>
I now have everything I need for a performance verdict. The key questions were:
1. Does the switch from `WithMaxSteps` to `WithMaxStepsFunc` for the unlimited case add meaningful overhead?
2. Does the per-step tool-call accounting add hot-path cost?
**`stepCeilingOption` now always returns `WithMaxStepsFunc`** — even when `maxCalls <= 0` and `b == nil`. Previously this returned the static `WithMaxSteps(base)`. At step granularity (each step = one full LLM round-trip, hundreds of milliseconds minimum), the extra function-pointer call and two pointer dereferences per iteration are completely dominated by network latency. Not a material regression.
**`toolCallsSeen += n` and `stepsSeen = s.Index + 1`** run unconditionally in the step observer regardless of whether `maxCalls > 0`, meaning the counters accumulate even when they can never be used to trip the cap. Two integer assignments per step — trivially cheap relative to the rest of the observer body and the LLM call itself.
**`map[string]any{...}` in `LogEvent`** only fires when `capHit` is set, i.e. at most once per run. Not hot.
**`isBudgetExhaustion`** now has one extra `errors.Is` chain item. This only runs on the error path. Not hot.
No other allocations or quadratic behaviors introduced.
---
**Verdict: No material issues found**
The PR adds two integer counters and widens the `WithMaxStepsFunc` closure to handle the unlimited/no-critic case. Every new computation is O(1) per step and orders of magnitude cheaper than the LLM round-trips that dominate each step. There is no hot-loop regression, no new allocation on the happy path, and no unbounded growth.
- `toolCallsSeen`/`stepsSeen` accumulate even when `maxCalls <= 0` (they'll never be read by the ceiling function in that case). Verified at `executor.go:425–426`. Two int writes per step — cannot justify calling this material.
</details>
<details><summary><b>🧯 Error handling & edge cases</b> — No material issues found</summary>
**No material issues found**
Examined through the error handling & edge cases lens: I read `executor.go` (single-loop path, step observer, salvage block, result construction), `critic.go` (the new `stepCeilingOption`), `phases.go` (`isBudgetExhaustion`, `salvagePhaseTranscript`), and the new test file. Key checks:
- **Nil safety on `runRes`:** The salvage block correctly guards `runRes != nil` before calling `salvagePhaseTranscript`. `runAgent` wraps `ag.Run()` and can return `(nil, ErrMaxSteps)`; the guard handles this without a panic. No issue.
- **Error chain semantics:** `runErr = fmt.Errorf("%w (max %d)", ErrMaxToolCalls, maxCalls)` preserves the `ErrMaxToolCalls` sentinel via `%w`, so `errors.Is(runErr, ErrMaxToolCalls)` is true and `isBudgetExhaustion` correctly fires. `errors.Is(relabeled, agent.ErrMaxSteps)` is false, so the relabeling is clean. Verified in `phases.go:286-290`.
- **`capHit` false-positive risk:** `capHit` is set only when `toolCallsSeen >= maxCalls` (step observer, single-loop only via `len(ra.Phases)==0` gate). The relabeling `capHit && errors.Is(runErr, agent.ErrMaxSteps)` can only fire if the tool-call threshold was genuinely crossed — the two conditions together correctly pinpoint a cap-triggered exit. No false positive path found.
- **`stepCeilingOption` nil receiver:** Called as `critic.stepCeilingOption(...)` where `critic` may be a nil `*criticBinding`. The method checks `if b != nil` before accessing `b.h`, so a nil receiver is safe. Confirmed at `critic.go:130-145`.
- **Ordering: relabeling → ResultSchema → FinalGuard → salvage:** The relabeling runs immediately after `runAgent`; ResultSchema and FinalGuard are gated on `runErr == nil` and therefore skip on budget errors; salvage runs after both. `runErr.Error()` in the log call is evaluated before `runErr = nil`, so no nil dereference. Confirmed at `executor.go:669-675` — result construction reads `runRes.Output` and `runErr` after the salvage block.
- **Plain `int` counters (`toolCallsSeen`, `stepsSeen`):** Only accessed from the step observer goroutine and `stepCeilingOption` (both called on majordomo's Run goroutine). No atomics needed; no race path introduced.
- **`isBudgetExhaustion` in phases:** Now includes `ErrMaxToolCalls`, which can never surface from the phased path today (capHit is gated on `len(ra.Phases)==0`). The addition is dead code currently but harmless and correct.
- **`fakeCriticHandle`/`fakeCritic` in tests:** Defined in `critic_test.go`; reused by `salvage_test.go` — no missing type.
Nothing concerning in the unhappy paths introduced by this diff.
</details>
</details>
<details><summary><b>deepseek-v4-pro: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
The diff is additive resource-limiting and error-recovery plumbing. Through the security lens:
- **No new injection vectors**: The salvage path concatenates model-generated step narration (already produced by the model and present in the transcript) with a hardcoded note string. No user-controlled input flows into the reconstructed output. The `salvagePhaseTranscript` function is pre-existing and unchanged.
- **No authn/authz gaps**: `MaxToolCalls` is a host-configured integer cap; `SalvageSingleLoopMaxSteps` is a host-configured boolean. Neither introduces a privilege or access-control decision.
- **No secret/credential leakage**: The audit log events (`max_tool_calls_reached`, `single_loop_salvaged`) record only integer counters and sentinel error strings (`ErrMaxToolCalls`, `ErrMaxSteps`, `ErrToolLoop`) — no model output, tool arguments, or user data is logged.
- **No unsafe deserialization or SSRF**: No new parsing, network calls, or external resource fetches are introduced.
- **No insecure defaults**: `MaxToolCalls <= 0` = unlimited (backward-compatible), and `SalvageSingleLoopMaxSteps` defaults to `false` (preserving the prior hard-error behavior). Both are safe zero-values.
- **Positive security impact**: `MaxToolCalls` is itself a resource-limiting control, reducing the blast radius of a runaway tool-calling loop.
</details>
<details><summary><b>🎯 Correctness</b> — Minor issues</summary>
The finding is confirmed. Let me verify the exact line numbers and the full chain:
1. **Line 535-537**: When `capHit && errors.Is(runErr, agent.ErrMaxSteps)`, `runErr` is relabeled to wrap `ErrMaxToolCalls`.
2. **Line 626-628**: The salvage gate calls `isBudgetExhaustion(runErr)`, which matches `ErrMaxToolCalls` (via `errors.Is(err, ErrMaxToolCalls)` at phases.go:289).
3. **Line 631**: The note unconditionally says `"reached its step budget before composing a final answer"` — even when the actual cause was the tool-call cap.
The test `TestMaxToolCalls_SalvagedWhenEnabled` (salvage_test.go:183-199) confirms the salvage path fires for tool-call exhaustion, but only asserts `"partial finding"` in the output, not the note text — so the misleading note is real and untested.
The finding survives verification.
---
## Verdict: Minor issues
- **`run/executor.go:631` — Misleading salvage note for tool-call exhaustion (verified).** When the single-loop salvage path fires for an `ErrMaxToolCalls` exit, the appended note hardcodes `"reached its step budget before composing a final answer"`. But the actual cause was the *tool-call* budget, not the step budget. The condition at line 626–628 gates on `isBudgetExhaustion(runErr)`, which matches both `ErrMaxSteps` and `ErrMaxToolCalls` (the latter was relabeled at line 535–537). A host that delivers `res.Output` to the user will show a note blaming the wrong budget. The fix: either make the note generic (`"reached its budget limit…"`) or branch on `errors.Is(runErr, ErrMaxToolCalls)` to say `"reached its tool-call budget…"`.
</details>
<details><summary><b>🧹 Code cleanliness & maintainability</b> — Minor issues</summary>
Both findings are confirmed against the actual code. The salvage note at `executor.go:631` is indeed hardcoded to say "step budget" regardless of which of the three exhaustion sentinels fired, and `salvagePhaseTranscript` is now called from the single-loop path despite its phase-specific name and doc comment.
## Verdict: Minor issues
- **`run/executor.go:631` — Salvage note text is misleading for non-step-budget exhaustion.** The hardcoded note `"reached its step budget before composing a final answer"` is attached to the salvaged output regardless of which exhaustion cause fired. `isBudgetExhaustion` matches three sentinels: `ErrMaxSteps` (step budget — accurate), `ErrToolLoop` (tool-error guard — not a step budget), and the new `ErrMaxToolCalls` (tool-call cap — not a step budget). For the latter two the user sees "step budget" when the real cause was something else. The phased path has the same pre-existing inaccuracy for `ErrToolLoop`, but this diff adds a *new* mismatch for `ErrMaxToolCalls`. The note should reflect the actual cause (e.g., branch on `runErr` to say "tool-call budget" vs "step budget" vs "tool-error guard"). Verified by reading `executor.go:626-637` and `phases.go:286-290`.
- **`run/phases.go:301` — `salvagePhaseTranscript` name is now misleading.** The function was written for the phased pipeline (its doc comment says "reconstructs a best-effort phase output"), but this diff now calls it from the single-loop salvage path in `executor.go:629`. A reader encountering the call site has to chase the definition to realize it's not phase-specific. Consider renaming to `salvageTranscript` (or similar) since it's now shared across both paths. Verified by reading `phases.go:297-327` and `executor.go:629`.
</details>
<details><summary><b>⚡ Performance</b> — No material issues found</summary>
## Verdict: No material issues found
The changes are performance-neutral. I verified the hot paths by reading the full files:
- **`stepCeilingOption` closure** (`run/critic.go:130-144`): Called once per step by majordomo. The closure body is O(1) — two integer comparisons, two nil checks, two pointer dereferences. The old `maxStepsOption` was also called per step (when a critic was bound). The only micro-regression is that the no-critic + no-cap case now uses `WithMaxStepsFunc` instead of the static `WithMaxSteps(base)`, adding ~two nil checks per step. In the context of LLM calls taking seconds, this is negligible.
- **Step observer additions** (`run/executor.go:425-435`): `toolCallsSeen += n`, `stepsSeen = s.Index + 1`, and a conditional cap-hit check — all O(1) per step, no allocations, no blocking.
- **Salvage path** (`run/executor.go:626-637`): Runs once at end-of-run on the error path only. `salvagePhaseTranscript` iterates `res.Steps` (bounded by `MaxIterations`, default 12) and uses `strings.Builder` — efficient and bounded.
- **`isBudgetExhaustion`** (`run/phases.go:286-289`): Called per-phase (not per-step), O(1) `errors.Is` checks.
- **`RunStateAccessor`** (`run/runengine.go:131`): Now receives the real `maxCalls` instead of hardcoded `0` — a field assignment, no runtime cost change.
No N+1 queries, no unnecessary allocations in hot paths, no unbounded growth, no blocking calls, no quadratic behavior introduced.
</details>
<details><summary><b>🧯 Error handling & edge cases</b> — Minor issues</summary>
The finding is confirmed. The salvage note at `executor.go:631` is hardcoded to say "step budget" regardless of which budget was exhausted. The `isBudgetExhaustion` function (phases.go:286-289) matches `ErrMaxToolCalls`, so the salvage path fires for tool-call cap exhaustion too — but the user-facing text still blames the step budget. This is a real, though small, UX issue.
---
## Verdict: Minor issues
- **`run/executor.go:630-631` — Salvage note text is misleading for `ErrMaxToolCalls`** (verified by reading `executor.go:626-637` and `phases.go:286-289`). The hardcoded note says *"reached its step budget"* regardless of which budget was exhausted. When the run hits `MaxToolCalls` (relabeled at line 536), `isBudgetExhaustion` matches it (line 289), so the salvage path fires — but the user-facing text still blames the step budget. A host or user reading the salvaged output sees "step budget" when the real cause was the tool-call cap, which is confusing. The fix is to vary the note based on the error: e.g., check `errors.Is(runErr, ErrMaxToolCalls)` and say "tool-call budget" instead.
This is a small UX edge-case in the error path; functionally the salvage and downgrade work correctly either way.
</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>
No material issues found
- **Salvage bypasses `ResultSchema` validation (intentional, default-off):** At `executor.go:626-636`, the single-loop salvage path runs only when `runErr != nil`, while `ResultSchema` validation at `executor.go:544` is gated on `runErr == nil` — so a salvaged partial answer is delivered as a successful result (`runErr = nil`) without ever passing schema validation. If a host were to set both `SalvageSingleLoopMaxSteps: true` and `inv.ResultSchema`, unvalidated model narration would be marked "ok" and handed to the host as a valid answer. This is a documented design tradeoff (salvage is meant for interactive research hosts; structured-output hosts keep it off, and the flag defaults to `false`), so it is not a vulnerability — but there is no guard preventing the two from being combined. Verified by reading `executor.go:544` and `executor.go:626`. Severity: trivial / out-of-lane-adjacent; no enforcement gap worth blocking on.
- **`MaxToolCalls` is host-controlled config, not untrusted input:** `RunnableAgent.MaxToolCalls` (`agent.go:40`) is set by the host, with `<= 0` meaning unlimited. No untrusted-user input reaches it; negative values are handled as a no-op rather than misinterpreted as a ceiling. No validation gap.
- **No injection / credential surface:** Salvage concatenates the model's own step narration (`salvagePhaseTranscript`, `phases.go:301`) plus a fixed English note into `Output`; no template expansion, no shell/SQL/path interpolation. The logged `runErr.Error()` in `single_loop_salvaged` is a budget-error sentinel string, not user/model-supplied free text — minimal leakage surface. Verified.
- **`ErrMaxToolCalls` relabel (`executor.go:535`):** `capHit && errors.Is(runErr, agent.ErrMaxSteps)` could mislabel a genuine step-ceiling exhaustion that merely coincided with crossing the tool-call count as a tool-cap exit. That is a correctness/status-reporting concern (another lens), not a security one — no authz/authn or injection implication.
</details>
<details><summary><b>🎯 Correctness</b> — No material issues found</summary>
VERDICT: No material issues found
I reviewed the change through the correctness lens, focusing on the step-ceiling / cap-relabel logic, the salvage downgrade, and the shared exhaustion classifier.
- **stepCeilingOption cap-hit math** (`run/critic.go:130`): When the cap is reached the function returns `*steps` (= `s.Index + 1`, the completed-step count). With majordomo's `stepIdx < ceiling` check, the next iteration has `stepIdx == ceiling` and exits with `ErrMaxSteps`. The `<= 0` clamp to `1` correctly avoids majordomo falling back to its static ceiling when no step has completed yet. The priority ordering (cap → critic → base) is sound, and the `maxCalls <= 0` path is a true no-op (`WithMaxSteps(base)` equivalent). Verified by reading `critic.go:113-145` and the observer counters in `executor.go:393-436`.
- **Relabel condition** (`run/executor.go:535`): `capHit && errors.Is(runErr, agent.ErrMaxSteps)`. `capHit` is gated on `len(ra.Phases) == 0` (single-loop only), set once and sticky. A cap-triggered exit always comes back as `ErrMaxSteps` (the driven-down ceiling), and a final-answer turn carries zero tool calls so it can't spuriously set `capHit`. The wrapped error preserves `ErrMaxToolCalls` via `%w`, so `isBudgetExhaustion` and `errors.Is(..., ErrMaxToolCalls)` both match. Verified.
- **Salvage downgrade** (`run/executor.go:626`): Gated on `SalvageSingleLoopMaxSteps && isBudgetExhaustion(runErr) && runRes != nil && Output empty`. It runs only after a budget error (ResultSchema/FinalGuard are gated on `runErr == nil`, so neither fired), mirroring the phased path. `runErr = nil` correctly downgrades to a delivered partial result. Verified against `phases.go:286-289` (shared `isBudgetExhaustion`) and `runOnePhase` salvage.
- **RunStateAccessor fix** (`run/runengine.go:151`, `executor.go:218`): Now threads `maxCalls` through instead of the hardcoded `0`. Correct.
- **Trivial (not blocking):** the salvaged note hardcodes "reached its step budget" (`executor.go:631`) even when the actual cause was `ErrMaxToolCalls` (tool-call budget). Cosmetic wording mismatch only; no behavioral impact.
No correctness bugs found within this lens.
</details>
<details><summary><b>🧹 Code cleanliness & maintainability</b> — No material issues found</summary>
**Verdict: No material issues found**
I reviewed the diff through the cleanliness/maintainability lens and verified the touched code in its checked-out state.
- `stepCeilingOption` (critic.go:130) cleanly replaces `maxStepsOption` and is the only caller of the ceiling option now (confirmed by grep: no remaining `maxStepsOption` references). The priority doc-comment is accurate and matches the implementation.
- `isBudgetExhaustion` (phases.go:281) properly generalizes the old `isPhaseBudgetExhaustion`; all three call sites (phases.go:159, phases.go:251, executor.go:627) and the `ports.go` doc reference were updated consistently — no stale name left behind (grep confirms zero remaining `isPhaseBudgetExhaustion`).
- The single-loop tool-call accounting (`toolCallsSeen`/`stepsSeen`/`capHit`) is declared once at executor.go:393 and threaded only where needed (the single-loop `stepCeilingOption` call at executor.go:518). The phased path intentionally uses a plain `WithMaxSteps(maxIter)` (phases.go:239) and the gate `len(ra.Phases) == 0` in the cap-hit log (executor.go:427) keeps the shared `stepObserver` from emitting misleading audit events on phased runs — consistent with the documented "single-loop only" scope.
- New exported sentinel `ErrMaxToolCalls` (ports.go) sits alongside `ErrCriticKill` following the existing pattern; the `isBudgetExhaustion` membership keeps the two exhaustion paths from drifting.
- The inline salvage note string (executor.go:631) is a one-off magic string, but it's used in exactly one place and mirrors the phased-path salvage style, so it's not a meaningful duplication.
Nothing in my lane is materially wrong.
</details>
<details><summary><b>⚡ Performance</b> — No material issues found</summary>
VERDICT: No material issues found
Through the performance lens, this change is clean:
- **No new hot-path allocations or copies.** The tool-call budget accounting adds two local ints and a bool to the step observer closure (`executor.go:393-394`); `stepCeilingOption` passes pointers to them. The closure is already per-run, not per-step, and the per-step work is `+= n` and a comparison — no new slicing or map churn. The `capHit` flag gates the `LogEvent` (a single map allocation) to fire exactly once per run, not per step.
- **No quadratic/unbounded growth introduced.** `salvagePhaseTranscript` is reused unchanged; it already bounds output to `maxSalvageBytes` with a tail-trim (`phases.go:318-325`). The single-loop salvage path calls it once at the end of the run, so there's no repeated or accumulating reconstruction.
- **`WithMaxStepsFunc` is invoked once per step** by majordomo (the ceiling poll), and the new branch is a constant-time int comparison before the existing critic poll — strictly additive, no extra iteration over steps or tool calls. The `*steps`/`*toolCalls` reads are pointer dereferences, not scans.
- **No missing pagination/limits or blocking calls on the hot path.** The `MaxToolCalls` cap actually *tightens* bounding (it can only exit the loop sooner), so it is a performance improvement on runs that would otherwise run to `MaxIterations` while burning tool calls.
I verified the step-observer accounting loop (`executor.go:396-436`), the `stepCeilingOption` constant-time branches (`critic.go:130-145`), and the bounded `salvagePhaseTranscript` (`phases.go:301-327`). The `agent.WithMaxStepsFunc` symbol is referenced throughout the existing codebase and I could not locate its declaration inside the sandbox (the `agent` package source isn't checked out here), so I could not confirm the per-step invocation frequency directly — but the diff's own comments and the existing critic-driven dynamic ceiling (`WithMaxStepsFunc` already in use pre-PR) confirm this is the established per-step polling pattern, so the additive branch adds no per-step cost of concern.
No material performance issues in my lane.
</details>
<details><summary><b>🧯 Error handling & edge cases</b> — Minor issues</summary>
**Verdict: Minor issues**
Through the error-handling & edge-case lens, I verified the new unhappy paths in `executor.go` and `phases.go`. The relabel (`%w` wrap of `ErrMaxToolCalls` at line 536), the shared `isBudgetExhaustion` matching it via `errors.Is`, the `capHit` gating on `len(ra.Phases)==0` (so phased runs don't mis-log), and the `stepsSeen = s.Index+1` ceiling math all check out — the loop exits on the next iteration and `ErrMaxSteps` is correctly relabeled. `salvagePhaseTranscript` is nil-safe, so a nil `runRes` is handled. Cancellation is not mis-salvaged (a cap hit + context cancel leaves `runErr` as a cancel error, which `isBudgetExhaustion` rejects).
One real edge-case mismatch:
- **`run/executor.go:631` — salvage note is wrong for tool-call-cap exits.** The single-loop salvage hardcodes the note `"reached its step budget before composing a final answer"` regardless of which budget triggered it. When the exit was `ErrMaxToolCalls` (relabeled at `executor.go:536`), the delivered partial Output now tells the user/auditor the run hit its *step* budget, when it actually hit its *tool-call* cap. This is user-facing prose emitted on a specific error path, and it's misleading precisely in the new `MaxToolCalls` case the PR adds. Verified: the salvage block (lines 626–637) runs after the relabel and uses a single fixed string; `runErr.Error()` is logged (line 633) but the *Output* note is unconditional. Suggested fix: branch the note on `errors.Is(runErr, ErrMaxToolCalls)` (check before `runErr = nil`), e.g. `"reached its tool-call budget..."` vs `"reached its step budget..."`.
No other edge-case issues found in my lane: nil/empty guards are present, the `<= 0` unlimited path is inert, the clamp-to-1 in `stepCeilingOption` is defensive (and unreachable in practice since `stepsSeen` is always `≥1` after a completed step), and no errors are swallowed — `runErr = nil` only happens after a non-empty salvage is actually produced.
</details>
</details>
<details><summary><b>kimi-k2.6:cloud</b> (ollama-cloud) — Blocking issues found</summary>
**Verdict: Blocking issues found** — 5 reviewers: security, correctness, maintainability, performance, error-handling
<details><summary><b>🔒 Security</b> — No material issues found</summary>
No material issues found
- No security-relevant changes to authn/authz, injection surfaces, deserialization, or secret handling.
- `SalvageSingleLoopMaxSteps` is opt-in (default `false`) and only affects error-to-success downgrading when enabled; no new trust boundary is crossed.
- `MaxToolCalls <= 0` is explicitly unlimited, preserving backward compatibility. No integer underflow risk — the check is `maxCalls > 0`.
- Error wrapping `fmt.Errorf("%w (max %d)", ErrMaxToolCalls, maxCalls)` is safe; `maxCalls` is an int, not user-controlled untrusted data.
- The `stepCeilingOption` reads counters via pointers from the same goroutine (documented at `run/executor.go:389-392`); no data race or atomic issue.
- `isBudgetExhaustion` correctly includes `ErrMaxToolCalls` alongside existing sentinels; no logic gap.
- Audit events (`"max_tool_calls_reached"`, `"single_loop_salvaged"`) emit only numeric caps and error strings — no credential or PII leakage.
</details>
<details><summary><b>🎯 Correctness</b> — Blocking issues found</summary>
**Blocking issues found**
- **`run/executor.go:393`** — Resumed runs bypass the `MaxToolCalls` cap. `toolCallsSeen` is declared as a local `int` at line 393 and is initialized to 0 on every `Executor.Run` invocation. When a run is resumed from a checkpoint (lines 522–528), `toolCallsSeen` is **not** restored from `RunCheckpointState` (which only persists `Messages` and `Iteration`). Consequently, a resumed execution starts counting tool calls from zero, allowing the total across the original + resumed execution to exceed `ra.MaxToolCalls`. This is a material violation of the cap contract.
*Fix:* Persist the cumulative tool-call count in `RunCheckpointState` and restore it into `toolCallsSeen` when `resume != nil`.
- **`run/executor.go:630–631`** — The salvage note hard-codes “reached its **step budget**” even when the exhaustion cause was `ErrMaxToolCalls`. This is semantically incorrect and will mislead users about which budget was exhausted.
*Fix:* Vary the note text based on the actual error (e.g. `runErr`) so a tool-call cap hit says “tool-call budget”.
- **`run/executor.go:585`** — The FinalGuard nudge round uses `agent.WithMaxSteps(finalNudgeMaxSteps)` directly and does **not** include `stepCeilingOption`, so the `MaxToolCalls` cap is not enforced during nudge-round tool dispatches. The shared `obs` still increments `toolCallsSeen`, but without the dynamic ceiling the nudge round can push the total past the cap.
*Fix:* Compose the tool-call ceiling into the nudge-round options (or explicitly document the nudge round as exempt).
</details>
<details><summary><b>🧹 Code cleanliness & maintainability</b> — No material issues found</summary>
No material issues found
- `run/executor.go:631` — The hardcoded salvage note says *"reached its step budget before composing a final answer"*, but `isBudgetExhaustion` now matches `ErrMaxToolCalls` (tool-call cap) and `ErrToolLoop` (tool-error guard) as well. When the run exhausts its tool-call budget, telling the user/host it hit its *step* budget is misleading and will confuse downstream consumers that surface this text. The note should be parameterized to match the actual cause, or at least generically say *"reached its budget"*.
Verified by reading `executor.go` lines 626–637 and `phases.go` lines 286–290; `isBudgetExhaustion` matches three distinct sentinels, but the downstream message only names one of them.
</details>
<details><summary><b>⚡ Performance</b> — No material issues found</summary>
**No material issues found**
The PR's changes are additive and performance-neutral:
- **Per-step overhead is trivial.** The step observer gains two integer increments (`toolCallsSeen += n`, `stepsSeen = s.Index + 1`) and a single bounded boolean check per step. The `stepCeilingOption` closure adds a few integer comparisons per step-ceiling poll. Neither introduces allocations, locks, or unbounded work on the hot path.
- **Salvage runs only on error.** The single-loop salvage path (`salvagePhaseTranscript`) is gated by `SalvageSingleLoopMaxSteps` **and** budget exhaustion **and** empty `Output`. It executes at most once per run, on a cold path, and reuses the existing phased-runner truncation logic (`maxSalvageBytes = 8000`). No new unbounded growth or repeated large allocations.
- **No blocking calls added.** All new logic is in-memory integer arithmetic, pointer dereferences, and conditional logging. No I/O, no serialization, no quadratic behavior.
- **No new goroutine coordination.** The comment correctly notes that `toolCallsSeen`/`stepsSeen` live on majordomo's Run goroutine, so plain ints suffice without atomics.
</details>
<details><summary><b>🧯 Error handling & edge cases</b> — No material issues found</summary>
No material issues found
* Reviewed the tool-call budget enforcement (`stepCeilingOption`), salvage path (`SalvageSingleLoopMaxSteps`), and error relabeling (`ErrMaxToolCalls`) through the error-handling & edge-cases lens.
* Verified `stepCeilingOption` guards against nil pointers (`toolCalls != nil`, `steps != nil`), handles the `maxCalls <= 0` unlimited case, and clamps the pre-step ceiling to `1` when `stepsSeen == 0` (defensive, though unreachable in practice since the observer fires after step completion).
* Confirmed the salvage path is properly gated: it requires `runRes != nil`, `isBudgetExhaustion(runErr)`, non-empty `runRes.Output` (after trim), and non-empty salvaged text before downgrading the error to nil.
* Confirmed `isBudgetExhaustion` correctly unwraps the wrapped `ErrMaxToolCalls` created via `fmt.Errorf("%w ...", ErrMaxToolCalls, ...)`.
* Checked that `capHit` is only set for single-loop runs (`len(ra.Phases) == 0`), preventing misleading audit events on phased runs.
* The `toolCallsSeen` and `stepsSeen` counters are plain `int`s; the PR documents they live on the same goroutine as the ceiling poll. No atomics are needed under that invariant, and the code makes no claims beyond single-threaded use.
</details>
</details>
</details>
<sub>Automated adversarial review by Gadfly — consensus across the model swarm. Advisory only — does not block merge.</sub>
steve
merged commit 55a8fb8866 into main2026-07-17 19:53:21 +00:00
steve
deleted branch feat/single-loop-salvage-max-tool-calls2026-07-17 19:53:21 +00:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes two gaps surfaced by a live fan-out smoke in a downstream host (mort). Both are additive and backward-compatible; suggested release: v0.1.10.
1. Single-loop max-steps salvage (opt-in)
A single-loop run that exhausts its step budget (
ErrMaxSteps/ErrToolLoop) returned empty Output + a hard error, discarding all the reasoning it produced — unlike the phased path, which already salvages a partial transcript and continues.New
Defaults.SalvageSingleLoopMaxSteps(default false): on budget exhaustion with empty Output, reconstruct a best-effort answer from the step narration (reusingsalvagePhaseTranscript) and downgrade the run to a successful partial result with a note. Off by default so a structured-output host (e.g. a bounded review swarm) keeps its clean hard error, where partial prose masquerading as a valid answer would be worse than a loud failure; an interactive host (research) sets it true.The kernel holds the full majordomo
*agent.Result(with per-stepResponsenarration) right at the single-loop error point, so salvage reuses the existing phased machinery with no new plumbing.2. Enforce
MaxToolCallsRunnableAgentbounded a run byMaxIterations(steps) +MaxRuntimeonly — but one step can dispatch several tool calls, so a host's "max tool calls" cap had nowhere to land.New
RunnableAgent.MaxToolCalls(<= 0= unlimited, backward-compatible): the step observer counts executed calls and, once the cap is reached,stepCeilingOptiondrives the step ceiling down to force the loop to exit at the next boundary; the resultingErrMaxStepsis relabeled to the new exportedErrMaxToolCallssentinel. A cap hit is treated as budget exhaustion, so salvage recovers its partial reasoning too. Enforced single-loop only for now (phased is a documented follow-up). Also fixesRunStateAccessorto report the real tool-call cap (was hardcoded0).isPhaseBudgetExhaustion→ sharedisBudgetExhaustion(now also matchesErrMaxToolCalls), so the phased and single-loop paths never drift on what counts as exhaustion.Safety / compatibility
Defaultsflag, new exported sentinel — a run that sets none reproduces prior behavior exactly (stepCeilingOptionwithmaxCalls<=0and no critic == the oldWithMaxSteps(base)).go mod tidyclean; whole suite green (go test ./...).Tests (
run/salvage_test.go)salvage on (recovers narration) / off (clean
ErrMaxSteps) / no-prose (still errors);MaxToolCallsforces exit + surfacesErrMaxToolCalls; cap + salvage recovers;MaxToolCalls<=0unbounded (regression); cap overrides a critic's raised ceiling.🤖 Generated with Claude Code
https://claude.ai/code/session_01Q1eJh5NVR11z6RzyxLANMK
🪰 Gadfly — live review status
5/5 reviewers finished · updated 2026-07-17 19:45:16Z
claude-code/opus· claude-code — ✅ doneclaude-code/sonnet· claude-code — ✅ donedeepseek-v4-pro:cloud· ollama-cloud — ✅ doneglm-5.2:cloud· ollama-cloud — ✅ donekimi-k2.6:cloud· ollama-cloud — ✅ doneLive status board. Findings are posted in each model's own comment. Advisory only — does not block merge.
🪰 Gadfly consensus review — 7 inline findings on changed lines. See the consensus comment for the full ranked summary.
Advisory only — does not block merge.
@@ -121,0 +127,4 @@// an unlimited-tool-call run is a true no-op. nil-safe on the binding (b == nil// skips the critic branch). toolCalls/steps are read live from the step// observer's counters (same goroutine as this poll — see executor.go).func (b *criticBinding) stepCeilingOption(base, maxCalls int, toolCalls, steps *int) agent.Option {🟡 *stepCeilingOption couples to executor locals via two bare int params; cap logic split across three sites
maintainability · flagged by 2 models
run/critic.go:130—stepCeilingOptiontakes two raw*intinto executor-loop locals. The method now reaches intotoolCallsSeen/stepsSeen(declared atexecutor.go:393) via pointers, while the crossing detection +capHitlogging live back in the step observer (executor.go:421-435) and the relabel lives further down. SoMaxToolCallsenforcement is spread across three sites coupled by shared mutable state. It's thoroughly commented and the same-goroutine invariant is stated, s…🪰 Gadfly · advisory
@@ -375,0 +390,4 @@// atomics). stepCeilingOption reads these to force the loop to exit once the// cap is reached; the caller then relabels the resulting ErrMaxSteps to// ErrMaxToolCalls. maxCalls <= 0 (unlimited) leaves this inert.var toolCallsSeen, stepsSeen int🔴 Resumed runs bypass MaxToolCalls cap because toolCallsSeen is not restored from checkpoint
correctness · flagged by 1 model
run/executor.go:393— Resumed runs bypass theMaxToolCallscap.toolCallsSeenis declared as a localintat line 393 and is initialized to 0 on everyExecutor.Runinvocation. When a run is resumed from a checkpoint (lines 522–528),toolCallsSeenis not restored fromRunCheckpointState(which only persistsMessagesandIteration). Consequently, a resumed execution starts counting tool calls from zero, allowing the total across the original + resumed execution to exceed…🪰 Gadfly · advisory
@@ -401,0 +422,4 @@// Only single-loop runs enforce the cap (phased pipelines are a follow-up),// so gate the crossing detection on Phases to avoid a misleading audit// event on a phased run that merely happens to carry a cap.toolCallsSeen += n🟡 toolCallsSeen/stepsSeen accumulated unconditionally for phased runs where they are never read
maintainability · flagged by 1 model
🪰 Gadfly · advisory
@@ -573,0 +623,4 @@// This runs only after a budget error — ResultSchema + FinalGuard above// are gated on runErr == nil, so neither fired. Gated on the host default// so a structured-output host keeps its clean hard error.if e.cfg.Defaults.SalvageSingleLoopMaxSteps &&🟡 Single-loop salvage downgrades ErrToolLoop (tool-error guard, a stuck run) to a successful partial result
error-handling · flagged by 1 model
run/executor.go:626/run/phases.go:286—ErrToolLoopgets downgraded to a "successful partial" by single-loop salvage.isBudgetExhaustionincludesagent.ErrToolLoop, which is the tool-error guard (consecutive tool errors / repeated identical calls) — a signal of a stuck/malfunctioning run, not merely a spent step budget. WithSalvageSingleLoopMaxStepson, such a run is now relabeledrunErr = nil, statusok, and delivered as a partial answer. This is opt-in and mirrors th…🪰 Gadfly · advisory
@@ -573,0 +627,4 @@isBudgetExhaustion(runErr) && runRes != nil &&strings.TrimSpace(runRes.Output) == "" {if salvaged := salvagePhaseTranscript(runRes); salvaged != "" {runRes.Output = salvaged +🟠 Salvage note says 'step budget' even for tool-call-cap exhaustion
correctness, error-handling, maintainability · flagged by 4 models
run/executor.go:631— Misleading salvage note for tool-call exhaustion (verified). When the single-loop salvage path fires for anErrMaxToolCallsexit, the appended note hardcodes"reached its step budget before composing a final answer". But the actual cause was the tool-call budget, not the step budget. The condition at line 626–628 gates onisBudgetExhaustion(runErr), which matches bothErrMaxStepsandErrMaxToolCalls(the latter was relabeled at line 535–537). A host that…🪰 Gadfly · advisory
@@ -283,3 +281,1 @@// partial transcript — as opposed to a hard error (cancellation, model failure).func isPhaseBudgetExhaustion(err error) bool {return errors.Is(err, agent.ErrMaxSteps) || errors.Is(err, agent.ErrToolLoop)// isBudgetExhaustion reports whether err is a soft budget/guard stop (the loop⚪ isBudgetExhaustion is cross-file shared but defined in phases.go, one of its two consumers
maintainability · flagged by 1 model
🪰 Gadfly · advisory
@@ -19,0 +22,4 @@// relabeled to this sentinel so audit + status report the real cause. Treated// as budget exhaustion by isBudgetExhaustion, so the single-loop salvage path// recovers a partial answer from it when enabled.var ErrMaxToolCalls = errors.New("run: max tool calls reached without a final answer")⚪ ErrMaxToolCalls is a budget sentinel, not a port/seam definition, misplaced in ports.go
maintainability · flagged by 1 model
🪰 Gadfly · advisory
🪰 Gadfly review — consensus across 5 models
Verdict: Blocking issues found · 9 findings (3 with multi-model agreement)
run/executor.go:630run/critic.go:130run/phases.go:3016 single-model findings (lower confidence)
run/executor.go:393run/executor.go:585run/executor.go:425run/executor.go:626run/phases.go:281run/ports.go:25Per-model detail
claude-code/opus (claude-code) — Minor issues
Verdict: Minor issues — 5 reviewers: security, correctness, maintainability, performance, error-handling
🔒 Security — No material issues found
I've verified the key paths. Let me confirm the salvage source only pulls model narration (not tool outputs or system prompt) and check there's no pre-delivery security control being bypassed.
The verification is conclusive:
salvagePhaseTranscript(run/phases.go:301-327) concatenates onlystep.Response.Text()— the model's own narration — never tool results, system prompt, or raw untrusted input. So the salvage path introduces no new leakage of privileged data.ResultSchemaandFinalGuard(both gated onrunErr == nil), but those are delivery/validation seams, not security controls — no authz/redaction gate is bypassed.MaxToolCalls == 0(unlimited) is a resource bound only;MaxIterationsandMaxRuntimestill bound the run, so the default doesn't remove any DoS ceiling.LogEventpayloads carry only the wrapped sentinel error string and integer counts — no secrets.VERDICT: No material issues found
Reviewed through the Security lens (authn/authz, injection, SSRF, deserialization, secret leakage, input validation, insecure defaults, tainted-data flow).
salvagePhaseTranscriptreads only assistantResponse.Text(), and salvage is opt-in (SalvageSingleLoopMaxStepsdefault false), off precisely for structured-output/review hosts. No system-prompt or tool-output content is exposed that the normal success path wouldn't already deliver.MaxToolCalls <= 0 == unlimitedis backward-compatible and does not disable the still-presentMaxIterations/MaxRuntimebounds, so it introduces no unbounded-execution/DoS regression.single_loop_salvaged,max_tool_calls_reached) contain only the sentinel error string and call counts — no secret/credential leakage.Nothing in my lane is materially wrong here.
🎯 Correctness — No material issues found
I've verified the core mechanism against the executus code. I could not reach the majordomo module source through the sandbox, but the step-ceiling contract this PR relies on (
WithMaxStepsFuncpolled each iteration,stepIdx < ceiling) is the same one the pre-existing critic path already depends on, so it's an established invariant rather than a new assumption.VERDICT: No material issues found
I traced the tool-call cap arithmetic and the salvage/relabel control flow carefully:
executor.go:396-435,critic.go:130-145): step0→toolCallsSeen=1, step1→toolCallsSeen=2setscapHit; the next poll returnsstepsSeen=2, sostepIdx 2 < 2fails →ErrMaxSteps→ relabeled. cap=1 yields exactly 1 call. Verified againstTestMaxToolCalls_ForcesExitAndSurfacesSentinel.capHit && errors.Is(runErr, agent.ErrMaxSteps)(executor.go:535) can only be true on a cap-driven exit: a capped step carriesn>0tool calls so it never doubles as a final-answer turn, and whencapHitis set the ceiling func returnsstepsSeen(≤ base), so the driven-down ceiling always fires at/before the natural one. Natural step exhaustion (cap not reached) keeps plainErrMaxSteps— confirmedcapHitstays false whentoolCallsSeen < maxCalls.maxCalls<=0andb==nil,stepCeilingOptionfalls through toreturn base— equivalent to the oldWithMaxSteps(base), and the critic path already exercisedWithMaxStepsFuncfor the base fallback.MaxToolCallsgating (len(ra.Phases)==0) keeps phased runs inert; phased uses its own ceiling, so the accumulating counters are harmless there.isBudgetExhaustion/ salvage correctly matchesErrMaxToolCalls, and salvage is reachable only onrunErr != nil(ResultSchema/FinalGuard arerunErr==nil-gated).RunStateAccessornow reports the real cap (runengine.go:151). Imports (fmt/errors/strings) all present.Two sub-material notes (not in scope to block):
executor.go:631— when salvage fires after a tool-call-cap exit (ErrMaxToolCalls), the appended note still says "reached its step budget". Slightly misleading to the end user; the tool-call cap isn't a step budget. TheLogEventrecords the true error, so it's cosmetic.toolCallsSeencounter starts at 0, so the "TOTAL … across the whole run" wording onMaxToolCalls(agent.go:40) isn't literally honored across a resume — butMaxIterationsresets the same way on resume, so this is consistent with existing bounding semantics, not a regression this PR introduces.🧹 Code cleanliness & maintainability — Minor issues
All three findings are confirmed against the actual code:
salvagePhaseTranscript(phases.go:301) has exactly two call sites — phases.go:252 and executor.go:629 — so thePhasein the name is indeed now stale for the shared single-loop path.isBudgetExhaustion, which now includesErrMaxToolCalls— so the note is reachable for a tool-call-cap exit.stepCeilingOption(critic.go:130) takes two bare*intintotoolCallsSeen/stepsSeen(executor.go:393), with the crossing/capHitlogging in the observer (421-435) and the relabel further down — the cross-site mutable coupling is real.All findings survive.
VERDICT: Minor issues
The change is clean, well-factored, and heavily documented. It reuses
salvagePhaseTranscriptand folds the two exhaustion predicates into one sharedisBudgetExhaustionrather than duplicating logic — good moves for maintainability. TheNewRunStateAccessorfix (realmaxCallsinstead of hardcoded0) is a genuine correctness-of-reporting cleanup, and its signature already had the param, so no ripple. Within my lens I found only small nits:run/phases.go:301—salvagePhaseTranscriptname is now stale. The function is no longer phase-specific:run/executor.go:629calls it for single-loop salvage, and the shared-predicate doc atrun/phases.go:284even says "Shared by the phased pipeline and the single-loop salvage." ThePhasein the name now misleads a reader into thinking it's phased-only. Low-churn fix: rename tosalvageTranscript(it's unexported, so a mechanical rename acrossphases.go/executor.go). Verified by grep: only these two call sites. Confidence: high; severity: trivial.run/executor.go:631— salvage note says "step budget" even for a tool-call-cap exit. The same block handlesErrMaxToolCalls(viaisBudgetExhaustion), but the user-facing note is hardcoded"reached its step budget before composing a final answer". For a run that exhaustedMaxToolCalls(not steps), that wording is inaccurate. Minor because it's best-effort prose, but since the PR's whole point is to distinguish the two causes (newErrMaxToolCallssentinel), the note undercuts that distinction. Verified: the block is reached for both sentinels. Confidence: high; severity: small.run/critic.go:130—stepCeilingOptiontakes two raw*intinto executor-loop locals. The method now reaches intotoolCallsSeen/stepsSeen(declared atexecutor.go:393) via pointers, while the crossing detection +capHitlogging live back in the step observer (executor.go:421-435) and the relabel lives further down. SoMaxToolCallsenforcement is spread across three sites coupled by shared mutable state. It's thoroughly commented and the same-goroutine invariant is stated, so it works and isn't blocking — but a small struct (e.g. atoolBudget{seen, steps int}passed by pointer, or a closure built inexecutor.go) would make the coupling explicit and read better than two bare*intparams. Confidence: medium (design judgment); severity: small.Not flagged (checked and fine): the
maxStepsOption→stepCeilingOptionrename correctly preserves theb == nilbehavior via the innerif b != nilguard; theisPhaseBudgetExhaustion→isBudgetExhaustionrename hits all call sites (phases.go:251,phases.gocase at 159,executor.go:627); tests are clear and well-named.⚡ Performance — No material issues found
VERDICT: No material issues found
I reviewed this change strictly through the Performance lens and verified the hot-path implications against the checked-out code.
What I checked:
Per-step tool-call accounting (
run/executor.go:391step observer:toolCallsSeen += n,stepsSeen = s.Index + 1, one comparison + one-shotcapHitflag). This is O(1) per step and runs on majordomo's existing Run goroutine — no new goroutine, no locking, no allocation. TheLogEventfires at most once (guarded by!capHit). No regression.stepCeilingOptionnow always usesWithMaxStepsFunc(run/critic.go:130-145), whereas the no-critic case previously used the staticWithMaxSteps(base). The closure is invoked once per step boundary and does only a couple of int derefs/comparisons. Steps map to LLM round-trips (seconds each), so this is immeasurable overhead — a micro-optimization at most, not a material regression, and I'm deliberately not flagging it as such.salvagePhaseTranscript(run/phases.go:301-327), now reused by the single-loop salvage path. Verified it usesstrings.Builder(no quadratic+=concat), iterates each step's narration once, and tail-trims to the boundedmaxSalvageBytes = 8000. It runs at most once, only on the terminal error path — not in any loop. No unbounded growth.isBudgetExhaustion(run/phases.go:286) adds one moreerrors.Is— constant-time, called on error paths only.No N+1, no work added inside the agent loop's hot path, no unnecessary copies/allocations, no unbounded accumulation, and no blocking calls introduced. The tool-call cap itself is a performance win (bounds total work). Nothing material in my lane.
🧯 Error handling & edge cases — Minor issues
Both findings verify against the actual code.
isBudgetExhaustion(phases.go:286-289) includesagent.ErrToolLoop, and the single-loop salvage block (executor.go:626-637) does relabelrunErr = niland deliver the partial. The hardcoded "reached its step budget" note is at executor.go:631. Both survive.VERDICT: Minor issues
run/executor.go:626/run/phases.go:286—ErrToolLoopgets downgraded to a "successful partial" by single-loop salvage.isBudgetExhaustionincludesagent.ErrToolLoop, which is the tool-error guard (consecutive tool errors / repeated identical calls) — a signal of a stuck/malfunctioning run, not merely a spent step budget. WithSalvageSingleLoopMaxStepson, such a run is now relabeledrunErr = nil, statusok, and delivered as a partial answer. This is opt-in and mirrors the pre-existing phased behavior (the oldisPhaseBudgetExhaustionalready includedErrToolLoop), so it's defensible for an interactive host — but the unhappy-path semantics are worth a conscious call-out: a genuinely broken tool loop is presented as success. Low severity given the opt-in gate; flagging so it's an intentional choice, not an accident.run/executor.go:631— salvage note hardcodes "reached its step budget" for every exhaustion cause, including anErrMaxToolCallscap. When the exit was the tool-call cap (orErrToolLoop), the user-facing note still says "reached its step budget before composing a final answer," which misdescribes the actual cause. Trivial/cosmetic — behavior is correct, only the disclaimer wording is imprecise. Suggest wording it generically (e.g. "reached its budget") or branching on the cause.Nothing blocking in the error-handling / edge-case lens: nil-safety, the forced-exit accounting, empty-output guards, and error preservation are all handled carefully and are covered by the new tests.
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
The changes introduce a new
MaxToolCallscap, aSalvageSingleLoopMaxStepsopt-in flag, and a renamed/extendedisBudgetExhaustionfunction. Through the security lens:Audit log data:
rec.LogEvent("single_loop_salvaged", map[string]any{"error": runErr.Error()})atexecutor.go:633—runErrat that point is one of the pre-defined sentinels (ErrMaxToolCalls,ErrMaxSteps,ErrToolLoop), all hardcoded strings derived from the system, not user input. No credential or user-data leakage risk.MaxToolCallsfield: Comes fromRunnableAgent, which is a host-controlled DTO, not a user-supplied field at a system boundary.maxCallsis only ever interpolated as%dinto an error string — no injection surface.Salvaged transcript in output:
salvagePhaseTranscriptwrites LLM response text intorunRes.Output. This is the expected behavior of the package (the entire run loop processes and returns LLM output). The single-loop salvage path does not feed this into the phase template engine — it writes directly torunRes.Outputafter the template path has already completed.isBudgetExhaustionaddingErrMaxToolCalls: This function is also called from the phased pipeline path. Verified (executor.go:480, 516–518) thatstepCeilingOptionis only wired inside thelen(ra.Phases) == 0branch, and theErrMaxToolCallsrelabeling (executor.go:535–537) only runs in that same branch — soErrMaxToolCallscannot propagate to the phased path under normal code flow.Pointer parameters in
stepCeilingOption:&toolCallsSeenand&stepsSeenare pointers to local ints read by theWithMaxStepsFuncclosure. The PR asserts (and the step observer architecture confirms) these run on the same majordomo goroutine, so no race. No authn/authz or injection vector here.🎯 Correctness — No material issues found
I've now traced every decision path through the critical new code. My analysis is complete.
Verdict: No material issues found
I examined the following in detail before forming this conclusion:
stepCeilingOptionceiling value (critic.go:131–144)The function returns
*steps(=s.Index + 1of the capping step) when the cap fires.stepsSeenis set BEFORE the cap check inside the observer, so at poll time*steps >= 1. majordomo's loop checkstepIdx < ceilingwithstepIdx = completedStepsandceiling = completedStepscorrectly evaluates tofalse, stopping the loop. Thereturn 1fallback (steps == nil || *steps == 0) is unreachable in practice becausetoolCallsSeen > 0requires at least one completed step, which meansstepsSeen >= 1. Verified by tracing scenarios with 1–3 tool calls per step.ncounting and tool-call accumulation (executor.go:408–425)n = min(len(calls), len(s.Results))is the pairwise zip count — exactly matching the audit recording loop above it.toolCallsSeen += nandstepsSeen = s.Index + 1are both set before the cap detection check, so the cap-crossing observation is always based on the just-completed step. ✓Relabeling and
errors.Ischain (executor.go:535–536)fmt.Errorf("%w (max %d)", ErrMaxToolCalls, maxCalls)wrapsErrMaxToolCalls. After relabeling:errors.Is(runErr, ErrMaxToolCalls)= true;errors.Is(runErr, agent.ErrMaxSteps)= false (chain ends atErrMaxToolCalls, which does not itself wrapErrMaxSteps).isBudgetExhaustioncorrectly matches the relabeled error via theErrMaxToolCallsbranch. ✓Phased-pipeline isolation
capHitis gated onlen(ra.Phases) == 0;stepCeilingOptionis only wired in the single-loop path; the relabeling block is inside theif len(ra.Phases) == 0scope. Phases can never produceErrMaxToolCalls; its inclusion inisBudgetExhaustionis forward-compatible. ✓Salvage ordering
The salvage block runs after
ResultSchemaandFinalGuard(both gated onrunErr == nil), so they can never fire on a cap/budget error. The salvage conditionisBudgetExhaustion(runErr) && runRes != nil && Output == ""is ordered correctly. ✓RunStateAccessorfix (executor.go:218)Was hardcoded
0; now passesmaxCalls(the resolvedra.MaxToolCalls). WhenmaxCalls == 0(unlimited), this reproduces the old value. ✓No-critic/no-cap case
stepCeilingOptionnow always returnsWithMaxStepsFuncinstead ofWithMaxStepsfor the nil-critic path. The two are functionally equivalent for a constant-valued function, and the pre-existing critic tests rely onWithMaxStepsFuncalready, confirming majordomo treats them identically. ✓🧹 Code cleanliness & maintainability — Minor issues
All four findings are confirmed against the actual code. Here is the corrected review:
Verdict: Minor issues
Findings
1. Dead accumulation of
toolCallsSeen/stepsSeenoutside the phase guard —executor.go:425-426toolCallsSeen += nandstepsSeen = s.Index + 1run unconditionally insidestepObserverfor both single-loop and phased runs. The comment at lines 422–424 says "gate the crossing detection on Phases", but only thecapHitdetection is gated (line 427) — the accumulation itself runs for every phased-run step too.stepCeilingOption(the only reader) is called exclusively insideif len(ra.Phases) == 0(executor.go:518); the phased path usesagent.WithMaxStepsinstead. So for phased runs, both variables are accumulated but never read.Fix: move both accumulation lines inside the
if len(ra.Phases) == 0guard so the code matches the comment.Severity: small — harmless dead work today, but the mismatch between comment and code will mislead anyone who later extends phased-run cap enforcement.
2. Misleading nil guards on non-nullable params in
stepCeilingOption—critic.go:132-135The closure checks
toolCalls != nil && steps != nilbefore using them. There is exactly one call site (executor.go:518), which always passes&toolCallsSeenand&stepsSeen(addresses of stack-allocated ints, never nil). The nil guards suggest these are optional/nullable, but passingnilwhenmaxCalls > 0would silently disable the cap with no diagnostic. This is inconsistent with the rest of the file's nil-safety pattern (recordStep/recordToolStartnil-check the receiver, not the parameters).Fix: remove the
toolCalls != nil && steps != nilguards and, if defensive intent is desired, replace with a precondition comment or a panic ("contract: non-nil when maxCalls > 0"). The nil receiver (b == nil) is correct and should stay.Severity: small — creates false API surface.
3.
isBudgetExhaustionlives inphases.gobut is a cross-file shared helper —phases.go:281Its own doc comment says "Shared by the phased pipeline and the single-loop salvage in executor.go". It is called in executor.go (line 627) and twice in phases.go (lines 159, 251). Defining it in phases.go — a file named for one consumer — puts a package-level function in a misleading home.
Fix: move to
executor.go(nearest toErrMaxToolCallswrapping) or toports.gonext toErrMaxToolCalls. Same package, no compile impact, but the wrong home invites re-duplication.Severity: trivial.
4.
ErrMaxToolCallssentinel inports.gois not a port/seam definition —ports.go:25ports.godefines Ports, seam interfaces, andErrCriticKill(which belongs because it's the cancellation cause wrapping a Critic interface signal).ErrMaxToolCallsis an internal budget-enforcement sentinel with no connection to any seam. Its doc comment even referencesisBudgetExhaustion, a private helper. Placing it with the seam definitions is a loose grouping.Fix: move to
executor.gonear the cap-relabeling logic, or to a smallbudget.goalongside the cap enforcement. Trivial change, purely organizational.Severity: trivial.
⚡ Performance — No material issues found
I now have everything I need for a performance verdict. The key questions were:
WithMaxStepstoWithMaxStepsFuncfor the unlimited case add meaningful overhead?stepCeilingOptionnow always returnsWithMaxStepsFunc— even whenmaxCalls <= 0andb == nil. Previously this returned the staticWithMaxSteps(base). At step granularity (each step = one full LLM round-trip, hundreds of milliseconds minimum), the extra function-pointer call and two pointer dereferences per iteration are completely dominated by network latency. Not a material regression.toolCallsSeen += nandstepsSeen = s.Index + 1run unconditionally in the step observer regardless of whethermaxCalls > 0, meaning the counters accumulate even when they can never be used to trip the cap. Two integer assignments per step — trivially cheap relative to the rest of the observer body and the LLM call itself.map[string]any{...}inLogEventonly fires whencapHitis set, i.e. at most once per run. Not hot.isBudgetExhaustionnow has one extraerrors.Ischain item. This only runs on the error path. Not hot.No other allocations or quadratic behaviors introduced.
Verdict: No material issues found
The PR adds two integer counters and widens the
WithMaxStepsFuncclosure to handle the unlimited/no-critic case. Every new computation is O(1) per step and orders of magnitude cheaper than the LLM round-trips that dominate each step. There is no hot-loop regression, no new allocation on the happy path, and no unbounded growth.toolCallsSeen/stepsSeenaccumulate even whenmaxCalls <= 0(they'll never be read by the ceiling function in that case). Verified atexecutor.go:425–426. Two int writes per step — cannot justify calling this material.🧯 Error handling & edge cases — No material issues found
No material issues found
Examined through the error handling & edge cases lens: I read
executor.go(single-loop path, step observer, salvage block, result construction),critic.go(the newstepCeilingOption),phases.go(isBudgetExhaustion,salvagePhaseTranscript), and the new test file. Key checks:Nil safety on
runRes: The salvage block correctly guardsrunRes != nilbefore callingsalvagePhaseTranscript.runAgentwrapsag.Run()and can return(nil, ErrMaxSteps); the guard handles this without a panic. No issue.Error chain semantics:
runErr = fmt.Errorf("%w (max %d)", ErrMaxToolCalls, maxCalls)preserves theErrMaxToolCallssentinel via%w, soerrors.Is(runErr, ErrMaxToolCalls)is true andisBudgetExhaustioncorrectly fires.errors.Is(relabeled, agent.ErrMaxSteps)is false, so the relabeling is clean. Verified inphases.go:286-290.capHitfalse-positive risk:capHitis set only whentoolCallsSeen >= maxCalls(step observer, single-loop only vialen(ra.Phases)==0gate). The relabelingcapHit && errors.Is(runErr, agent.ErrMaxSteps)can only fire if the tool-call threshold was genuinely crossed — the two conditions together correctly pinpoint a cap-triggered exit. No false positive path found.stepCeilingOptionnil receiver: Called ascritic.stepCeilingOption(...)wherecriticmay be a nil*criticBinding. The method checksif b != nilbefore accessingb.h, so a nil receiver is safe. Confirmed atcritic.go:130-145.Ordering: relabeling → ResultSchema → FinalGuard → salvage: The relabeling runs immediately after
runAgent; ResultSchema and FinalGuard are gated onrunErr == niland therefore skip on budget errors; salvage runs after both.runErr.Error()in the log call is evaluated beforerunErr = nil, so no nil dereference. Confirmed atexecutor.go:669-675— result construction readsrunRes.OutputandrunErrafter the salvage block.Plain
intcounters (toolCallsSeen,stepsSeen): Only accessed from the step observer goroutine andstepCeilingOption(both called on majordomo's Run goroutine). No atomics needed; no race path introduced.isBudgetExhaustionin phases: Now includesErrMaxToolCalls, which can never surface from the phased path today (capHit is gated onlen(ra.Phases)==0). The addition is dead code currently but harmless and correct.fakeCriticHandle/fakeCriticin tests: Defined incritic_test.go; reused bysalvage_test.go— no missing type.Nothing concerning in the unhappy paths introduced by this diff.
deepseek-v4-pro: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
The diff is additive resource-limiting and error-recovery plumbing. Through the security lens:
salvagePhaseTranscriptfunction is pre-existing and unchanged.MaxToolCallsis a host-configured integer cap;SalvageSingleLoopMaxStepsis a host-configured boolean. Neither introduces a privilege or access-control decision.max_tool_calls_reached,single_loop_salvaged) record only integer counters and sentinel error strings (ErrMaxToolCalls,ErrMaxSteps,ErrToolLoop) — no model output, tool arguments, or user data is logged.MaxToolCalls <= 0= unlimited (backward-compatible), andSalvageSingleLoopMaxStepsdefaults tofalse(preserving the prior hard-error behavior). Both are safe zero-values.MaxToolCallsis itself a resource-limiting control, reducing the blast radius of a runaway tool-calling loop.🎯 Correctness — Minor issues
The finding is confirmed. Let me verify the exact line numbers and the full chain:
capHit && errors.Is(runErr, agent.ErrMaxSteps),runErris relabeled to wrapErrMaxToolCalls.isBudgetExhaustion(runErr), which matchesErrMaxToolCalls(viaerrors.Is(err, ErrMaxToolCalls)at phases.go:289)."reached its step budget before composing a final answer"— even when the actual cause was the tool-call cap.The test
TestMaxToolCalls_SalvagedWhenEnabled(salvage_test.go:183-199) confirms the salvage path fires for tool-call exhaustion, but only asserts"partial finding"in the output, not the note text — so the misleading note is real and untested.The finding survives verification.
Verdict: Minor issues
run/executor.go:631— Misleading salvage note for tool-call exhaustion (verified). When the single-loop salvage path fires for anErrMaxToolCallsexit, the appended note hardcodes"reached its step budget before composing a final answer". But the actual cause was the tool-call budget, not the step budget. The condition at line 626–628 gates onisBudgetExhaustion(runErr), which matches bothErrMaxStepsandErrMaxToolCalls(the latter was relabeled at line 535–537). A host that deliversres.Outputto the user will show a note blaming the wrong budget. The fix: either make the note generic ("reached its budget limit…") or branch onerrors.Is(runErr, ErrMaxToolCalls)to say"reached its tool-call budget…".🧹 Code cleanliness & maintainability — Minor issues
Both findings are confirmed against the actual code. The salvage note at
executor.go:631is indeed hardcoded to say "step budget" regardless of which of the three exhaustion sentinels fired, andsalvagePhaseTranscriptis now called from the single-loop path despite its phase-specific name and doc comment.Verdict: Minor issues
run/executor.go:631— Salvage note text is misleading for non-step-budget exhaustion. The hardcoded note"reached its step budget before composing a final answer"is attached to the salvaged output regardless of which exhaustion cause fired.isBudgetExhaustionmatches three sentinels:ErrMaxSteps(step budget — accurate),ErrToolLoop(tool-error guard — not a step budget), and the newErrMaxToolCalls(tool-call cap — not a step budget). For the latter two the user sees "step budget" when the real cause was something else. The phased path has the same pre-existing inaccuracy forErrToolLoop, but this diff adds a new mismatch forErrMaxToolCalls. The note should reflect the actual cause (e.g., branch onrunErrto say "tool-call budget" vs "step budget" vs "tool-error guard"). Verified by readingexecutor.go:626-637andphases.go:286-290.run/phases.go:301—salvagePhaseTranscriptname is now misleading. The function was written for the phased pipeline (its doc comment says "reconstructs a best-effort phase output"), but this diff now calls it from the single-loop salvage path inexecutor.go:629. A reader encountering the call site has to chase the definition to realize it's not phase-specific. Consider renaming tosalvageTranscript(or similar) since it's now shared across both paths. Verified by readingphases.go:297-327andexecutor.go:629.⚡ Performance — No material issues found
Verdict: No material issues found
The changes are performance-neutral. I verified the hot paths by reading the full files:
stepCeilingOptionclosure (run/critic.go:130-144): Called once per step by majordomo. The closure body is O(1) — two integer comparisons, two nil checks, two pointer dereferences. The oldmaxStepsOptionwas also called per step (when a critic was bound). The only micro-regression is that the no-critic + no-cap case now usesWithMaxStepsFuncinstead of the staticWithMaxSteps(base), adding ~two nil checks per step. In the context of LLM calls taking seconds, this is negligible.Step observer additions (
run/executor.go:425-435):toolCallsSeen += n,stepsSeen = s.Index + 1, and a conditional cap-hit check — all O(1) per step, no allocations, no blocking.Salvage path (
run/executor.go:626-637): Runs once at end-of-run on the error path only.salvagePhaseTranscriptiteratesres.Steps(bounded byMaxIterations, default 12) and usesstrings.Builder— efficient and bounded.isBudgetExhaustion(run/phases.go:286-289): Called per-phase (not per-step), O(1)errors.Ischecks.RunStateAccessor(run/runengine.go:131): Now receives the realmaxCallsinstead of hardcoded0— a field assignment, no runtime cost change.No N+1 queries, no unnecessary allocations in hot paths, no unbounded growth, no blocking calls, no quadratic behavior introduced.
🧯 Error handling & edge cases — Minor issues
The finding is confirmed. The salvage note at
executor.go:631is hardcoded to say "step budget" regardless of which budget was exhausted. TheisBudgetExhaustionfunction (phases.go:286-289) matchesErrMaxToolCalls, so the salvage path fires for tool-call cap exhaustion too — but the user-facing text still blames the step budget. This is a real, though small, UX issue.Verdict: Minor issues
run/executor.go:630-631— Salvage note text is misleading forErrMaxToolCalls(verified by readingexecutor.go:626-637andphases.go:286-289). The hardcoded note says "reached its step budget" regardless of which budget was exhausted. When the run hitsMaxToolCalls(relabeled at line 536),isBudgetExhaustionmatches it (line 289), so the salvage path fires — but the user-facing text still blames the step budget. A host or user reading the salvaged output sees "step budget" when the real cause was the tool-call cap, which is confusing. The fix is to vary the note based on the error: e.g., checkerrors.Is(runErr, ErrMaxToolCalls)and say "tool-call budget" instead.This is a small UX edge-case in the error path; functionally the salvage and downgrade work correctly either way.
glm-5.2: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
ResultSchemavalidation (intentional, default-off): Atexecutor.go:626-636, the single-loop salvage path runs only whenrunErr != nil, whileResultSchemavalidation atexecutor.go:544is gated onrunErr == nil— so a salvaged partial answer is delivered as a successful result (runErr = nil) without ever passing schema validation. If a host were to set bothSalvageSingleLoopMaxSteps: trueandinv.ResultSchema, unvalidated model narration would be marked "ok" and handed to the host as a valid answer. This is a documented design tradeoff (salvage is meant for interactive research hosts; structured-output hosts keep it off, and the flag defaults tofalse), so it is not a vulnerability — but there is no guard preventing the two from being combined. Verified by readingexecutor.go:544andexecutor.go:626. Severity: trivial / out-of-lane-adjacent; no enforcement gap worth blocking on.MaxToolCallsis host-controlled config, not untrusted input:RunnableAgent.MaxToolCalls(agent.go:40) is set by the host, with<= 0meaning unlimited. No untrusted-user input reaches it; negative values are handled as a no-op rather than misinterpreted as a ceiling. No validation gap.salvagePhaseTranscript,phases.go:301) plus a fixed English note intoOutput; no template expansion, no shell/SQL/path interpolation. The loggedrunErr.Error()insingle_loop_salvagedis a budget-error sentinel string, not user/model-supplied free text — minimal leakage surface. Verified.ErrMaxToolCallsrelabel (executor.go:535):capHit && errors.Is(runErr, agent.ErrMaxSteps)could mislabel a genuine step-ceiling exhaustion that merely coincided with crossing the tool-call count as a tool-cap exit. That is a correctness/status-reporting concern (another lens), not a security one — no authz/authn or injection implication.🎯 Correctness — No material issues found
VERDICT: No material issues found
I reviewed the change through the correctness lens, focusing on the step-ceiling / cap-relabel logic, the salvage downgrade, and the shared exhaustion classifier.
stepCeilingOption cap-hit math (
run/critic.go:130): When the cap is reached the function returns*steps(=s.Index + 1, the completed-step count). With majordomo'sstepIdx < ceilingcheck, the next iteration hasstepIdx == ceilingand exits withErrMaxSteps. The<= 0clamp to1correctly avoids majordomo falling back to its static ceiling when no step has completed yet. The priority ordering (cap → critic → base) is sound, and themaxCalls <= 0path is a true no-op (WithMaxSteps(base)equivalent). Verified by readingcritic.go:113-145and the observer counters inexecutor.go:393-436.Relabel condition (
run/executor.go:535):capHit && errors.Is(runErr, agent.ErrMaxSteps).capHitis gated onlen(ra.Phases) == 0(single-loop only), set once and sticky. A cap-triggered exit always comes back asErrMaxSteps(the driven-down ceiling), and a final-answer turn carries zero tool calls so it can't spuriously setcapHit. The wrapped error preservesErrMaxToolCallsvia%w, soisBudgetExhaustionanderrors.Is(..., ErrMaxToolCalls)both match. Verified.Salvage downgrade (
run/executor.go:626): Gated onSalvageSingleLoopMaxSteps && isBudgetExhaustion(runErr) && runRes != nil && Output empty. It runs only after a budget error (ResultSchema/FinalGuard are gated onrunErr == nil, so neither fired), mirroring the phased path.runErr = nilcorrectly downgrades to a delivered partial result. Verified againstphases.go:286-289(sharedisBudgetExhaustion) andrunOnePhasesalvage.RunStateAccessor fix (
run/runengine.go:151,executor.go:218): Now threadsmaxCallsthrough instead of the hardcoded0. Correct.Trivial (not blocking): the salvaged note hardcodes "reached its step budget" (
executor.go:631) even when the actual cause wasErrMaxToolCalls(tool-call budget). Cosmetic wording mismatch only; no behavioral impact.No correctness bugs found within this lens.
🧹 Code cleanliness & maintainability — No material issues found
Verdict: No material issues found
I reviewed the diff through the cleanliness/maintainability lens and verified the touched code in its checked-out state.
stepCeilingOption(critic.go:130) cleanly replacesmaxStepsOptionand is the only caller of the ceiling option now (confirmed by grep: no remainingmaxStepsOptionreferences). The priority doc-comment is accurate and matches the implementation.isBudgetExhaustion(phases.go:281) properly generalizes the oldisPhaseBudgetExhaustion; all three call sites (phases.go:159, phases.go:251, executor.go:627) and theports.godoc reference were updated consistently — no stale name left behind (grep confirms zero remainingisPhaseBudgetExhaustion).toolCallsSeen/stepsSeen/capHit) is declared once at executor.go:393 and threaded only where needed (the single-loopstepCeilingOptioncall at executor.go:518). The phased path intentionally uses a plainWithMaxSteps(maxIter)(phases.go:239) and the gatelen(ra.Phases) == 0in the cap-hit log (executor.go:427) keeps the sharedstepObserverfrom emitting misleading audit events on phased runs — consistent with the documented "single-loop only" scope.ErrMaxToolCalls(ports.go) sits alongsideErrCriticKillfollowing the existing pattern; theisBudgetExhaustionmembership keeps the two exhaustion paths from drifting.Nothing in my lane is materially wrong.
⚡ Performance — No material issues found
VERDICT: No material issues found
Through the performance lens, this change is clean:
No new hot-path allocations or copies. The tool-call budget accounting adds two local ints and a bool to the step observer closure (
executor.go:393-394);stepCeilingOptionpasses pointers to them. The closure is already per-run, not per-step, and the per-step work is+= nand a comparison — no new slicing or map churn. ThecapHitflag gates theLogEvent(a single map allocation) to fire exactly once per run, not per step.No quadratic/unbounded growth introduced.
salvagePhaseTranscriptis reused unchanged; it already bounds output tomaxSalvageByteswith a tail-trim (phases.go:318-325). The single-loop salvage path calls it once at the end of the run, so there's no repeated or accumulating reconstruction.WithMaxStepsFuncis invoked once per step by majordomo (the ceiling poll), and the new branch is a constant-time int comparison before the existing critic poll — strictly additive, no extra iteration over steps or tool calls. The*steps/*toolCallsreads are pointer dereferences, not scans.No missing pagination/limits or blocking calls on the hot path. The
MaxToolCallscap actually tightens bounding (it can only exit the loop sooner), so it is a performance improvement on runs that would otherwise run toMaxIterationswhile burning tool calls.I verified the step-observer accounting loop (
executor.go:396-436), thestepCeilingOptionconstant-time branches (critic.go:130-145), and the boundedsalvagePhaseTranscript(phases.go:301-327). Theagent.WithMaxStepsFuncsymbol is referenced throughout the existing codebase and I could not locate its declaration inside the sandbox (theagentpackage source isn't checked out here), so I could not confirm the per-step invocation frequency directly — but the diff's own comments and the existing critic-driven dynamic ceiling (WithMaxStepsFuncalready in use pre-PR) confirm this is the established per-step polling pattern, so the additive branch adds no per-step cost of concern.No material performance issues in my lane.
🧯 Error handling & edge cases — Minor issues
Verdict: Minor issues
Through the error-handling & edge-case lens, I verified the new unhappy paths in
executor.goandphases.go. The relabel (%wwrap ofErrMaxToolCallsat line 536), the sharedisBudgetExhaustionmatching it viaerrors.Is, thecapHitgating onlen(ra.Phases)==0(so phased runs don't mis-log), and thestepsSeen = s.Index+1ceiling math all check out — the loop exits on the next iteration andErrMaxStepsis correctly relabeled.salvagePhaseTranscriptis nil-safe, so a nilrunResis handled. Cancellation is not mis-salvaged (a cap hit + context cancel leavesrunErras a cancel error, whichisBudgetExhaustionrejects).One real edge-case mismatch:
run/executor.go:631— salvage note is wrong for tool-call-cap exits. The single-loop salvage hardcodes the note"reached its step budget before composing a final answer"regardless of which budget triggered it. When the exit wasErrMaxToolCalls(relabeled atexecutor.go:536), the delivered partial Output now tells the user/auditor the run hit its step budget, when it actually hit its tool-call cap. This is user-facing prose emitted on a specific error path, and it's misleading precisely in the newMaxToolCallscase the PR adds. Verified: the salvage block (lines 626–637) runs after the relabel and uses a single fixed string;runErr.Error()is logged (line 633) but the Output note is unconditional. Suggested fix: branch the note onerrors.Is(runErr, ErrMaxToolCalls)(check beforerunErr = nil), e.g."reached its tool-call budget..."vs"reached its step budget...".No other edge-case issues found in my lane: nil/empty guards are present, the
<= 0unlimited path is inert, the clamp-to-1 instepCeilingOptionis defensive (and unreachable in practice sincestepsSeenis always≥1after a completed step), and no errors are swallowed —runErr = nilonly happens after a non-empty salvage is actually produced.kimi-k2.6:cloud (ollama-cloud) — Blocking issues found
Verdict: Blocking issues found — 5 reviewers: security, correctness, maintainability, performance, error-handling
🔒 Security — No material issues found
No material issues found
SalvageSingleLoopMaxStepsis opt-in (defaultfalse) and only affects error-to-success downgrading when enabled; no new trust boundary is crossed.MaxToolCalls <= 0is explicitly unlimited, preserving backward compatibility. No integer underflow risk — the check ismaxCalls > 0.fmt.Errorf("%w (max %d)", ErrMaxToolCalls, maxCalls)is safe;maxCallsis an int, not user-controlled untrusted data.stepCeilingOptionreads counters via pointers from the same goroutine (documented atrun/executor.go:389-392); no data race or atomic issue.isBudgetExhaustioncorrectly includesErrMaxToolCallsalongside existing sentinels; no logic gap."max_tool_calls_reached","single_loop_salvaged") emit only numeric caps and error strings — no credential or PII leakage.🎯 Correctness — Blocking issues found
Blocking issues found
run/executor.go:393— Resumed runs bypass theMaxToolCallscap.toolCallsSeenis declared as a localintat line 393 and is initialized to 0 on everyExecutor.Runinvocation. When a run is resumed from a checkpoint (lines 522–528),toolCallsSeenis not restored fromRunCheckpointState(which only persistsMessagesandIteration). Consequently, a resumed execution starts counting tool calls from zero, allowing the total across the original + resumed execution to exceedra.MaxToolCalls. This is a material violation of the cap contract.Fix: Persist the cumulative tool-call count in
RunCheckpointStateand restore it intotoolCallsSeenwhenresume != nil.run/executor.go:630–631— The salvage note hard-codes “reached its step budget” even when the exhaustion cause wasErrMaxToolCalls. This is semantically incorrect and will mislead users about which budget was exhausted.Fix: Vary the note text based on the actual error (e.g.
runErr) so a tool-call cap hit says “tool-call budget”.run/executor.go:585— The FinalGuard nudge round usesagent.WithMaxSteps(finalNudgeMaxSteps)directly and does not includestepCeilingOption, so theMaxToolCallscap is not enforced during nudge-round tool dispatches. The sharedobsstill incrementstoolCallsSeen, but without the dynamic ceiling the nudge round can push the total past the cap.Fix: Compose the tool-call ceiling into the nudge-round options (or explicitly document the nudge round as exempt).
🧹 Code cleanliness & maintainability — No material issues found
No material issues found
run/executor.go:631— The hardcoded salvage note says "reached its step budget before composing a final answer", butisBudgetExhaustionnow matchesErrMaxToolCalls(tool-call cap) andErrToolLoop(tool-error guard) as well. When the run exhausts its tool-call budget, telling the user/host it hit its step budget is misleading and will confuse downstream consumers that surface this text. The note should be parameterized to match the actual cause, or at least generically say "reached its budget".Verified by reading
executor.golines 626–637 andphases.golines 286–290;isBudgetExhaustionmatches three distinct sentinels, but the downstream message only names one of them.⚡ Performance — No material issues found
No material issues found
The PR's changes are additive and performance-neutral:
toolCallsSeen += n,stepsSeen = s.Index + 1) and a single bounded boolean check per step. ThestepCeilingOptionclosure adds a few integer comparisons per step-ceiling poll. Neither introduces allocations, locks, or unbounded work on the hot path.salvagePhaseTranscript) is gated bySalvageSingleLoopMaxStepsand budget exhaustion and emptyOutput. It executes at most once per run, on a cold path, and reuses the existing phased-runner truncation logic (maxSalvageBytes = 8000). No new unbounded growth or repeated large allocations.toolCallsSeen/stepsSeenlive on majordomo's Run goroutine, so plain ints suffice without atomics.🧯 Error handling & edge cases — No material issues found
No material issues found
stepCeilingOption), salvage path (SalvageSingleLoopMaxSteps), and error relabeling (ErrMaxToolCalls) through the error-handling & edge-cases lens.stepCeilingOptionguards against nil pointers (toolCalls != nil,steps != nil), handles themaxCalls <= 0unlimited case, and clamps the pre-step ceiling to1whenstepsSeen == 0(defensive, though unreachable in practice since the observer fires after step completion).runRes != nil,isBudgetExhaustion(runErr), non-emptyrunRes.Output(after trim), and non-empty salvaged text before downgrading the error to nil.isBudgetExhaustioncorrectly unwraps the wrappedErrMaxToolCallscreated viafmt.Errorf("%w ...", ErrMaxToolCalls, ...).capHitis only set for single-loop runs (len(ra.Phases) == 0), preventing misleading audit events on phased runs.toolCallsSeenandstepsSeencounters are plainints; the PR documents they live on the same goroutine as the ceiling poll. No atomics are needed under that invariant, and the code makes no claims beyond single-threaded use.Automated adversarial review by Gadfly — consensus across the model swarm. Advisory only — does not block merge.