Skip to content

Cache-stable requests, step 2: ContextSift keeps the prefix - #133

Merged
ayman3000 merged 12 commits into
mainfrom
cache-stable-step2
Oct 9, 2026
Merged

ayman3000 merged 12 commits into
mainfrom
cache-stable-step2

Conversation

@ayman3000

Copy link
Copy Markdown
Owner

Step 2 of Naseem's cache-stable requests design. Between eviction batches, the request a conversation sends is byte-identical to the previous call plus the new content; a batch changes only the region where receipts replaced evicted steps.

  • Real eviction batches: a batch needs a minimum amount of new evictable content and aims at max(target, floor + slack), so a conversation whose uncuttable floor is near the budget no longer evicts on every step. The system prompt is off the sifted size (budget recalibrated; minMessageBudgetFraction).
  • Receipts in place: the eviction ledger leaves the system message; each evicted step is replaced by a deterministic receipt at its own position. Sifted results keep their images. Entering sifting alone changes no bytes (a completed result is truncated only as part of a batch).
  • No silent trim: with a ContextManager, the pre-sift history trim is gone. An overflow-only safety net (overflowFraction, default 0.8; overflowTargetFraction) measures the sifted request, removes the fewest oldest whole steps, keeps the cut until the bound is breached again, never drops the task or the step the model is waiting on, and emits .historyTrimmed with a reason (.overflow, .fitToWindow, .messageCap, .tokenBudget). Apps with their own compaction set overflowFraction above their trigger and compact on .overflow.
  • Progress note last: the periodic progress note is the last user-role message of its call, never merged into the system message.
  • Sub-agents get a childManager() with every parent setting (readIdentityParams included).
  • Wire tests: consecutive-call byte-identity through an eviction batch (OpenAI, streaming and not), Anthropic role alternation with trailing nudges.

Requires LLMProviderKit 0.1.0-alpha.41 (Gemini and Anthropic merge consecutive same-role messages).

Breaking: AgentEvent.historyTrimmed gained a reason (SwiftAgentKitUI's AgentEventView needs a third _).

Tests: 644 pass (7 skipped).

🤖 Generated with Claude Code

ayman3000 and others added 12 commits October 9, 2026 09:13
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…udget

Once what cannot be evicted reached the budget, every call evicted the step
that had just finished, so the prompt prefix changed on every call (the
32K-window thrash). A pass now evicts only when at least a quarter of the
budget can go, down to max(target, floor + slack). The system prompt is no
longer part of the sifted size; it is taken off the budget instead, never
below a quarter, so system + messages keep the same ceiling.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The receipt ledger lived in the system message, so every eviction batch
changed the start of the request and the whole conversation missed the
prompt cache. Each evicted step is now one assistant message in its own
place (its text, then a bracketed receipt per call); a batch changes
nothing before its first evicted step. The 20-line ledger cap is gone
(ledgerEntries is kept, unused). Sifted tool results keep their images.

A completed result is bounded (preview + artifact reference) only as
part of a batch, after its first evicted step, or when it was already
bounded while active: crossing into sifting alone no longer truncates a
result the normal path sent whole.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ceipt, not ledger

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The child manager dropped readIdentityParams (so sim_ui/mac_ui reads were
never protected), evictionTargetFraction and eagerPersistMinChars.
childManager() copies every setting onto a fresh manager over the same store.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The stored history was trimmed to 80% of the window before ContextSift ran,
which slid the window on every call once the stored history passed it; the
token trim after each step deleted history for good. With a ContextManager
the agent now sends from the whole history, falls back to the trimmed one
only when the sifted request itself, tool definitions counted, passes the
same 80% bound, and reports every trim (.historyTrimmed).

The fallback counts removals against the stored history and keeps a
per-call note (the progress nudge) after the trimmed history, so a nudge
turn neither reports a phantom trim nor loses its nudge.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The net trimmed the RAW stored history to fit, so one breach dropped about
twice the steps it needed, and it recomputed the cut on every later call, so
the prefix moved by about a step per call. It now leaves out the fewest
oldest whole steps that bring the SIFTED request (tool definitions counted)
to a low watermark (overflowTargetFraction, 0.7 of the bound), remembers the
cut by message id, and holds it until the request passes the bound again:
consecutive calls send the same prefix. The step the model is waiting on,
the task and the system prompt are never left out. Compaction clears the cut.

The bound is ContextManager.overflowFraction (default 0.8, as before), so an
app with its own compaction trigger can set it above that trigger. Every
.historyTrimmed now carries a reason (fitToWindow, messageCap, tokenBudget,
overflow); .overflow is the app's signal to compact. A net that cannot get
under its bound logs a warning.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…s commit nothing

The net pinned the latest user message as "the task", so any mid-run nudge
(loop, repair, verifier, plan, reasoning) made the real task the first unit
dropped, and a nudge after the tool results unpinned the step the model was
answering. The agent now records the task's id at run start and pins it, and
trailing user-role nudges count as part of the current step.

The cut search's trial sifts now restore ContextManager's sticky state
(stickyEvicted, boundedCallIDs) after each trial; only the chosen cut's
state is committed, under any eviction fractions.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
It joined the system message, so the turns that carried it changed the
prompt prefix — and on Anthropic without a ContextManager it replaced the
whole system prompt. It is now appended after ContextSift as the call's
last message. beforeModel no longer sees it among the messages.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ame-role messages)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The task-14 review flagged a real HTTP 400 risk: a progress note or a
stored stop-reasoning nudge landing after a tool-result turn produced
consecutive user-mapped entries that AnthropicProvider's old merge
helper (tool-result blocks only) didn't catch. LLMProviderKit
0.1.0-alpha.41 (already required by HEAD) fixes this generically in
the adapter via mergingConsecutiveRoles, but nothing exercised it
through the real wire-building path.

- Add AnthropicRoleAlternationTests: runs an agent through a
  tool-using turn, a reasoning-timeout turn (stores the "stop
  reasoning, act now" nudge), and a progress-note turn that lands on
  the same call as that stored nudge — the exact collision the review
  named. Builds the request through the real
  AnthropicProvider.prepareRequest (not ScriptedProvider) and asserts
  the wire body's messages roles strictly alternate and the note is
  the last text block of the last user entry.
- Add LLMProviderKitAnthropic to the SwiftAgentKitTests target so that
  test can build real Anthropic request bodies next to
  ProgressNoteTests / CacheStableRequestTests.
- Restore the coverage StreamedReasoningTests had neutralized:
  aCallThatOnlyThinksPastTheLimitIsStoppedAndToldToAct now keeps
  progress nudges ON (the default fractions) instead of disabling
  them, and asserts the stop-reasoning nudge is found among the
  turn's user messages rather than assuming it is the literal last
  one (which, with nudges on, can be the trailing progress note
  instead).

swift test --filter "ProgressNote|StreamedReasoning|CacheStable|HistoryTrim|AnthropicRoleAlternation": 29 tests passed.
Full swift test: 642 tests in 48 suites passed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Also checks the streaming request body (stream: true) holds the same
append-only promise, and pins that a progress note changes only the tail.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@ayman3000
ayman3000 merged commit 841df6a into main Oct 9, 2026
1 check passed
@ayman3000
ayman3000 deleted the cache-stable-step2 branch October 9, 2026 07:40
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