fix(reflect): split synthesis — parallel claim extraction + reduce instead of dropping over-budget evidence - #3392
Merged
Merged
Conversation
…ed synthesis When reflect is forced to answer without tools (context guard, last iteration, LLM error, clean stop) and the accumulated tool results exceed the prompt budget, build_final_prompt dropped any over-budget block whole — plus every older one. The synthesis model then saw an empty Retrieved Data section and answered a confident 'I don't have information' while the response attached every retrieved citation (#3122). Whether anything survived depended on whether a small-enough block happened to be newest. Now the history is split, not truncated: budget-sized chunks (block-boundary greedy packing; an over-budget block splits on result-entry boundaries) are each compressed by a parallel LLM call into dated, cited claims, and one reduce call synthesizes the answer from every chunk's claims. Claims carry mentioned_at + memory ids so the reduce call can apply the latest-statement-wins supersession rule across chunks — conflicting facts may land in different chunks. Only an indivisible entry larger than the whole budget (e.g. one giant document expand) is token-cut. When everything fits — the overwhelming majority of reflects — the path is byte-identical to before: one final call, same prompt. The four duplicated forced-synthesis bodies in the agent loop collapse into one helper. Closes #3122.
nicoloboschi
force-pushed
the
reflect-split-synthesis
branch
from
August 11, 2026 15:59
a291f5b to
d7e26b9
Compare
Collaborator
Author
|
Live end-to-end verification on an isolated instance (fresh embedded pg0, real
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #3122. Supersedes the approach in #3126 (closed) — thanks to @feniix, whose diagnosis pinpointed the mechanism.
The bug
When reflect is forced to answer without tools (context guard, last iteration, LLM error, clean stop),
build_final_promptwalks tool-result blocks newest-first and, when one exceeds the remaining budget,breaks — discarding that block and every older one. If the newest block alone is over budget, the synthesis model sees an empty Retrieved Data section, correctly answers "I don't have information", and the response still attaches every retrieved citation (the reported 730-input-token synthesis with 503 citations). Whether anything survives depends on whether a small-enough block happens to be newest — same bank, same question, opposite outcomes.The fix: split, don't truncate
When the accumulated tool results exceed the prompt budget, they are partitioned — never dropped:
observations/memories/results), so evidence spreads across chunks intact. Only an indivisible entry bigger than the whole budget (e.g. one giantexpand depth=documentpayload) is token-cut.(mentioned_at, occurred, memory_ids). The dates are load-bearing: conflicting facts can land in different chunks, and only the reduce call sees all of them.mentioned_at-wins supersession rule across chunks.When everything fits — the overwhelming majority of reflects — the path is unchanged: one final call, same prompt as today.
The per-chunk budget floors at ~1k tokens so a tiny configured
max_context_tokens(tests use 1) can't shred the history into an LLM call per fact. Above 4 chunks the split still runs but logs loudly — that volume signals an unbudgeted tool result, not a normal reflect.Refactor ridealong: the four byte-identical forced-synthesis bodies in the agent loop collapse into one
_forced_final_synthesishelper; the map/reduce logic lives there once.Tests
tests/test_reflect_split_synthesis.py:max_tokenscaps only the answer, not the evidence extraction); fitting history stays a singlefinalcall with unchanged scopes; every memory id reaches exactly one map prompt.hs_llm_core): two distinctive facts placed in different chunks (dozens of filler entries apart) must both surface in the final answer — judged, not string-matched. Under the old behavior this scenario returned "no information".Existing
TestContextOverflowIntegration(real LLM, tiny budget) passes through the new path.test_proactive_guard_fires_when_budget_exceededupdated: it pinned "exactly one synthesis call" on overflow, which is precisely the behavior this PR replaces.Follow-ups (separate, per review of the overflow doors)
max_tokenson reflect tools (floor exists, no ceiling);expand depth=document(returns whole documents unbudgeted).