Skip to content

feat(backend): chat context compaction - #261

Open
MesoX wants to merge 40 commits into
willdady:mainfrom
MesoX:feature/context-compaction-clean
Open

feat(backend): chat context compaction#261
MesoX wants to merge 40 commits into
willdady:mainfrom
MesoX:feature/context-compaction-clean

Conversation

@MesoX

@MesoX MesoX commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Chat context compaction: keep chats alive past the model's context window

Design doc: docs/adr/0012-context-compaction.md (ADR-0012)

Problem

Chats hard-fail the moment their message history exceeds a model's context window. The AI SDK (ai@6) reports real token usage after each call, but it exposes no context-window metadata on the model interface and no pre-call tokenizer. Providers also disagree on whether the window is discoverable at all: Google, OpenRouter, and vLLM expose it via their APIs, while OpenAI, Anthropic, and Bedrock do not. On top of that, the existing error handling only covered auth, rate-limit, and 5xx failures, so a context-overflow rejection killed the turn outright.

What this PR does

This change adds a two-tier, view-not-delete compaction model. It is fed by a single token estimator, all of its durable state is mutated through a single versioned compare-and-swap (CAS) writer, an always-on recovery net catches the overflow errors that the proactive path misses, and a deterministic context-editing pass prunes stale, bulky tool results without ever calling a model. Because top-level chats and sub-agents both run through the shared agent-runner/ToolLoopAgent, one implementation covers both.

The full rationale — including every alternative we rejected and why — lives in ADR-0012. The key decisions, summarized from it, are below.

Load-bearing principles

  • View, not delete. The watermark and summary change what is sent to the model, never what is stored. Raw messages persist in the database untouched, so compaction is non-destructive in the data sense: a user can still read the full history, and a future "expand summary" UI comes for free. This reduces the "irreversible data loss" objection to a matter of UX courtesy rather than correctness. A summarized message is never hard-deleted.
  • One estimator. Token counting lives in exactly one function operating over one neutral structure (CountUnit[]). Both Tier 1 (UIMessages) and Tier 2 (ModelMessages) normalize into it, and both count only model-bound parts (text, tool-call, tool-result, file, image). Divergence between the two tiers is therefore impossible by construction, because a tier cannot fire on a number the other tier never sees.
  • One durable writer. Every mutation of compaction state (summaryWatermark, contextSummary, compactionDirty) goes through a single compare-and-swap keyed on a version column. Concurrency — for example a trigger run racing a user run — and the interaction between compaction and history-edit invalidation are both resolved by version rather than by comparing watermark values. That matters because a watermark can move backward on a history edit, and a value-based comparison would misread that as "not yet advanced" and write a stale summary over mutated history. On a CAS conflict the loser re-reads the row: if the winner already covered its prefix it skips, otherwise it retries once and then skips with a contended warning. There is no recompute loop and no livelock.
  • Recovery is the net. When a 400/413 overflow error is caught, the messages are aggressively trimmed in memory (through the same Tier 2 adapter, so there is no bespoke trimmer) and the call is retried exactly once. Recovery flags compactionDirty and leaves the durable compaction to the next turn. Crucially, recovery stays on even when proactive compaction is globally disabled — it is the last line of defense, not a risk surface.

Mechanisms

  • Window resolution. resolveContextWindow(provider, modelId) resolves a window per model, in order: a manual override (provider.modelMeta), then provider API auto-detection (Google / OpenRouter / vLLM), then the community-maintained litellm registry JSON (which covers OpenAI / Anthropic / Bedrock), and finally a conservative 8192 default. We deliberately do not maintain our own context-window table. Key normalization is boundary-safe, results are cached per provider+model with a TTL, the cache is evicted immediately on a modelMeta edit, default results use a short TTL, and API fetches use a 5 s timeout behind a single-flight guard.
  • Token estimation. The estimator uses char/4 over text parts only (it never runs char/4 over a base64 image) and a modality table to size non-text parts. It is used only on the first turn, before any provider usage exists; every later turn uses the real, provider-reported usage.inputTokens. We accept the first-turn imprecision — guarded by a 1.15 cold-start margin and the recovery net — rather than ship a per-provider tokenizer.
  • Tier 1 — cross-turn (durable). This runs in prepareChatTurn over the durable history. Its budget math works off the input budget (window − maxOutputReserve − safetyReserve), with a trigger ratio of 0.8 and a target of 0.5. The two ratios are deliberately distinct to give hysteresis, so compaction does not re-fire on the very next turn. The work is staged cheap-first: Stage 0 context-edits (pruning bulky old tool results with no model call), Stage 1 prunes the older prefix, and Stage 2 summarizes the prefix into a single synthetic message only if the conversation is still over target. Tool-call/result pairs stay atomic across the keep boundary, summarization is incremental (only the messages after the watermark), and a visible context-compacted event keeps the behavior fail-loud.
  • Tier 2 — intra-turn (in-memory). This handles a single heavy response whose own tool loop bloats the window mid-loop. It runs in the SDK prepareStep hook on both streamText and generateText, summarizing old completed tool results while keeping recent steps verbatim and preserving call/result pairing. It fires only when genuinely near the limit, and it is not persisted — the next turn's Tier 1 folds the result into the durable summary.
  • Sub-agents. Sub-agents start fresh on each invocation and carry no cross-turn history, so they use Tier 2 only. Each one resolves its own model's window and output limits, and recovery covers them as well through the shared runner.
  • Config and kill switch. Compaction behavior is global (DEFAULT_COMPACTION_CONFIG); only the window/output size is per model. Setting COMPACTION_ENABLED=false disables all proactive compaction in production without a deploy, and recovery ignores that flag. Per-agent tuning was shipped and then removed, because no surveyed tool exposes it and the ratios already self-normalize to the model window.

