Skip to content

Commit 886c423

Browse files
kevin-dpclaude
andauthored
feat(agents): context compaction (budget notices, mid-turn floor, background compaction) (#4605)
Adds context compaction to the agents runtime, which previously only truncated at the window limit. Modelled on OpenAI Codex's summarization but adapted to our event-sourced timeline: a compaction checkpoint is a durable `context_inserted` row placed at a stored **watermark**, so history reconstruction folds everything up to the watermark into a summary. ### What's included - **Token-usage gauge** — persist cache-inclusive `context_input_tokens` + `context_window` per step; `ContextUsageIndicator` shows "X% used" in the composer footer. - **Budget notices** — inject a `<token_budget>` message into the model context at 25 / 50 / 75% usage (synthesized at the model-call seam, not persisted). - **Tool-output truncation** — cap any single oversized `tool_result` with a placeholder. - **Mid-turn synchronous floor** — at the 90% hard ceiling, compact before the model step via the adapter's `transformContext` hook (so a single tool-heavy turn can't blow the window). - **Background (turn-end) compaction** — non-blocking: at 85% a detached summarize runs off the critical path; its checkpoint is applied at the next turn's start, or immediately if it finishes while idle. Each generation uses a watermark-unique checkpoint id so a new run can't supersede a prior completed one, and summarize calls are bounded by a hard timeout. - **UI** — a "Compacting…" indicator (distinct blocking vs. background styling) and a collapsible "Context compacted" entry in the conversation timeline. ### Design notes - Reconstruction places the summary **at** the stored watermark, so a prompt+answer that arrived while a background summary ran (physically after the checkpoint, logically after the watermark) are kept verbatim **after** the summary. - Only `complete` checkpoints act as a reconstruction watermark; `running`/`failed` are UI-only and never hide history (crash-safe). - Thresholds are env-tunable: `ELECTRIC_AGENTS_COMPACT_CEILING`, `ELECTRIC_AGENTS_COMPACT_BG_CEILING`, `ELECTRIC_AGENTS_COMPACT_MIN_TOKENS`. ### Testing - Full runtime suite green (1020 passing), including dedicated compaction, reconstruction, mid-turn, and background unit tests. - Verified live in the desktop app end-to-end (compaction applies, context shrinks, indicator clears). ### Note PR #4596 (context-usage ring gauge + breakdown) is stacked on this branch; re-target it to `main` after this merges. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent cf476c1 commit 886c423

34 files changed

Lines changed: 2709 additions & 41 deletions

‎.changeset/context-compaction.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@electric-ax/agents-runtime': patch
3+
'@electric-ax/agents-server-ui': patch
4+
---
5+
6+
Context compaction for the agents runtime. Modelled on OpenAI Codex's
7+
summarization but adapted to the event-sourced timeline (a `context_inserted`
8+
checkpoint placed at a stored watermark, so reconstruction folds older messages
9+
into a summary):
10+
11+
- A context-window usage gauge (cache-inclusive `context_input_tokens` +
12+
`context_window` persisted per step) and `<token_budget>` notices injected at
13+
25 / 50 / 75% usage.
14+
- Oversized tool-output truncation, and a synchronous mid-turn compaction floor
15+
at the 90% hard ceiling (runs before every model step via the adapter's
16+
`transformContext` hook).
17+
- Non-blocking background (turn-end) compaction that starts at 85%: a detached
18+
summarize whose checkpoint is applied at the next turn's start, or immediately
19+
if it finishes while idle. Each generation uses a watermark-unique checkpoint
20+
id so a new run can't supersede a prior completed one. Summarize calls are
21+
bounded by a hard timeout.
22+
- UI: a "Compacting…" indicator (blocking vs. background) and a collapsible
23+
"Context compacted" entry in the conversation timeline.
24+
25+
Thresholds are env-tunable (`ELECTRIC_AGENTS_COMPACT_CEILING`,
26+
`ELECTRIC_AGENTS_COMPACT_BG_CEILING`).

