Repository navigation
fix(chat): compact at a turn boundary, preserve user turns, and carry extracted state (#14066) - #14249
Conversation
✅ SSOT Configuration Compliance: Passing🎉 No hardcoded values detected that have SSOT config equivalents! |
|
The gate requires four headings by exact name (
This body has Not editing the body since this is another session's PR and a concurrent edit would collide — |
|
#14266 taught Not a stale-branch artefact: these are yours, and the defaults matter for a compaction path where a wrong bound silently changes what the model sees.
|
0620a66 to
0f26692
Compare
Code review — clean to mergeNo user-turn loss, no reordering, no active crash path. I traced the boundary cases specifically Two findings worth recording. Neither is triggerable today, which is exactly why they are easy to 1. The same function deliberately computes Latent only because every helper is 2.
So the test passes for the wrong reason: faking the role as Re-running that test with 3. Minor, pre-merge: the 4 new 4. Informational: verbatim preservation is capped at Test quality is above average otherwise: Filing 1 and 2 as a follow-up rather than holding the merge. |
|
Re-checked at One thing still outstanding, and it is a single command: The four I have not run it myself: regenerating means executing a script from the checkout, and this session is under a standing instruction never to do that (the checkout is not the deployment and would produce different results). So it needs whoever owns this branch. The two findings from my review are filed as #14322 and are deliberately not blocking:
|
… into issue-14066-compaction-boundary
Blocked on #14111, not on this diff — root cause and evidence
This is the third recorded instance of #14111 (same file, same shard as the #13991 instance). Evidence and the ruled-out hypotheses are on that issue; the summary for this PR: Not caused by this diff
The diff touches Not a flake either — it is deterministic
The variable is worker count, not shard membershipThe runner reports Supporting signal from the same CI job — five Ruled out, so nobody re-checks it
Why this is labelled
|
Thinking Path
Compaction replaced the oldest half of a conversation and depended entirely on one LLM call
to carry it forward. Three things followed from
split_point = len(messages) // 2:carrying
tool_callsand itsrole="tool"responses._sanitize_tool_messagesexists andrepairs exactly this — but it was applied only to the summariser's input, never to the
half that continues on to the provider.
constraints under "Key Context", which is a request, not a guarantee — and a long session
re-summarises the same region repeatedly, each pass another chance to drop the thing the
user said once.
written, commands run with exit status — were re-derived by a model from prose, or lost.
Why this approach: keep the LLM summary and add a deterministic layer beside it, rather
than replacing summarisation. The state block's value is precisely that it still works when
the summary is bad; that is a design property, not an optimisation. Preserving user turns is
made safe by bounding the set, not by choosing which ones matter.
Alternative rejected: repairing the kept half with
_sanitize_tool_messagesinstead ofmoving the boundary. That drops the orphaned tool responses — losing real content — where
snapping the cut keeps the whole batch together on one side.
What Changed
autobot-backend/chat_history/context_overflow.py_pick_boundary— snapslen(messages) // 2back to a turn start, preferring a user turnwithin a bounded window. A
role="tool"message is never a valid cut point._preserved_user_messages— the most recent N user messages, verbatim, never summarised._extract_state/_render_state_block— files written, shell commands, tools used, readoff the tool calls. Tool-name matching reuses
autobot_shared.tool_catalogue(
FILE_WRITE_TOOLS,SHELL_EXEC_TOOLS,match_tool_name), not a local literal list._clip_tool_results— clips oversized tool results in the summarised region. Returns anew list; the caller's history and the persisted transcript are untouched.
_compose_summary— joins the three._message_text+ new_text_parts— defect fixed: it read onlymsg["text"], so everyAPI-schema message (
role/content) estimated as 0 tokens and the post-compactionrefill under-counted the retained half. That delays the next compaction rather than
triggering one early, so it produced no error and no alert.
autobot_shared/env_registry.py— registers the four newAUTOBOT_COMPACTION_*vars.changelog/unreleased/14066-compaction-boundary.md— new fragment.autobot-backend/chat_history/context_overflow_boundary_test.py— new, 30 tests.autobot-backend/chat_history/context_overflow_test.py— one assertion pair updated (see Risks).Second defect fixed, found by a test failing at baseline: the user-turn preference could
drag the boundary to index 0.
_create_summarythen summarised no messages and returnedearly — before the tracker reset — so the session stayed over threshold and re-attempted
compaction every turn while reporting compaction as enabled. Both searches now floor at index 1.
Verification
Mutation-tested — every guard was verified to fail when the line it guards is reverted.
This mattered: the first version of these tests called
_pick_boundarydirectly, and all 26passed with
_create_summaryreverted tolen(messages) // 2. Three wiring tests were addedto close that.
len // 2return summary[]""max(1, …)→max(0, …)Baseline restored and re-verified green after each.
The token-accounting acceptance criterion is asserted as an invariant — the composed
replacement is smaller than the region it replaces — rather than quoted as a number here.
Risks
summary. Bounded by
AUTOBOT_COMPACTION_USER_MESSAGE_CAP(40) andAUTOBOT_COMPACTION_STATE_COMMAND_CAP(10); the size invariant is asserted in a test.earlier for those sessions than it has been. This is the fix, not a regression, but it is
the change most likely to be visible in production.
test_a_malformed_retained_message_does_not_clear_the_counterpinned
summary == "Summary."andawait_count == 4; the return value is now composed andthe boundary snaps from index 4 to 3. Its intent — exactly one reset, every retained message
re-counted despite malformed entries — is unchanged and still asserted.
summary_textremains a single string injected as a system message; no consumer contractchanged (
chat_history/overflow_integration.py).was never touched.
Model Used
Claude Opus 5 (claude-opus-5[1m])
Issue Link
Closes #14066
Related: #14065 (the failure-path fix this builds on — its guarantee is re-asserted here so it
cannot regress), #14029 (context sizing), #13685 (tiered context stack), #13593.
Changelog fragment
changelog/unreleased/14066-compaction-boundary.mdChecklist
CLAUDE.mdgit commitruns them automatically)Dev_new_gui(notmain)Review finding fixed after CI first went green (
747e9c730)Self-review before merge caught a real bug in this PR.
_clip_tool_resultschose its writekey with
isinstance(msg.get("content"), str), but_format_messagesreadsmsg.get("content") or msg.get("text", ""). For a tool result whosecontentis amultimodal list, the clipped body was written to
textwhile the untouched list stayed incontent— so the full body still reached the summarizer while the clip looked applied.Every clipping test passed, because they all used string content. That is the
empty-result-reads-as-clean shape this PR is otherwise about.
Fixed by
_body_key, which mirrors_message_text's own precedence so a rewrite lands inthe key that is actually read. Four regression tests added, asserting through
_format_messages(what the summarizer sees) rather than through the returned dict.Mutation-verified: reverting to the old key selection fails
test_a_list_content_tool_result_is_actually_clipped.