Frontend

  • Context-usage ring. A small ring next to the model selector fills as usedTokens / contextWindow, ramping from green to amber (≥ 0.7) to red (≥ 0.9), and rendering a neutral grey when the window is unknown or defaulted rather than guessing a ramp. The window comes from the currently selected model, and the numerator is the last response's peak contextTokens.
  • Per-message stats. An (i) popover under each assistant response shows input/output tokens, time-to-first-token, and total generation time, reusing the existing tool-call timing mechanism.
  • Force-compact on demand. The ring is clickable: POST /chats/:id/compact runs Tier 1 once regardless of the threshold and returns the post-compact usage so the ring refreshes immediately. A click is deferred while a response is streaming, and a confirmation dialog appears only when the drop would be significant.
  • Compaction trace. Compaction is surfaced as a synthetic compact_context tool-call/result pair that reuses the tool-call UI. The trace part is stripped before convertToModelMessages, so it never replays to the provider as a phantom tool call.

Notable rejected alternatives (full list in ADR §Considered Options)

  • Single-tier compaction (cross-turn only). Rejected because it cannot rescue a single response whose own tool loop overflows the window.
  • Hard-deleting or truncating old messages. Rejected because it is irreversible and reproduces the "silently drops the middle" failure mode seen in gateway truncation. View-not-delete keeps the data and makes the action auditable.
  • A homegrown context-window lookup table. Rejected because it is unmaintainable across providers; the litellm registry is the industry's "don't maintain your own table" answer.
  • A real pre-call tokenizer. Rejected for v1 because it is a heavy per-provider dependency for a number the provider returns accurately after the very first call.
  • Optimistic concurrency by comparing watermark values. Rejected because it breaks when invalidation moves the watermark backward; versioned CAS removes that monotonicity assumption.
  • Compacting only to the trigger threshold. Rejected because it re-fires every turn (the thrash failure mode); distinct trigger and target ratios are what provide hysteresis.
  • A token floor on the trigger (max(window × pct, 64000)). Rejected because it overflows sub-64k models, where the trigger would never fire. A floor belongs only on the window fallback, never on the trigger.

Schema changes (additive, nullable/defaulted)

  • provider.modelMeta (JSONB) — per-model window/output overrides.
  • chat/run gain contextSummary, summaryWatermark, compactionDirty, and version.
  • Migration: apps/backend/drizzle/0046_context_compaction.sql.

Rollout is lazy and there is no backfill: existing chats compact only on their next turn. An eager backfill would create a thundering herd of summarize calls.

Cross-tenant safety

The submit route verifies that the body id belongs to the caller's workspace before a run starts. The compaction store is keyed by chat id alone, so without that check one workspace could mutate another's summary and watermark.

Observability

The feature emits structured metric:-tagged log lines: compaction.fired, summarize.latency_ms, recovery.*, context_window.fell_to_default, litellm.key_miss, cas.conflict, and context_edited.

Config

New apps/backend/.env.example keys, all optional with built-in defaults: COMPACTION_ENABLED, COMPACTION_TRIGGER_RATIO, COMPACTION_TARGET_RATIO, COMPACTION_RESERVE_RATIO, COMPACTION_KEEP_RECENT, COMPACTION_MIN_PRUNABLE_CHARS, and COMPACTION_MIN_RECENT_PRUNABLE_CHARS.

Tests

New suites cover compaction.test.ts, context-window.test.ts, token-estimate.test.ts, and recovery.test.ts; existing suites for agent-runner.test.ts, chat.test.ts, chat-execution.test.ts, sub-agent.test.ts, and packages/schemas/index.test.ts were extended. Recovery's per-provider overflow regex is fixture-tested across OpenAI/vLLM, Anthropic, Google, and Bedrock.

