Skip to content

fix(chat): compaction that survives a full context (llama.cpp overflows, sub-agents, interrupted compactions) - #414

Open
vv7r wants to merge 9 commits into
co-l:developfrom
vv7r:fix/compaction-small-context
Open

vv7r wants to merge 9 commits into
co-l:developfrom
vv7r:fix/compaction-small-context

Conversation

@vv7r

@vv7r vv7r commented Oct 7, 2026

Copy link
Copy Markdown

Compaction fixes found while running long, tool-heavy turns with a local Qwen3-based model on llama.cpp (n_ctx 81,920). On today's develop such a turn gets stuck as soon as one tool result pushes a request past the backend's window: the overflow error is not recognized and the same request is retried with backoff forever. Nine commits, one problem each, each with its own test written first (failing before the fix).

This is also the sub-agent compaction fix mossaab#2 (follow-up to #401) depends on.

Commit Problem Fix
do not use cut-off reasoning as the compaction summary With the context nearly full, a thinking model can spend the whole output budget reasoning; the cut reasoning was stored as the summary (twice in a row live) Retry once in non-thinking mode after closing the failed attempt
compact when a request no longer fits the context window llama.cpp's "exceeds the available context size" error was not recognized: the same request was retried with backoff until the turn gave up Recognize it and compact; check the threshold before sending too (tool results added since the last response); shorten tool results so the summary request fits, sized on the token density the backend measured
keep the sub-agent's task in its context after a compaction A sub-agent's context restarted at the summary, without its task: no user message left, and Qwen3-style templates answer HTTP 500 "No user query found in messages" on every retry Keep the task first, before the summary
run a response's tool calls before compacting A response crossing the threshold had its tool calls dropped; with small windows the agent redid the same work over and over (15 compactions for a 1..10 task) Run them first; the pre-send check compacts with their results counted
close the response a threshold compaction follows That response never got a message.done and stayed "streaming" for good, live and after a reload Close it first
learn the token density on every response The ~4 chars/token estimate is far off for logs and code (~3); with a configured window smaller than the backend's nothing overflows, and a summary request reached 64k tokens for a 40k setting Measure the density on every large enough response, whole request included (system prompt and tools)
close a summary attempt that called a tool Same stuck "streaming" message when the model calls a tool instead of summarizing, or when no summary comes Close it as partial
drop a compaction prompt left by an interrupted compaction A compaction stopped by the user (or a crash) left its prompt in the window: the next "go on" got a summary as an answer and the agent stopped A compaction prompt followed by a user-written message is dropped when the context is built
keep the summary request in the backend's prompt cache The summary request added the round that crossed the threshold, so it no longer started like the previous request: 28-57k tokens prefilled again per compaction (23-51 s) Leave that round out of the summary request (exactly the previous request plus the prompt) and carry it into the next window

Live results

Same model and machine, provider configured with a 80,000-token context and a 0.93 compaction threshold, one session at a time. Each task asks for N commands whose output (14 or 43 KB of logs) hides one secret line, then for the N secrets in order.

Scenario develop This branch
10 × 43 KB outputs stuck (request of 91,660 tokens retried every 60 s) 10/10 secrets, 4 compactions, 4 min 47 s
sub-agent, 8 × 43 KB stuck 8/8, 3 compactions, 4 min 20 s
20 × 14 KB outputs stuck (summary request of 82,077 tokens retried) 20/20, 2 compactions, 4 min 39 s
sub-agent, 20 × 14 KB stuck after its first compaction (HTTP 500 "No user query found") 20/20, 2 compactions, 4 min 41 s
stopped mid-compaction, then "go on" stuck before any compaction 10/10, 4 compactions, 5 min 49 s

No errors on this branch; largest prompt 73,896 tokens.

Verification

  • Lint, typecheck, format, duplicate and the full suite (6,038 unit, 357 e2e) pass on top of today's develop, through the pre-commit hook.
  • Every request captured with Cache Hunter, on both versions (see Cache Impact).

AI-Enhanced Development

  • AI Models: Claude Opus 5.5 (Claude Code); live tests with a local Qwen3-based model on llama.cpp

Cache Impact

  • Yes, improved, and only around compactions. A regular turn (plan, build, skill, MCP, manual compaction) keeps exactly the same prefix behavior as develop, request by request. With this branch, summary requests keep the whole previous request as their prefix, and the prefix only breaks where a new context window starts after a summary. When a summary request has to be shortened, the cut is placed as late as possible so most of it stays cached.

🤖 Generated with Claude Code

vv7r and others added 9 commits October 6, 2026 20:05
A compaction request runs with the context nearly full, and its output
budget is clamped to what is left (down to a 256-token floor). A
thinking model can spend all of it reasoning and return no answer, or a
cut one; the loop then took the reasoning as the summary. Seen live with
a Qwen3-based model behind llama.cpp: two compactions in a row stored the
same 700 characters of reasoning cut mid-sentence ("The user wants me
to summarize the conversation. Let me look at..."); the second window
started from that text and the next compaction copied it.

A summary attempt with no answer, or a cut one, is now retried once in
the model's non-thinking mode (getCurrentModelSettings({ thinking: false
})), after closing the failed attempt and asking for the summary only.
On the retry, an answer is kept even if cut, and a complete
reasoning-only answer is still accepted (setups that route the whole
answer through the reasoning field); otherwise the existing empty-summary
path keeps the full context and reports a recoverable error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Seen live with llama.cpp (n_ctx 81920): a large tool result pushed the
next request to 88,901 tokens. llama.cpp's "exceeds the available context
size" error was not recognized as a context overflow, so the very same
request was retried with backoff until the turn gave up.

