feat(agent): add token/tool-call cost guardrails to the review path - #508
Conversation
|
🔍 OpenCodeReview found 5 issue(s) in this PR.
|
| // internal/agent). This method exists only so *scan.Agent satisfies the | ||
| // cmd/opencodereview.ResultProvider interface, keeping scan's JSON output | ||
| // unchanged (status stays success / completed_with_*). | ||
| func (a *Agent) BudgetExceeded() bool { return false } |
There was a problem hiding this comment.
Bug: BudgetExceeded() always returns false even when scan's token budget is actually exceeded.
The scan agent has its own MaxTokensBudget mechanism (see Args.MaxTokensBudget at line 69 and the budget gate in dispatchBatch at lines 483-493). When the budget is hit, dispatchBatch returns budgetHit=true, causing dispatchSubtasks to stop scheduling remaining batches — exactly the same semantics as the diff-review agent's budgetExceeded flag.
However, this method hardcodes return false, so when a scan run is truncated by the token budget:
- The JSON output status will be
"success"or"completed_with_warnings"instead of"budget_exceeded" - The
budget_exceededfield in the JSON summary will always be omitted/false - Callers relying on the typed
budget_exceededstatus (as documented in theResultProviderinterface) won't detect it
To fix this, track the budget-exceeded state in scan.Agent (similar to how internal/agent.Agent uses a.budgetExceeded) by propagating the budgetHit return value from dispatchBatch up to a field on the struct, then return that field here.
There was a problem hiding this comment.
Not a bug — intentional scoped-out follow-up, not a defect in this PR.
This change targets the diff-review path only. Scan already has its own MaxTokensBudget gate (dispatchBatch returns budgetHit) and this PR deliberately does NOT wire that into a typed budget_exceeded status — it is explicitly listed under "What We're NOT Doing": scan-mode changes beyond the shared emitRunResult plumbing are out of scope. The BudgetExceeded() stub returning false exists for one reason: *scan.Agent must satisfy the shared ResultProvider interface, which gained the method so agent.Agent can signal budget-exceeded. Scan's JSON output is intentionally unchanged (status stays success / completed_with_).
Propagating scan's budgetHit into a typed status is a reasonable follow-up for review/scan parity — happy to open a separate issue. Doing it here would expand scope into scan core logic, which was scoped out to keep this PR focused and mergeable.
| // files (they are skipped before dispatch and must not trip the gate). Used | ||
| // both by the aggregate estimate (estimateDiffCost) and by the per-file budget | ||
| // look-ahead in dispatchSubtasks. | ||
| func estimateDiffFileTokens(d model.Diff) int64 { |
There was a problem hiding this comment.
This new file has no corresponding test file (internal/agent/estimate_test.go). The scan counterpart (internal/scan/estimate_test.go) has comprehensive tests covering humanTokens, estimateFileTokens, estimateCost, edge cases (empty/binary/deleted), and consistency between per-file and aggregate estimates.
Given that the comments explicitly state these functions "mirror" the scan versions and must be kept in sync, adding parallel tests here would:
- Verify correctness of
estimateDiffFileTokens(deleted/empty diffs → 0, positive for normal diffs). - Verify
estimateDiffCostaggregates correctly andTotalTokens == InputTokens + OutputTokens. - Verify
humanTokensoutput matches scan's (the sync invariant). - Verify
sumToolCallssums correctly. - Provide a regression safety net so future changes to either copy are caught if they diverge.
| fmt.Fprintf(stdout.Writer(), "[ocr] token budget reached (used %s + next-file est ≈ %s > budget %s) — skipping %s and remaining files\n", | ||
| humanTokens(used), humanTokens(projected), humanTokens(a.args.MaxTokensBudget), toDispatch[i].NewPath) |
There was a problem hiding this comment.
The format string labels the second value as "next-file est" but actually prints projected, which is used + estimateDiffFileTokens(toDispatch[i]) — i.e., the total projected cost, not just the next file's estimate. This makes the log message misleading: a reader would interpret "used X + next-file est ≈ Y" as Y being the per-file estimate, when it's actually the sum.
Either print the per-file estimate separately (humanTokens(estimateDiffFileTokens(toDispatch[i]))) or relabel the field to "projected total".
Suggestion:
| fmt.Fprintf(stdout.Writer(), "[ocr] token budget reached (used %s + next-file est ≈ %s > budget %s) — skipping %s and remaining files\n", | |
| humanTokens(used), humanTokens(projected), humanTokens(a.args.MaxTokensBudget), toDispatch[i].NewPath) | |
| fmt.Fprintf(stdout.Writer(), "[ocr] token budget reached (used %s + next-file est ≈ %s = %s > budget %s) — skipping %s and remaining files\n", | |
| humanTokens(used), humanTokens(estimateDiffFileTokens(toDispatch[i])), humanTokens(projected), humanTokens(a.args.MaxTokensBudget), toDispatch[i].NewPath) |
lizhengfeng101
left a comment
There was a problem hiding this comment.
Great work on the token budget guardrail — the design is clean and the pre-review estimate is a nice touch.
One thought on --max-tool-calls: I wonder if this flag pulls its weight given the existing controls. We already have:
--max-tokens-budget(global) — directly caps cost, which is what users actually care about. When tokens are exhausted, tool calls stop naturally.--max-tools(per-file) — prevents any single subtask from spinning in an infinite tool-use loop, with a sensible default.
--max-tool-calls sits between these two but is harder to reason about in practice — there's no intuitive answer to "how many total tool calls should a 200-file review use?" and no good default we can recommend. It's also highly correlated with token usage, so in most scenarios the token budget will trip first anyway.
My suggestion: drop --max-tool-calls as a user-facing flag. The JSON output already reports tool_calls.total and tool_calls.by_tool distribution, so observability is covered without making it a configurable gate.
This keeps the CLI surface simple — one global knob (tokens = cost) and one local safety valve (per-file rounds) — without adding a third dial that users can't easily calibrate.
Address PR alibaba#508 review feedback: 1. emitFailureUsage now reports ag.BudgetExceeded() instead of a hardcoded false. There is a residual edge: a budget gate can trip after dispatching N files, and if every dispatched file then fails, dispatchSubtasks returns (nil, error) — so the failure path is reached with BudgetExceeded()==true. The failure usage record must reflect the agent's actual state, never contradict its typed status. New test guards both text and JSON forms. 2. Fix a misleading budget-gate log line: it printed `projected` (used + estimate) but labelled it "next-file est". Now prints all three values: "used X + next-file est Y = projected Z > budget B". 3. Add SetIndent to the stderr failure-usage JSON for consistency with the other JSON emitters in output.go. 4. Add internal/agent/estimate_test.go mirroring internal/scan/estimate_test.go: humanTokens parity table (so the two copies' divergence is caught), estimateDiffFileTokens zero/skipped/floor checks, estimateDiffCost aggregate + Total==Input+Output invariant + scales-with-content, sumToolCalls. Full `make test` (-race) green.
|
@lizhengfeng101 thanks for the thoughtful review — and glad the pre-review estimate landed well. On dropping
That said — if after the above you'd still prefer it dropped, I'm happy to do it; it's a clean removal (the Everything else from the OCR pass I've addressed in
|
|
Minor: misleading comment in The comment states:
However, Suggested rewording: // INV-4: emit a best-effort structured usage record on the failure path so
// the cost of the failed attempt is not lost. Budget exhaustion typically
// returns partial comments with a nil error and does not reach here, but a
// residual edge exists (budget trips, then all dispatched files error) —
// emitFailureUsage reports the agent's actual BudgetExceeded() state. |
|
Thanks for the detailed rationale on keeping That said, I'd still lean toward deferring it for now. My thinking:
Would you be open to shipping with just Either way, the core design (budget gate, typed status, failure-path usage) is solid work. 👍 |
e924d3b to
d149c0a
Compare
|
Sounds good — convinced by the UX-minimalism argument. I've dropped If real-world usage reveals cheap-but-numerous tool-call loops slipping through the token budget, re-adding it is straightforward from this PR's history. Thanks for the review. Also applied your comment wording fix in |
|
Minor: unconditional estimate output is a behavioral change for all text-mode users The pre-run estimate line in est := estimateDiffCost(a.diffs)
fmt.Fprintf(stdout.Writer(), "[ocr] estimated cost: %s\n", est)is printed unconditionally for every Suggestion: gate the estimate behind if a.args.MaxTokensBudget > 0 {
est := estimateDiffCost(a.diffs)
fmt.Fprintf(stdout.Writer(), "[ocr] estimated cost: %s\n", est)
fmt.Fprintf(stdout.Writer(), "[ocr] token budget: %s (dispatch stops once exceeded)\n", humanTokens(a.args.MaxTokensBudget))
if est.TotalTokens > a.args.MaxTokensBudget {
fmt.Fprintf(stdout.Writer(), "[ocr] WARNING: estimate (%s) exceeds token budget (%s); review will stop partway\n",
humanTokens(est.TotalTokens), humanTokens(a.args.MaxTokensBudget))
}
}Rationale: users who opt into a budget are exactly the audience who benefits from the pre-flight estimate ("my budget vs. projected cost"). Users who don't set a budget have already chosen "unlimited" and gain nothing from the extra line — it's noise. This keeps the INV-5 intent intact while eliminating the unconditional behavioral change for existing users. |
lizhengfeng101
left a comment
There was a problem hiding this comment.
Suggestion: gate the estimate behind MaxTokensBudget > 0
d149c0a to
e944e5c
Compare
|
Done in |
|
CI is failing across all jobs — the root cause is an incorrect module path in the new test files. The imports use Affected files (grep for
Fix: replace all occurrences of |
Add a runtime token-budget guardrail to the diff-review path so a large MR stops itself before runaway cost (issue alibaba#409: ~90.4M tokens in one failed attempt), instead of timing out and losing all structured output. New `ocr review` flag (mirror scan's proven --max-tokens-budget): - --max-tokens-budget N : cap aggregate token usage; dispatch stops once the running total + a per-file look-ahead would exceed it (0 = unlimited). Mechanism mirrors scan/agent.go's dispatchBatch gate exactly: read the existing atomic Runner counters (no duplicate counter — a second one would be a drift bug) before acquiring the semaphore, and break the dispatch loop on exceed. In-flight workers are allowed to finish (overrun bounded by the in-flight count, <= concurrency), matching scan's documented contract. Budget exhaustion returns the partial comments already produced with a nil error (not a Go error — the failure path suppresses output), and signals budget-exceeded out-of-band so the output layer sets a typed `budget_exceeded` status distinct from success / completed_with_warnings / completed_with_errors. Additional changes tied to the invariants: - Pre-review scale warning (files, diff tokens, configured budget) printed before any model spend; non-blocking, warn-only. - Structured usage emitted on the failure path (stderr) so the cost of a failed attempt is never lost. Carries only token/tool tallies — no credentials or prompts. Reports the agent's actual BudgetExceeded() state so the residual budget-trip + all-dispatched-fail edge can never contradict the typed status. - summary.budget_exceeded JSON field (additive, omitempty) so old parsers are unaffected; default 0/unlimited restores prior behavior. Internal/scan gains only the BudgetExceeded() accessor (returns false) so the shared ResultProvider interface compiles; scan keeps its own token budget and its JSON output is unchanged. Tests mirror internal/scan/budget_test.go: token-budget gate stops dispatch early and sets BudgetExceeded(); unlimited default runs all files; estimate helpers project sane values (with a humanTokens parity table so the two copies cannot diverge); JSON output asserts the typed status and the failure-path usage record. Full `make test` (-race) green.
e944e5c to
6e83d33
Compare
|
Updated @lizhengfeng101 |
|
Thanks @lizhengfeng101. I don't see a merge option. Assuming someone from the core team will move this forward. |
Running benchmark, please wait. |
What & why
Closes #409 (track 2). Adds runtime token-cost and tool-call budget guardrails to the diff-review path so a large MR stops itself before runaway cost, instead of timing out and losing all structured output. The #409 report quantified a single failed attempt at ≈90.4M tokens / ≈2,612 tool calls.
Scan already has
--max-tokens-budget; this mirrors that proven gate onto the review path and adds the missing tool-call dimension plus the typed terminal state the report asked for.Changes
New
ocr reviewflags (mirror scan's flag wording):--max-tokens-budget N— cap aggregate token usage; dispatch stops once the running total + a per-file look-ahead would exceed it (0= unlimited).--max-tool-calls N— cap aggregate tool-call count across the run (0= unlimited).Mechanism mirrors
internal/scan/agent.go'sdispatchBatchgate exactly: read the existing atomicRunnercounters (no duplicate counter — a second one would be a drift bug) before acquiring the semaphore, andbreakthe dispatch loop on exceed. In-flight workers are allowed to finish (overrun bounded by the in-flight count, ≤ concurrency), matching scan's documented contract.Typed terminal state (INV-3): budget exhaustion returns the partial comments already produced with a nil error (returning an error would suppress all structured output via
review_cmd.go's failure path) and signals budget-exceeded out-of-band. The output layer setsstatus: "budget_exceeded"(precedence overcompleted_with_warnings/completed_with_errors) andsummary.budget_exceeded: true.Structured usage on failure (INV-4): a non-budget failure now emits a structured usage record to stderr (token totals, tool-call count, elapsed,
budget_exceeded=false) so the cost of a failed attempt is never lost. Carries only numeric tallies — no credentials or prompts.Pre-review scale warning (INV-5): prints estimated scale (files, est. tokens) and configured budgets before any model spend; non-blocking, warn-only.
Scope decisions (what's NOT in this PR)
Per the issue's open product choices and to keep the change focused/mergeable:
max_model_requests/max_elapsed_seconds/ hard-refuselarge_mr_policyare deferred follow-ups (each needs cross-package instrumentation; the pre-check is warn-only).BudgetExceeded() bool { return false }accessor so the sharedResultProviderinterface compiles; scan keeps its own token budget and its JSON output is unaffected.statusremains a free-form string — only a new documented constant valuebudget_exceededis added (no enum rewrite).Additive JSON + zero-value defaults mean rolling back restores prior behavior with no migration. Detailed design/analysis and the invariant spec are in the linked plan bundle.
Testing
internal/scan/budget_test.go: token-budget gate stops dispatch early and setsBudgetExceeded()==truewith atoken_budget_reachedwarning + partial comments; tool-call-budget variant; unlimited default runs all files; estimate helpers; CLI negative-value validation.status=="budget_exceeded"+summary.budget_exceeded==true, the precedence over warning/error statuses, the failure-path usage record (text + JSON), and that the success path omits the new field (omitempty).make testfull-racesuite green (23 packages, 0 failures, 0 race warnings).Checklist
make build/go vet ./.../gofmtcleanmake testgreen (-race)0/omitemptydefaults)