Known limitations (ADR §Open/deferred)

  • A single oversized newest result. A tool result too large to fit even as the newest message still hard-errors; the fix is an ingestion cap at storage time, which is out of scope here.
  • A few items are consciously deferred and are not needed for the "no more hard fails" goal: a live Pending → Running compaction trace (it currently renders post-hoc only), the estimate-vs-real divergence metric (log-only for now), and content-type tool-result media accounting.

🤖 Generated with Claude Code

frantisek.spacek@morosystems.cz and others added 22 commits June 11, 2026 20:26
…paction

Adds proactive + reactive context-window management so chats no longer hard-fail
when history exceeds a model's window (docs/adr/0009).

- Context-window resolution (context-window.ts + litellm-registry.ts): manual
  override → provider API → vendored litellm registry → conservative default,
  cached with TTL and evicted on modelMeta change.
- Single token estimator over a neutral CountUnit structure (token-estimate.ts),
  shared by Tier 1 (UIMessages) and Tier 2 (ModelMessages).
- Tier 1 cross-turn compaction (compaction.ts): prune-then-summarize behind a
  single versioned CAS watermark writer; hysteresis trigger/target ratios.
- Tier 2 in-turn compaction via prepareStep; sub-agent wiring threads the window.
- Recovery middleware (recovery.ts): detect provider context-overflow, trim and
  retry once; always on, independent of the proactive kill switch.
- agent-runner wraps the model with recovery, threads RV1 prior-messages for
  C4 edit-detection, and stamps §H/§I run stats onto the assistant message.
- Schema: additive nullable provider.modelMeta + chat compaction state + per-agent
  compaction config; migration 0046. RV2 workspace-scope check on chat submit;
  POST /:chatId/compact for on-demand compaction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ations

Surfaces the compaction work in the chat UI.

- Context-usage ring beside the model selector (§H): fill = last input tokens /
  resolved window for the selected model; neutral grey when the window is
  unknown/default. Clickable to compact on demand (§J).
- Per-message stats popover next to Regenerate (§I): input/output tokens, TTFT
  and total generation time from the server-stamped metadata.stats.
- Tool-call run durations in the tool header, reusing server start/complete
  timestamps with a client-observed fallback (useToolDuration). The hook is
  written to satisfy the react-hooks purity / set-state-in-effect / refs rules:
  render reads only state, and all writes are deferred into timer callbacks.
- Per-agent compaction config fields in the agent form.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
# Conflicts:
#	apps/backend/src/services/chat-execution.ts
…nds in /v1

vLLM and most OpenAI-compatible providers set baseUrl to "{root}/v1" (the
OpenAI SDK needs it that way for chat calls). detectOpenAiCompatible appended
"/v1/models" to that, producing "{root}/v1/v1/models" → 404 → the window
silently fell to the 8192 default and the §H usage ring rendered "unknown".
Strip a trailing "/v1" before building the models URL so the probe hits
"{root}/v1/models" and reads max_model_len. Added a regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The §I stats popover (i) icon was gated on metadata.stats, which was only
stamped post-stream and persisted to the DB — so it appeared a refetch
round-trip after the answer finished, lagging the copy/delete/regenerate icons.

Emit the stats via messageMetadata on the `finish` event so they ride the final
stream chunk and the (i) appears the instant the answer completes. firstTokenAt
is captured in streamText.onChunk (fires before finish) instead of the async
snapshot drain. A single buildMessageStats() helper feeds both the streamed copy
and the post-stream persist stamp (sharing one finishedAt) so live and reloaded
stats match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… trace

11a (U5): ring no longer jumps back to pre-compaction value on user Send.
Expiry now tracks `assistantMessageCount` instead of `messages.length` so
optimistic user-message pushes don't trigger early fallback.

11b: wrap the agent-info (i) button in a Tooltip so hover reveals its
purpose without opening the dialog. Shows agent description or "Agent info".

11c (§K): Tier 1 compaction is now visible in the chat timeline.
- Backend emits synthetic `compact_context` tool-call + tool-result chunks
  into the UIMessage stream (via `prependCompactionChunks`) immediately after
  the `start` event; the existing tool-call expander renders them for free.
- `CompactionTrace` threads through Tier1Output → ChatTurn.compactionTrace →
  agent-runner.stream() — only when a model summary was produced (prune-only
  turns produce no trace to avoid empty/confusing entries).
- `COMPACT_CONTEXT_TOOL_NAME` constant shared across stream producer, strip
  filter, §J message builder, and frontend display-name mapping.
- `stripCompactionTraceParts` removes trace parts before ModelMessage
  conversion so the provider never sees the phantom tool call on replay.
- `buildCompactionTraceMessage` builds a standalone assistant message for §J
  (forced compact endpoint — no live stream to inject into).
- `title: "Context compaction"` on the chunk + tool.tsx display-name mapping
  shows a friendly label instead of the raw underscore name.
