Skip to content

Integration: compaction fixes round 2 (development -> main) - #134

Merged
adriannoes merged 6 commits into
mainfrom
development
Oct 18, 2026
Merged

adriannoes merged 6 commits into
mainfrom
development

Conversation

@adriannoes

Copy link
Copy Markdown
Collaborator

Summary

Promotes the last consolidated fix round: PR #133 (compaction only-empty stub policy + assistant/tool convergence folds + review follow-ups), consolidating and superseding Bugbot PR #132.

  • A never-fit session line is stubbed only against an empty summary source; later oversized lines are deferred whole instead of cut to a sliver.
  • Any non-user line stopping a compaction pass at index 1 folds the partial-pass summary into the line, so the count always shrinks or advances toward the full-buffer stub (the 65500-byte band stall is regression-pinned).
  • Convergence-turn pins and a tool-role mid-history regression; changelog compaction bullets merged into one.

Testing

  • CI=true make test — all 46 test binaries passed; make static — passed; make test_agent — 26 tests, 0 failed.

adriannoes and others added 6 commits October 18, 2026 11:24
Compaction treated a truncated stub as the whole message. When earlier
lines had already filled the 64 KiB summary source, a later message
that cannot fit was cut to the leftover sliver and then dropped.
Stub that line only on an empty source so this pass summarizes the
prefix and the next pass keeps about 64 KiB of the oversized line.

Consolidated from cursor/critical-bug-identification-d77c onto the
post-promote development base: the only-empty rule replaces the
half-source stub threshold from 0d4e380 (same if/branch), and the
later-line regression now drives the oversized turn through several
turns with a 50 KiB predecessor to assert the defer-then-full-stub
sequence instead of a one-shot sliver stub.

Supersedes #132.
… line

The only-empty stub rule lets a pass defer a never-fit line to the next
one, which works for a user line: the pass folds its summary into the
kept line, the line lands at the head of the session and the next pass
stubs it against a full 64 KiB source. An assistant line cannot take
that path. The kept tail starts on it, a user summary gets prepended in
front of it, and the next pass sees the same pair again - the wrapper
always counts toward used, so no pass ever reaches the only-empty stub.
The count then only grows until SESSION_JSON_MAX stops the saves and
the stored session stays above max_context_messages forever. The new
regression test reproduces the stall on the exact post-partial-pass
state: without the fix the count stayed at 6..22 across eight turns.

Fix shape: a partial pass that consumed exactly one line and breaks on
a never-fit non-user line now folds its summary into that line instead
of prepending a wrapper. The line keeps its content and role, the count
shrinks, and the next pass starts on the line with an empty source and
stubs it whole. Chosen over forcing a truncated stub at the stuck pass:
that restores progress but re-creates the sliver this branch exists to
prevent whenever the one consumed line was large; the fold always
reaches the full stub. The wrapper variant (not counting the summary
toward used) was rejected: the wrapper cannot be told from real content
structurally and dropping it from the source would lose the accumulated
summary.

The full-buffer next-pass test now expects the count to shrink on the
first partial pass (5 + 2 instead of 6 + 2): the fold consumes the head
line together with the oversized line, where the old half-source
threshold left the count unchanged for one pass. The full-buffer stub
assertion itself is unchanged and still holds.
A line that fits a fresh source but not the wrapped remainder behind the
previous head used to miss the fold (it is not "never-fit"), take the
old prepend path and freeze the session at the same count: every pass
rebuilt [summary, bigA] and the count only grew until
SESSION_JSON_MAX. The fold now fires for any non-user line that stops the
pass at index 1, never-fit or not; regression pins the 65500-byte band
(stalled red at count 14 after 4 turns, converges by turn 3 green).
The assistant regression now requires convergence within three turns
instead of accepting any of eight, and a tool-role oversized line behind
two consumed lines pins the normal prepend path: the first pass must
shrink the count and the session must still converge like the assistant
cases.
…avior

The stub-only-empty, defer-whole and fold behaviors are one mechanism;
the release note now reads as the resulting guarantee instead of three
incremental fixes.
…-stub

fix(agent): stub oversized history only on an empty summary

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Summary

This PR promotes #133 (development to main): session compaction now stubs an oversized line only against an empty summary source, defers it otherwise, and folds the summary into the non-user line that stops a pass at index 1 so the message count keeps shrinking. It is 3 files (src/core/agent.c, tests/test_agent.c, CHANGELOG.md). test_agent passes here (I stubbed libcurl because the dev headers are not installed). The 65500-byte livelock I reported on #133 is fixed by 7cf7beb.

