Skip to content

fix(buzz-agent): budget summarizer reasoning separately so it cannot starve the handoff summary - #5248

Merged
tlongwell-block merged 2 commits into
mainfrom
eva/handoff-summarizer-exclude-reasoning
Aug 7, 2026
Merged

fix(buzz-agent): budget summarizer reasoning separately so it cannot starve the handoff summary#5248
tlongwell-block merged 2 commits into
mainfrom
eva/handoff-summarizer-exclude-reasoning

Conversation

@tlongwell-block

Copy link
Copy Markdown
Collaborator

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 empty content, and summarize() — which only reads content — 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_body now 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 text
  • reasoning.exclude = true — reasoning is never in the response body; summarize() only reads content
  • max_tokens = max_output_tokens * 2 — the total cap covers 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, for the reasons documented at apply_openrouter_mutations (it hard-404s valid model ids).

The prior test openrouter_summary_carries_neither_reasoning_nor_provider asserted reasoning absent from the summary body — that assertion guarded against effort-based reasoning leaking in from config (the body is built independently of cfg, which is still true and still tested: reasoning.effort stays unset). Replaced with openrouter_summary_budgets_reasoning_separately_and_carries_no_provider.

Verification

  • cargo test -p buzz-agent: 422 unit + 110 integration tests pass at bb2fedd
  • cargo fmt / cargo clippy -p buzz-agent --all-targets: clean
  • Not yet validated against a live OpenRouter reasoning endpoint — the failing scenario needs a long-context session to trigger organically. Evidence for the mechanism is from run artifacts (13/13 empty-summary length-stops on deepseek-v4-flash) and OpenRouter's documented reasoning.max_tokens/reasoning.exclude semantics.

…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>
@tlongwell-block
tlongwell-block requested a review from a team as a code owner August 7, 2026 22:09

@atishpatel atishpatel left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@tlongwell-block

Copy link
Copy Markdown
Collaborator Author

@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 summary_completion_cap(provider, budget) in llm.rs is now the single source of truth for what summarize() actually requests: 2× the text budget for OpenRouter (text + equal reasoning budget), unchanged for Anthropic/OpenAI/Databricks/DatabricksV2 (their summary bodies request the caller's budget as-is). Both openrouter_summary_body (wire cap) and build_handoff_prompt (input reservation) derive from it, so the two can't drift apart again.

Test pinning the join. openrouter_prompt_budget_reserves_doubled_completion_cap asserts estimate_tokens(budget + fixed) + actual_completion_cap <= window at the 1-byte/token upper bound — and additionally asserts the old single reservation observably violates that same join (so the test fails if anyone reverts to reserving only HANDOFF_MAX_OUTPUT_TOKENS). non_openrouter_completion_cap_is_the_callers_budget pins the other providers unchanged.

Prompt suggestion — taken. HANDOFF_SYSTEM_PROMPT is now a LazyLock<String> built with format!, reading "Keep the visible plain-text summary under {HANDOFF_MAX_OUTPUT_TOKENS} tokens." — no duplicated literal, explicit visible-text referent. Pinned by handoff_system_prompt_derives_limit_and_targets_visible_text.

One deliberate non-change: token_threshold (the handoff gate) still reserves cfg.max_output_tokens — that reservation is for the agent's own next completion, not the summarizer call, so the doubled cap doesn't apply there. The summarizer's window safety is what handoff_prompt_budget_bytes guards, which is now provider-aware.

Verified at 5f53236: full cargo test -p buzz-agent (425 unit + 110 integration) green, fmt/clippy clean.

@tlongwell-block

Copy link
Copy Markdown
Collaborator Author

Independently reviewed and live-tested at exact PR head 5f53236161a9b411a1ae1a7a9e4635e910b3ac12.

The revised design resolves the earlier blocker:

  • OpenRouter summary requests set reasoning: { max_tokens: 8192, exclude: true } and a top-level completion cap of 16,384, leaving distinct budgets for hidden reasoning and visible summary text.
  • summary_completion_cap() is shared by request construction and handoff-input budgeting, so the prompt cannot reserve only the visible-text half and drift from the wire allowance again.
  • Other providers retain the original completion cap.
  • The prompt now unambiguously constrains the visible plain-text summary to the derived 8,192-token limit.
  • The normal handoff gate correctly continues reserving the agent response budget; the doubled allowance applies to the separate summarizer request.

Verification at that exact head:

  • Built the release buzz-agent.
  • Ran the full cargo test -p buzz-agent: 425 unit tests passed, plus all integration/doc suites (including 48 regression tests).
  • Ran two real ACP sessions through OpenRouter with deepseek/deepseek-v4-flash-0731; forced proactive handoff produced non-empty summaries and both sessions continued into the next prompt, with no empty-summary fallback or logged errors.
  • Sent a direct OpenRouter request using the PR's exact reasoning shape; the response contained visible content, omitted reasoning text, and reported 347 hidden reasoning tokens separately, confirming that the provider honors exclude: true in this configuration.

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.

@tlongwell-block
tlongwell-block merged commit c7b6636 into main Aug 7, 2026
32 checks passed
@tlongwell-block
tlongwell-block deleted the eva/handoff-summarizer-exclude-reasoning branch August 7, 2026 23:18
tlongwell-block pushed a commit that referenced this pull request Aug 7, 2026
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>
wpfleger96 pushed a commit that referenced this pull request Aug 7, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants