Skip to content

fix(chat): stop context overflows from leaving sessions stuck - #114

Merged
mudler merged 16 commits into
masterfrom
sdd/context-management-robustness
Sep 26, 2026
Merged

mudler merged 16 commits into
masterfrom
sdd/context-management-robustness

Conversation

@mudler-agent

@mudler-agent mudler-agent commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator

Sessions on regolo (a LiteLLM proxy in front of vLLM) could overflow the context and then stay stuck: every turn, and a manual /compact, failed the same way. This PR fixes the cause and makes every stage of overflow recovery able to make progress.

What went wrong

Reproduced against the live endpoint:

  • regolo's /model/info reports max_input_tokens: 96000 and max_output_tokens: 96000. The vLLM behind it accepts 210000. LiteLLM does not enforce 96000.
  • nib sent max_completion_tokens=96000 on every request, including the compaction summary. vLLM rejects a request when prompt + requested output exceeds 210000, so any prompt above ~114k failed. nib's budget only reserved 4096 tokens for output.
  • On the first overflow nib learned the 210000 window from the error, so compaction fired at ~165k, above the ~114k that already failed.
  • The summary request also reserved 96000 output tokens, so it overflowed too. Its retry scaled the prompt by window/total, which cannot fit a fixed reservation. Compaction failed, and the failed-turn rollback restored the history from before recovery compacted, so the next turn overflowed again.

Changes

Root causes:

  • Clamp max_tokens per request to the room the window has left (clampOutputTokens in the tracked LLM wrapper, tool schemas counted).
  • Summary that always fits. The summary has its own output cap (compaction.summary_max_tokens, default 16384). An overflowing summary shrinks by the overshoot the backend reports, and the compaction boundary moves earlier. Messages cut from the summary stay verbatim.
  • Rolling summary. When one call cannot hold the head, it is summarized in chunks, each merged with the running summary, so the result is small and nothing is dropped. When the window is known and the head is already too large, rolling starts at once instead of truncating the summary input.

Recovery:

  • Overflow error catalog. Detection is one ordered list of regex rows with named groups (window, total, input, output), backed by fixtures in chat/testdata/overflow/ (the regolo ones are live captures). Users can add rows with compaction.overflow_patterns. The HTTP status gates weak evidence only; LocalAI reports an overflow as HTTP 500, so provider rows classify at any status.
  • Three overflow kinds. KindContext compacts. KindBudget (prompt fits, prompt + output does not) retries with a lower max_tokens and never compacts. KindOutputCap lowers the output cap.
  • Escalation chain. Compaction, then a smaller kept tail, then a forced prune, then hard truncation, then a non-LLM fallback compaction. Each step is validated (validateCompaction: size, budget, tool-call pairing). The whole chain counts as one recovery.
  • The failed-turn rollback keeps the compaction. It drops only the failed turn's messages.