- isContextLengthError recognizes llama.cpp's error; promptOverflow()
  reads when the prompt alone exceeds the window (llama.cpp, or an
  OpenAI-style error whose messages alone are over), where a smaller
  maxTokens cannot help.
- On such an overflow the loop starts a compaction instead of retrying.
- The threshold was only checked on the measured prompt after each
  response; tool results added since can cross it. It is now also
  checked before sending, with the estimated tool result tokens.
- A compaction request that would leave less than 4,096 tokens for the
  summary shortens tool results (start and end kept, marker in between)
  instead of overflowing too. The results before the latest round go
  first, most recent first: the conversation up to the previous request
  is in the provider's prompt cache, and shortening near its start made
  llama.cpp prefill 28-69k tokens again (25-55 s per compaction); near
  its end only the part after the change is recomputed. The latest round
  goes last: it is what the agent is about to act on (the compaction
  often runs right after it arrives), and cutting its middle made the
  agent re-run the command. After an overflow, the request is
  sized from what the backend measured (tokens per character of the
  rejected request): the ~4 characters per token estimate is far off for
  code (llama.cpp counted ~3), and a request cut on it still overflowed.
  The measured density is kept for the rest of the turn: later
  compactions (often started by the pre-send check, with nothing measured)
  and the pre-send estimate use it too; live, three compactions in a turn
  each began with an overflow before that.
- The overflow retries are a budget per run of failures, reset after a
  successful request, not per turn: a long turn can compact many times.

The existing behavior for an overflow caused by the requested output
only (retry with a halved maxTokens) is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After a sub-agent compaction its context restarted at the summary, which
dropped the task it was given (its first user message). Once the
sub-agent went on with tool calls the request had no user message at
all, and Qwen3-style chat templates reject that: llama.cpp answered
HTTP 500 "No user query found in messages" on every retry until the
turn was stopped (seen live with a Qwen3-based model).

The task is now kept first, before the summary: the summary is the
model's paraphrase of the work so far, the task is what was asked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a response crossed the compaction threshold, the loop started the
compaction at once and dropped that response's tool calls. With a context
window not much larger than one tool output this happened at nearly every
step: seen live with a 40k window and ~22k-token command outputs, the
model's write of the value it had just read was dropped, the summary did
not have it, and the agent ran the same commands again and again (1 1 2
1 2 1 2 3 4 5 6 1 1 2 3 for a 1..10 task, 15 compactions).

A response with tool calls now runs them first; the pre-send check then
compacts before the next request, with their results counted, so the
summary includes the work done. A response without tool calls compacts
right away as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a response without tool calls crossed the compaction threshold, the
loop appended the compaction prompt without closing that response: its
message never got a message.done and stayed "streaming" for good, live
and after a reload (seen live: one stuck message per compaction).

It is now closed first (segments and interim stats, like the other
mid-turn closes) and the client is told it stopped streaming.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The compaction sizing estimated ~4 characters per token and only learned
the real density from an overflow error. With a configured context window
smaller than the backend's (40k in OpenFox, 81,920 in llama.cpp), nothing
ever overflows: dense tool output (logs, code: ~3 characters per token)
crossed the threshold unseen and a compaction request reached 64k tokens
for a 40k setting.

The density is now measured on every response whose request is large
enough to be reliable (40k message characters: below, the system prompt
and tool definitions skew it), and a measure above the estimate (e.g. only
uncached tokens reported) is ignored. The pre-send check and the
compaction sizing use it for the rest of the turn.

The density counts the whole request: messages, system prompt and tool
definitions. Measured on messages only, the fixed part made the smaller
summary request come out above its target (live: 37-38k for ~34k).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the model called a tool while asked for the compaction summary, the
loop appended the "tool calls are not possible" reminder without closing
that attempt: its message never got a message.done and stayed "streaming"
for good, live and after a reload (seen live: one stuck message per such
attempt). It is now closed as partial first, like the other discarded
attempts.

Same when no summary comes at all: the empty-summary path reported the
error without closing the last attempt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A compaction stopped by the user, or cut by a server crash, before its
summary leaves its prompt (and the cut attempt) in the context window.
The next user message then got a summary for an answer instead of the
work: seen live after killing the server mid-compaction, the agent
answered "go on" with a summary and stopped, 1 step of 10 done. It only
went unnoticed when the context was still over the threshold, since a
new compaction then followed.

When the context is built, a compaction prompt followed by a message the
user wrote is no longer being answered: it is dropped with what follows
it up to that message. A prompt still being answered is only followed by
system messages (retries, reminders) and is kept. Snapshot replays carry
isSystemGenerated and metadata so the same applies to them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When new tool results push the context over the compaction threshold, the
summary request added them to the previous request and, when that did not
fit, shortened tool results inside it. Either way the request no longer
started like the previous one: the backend prefilled most of the
conversation again at the worst moment, with the context full. Seen with
llama.cpp and Cache Hunter: 28-57k tokens recomputed per compaction
(23-51 s), on every compaction of a long tool-heavy turn.

The round that crossed the threshold (the assistant message and its tool
results, not yet sent) is now left out of the summary request, which is
then exactly the previous request plus the compaction prompt. The summary
records it (carriedMessageIds) and the next window goes on from the
summary and that round, in both the top-level and the sub-agent context
(snapshots included). A round too big for that (more than half of what
the window leaves after the summary) is summarized with the rest, as
before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Sub-agent context ignores the latest snapshot: a mid-turn snapshot absorbs its messages and the sub-agent loses its task

1 participant