- `context-usage-ring.tsx`: improve neutral-state tooltip copy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Wraps the existing Popover trigger in a Tooltip using the
TooltipTrigger asChild > PopoverTrigger asChild composition pattern.
Hover shows compact stats (In/Out/TTFT/Total); click still opens
the full popover.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Chunk 12: drop the per-agent context-compaction override surface.
Compaction now runs from DEFAULT_COMPACTION_CONFIG (gated by the
COMPACTION_ENABLED kill switch); per-model config lands separately.

- schema.ts: drop 6 agent columns (compaction_enabled, trigger_ratio,
  target_ratio, reserve_ratio, keep_recent_messages, min_prunable_chars)
- schemas: drop matching base fields + the targetRatio<triggerRatio
  hysteresis refine; slim agentCreate/agentUpdate picks
- compaction.ts: delete CompactionConfigOverrides + resolveCompactionConfig
- chat-execution.ts: build config from DEFAULT_COMPACTION_CONFIG
- agent-form.tsx: remove the Context compaction form block + state
- tests: drop the per-agent config cases

Migration: rewrite 0046 to never add the agent columns (it has not
reached main) + trim its snapshot, rather than stacking a drop
migration. Already-migrated DBs (dev, test server) had the columns
dropped manually.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per-agent compaction config was removed in 625ff96; the `agent` parameter
threaded into buildCompactionRuntime is now unused. Remove it from the args
type, the destructure, and all three call sites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
After Chunk 12 compaction config is global (DEFAULT_COMPACTION_CONFIG +
COMPACTION_ENABLED kill switch). Add optional env overrides for the ceiling
ratios so a test deployment can lower the trigger and exercise auto-compaction
without filling a large context window.

Unset/blank/invalid env values fall back to the built-in defaults, so
production behavior is unchanged. Knobs: COMPACTION_TRIGGER_RATIO,
COMPACTION_TARGET_RATIO, COMPACTION_RESERVE_RATIO, COMPACTION_KEEP_RECENT,
COMPACTION_MIN_PRUNABLE_CHARS. Documented in .env.example.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…to compaction plan