Broken tool calls (with mudler/cogito#80):

  • Interrupted streams and invalid tool arguments get their own retry classes. An interrupted stream is retried at most 3 times, invalid arguments at most once, instead of the 10 turn retries × 3 cogito attempts an unknown error used to get. Each has a clear message instead of unexpected end of JSON input.
  • A tool call cut off because the context window ran out is context exhaustion. This is detected from the usage cogito now reports (prompt + completion ≥ 95% of the window, as llama.cpp/LocalAI stop at n_ctx) or from the clamp having lowered max_tokens.
    • If the prompt is at least 25% of the window, nib compacts, then retries once with a one-turn note telling the model which call was cut and to split it.
    • Otherwise it retries once without compacting, lowering the reasoning effort for that turn when reasoning filled the window.
    • The note is never stored in the history.
  • No per-request output ceiling. It would cut legitimate long tool calls such as writing a whole file.

Also:

  • Progressive, sticky tool-output compression in four levels. Levels only escalate, so the backend's prefix cache stays warm.
  • Tool-schema floor, measured. The request wrapper measures what each request really carries in tool schemas and system messages, MCP servers and cogito's own tools included, and scales it by the tokenizer ratio the backend reports (clamped to 1–4×; image requests do not calibrate). The floor is deducted from the validation budget.
  • Per-MCP-server attribution. When the floor crosses the warning threshold, the user sees one notice, for example: tool schemas use 81k of the 96k context window — github 34k (52 tools), jira 22k (31 tools)…: disable MCP servers or filter their tools.
  • No compaction when the schemas are the problem. If the floor alone leaves no room for the smallest possible request, recovery and auto-compaction do not touch the history. The turn ends with that same message, and the conversation is kept.
  • Bump cogito to 5aa9d6d: typed status errors from the streaming path (fix(clients): return a typed status error from the LocalAI streaming path cogito#79), and sentinel errors for interrupted streams and broken tool-call arguments (fix(tools): stop retrying broken tool-call arguments with identical requests cogito#80). The old text forms are still parsed.

Testing

  • go test ./... -count=1 (with an isolated HOME, see below), go vet ./... and go test -race ./chat/ -count=1 pass.
  • An HTTP regression test models the regolo setup at 1:10 scale (LiteLLM window 9600 with a 9600 output cap, vLLM enforcing 21000 on prompt + max_tokens). After the one turn that teaches nib the real window, 12 more turns succeed while the prompt grows past the old failure point, and a manual compaction succeeds.
  • Each commit was written test-first and reviewed separately.

Notes for review

  • Output clamping applies to OpenAI-compatible clients, where the session knows the output cap. Other adapters are unchanged.
  • A generic overflow phrase in a 5xx response is now retried as transient instead of treated as fatal.
  • Before the first request of a session the floor is an estimate (built-ins plus each MCP server's listed tools). It is only used to block recovery when the estimate alone is over the window.
  • The per-server listing is cached and refreshed when the set of MCP servers changes.
  • Follow-up, not in this PR: deferred MCP tool loading (full schemas only for built-ins and a bounded set of MCP tools, the rest by name with a load-on-demand tool). It is the structural fix for large MCP setups.
  • Test isolation (existing bug, also on master): cmd tests such as TestRunCLIWritesTheSummaryToStderrOnly read ~/.config/nib and send their chat request to the saved real provider instead of the test server. On a machine with a saved /login they make billed requests and time out under rate limits. Run the suite with an isolated HOME until that is fixed separately.
  • The implementation plan is docs/specs/2026-09-27-context-management-robustness.md (not committed).

🤖 Generated with Claude Code

mudler and others added 16 commits September 25, 2026 20:29
The client sent its full output cap as max_tokens on every request. vLLM
rejects prompt + max_tokens > window, so with a cap that is a large part of
the window every long prompt, compaction summaries included, overflowed.

The turn's LLM wrapper now sets max_tokens to
min(cap, window - prompt estimate - 512), floored at 1024, when the request
carries no explicit value. The session records the cap the factory and
discovery resolved for the current model.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ncating

A summary that overflowed was retried once, at a size scaled by
window/request. The output the request reserves does not scale with the
prompt, so with a large reservation the retry overflowed too, and the
session stayed stuck.

The summary request now reserves min(output cap, summary_max_tokens,
default 16384) through CreateChatCompletion, and its prompt is fitted
into the window minus that reservation. On an overflow, summarizeFitting
subtracts the stated overshoot from the prompt and moves the boundary
earlier until the head fits, up to 6 attempts. Messages that leave the
head stay in the history verbatim. End-of-turn, mid-turn and /compact
all use this loop, and the artifact holds the head that was summarized.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the summary of the whole head overflowed, the boundary moved
earlier and the cut messages stayed verbatim. Nothing was lost, but a
far move left the history barely smaller, so the next request compacted
again.

Now the head is summarized in chunks from oldest to newest, each fitted
to the retry target. Every chunk after the first carries the running
summary and asks to merge it. A chunk that overflows anyway shrinks and
passes the rest to the next chunk. Chunks end only on tool-call
boundaries, and an over-large message is cut down, not skipped. After 8
chunks the uncovered messages stay verbatim, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add validateCompaction, which rejects an empty result, a result that
grew, a no-op, a result over the budget, and broken tool-call pairing.
The sentinel errors are exported so overflow recovery can switch on them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SchemaBudget measures the fixed per-request overhead (tool schemas plus
system prompt, bytes/4) against the context budget and window, and logs
a warning when it passes 60% of the budget or 90% of the window.
toolOptions records the tool definitions it registers so the session
can measure them; cogito keeps its own list private.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
iterativeTrim runs compaction, compaction with a smaller tail, an
aggressive tool-output prune and a hard truncation in turn, and
validates each result with validateCompaction before it moves on.
compactHistory now calls compactHistoryKeep, so a smaller tail does
not write the shared config.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add compressToolOutput with four levels (full, truncated, outline,
elided) and Session.progressivePrune, which runs pruneMessages and then
assigns levels by position and context pressure. The turn's messages
manipulator now calls progressivePrune, so every request carries the
levels.

Levels are recorded by tool_call_id with their rendered text, only go
up, and are assigned in one batch only when pressure rises into a
higher band. The prompt prefix changes at most once per threshold
crossing. Compaction and the forced prune of the overflow recovery
render the same levels, so they see what the requests sent.

An elided result with no artifact names the tool that produced it.

The mid-turn compaction tests now use 7500-token results. At 9000 the
request passed 90% of the budget, the older result was elided and the
request fell under the trigger, so no compaction ran to test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replace the substring markers and the "first two N tokens" guesser with
an ordered list of regex rows. Named groups (window, total, input,
output) say what each figure means. classifyOverflow applies the
structured error code, the rows, an output-cap guard and a 413 rule,
and returns one of KindContext, KindBudget or KindOutputCap. Fixtures
in chat/testdata/overflow include the two regolo captures on both
cogito paths, and the typed streaming text form cogito is moving to.

Each row is marked strong or weak. The status gate (400, 413 or
unknown) applies only to weak evidence: the generic phrase rows and
the 413 rule. A structured code, a provider row or a config row
classifies at any status, because LocalAI and llama.cpp report an
overflow as HTTP 500 (gRPC Internal).

isContextOverflow is now KindContext only. isBudgetOverflow and
isOutputCapError are added for the recovery work. learnedWindowFrom,
overflowFigures and overflowDetail read the catalog.
compaction.overflow_patterns adds user rows ahead of the built-ins.

Changes to existing tests and callers:
- summary_shift_test: TestSummaryGivesUpAfterMaxAttempts expects
  isWindowOverflow. Its vLLM rejections state an input that fits, so
  they are budget overflows now.
- config_test: compare Compaction with reflect.DeepEqual, because the
  struct now holds a slice.
- The summary retry (summarizeFitting, rollingCover) shrinks on context
  and budget overflows through canShrinkSummary.
- classifyBackendError reads classifyOverflow: a LocalAI overflow at
  HTTP 500 stays fatal; a generic phrase in a 5xx is retried.
- No test learned a window from a wording that no row names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ic retries

Overflow recovery in SendMessage now acts on the overflow kind:

- context: run iterativeTrim, then basicCompact when trimming cannot
  make the history fit. The whole chain is one recovery attempt, and
  the retry is announced only when a step changed the history.
- budget: do not compact. Retry once with the output reservation set
  to window - input - outputSafetyMargin, from the backend's figures,
  through a one-turn override that requestLimits reads. When that is
  below minOutputTokens, or the lowered retry overflows again, only a
  smaller prompt helps, so the turn goes through the context chain.
- output_cap: do not compact. Lower the session's output cap (and the
  client's) to the stated maximum, never raising it, and retry once.

When the retry after recovery still overflows, the rollback keeps the
compaction and removes only the failed turn's own messages. Before, it
restored the pre-turn history, so the next turn overflowed again and
the session was stuck.

Changes to existing tests: the overflow fixtures seeded a history of a
few bytes, which a summary cannot shrink, so the validated chain
rejected it. They now seed longer replies. The tests that need the
whole chain to fail keep the tiny history (tinyHistory).

The regolo regression test runs over HTTP at 1:10 scale: vLLM window
21000, LiteLLM /model/info 9600/9600.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cogito 01190bf (mudler/cogito#79) returns a non-200 streaming answer as
an *openai.APIError or *openai.RequestError that carries the HTTP
status, instead of the text "localai stream: status 400: <body>".
errorStatus now reads the status from the typed error on the streaming
path too; the text form stays supported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With a known window, a head larger than the summary prompt budget still
went out whole first, and fitSummaryInput cut long pieces and dropped
middle messages to make it fit. That built a lossy summary input on
purpose, and cost the same one call before rolling would start.

Now such a head goes directly to rollingCover with the initial target
(window less the summary output reservation, capped by ContextBudget).
A head that fits, or an unknown window, keeps the single whole-head
call and rolls only after an overflow. fitSummaryInput still cuts a
single piece too large for a chunk on its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SchemaBudget counted only the tools nib registers itself. cogito adds
every MCP server's tools and its agent tools to each request, so with many
MCP servers the real floor was far larger than the estimate and no warning
fired.

The request wrapper now records the size of the tools and system messages
of each request that carries tools, raised to the backend's own overhead
when the reported prompt tokens say more. SchemaBudget uses that floor once
a request was sent, and the MCP servers' tools in the estimate before. It
attributes the schemas per MCP server (cached until the server set
changes), and the session reports the floor once through OnStatus when it
crosses the warn threshold, and again at the error threshold.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e window

When the tool schemas and system prompt alone leave no room for the
user's message and a minimal answer, no compaction makes a request fit.
Overflow recovery compacted, trimmed and basic-compacted the history
anyway, and then failed.

Overflow recovery now checks the floor before the chain. When the floor
plus the user's message and minOutputTokens is over the window, the turn
ends with errSchemaFloor: the history is restored to the pre-turn state,
the user's message stays on screen, and the message names the largest
MCP servers, in the same words as the schema-budget notice. End-of-turn
and mid-turn auto-compaction are skipped when the floor plus the last
user message is over the context budget. A floor that is only an
estimate blocks only when it alone is over the limit.

TestSessionAutoCompacts reported 1000 prompt tokens against a 100-token
window, which calibrates the floor far over the budget. It now reports
14000 against a 20000-token window: over the trigger, under the budget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The floor was raised to prompt_tokens minus the estimate of the
non-system messages. That residual holds the tool schemas and also the
tokenizer skew on the whole conversation, so the floor grew with the
history: 5k of schemas under 60k of history on a backend counting 1.5x
read as a 35k floor. With a large reserve, auto-compaction was skipped
although the history was the problem, and the schema notice reported
the skew as tool schemas.

The wrapper now learns a ratio, prompt_tokens over the byte/4 estimate
of the whole request, clamped to [1, maxTokenizerRatio], and the floor
is the measured schemas and system prompt times that ratio. A request
with an image part does not calibrate, as the estimate does not count
images. The mid-turn trigger still uses the full residual.

TestSessionAutoCompacts now runs against a backend that counts 1.5x the
estimate, with a large history and half the window reserved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cogito now names why a decision failed. An interrupted stream is retried
at most 3 times and invalid tool arguments once, instead of the 10 turn
retries every unknown error got.

A tool call cut by finish_reason=length is a context problem when the
window ran out: prompt plus completion reached 95% of it, or the output
clamp lowered max_tokens for that request. nib then retries the turn once
with a one-turn note that asks the model to split the call. It compacts
first when the prompt is at least a quarter of the window, and lowers the
reasoning effort for the turn when the reasoning filled it. The output
cap is never lowered. When the cap was reached with room left, the turn
fails with a message that names max_tokens.

cogito's text for a truncated call says "context window", so the overflow
catalog no longer reads it as a rejected request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mudler
mudler merged commit a81a44a into master Sep 26, 2026
2 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.

2 participants