Skip to content

fix(reflect): split synthesis — parallel claim extraction + reduce instead of dropping over-budget evidence - #3392

Merged
nicoloboschi merged 1 commit into
mainfrom
reflect-split-synthesis
Aug 12, 2026
Merged

fix(reflect): split synthesis — parallel claim extraction + reduce instead of dropping over-budget evidence#3392
nicoloboschi merged 1 commit into
mainfrom
reflect-split-synthesis

Conversation

@nicoloboschi

Copy link
Copy Markdown
Collaborator

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_prompt walks 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:

  1. Split the history into budget-sized chunks at tool-result block boundaries (greedy chronological packing). A single over-budget block splits on result-entry boundaries (observations/memories/results), so evidence spreads across chunks intact. Only an indivisible entry bigger than the whole budget (e.g. one giant expand depth=document payload) is token-cut.
  2. Map: each chunk is compressed in parallel into dated, cited claims — every claim carries (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.
  3. Reduce: one synthesis call over every chunk's claims, instructed to apply the latest-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_synthesis helper; the map/reduce logic lives there once.

Tests

tests/test_reflect_split_synthesis.py:

  • Splitter (pure): single-chunk passthrough, greedy packing preserves order and every entry, over-budget block splits on entry boundaries keeping sibling keys, indivisible entry is token-cut, budget floor prevents per-entry fan-out.
  • Agent flow (mock LLM): overflow → N map calls + reduce as the last call, reduce prompt carries every map output; map calls carry no completion cap (a small caller max_tokens caps only the answer, not the evidence extraction); fitting history stays a single final call with unchanged scopes; every memory id reaches exactly one map prompt.
  • Real LLM + judge (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_exceeded updated: 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)

  • clamp the model-requested max_tokens on reflect tools (floor exists, no ceiling);
  • budget expand depth=document (returns whole documents unbudgeted).

…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
nicoloboschi force-pushed the reflect-split-synthesis branch from a291f5b to d7e26b9 Compare August 11, 2026 15:59
@nicoloboschi

Copy link
Copy Markdown
Collaborator Author

Live end-to-end verification on an isolated instance (fresh embedded pg0, real gemini-2.5-flash-lite, REFLECT_MAX_CONTEXT_TOKENS=4000 to force overflow). Seeded 57 facts with two distinctive hobby facts at opposite ends of the corpus (so they land in different chunks) and 55 filler facts between, then asked a broad summary question.

  • Guard + split engaged: Context budget exceeded on iteration 2: ~6656 >= 4000split synthesis over 2 chunks.
  • Answer is a real synthesis and names both chunk-separated facts (Zara's beekeeping, Marco's synthesizers) — the exact scenario that previously returned "I don't have information" with citations attached. 26 based_on citations.
  • Maps ran genuinely in parallel: final_map_2 (3.6s) finished before final_map_1 (7.4s); total 10.9s ≈ agent (1.0s) + max(maps) (7.4s) + reduce (1.9s).
  • Telemetry: trace.llm_calls scopes [agent_1, final_map_2, final_map_1, final]; llm_requests recorded all four calls (scope/model/tokens/duration/status), and their token sums — in 14,601 / out 4,180 — match the response usage block exactly.

@nicoloboschi
nicoloboschi merged commit 07aec3b into main Aug 12, 2026
106 checks passed
@nicoloboschi
nicoloboschi deleted the reflect-split-synthesis branch August 12, 2026 04:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant