Conversation
`completeMessages` replaces the silent truncation `messages` did: it keeps every message since the newest checkpoint or returns `error.ContextTooLarge`. `requestBudget` folds the system, tools and instruction cost into the budget and reserves less output room while summarising. Ollama's shrink-on-overflow retry is removed because it can no longer help: halving the budget only makes a complete request more likely to be refused. `systemText` loses its `dropped` argument for the same reason, since nothing drops any more, and the four helpers with no callers outside this file are narrowed from `pub fn` to `fn`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The loop now compacts once and resumes the turn when a request outgrows the window, rather than letting the transcript be silently trimmed. `preserve_context` keeps unsummarised messages past the display cap, `contextMessageCount` counts what a session needs to continue, and `recovered_from_overflow` bounds recovery to one attempt per submit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Important
The keep-all-or-refuse contract works, but recovery from a server-rejected overflow is gated by the same local estimate that caused it — worth addressing before merge.
Reviewed changes — full diff of both commits against master, plus the surrounding loop/provider code each path touches.
window.zigkeep-all-or-refuse — newcompleteMessagesreturns the entire active context (floored at the newest checkpoint) orerror.ContextTooLarge;requestBudgetfolds system, tools, and instruction cost into one budget with a 90% reserve while summarising;contextTokensestimates the active transcript. Four now-file-private helpers lostpubwith no remaining external callers (verified by grep).- Loop compacts and resumes —
ask()now checks budget, then auto-compact threshold, then fit, recovering from one overflow per submit viastartCompaction(true)(recovered_from_overflow, reset per submit);turn("")sends a continuation instruction when the last message is the checkpoint;stop()and the cancel path now append settled tool results so the wire history stays complete on halted turns. - Providers — ollama's halve-and-retry overflow branch is removed (
waitAndRetrynow only retries transient failures); both backends serialize throughrequestBudget/completeMessagesandsystemTextloses its never-fired dropped-messages note;provider.explainspecial-casesContextTooLarge. - Persistence/display —
resumeSessionloads@max(limit, contextMessageCount)so the unsummarised context always loads;Conversation.preserve_contextstopstrimfrom dropping anything back to the newest checkpoint;usage.context_tokensis now prompt+completion. - Tests — new coverage for overflow recovery, compaction-failure, resume-past-the-page, and complete tool exchanges after cancel; existing loop tests tightened (every call answered exactly once, turns continue after a halt).
⚠️ A server-rejected request has no recovery path when the local estimate was low
The refusal contract has two overflow surfaces: the pre-send estimate and the provider's own rejection. Both recoveries are gated by the same 4-bytes-per-token estimate — ask()'s recovery requires contextFits to fail, and manual /compact is checked by contextFits too — and a server rejection never updates usage.context_tokens, so the loop cannot learn the true size. On content the estimator undercounts (CJK text, per-message tool-JSON overhead) the first request of a session — or the first after compaction, where finishCompaction zeroes usage.context_tokens — can be rejected by the server, fail the turn, and then the suggested remedy /compact refuses with the same estimate: the two advice messages (ollama's "Try /compact" and the loop's "too large to compact safely … switch to a model with a larger context window") point at each other with no exit. The removed ollama halve-and-retry was the only path that reacted to server feedback rather than the estimate.
Technical details
# Overflow recovery is gated by the same estimate that failed
## Affected sites
- src/agent/loop.zig:923-932 (`ask`) — overflow recovery only triggers when the local `contextFits` estimate says the request does not fit; a server-side rejection bypasses this path entirely.
- src/agent/loop.zig:722-730 (`contextFits`) — same estimate gates manual `/compact` via `startCompaction`, so the remedy the failure message suggests fails for the same reason.
- src/agent/loop.zig:734-745 (`finishCompaction`) — zeroes `usage.context_tokens`, so the first request after compaction is estimate-only again.
- src/agent/loop.zig:1168-1181 (`pollRequest` failure path) — a rejected request never records provider-reported usage, so nothing corrects `usage.context_tokens` after a rejection.
## Required outcome
- A server-rejected overflow must lead to either a forced compaction attempt or a corrected size estimate, not a dead end where the error advice and the compaction gate reference each other.
## Suggested approach (optional)
- Update `usage.context_tokens` from provider-reported usage on the failure path when the rejection carries it, and/or let an overflow-classified provider error force `startCompaction(true)` once, mirroring the `recovered_from_overflow` bound.
## Open questions for the human (optional)
- Is "switch to a larger-context model" the intended terminal UX when the estimate is wrong, or should the server's own token count drive compaction?openai-compatible/glm-5.3-flash:cloud | 𝕏
Restores the `dropped` argument and the note it prints. Nothing in this branch passes a non-zero count now that requests are complete-or-refuse, so the note cannot fire today, but removing it was a behaviour deletion this change did not need to make. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
ℹ️ No new issues in the delta — but prior review feedback is unchanged, so this is not an approval.
Reviewed changes — the incremental delta since the prior pullfrog review (head 1673d6c), one commit:
- Restored
systemText's dropped-messages note — thedropped: usizeparameter and the<note>block that1673d6cremoved are back, byte-identical tomaster's version (verified against271777c), including the "the dropped-messages note still gets through" test. Both production call sites (src/provider/ollama.zig:897,src/provider/openai.zig:833) now pass an explicit0, so the note cannot fire while requests are complete-or-refuse — a deliberate scope decision documented in the PR body.
The delta itself is clean. What keeps the PR short of mergeable is prior, still-open feedback: the src/provider/ollama.zig:532-534.
openai-compatible/glm-5.3-flash:cloud | 𝕏
`max_shrink_attempts` and `min_budget` went with the halve-and-retry branch, but their doc stayed and ran straight into the next block, describing behaviour that no longer exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
ℹ️ Minor suggestions only — one recall gap in the new overflow classifier, inline; everything else in the delta checks out.
Reviewed changes — the incremental delta since the prior pullfrog review (head 71d9bf8), one commit:
- Server-rejected overflow now recovers —
pollRequestanswers a providererror.ContextTooLargewith one compaction attempt per submit (recovered_from_overflow, gated onauto_compact_at), mirroring the bound the predicted-overflow path inask()already had; a compaction the backend also refuses falls through and reports instead of trying again. - Overflow classification centralized — the needle list ollama previously kept locally for advice text moved into
Provider.mentionsOverflow(provider.zig);openainow maps a matching non-success body toerror.ContextTooLarge(previouslyerror.HttpError) and ollama delegates through itsisOverflowwrapper. Request.startsnapshotsturn.compacting— the worker's copy of the turn keeps the summarisation flag.- Two new tests pin the recovery (refuse once → compact → carry on) and its one-attempt bound (a compaction the backend also refuses reports after two calls, not three).
The prior 71d9bf8; what remains is minor. CI passes on the head commit.
openai-compatible/glm-5.3-flash:cloud | 𝕏
| const needles: []const []const u8 = &.{ | ||
| "context length", "context window", "too long", "too large", | ||
| "too many token", "exceeds", "input length", "token limit", | ||
| "maximum context", | ||
| }; |
There was a problem hiding this comment.
Verified recall gap in the new classifier: TGI's OpenAI-compatible route refuses genuine overflows with Input validation error: \inputs` must have less than 2048 tokens. Given: 4194— zero needle matches — so it degrades toerror.HttpErrorand the new recovery never fires for TGI users (huggingface/text-generation-inference issues #937, #1519, #1572; the last from thechat_completionsroute). Fail-safe rather than destructive, so this is recall only. Adding"must have less than"closes it and cannot collide with quota bodies; keep"exceeded"` out — OpenAI's 403 quota body ("You exceeded your current quota") would match it.
| const needles: []const []const u8 = &.{ | |
| "context length", "context window", "too long", "too large", | |
| "too many token", "exceeds", "input length", "token limit", | |
| "maximum context", | |
| }; | |
| const needles: []const []const u8 = &.{ | |
| "context length", "context window", "too long", "too large", | |
| "too many token", "exceeds", "input length", "token limit", | |
| "maximum context", "must have less than", | |
| }; |

