fix(buzz-agent): budget summarizer reasoning separately so it cannot starve the handoff summary - #5248
Conversation
…starve the handoff summary Reasoning models spend output tokens thinking before any visible text, and that spend counts against the summary call's max_tokens. On deepseek-v4-flash this starved the handoff summarizer completely: in a 89-task benchmark run, 13 consecutive handoff attempts across 5 trials length-stopped inside the reasoning channel, returned empty content, and every one degraded to lossy history truncation (~40 minutes of burned reasoning) before a stochastically-short reasoning run finally fit. Give reasoning its own equal-sized budget on top of the text budget (reasoning.max_tokens) and exclude it from the response (reasoning.exclude) — summarize() only reads content. max_tokens is raised to cover both budgets so the text budget the caller asked for is actually available for text. Non-reasoning endpoints ignore the reasoning object; deliberately not paired with provider.require_parameters (see apply_openrouter_mutations). Verification: - cargo test -p buzz-agent (422 unit + 110 integration, all pass) - cargo fmt / clippy clean Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Signed-off-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
atishpatel
left a comment
There was a problem hiding this comment.
Requesting one fix before merge.
Blocking — reserve the doubled OpenRouter completion cap in the handoff input budget. openrouter_summary_body now sends top-level max_tokens = 2 * max_output_tokens (16,384 for handoffs), because OpenRouter counts reasoning and visible text together there. But build_handoff_prompt still calls handoff_prompt_budget_bytes(..., HANDOFF_MAX_OUTPUT_TOKENS, ...), so it reserves only 8,192 tokens for output. At the maximum constructed prompt, the request can therefore be context_window - 8,192 input plus 16,384 output: 8,192 over the configured window. This invalidates handoff_prompt_budget_bytes's guarantee that it “should not build a prompt that exceeds the configured context window” and can make the summarizer itself context-reject precisely when handoff is needed. Please make the reservation reflect the provider's actual top-level completion cap (2x for OpenRouter, unchanged for other providers) and pin that join in a test.
On the prompt question: HANDOFF_SYSTEM_PROMPT does contain Stay under 8192 tokens. I do not think that recreates the original accounting bug after the wire budgets are fixed: reasoning.max_tokens = 8192 independently caps hidden reasoning, while top-level max_tokens = 16384 leaves another 8192 for visible content. The natural referent is the requested plain-text summary. Still, I suggest making it explicit — e.g. “Keep the visible plain-text summary under 8192 tokens” — and ideally deriving the number from HANDOFF_MAX_OUTPUT_TOKENS rather than maintaining a duplicated literal. That is robustness/clarity, not my blocking finding.
Evidence: reviewed and ran the focused wire-shape and prompt-budget tests at bb2feddee0a006c56e7c9d80bbf6d10ab805f0a4 with a clean worktree; both pass, which also shows the existing prompt-budget test does not cover the new 2x join.
… in the handoff input budget Review follow-up (PR #5248, atishpatel): the previous commit doubled OpenRouter's top-level max_tokens to 16384 (text budget + reasoning budget) but build_handoff_prompt still reserved only 8192 output tokens when sizing the input. At the maximum constructed prompt, input plus the actual completion allowance could exceed the configured context window by 8192 — making the summarizer itself context-reject exactly when a handoff is needed. Introduce summary_completion_cap(provider, budget): 2x for OpenRouter (where reasoning gets its own equal budget), unchanged for Anthropic, OpenAI, Databricks, and DatabricksV2 (whose summary bodies request the caller's budget as-is). openrouter_summary_body and build_handoff_prompt both derive from it, so the wire cap and the input reservation cannot drift apart again. A regression test pins the join: budget + fixed prompt + actual completion cap <= window at the 1-byte/token upper bound, and asserts the old single reservation observably violates it. Also per review suggestion: HANDOFF_SYSTEM_PROMPT now says 'Keep the visible plain-text summary under N tokens' with N derived from HANDOFF_MAX_OUTPUT_TOKENS via format!, removing the duplicated literal and the ambiguity about whether hidden reasoning counts against the figure. Verification: - cargo test -p buzz-agent (425 unit + 110 integration, all pass) - cargo fmt / clippy clean Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Signed-off-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
|
@atishpatel Good catch — the blocking finding was real. The wire cap and the input reservation were computed in two different places and the first commit only moved one of them. Fixed in 5f53236: Blocking issue — provider-aware output reservation. New Test pinning the join. Prompt suggestion — taken. One deliberate non-change: Verified at 5f53236: full |
|
Independently reviewed and live-tested at exact PR head The revised design resolves the earlier blocker:
Verification at that exact head:
I found no remaining blocker. GitHub will not let this authenticated account formally approve because it is also the PR author, so recording my independent approval verdict as a comment. |
Brings the bench branch up from 13c9e90 to current main (c7b6636) as a merge commit — no rebase, no history rewrite. Clean auto-merge, zero conflicts. Notably picks up: - #5248: budget summarizer reasoning separately so it cannot starve the handoff summary - #5223: recover from max-token response truncation - #5195: Responses reasoning summary + ACP v2 messageId fixes - #5130: escalate LLM timeouts per retry, log per-call latency Originating Buzz thread: buzz://message?channel=c3252dd2-0142-4e01-88c7-a2183c3960a5&id=9e60a8dfa64a59339d7b357c509e231f6bcf76e5507a92078a6723378f9d1e95 Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Signed-off-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
…format * origin/main: (60 commits) feat(desktop): unify add agent flows (#5015) fix(buzz-agent): budget summarizer reasoning separately so it cannot starve the handoff summary (#5248) infra: bind development services to loopback (#4871) chore(release): release Buzz Desktop version 0.5.7 (#5252) fix(desktop): isolate relay admission tests (#5221) fix(desktop): externalize boot <style> to prevent Tauri CSP nonce override (#5242) fix(desktop): let imported and recovered identities finish onboarding (#5228) Recover from max-token response truncation (#5223) chore(release): release Buzz Desktop version 0.5.6 (#5214) fix(mobile): keep latest messages above composer (#4981) fix(sdk): preserve self-mention p tags in message and forum event builders (#4975) bump @tauri-apps/cli to ~2.11.4 to fix linux app icon issue (#4858) feat(desktop): adding rich link previews to messages (#3818) fix(buzz-agent): Responses reasoning summary, Anthropic display:summarized, ACP v2 messageId (#5195) fix(desktop): retain distinct agent instances in autocomplete (#5202) fix(desktop): defer channel visibility change to Save (#5203) feat(desktop): Projects follow-ups — access restrictions, fast loading, activity feed polish (#5073) refactor(cli): replace probe/decider/detail split with single typed extractor (#5191) fix(desktop): drop unhandled rejection from throwing window.Notification (#5143) fix(desktop): fence localStorage SecurityError from killing the React tree (#5142) ... Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Problem
The handoff summarizer sends
max_tokens: 8192(HANDOFF_MAX_OUTPUT_TOKENS) with no reasoning budget separation. On reasoning models, thinking tokens count against that cap: the model can spend the entire budget reasoning, length-stop with emptycontent, andsummarize()— which only readscontent— reports an empty summary. The handoff then degrades to lossy history truncation.Observed on deepseek-v4-flash during a terminal-bench 2.1 run (tb21-solo-3, 89 tasks): 13 consecutive handoff attempts across 5 trials failed exactly this way (
handoff returned empty summary; truncating), each burning ~3 minutes of full-cap reasoning, before a stochastically-short reasoning run finally fit. circuit-fibsqrt alone: 5 failures, 5 truncations, then success on attempt 6. video-processing failed its task by one frame after 3 context truncations.Fix
openrouter_summary_bodynow grants reasoning its own equal-sized budget and excludes it from the response:reasoning.max_tokens = max_output_tokens— thinking gets a dedicated budget instead of competing with the summary textreasoning.exclude = true— reasoning is never in the response body;summarize()only readscontentmax_tokens = max_output_tokens * 2— the total cap covers both budgets, so the text budget the caller asked for is actually available for textNon-reasoning endpoints ignore the
reasoningobject. Deliberately not paired withprovider.require_parameters, for the reasons documented atapply_openrouter_mutations(it hard-404s valid model ids).The prior test
openrouter_summary_carries_neither_reasoning_nor_providerassertedreasoningabsent from the summary body — that assertion guarded against effort-based reasoning leaking in from config (the body is built independently ofcfg, which is still true and still tested:reasoning.effortstays unset). Replaced withopenrouter_summary_budgets_reasoning_separately_and_carries_no_provider.Verification
cargo test -p buzz-agent: 422 unit + 110 integration tests pass at bb2feddcargo fmt/cargo clippy -p buzz-agent --all-targets: cleanreasoning.max_tokens/reasoning.excludesemantics.