‎packages/agents-runtime/src/client.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,18 @@ export {
4646
// drift from the runtime dispatcher.
4747
export { isGoalCommandText, parseGoalCommand } from './goal-command'
4848
export { formatTokenCount } from './token-budget'
49+
export {
50+
CONTEXT_USAGE_BACKGROUND_START,
51+
CONTEXT_USAGE_HARD_CEILING,
52+
computeContextUsage,
53+
contextUsageLevel,
54+
formatContextUsagePercent,
55+
} from './token-accountant'
56+
export type {
57+
ContextUsage,
58+
ContextUsageInput,
59+
ContextUsageLevel,
60+
} from './token-accountant'
4961
export type { GoalCommand } from './goal-command'
5062

5163
export type {
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { COMPACTION_CHECKPOINT_NAME } from './compaction'
2+
import type { CompactionStatus } from './compaction'
3+
4+
/** A pi-agent message — loose shape; we only build and slice these. */
5+
export type AgentMessageLike = {
6+
role: string
7+
content: unknown
8+
timestamp?: number
9+
}
10+
11+
export interface MidTurnCompactorDeps {
12+
/** Summarize the given (older) messages into a handoff summary string. */
13+
summarize: (messages: Array<AgentMessageLike>) => Promise<string>
14+
/** Persist the checkpoint lifecycle row (UI marker + future reconstruction). */
15+
writeCheckpoint: (status: CompactionStatus, content: string) => void
16+
/** Compaction fires at/above this fraction of the context window. */
17+
ceiling: number
18+
}
19+
20+
export interface CompactContextInput {
21+
messages: Array<AgentMessageLike>
22+
/** Estimated tokens of the outgoing context (real last-step usage + tail). */
23+
currentTokens: number
24+
contextWindow: number
25+
}
26+
27+
export type CompactContextFn = (
28+
input: CompactContextInput
29+
) => Promise<Array<AgentMessageLike> | null>
30+
31+
export function buildCompactionSummaryMessage(
32+
summary: string
33+
): AgentMessageLike {
34+
return {
35+
role: `user`,
36+
content: [
37+
{
38+
type: `text`,
39+
text: `<${COMPACTION_CHECKPOINT_NAME}>\n${summary}\n</${COMPACTION_CHECKPOINT_NAME}>`,
40+
},
41+
],
42+
timestamp: Date.now(),
43+
}
44+
}
45+
46+
/**
47+
* Build the per-turn mid-turn compaction hook for one agent run. The returned
48+
* function is wired to pi-agent's `transformContext` — it runs before every
49+
* model step. Once the estimated outgoing context crosses the ceiling it folds
50+
* the WHOLE context into a summary and continues from `[summary, ...anything
51+
* appended since]` (Codex-style — no verbatim pre-compaction tail).
52+
*
53+
* Summarizing everything is what keeps the persisted checkpoint sound: the
54+
* `writeCheckpoint` wiring stamps it with `watermark = timeline head`, and a
55+
* verbatim tail kept below that head would be dropped on the next turn's
56+
* reconstruction yet excluded from the summary — silently losing context. By
57+
* covering everything up to the head, summary and watermark agree.
58+
*
59+
* The summary is cached for the rest of the turn: later steps reuse it
60+
* (returning the compacted view) instead of re-summarizing. Coverage is only
61+
* extended once new messages appended this turn push back over the ceiling, and
62+
* a re-summarization chains off the previous summary (prev summary + new
63+
* middle) rather than re-reading the whole already-summarized bulk.
64+
*
65+
* Returns `null` to leave the context untouched (no compaction needed/active).
66+
*/
67+
export function createMidTurnCompactor(
68+
deps: MidTurnCompactorDeps
69+
): CompactContextFn {
70+
let state: { summary: string; coveredCount: number } | null = null
71+
72+
const compactedView = (
73+
messages: Array<AgentMessageLike>
74+
): Array<AgentMessageLike> | null =>
75+
state
76+
? [
77+
buildCompactionSummaryMessage(state.summary),
78+
...messages.slice(state.coveredCount),
79+
]
80+
: null
81+
82+
return async ({ messages, currentTokens, contextWindow }) => {
83+
const overCeiling = currentTokens >= deps.ceiling * contextWindow
84+
85+
// Under the ceiling: keep the compacted view sticky if we already compacted
86+
// this turn, otherwise leave the context untouched.
87+
if (!overCeiling) return compactedView(messages)
88+
89+
// Fold the ENTIRE current context (everything maps to timeline items at or
90+
// below the head the checkpoint will store), so summary and watermark agree.
91+
const coveredCount = messages.length
92+
93+
// Nothing new to fold beyond the existing summary's coverage.
94+
if (coveredCount <= 0) return compactedView(messages)
95+
if (state && coveredCount <= state.coveredCount)
96+
return compactedView(messages)
97+
98+
// Chain off the previous summary so we don't re-summarize the whole bulk.
99+
const toSummarize = state
100+
? [
101+
buildCompactionSummaryMessage(state.summary),
102+
...messages.slice(state.coveredCount, coveredCount),
103+
]
104+
: messages.slice(0, coveredCount)
105+
106+
deps.writeCheckpoint(`running`, ``)
107+
try {
108+
const summary = await deps.summarize(toSummarize)
109+
deps.writeCheckpoint(`complete`, summary)
110+
state = { summary, coveredCount }
111+
return compactedView(messages)
112+
} catch {
113+
deps.writeCheckpoint(`failed`, ``)
114+
// Fall back to any compaction we already had (or leave untouched).
115+
return compactedView(messages)
116+
}
117+
}
118+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { completeSimple } from '@mariozechner/pi-ai'
2+
import { resolvePiModel, toAgentHistory } from './pi-adapter'
3+
import {
4+
COMPACTION_SUMMARIZATION_PROMPT,
5+
COMPACTION_SUMMARY_PREFIX,
6+
} from './compaction'
7+
import type { LLMMessage, SummarizeCompleteFn } from './types'
8+
9+
export type { SummarizeCompleteFn }
10+
11+
const DEFAULT_SUMMARY_MAX_TOKENS = 2048
12+
13+
/**
14+
* Hard deadline for a single summarization request.
15+
*
16+
* pi-ai's anthropic provider applies a client-side timeout (and abort) ONLY
17+
* when the caller passes `timeoutMs`/`signal`, and it never retries. Background
18+
* compaction fires this call CONCURRENTLY with the agent's own streaming turn on
19+
* the same (OAuth) token; if that concurrent stream stalls, an unbounded call
20+
* hangs forever — wedging the pending slot and blocking all future attempts.
21+
* Bounding it turns a stall into a failure the caller can retry next turn-end.
22+
*/
23+
const DEFAULT_SUMMARY_TIMEOUT_MS = 120_000
24+
25+
/**
26+
* Summarize a conversation into a compaction handoff summary.
27+
*
28+
* Uses the conversation's own model by default: a cheaper, small-window model
29+
* would overflow on a near-full context — the whole reason we are compacting.
30+
* The full history is sent followed by Codex's summarization prompt; the summary
31+
* is prefixed with Codex's preamble so the resuming model knows it's a handoff.
32+
*/
33+
interface SummarizeCoreInput {
34+
model: string | object
35+
provider?: string
36+
apiKey?: string
37+
maxTokens?: number
38+
/** Hard deadline for the model call; defaults to {@link DEFAULT_SUMMARY_TIMEOUT_MS}. */
39+
timeoutMs?: number
40+
complete?: SummarizeCompleteFn
41+
}
42+
43+
/**
44+
* Core summarization over already-converted history messages (pi-agent's
45+
* `AgentMessage[]` shape). Appends Codex's summarization prompt, calls the
46+
* model, and prefixes the result. Both the LLMMessage path and the mid-turn
47+
* AgentMessage path funnel through here.
48+
*/
49+
async function summarizeConverted(
50+
historyMessages: ReadonlyArray<unknown>,
51+
input: SummarizeCoreInput
52+
): Promise<string> {
53+
const complete =
54+
input.complete ?? (completeSimple as unknown as SummarizeCompleteFn)
55+
const model = resolvePiModel({
56+
model: input.model as never,
57+
...(input.provider && { provider: input.provider as never }),
58+
})
59+
const context = {
60+
messages: [
61+
...historyMessages,
62+
{
63+
role: `user`,
64+
content: COMPACTION_SUMMARIZATION_PROMPT,
65+
timestamp: Date.now(),
66+
},
67+
],
68+
}
69+
70+
// Bound the call: pass `timeoutMs`/`signal` (which the anthropic provider
71+
// honours) AND race against a hard timer, so a stalled stream that ignores the
72+
// abort still rejects rather than hanging the background slot forever.
73+
const timeoutMs = input.timeoutMs ?? DEFAULT_SUMMARY_TIMEOUT_MS
74+
const controller = new AbortController()
75+
let timer: ReturnType<typeof setTimeout> | undefined
76+
const timeout = new Promise<never>((_, reject) => {
77+
timer = setTimeout(() => {
78+
controller.abort()
79+
reject(new Error(`[compaction] summarize timed out after ${timeoutMs}ms`))
80+
}, timeoutMs)
81+
})
82+
83+
const call = complete(model, context, {
84+
maxTokens: input.maxTokens ?? DEFAULT_SUMMARY_MAX_TOKENS,
85+
...(input.apiKey && { apiKey: input.apiKey }),
86+
signal: controller.signal,
87+
timeoutMs,
88+
})
89+
// If the timeout wins the race, `call` rejects later (aborted) — swallow it so
90+
// the loser doesn't surface as an unhandled rejection.
91+
call.catch(() => {})
92+
93+
let res: Awaited<ReturnType<SummarizeCompleteFn>>
94+
try {
95+
res = await Promise.race([call, timeout])
96+
} finally {
97+
if (timer) clearTimeout(timer)
98+
}
99+
100+
const textBlock = res.content.find((block) => block.type === `text`)
101+
const text = textBlock && `text` in textBlock ? (textBlock.text ?? ``) : ``
102+
if (text.trim().length === 0) {
103+
throw new Error(
104+
`[compaction] empty summary (stopReason=${res.stopReason ?? `none`} error=${res.errorMessage ?? `none`})`
105+
)
106+
}
107+
108+
return `${COMPACTION_SUMMARY_PREFIX}\n${text}`
109+
}
110+
111+
/**
112+
* Summarize a conversation (LLMMessage form) into a compaction handoff summary.
113+
*
114+
* Uses the conversation's own model by default: a cheaper, small-window model
115+
* would overflow on a near-full context — the whole reason we are compacting.
116+
*/
117+
export async function summarizeMessages(
118+
input: SummarizeCoreInput & { messages: ReadonlyArray<LLMMessage> }
119+
): Promise<string> {
120+
return summarizeConverted(toAgentHistory([...input.messages]), input)
121+
}
122+
123+
/**
124+
* Summarize already-converted `AgentMessage[]` (what `transformContext` hands
125+
* us mid-turn) — same as `summarizeMessages` but skips the LLMMessage→Agent
126+
* conversion since the messages are already in that shape.
127+
*/
128+
export async function summarizeAgentMessages(
129+
input: SummarizeCoreInput & { messages: ReadonlyArray<unknown> }
130+
): Promise<string> {
131+
return summarizeConverted(input.messages, input)
132+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/**
2+
* Context compaction.
3+
*
4+
* When the conversation approaches the context window, it is summarized into a
5+
* durable "checkpoint" — a `context_inserted` row tagged `kind: "compaction"` —
6+
* and the messages it summarizes are dropped from the reconstructed history. The
7+
* checkpoint carries a watermark: reconstruction hides everything up to it and
8+
* emits the summary in their place (see `timelineMessages`).
9+
*/
10+
11+
/** `attrs.kind` marking a `context_inserted` row as a compaction checkpoint. */
12+
export const COMPACTION_CHECKPOINT_KIND = `compaction`
13+
14+
/** `name` (and thus the rendered tag) for a compaction checkpoint entry. */
15+
export const COMPACTION_CHECKPOINT_NAME = `compaction_summary`
16+
17+
/** Stable id for the (single, self-superseding) compaction checkpoint entry. */
18+
export const COMPACTION_CHECKPOINT_ID = `compaction`
19+
20+
/**
21+
* Lifecycle of a compaction checkpoint, carried in `attrs.status`:
22+
* - `running` — summarization in flight (UI shows a live "Compacting…" entry)
23+
* - `complete` — summary ready; acts as the timeline watermark
24+
* - `failed` — summarization failed; turn proceeded uncompacted
25+
*/
26+
export type CompactionStatus = `running` | `complete` | `failed`
27+
28+
/**
29+
* Whether a `context_inserted` row's attrs mark it as a compaction checkpoint
30+
* (any status).
31+
*/
32+
export function isCompactionCheckpointAttrs(
33+
attrs: Record<string, string | number | boolean> | undefined
34+
): boolean {
35+
return attrs?.kind === COMPACTION_CHECKPOINT_KIND
36+
}
37+
38+
/**
39+
* Whether attrs mark a *completed* compaction checkpoint — the only state that
40+
* acts as the reconstruction watermark. A `running` (or crashed) checkpoint
41+
* must never hide history.
42+
*/
43+
export function isCompleteCompactionCheckpointAttrs(
44+
attrs: Record<string, string | number | boolean> | undefined
45+
): boolean {
46+
return (
47+
attrs?.kind === COMPACTION_CHECKPOINT_KIND && attrs?.status === `complete`
48+
)
49+
}
50+
51+
/**
52+
* Summarization prompt, reused verbatim from OpenAI Codex. Appended as a user
53+
* message after the conversation being compacted.
54+
*/
55+
export const COMPACTION_SUMMARIZATION_PROMPT = `You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume the task.
56+
57+
Include:
58+
- Current progress and key decisions made
59+
- Important context, constraints, or user preferences
60+
- What remains to be done (clear next steps)
61+
- Any critical data, examples, or references needed to continue
62+
63+
Be concise, structured, and focused on helping the next LLM seamlessly continue the work.`
64+
65+
/**
66+
* Prefix prepended to the produced summary when it is reinserted as the
67+
* checkpoint, reused verbatim from Codex.
68+
*/
69+
export const COMPACTION_SUMMARY_PREFIX = `Another language model started to solve this problem and produced a summary of its thinking process. You also have access to the state of the tools that were used by that language model. Use this to build on the work that has already been done and avoid duplicating work. Here is the summary produced by the other language model, use the information in this summary to assist with your own analysis:`

0 commit comments

Comments
 (0)