feat(backend): chat context compaction - #261
Conversation
…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>
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>
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>
|
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. |
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>
…/MesoX/platypus into feature/context-compaction-clean
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>
…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
left a comment
There was a problem hiding this comment.
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:
defaultHttpGetJsonin 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-1or a float would flow into the budget math.> 0 && Number.isFinitewould 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
|
@MesoX please let me know when this is ready for another review 👍 |
|
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. |
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
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.summaryWatermark,contextSummary,compactionDirty) goes through a single compare-and-swap keyed on aversioncolumn. 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.400/413overflow 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 flagscompactionDirtyand 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
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 conservative8192default. 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 amodelMetaedit,defaultresults use a short TTL, and API fetches use a 5 s timeout behind a single-flight guard.usageexists; every later turn uses the real, provider-reportedusage.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.prepareChatTurnover 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 visiblecontext-compactedevent keeps the behavior fail-loud.prepareStephook on bothstreamTextandgenerateText, 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.DEFAULT_COMPACTION_CONFIG); only the window/output size is per model. SettingCOMPACTION_ENABLED=falsedisables 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
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 peakcontextTokens.(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.POST /chats/:id/compactruns 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.compact_contexttool-call/result pair that reuses the tool-call UI. The trace part is stripped beforeconvertToModelMessages, so it never replays to the provider as a phantom tool call.Notable rejected alternatives (full list in ADR §Considered Options)
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.contextSummary,summaryWatermark,compactionDirty, andversion.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
idbelongs 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, andcontext_edited.Config
New
apps/backend/.env.examplekeys, all optional with built-in defaults:COMPACTION_ENABLED,COMPACTION_TRIGGER_RATIO,COMPACTION_TARGET_RATIO,COMPACTION_RESERVE_RATIO,COMPACTION_KEEP_RECENT,COMPACTION_MIN_PRUNABLE_CHARS, andCOMPACTION_MIN_RECENT_PRUNABLE_CHARS.Tests
New suites cover
compaction.test.ts,context-window.test.ts,token-estimate.test.ts, andrecovery.test.ts; existing suites foragent-runner.test.ts,chat.test.ts,chat-execution.test.ts,sub-agent.test.ts, andpackages/schemas/index.test.tswere extended. Recovery's per-provider overflow regex is fixture-tested across OpenAI/vLLM, Anthropic, Google, and Bedrock.Known limitations (ADR §Open/deferred)
Pending → Runningcompaction 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