Survey of Hermes Agent, Codex CLI, Claude Code, Cline confirms the shipped
design already implements real-token feeding (C1), input-tokens-only window
(F1), reserve-carve trigger (C3 = Codex 90% cap), and two-layer proactive+
recovery (P4). Documents the Hermes token-floor (#14690) as a do-not-copy
anti-pattern and defers three optional adds (message-count valve, maxOutput
reserve floor, model-aware aggressiveness).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Capture the 2026-06-12 live test-server findings: per-step-timeout bug that
kills pre-stream compaction (150s summarize > 120s watchdog), the 8,631-token
runaway, and the lost-turn root cause. Add the four fixes (heartbeat,
maxOutputTokens cap + concise prompt, stream-open-before-compaction with live
Running status, abortSignal), the prior-art summarization-prompt survey
(Claude Code / Codex / OpenCode / Hermes), the proposed replacement prompt,
and the decided-against provider-model-selector.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…pt overhaul

Fix 1 (CRITICAL): thread `onActivity` into `buildCompactionRuntime` and tick it
every 10 s via `setInterval` while `generateText` runs inside the `summarize`
closure. The 120 s per-step stall watchdog now sees regular activity during a
slow summarize call instead of silence, and no longer kills the run before the
summary is committed.

Fix 2: add `maxOutputTokens: 2000` ceiling to the summarize `generateText` call
as a hard backstop against the runaway-expansion failure mode observed in the
live test (8,631-token output for a 6,178-token input). Log `finishReason` and
emit a `warn` if the ceiling is hit so the event is visible in prod logs.
Replace the unstructured one-liner system prompt with a structured four-section
handoff prompt (Intent / Decisions / Files / Next step) that survives repeated
re-compactions and includes an explicit "aim under ~1500 tokens" length target.

Fix 3 (stream-before-compaction) deferred — heartbeat addresses the immediate
timeout bug; the live-indicator refactor is a larger architectural change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Address review findings on the chunk 13 summarize changes:

- Reorder the handoff prompt sections most-critical-first (Intent →
  Current state & next step → Decisions → Files) so a truncation at the
  maxOutputTokens ceiling drops file/tool detail rather than the
  resume-critical intent and next step. Add a front-load instruction.
- Thread the run's AbortSignal through prepareChatTurn and
  buildCompactionRuntime into the summarizer generateText call. The Fix 1
  heartbeat suppresses the per-step stall watchdog during summarize, so
  without this a hung call would leak until the 10 min per-run timeout;
  now a cancel/timeout aborts it.
- Drop the redundant onActivity wrapper at the call site (the optional
  event param already satisfies the () => void heartbeat signature).
- Reconcile the conflicting token-count comments (~150-200 vs ~1500)
  around the output ceiling.
- Add tests for the summarize closure: abort signal + ceiling + ordered
  prompt threading, the finishReason === length warn path, and the
  heartbeat tick/clear lifecycle. Export buildCompactionRuntime for test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stage 2 compaction returned recent messages verbatim, so large tool
results (e.g. MCP dumps) in the kept window dominated tokensAfter and
prevented reaching targetTokens. Prune recent tool outputs with a
higher threshold (minRecentPrunableChars, default minPrunableChars*5)
and apply it across every over-target return path — including the
empty-prefix / null-watermark bail where the whole history fits within
keepRecentMessages. Warn when the post-compaction estimate still
exceeds 2x target. Raise summarizer output ceiling 2000 -> 4000 to
catch models that ignore the 1500-token prompt limit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three tasks: (1) Tier 1 recent-trim safety (64232d8 shipped; option D
overflow-gate + exempt-newest remaining), (2) context-editing-style
prune of kept tool results (needs review + bigger plan; session
iteration captured), (3) ingestion cap for oversized MCP/sub-agent
results (upstream-issue candidate, marked not filed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…newest

Tier 1 recent (kept) tool results are now trimmed only when the kept view
would breach the hard window wall (inputBudget), not on a soft targetTokens
(hysteresis) miss. A soft miss is left at full fidelity and re-compacts next
turn. The single newest message is always exempt.

- UICompactOptions.inputBudget threaded from applyTier1Compaction as
  max(0, budget.inputBudget - overheadTokens) (mirrors effectiveTarget).
- keepRecentWithinWall applied on both over-target paths (Stage 2 + empty
  prefix); omitted budget falls back to always-trim (pre-option-D guard).

Review fixes:
- Re-gate the over-target warning to afterEstimate > inputBudget; the old
  target*2 heuristic fired on every healthy compaction under a low target
  ratio. Falls back to target*2 when no wall is supplied.
- keepRecentWithinWall returns the recent estimate to avoid a double pass.

Tests: 57 pass (option-D verbatim/trim/empty-prefix/no-warn cases).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… elision)

editToolResults + elidedToolPlaceholder: pure view transform eliding OLD
bulky tool-result bodies to a self-describing placeholder before the Tier 1
trigger, so a leaned view can skip summarization. Recency by tool-result
count, newest message exempt, size-gated; no durable state (P1).

Post-review hardening: grow-guard (never elide when placeholder >= output;
no prompt inflation / negative reclaim) + idempotency guard, both decided up
front into a Map so the rewrite runs only on real work and never copies on a
no-op. 3 config fields + 3 env overrides. +10 tests; runs/ suite 193 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ction-clean

# Conflicts:
#	apps/backend/src/runs/agent-runner.test.ts
#	apps/backend/src/runs/agent-runner.ts
#	apps/backend/src/services/chat-execution.ts
#	apps/backend/src/tools/sub-agent.ts
Defensive fixes to context compaction surfaced by chunk 14 review:

- A1: projectTier1Tokens treats lastInputTokens<=0 as no-baseline
  (usage-less gateways persist contextTokens=0); findLastInputTokens
  skips trace messages and zero-count turns symmetrically.
- A2: no-dimension image token fallback uses pessimistic per-provider
  ceilings (Anthropic 1600 / OpenAI-high 2000) instead of flat 1200.
- A6: cap output reserve at half the window so an input-scoped
  contextWindow minus a large max_output_tokens cannot collapse
  inputBudget and thrash.
- B-F1/C2: range-validate compaction env overrides (warn + default on
  out-of-range); restore target<trigger hysteresis clamp lost when
  chunk 12 removed resolveCompactionConfig.
- B-F3: wrap sub-agent models with overflow-recovery middleware (built
  always; recovery is the P4 net even under the kill switch), guarded
  on a model instance so a string id degrades instead of dropping the
  sub-agent.
- B-F7: report same-basis tokensBefore/After in compaction.fired.
- B-F8/A4: forceCompactChat appends the trace via atomic jsonb
  concatenation instead of overwriting the whole column.
- T10 deviation: version-mismatch skip no longer clears dirty (a
  concurrent invalidate also bumps version but leaves dirty on purpose).

Typecheck clean; compaction + token-estimate suites 92 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Map-reduce summarizer no longer re-overflows: summarizePrefix checks the
folded size (prior summary + framing) and recurses the reduce step; chunks
split on message boundaries via packSegments instead of arbitrary char slices.

Force-compact confirm now gates on significance (ADR-0012 §Force-compact):
/compact returns tokensBefore/messagesDropped/keepRecentMessages and the
context-window endpoint exposes keepRecentMessages so the client confirms only
when the drop is significant, else runs immediately.

Token estimator counts errorText on output-error UI parts, restoring the
UI/Model adapter count equality (§One estimator). Sub-agent recovery/Tier 2
subtract per-sub-agent overhead. Extracted resolveCompactionConfig so the
runtime and the context-window route share one config source.

Also: drop fabricated Qwen3-72B registry key, rename ADR 0009→0012 (resolves
collision with the invitation ADR-0009), refresh stale plan/0009 references,
ring amber via CSS var with hex fallback, add cross-tenant 404 submit test,
remove shipped internal plan doc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@MesoX MesoX changed the title feat(backend): chat context compaction (ADR-0012) feat(backend): chat context compaction Jun 15, 2026
CI `pnpm lint` failed on the compaction branch under type-aware rules:
- require-await: de-async test mocks + two prod helpers (loadBuiltinRegistry,
  the default loadRegistry slot) that never await; return Promise.resolve/reject
  to preserve behavior.
- no-unnecessary-type-assertion: drop redundant `as` casts (autofix).
- no-unsafe-* / no-explicit-any: type the captured mock-call args in
  chat-execution/sub-agent tests instead of leaking `any`.
- no-useless-assignment: drop the dead `agent` local in the force-compact path.

No behavior change. Lint, typecheck, and 203 affected tests all pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
willdady and others added 4 commits June 23, 2026 16:56
Two spots in the "interactive stream" test were never updated when the
withToolTimestamps/tee pipeline was reordered: the toUIMessageStream
mock returned a plain object instead of a real ReadableStream, and the
test still drove completion through toUIMessageStream's onFinish
callback, which the runner stopped using in favor of ending the
snapshot queue.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…l durations

Review fixes:
- M1: force Tier 1 past the char-only no-op gate when the provider-token
  floor triggered the compaction
- M2: drop "."/":" from family-key separators so gpt-4.6 no longer resolves
  to a stale gpt-4 8192 window
- M3: wrap Tier 2 prepareStep summarize in try/catch so a summarizer failure
  never kills the turn
- M4: tag post-compact ring estimate +1 for the appended trace message
- recovery: guard byte-identical retry (messagesDropped === 0); widen
  overflow phrasings (context window / maximum prompt length / request_too_large)
- compact route: ownership check before runRegistry probe; {error} response shape
- gate compaction trace on a committed CAS write (no phantom trace)
- context-window: resolver cache sweep; encode Google probe URL; OpenRouter auth
- summarizePrefix termination guard; document non-image token under-count
- thread abort/heartbeat into sub-agent + force-compact summarize
- frontend: 409 backoff on deferred compact; duration rounding carry

Remove the tool-call duration feature (scope creep, mixed server/client
clocks): the useToolDuration hook and its header wiring, plus the backend
withToolTimestamps/applyToolCompletions stamping. Keep the context-usage ring
and the per-message stats popover.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@MesoX

MesoX commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

Hi @willdady, after few weeks of usage I have noticed few small things which are fixed in latest commit in this branch. Overal implementation is heavily inspired by industry leaders - Anthropic, Hermes Agent and others,

I have also played around with Fable and let it do a thorough review of the process itself and most findings were rather cosmetic ones, so I am pretty confident we are near production quality.

There sure are things which can be expanded later (like handling precalculations for attached files), but I mainly wanted to prepare solid base with two tiered compaction in place which greatly helps for smaller LLMs with lower context.

Thanks.

frantisek.spacek@morosystems.cz and others added 11 commits July 10, 2026 09:56
Surface context compaction as a live two-phase compact_context tool call:
tool-input-available (Running) the instant a model summary begins, then
tool-output-available (Completed) once it lands. Input carries before-stats
(tokensBefore/messagesBefore); Output carries after-stats (tokensAfter,
tokensSaved, reductionPct, messagesDropped, summaryExcerpt) via a shared
compactionTracePayloads helper so auto and force paths render identically.

- agent-runner: open the client UI stream before prepare via
  createUIMessageStream({execute}); a one-shot onSummarizeStart callback writes
  the in-progress chunk mid-prepare. finalizeCompactionTrace always closes a
  fired spinner (Done / degraded Done / error) so the badge never hangs.
- compaction: carry tokensBefore/tokensAfter/messagesBefore on CompactionTrace
  and fire onSummarizeStart from inside applyTier1Compaction where the basis is
  in scope.
- frontend: optimistic Running part on force-compact, swapped for the persisted
  Done trace on success, removed on error.
- docs: ADR-0012 rewritten for the live two-phase emission; deferred
  Pending -> Running bullet removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…cker

Open the assistant message with an explicit `start` before the in-progress
compaction chunk, so it carries a stable id from its first client-visible
chunk. The merged model stream then suppresses its own `start`
(sendStart:false), keeping a single start/finish pair — no re-key flicker
when the model stream lands ~1s after summarize. On the prepare-error path
emit a terminal `finish` so the manually-opened message closes as a matched
pair instead of hanging mid-stream.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…willdady#2 estimate vs actual, willdady#3 effectiveness)

Open questions (fix willdady#2 scope, fix willdady#3 direction) recorded at the top of the
file to answer before implementation. Captures root cause + file refs from
the live investigation of chat JYk7oF so work can resume on another machine.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Recover the post-compaction ring value from the persisted compact_context
  trace on reload, instead of reverting to the pre-compaction turn's count.
- Anchor the force-compact "after" estimate (and the trace's before/after) to
  the last provider-reported input count, so the ring reconciles with real
  turns instead of jumping up on the next response.
- Lower keepRecentMessages default 10 -> 5 and extend the hard-wall retained
  trim to large text parts (e.g. big recent answers), not just tool results.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The context-ring fixes are shipped; the working plan doc is no longer needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The manual useMemo for ringUsedTokens uses a loop-with-early-return shape
the React Compiler can't preserve, failing `eslint` (chat.tsx:443). Convert
to a plain IIFE so the compiler auto-memoizes it on `messages`; behavior and
memoization are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolved conflicts (combine both sides — compaction/recovery + willdady#306 security guardrails):
- sub-agent.ts: keep recovery-middleware model wrap AND provider security-guardrails instructions
- chat-execution.ts: sub-agent resolver returns {model, securityGuardrails} using resolved provider
- sub-agent.test.ts: merged mock (capturedSettings + agentConstructorSpy), kept both tests
- drizzle: main's 0046_brave_smasher kept; branch migration regenerated as 0047_context_compaction (proper snapshot chain on merged schema)

Note: brings AI SDK v6 -> v7 (willdady#316); run pnpm install + typecheck before deploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The main merge brought AI SDK v6 -> v7 (willdady#316), which had breaking changes
affecting the compaction branch's code:

- PrepareStepFunction is now generic (PrepareStepFunction<ToolSet>): parametrize
  it in compaction.ts, chat-execution.ts, sub-agent.ts.
- token-estimate bytesFromDataContent: v7 convertToModelMessages wraps file data
  as { type: "url", url: URL } (url is a URL instance). Widen param to unknown and
  unwrap it so image dimensions are still parsed — otherwise the model-shape token
  estimate loses image size and diverges from the UI-shape estimate.
- agent-runner.test: prepareStep options dropped experimental_context and added
  required fields; cast the synthetic options object to the param type.

Backend typecheck clean; full suite (1272 tests) passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Both casts are redundant after TS narrowing:
- token-estimate.ts: `data` is narrowed by the `"url" in data` guard
- sub-agent.ts: `model` is narrowed by `typeof model !== "string"`

Fixes the lint failure on CI (@typescript-eslint/no-unnecessary-type-assertion).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
MesoX pushed a commit to MesoX/platypus that referenced this pull request Jul 12, 2026
…61-168

Brings the reviewed + lint-updated compaction branch (main-merged: v2.1 + AI SDK
v7 + willdady#306) onto the 3-PR test-deploy branch, on top of the canonical fork tip
(context-ring fix + frontend port-80 deploy fix).

Conflict resolution:
- schema.ts / schemas/index.ts / organization.test.ts / organization-form.tsx:
  keep branch's willdady#168 agentRunSettings (identityContext willdady#306 preserved via auto-merge)
- sub-agent.test.ts: take lint-updated intersection-type order
- chat.tsx: take lint version (useMemo -> plain IIFE for React Compiler); body
  keeps the honest context-ring logic via auto-merge
- drizzle: main's 0046_brave_smasher kept; branch migrations dropped and the
  combined delta regenerated as 0047_combined_306_261_168 (willdady#261 + willdady#168 columns;
  willdady#306 already in 0046)

Backend typecheck clean; full suite (1285 tests) passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@willdady willdady left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thanks for this, @MesoX. It's a serious piece of work and the ADR is genuinely good to read. The two-tier view-not-delete model is the right call, the versioned CAS writer holds up, tool-call/result pairing stays intact across the keep boundary, and the tests actually exercise the logic instead of stubbing it out. The /compact endpoint scopes by workspace correctly, and the data.id check on submit closes a real gap that exists on main today. So no complaints about the craft.

I'm hesitant to merge yet, though, mostly over one schema decision.

modelMeta vs the per-model config we already have. Since you opened this, main swapped modelIds: string[] for modelConfigSchema ({ id, passthroughFileTypes }), and that object's comment already calls itself the home for this kind of metadata: "the intended home for future per-model metadata (e.g. max input/output tokens)". Your PR adds modelMeta as a separate JSONB column keyed by model id instead, so we'd have two per-model config structures shaped differently (an array of objects, and a record) with nothing keeping them in sync. A model can have a modelMeta entry but not be in modelIds, or drop out of modelIds and leave its modelMeta entry orphaned. dedupeModelConfigs only touches modelIds.

Can we put contextWindow? and maxOutputTokens? on modelConfigSchema, right next to passthroughFileTypes where operators already set per-model behaviour, and drop the modelMeta column? That kills the drift problem and follows the direction the code already documents. It's a migration, so it's a lot cheaper to settle before the rest lands on top of it.

Tier 2 prepareStep. I want to flag this rather than assert it, since I was reading against an older SDK than the ai@7 we're on now. My concern: the messages you return from prepareStep build that one step's prompt, but if the SDK doesn't fold them back into the running history, every later step rebuilds from the untrimmed messages, and summarize() (a real model call) fires again on each step with the result thrown away. Not an infinite loop, but on a long tool loop it'd be a quiet cost multiplier. Could you confirm how this behaves on v7? A test over a multi-step loop asserting summarize runs once would settle it.

A few smaller things:

  • defaultHttpGetJson in the window auto-detect has a 5s timeout but no size cap, and it hits a provider-configured baseUrl. A misbehaving gateway could stream a huge body inside the timeout and blow up memory. Worth bounding.
  • The API-reported window is accepted on if (contextWindow), so a -1 or a float would flow into the budget math. > 0 && Number.isFinite would be safer.
  • The PR text says window resolution falls back to a remotely-fetched litellm registry, but the code vendors it as static data with no fetch. That's the safer choice, just worth fixing the description so nobody goes looking for a network dependency that isn't there.
  • The overflow regex in recovery.ts has some broad alternates (too many tokens, request_too_large) that could catch an unrelated 400 and trigger a needless trim and retry. Also only 400/413 are handled; a few gateways use 422.

Last thing, and it's about process, not the code: for something this load-bearing I'd rather have hashed out the design in an issue first, mainly so the modelMeta question above got answered before 14k lines were built on it. Can we start there? One way to unblock the rest: split the per-model window config and resolver into their own smaller PR, get the schema shape agreed, then stack the compaction work on top. Happy to pair on the modelMeta change if it helps.

…ction-clean

# Conflicts:
#	apps/backend/drizzle/meta/0047_snapshot.json
#	apps/backend/drizzle/meta/_journal.json
#	apps/backend/src/routes/chat.test.ts
#	apps/backend/src/routes/chat.ts
#	apps/backend/src/routes/org-provider.ts
#	apps/backend/src/routes/provider.ts
#	apps/backend/src/runs/agent-runner.test.ts
#	apps/backend/src/runs/agent-runner.ts
#	apps/backend/src/services/chat-execution.test.ts
#	packages/schemas/index.test.ts
#	packages/schemas/index.ts
@willdady

Copy link
Copy Markdown
Owner

@MesoX please let me know when this is ready for another review 👍

@MesoX

MesoX commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

Hi @willdady,

Thanks for the review.

I'll update it accordingly - just give me a little time to test all the changes.

On the last part: I completely agree, and I've since started raising things as issues up front, because it does feel like the better direction. In this case it initially looked like there wasn't that much code involved - at least from the early findings. Splitting the two tiers also would have felt like shipping an incomplete feature: I try to deliver something MVP-like, and one tier without the other is more of a mess than something useful. Then a lot of small issues cropped up here and there once our users started testing it.

On the split specifically: I'll ship a smaller PR first for the per-model window configuration (the schema shape + resolver + UI), get that shape agreed and merged, then rebase this PR on top of it. That should let us settle the modelMeta → modelConfig question in isolation before the rest lands on it.

On the litellm registry: you're right on both counts - the PR text is wrong (it claims the window falls back to a remotely-fetched registry, but the code vendors it as static data - it originated from LiteLLM registry and I worded it incorrectly), and vendored-static is the safer runtime choice. I'll fix the description regardless.

The one thing I'd flag before we lock it in: the static file is hand-written and only covers ~120 models, while litellm's own list is 300+ and moving. Left as-is it quietly drifts out of date, and a model that's missing silently falls to the conservative 8192 default. So I'd like to keep it static at runtime but stop hand-maintaining it. A few ways to do that:

Build-time generated vendor (my preference). A small script (pnpm update:litellm-registry) fetches litellm's official model_prices_and_context_window.json (MIT-licensed, ~1.4 MB), prunes each entry to {max_input_tokens, max_output_tokens, max_tokens}, and writes a checked-in JSON. Runtime stays exactly as it is now — static import, no network — but refreshing the data is one command (or a periodic CI job that opens a PR) instead of hand-editing.
Runtime fetch + cache, with the vendored file as fallback. Always-latest, but adds a startup egress dependency to GitHub — I don't think it's worth it given your (correct) preference for no runtime fetch.
Drop the registry entirely (close second for me). Now that the per-model window is a real UI field, we could require operators to set it explicitly for providers that can't auto-detect (OpenAI/Anthropic/Bedrock) and fall straight to the default otherwise.
My honest reasoning on (3): if we commit to the principle that an admin should know which model and provider they're connecting, then we don't strictly need a registry at all. But since a well-maintained, community-driven registry already exists and is trivially reachable, it feels daft not to lean on it — which is the only thing that edges (1) ahead of (3) for me. Happy to go either way. What's your call?

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