Skip to content

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

Merged
adriannoes merged 5 commits into
developmentfrom
fix/agent-compaction-empty-stub
Oct 18, 2026
Merged

adriannoes merged 5 commits into
developmentfrom
fix/agent-compaction-empty-stub

Conversation

@adriannoes

@adriannoes adriannoes commented Oct 18, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Consolidates Bugbot PR #132 as operator-authored commits, plus two follow-ups from this PR's own review round:

  • bd5081d (supersedes fix(agent): stub oversized history only on an empty summary #132): a never-fit line is stubbed only against an empty summary source; a later oversized line is deferred whole so the next pass stubs it at the full 64 KiB, instead of cutting it to the sliver earlier lines left.
  • ac283fb: the assistant-role convergence case — a partial pass that would strand the oversized line in front of a user summary folds the summary into the line instead, so the count shrinks and the next pass gets the full stub (the suspected stall was reproduced red and closed green).
  • 7cf7beb: review round — any non-user line that stops the pass at index 1 folds, never-fit or not (the 65500-byte band that fits a fresh source but not the wrapped remainder stalled the count; red at count 14 after 4 turns, converges by turn 3 green).
  • 7dfbfc1: convergence-turn pins (assistant within 3 turns) and a tool-role mid-history regression pinning the normal prepend path.
  • 712e068: the three incremental compaction stub bullets merged into one release-note bullet describing the final behavior.

The compaction guarantee is the fold -> empty-source-stub chain: every pass either shrinks the stored count or advances an oversized line toward its full-buffer stub, and the band freeze at a constant count is closed by the fold (regression-pinned from both directions).

Testing

  • make test_agent — 26 tests, 0 failed (run twice)
  • CI=true make test — all 46 test binaries passed
  • make static — passed

Supersedes #132.

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.

@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 changes session compaction in src/core/agent.c so a never-fit line is stubbed only against an empty summary source, and so a partial pass that stops on an oversized non-user line at index 1 folds its summary into that line instead of prepending a user wrapper. The logic is mostly sound and make test_agent passes here (built with gcc and the multiarch curl header path). But I reproduced a no-progress state that the PR description says cannot happen, in a narrow size band just below the never-fit threshold.

Must Fix

None strictly blocking. See the first Should Fix: the PR states "every compaction pass strictly decreases the message count", and that is false for one reachable input.

Should Fix

  1. src/core/agent.c:284 The fold only fires when summary_line_never_fits() is true. An assistant line that fits a fresh source but not behind the previous head (the head is typically the summary, so used > 0) takes the old path: next_unsummarized = 1, merge == 0, summary prepended, count unchanged. The next pass sees [summary, bigA] again and repeats: one provider call per turn, count growing by 2 per turn. Repro: I changed OVERSIZE in test_compaction_progresses_when_assistant_message_is_oversized (tests/test_agent.c:1136) from 65 * 1024 to 65500 and got FAIL: oversized assistant line never left the session or count stayed at 22, the same symptom the PR describes for the 65 KiB case. The band is L from about 65536 - 12 - len("user: " + summary + " ") up to 65523, so it is a few hundred bytes wide for a typical summary and up to about 4 KiB for a max-size one. Narrow, but it ends in the same frozen session at SESSION_JSON_MAX. Fix: drop summary_line_never_fits() from the fold condition, so any non-user line that stopped the pass at index 1 folds. The folded line is `summary + "

" + line`, so it is larger than the line and will take the never-fit stub next pass. Add a test at this size.

  1. src/core/agent.c:284 The fold is keyed on i == 1, which means "index 1", not "exactly one line consumed". If index 0 is not an object it is skipped, used == 0 at index 1, and the stub path runs, so behavior is fine. But the comment says "directly behind the only line this pass consumed". State the real condition or track a consumed-line counter.

  2. src/core/agent.c:340 After the fold, when msg_count - 1 == max_ctx, the first history message sent to the provider is an assistant turn (the folded line). Before this PR the tail always began with a user summary. append_anthropic_messages (src/providers/anthropic.c:370) does not guard a leading assistant, and the repo already treats role alternation on Anthropic as a hard requirement (comment at agent.c:320). Please confirm against the Anthropic Messages API, or add a test that asserts the first history role sent to the provider. The summary is also now attributed to the assistant role; worth one line in the comment.

  3. The new comment blocks are long (14 lines at agent.c:262-283) and narrate history ("would leave the count unchanged...", "the wrapper always counts toward used...") rather than the invariant. Keep the invariant ("a partial pass must shrink the count or defer to an empty-source stub") and leave the walk-through to the PR or CHANGELOG. compact_session_via_llm is also about 130 lines now, against the repo's 5 to 40 line target. Extracting "decide how to stop the pass" into a helper would make the two special cases (stub-on-empty, fold-on-index-1) testable on their own.

  4. tests/test_agent.c The three oversized-line tests share about 80 lines of near-identical setup each (seed JSON, write config, loop agent_run, load session). A small helper would cut about 150 lines and make the band case above cheap to add.

Nice to Have

  • tests/test_agent.c:1207 The for (i = 0; i < 8; i++) loop with early break accepts convergence on any turn up to 8. Asserting the expected turn (for example, shrunk by turn 3) would catch a regression that only slows convergence.
  • No test covers an oversized non-user line at i >= 2 (count shrinks via the normal prepend path) or a tool role line. Both go through the same branch and are cheap to pin.
  • CHANGELOG: the earlier Unreleased bullet ("summarizes a message larger than the summary source as a truncated stub...") is now partly superseded by the two new bullets. Consider merging the three into one so the release note describes the final behavior.

Positive Highlights

  • The empty-source-only stub is the right call. The previous half-buffer threshold could still delete an unread tail after a sliver stub, and used == 0 is simple to reason about. test_compaction_progresses_when_later_message_is_oversized now checks the three things that matter on pass 1 (oversized line kept, predecessor summarized, summary present).
  • The PR description documents the failure it reproduced (count 6 to 22) and why the alternatives were rejected, and the regression test seeds the exact post-partial-pass state.
  • Generalizing fold_summary_into_user to fold_summary_into_message reuses the existing fold code instead of adding a second merge path, and the folded guard still fails closed with -1 when nothing was folded.
Open in Web View Automation 

Sent by Cursor Automation: Code review

Comment thread src/core/agent.c Outdated
* pass's summary into the line instead: the count shrinks
* and the next pass starts on the line with an empty source,
* so it gets the full 64 KiB stub (see the tail build). */
if (i == 1 && strcmp(role, "user") != 0 &&

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Repro of a no-progress case: with OVERSIZE = 65500 in the assistant test (a line that fits a fresh source but not behind the summary line), summary_line_never_fits() is false, so this fold is skipped, the summary is prepended, and the count stays put every turn (22 after 8 turns). Dropping summary_line_never_fits(role, content) from this condition makes any non-user line that stopped the pass at index 1 fold, which is what the PR description promises ("every pass strictly decreases the count").

Comment thread src/core/agent.c
if (merge && i == start) {
if (fold_summary_into_user(dup, summary_buf) != 0) {
if ((merge || fold_into_oversized) && i == start) {
if (fold_summary_into_message(dup, summary_buf) != 0) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

When the fold triggers and the window reaches the folded line, the first message sent to the provider is an assistant turn. Before this PR the tail always began with a user summary. append_anthropic_messages does not guard a leading assistant. Please confirm the API accepts it, or assert the first history role in a test.

@adriannoes adriannoes self-assigned this Oct 18, 2026
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.
@adriannoes

Copy link
Copy Markdown
Collaborator Author

Review response — all items closed:

  • Should Fix 1 (the fresh-source band) → 7cf7beb: the fold now fires for any non-user line stopping the pass at index 1, never-fit or not. Reproduced your 65500-byte repro as the band regression (red: count 14 after 4 turns; green: converges by turn 3).
  • Must-Fix note (the "every pass strictly decreases" claim) → PR body rewritten in this update. The wording is now the real guarantee: every pass either shrinks the count or advances the line through the fold -> empty-source-stub chain, pinned by the band and assistant regressions from both directions.
  • Nice 1 (turn pin) and Nice 2 (i>=2 / tool-role coverage) → 7dfbfc1: assistant convergence now required within 3 turns (was: any of 8), and a new mid-history tool regression pins the normal prepend path — the count must shrink on the very first pass there.
  • Nice 3 (changelog bullets) → 712e068: the three stub bullets merged into one describing the final behavior.

@adriannoes
adriannoes merged commit 5ffb59f into development Oct 18, 2026
1 check passed
@adriannoes
adriannoes deleted the fix/agent-compaction-empty-stub branch October 18, 2026 14:58
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