Summary
Fixes the bug behind "it kept saying it had no recollection of the session": a long conversation was silently truncated to fit the context window, so the model lost history nobody told it about.
window.completeMessageskeeps every message since the newest checkpoint or returnserror.ContextTooLarge. It never drops silently.recovered_from_overflowbounds recovery to one attempt per submit, and resets on each new user message.preserve_contextkeeps unsummarised messages past the in-memory display cap, so the display cap can no longer cost the model real context.requestBudgetfolds system, tools and instruction cost into the budget, and reserves less output room while summarising.Extracted from #23, which is being closed. The Codex provider is not part of this: all
provider_stateplumbing and the cached-token field were left behind.Two consequences worth calling out, both caused by this change rather than carried over:
systemTextkeeps itsdroppedargument and its note. Nothing passes a non-zero count now that requests are complete-or-refuse, so it cannot fire today, but deleting it was a behaviour change this PR did not need to make. Restored in693be74.Also narrows four
window.zighelpers with no callers outside that file frompub fntofn.Test plan
zig build test(515 passed, 5 skipped, up from 506 on master)zig buildcleanzig fmt --checkcleangit bisectworks across this branchDeviations from the ticket text
No ticket. Scope is the context and compaction work from #23, minus anything Codex specific.
Out of scope (flagged for follow-up)
loop.zigestimates tokens at a hardcoded four bytes per token. Worth a named constant.window.zig, pre-existing). A user on a model synth cannot resolve metadata for gets compacted every ~24K tokens with no explanation of why.max_transcript_messagesintui/model.zigdocuments an invariant nothing enforces now that compaction is token driven.openai.zigto readusage.prompt_tokens_details.cached_tokens, which is new work rather than extraction.🤖 Generated with Claude Code