I also probed 316 combinations (line sizes 65400 to 200000, roles assistant/tool/user, indices 0 to 3, two consecutive oversized lines, 10 turns each). Compaction converged in 4 turns or fewer every time. The only non-converging cases were sessions already over SESSION_JSON_MAX (128 KB), which the existing too-large guard freezes. That is outside this change.

Must Fix

None.

Should Fix

  1. The window sent to the provider can start with an assistant or tool message, and the summary can be dropped on that turn. Compaction can now return max_ctx + 1 entries (summary or folded line, plus the oversized line, plus the tail). parse_session_into_messages (src/core/agent.c:452-466) keeps only the last max_ctx entries, so the summary user turn is sliced off. Repro on development: max_context_messages = 4, seed [user m0, assistant m1, assistant <65520 x 'A'>, assistant m3, user m4, assistant m5], one agent_run. The provider receives [system, assistant, assistant, user, assistant, user]. With a tool line at the same spot it receives tool first. In that probe the stored count went 7, 8, 9, then 6 on turn 4. The fold path can do the same: 10 messages, max 5, an assistant line of 65500 at index 1 sent A U A U A U on turn 1. Anthropic needs a leading user turn (append_anthropic_messages does not guard it; I raised this on #133). Because a failed provider call leaves the stored session unchanged, the next turn replays the same compaction and likely the same window. I could not test that against the real API. Suggested fix: in agent_build_messages, drop leading non-user entries after slicing, or make compaction produce at most max_ctx entries.

  2. fold_into_oversized is a misleading name (src/core/agent.c:250, 284, fold_summary_into_message doc at 168-170). It is set for any non-user line that stops the pass at index 1, including one that fits a fresh source and is not oversized (the 65500 case). Something like fold_into_stop_line matches the behavior and is easier to grep.

  3. compact_session_via_llm is now about 130 lines with three nested flags (used, next_unsummarized, fold_into_oversized). The summary-source loop (251-289) can be pulled into a helper that returns used bytes, the stop index and the fold flag. That would also make the stub/defer/fold rules unit-testable without five 90 to 130 line end-to-end tests.

  4. Test gaps (tests/test_agent.c):

    • Nothing asserts the roles sent to the provider after an assistant fold, so finding 1 is untested. test_compaction_merges_summary_when_tail_starts_with_user only guards consecutive users.
    • test_compaction_progresses_when_tool_message_is_oversized_mid_history (about 1137) uses index 2, so it never exercises the fold. There is no oversized tool or assistant line at index 1 directly behind a plain user line.
    • No test has two consecutive oversized lines, or a multibyte UTF-8 cut on the deferred (second-line) path.
    • test_compaction_folds_assistant_line_that_fits_a_fresh_source (about 1236) and test_compaction_progresses_when_assistant_message_is_oversized (about 1349) are the same skeleton with a different OVERSIZE and turn bound. A shared scenario helper would remove 200+ duplicated lines.
    • The tool and assistant stall tests mostly assert "marker gone and count <= 6", which does not distinguish a summarized line from a stubbed one.

Nice to Have

  • The new block comments in compact_session_via_llm (263-283) and at 320-324 are long and partly restate the code. Keep the WHY (a sliver stub counts as the whole message, so the unread tail is deleted; the wrapper always eats used, so the only-empty stub is never reached) and cut the rest. The hyphen-as-dash in pair again - the wrapper should be rephrased.
  • Test sizes (50*1024, 34*1024, 65500, 66*1024, 60*1024) and counts (4+2, 5+2) are all tied to SUMMARY_SOURCE_MAX. Derive them from it, or add one comment explaining the +2 (the new user and assistant turn).
  • The new tests reset compaction_call_count but not compaction_last_source or compaction_source_truncated. Harmless today, brittle if a test starts reading them.
  • The CHANGELOG.md bullet at line 25 is one sentence of about 70 words with three behaviors. Splitting it into stub, defer and fold would read better.

Positive Highlights

  • Deferring the stub until the next pass (full 64 KiB source) instead of cutting an oversized line to the leftover sliver is the right call. The turn-0 assertions that the later marker survives and the earlier one is summarized pin that behavior well.
  • The 7cf7beb fix widened the fold condition from "never fits" to "any non-user line at index 1", which closes the band between the wrapped remainder and a fresh source. test_compaction_gives_oversized_line_full_buffer_on_next_pass now asserts the exact count (7) after pass 1, which directly checks the fold.
  • The UTF-8 back-off in summary_source_append_truncated keeps the stub valid for providers, and the integration PR carries only the already-reviewed #133 content, so the scope is clean.
Open in Web View Automation 

Sent by Cursor Automation: Code review

@adriannoes
adriannoes merged commit 0e7fae9 into main Oct 18, 2026
3 checks passed
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.

1 participant