diff --git a/agents/__tests__/context-pruner.test.ts b/agents/__tests__/context-pruner.test.ts index fbeeb7839c..4aca5ecdf0 100644 --- a/agents/__tests__/context-pruner.test.ts +++ b/agents/__tests__/context-pruner.test.ts @@ -3641,6 +3641,35 @@ describe('context-pruner threshold behavior', () => { expect(knowledgeMemory).not.toContain('diagnostic '.repeat(500)) }) + // The 2,400-character goal cap documented in docs/agents-and-tools.md and + // cli/CHANGELOG.md is a baseline at the legacy 100k semantic target, not a + // fixed absolute: it is scaled by clamp(targetTokens / 100_000, 0.5, 3). + // A 200k-class window resolves a 72k target, so the effective cap is + // round(2_400 * 0.72) = 1_728 characters. + test('scales the pinned goal cap below the documented baseline on a 200k window', () => { + mockAgentState.contextWindowTokens = 200_000 + const latest: Message = { + ...createMessage( + 'user', + ['GOAL-HEAD', 'filler '.repeat(2_000), 'GOAL-TAIL'].join('\n'), + ), + tags: ['USER_PROMPT'], + } + + // No explicit maxContextLength: the target comes from the resolved window. + const results = runHandleSteps([latest], 250_000) + const content = results[0].input.messages[0].content[0].text + const goal = content.match( + /Goal:\n {2}([\s\S]*?)\n(?:Decisions:|Files Inspected:|Edits Made:|Validation Results:|Review Receipts:|Post-Edit Anchors:|Blockers:|Next Action:|<\/knowledge_memory>)/, + )?.[1] + + expect(goal).toBeDefined() + expect(goal).toContain('GOAL-HEAD') + expect(goal).toContain('GOAL-TAIL') + expect(goal!.length).toBeLessThanOrEqual(1_728) + expect(goal!.length).toBeGreaterThan(1_400) + }) + test('preserves a structured inline reviewer receipt with the full fingerprint', () => { const fingerprint = 'a'.repeat(64) const messages: Message[] = [ @@ -4161,11 +4190,15 @@ describe('context-pruner dual-budget behavior', () => { const runHandleSteps = ( messages: Message[], contextTokenCount: number, - maxContextLength: number, + maxContextLength?: number, budgets?: { assistantToolBudget?: number userBudget?: number toolFactsBudget?: number + semanticBudget?: { + triggerBudgetTokens?: number + targetBudgetTokens?: number + } }, ) => { mockAgentState.messageHistory = messages @@ -4179,7 +4212,10 @@ describe('context-pruner dual-budget behavior', () => { const generator = contextPruner.handleSteps!({ agentState: mockAgentState, logger: mockLogger, - params: { maxContextLength, ...budgets }, + params: { + ...(maxContextLength !== undefined ? { maxContextLength } : {}), + ...budgets, + }, }) const results: any[] = [] let result = generator.next() @@ -4869,4 +4905,163 @@ describe('context-pruner dual-budget behavior', () => { expect(content).toContain('New request about feature B') expect(content).toContain('Working on feature B') }) + + const extractKnowledgeMemoryBlock = (content: string): string => + content.match(/[\s\S]*?<\/knowledge_memory>/)?.[0] ?? '' + + /** Count " - entry" lines under a knowledge-memory section header. */ + const countKnowledgeMemoryEntries = ( + content: string, + header: string, + ): number => { + const section = + extractKnowledgeMemoryBlock(content).split(`${header}:\n`)[1] ?? '' + let count = 0 + for (const line of section.split('\n')) { + if (!line.startsWith(' - ')) break + count += 1 + } + return count + } + + const createSuccessfulReadMessages = (paths: string[]): Message[] => + paths.flatMap((path, index) => [ + createToolCallMessage(`scaled-read-${index}`, 'read_files', { + paths: [path], + }), + createToolResultMessage(`scaled-read-${index}`, 'read_files', { + kind: 'read_files_result', + version: 1, + status: 'ok', + summary: { requested: 1, ok: 1, partial: 0, failed: 0, uniquePaths: 1 }, + results: [ + { + selector: 'file', + requestIndex: 0, + path, + status: 'ok', + content: 'export const value = 1', + complete: true, + template: false, + }, + ], + }), + ]) + + const createValidationMessages = (count: number): Message[] => + Array.from({ length: count }, (_, index) => index).flatMap((index) => [ + createToolCallMessage(`scaled-cmd-${index}`, 'run_terminal_command', { + command: `bun test suite-${index}`, + }), + createToolResultMessage(`scaled-cmd-${index}`, 'run_terminal_command', { + exitCode: 0, + command: `bun test suite-${index}`, + }), + ]) + + test('retains strictly more Files Inspected entries as the semantic target scales up', () => { + const paths = Array.from( + { length: 90 }, + (_, index) => `src/module-${index}/inspected-file-${index}.ts`, + ) + const messages: Message[] = [ + createMessage('user', 'Inspect the whole module tree'), + ...createSuccessfulReadMessages(paths), + ] + + // ~200k window: target 70k tokens -> 0.70 scale. + const compactContent = runHandleSteps(messages, 200_000, undefined, { + semanticBudget: { + triggerBudgetTokens: 140_000, + targetBudgetTokens: 70_000, + }, + })[0].input.messages[0].content[0].text + // ~1M window: target 350k tokens -> clamped 3.0 scale. + const largeContent = runHandleSteps(messages, 800_000, undefined, { + semanticBudget: { + triggerBudgetTokens: 700_000, + targetBudgetTokens: 350_000, + }, + })[0].input.messages[0].content[0].text + + const compactCount = countKnowledgeMemoryEntries( + compactContent, + 'Files Inspected', + ) + const largeCount = countKnowledgeMemoryEntries( + largeContent, + 'Files Inspected', + ) + + expect(compactCount).toBe(18) // round(25 * 0.70) + expect(largeCount).toBe(75) // 25 * KNOWLEDGE_MEMORY_MAX_SCALE + expect(largeCount).toBeGreaterThan(compactCount) + }) + + test('bounds the pinned knowledge_memory block on a small window but never drops Goal or Next Action', () => { + mockAgentState.base2ActiveWork = { + nextRequiredAction: `Re-run the failing suite and repair the regression. ${'NEXT_ACTION_DETAIL '.repeat( + 60, + )}`, + } + const paths = Array.from( + { length: 40 }, + (_, index) => + `src/deep/nested/${'segment-'.repeat(12)}${index}/file-${index}.ts`, + ) + const decisions = Array.from( + { length: 20 }, + (_, index) => `Decision: option ${index} ${'rationale '.repeat(30)}`, + ).join('\n') + const messages: Message[] = [ + createMessage( + 'user', + `Bound the pinned block. ${'GOAL_DETAIL '.repeat(300)}`, + ), + ...createSuccessfulReadMessages(paths), + createMessage('assistant', decisions), + ...createValidationMessages(20), + ] + + // 8k-class window: target 2.5k tokens, so the ceiling is the + // KNOWLEDGE_MEMORY_MIN_BUDGET_TOKENS floor of 1500 tokens. + const content = runHandleSteps(messages, 20_000, undefined, { + semanticBudget: { + triggerBudgetTokens: 2_800, + targetBudgetTokens: 2_500, + }, + })[0].input.messages[0].content[0].text + const block = extractKnowledgeMemoryBlock(content) + + expect(block).not.toBe('') + expect(Math.ceil(block.length / 3)).toBeLessThanOrEqual(1_500) + expect(block).toContain('Goal:') + expect(block).toContain('Next Action:') + }) + + test('keeps legacy 100k-target retention counts unchanged', () => { + // No contextWindowTokens and no maxContextLength => 140k trigger / + // 100k target, the scale-factor 1.0 baseline. + const paths = Array.from( + { length: 40 }, + (_, index) => `src/legacy-${index}.ts`, + ) + const decisions = Array.from( + { length: 20 }, + (_, index) => `Decision: keep legacy retention path ${index}`, + ).join('\n') + const messages: Message[] = [ + createMessage('user', 'Preserve the legacy retention baseline'), + ...createSuccessfulReadMessages(paths), + createMessage('assistant', decisions), + ...createValidationMessages(20), + ] + + const content = runHandleSteps(messages, 200_000)[0].input.messages[0] + .content[0].text + + expect(countKnowledgeMemoryEntries(content, 'Files Inspected')).toBe(25) + expect(countKnowledgeMemoryEntries(content, 'Decisions')).toBe(12) + expect(countKnowledgeMemoryEntries(content, 'Validation Results')).toBe(12) + }) }) diff --git a/agents/context-pruner.ts b/agents/context-pruner.ts index ddd1e248b8..3c78d2b246 100644 --- a/agents/context-pruner.ts +++ b/agents/context-pruner.ts @@ -182,20 +182,33 @@ const definition: AgentDefinition = { // user's trailing "what to do now" instruction, which often follows a // long diagnostic transcript. const KNOWLEDGE_MEMORY_MAX_GOAL_CHARS = 2_400 - const KNOWLEDGE_MEMORY_MAX_DECISIONS = 8 + const KNOWLEDGE_MEMORY_MAX_DECISIONS = 12 const KNOWLEDGE_MEMORY_MAX_FILES_INSPECTED = 25 const KNOWLEDGE_MEMORY_MAX_EDITS = 25 const KNOWLEDGE_MEMORY_MAX_VALIDATION_RESULTS = 12 const KNOWLEDGE_MEMORY_MAX_REVIEW_RECEIPTS = 12 const KNOWLEDGE_MEMORY_MAX_POST_EDIT_ANCHORS = 16 - const KNOWLEDGE_MEMORY_MAX_BLOCKERS = 8 - const KNOWLEDGE_MEMORY_MAX_NEXT_ACTION_CHARS = 1_000 + const KNOWLEDGE_MEMORY_MAX_BLOCKERS = 12 + const KNOWLEDGE_MEMORY_MAX_NEXT_ACTION_CHARS = 1_400 const KNOWLEDGE_MEMORY_ENTRY_CHARS = 480 const KNOWLEDGE_MEMORY_FILE_FINDING_CHARS = 160 const KNOWLEDGE_MEMORY_REVIEW_RECEIPT_CHARS = 1_200 const TOOL_ERROR_DETAIL_CHARS = 1_200 const EDIT_RESULT_DETAIL_CHARS = 2_000 + /** Target at which the caps above apply verbatim (legacy DEFAULT_TARGET_CONTEXT_LENGTH). */ + const KNOWLEDGE_MEMORY_SCALE_BASELINE_TARGET_TOKENS = 100_000 + const KNOWLEDGE_MEMORY_MIN_SCALE = 0.5 + const KNOWLEDGE_MEMORY_MAX_SCALE = 3 + /** The pinned block may not exceed this share of the summary target. */ + const KNOWLEDGE_MEMORY_MAX_BUDGET_FRACTION = 0.25 + /** Floor so a tiny window still keeps a usable task contract. */ + const KNOWLEDGE_MEMORY_MIN_BUDGET_TOKENS = 1_500 + const KNOWLEDGE_MEMORY_MIN_GOAL_CHARS = 480 + const KNOWLEDGE_MEMORY_MIN_NEXT_ACTION_CHARS = 240 + /** Share of a list dropped per ceiling-eviction pass (bounds re-render cost). */ + const KNOWLEDGE_MEMORY_EVICTION_CHUNK_FRACTION = 0.25 + /** Tool categories for knowledge memory extraction */ const FILE_INSPECTION_TOOLS = [ 'read_files', @@ -822,11 +835,22 @@ const definition: AgentDefinition = { }) } + /** + * Normalize for dedupe comparison only (collapse internal whitespace, + * lowercase) so near-duplicate repeats of the same decision/blocker/ + * validation line stop consuming retention budget. The original trimmed + * text is what gets stored. + */ + function normalizeDedupeKey(text: string): string { + return text.replace(/\s+/g, ' ').trim().toLowerCase() + } + function addUniqueLine(lines: string[], line: string): void { const trimmed = line.trim() - if (trimmed && !lines.includes(trimmed)) { - lines.push(trimmed) - } + if (!trimmed) return + const key = normalizeDedupeKey(trimmed) + if (lines.some((existing) => normalizeDedupeKey(existing) === key)) return + lines.push(trimmed) } function extractPinnedActiveWorkState(text: string): string[] { @@ -877,9 +901,10 @@ const definition: AgentDefinition = { function addUniqueEntry(lines: string[], entry: string): void { const trimmed = entry.trim() - if (trimmed && !lines.includes(trimmed)) { - lines.push(trimmed) - } + if (!trimmed) return + const key = normalizeDedupeKey(trimmed) + if (lines.some((existing) => normalizeDedupeKey(existing) === key)) return + lines.push(trimmed) } /** Parse a previous block back into the accumulator. */ @@ -989,7 +1014,10 @@ const definition: AgentDefinition = { function addUniqueText(texts: string[], text: string): void { const trimmed = text.trim() - if (trimmed && !texts.includes(trimmed)) texts.push(trimmed) + if (!trimmed) return + const key = normalizeDedupeKey(trimmed) + if (texts.some((existing) => normalizeDedupeKey(existing) === key)) return + texts.push(trimmed) } function collectFailureTexts( @@ -1515,50 +1543,102 @@ const definition: AgentDefinition = { return first.slice(0, KNOWLEDGE_MEMORY_FILE_FINDING_CHARS - 3) + '...' } - /** Apply per-field budgets with rolling eviction of oldest entries (RISK2). */ + /** + * Bounded scale for the per-field knowledge-memory caps. The legacy + * default target (100k tokens) yields exactly 1.0, so the baseline cap + * constants apply unscaled; note that those baseline constants were + * themselves raised for decisions (8 -> 12), blockers (8 -> 12), and the + * next action (1,000 -> 1,400 chars), so a 1.0 factor is not a + * byte-identical replay of the previous retention. Larger model-aware + * targets retain more recoverable evidence per pass and smaller ones + * retain less. + */ + function knowledgeMemoryScale(target: number): number { + if (!Number.isFinite(target) || target <= 0) return 1 + const raw = target / KNOWLEDGE_MEMORY_SCALE_BASELINE_TARGET_TOKENS + return Math.min( + KNOWLEDGE_MEMORY_MAX_SCALE, + Math.max(KNOWLEDGE_MEMORY_MIN_SCALE, raw), + ) + } + + /** Estimated tokens of the emitted pinned block (same heuristic as elsewhere). */ + function estimateKnowledgeMemoryTokens(km: KnowledgeMemory): number { + return Math.ceil(buildKnowledgeMemoryBlock(km).length / CHARS_PER_TOKEN) + } + + /** + * Apply per-field budgets with rolling eviction of oldest entries (RISK2), + * scaled by the resolved summary target, then enforce a hard ceiling on the + * whole pinned block. The block is emitted verbatim and is explicitly not + * subject to the normal budget cutoff, so the ceiling is what keeps deeper + * retention from crowding out the live working set on small windows. + * Goal and Next Action are truncated toward a floor, never dropped. + */ function enforceKnowledgeMemoryBudgets(km: KnowledgeMemory): void { + type KnowledgeMemoryListField = + | 'postEditAnchors' + | 'filesInspected' + | 'decisions' + | 'validationResults' + | 'reviewReceipts' + | 'editsMade' + | 'blockers' + + const scale = knowledgeMemoryScale(targetContextLength) + const scaleBudget = (base: number): number => + Math.max(1, Math.round(base * scale)) const capTextPreservingEnds = (text: string, max: number): string => truncateLongText(text, max) - km.goal = capTextPreservingEnds(km.goal, KNOWLEDGE_MEMORY_MAX_GOAL_CHARS) + const maxDecisions = scaleBudget(KNOWLEDGE_MEMORY_MAX_DECISIONS) + const maxFilesInspected = scaleBudget( + KNOWLEDGE_MEMORY_MAX_FILES_INSPECTED, + ) + const maxEdits = scaleBudget(KNOWLEDGE_MEMORY_MAX_EDITS) + const maxValidationResults = scaleBudget( + KNOWLEDGE_MEMORY_MAX_VALIDATION_RESULTS, + ) + const maxReviewReceipts = scaleBudget( + KNOWLEDGE_MEMORY_MAX_REVIEW_RECEIPTS, + ) + const maxPostEditAnchors = scaleBudget( + KNOWLEDGE_MEMORY_MAX_POST_EDIT_ANCHORS, + ) + const maxBlockers = scaleBudget(KNOWLEDGE_MEMORY_MAX_BLOCKERS) + + km.goal = capTextPreservingEnds( + km.goal, + scaleBudget(KNOWLEDGE_MEMORY_MAX_GOAL_CHARS), + ) km.nextAction = capTextPreservingEnds( km.nextAction, - KNOWLEDGE_MEMORY_MAX_NEXT_ACTION_CHARS, + scaleBudget(KNOWLEDGE_MEMORY_MAX_NEXT_ACTION_CHARS), ) - if (km.decisions.length > KNOWLEDGE_MEMORY_MAX_DECISIONS) { - km.decisions = km.decisions.slice(-KNOWLEDGE_MEMORY_MAX_DECISIONS) + if (km.decisions.length > maxDecisions) { + km.decisions = km.decisions.slice(-maxDecisions) } - if (km.filesInspected.length > KNOWLEDGE_MEMORY_MAX_FILES_INSPECTED) { - km.filesInspected = km.filesInspected.slice( - -KNOWLEDGE_MEMORY_MAX_FILES_INSPECTED, - ) + if (km.filesInspected.length > maxFilesInspected) { + km.filesInspected = km.filesInspected.slice(-maxFilesInspected) } - if (km.editsMade.length > KNOWLEDGE_MEMORY_MAX_EDITS) { - km.editsMade = km.editsMade.slice(-KNOWLEDGE_MEMORY_MAX_EDITS) + if (km.editsMade.length > maxEdits) { + km.editsMade = km.editsMade.slice(-maxEdits) } - if ( - km.validationResults.length > KNOWLEDGE_MEMORY_MAX_VALIDATION_RESULTS - ) { - km.validationResults = km.validationResults.slice( - -KNOWLEDGE_MEMORY_MAX_VALIDATION_RESULTS, - ) + if (km.validationResults.length > maxValidationResults) { + km.validationResults = km.validationResults.slice(-maxValidationResults) } - if (km.reviewReceipts.length > KNOWLEDGE_MEMORY_MAX_REVIEW_RECEIPTS) { - km.reviewReceipts = km.reviewReceipts.slice( - -KNOWLEDGE_MEMORY_MAX_REVIEW_RECEIPTS, - ) + if (km.reviewReceipts.length > maxReviewReceipts) { + km.reviewReceipts = km.reviewReceipts.slice(-maxReviewReceipts) } - if (km.postEditAnchors.length > KNOWLEDGE_MEMORY_MAX_POST_EDIT_ANCHORS) { - km.postEditAnchors = km.postEditAnchors.slice( - -KNOWLEDGE_MEMORY_MAX_POST_EDIT_ANCHORS, - ) + if (km.postEditAnchors.length > maxPostEditAnchors) { + km.postEditAnchors = km.postEditAnchors.slice(-maxPostEditAnchors) } - if (km.blockers.length > KNOWLEDGE_MEMORY_MAX_BLOCKERS) { - km.blockers = km.blockers.slice(-KNOWLEDGE_MEMORY_MAX_BLOCKERS) + if (km.blockers.length > maxBlockers) { + km.blockers = km.blockers.slice(-maxBlockers) } const capEntry = (entry: string, max: number): string => - capTextPreservingEnds(entry, max) + capTextPreservingEnds(entry, scaleBudget(max)) km.decisions = km.decisions.map((e) => capEntry(e, KNOWLEDGE_MEMORY_ENTRY_CHARS), @@ -1578,6 +1658,63 @@ const definition: AgentDefinition = { km.blockers = km.blockers.map((e) => capEntry(e, KNOWLEDGE_MEMORY_ENTRY_CHARS), ) + + // Hard ceiling on the pinned block, computed from the post-compaction + // history target (not the trigger). Evicted first -> last, oldest entries + // first, mirroring the rolling-eviction policy of the caps above. + const ceiling = Math.max( + KNOWLEDGE_MEMORY_MIN_BUDGET_TOKENS, + Math.floor(targetContextLength * KNOWLEDGE_MEMORY_MAX_BUDGET_FRACTION), + ) + const EVICTION_ORDER: KnowledgeMemoryListField[] = [ + 'postEditAnchors', + 'filesInspected', + 'decisions', + 'validationResults', + 'reviewReceipts', + 'editsMade', + 'blockers', + ] + while (estimateKnowledgeMemoryTokens(km) > ceiling) { + const field = EVICTION_ORDER.find((key) => km[key].length > 0) + if (field) { + const entries = km[field] + entries.splice( + 0, + Math.max( + 1, + Math.floor( + entries.length * KNOWLEDGE_MEMORY_EVICTION_CHUNK_FRACTION, + ), + ), + ) + continue + } + // Every list is empty: the task contract itself is over the ceiling. + // Shrink it toward the floor instead of dropping it, halving per pass. + if (km.nextAction.length > KNOWLEDGE_MEMORY_MIN_NEXT_ACTION_CHARS) { + km.nextAction = capTextPreservingEnds( + km.nextAction, + Math.max( + KNOWLEDGE_MEMORY_MIN_NEXT_ACTION_CHARS, + Math.floor(km.nextAction.length / 2), + ), + ) + continue + } + if (km.goal.length > KNOWLEDGE_MEMORY_MIN_GOAL_CHARS) { + km.goal = capTextPreservingEnds( + km.goal, + Math.max( + KNOWLEDGE_MEMORY_MIN_GOAL_CHARS, + Math.floor(km.goal.length / 2), + ), + ) + continue + } + // Both are at their floor: keep them (R3) rather than looping forever. + break + } } /** Detect the latest substantive user request or override. */ diff --git a/agents/e2e/editor-orchestrator-contract.e2e.test.ts b/agents/e2e/editor-orchestrator-contract.e2e.test.ts index 8037069483..3dcbfa2e14 100644 --- a/agents/e2e/editor-orchestrator-contract.e2e.test.ts +++ b/agents/e2e/editor-orchestrator-contract.e2e.test.ts @@ -57,6 +57,7 @@ function createCliContext() { setStreamingAgents: noop, setStreamStatus: noop, setContextWindowUsage: noop, + setCompactionNotice: noop, }, message: { aiMessageId: 'ai-1', diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 0eafd3da24..bbe4236f35 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -26,7 +26,10 @@ All notable changes to the `@openbuff/cli` package will be documented in this fi - `git_status` tool result schema now accepts the per-turn suppressed `{ unchanged: true, note }` payload (mirroring `list_jobs`), so byte-identical worktree re-observations no longer fail as `malformed_result`. - Inline reviewers now receive the orchestrator history as read context without copying their private file reads, tool results, and `set_output` transcript back into the parent prompt. Deliberate `set_messages` control-plane rewrites and context-pruner compaction still propagate. - Structured reviewer results are bounded before entering parent history, while retaining verdicts, snapshots, findings, corrections, dimensions, and representative evidence. -- Semantic compaction preserves a larger beginning-and-end task contract and next action so trailing instructions survive long pasted diagnostics. +- Semantic compaction preserves a larger beginning-and-end task contract and next action so trailing instructions survive long pasted diagnostics. The pinned next action's baseline bound is now **1,400** characters (previously 1,000) and the pinned goal's is **2,400**. Both are baselines at the legacy 100k semantic target, not fixed absolutes: they are scaled by `clamp(targetTokens / 100_000, 0.5, 3.0)` (see the scaled-retention entry below), so the default 200k-class window resolves a 72k target and caps the goal/next action at ~1,728/~1,008 characters. `docs/agents-and-tools.md` documents the baseline caps and the scaling. +- **Consumer-visible (additive) compaction contract changes.** `context_compaction` events on the public `handleEvent` surface gained optional telemetry fields (`resolvedContextWindowTokens`, `triggerBudgetTokens`, `targetBudgetTokens`, `compactionCount`, `consecutiveNoProgressCompactions`, `shortfallTokens`, `fitsBudget`, `escalated` in `common/src/types/print-mode.ts`). All are optional, so persisted/replayed events emitted before the telemetry existed still validate and consumers that ignore them keep their previous behavior. The CLI now records each compaction pass as a typed `compaction` content block (`CompactionContentBlock` in `cli/src/types/chat.ts`, rendered by `CompactionBox`) instead of appending the previous concatenated free-text `text` block; replayed sessions that still hold the old notice keep rendering as plain text. Full contract in `docs/agents-and-tools.md` under "Context-window-aware compaction budgets". +- Pinned `` retention now scales with the resolved semantic target budget (`targetTokens / 100_000`, clamped to `[0.5, 3.0]`) instead of using fixed per-field caps, so a ~1m-token window retains up to 3x the decisions, inspected files, edits, validation results, review receipts, post-edit anchors, and blockers, and a small BYOK window retains half. The same factor scales every per-field **character** cap, including the pinned 2,400-character goal and 1,400-character next action, so those documented numbers are baselines at a 100k target rather than fixed absolutes — the default 200k-class window resolves a 72k target (scale `0.72`) and caps them at ~1,728 and ~1,008 characters. The 100k target remains the scale-`1.0` baseline, but retention at that baseline is **not** byte-identical to the previous release: this change also raised three of the baseline caps — decisions (8 → 12), blockers (8 → 12), and the next action (1,000 → 1,400 characters). Every other baseline cap is unchanged (2,400-character goal, 25 files inspected, 25 edits, 12 validation results, 12 review receipts, 16 post-edit anchors, 480-character entries). Because the block is pinned verbatim and exempt from the normal budget cutoff, it is now bounded by a hard ceiling of `max(1_500, floor(targetTokens * 0.25))` estimated tokens, enforced by oldest-first eviction in a fixed field order; `Goal:` and `Next Action:` are truncated toward 480/240-character floors rather than dropped. Documented in `docs/agents-and-tools.md` under "Context-window-aware compaction budgets". +- **Additive live compaction status event.** A new `context_compaction_status` variant on the public `handleEvent` surface (`common/src/types/print-mode.ts`) reports `state: 'started' | 'settled'` with the required agent/run correlation `runId` and `ancestorRunIds` (plus optional `agentId`) and optional `contextTokens`, `resolvedContextWindowTokens`, `triggerBudgetTokens`, and `targetBudgetTokens`. `started` fires before a programmatic step whose window-derived semantic trigger is exceeded (never for an explicit `maxContextLength` override), and `settled` with the same `runId` always follows it — including from the run's exit path when a step throws or is cancelled first — so a pass that decides not to compact cannot leave a pending state on screen. Because every agent loop (root turn, foreground subagents, inline agents) emits these events, both the event and the `context_compaction` result now carry run correlation and the protocol is scoped by run: `ancestorRunIds` is empty only for the root run, `started`/`settled` pair by `runId`, and `compactionCount` counts the emitting run's own passes rather than a per-turn total. `runId`/`ancestorRunIds` survive every forwarding hop; the optional `agentId` does not, because the `spawn_agents` forwarding path rewrites it to the direct child's agent id, so at nesting depth >= 2 it identifies the nearest forwarding child rather than the emitter and must not be used as a per-agent key. The CLI renders only a root-run `started` as a pending `compaction` block (stamped with that `runId`) plus a live status chip, replaces it in place with the terminal `context_compaction` result of the same run, drops only that run's pending block on its `settled`, and clears any stray pending block at the turn boundary; a subagent's compaction can no longer render as a root-level "Compacting context…" card, cross-settle the root run's live pass, or overwrite the root turn's compaction count. Because a user-initiated abort makes the SDK drop every post-abort event, the transient pending block also carries an optional `liveSessionId` (`CLI_LIVE_SESSION_ID` in `cli/src/types/chat.ts`) stamping the producing CLI process: a replayed pending block from a session the user aborted mid-compaction renders as "Compaction interrupted" instead of a permanent "Compacting context…" card, and the live status chip only honors `pending` while the run is active. Both this event and the scaled retention above are additive and require no consumer migration: unknown event variants are no-ops for `handleEvent` consumers, the result event's correlation fields are optional so replayed events without them stay root-attributed, replayed sessions whose compaction blocks carry no `status` field render as completed passes, and blocks persisted without `liveSessionId`/`runId` round-trip unchanged. - `edit_transaction` now strongly requests real edit arrays, continues to repair complete legacy JSON encodings, and reports truncated encodings at the `edits` field with safe recovery guidance instead of a misleading `edits[0]` object error. ## [1.1.11] - 2026-07-07 diff --git a/cli/knowledge.md b/cli/knowledge.md index b705321cb2..299878a969 100644 --- a/cli/knowledge.md +++ b/cli/knowledge.md @@ -902,4 +902,8 @@ Streaming markdown renders as plain text until the message or agent finishes. Th - `cli/src/utils/create-run-config.ts` and regenerated agent type sources track content-search/glob `cwd` ergonomics (file-as-cwd coercion, paths param, flag allowlist). After public tool schema changes, regenerate `cli/src/data/initial-agent-type-sources.generated.ts` via the root tool-definition generator. - `cli/src/components/blocks/blocks-renderer.tsx` wraps every block and block group in its own nested `ErrorBoundary` (`isolateBlock`), with the React key on the boundary. Keep new block handlers routed through it: `@opentui/react`'s root boundary is app-wide, so an unguarded render throw in one persisted block blanks the whole session on every reload. +- `cli/src/components/renderers/compaction-box.tsx` derives the pending/interrupted/unsettled triple in one `derivePresentation` helper that both `deriveTone` and the render path consume, so the chosen tone and the rendered lines cannot drift. A `status: 'pending'` block is only presented as live when `isLiveCompaction` confirms it belongs to THIS process (matching `liveSessionId`); a replayed pending block from a persisted transcript renders as "Interrupted before this pass reported a result." rather than a permanently spinning "Compacting context…" card. `cli/src/utils/sdk-event-handlers.ts` consumes the additive `context_compaction_status` event and pairs `started`/`settled` strictly by the event's required `runId` — never by `agentId`, which subagent forwarding rewrites — so a nested agent loop's settle cannot clear the root turn's live card; `handleFinish` rewrites any stray pending block as interrupted so an aborted turn leaves an honest terminal record. + - _Knowledge refresh 2026-08-23: add `/memory` (alias `/mem`) slash command; staleness guard touch._ + +- _Knowledge refresh 2026-08-31: live compaction status rendering (`context_compaction_status` consumption, run-correlated pending/settled pairing, replayed-pending-as-interrupted) in `cli/src/utils/sdk-event-handlers.ts` and `cli/src/components/renderers/compaction-box.tsx`._ diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index fd16c88ab4..81790038b3 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -100,6 +100,7 @@ import type { MultilineInputHandle } from './components/multiline-input' import type { MatchedSlashCommand } from './hooks/use-suggestion-engine' import { AGENT_MODE_TO_ID, type AgentMode } from './utils/constants' import type { FileTreeNode } from '@codebuff/common/util/file' +import type { CompactionNotice } from './utils/sdk-event-handlers' import type { ScrollBoxRenderable } from '@opentui/core' export const Chat = ({ @@ -391,6 +392,12 @@ export const Chat = ({ max: number } | null>(null) + // Accumulated context-compaction notice for the turn in progress (reset to + // null when a new run starts, inside useSendMessage). Drives the status-bar + // compaction chip. + const [compactionNotice, setCompactionNotice] = + useState(null) + // M9.3: Session-cost accumulator (sum of per-turn cost in cents from // onTotalCost callbacks). Model name + git diff-stats for the status bar. const [sessionCostCents, setSessionCostCents] = useState(0) @@ -474,6 +481,7 @@ export const Chat = ({ isChainInProgressRef, setStreamStatus, setContextWindowUsage, + setCompactionNotice, setCanProcessQueue, abortControllerRef, agentId, @@ -1735,6 +1743,7 @@ export const Chat = ({ scrollToLatest={scrollToLatest} statusIndicatorState={statusIndicatorState} contextWindowUsage={contextWindowUsage} + compactionNotice={compactionNotice} sessionCostCents={sessionCostCents} modelName={modelName} diffStats={diffStats} diff --git a/cli/src/components/__tests__/sweep-boxes.test.tsx b/cli/src/components/__tests__/sweep-boxes.test.tsx index 8f199fc6b5..2a201c63e3 100644 --- a/cli/src/components/__tests__/sweep-boxes.test.tsx +++ b/cli/src/components/__tests__/sweep-boxes.test.tsx @@ -3,6 +3,7 @@ import React from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { initializeThemeStore } from '../../hooks/use-theme' +import { CompactionBox } from '../renderers/compaction-box' import { ContextBox } from '../renderers/context-box' import { InfoBox } from '../renderers/info-box' import { DoctorBox } from '../renderers/doctor-box' @@ -10,15 +11,289 @@ import { IndexStatusBox } from '../renderers/index-status-box' import { PlanStatusBox } from '../renderers/plan-status-box' import type { + CompactionContentBlock, ContextContentBlock, InfoContentBlock, DoctorContentBlock, IndexStatusContentBlock, PlanStatusContentBlock, } from '../../types/chat' +import { CLI_LIVE_SESSION_ID } from '../../types/chat' initializeThemeStore() +const compactionBlock = ( + overrides: Partial = {}, +): CompactionContentBlock => ({ + type: 'compaction', + action: 'semantic_compaction', + beforeTokens: 190_000, + afterTokens: 120_000, + beforeMessages: 20, + afterMessages: 12, + reductionPercent: 37, + retainedKnowledgeMemory: true, + recovery: 'Re-read exact files before editing.', + categoryDeltas: [ + { category: 'toolResults', beforeTokens: 60_000, afterTokens: 10_000 }, + { category: 'fileReads', beforeTokens: 30_000, afterTokens: 8_000 }, + ], + ...overrides, +}) + +describe('CompactionBox', () => { + test('renders headline counts, per-category rows and the retained-memory line', () => { + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Context compacted') + expect(markup).toContain('190k → 120k tokens (−37%)') + expect(markup).toContain('20 → 12 messages') + expect(markup).toContain('tool results 60k → 10k') + expect(markup).toContain('file reads 30k → 8k') + expect(markup).toContain('Knowledge memory retained') + expect(markup).toContain('Window 200k · trigger 176k · target 150k') + expect(markup).toContain('Approaching the trigger budget.') + expect(markup).toContain('Re-read exact files before editing.') + // Raw category keys never leak into the rendered output. + expect(markup).not.toContain('toolResults') + expect(markup).not.toContain('fileReads') + }) + + test('renders a pending pass as a live compacting state without result lines', () => { + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Compacting context…') + expect(markup).toContain('152k tokens → target 70k') + // The unknown result fields never render as a settled outcome. + expect(markup).not.toContain('tokens (−0%)') + expect(markup).not.toContain('0 → 0 messages') + expect(markup).not.toContain('No knowledge memory retained') + expect(markup).not.toContain('Knowledge memory retained') + // A pass owned by this process is live, not interrupted. + expect(markup).not.toContain('Compaction interrupted') + // A pending mechanical action still keeps the emergency title out. + expect(markup).not.toContain('Context trimmed (emergency)') + // The budget line is unchanged when both bounds are reported. + expect(markup).toContain('Window 200k · trigger 150k · target 70k') + }) + + test('renders a replayed pending block as an interrupted pass, never as live', () => { + // Persisted blocks are replayed on reload. A pass the user aborted + // mid-compaction was written by an earlier process, so its liveSessionId + // cannot match this one (and an older CLI wrote none at all). + for (const liveSessionId of [ + undefined, + 'some-earlier-process-1700000000', + ]) { + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Compaction interrupted') + expect(markup).toContain( + 'Interrupted before this pass reported a result.', + ) + // Never presented as still running. + expect(markup).not.toContain('Compacting context…') + // The unknown result fields still never render as a settled outcome. + expect(markup).toContain('152k tokens → target 70k') + expect(markup).not.toContain('tokens (−0%)') + expect(markup).not.toContain('0 → 0 messages') + expect(markup).not.toContain('No knowledge memory retained') + expect(markup).not.toContain('Re-read exact files before editing.') + } + }) + + test('renders an explicitly interrupted block as an interrupted pass', () => { + // The abort/teardown path rewrites a pass that never reported a result to + // this terminal status, so it is the primary interrupted path: no + // liveSessionId is involved at all. + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Compaction interrupted') + expect(markup).toContain('Interrupted before this pass reported a result.') + expect(markup).toContain('152k tokens → target 70k') + // Never presented as still running, and the unknown result fields never + // render as a settled outcome. + expect(markup).not.toContain('Compacting context…') + expect(markup).not.toContain('tokens (−0%)') + expect(markup).not.toContain('0 → 0 messages') + expect(markup).not.toContain('No knowledge memory retained') + expect(markup).not.toContain('Re-read exact files before editing.') + }) + + test('omits the target clause of a pending line when no target budget is reported', () => { + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('152k tokens') + expect(markup).not.toContain('target') + }) + + test('renders the emergency title, shortfall line and missing-memory line', () => { + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Context trimmed (emergency)') + expect(markup).toContain('Still over budget by 12.4k tokens') + expect(markup).toContain('No knowledge memory retained') + }) + + test('omits the shortfall count when shortfallTokens is missing', () => { + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Still over budget') + expect(markup).not.toContain('Still over budget by') + }) + + test('renders the thrash line at two consecutive low-yield passes', () => { + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain( + 'Compaction is not reclaiming space (2 consecutive low-yield passes)', + ) + + const single = renderToStaticMarkup( + , + ) + expect(single).not.toContain('Compaction is not reclaiming space') + }) + + test('omits the budget line unless both trigger and target are present', () => { + const markup = renderToStaticMarkup( + , + ) + + expect(markup).not.toContain('trigger') + }) + + test('does not throw on a degenerate replayed block', () => { + // Zero tokens, no category deltas, no optional fields and garbage numbers: + // exactly what a persisted/partially-populated block can replay as. + expect(() => + renderToStaticMarkup( + , + ), + ).not.toThrow() + + const garbage = renderToStaticMarkup( + , + ) + expect(garbage).not.toContain('NaN') + expect(garbage).not.toContain('Infinity') + expect(garbage).toContain('0 → 0 tokens (−100%)') + }) +}) + describe('sweep renderers', () => { test('ContextBox renders without throwing and contains Context title', () => { const block: ContextContentBlock = { @@ -44,6 +319,25 @@ describe('sweep renderers', () => { expect(markup).toContain('Gate budgets: ok') }) + test('ContextBox renders sub-headings and drops the redundant ledger heading', () => { + const block: ContextContentBlock = { + type: 'context', + ledgerText: + 'Context Budget Breakdown\n------------------------\ntoolResults 1000 1.0%\ntotal 2000 2.0%\nwindow 200000\n(recorded before the last /compact; system-prompt blocks remain accurate)', + gateBudgetsText: 'Gate budgets\nbudget row', + } + const markup = renderToStaticMarkup() + + expect(markup).toContain('Budget ledger') + expect(markup).toContain('Gate budgets') + expect(markup).not.toContain('Context Budget Breakdown') + expect(markup).not.toContain('------------------------') + expect(markup).toContain('toolResults') + expect(markup).toContain('total') + expect(markup).toContain('window') + expect(markup).toContain('(recorded before the last /compact') + }) + test('InfoBox renders version, workspace and Auth', () => { const block: InfoContentBlock = { type: 'info', diff --git a/cli/src/components/blocks/single-block.tsx b/cli/src/components/blocks/single-block.tsx index b58f4e9bfe..dec16d61b6 100644 --- a/cli/src/components/blocks/single-block.tsx +++ b/cli/src/components/blocks/single-block.tsx @@ -9,6 +9,7 @@ import { ContentWithMarkdown } from './content-with-markdown' import { ImageBlock } from './image-block' import { UserBlockTextWithInlineCopy } from './user-content-copy' import { useTheme } from '../../hooks/use-theme' +import { CompactionBox } from '../renderers/compaction-box' import { CompletionSummaryBox } from '../renderers/completion-summary-box' import { ContextBox } from '../renderers/context-box' import { DoctorBox } from '../renderers/doctor-box' @@ -147,6 +148,14 @@ export const SingleBlock = memo( ) } + case 'compaction': { + return ( + + + + ) + } + case 'memory': { return ( diff --git a/cli/src/components/renderers/compaction-box.tsx b/cli/src/components/renderers/compaction-box.tsx new file mode 100644 index 0000000000..b8ffeb9524 --- /dev/null +++ b/cli/src/components/renderers/compaction-box.tsx @@ -0,0 +1,252 @@ +import { memo } from 'react' + +import { HarnessBox } from './harness-box' +import { useTheme } from '../../hooks/use-theme' +import { CLI_LIVE_SESSION_ID } from '../../types/chat' +import { formatStatusTokenCount } from '../../utils/status-bar-chips' + +import type { + CompactionCategoryDelta, + CompactionContentBlock, +} from '../../types/chat' +import type { ChatTheme } from '../../types/theme-system' + +type Tone = 'secondary' | 'warning' | 'error' + +/** + * Blocks are persisted and replayed, so a field can come back missing, + * non-finite, or negative. Mirrors `sanitizeLedgerNumber` in + * common/src/util/context-budget.ts: coerce to a safe non-negative integer + * instead of rendering NaN or throwing. + */ +const sanitizeCount = (value: unknown): number => + typeof value === 'number' && Number.isFinite(value) && value >= 0 + ? Math.floor(value) + : 0 + +/** + * Exhaustive by construction: a new ContextCategory fails to compile here + * rather than rendering its raw key. + */ +const CATEGORY_LABEL: Record = { + toolResults: 'tool results', + todos: 'todos', + fileReads: 'file reads', + subagents: 'subagents', + userAssistantMessages: 'conversation', +} + +/** Falls back to the raw key for a category a persisted block no longer maps. */ +const categoryLabel = (category: string): string => + category in CATEGORY_LABEL + ? CATEGORY_LABEL[category as CompactionCategoryDelta['category']] + : category + +/** + * A pending pass is only live in the process that created it. The abort and + * turn-end paths rewrite a pass that never reported a result to + * `status: 'interrupted'`, so this stamp is defense in depth for a crash that + * ran no teardown at all: such a block is replayed with an absent or foreign + * `liveSessionId` and must render as an interrupted pass rather than a + * permanently "compacting" card. + */ +const isLiveCompaction = (block: CompactionContentBlock): boolean => + block.status === 'pending' && block.liveSessionId === CLI_LIVE_SESSION_ID + +/** Shown for a pass that ended before it reported a result. */ +const INTERRUPTED_TEXT = 'Interrupted before this pass reported a result.' + +/** + * The pending/interrupted/unsettled triple that both the tone and every + * rendered line depend on. + */ +type CompactionPresentation = { + unsettled: boolean + pending: boolean + interrupted: boolean +} + +/** + * Single source of truth for pending vs interrupted, consumed by both + * {@link deriveTone} and the render path so the chosen tone and the rendered + * lines cannot drift apart. + * + * `unsettled` covers a live pass, a terminated one and a replayed pending one: + * none has result fields, so the result lines stay suppressed for all. A + * pending block that is not live in THIS process (absent or foreign + * `liveSessionId`, i.e. a replayed transcript) is deliberately presented as + * interrupted — see {@link isLiveCompaction}. + */ +const derivePresentation = ( + block: CompactionContentBlock, +): CompactionPresentation => { + const unsettled = block.status === 'pending' || block.status === 'interrupted' + const pending = block.status === 'pending' && isLiveCompaction(block) + return { unsettled, pending, interrupted: unsettled && !pending } +} + +const deriveTone = ( + block: CompactionContentBlock, + presentation: CompactionPresentation, +): Tone => { + // A pass that is still running has no result to judge yet, so it always + // reads as neutral regardless of `action`; a pass that never settled reads + // as a warning. + if (presentation.interrupted) return 'warning' + if (presentation.pending) return 'secondary' + if (block.fitsBudget === false) return 'error' + const noProgress = block.consecutiveNoProgressCompactions + if ( + (noProgress !== undefined && sanitizeCount(noProgress) >= 2) || + block.action === 'mechanical_trim' || + block.escalated === true + ) { + return 'warning' + } + return 'secondary' +} + +const statusColorForTone = (tone: Tone, theme: ChatTheme): string => { + switch (tone) { + case 'error': + return theme.error + case 'warning': + return theme.warning + default: + return theme.secondary + } +} + +interface CompactionBoxProps { + block: CompactionContentBlock +} + +export const CompactionBox = memo(({ block }: CompactionBoxProps) => { + const theme = useTheme() + const { unsettled, pending, interrupted } = derivePresentation(block) + const tone = deriveTone(block, { unsettled, pending, interrupted }) + const title = pending + ? 'Compacting context…' + : interrupted + ? 'Compaction interrupted' + : block.action === 'mechanical_trim' + ? 'Context trimmed (emergency)' + : 'Context compacted' + + const beforeTokens = sanitizeCount(block.beforeTokens) + const afterTokens = sanitizeCount(block.afterTokens) + const reductionPercent = Math.min(100, sanitizeCount(block.reductionPercent)) + const headline = `${formatStatusTokenCount(beforeTokens)} → ${formatStatusTokenCount(afterTokens)} tokens (−${reductionPercent}%)` + const messagesText = ` ${sanitizeCount(block.beforeMessages)} → ${sanitizeCount(block.afterMessages)} messages` + // Live state: the result fields are not known yet, so only the current size + // and (when reported) the target it is compacting toward are shown. + const pendingText = + block.targetBudgetTokens === undefined + ? `${formatStatusTokenCount(beforeTokens)} tokens` + : `${formatStatusTokenCount(beforeTokens)} tokens → target ${formatStatusTokenCount(sanitizeCount(block.targetBudgetTokens))}` + + const categoryDeltas = Array.isArray(block.categoryDeltas) + ? block.categoryDeltas + : [] + + const showBudget = + block.triggerBudgetTokens !== undefined && + block.targetBudgetTokens !== undefined + const budgetText = showBudget + ? `Window ${ + block.resolvedContextWindowTokens === undefined + ? 'unknown' + : formatStatusTokenCount( + sanitizeCount(block.resolvedContextWindowTokens), + ) + } · trigger ${formatStatusTokenCount( + sanitizeCount(block.triggerBudgetTokens), + )} · target ${formatStatusTokenCount( + sanitizeCount(block.targetBudgetTokens), + )}` + : '' + + const shortfallText = + block.shortfallTokens === undefined + ? 'Still over budget' + : `Still over budget by ${formatStatusTokenCount(sanitizeCount(block.shortfallTokens))} tokens` + + const noProgress = block.consecutiveNoProgressCompactions + const showThrash = noProgress !== undefined && sanitizeCount(noProgress) >= 2 + const thrashText = `Compaction is not reclaiming space (${sanitizeCount(noProgress)} consecutive low-yield passes)` + + const reason = typeof block.reason === 'string' ? block.reason.trim() : '' + const recovery = + typeof block.recovery === 'string' ? block.recovery.trim() : '' + const memoryText = block.retainedKnowledgeMemory + ? 'Knowledge memory retained' + : 'No knowledge memory retained' + + return ( + + {unsettled ? ( + + {pendingText} + + ) : ( + + + {headline} + + {messagesText} + + )} + {categoryDeltas.length > 0 + ? categoryDeltas.map((delta, index) => ( + + {` ${categoryLabel(delta.category)} ${formatStatusTokenCount(sanitizeCount(delta.beforeTokens))} → ${formatStatusTokenCount(sanitizeCount(delta.afterTokens))}`} + + )) + : null} + {unsettled ? null : ( + + {memoryText} + + )} + {interrupted ? ( + + {INTERRUPTED_TEXT} + + ) : null} + {showBudget ? ( + {budgetText} + ) : null} + {block.fitsBudget === false ? ( + + {shortfallText} + + ) : null} + {showThrash ? ( + + {thrashText} + + ) : null} + {reason ? ( + {reason} + ) : null} + {recovery && !unsettled ? ( + + {recovery} + + ) : null} + + ) +}) diff --git a/cli/src/components/renderers/context-box.tsx b/cli/src/components/renderers/context-box.tsx index 9dd1731848..d087602686 100644 --- a/cli/src/components/renderers/context-box.tsx +++ b/cli/src/components/renderers/context-box.tsx @@ -9,12 +9,31 @@ interface ContextBoxProps { block: ContextContentBlock } +/** + * Lines `formatLedgerForCli` emits as its own heading, which the box replaces + * with an explicit sub-heading. Matched exactly: if the producer's output ever + * changes, the lines simply render as-is rather than being silently dropped. + */ +const REDUNDANT_LEDGER_HEADINGS = new Set([ + 'Context Budget Breakdown', + '------------------------', +]) + +/** The ledger's summary rows start their label at column 0. */ +const isSummaryLedgerRow = (line: string): boolean => + line.startsWith('total') || line.startsWith('window') + +/** Staleness note appended by `formatLedgerForCli` after a /compact. */ +const isStalenessNote = (line: string): boolean => + line.startsWith('(recorded before the last /compact') + export const ContextBox = memo(({ block }: ContextBoxProps) => { const theme = useTheme() - const ledgerLines = + const ledgerLines = ( block.ledgerText && block.ledgerText.trim().length > 0 ? block.ledgerText.split('\n') : [] + ).filter((line) => !REDUNDANT_LEDGER_HEADINGS.has(line)) const gateLines = block.gateBudgetsText && block.gateBudgetsText.trim().length > 0 ? block.gateBudgetsText.split('\n') @@ -24,24 +43,31 @@ export const ContextBox = memo(({ block }: ContextBoxProps) => { {ledgerLines.length > 0 ? ( - {ledgerLines.map((line, idx) => { - const isHeader = idx < 2 - return ( - - {line.length > 0 ? line : ' '} - - ) - })} + + Budget ledger + + {ledgerLines.map((line, idx) => ( + + {line.length > 0 ? line : ' '} + + ))} ) : null} {gateLines.length > 0 ? ( + + Gate budgets + {gateLines.map((line, idx) => { const isTitle = line.startsWith('Gate repair budgets') || diff --git a/cli/src/components/status-bar.tsx b/cli/src/components/status-bar.tsx index 0e664814e3..a445925929 100644 --- a/cli/src/components/status-bar.tsx +++ b/cli/src/components/status-bar.tsx @@ -15,6 +15,7 @@ import { selectStatusBarChips, type StatusBarChipTone, } from '../utils/status-bar-chips' +import type { CompactionNotice } from '../types/chat' import type { StatusIndicatorState } from '../utils/status-indicator-state' /** A small status-bar action button with hover-bold styling. */ @@ -71,6 +72,11 @@ interface StatusBarProps { scrollToLatest: () => void statusIndicatorState: StatusIndicatorState contextWindowUsage?: { used: number; max: number } | null + /** + * Accumulated context-compaction notice for the current turn (null once the + * next turn starts). Rendered as a chip beside the context usage. + */ + compactionNotice?: CompactionNotice | null /** Session-accumulated cost in cents (1 dollar = 100 cents). */ sessionCostCents?: number | null /** Resolved model id for the active agent mode (short display string). */ @@ -88,6 +94,7 @@ export const StatusBar = ({ scrollToLatest, statusIndicatorState, contextWindowUsage, + compactionNotice, sessionCostCents, modelName, diffStats, @@ -138,6 +145,7 @@ export const StatusBar = ({ widthSize: width.size, terminalWidth, contextWindowUsage, + compactionNotice, sessionCostCents, modelName, diffStats, diff --git a/cli/src/hooks/helpers/__tests__/send-message.test.ts b/cli/src/hooks/helpers/__tests__/send-message.test.ts index 7f635ddf30..00fe308017 100644 --- a/cli/src/hooks/helpers/__tests__/send-message.test.ts +++ b/cli/src/hooks/helpers/__tests__/send-message.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'bun:test' -import type { ChatMessage } from '../../../types/chat' +import type { ChatMessage, CompactionContentBlock } from '../../../types/chat' import type { SendMessageTimerController } from '../../../utils/send-message-timer' import type { StreamStatus } from '../../use-message-queue' @@ -33,6 +33,9 @@ const { } = await import('../send-message') const { createBatchedMessageUpdater } = await import('../../../utils/message-updater') +const { markPendingCompactionInterrupted } = + await import('../../../utils/message-block-helpers') +const { CLI_LIVE_SESSION_ID } = await import('../../../types/chat') import type { RunState } from '@openbuff/sdk' import type { PendingFileAttachment } from '../../../types/store' @@ -77,6 +80,43 @@ const createBaseMessages = (): ChatMessage[] => [ }, ] +const pendingCompactionBlock = (): CompactionContentBlock => ({ + type: 'compaction', + status: 'pending', + liveSessionId: CLI_LIVE_SESSION_ID, + runId: 'root-run', + action: 'semantic_compaction', + beforeTokens: 152_000, + afterTokens: 0, + beforeMessages: 0, + afterMessages: 0, + reductionPercent: 0, + retainedKnowledgeMemory: false, + recovery: '', + categoryDeltas: [], + targetBudgetTokens: 70_000, +}) + +/** + * A settled pass: no live stamp, and no `status` key at all when none is + * passed (the shape of every block persisted before the field existed). + */ +const settledCompactionBlock = ( + status?: 'complete' | 'interrupted', +): CompactionContentBlock => { + const { + liveSessionId: _liveSessionId, + status: _status, + ...rest + } = pendingCompactionBlock() + return { + ...rest, + afterTokens: 60_000, + reductionPercent: 60, + ...(status === undefined ? {} : { status }), + } +} + describe('createRunOwnership', () => { test('superseding run owns persistence and stale owner release cannot clear it', () => { const activeRunOwnerRef = { current: null as symbol | null } @@ -441,6 +481,103 @@ describe('setupStreamingContext', () => { // Verify timer was started with correct message ID expect(timerController.startCalls).toContain('ai-1') }) + + test('abort terminates a still-running compaction block instead of leaving it pending', () => { + // A pending compaction card is only ever settled by a `settled`/result + // SDK event, and the SDK drops every post-abort event, so the abort + // listener is the last write before the turn is persisted. + let messages: ChatMessage[] = [ + { + id: 'ai-1', + variant: 'ai', + content: 'Partial streamed content', + blocks: [ + { type: 'text', content: 'Some text' }, + pendingCompactionBlock(), + ], + timestamp: 'now', + }, + ] + const streamRefs = createStreamController() + const timerController = createMockTimerController() + const abortControllerRef = { current: null as AbortController | null } + + const { updater, abortController } = setupStreamingContext({ + aiMessageId: 'ai-1', + timerController, + setMessages: (fn: any) => { + messages = fn(messages) + }, + streamRefs, + abortControllerRef, + setStreamStatus: () => {}, + setCanProcessQueue: () => {}, + updateChainInProgress: () => {}, + setIsRetrying: () => {}, + setStreamingAgents: () => {}, + }) + + abortController.abort() + updater.flush() + + const blocks = messages.find((m) => m.id === 'ai-1')?.blocks ?? [] + const compactionBlocks = blocks.filter( + (block) => block.type === 'compaction', + ) + // Terminated in place: an honest interrupted record, never dropped and + // never still 'pending'. + expect(compactionBlocks).toHaveLength(1) + expect(compactionBlocks[0]).toMatchObject({ + type: 'compaction', + status: 'interrupted', + runId: 'root-run', + }) + // The live stamp is meaningless once the run is over. + expect(compactionBlocks[0]).not.toHaveProperty('liveSessionId') + // The pre-existing abort behavior is unchanged. + const lastBlock = blocks[blocks.length - 1] + expect(lastBlock?.type).toBe('text') + expect( + (lastBlock as { type: 'text'; content: string }).content, + ).toContain('[response interrupted]') + }) + }) +}) + +describe('markPendingCompactionInterrupted', () => { + test('rewrites a pending block and strips its live stamp', () => { + const blocks = [ + { type: 'text' as const, content: 'Some text' }, + pendingCompactionBlock(), + ] + + const next = markPendingCompactionInterrupted(blocks) + + expect(next).not.toBe(blocks) + expect(next[0]).toBe(blocks[0]) + expect(next[1]).toMatchObject({ + type: 'compaction', + status: 'interrupted', + runId: 'root-run', + beforeTokens: 152_000, + targetBudgetTokens: 70_000, + }) + expect(next[1]).not.toHaveProperty('liveSessionId') + }) + + test('leaves complete, interrupted and status-less compaction blocks untouched by identity', () => { + // An absent status is a completed pass: that is every block persisted + // before the field existed and it must round-trip unchanged. An already + // terminated pass must not be rewritten again either. + const blocks = [ + settledCompactionBlock('complete'), + settledCompactionBlock(), + settledCompactionBlock('interrupted'), + ] + + // Nothing was pending, so the original array reference comes back and + // React skips a re-render. + expect(markPendingCompactionInterrupted(blocks)).toBe(blocks) }) }) diff --git a/cli/src/hooks/helpers/send-message.ts b/cli/src/hooks/helpers/send-message.ts index 510752ddfa..e1a004919b 100644 --- a/cli/src/hooks/helpers/send-message.ts +++ b/cli/src/hooks/helpers/send-message.ts @@ -11,7 +11,10 @@ import { formatElapsedTime } from '../../utils/format-elapsed-time' import { processImagesForMessage } from '../../utils/image-processor' import { logger } from '../../utils/logger' import { getFileAttachmentContextMetadata } from '../../utils/pending-attachments' -import { appendInterruptionNotice } from '../../utils/message-block-helpers' +import { + appendInterruptionNotice, + markPendingCompactionInterrupted, +} from '../../utils/message-block-helpers' import { getUserMessage } from '../../utils/message-history' import { createBatchedMessageUpdater, @@ -371,7 +374,13 @@ export const setupStreamingContext = (params: { const cancelledBlocks = markRunningToolsAsCancelled( markRunningAgentsAsCancelled(blocks), ) - return appendInterruptionNotice(cancelledBlocks) + // A compaction pass that was still running is terminated here, in the + // same composed update: the SDK drops every post-abort event, so neither + // `settled` nor `finish` arrives to end the pending state, and this is + // the last write before the turn's blocks are persisted. + return appendInterruptionNotice( + markPendingCompactionInterrupted(cancelledBlocks), + ) }) updater.markComplete() }) diff --git a/cli/src/hooks/use-send-message.ts b/cli/src/hooks/use-send-message.ts index fbbb1568ae..a7f178f6d7 100644 --- a/cli/src/hooks/use-send-message.ts +++ b/cli/src/hooks/use-send-message.ts @@ -45,6 +45,7 @@ import type { ChatMessage } from '../types/chat' import type { SendMessageFn } from '../types/contracts/send-message' import type { AgentMode } from '../utils/constants' import type { SendMessageTimerEvent } from '../utils/send-message-timer' +import type { SetCompactionNoticeFn } from '../utils/sdk-event-handlers' import type { AgentDefinition, MessageContent, RunState } from '@openbuff/sdk' interface UseSendMessageOptions { inputRef: React.MutableRefObject @@ -52,6 +53,7 @@ interface UseSendMessageOptions { isChainInProgressRef: React.MutableRefObject setStreamStatus: (status: StreamStatus) => void setContextWindowUsage: (usage: { used: number; max: number } | null) => void + setCompactionNotice: SetCompactionNoticeFn setCanProcessQueue: (can: boolean) => void abortControllerRef: React.MutableRefObject agentId?: string @@ -108,6 +110,7 @@ export const useSendMessage = ({ isChainInProgressRef, setStreamStatus, setContextWindowUsage, + setCompactionNotice, setCanProcessQueue, abortControllerRef, agentId, @@ -300,6 +303,9 @@ export const useSendMessage = ({ agentId, }) setIsRetrying(false) + // A new turn starts with no compaction notice: the status-bar chip only + // reports compactions that happened during the turn in progress. + setCompactionNotice(() => null) // Prepare user message (bash context, images, text attachments, mode divider) let userMessageId: string @@ -540,6 +546,7 @@ export const useSendMessage = ({ setStreamingAgents, setStreamStatus, setContextWindowUsage, + setCompactionNotice, aiMessageId, updater, hasReceivedContentRef, @@ -682,6 +689,7 @@ export const useSendMessage = ({ resumeQueue, scrollToLatest, setCanProcessQueue, + setCompactionNotice, setContextWindowUsage, setStreamStatus, streamRefs, diff --git a/cli/src/types/chat.ts b/cli/src/types/chat.ts index 70f024b0a3..ced218d230 100644 --- a/cli/src/types/chat.ts +++ b/cli/src/types/chat.ts @@ -170,6 +170,120 @@ export type CompletionSummaryContentBlock = { summary: CompletionSummary } +export type CompactionCategoryDelta = { + category: + | 'toolResults' + | 'todos' + | 'fileReads' + | 'subagents' + | 'userAssistantMessages' + beforeTokens: number + afterTokens: number +} + +/** + * Identifies the CLI process that produced a still-live block. Blocks are + * persisted to chat-messages.json and replayed on reload, and a live + * (`status: 'pending'`) compaction pass has no cleanup path when the user + * aborts the turn before it settles, so a replayed block would otherwise come + * back as a permanently "compacting" card. A restored block carries the id of + * the process that wrote it, which can never match the current one, so + * consumers render it as an interrupted pass instead. Opaque and + * diagnostic-only: nothing parses its contents. + */ +export const CLI_LIVE_SESSION_ID = `${process.pid}-${Date.now()}-${Math.random() + .toString(36) + .slice(2, 10)}` + +/** + * Plain-JSON record of one context-compaction pass. Blocks are persisted to + * chat-messages.json and replayed on reload, so every field is serializable + * (no functions, no class instances) and the renderer must tolerate missing or + * garbage values coming back from an older/partial session. + * + * Consumer-visible contract change: this typed block replaces the previous + * concatenated free-text `text` compaction notice. A replayed session that + * still holds the old notice keeps rendering as plain text. See + * `docs/agents-and-tools.md` under "Context-window-aware compaction budgets". + */ +export type CompactionContentBlock = { + type: 'compaction' + /** + * 'pending' while the pruner is still running: the result fields are not yet + * known and render as a live state. Absent or 'complete' is a finished pass, + * which is what every persisted/replayed block from before this field holds. + * 'interrupted' is the terminal state of a pass whose run ended before it + * reported a result (the user aborted mid-compaction, or the turn ended + * abnormally): the abort/teardown path rewrites 'pending' to it, so a block + * that reaches persistence never claims to still be running. + */ + status?: 'pending' | 'complete' | 'interrupted' + /** + * Set to {@link CLI_LIVE_SESSION_ID} while `status: 'pending'` is live in the + * producing process. Absent on a completed pass, on an 'interrupted' one (the + * stamp is meaningless once the run is over) and on persisted blocks written + * by an older CLI, so an absent or foreign value marks a pending pass that + * this process cannot still be running (a crash that ran no teardown at all). + */ + liveSessionId?: string + /** + * Agent run that produced this pass (`runId` on the compaction events). Only + * root-run passes are recorded as root-level blocks, but the id is retained + * so a `settled`/result event can only ever settle the card its own run + * started, never a concurrent or nested agent loop's. Absent on blocks + * persisted before the correlation existed; those pair with equally + * uncorrelated events. + */ + runId?: string + action: 'semantic_compaction' | 'mechanical_trim' + beforeTokens: number + afterTokens: number + beforeMessages: number + afterMessages: number + /** Whole-percent reduction, 0..100, already clamped by the producer. */ + reductionPercent: number + retainedKnowledgeMemory: boolean + recovery: string + /** Categories that shrank, with their before/after token counts. */ + categoryDeltas: CompactionCategoryDelta[] + reason?: string + resolvedContextWindowTokens?: number + triggerBudgetTokens?: number + targetBudgetTokens?: number + compactionCount?: number + consecutiveNoProgressCompactions?: number + fitsBudget?: boolean + shortfallTokens?: number + escalated?: boolean +} + +/** + * Canonical accumulated context-compaction notice for the current turn, or + * null when nothing has been compacted. Declared once here and reused by the + * SDK event handler that produces it, the status-bar chip selector, and the + * status-bar component, so a later additive field cannot go silently missing + * from one consumer. + * + * Turn-scoped and root-level: every agent loop (root, foreground subagents, + * inline agents) reports its own compaction events, so the producer counts a + * nested run's completed pass but only ever adopts the ROOT run's own + * `compactionCount` as the total, and only the root run's live pass sets + * `pending`. + */ +export type CompactionNotice = { + count: number + /** + * Action of the most recently COMPLETED pass. A pass that has only started + * leaves it untouched, so an aborted turn still labels the settled chip by + * what actually completed. + */ + action: CompactionContentBlock['action'] + /** The pass did not fit the budget, or stopped reclaiming space. */ + degraded: boolean + /** A compaction pass is running right now in the root agent run. */ + pending?: boolean +} + export type AskUserContentBlock = { type: 'ask-user' toolCallId: string @@ -338,6 +452,7 @@ export type ContentBlock = | AgentListContentBlock | AskUserContentBlock | CompletionSummaryContentBlock + | CompactionContentBlock | ContextContentBlock | DoctorContentBlock | GateStateContentBlock @@ -457,6 +572,12 @@ export function isCompletionSummaryBlock( return block.type === 'completion-summary' } +export function isCompactionBlock( + block: ContentBlock, +): block is CompactionContentBlock { + return block.type === 'compaction' +} + export function isMemoryBlock( block: ContentBlock, ): block is MemoryContentBlock { diff --git a/cli/src/utils/__tests__/sdk-event-handlers.test.ts b/cli/src/utils/__tests__/sdk-event-handlers.test.ts index 5339258f76..91e12f8428 100644 --- a/cli/src/utils/__tests__/sdk-event-handlers.test.ts +++ b/cli/src/utils/__tests__/sdk-event-handlers.test.ts @@ -6,12 +6,14 @@ import { createStreamChunkHandler, } from '../sdk-event-handlers' -import type { ChatMessage } from '../../types/chat' +import type { ChatMessage, CompactionNotice } from '../../types/chat' +import { CLI_LIVE_SESSION_ID } from '../../types/chat' import type { EventHandlerState } from '../sdk-event-handlers' import { printModeEventSchema } from '@codebuff/common/types/print-mode' import type { Logger } from '@codebuff/common/types/contracts/logger' import type { + PrintModeContextCompaction, PrintModeEvent, PrintModeJobUpdate, } from '@codebuff/common/types/print-mode' @@ -62,6 +64,7 @@ const createTestContext = () => { setStreamingAgents: () => {}, setStreamStatus: () => {}, setContextWindowUsage: () => {}, + setCompactionNotice: () => {}, }, message: { aiMessageId: 'ai-1', @@ -1501,6 +1504,12 @@ describe('sdk-event-handlers', () => { test('persists context compaction details in the assistant message', () => { const { ctx, getMessages } = createTestContext() + const notices: Array = [] + let notice: CompactionNotice | null = null + ctx.streaming.setCompactionNotice = (update) => { + notice = update(notice) + notices.push(notice) + } const handleEvent = createEventHandler(ctx) const categories = { toolResults: { tokens: 10, percent: 10, messages: 1 }, @@ -1517,26 +1526,707 @@ describe('sdk-event-handlers', () => { triggerBudgetTokens: 176000, targetBudgetTokens: 176000, reason: 'Semantic compaction did not leave enough provider headroom.', - before: { tokens: 190000, messages: 20, categories }, - after: { tokens: 120000, messages: 12, categories }, + before: { + tokens: 190000, + messages: 20, + categories, + }, + after: { + tokens: 120000, + messages: 12, + categories: { + ...categories, + toolResults: { tokens: 4, percent: 4, messages: 1 }, + fileReads: { tokens: 6, percent: 6, messages: 1 }, + }, + }, removedCategories: ['toolResults', 'fileReads'], retainedKnowledgeMemory: false, recovery: 'Re-read exact files before editing.', }) - const text = getMessages()[0].blocks?.find( - (block) => block.type === 'text' && block.content.includes('context'), + const block = getMessages()[0].blocks?.find( + (candidate) => candidate.type === 'compaction', ) - const content = String(text?.type === 'text' ? text.content : '') - expect(text?.type).toBe('text') - expect(content).toContain('190,000 → 120,000 tokens') - expect(content).toContain('Resolved window: 200,000 tokens') - expect(content).toContain('trigger budget: 176,000') - expect(content).toContain('target budget: 176,000') - expect(content).toContain( - 'Reason: Semantic compaction did not leave enough provider headroom.', + // Deliberate contract change: compaction details are now a structured + // 'compaction' block instead of one concatenated text block. + expect(block).toMatchObject({ + type: 'compaction', + action: 'mechanical_trim', + beforeTokens: 190000, + afterTokens: 120000, + beforeMessages: 20, + afterMessages: 12, + // (190000 - 120000) / 190000 = 36.8% -> 37 + reductionPercent: 37, + retainedKnowledgeMemory: false, + recovery: 'Re-read exact files before editing.', + resolvedContextWindowTokens: 200000, + triggerBudgetTokens: 176000, + targetBudgetTokens: 176000, + reason: 'Semantic compaction did not leave enough provider headroom.', + categoryDeltas: [ + { category: 'toolResults', beforeTokens: 10, afterTokens: 4 }, + { category: 'fileReads', beforeTokens: 20, afterTokens: 6 }, + ], + }) + // No concatenated text block is emitted any more. + expect( + getMessages()[0].blocks?.some((candidate) => candidate.type === 'text'), + ).toBe(false) + expect(notices).toEqual([ + { count: 1, action: 'mechanical_trim', degraded: false }, + ]) + }) + + test('degrades a compaction event whose category map omits a removed category', () => { + const { ctx, getMessages } = createTestContext() + const handleEvent = createEventHandler(ctx) + // Cross-version / replayed payload: `fileReads` is reported as removed but + // neither category map carries an entry for it. The handler must record 0 + // tokens for the missing entry instead of throwing a TypeError on the + // dynamic index inside the SDK event handler. + const partialCategories = { + toolResults: { tokens: 10, percent: 10, messages: 1 }, + todos: { tokens: 10, percent: 10, messages: 1 }, + subagents: { tokens: 20, percent: 20, messages: 2 }, + userAssistantMessages: { tokens: 40, percent: 40, messages: 4 }, + } + const event = { + type: 'context_compaction', + action: 'semantic_compaction', + before: { tokens: 100, messages: 10, categories: partialCategories }, + after: { tokens: 60, messages: 6, categories: partialCategories }, + removedCategories: ['toolResults', 'fileReads'], + retainedKnowledgeMemory: true, + recovery: 'Re-read exact files before editing.', + } as unknown as PrintModeContextCompaction + + expect(() => handleEvent(event)).not.toThrow() + expect( + getMessages()[0].blocks?.find( + (candidate) => candidate.type === 'compaction', + ), + ).toMatchObject({ + type: 'compaction', + reductionPercent: 40, + categoryDeltas: [ + { category: 'toolResults', beforeTokens: 10, afterTokens: 10 }, + { category: 'fileReads', beforeTokens: 0, afterTokens: 0 }, + ], + }) + }) + + test('degrades a compaction event that omits removedCategories entirely', () => { + const { ctx, getMessages } = createTestContext() + const handleEvent = createEventHandler(ctx) + // Cross-version / replayed payload emitted before `removedCategories` + // existed. The handler must record an empty delta list instead of throwing + // a TypeError while mapping an absent array. + const categories = { + toolResults: { tokens: 10, percent: 10, messages: 1 }, + todos: { tokens: 10, percent: 10, messages: 1 }, + fileReads: { tokens: 20, percent: 20, messages: 2 }, + subagents: { tokens: 20, percent: 20, messages: 2 }, + userAssistantMessages: { tokens: 40, percent: 40, messages: 4 }, + } + const event = { + type: 'context_compaction', + action: 'semantic_compaction', + before: { tokens: 100, messages: 10, categories }, + after: { tokens: 60, messages: 6, categories }, + retainedKnowledgeMemory: true, + recovery: 'Re-read exact files before editing.', + } as unknown as PrintModeContextCompaction + + expect(() => handleEvent(event)).not.toThrow() + expect( + getMessages()[0].blocks?.find( + (candidate) => candidate.type === 'compaction', + ), + ).toMatchObject({ + type: 'compaction', + action: 'semantic_compaction', + reductionPercent: 40, + categoryDeltas: [], + }) + }) + + test('accumulates the compaction notice and flags a degraded pass', () => { + const { ctx } = createTestContext() + // Collected instead of read from a single mutable binding so the + // assertions below are not control-flow narrowed to `null`. + const notices: Array = [] + let notice: CompactionNotice | null = null + ctx.streaming.setCompactionNotice = (update) => { + notice = update(notice) + notices.push(notice) + } + const handleEvent = createEventHandler(ctx) + const categories = { + toolResults: { tokens: 10, percent: 10, messages: 1 }, + todos: { tokens: 10, percent: 10, messages: 1 }, + fileReads: { tokens: 20, percent: 20, messages: 2 }, + subagents: { tokens: 20, percent: 20, messages: 2 }, + userAssistantMessages: { tokens: 40, percent: 40, messages: 4 }, + } + const baseEvent = { + type: 'context_compaction' as const, + before: { tokens: 100, messages: 10, categories }, + after: { tokens: 90, messages: 9, categories }, + removedCategories: [], + retainedKnowledgeMemory: true, + recovery: 'Re-read exact files before editing.', + } + + // No compactionCount reported: the notice counts locally. + handleEvent({ ...baseEvent, action: 'semantic_compaction' }) + expect(notices.at(-1)).toEqual({ + count: 1, + action: 'semantic_compaction', + degraded: false, + }) + + // A low-yield streak degrades the notice even when the trim fits. + handleEvent({ + ...baseEvent, + action: 'mechanical_trim', + consecutiveNoProgressCompactions: 2, + }) + expect(notices.at(-1)).toEqual({ + count: 2, + action: 'mechanical_trim', + degraded: true, + }) + + // The runtime's own count wins, and fitsBudget: false also degrades. + handleEvent({ + ...baseEvent, + action: 'mechanical_trim', + compactionCount: 7, + fitsBudget: false, + shortfallTokens: 1234, + }) + expect(notices.at(-1)).toEqual({ + count: 7, + action: 'mechanical_trim', + degraded: true, + }) + }) + + test('context_compaction_status started appends a pending compaction block and marks the chip live', () => { + const { ctx, getMessages } = createTestContext() + const notices: Array = [] + let notice: CompactionNotice | null = null + ctx.streaming.setCompactionNotice = (update) => { + notice = update(notice) + notices.push(notice) + } + const handleEvent = createEventHandler(ctx) + + dispatchValidEvent(handleEvent, { + type: 'context_compaction_status', + state: 'started', + // Root turn: empty lineage. Every agent loop emits this event, so the + // correlation is what tells the CLI it may render root-level live state. + runId: 'root-run', + ancestorRunIds: [], + contextTokens: 152_000, + resolvedContextWindowTokens: 200_000, + triggerBudgetTokens: 150_000, + targetBudgetTokens: 70_000, + }) + + const blocks = getMessages()[0].blocks ?? [] + expect(blocks.filter((block) => block.type === 'compaction')).toHaveLength( + 1, + ) + expect(blocks[0]).toMatchObject({ + type: 'compaction', + status: 'pending', + // Stamped with this process's id so a persisted/replayed copy of the + // transient block cannot come back as a permanently live card. + liveSessionId: CLI_LIVE_SESSION_ID, + // Correlated to the producing run so only its own settle/result consumes it. + runId: 'root-run', + action: 'semantic_compaction', + beforeTokens: 152_000, + afterTokens: 0, + beforeMessages: 0, + afterMessages: 0, + reductionPercent: 0, + retainedKnowledgeMemory: false, + recovery: '', + categoryDeltas: [], + resolvedContextWindowTokens: 200_000, + triggerBudgetTokens: 150_000, + targetBudgetTokens: 70_000, + }) + expect(notices.at(-1)).toEqual({ + count: 0, + action: 'semantic_compaction', + degraded: false, + pending: true, + }) + }) + + test('a compaction result settles the pending block in place instead of duplicating it', () => { + const { ctx, getMessages } = createTestContext() + const notices: Array = [] + let notice: CompactionNotice | null = null + ctx.streaming.setCompactionNotice = (update) => { + notice = update(notice) + notices.push(notice) + } + const handleEvent = createEventHandler(ctx) + const categories = { + toolResults: { tokens: 10, percent: 10, messages: 1 }, + todos: { tokens: 10, percent: 10, messages: 1 }, + fileReads: { tokens: 20, percent: 20, messages: 2 }, + subagents: { tokens: 20, percent: 20, messages: 2 }, + userAssistantMessages: { tokens: 40, percent: 40, messages: 4 }, + } + + dispatchValidEvent(handleEvent, { + type: 'context_compaction_status', + state: 'started', + runId: 'root-run', + ancestorRunIds: [], + contextTokens: 152_000, + }) + dispatchValidEvent(handleEvent, { + type: 'context_compaction', + action: 'semantic_compaction', + runId: 'root-run', + ancestorRunIds: [], + before: { tokens: 152_000, messages: 20, categories }, + after: { tokens: 60_000, messages: 8, categories }, + removedCategories: [], + retainedKnowledgeMemory: true, + recovery: 'Resume from .', + }) + + let compactionBlocks = (getMessages()[0].blocks ?? []).filter( + (block) => block.type === 'compaction', + ) + // The live card settled in place: one block, now complete. + expect(compactionBlocks).toHaveLength(1) + expect(compactionBlocks[0]).toMatchObject({ + type: 'compaction', + status: 'complete', + action: 'semantic_compaction', + beforeTokens: 152_000, + afterTokens: 60_000, + beforeMessages: 20, + afterMessages: 8, + }) + // A settled result is no longer live, so it carries no session stamp and + // persists as a plain completed pass. + expect(compactionBlocks[0]).not.toHaveProperty('liveSessionId') + expect(notices.at(-1)).toEqual({ + count: 1, + action: 'semantic_compaction', + degraded: false, + }) + + // A second result in the same iteration (semantic then mechanical) has no + // pending block left to consume, so it appends instead of overwriting. + dispatchValidEvent(handleEvent, { + type: 'context_compaction', + action: 'mechanical_trim', + runId: 'root-run', + ancestorRunIds: [], + before: { tokens: 60_000, messages: 8, categories }, + after: { tokens: 40_000, messages: 5, categories }, + removedCategories: [], + retainedKnowledgeMemory: false, + recovery: 'Re-gather exact constraints.', + }) + + compactionBlocks = (getMessages()[0].blocks ?? []).filter( + (block) => block.type === 'compaction', + ) + expect(compactionBlocks).toHaveLength(2) + expect(compactionBlocks.map((block) => block.action)).toEqual([ + 'semantic_compaction', + 'mechanical_trim', + ]) + + // Settling after a real result leaves the completed cards untouched. + dispatchValidEvent(handleEvent, { + type: 'context_compaction_status', + state: 'settled', + runId: 'root-run', + ancestorRunIds: [], + }) + expect( + (getMessages()[0].blocks ?? []).filter( + (block) => block.type === 'compaction', + ), + ).toHaveLength(2) + expect(notices.at(-1)).toEqual({ + count: 2, + action: 'mechanical_trim', + degraded: false, + }) + }) + + test('settled with no result drops the pending block and clears the notice', () => { + const { ctx, getMessages } = createTestContext() + const notices: Array = [] + let notice: CompactionNotice | null = null + ctx.streaming.setCompactionNotice = (update) => { + notice = update(notice) + notices.push(notice) + } + const handleEvent = createEventHandler(ctx) + + dispatchValidEvent(handleEvent, { + type: 'context_compaction_status', + state: 'started', + runId: 'root-run', + ancestorRunIds: [], + contextTokens: 152_000, + }) + dispatchValidEvent(handleEvent, { + type: 'context_compaction_status', + state: 'settled', + runId: 'root-run', + ancestorRunIds: [], + }) + + expect( + (getMessages()[0].blocks ?? []).filter( + (block) => block.type === 'compaction', + ), + ).toHaveLength(0) + // Nothing ever completed, so no '⇲ compacted ×0' chip is left behind. + expect(notices.at(-1)).toBeNull() + }) + + test('a started pass keeps the completed action so an aborted turn labels the chip by what finished', () => { + const { ctx } = createTestContext() + const notices: Array = [] + let notice: CompactionNotice | null = null + ctx.streaming.setCompactionNotice = (update) => { + notice = update(notice) + notices.push(notice) + } + const handleEvent = createEventHandler(ctx) + const categories = { + toolResults: { tokens: 10, percent: 10, messages: 1 }, + todos: { tokens: 10, percent: 10, messages: 1 }, + fileReads: { tokens: 20, percent: 20, messages: 2 }, + subagents: { tokens: 20, percent: 20, messages: 2 }, + userAssistantMessages: { tokens: 40, percent: 40, messages: 4 }, + } + + // The turn's only completed pass is an emergency mechanical trim. + dispatchValidEvent(handleEvent, { + type: 'context_compaction', + action: 'mechanical_trim', + runId: 'root-run', + ancestorRunIds: [], + before: { tokens: 152_000, messages: 20, categories }, + after: { tokens: 120_000, messages: 15, categories }, + removedCategories: [], + retainedKnowledgeMemory: false, + recovery: 'Re-gather exact constraints.', + }) + expect(notices.at(-1)).toEqual({ + count: 1, + action: 'mechanical_trim', + degraded: false, + }) + + // A next pass starts and the user aborts before it reports anything. The + // live label ignores `action`, so the completed pass's action must survive: + // otherwise the settled chip would claim '⇲ compacted ×1'. + dispatchValidEvent(handleEvent, { + type: 'context_compaction_status', + state: 'started', + runId: 'root-run', + ancestorRunIds: [], + contextTokens: 120_000, + }) + expect(notices.at(-1)).toEqual({ + count: 1, + action: 'mechanical_trim', + degraded: false, + pending: true, + }) + }) + + test('a subagent compaction status neither renders nor cross-settles the root run state', () => { + const { ctx, getMessages } = createTestContext() + const notices: Array = [] + let notice: CompactionNotice | null = null + ctx.streaming.setCompactionNotice = (update) => { + notice = update(notice) + notices.push(notice) + } + const handleEvent = createEventHandler(ctx) + + dispatchValidEvent(handleEvent, { + type: 'context_compaction_status', + state: 'started', + runId: 'root-run', + ancestorRunIds: [], + contextTokens: 152_000, + }) + // A foreground subagent / inline agent loop compacts its own context. Its + // lineage is non-empty, so it must not add a second root-level card. + dispatchValidEvent(handleEvent, { + type: 'context_compaction_status', + state: 'started', + runId: 'child-run', + ancestorRunIds: ['root-run'], + agentId: 'child-agent', + contextTokens: 90_000, + }) + // Nor may its settle clear the root run's still-live card. + dispatchValidEvent(handleEvent, { + type: 'context_compaction_status', + state: 'settled', + runId: 'child-run', + ancestorRunIds: ['root-run'], + agentId: 'child-agent', + }) + + const pending = (getMessages()[0].blocks ?? []).filter( + (block) => block.type === 'compaction', + ) + expect(pending).toHaveLength(1) + expect(pending[0]).toMatchObject({ status: 'pending', runId: 'root-run' }) + expect(notices.at(-1)).toEqual({ + count: 0, + action: 'semantic_compaction', + degraded: false, + pending: true, + }) + + // Only the root run's own settle ends the live state. + dispatchValidEvent(handleEvent, { + type: 'context_compaction_status', + state: 'settled', + runId: 'root-run', + ancestorRunIds: [], + }) + expect( + (getMessages()[0].blocks ?? []).filter( + (block) => block.type === 'compaction', + ), + ).toHaveLength(0) + expect(notices.at(-1)).toBeNull() + }) + + test("a subagent compaction result cannot overwrite the root turn's count or settle its live card", () => { + const { ctx, getMessages } = createTestContext() + const notices: Array = [] + let notice: CompactionNotice | null = null + ctx.streaming.setCompactionNotice = (update) => { + notice = update(notice) + notices.push(notice) + } + const handleEvent = createEventHandler(ctx) + const categories = { + toolResults: { tokens: 10, percent: 10, messages: 1 }, + todos: { tokens: 10, percent: 10, messages: 1 }, + fileReads: { tokens: 20, percent: 20, messages: 2 }, + subagents: { tokens: 20, percent: 20, messages: 2 }, + userAssistantMessages: { tokens: 40, percent: 40, messages: 4 }, + } + + dispatchValidEvent(handleEvent, { + type: 'context_compaction_status', + state: 'started', + runId: 'root-run', + ancestorRunIds: [], + contextTokens: 152_000, + }) + // `compactionCount` counts the emitting run's own passes, so a subagent's + // 9 must not become the root turn's total, and its result must not consume + // the root run's pending card or clear the live chip. + dispatchValidEvent(handleEvent, { + type: 'context_compaction', + action: 'semantic_compaction', + runId: 'child-run', + ancestorRunIds: ['root-run'], + agentId: 'child-agent', + compactionCount: 9, + before: { tokens: 90_000, messages: 12, categories }, + after: { tokens: 40_000, messages: 6, categories }, + removedCategories: [], + retainedKnowledgeMemory: true, + recovery: 'Resume from .', + }) + + let blocks = (getMessages()[0].blocks ?? []).filter( + (block) => block.type === 'compaction', + ) + expect(blocks).toHaveLength(2) + expect(blocks[0]).toMatchObject({ status: 'pending', runId: 'root-run' }) + expect(blocks[1]).toMatchObject({ status: 'complete', runId: 'child-run' }) + expect(notices.at(-1)).toEqual({ + count: 1, + action: 'semantic_compaction', + degraded: false, + pending: true, + }) + + // The root run's own result adopts its reported count and clears the live + // state, settling the root card in place. + dispatchValidEvent(handleEvent, { + type: 'context_compaction', + action: 'mechanical_trim', + runId: 'root-run', + ancestorRunIds: [], + compactionCount: 2, + before: { tokens: 152_000, messages: 20, categories }, + after: { tokens: 60_000, messages: 8, categories }, + removedCategories: [], + retainedKnowledgeMemory: false, + recovery: 'Re-gather exact constraints.', + }) + + blocks = (getMessages()[0].blocks ?? []).filter( + (block) => block.type === 'compaction', + ) + expect(blocks).toHaveLength(2) + expect(blocks[0]).toMatchObject({ + status: 'complete', + runId: 'root-run', + action: 'mechanical_trim', + }) + expect(notices.at(-1)).toEqual({ + count: 2, + action: 'mechanical_trim', + degraded: false, + }) + }) + + test('a forwarding-rewritten agentId does not affect run correlation for a deeply nested compaction', () => { + const { ctx, getMessages } = createTestContext() + const notices: Array = [] + let notice: CompactionNotice | null = null + ctx.streaming.setCompactionNotice = (update) => { + notice = update(notice) + notices.push(notice) + } + const handleEvent = createEventHandler(ctx) + const categories = { + toolResults: { tokens: 10, percent: 10, messages: 1 }, + todos: { tokens: 10, percent: 10, messages: 1 }, + fileReads: { tokens: 20, percent: 20, messages: 2 }, + subagents: { tokens: 20, percent: 20, messages: 2 }, + userAssistantMessages: { tokens: 40, percent: 40, messages: 4 }, + } + + dispatchValidEvent(handleEvent, { + type: 'context_compaction_status', + state: 'started', + runId: 'root-run', + ancestorRunIds: [], + contextTokens: 152_000, + }) + // Depth-2 emission as the CLI actually receives it: the spawn_agents + // forwarding path rewrote `agentId` to the DIRECT child's agent id, so the + // delivered value names the forwarding child rather than the grandchild + // that compacted. `runId`/`ancestorRunIds` survive every hop, so they -- not + // `agentId` -- decide what is root-level and what settles which card. + dispatchValidEvent(handleEvent, { + type: 'context_compaction_status', + state: 'started', + runId: 'grandchild-run', + ancestorRunIds: ['root-run', 'child-run'], + agentId: 'direct-child-agent', + contextTokens: 70_000, + }) + dispatchValidEvent(handleEvent, { + type: 'context_compaction_status', + state: 'settled', + runId: 'grandchild-run', + ancestorRunIds: ['root-run', 'child-run'], + agentId: 'direct-child-agent', + }) + + // The nested pass never rendered root-level live state, and its settle left + // the root run's live card and chip alone. + let blocks = (getMessages()[0].blocks ?? []).filter( + (block) => block.type === 'compaction', + ) + expect(blocks).toHaveLength(1) + expect(blocks[0]).toMatchObject({ status: 'pending', runId: 'root-run' }) + expect(notices.at(-1)).toEqual({ + count: 0, + action: 'semantic_compaction', + degraded: false, + pending: true, + }) + + // Its result is recorded under its own emitting run, even though the + // delivered `agentId` matches the forwarding child rather than the emitter. + dispatchValidEvent(handleEvent, { + type: 'context_compaction', + action: 'semantic_compaction', + runId: 'grandchild-run', + ancestorRunIds: ['root-run', 'child-run'], + agentId: 'direct-child-agent', + compactionCount: 4, + before: { tokens: 70_000, messages: 10, categories }, + after: { tokens: 30_000, messages: 5, categories }, + removedCategories: [], + retainedKnowledgeMemory: true, + recovery: 'Resume from .', + }) + + blocks = (getMessages()[0].blocks ?? []).filter( + (block) => block.type === 'compaction', + ) + expect(blocks).toHaveLength(2) + expect(blocks[0]).toMatchObject({ status: 'pending', runId: 'root-run' }) + expect(blocks[1]).toMatchObject({ + status: 'complete', + runId: 'grandchild-run', + }) + // The nested run's own count never becomes the root turn's total, and the + // root run's live pass is still live. + expect(notices.at(-1)).toEqual({ + count: 1, + action: 'semantic_compaction', + degraded: false, + pending: true, + }) + }) + + test('handleFinish rewrites a stray pending compaction block as interrupted', () => { + const { ctx, getMessages } = createTestContext() + const handleEvent = createEventHandler(ctx) + + dispatchValidEvent(handleEvent, { + type: 'context_compaction_status', + state: 'started', + runId: 'root-run', + ancestorRunIds: [], + contextTokens: 152_000, + }) + // No `settled` arrives (an abnormal turn end between the two): the turn + // boundary must terminate the live card rather than silently deleting it, + // so the transcript keeps an honest record of the unfinished pass. + dispatchValidEvent(handleEvent, { type: 'finish', totalCost: 0 }) + + const compactionBlocks = (getMessages()[0].blocks ?? []).filter( + (block) => block.type === 'compaction', ) - expect(content).toContain('Removed: toolResults, fileReads') - expect(content).toContain('Retained knowledge memory: no') + expect(compactionBlocks).toHaveLength(1) + expect(compactionBlocks[0]).toMatchObject({ + type: 'compaction', + status: 'interrupted', + runId: 'root-run', + beforeTokens: 152_000, + }) + // The live stamp is meaningless once the run is over, so it does not + // persist alongside the terminal state. + expect(compactionBlocks[0]).not.toHaveProperty('liveSessionId') }) }) diff --git a/cli/src/utils/__tests__/status-bar-chips.test.ts b/cli/src/utils/__tests__/status-bar-chips.test.ts index e53e62c6df..21cd3d0664 100644 --- a/cli/src/utils/__tests__/status-bar-chips.test.ts +++ b/cli/src/utils/__tests__/status-bar-chips.test.ts @@ -263,7 +263,7 @@ describe('selectStatusBarChips', () => { expect(lgLabel.startsWith(mdLabel.slice(0, -1))).toBe(true) }) - test('sm is percent-only context and keeps git when there is no index chip', () => { + test('sm prefixes the context percent and keeps git when there is no index chip', () => { const { chips } = selectStatusBarChips({ ...full, widthSize: 'sm', @@ -271,13 +271,42 @@ describe('selectStatusBarChips', () => { }) const chipsById = byId(chips) - expect(chipsById.context?.label).toBe('48%') + // 'ctx' prefix so the percent is not read as part of the neighbouring git + // chip; sm still renders no bar. + expect(chipsById.context?.label).toBe('ctx 48%') expect(chipsById.context?.label).not.toContain('█') expect(chipsById.model).toBeUndefined() expect(chipsById.cost).toBeUndefined() expect(chipsById.git?.label).toBe('~3 +2') }) + test('sm context degrades from the prefixed label to the bare percent', () => { + const timerLabel = '12s' + const budgetFor = (contextLabel: string) => + statusBarClusterWidth([ + { id: 'context', label: contextLabel, tone: 'secondary' }, + { id: 'timer', label: timerLabel, tone: 'secondary' }, + ]) + const contextAt = (terminalWidth: number) => + byId( + selectStatusBarChips({ + ...full, + widthSize: 'sm', + terminalWidth, + isActive: false, + }).chips, + ).context + + // Room for the prefixed label beside the timer, so nothing degrades. + expect( + contextAt(widthForBudget(budgetFor('ctx 48%'), full.showStop))?.label, + ).toBe('ctx 48%') + // One column tighter: the prefix goes and the bare percent survives. + expect( + contextAt(widthForBudget(budgetFor('ctx 48%') - 1, full.showStop))?.label, + ).toBe('48%') + }) + test('sm drops git for a secondary index chip, not only for alerts', () => { const { chips } = selectStatusBarChips({ ...full, @@ -443,12 +472,258 @@ describe('selectStatusBarChips', () => { }).chips, ) - // Token counts belong to the lg >=70% branch only. + // md only adds token counts from 80%; 75% keeps the bar-and-percent form. expect(chipsById.context?.label).toMatch(/^[█░]{6} 75%$/) expect(chipsById.context?.label).not.toContain('/') expect(chipsById.context?.tone).toBe('warning') }) + test('md shows token counts from 80% and degrades counts, then bar, then percent', () => { + const contextAt = (terminalWidth: number) => + byId( + selectStatusBarChips({ + ...full, + widthSize: 'md', + terminalWidth, + contextWindowUsage: { used: 170_000, max: 200_000 }, // 85% + isActive: false, + }).chips, + ).context + + const countsLabel = contextAt(200)?.label ?? '' + expect(countsLabel).toMatch(/^170k\/200k [█░]{6} 85%$/) + + // Same label without its token-count prefix: the intermediate form the + // overflow loop should stop at while it still fits. + const barLabel = countsLabel.slice(countsLabel.indexOf(' ') + 1) + const timerLabel = '12s' + const budgetFor = (contextLabel: string) => + statusBarClusterWidth([ + { id: 'context', label: contextLabel, tone: 'warning' }, + { id: 'timer', label: timerLabel, tone: 'secondary' }, + ]) + + const intermediate = contextAt( + widthForBudget(budgetFor(barLabel), full.showStop), + ) + expect(intermediate?.label).toBe(barLabel) + expect(intermediate?.label).toMatch(/^[█░]{6} 85%$/) + + const bare = contextAt( + widthForBudget(budgetFor(barLabel) - 1, full.showStop), + ) + expect(bare?.label).toBe('85%') + }) + + test('compaction chip label and tone follow the width size and action', () => { + const compactionAt = ( + widthSize: 'xs' | 'sm' | 'md' | 'lg', + notice: NonNullable, + ) => + byId( + selectStatusBarChips({ + ...full, + widthSize, + terminalWidth: 400, + compactionNotice: notice, + }).chips, + ).compaction + + const semantic = { + count: 2, + action: 'semantic_compaction', + degraded: false, + } as const + + expect(compactionAt('xs', semantic)?.label).toBe('⇲ 2') + expect(compactionAt('sm', semantic)?.label).toBe('⇲ 2') + expect(compactionAt('md', semantic)?.label).toBe('⇲ compacted ×2') + expect(compactionAt('lg', semantic)?.label).toBe('⇲ compacted ×2') + expect(compactionAt('lg', semantic)?.tone).toBe('warning') + + const trimmed = { + count: 3, + action: 'mechanical_trim', + degraded: true, + } as const + expect(compactionAt('md', trimmed)?.label).toBe('⇲ trimmed ×3') + expect(compactionAt('lg', trimmed)?.label).toBe('⇲ trimmed ×3') + expect(compactionAt('lg', trimmed)?.tone).toBe('error') + expect(compactionAt('sm', trimmed)?.label).toBe('⇲ 3') + expect(compactionAt('sm', trimmed)?.tone).toBe('error') + }) + + test('compaction chip reports the live state while a pass is pending', () => { + const pendingAt = (widthSize: 'xs' | 'sm' | 'md' | 'lg') => + byId( + selectStatusBarChips({ + ...full, + widthSize, + terminalWidth: 400, + compactionNotice: { + // Nothing has completed yet: the chip must still render at count 0. + count: 0, + action: 'semantic_compaction', + degraded: false, + pending: true, + }, + }).chips, + ).compaction + + expect(pendingAt('xs')?.label).toBe('⇲ …') + expect(pendingAt('sm')?.label).toBe('⇲ …') + expect(pendingAt('md')?.label).toBe('⇲ compacting…') + expect(pendingAt('lg')?.label).toBe('⇲ compacting…') + expect(pendingAt('lg')?.tone).toBe('warning') + + // A degraded earlier pass does not tone the live chip red. + const degradedPending = byId( + selectStatusBarChips({ + ...full, + widthSize: 'lg', + terminalWidth: 400, + compactionNotice: { + count: 1, + action: 'mechanical_trim', + degraded: true, + pending: true, + }, + }).chips, + ).compaction + expect(degradedPending?.label).toBe('⇲ compacting…') + expect(degradedPending?.tone).toBe('warning') + }) + + test('an idle run stops reporting a pending pass as live', () => { + // The run aborted mid-compaction, so no settling event will ever arrive. + // The chip must not keep claiming a compaction is running. + const settled = byId( + selectStatusBarChips({ + ...full, + widthSize: 'lg', + terminalWidth: 400, + isActive: false, + compactionNotice: { + count: 2, + action: 'mechanical_trim', + degraded: true, + pending: true, + }, + }).chips, + ).compaction + expect(settled?.label).toBe('⇲ trimmed ×2') + // Settled again, so the degraded pass tones the chip red. + expect(settled?.tone).toBe('error') + + // Nothing ever completed: there is no count worth showing, so the chip is + // dropped rather than rendering an information-free '⇲ 0'. + expect( + byId( + selectStatusBarChips({ + ...full, + widthSize: 'lg', + terminalWidth: 400, + isActive: false, + compactionNotice: { + count: 0, + action: 'semantic_compaction', + degraded: false, + pending: true, + }, + }).chips, + ).compaction, + ).toBeUndefined() + }) + + test('a zero-count settled notice renders no compaction chip', () => { + expect( + byId( + selectStatusBarChips({ + ...full, + widthSize: 'lg', + terminalWidth: 400, + compactionNotice: { + count: 0, + action: 'semantic_compaction', + degraded: false, + }, + }).chips, + ).compaction, + ).toBeUndefined() + }) + + test('compaction chip renders right after context and is omitted without a notice', () => { + const withNotice = selectStatusBarChips({ + ...full, + widthSize: 'lg', + terminalWidth: 400, + compactionNotice: { + count: 1, + action: 'semantic_compaction', + degraded: false, + }, + indexChip: { label: 'idx ready', tone: 'secondary' }, + }) + expect(withNotice.chips.map((chip) => chip.id)).toEqual([ + 'context', + 'compaction', + 'index', + 'git', + 'model', + 'cost', + 'timer', + ]) + + const withoutNotice = selectStatusBarChips({ + ...full, + widthSize: 'lg', + terminalWidth: 400, + compactionNotice: null, + }) + expect(byId(withoutNotice.chips).compaction).toBeUndefined() + }) + + test('overflow drops the compaction chip after git but before context', () => { + const compactionNotice = { + count: 2, + action: 'semantic_compaction', + degraded: false, + } as const + const chipsAt = (terminalWidth: number) => + selectStatusBarChips({ + ...full, + widthSize: 'lg', + terminalWidth, + compactionNotice, + }).chips + const idsAt = (terminalWidth: number) => + chipsAt(terminalWidth).map((chip) => chip.id) + + const allChips = chipsAt(widthForBudget(120, full.showStop)) + const clusterWithout = (dropped: StatusBarChip['id'][]) => + statusBarClusterWidth( + allChips.filter((chip) => !dropped.includes(chip.id)), + ) + + // The compaction chip survives while cost, model, and git are given up. + expect( + idsAt( + widthForBudget(clusterWithout(['cost', 'model', 'git']), full.showStop), + ), + ).toEqual(['context', 'compaction', 'timer']) + + // One priority step further: the compaction chip goes before context is + // shortened or dropped. + expect( + idsAt( + widthForBudget( + clusterWithout(['cost', 'model', 'git', 'compaction']), + full.showStop, + ), + ), + ).toEqual(['context', 'timer']) + }) + test('xs keeps the timer when the stop hint is hidden', () => { const { chips } = selectStatusBarChips({ ...full, diff --git a/cli/src/utils/create-event-handler-state.ts b/cli/src/utils/create-event-handler-state.ts index e30f5e75cb..b8dc9cbc89 100644 --- a/cli/src/utils/create-event-handler-state.ts +++ b/cli/src/utils/create-event-handler-state.ts @@ -2,6 +2,7 @@ import type { AgentMode } from './constants' import type { MessageUpdater } from './message-updater' import type { EventHandlerState, + SetCompactionNoticeFn, SetContextWindowUsageFn, SetStreamingAgentsFn, SetStreamStatusFn, @@ -15,6 +16,7 @@ export type CreateEventHandlerStateParams = { setStreamingAgents: SetStreamingAgentsFn setStreamStatus: SetStreamStatusFn setContextWindowUsage: SetContextWindowUsageFn + setCompactionNotice: SetCompactionNoticeFn aiMessageId: string updater: MessageUpdater hasReceivedContentRef: MutableRefObject @@ -35,6 +37,7 @@ export const createEventHandlerState = ( setStreamingAgents, setStreamStatus, setContextWindowUsage, + setCompactionNotice, aiMessageId, updater, hasReceivedContentRef, @@ -53,6 +56,7 @@ export const createEventHandlerState = ( setStreamingAgents, setStreamStatus, setContextWindowUsage, + setCompactionNotice, }, message: { aiMessageId, diff --git a/cli/src/utils/message-block-helpers.ts b/cli/src/utils/message-block-helpers.ts index 1e1881bce8..11909179f0 100644 --- a/cli/src/utils/message-block-helpers.ts +++ b/cli/src/utils/message-block-helpers.ts @@ -835,6 +835,32 @@ export const appendInterruptionNotice = ( return [...blocks, interruptionNotice] } +/** + * Terminates every still-running (`status: 'pending'`) root-level compaction + * block, rewriting it to the terminal `'interrupted'` state and dropping the + * now-meaningless `liveSessionId` stamp. Composed into the abort/teardown block + * update so a pass whose run ended before it reported a result can never be + * persisted — or replayed — as a live "Compacting context…" card. + * + * Returns the ORIGINAL array reference when nothing was pending so React skips + * a re-render. Root-level only: compaction blocks are never nested under an + * agent block. + */ +export const markPendingCompactionInterrupted = ( + blocks: ContentBlock[], +): ContentBlock[] => { + let changed = false + const next = blocks.map((block) => { + if (block.type !== 'compaction' || block.status !== 'pending') { + return block + } + changed = true + const { liveSessionId: _liveSessionId, ...rest } = block + return { ...rest, status: 'interrupted' as const } + }) + return changed ? next : blocks +} + /** * Recursively finds an agent block by ID and returns its agent type. * Returns undefined if not found. diff --git a/cli/src/utils/sdk-event-handlers.ts b/cli/src/utils/sdk-event-handlers.ts index 0512a36ead..d8803d648f 100644 --- a/cli/src/utils/sdk-event-handlers.ts +++ b/cli/src/utils/sdk-event-handlers.ts @@ -21,6 +21,7 @@ import { findAgentTypeById, getBackgroundShellJobIdFromToolOutput, insertPlanBlock, + markPendingCompactionInterrupted, nestBlockUnderParent, transformAskUserBlocks, updateBlocksRecursively, @@ -42,6 +43,7 @@ import { processTextChunk, } from './stream-chunk-processor' import { computeCompletionSummary } from './completion-summary' +import { CLI_LIVE_SESSION_ID } from '../types/chat' import type { AgentMode } from './constants' import type { MessageUpdater } from './message-updater' @@ -49,6 +51,9 @@ import type { StreamController } from '../hooks/stream-state' import type { StreamStatus } from '../hooks/use-message-queue' import type { AgentContentBlock, + CompactionCategoryDelta, + CompactionContentBlock, + CompactionNotice, ContentBlock, TextContentBlock, ToolContentBlock, @@ -57,6 +62,7 @@ import type { Logger } from '@codebuff/common/types/contracts/logger' import type { PrintModeContextWindow, PrintModeContextCompaction, + PrintModeContextCompactionStatus, PrintModeEvent as SDKEvent, PrintModeJobUpdate, PrintModeFinish, @@ -80,6 +86,19 @@ export type SetContextWindowUsageFn = ( usage: { used: number; max: number } | null, ) => void +// Re-exported from its canonical declaration in ../types/chat so existing +// importers of this module keep working while there is only one shape. +export type { CompactionNotice } + +/** + * Accumulating setter for the status-bar compaction chip: receives an updater so + * repeated compactions within one turn can keep counting from the previous + * notice, and null to clear it when a new turn starts. + */ +export type SetCompactionNoticeFn = ( + update: (previous: CompactionNotice | null) => CompactionNotice | null, +) => void + export type StreamChunkEvent = | string | { @@ -100,6 +119,7 @@ export type StreamingState = { setStreamingAgents: SetStreamingAgentsFn setStreamStatus: SetStreamStatusFn setContextWindowUsage: SetContextWindowUsageFn + setCompactionNotice: SetCompactionNoticeFn } export type MessageState = { @@ -1245,40 +1265,284 @@ const handleContextWindow = ( }) } +/** + * Tokens recorded for one category of a compaction snapshot, or 0 when the + * payload omits that category map entry. A cross-version or replayed + * `context_compaction` event may carry a removed category with no matching + * entry in `before`/`after.categories`; degrading to 0 keeps the block + * renderable instead of throwing a TypeError inside the SDK event handler. + */ +const compactionCategoryTokens = ( + categories: + | Partial> + | undefined, + category: CompactionCategoryDelta['category'], +): number => { + const tokens = categories?.[category]?.tokens + return typeof tokens === 'number' && Number.isFinite(tokens) ? tokens : 0 +} + +/** + * True when a compaction event belongs to the ROOT agent run rather than a + * subagent or inline one. `ancestorRunIds` is empty exactly for the root run + * (the same convention as `reasoning_delta`), so a non-empty lineage describes + * another agent's context and must not drive root-level state. The + * `context_compaction` result carries the correlation optionally for + * persisted/replayed events emitted before it existed; an absent field keeps + * the previous root-attributed behavior. + */ +const isRootCompactionEvent = (event: { ancestorRunIds?: string[] }): boolean => + (event.ancestorRunIds?.length ?? 0) === 0 + +/** + * Index of the newest still-running compaction block produced by `runId`, or + * -1. Only the newest one is consumed by an arriving result: two results can + * land in one runtime iteration (semantic then mechanical) and the second must + * append rather than overwrite the first. Matched on the producing run so a + * result from one agent loop cannot settle another loop's live card; a legacy + * uncorrelated result (`undefined`) still pairs with an equally uncorrelated + * block. + */ +const findLastPendingCompactionIndex = ( + blocks: ContentBlock[], + runId: string | undefined, +): number => { + for (let index = blocks.length - 1; index >= 0; index--) { + const block = blocks[index] + if ( + block.type === 'compaction' && + block.status === 'pending' && + block.runId === runId + ) { + return index + } + } + return -1 +} + +/** + * Drops the still-running compaction blocks produced by `runId`, returning the + * original array reference when there was nothing to drop so React skips a + * re-render. Used by the `settled` path only: a pass that reported no result + * never compacted anything, so its card has nothing to report and should + * disappear entirely. An abnormal turn end instead rewrites the block via + * {@link markPendingCompactionInterrupted}, which keeps an honest terminal + * record. Only that run's blocks are dropped, so one agent loop's `settled` + * cannot clear another loop's live card. `runId` is required on + * `printModeContextCompactionStatusSchema`, so unlike + * {@link findLastPendingCompactionIndex} — reached from the `context_compaction` + * result path, where the correlation fields are optional — there is no + * uncorrelated case to pair here. Root-level only: compaction blocks are never + * nested under an agent block. + */ +const dropPendingCompactionBlocks = ( + blocks: ContentBlock[], + runId: string, +): ContentBlock[] => { + const next = blocks.filter( + (block) => + !( + block.type === 'compaction' && + block.status === 'pending' && + block.runId === runId + ), + ) + return next.length === blocks.length ? blocks : next +} + +/** + * Live compaction state. The pruner agent runs inline and is hidden from the + * CLI, so `started` renders a pending compaction card (and a live status-bar + * chip) that the terminal `context_compaction` result settles in place. + * `settled` is the runtime's guarantee that a pass which decided not to compact + * cannot leave that pending state on screen. + * + * Every agent loop emits this event, so it is filtered and paired by run: + * only the root run (empty `ancestorRunIds`) drives this root-level card and + * chip, and a `settled` only clears the pending card its own `runId` started. + * Without both rules a subagent's compaction would render as a root-level + * 'Compacting context…' card and cross-settle the root run's live pass. + * + * Neither cleanup path is reachable when the user aborts mid-compaction: the + * SDK drops every post-abort event, and the blocks of the turn are persisted to + * chat-messages.json afterwards. The pending block is therefore stamped with + * {@link CLI_LIVE_SESSION_ID} so a replayed copy renders as an interrupted + * pass rather than a permanent "Compacting context…" card, and the status chip + * stops reporting a live pass once the run is no longer active. + */ +const handleContextCompactionStatus = ( + state: EventHandlerState, + event: PrintModeContextCompactionStatus, +) => { + if (!isRootCompactionEvent(event)) return + + if (event.state === 'started') { + const pendingBlock: CompactionContentBlock = { + type: 'compaction', + status: 'pending', + // Stamped so a persisted/replayed copy of this transient block (the user + // aborted the turn before `settled` or `context_compaction` arrived) + // renders as an interrupted pass instead of a permanently live card. + liveSessionId: CLI_LIVE_SESSION_ID, + // Correlated so only this run's own `settled`/result can consume the card. + runId: event.runId, + action: 'semantic_compaction', + beforeTokens: event.contextTokens ?? 0, + afterTokens: 0, + beforeMessages: 0, + afterMessages: 0, + reductionPercent: 0, + retainedKnowledgeMemory: false, + recovery: '', + categoryDeltas: [], + ...(event.resolvedContextWindowTokens !== undefined && { + resolvedContextWindowTokens: event.resolvedContextWindowTokens, + }), + ...(event.triggerBudgetTokens !== undefined && { + triggerBudgetTokens: event.triggerBudgetTokens, + }), + ...(event.targetBudgetTokens !== undefined && { + targetBudgetTokens: event.targetBudgetTokens, + }), + } + state.message.updater.updateAiMessageBlocks((blocks) => [ + ...blocks, + pendingBlock, + ]) + state.streaming.setCompactionNotice((previous) => ({ + count: previous?.count ?? 0, + // The live chip label does not read `action`, and a pass that has only + // started has not decided its own action yet, so the accumulated action + // of the last COMPLETED pass is carried forward. Overwriting it would + // mislabel the settled chip after an abort mid-compaction: a turn whose + // only completed pass was a mechanical trim would read '⇲ compacted ×N'. + action: previous?.action ?? 'semantic_compaction', + degraded: previous?.degraded ?? false, + pending: true, + })) + return + } + + state.message.updater.updateAiMessageBlocks((blocks) => + dropPendingCompactionBlocks(blocks, event.runId), + ) + state.streaming.setCompactionNotice((previous) => { + if (!previous) return null + // A start fired but nothing ever completed: clearing the notice keeps a + // '⇲ compacted ×0' chip from ever being rendered. + if (previous.count === 0) return null + if (!previous.pending) return previous + return { + count: previous.count, + action: previous.action, + degraded: previous.degraded, + } + }) +} + const handleContextCompaction = ( state: EventHandlerState, event: PrintModeContextCompaction, ) => { - const action = - event.action === 'semantic_compaction' - ? 'Semantic context compaction' - : 'Emergency context trim' - const removed = - event.removedCategories.length > 0 - ? ` Removed: ${event.removedCategories.join(', ')}.` - : '' - const retained = event.retainedKnowledgeMemory - ? ' Retained knowledge memory: yes.' - : ' Retained knowledge memory: no.' - const resolvedWindow = event.resolvedContextWindowTokens - ? event.resolvedContextWindowTokens.toLocaleString() - : 'unknown (conservative fallback)' - const budgetDetails = - event.triggerBudgetTokens !== undefined && - event.targetBudgetTokens !== undefined - ? ` Resolved window: ${resolvedWindow} tokens; trigger budget: ${event.triggerBudgetTokens.toLocaleString()}; target budget: ${event.targetBudgetTokens.toLocaleString()}.` - : '' - const reason = event.reason ? ` Reason: ${event.reason}` : '' - const content = `${action}: ${event.before.tokens.toLocaleString()} → ${event.after.tokens.toLocaleString()} tokens; ${event.before.messages} → ${event.after.messages} messages.${budgetDetails}${reason}${removed}${retained} ${event.recovery}` + // Whole-percent reduction, clamped so a compaction that somehow grew the + // context (or reported a zero baseline) renders 0% instead of a negative or + // out-of-range percent. + const reductionPercent = + event.before.tokens > 0 + ? Math.min( + 100, + Math.max( + 0, + Math.round( + ((event.before.tokens - event.after.tokens) / + event.before.tokens) * + 100, + ), + ), + ) + : 0 + + // `removedCategories` is required by the current event contract, but a + // cross-version or replayed `context_compaction` payload emitted before the + // field existed can omit it. Degrading to an empty delta list keeps the card + // renderable instead of throwing a TypeError inside the SDK event handler. + const removedCategories = Array.isArray(event.removedCategories) + ? event.removedCategories + : [] - state.message.updater.updateAiMessageBlocks((blocks) => [ - ...blocks, - { - type: 'text' as const, - textType: 'text' as const, - content, - }, - ]) + const categoryDeltas: CompactionCategoryDelta[] = removedCategories.map( + (category) => ({ + category, + beforeTokens: compactionCategoryTokens(event.before.categories, category), + afterTokens: compactionCategoryTokens(event.after.categories, category), + }), + ) + + const degraded = + event.fitsBudget === false || + (event.consecutiveNoProgressCompactions ?? 0) >= 2 + + const resultBlock: CompactionContentBlock = { + type: 'compaction', + status: 'complete', + action: event.action, + ...(event.runId !== undefined && { runId: event.runId }), + beforeTokens: event.before.tokens, + afterTokens: event.after.tokens, + beforeMessages: event.before.messages, + afterMessages: event.after.messages, + reductionPercent, + retainedKnowledgeMemory: event.retainedKnowledgeMemory, + recovery: event.recovery, + categoryDeltas, + ...(event.reason !== undefined && { reason: event.reason }), + ...(event.resolvedContextWindowTokens !== undefined && { + resolvedContextWindowTokens: event.resolvedContextWindowTokens, + }), + ...(event.triggerBudgetTokens !== undefined && { + triggerBudgetTokens: event.triggerBudgetTokens, + }), + ...(event.targetBudgetTokens !== undefined && { + targetBudgetTokens: event.targetBudgetTokens, + }), + ...(event.compactionCount !== undefined && { + compactionCount: event.compactionCount, + }), + ...(event.consecutiveNoProgressCompactions !== undefined && { + consecutiveNoProgressCompactions: event.consecutiveNoProgressCompactions, + }), + ...(event.fitsBudget !== undefined && { fitsBudget: event.fitsBudget }), + ...(event.shortfallTokens !== undefined && { + shortfallTokens: event.shortfallTokens, + }), + ...(event.escalated !== undefined && { escalated: event.escalated }), + } + + // The live pending card settles into the result in place; with no pending + // card for this run (e.g. a mechanical trim with no preceding start, or a + // subagent result while the root card is live) the result appends, which is + // the pre-existing behavior. + state.message.updater.updateAiMessageBlocks((blocks) => { + const pendingIndex = findLastPendingCompactionIndex(blocks, event.runId) + if (pendingIndex === -1) return [...blocks, resultBlock] + const next = [...blocks] + next[pendingIndex] = resultBlock + return next + }) + + // The notice accumulates across compactions within a turn. `compactionCount` + // counts the EMITTING run's own passes, so only the root run's count may + // replace the turn total; a subagent/inline result contributes one pass + // instead of overwriting it, and never settles the root run's live pass. + const rootScoped = isRootCompactionEvent(event) + state.streaming.setCompactionNotice((previous) => ({ + count: rootScoped + ? (event.compactionCount ?? (previous?.count ?? 0) + 1) + : (previous?.count ?? 0) + 1, + action: event.action, + degraded, + ...(!rootScoped && previous?.pending === true ? { pending: true } : {}), + })) } const handleFinish = (state: EventHandlerState, event: PrintModeFinish) => { @@ -1288,7 +1552,15 @@ const handleFinish = (state: EventHandlerState, event: PrintModeFinish) => { const settledIds = new Set() state.message.updater.updateAiMessageBlocks((blocks) => { - const settledBlocks = settleOrphanedForegroundAgents(blocks, settledIds) + // Defensive turn-boundary cleanup: an abnormal end between `started` and + // `settled` must not leave a live compacting card on screen. The pass is + // rewritten to its terminal interrupted state rather than deleted, so the + // transcript keeps an honest record of a compaction that never reported a + // result. Kept separate from the recursive agent/tool settling below, which + // walks nested blocks. A user abort never reaches this handler: the abort + // listener in hooks/helpers/send-message.ts applies the same rewrite. + const rootBlocks = markPendingCompactionInterrupted(blocks) + const settledBlocks = settleOrphanedForegroundAgents(rootBlocks, settledIds) const summary = computeCompletionSummary(settledBlocks) if (!summary) return settledBlocks @@ -1427,6 +1699,9 @@ export const createEventHandler = .with({ type: 'context_compaction' }, (e) => handleContextCompaction(state, e), ) + .with({ type: 'context_compaction_status' }, (e) => + handleContextCompactionStatus(state, e), + ) .with({ type: 'job_update' }, (e) => handleJobUpdate(state, e)) .otherwise(() => undefined) } diff --git a/cli/src/utils/status-bar-chips.ts b/cli/src/utils/status-bar-chips.ts index 8805a32acf..af6ae03845 100644 --- a/cli/src/utils/status-bar-chips.ts +++ b/cli/src/utils/status-bar-chips.ts @@ -2,8 +2,11 @@ import stringWidth from 'string-width' import { formatElapsedTime } from './format-elapsed-time' +import type { CompactionNotice } from '../types/chat' + export type StatusBarChipId = | 'context' + | 'compaction' | 'index' | 'git' | 'model' @@ -32,6 +35,17 @@ export type SelectStatusBarChipsInput = { * '!', so the label should lead with its subject (e.g. 'idx failed: …'). */ indexChip?: { label: string; tone: 'secondary' | 'warning' | 'error' } | null + /** + * Accumulated context-compaction notice for the current turn, or null when + * nothing has been compacted. `degraded` marks a compaction that did not fit + * the budget or that stopped reclaiming space, and `pending` marks a pass + * that is running right now (the chip renders even at `count: 0`). `pending` + * is only honored while `isActive`: an aborted turn never delivers the + * settling event, so an idle run must not keep claiming a live pass. The + * producer only sets `pending` for the root agent run, so a subagent's + * compaction never renders here as a live root-level pass. + */ + compactionNotice?: CompactionNotice | null elapsedSeconds: number showTimer: boolean showStop: boolean @@ -111,6 +125,16 @@ const contextTone = (pct: number): StatusBarChipTone => { /** Bare percent label, shared by the full labels and the overflow-shorten step. */ const percentLabel = (pct: number): string => `${pct}%` +/** + * 'sm' prefixes the percent so it is not mistaken for part of a neighbouring + * chip (a bare '48%' beside a git '~3 +1' chip reads ambiguously). + */ +const contextPercentLabel = (pct: number): string => `ctx ${pct}%` + +/** '/' prefix for the sizes wide enough to render token counts. */ +const contextCountsPrefix = (usage: { used: number; max: number }): string => + `${formatStatusTokenCount(usage.used)}/${formatStatusTokenCount(usage.max)}` + /** `pct` must already be clamped to 0..100 by the caller. */ const buildUsageBar = (pct: number, length: number): string => { const filled = Math.round((pct / 100) * length) @@ -135,34 +159,78 @@ const barPercentLabel = ( : `${buildUsageBar(pct, barLength)} ${percentLabel(pct)}` } +/** + * Usage percent at which a size that can render token counts starts doing so, + * or null for the sizes that never render them. 'md' waits until 80% (its bar + * is narrower, so the counts cost proportionally more of the row) while 'lg' + * shows them from 70%. + */ +const contextCountsThreshold = ( + widthSize: StatusBarWidthSize, +): number | null => { + if (widthSize === 'lg') return 70 + if (widthSize === 'md') return 80 + return null +} + /** * Progressively shorter context labels for the overflow loop, widest first, so - * the lg token-count label gives up its counts before its bar instead of - * collapsing straight to the bare percent. Sizes that render no bar have the - * bare percent as their only form, so they return a single entry instead of - * repeating it. The first entry is also the widest form buildContextLabel - * renders, so the two cannot drift. + * a token-count label gives up its counts before its bar instead of collapsing + * straight to the bare percent. Sizes that render neither counts nor a bar have + * fewer entries rather than repeating one. The first entry is also the widest + * form buildContextLabel renders, so the two cannot drift. */ const contextLabelFallbacks = ( widthSize: StatusBarWidthSize, + usage: { used: number; max: number }, pct: number, -): [string, ...string[]] => - contextBarLength(widthSize) == null - ? [percentLabel(pct)] - : [barPercentLabel(widthSize, pct), percentLabel(pct)] +): [string, ...string[]] => { + const barPercent = barPercentLabel(widthSize, pct) + if (contextCountsThreshold(widthSize) != null) { + return [ + `${contextCountsPrefix(usage)} ${barPercent}`, + barPercent, + percentLabel(pct), + ] + } + if (widthSize === 'sm') { + return [contextPercentLabel(pct), percentLabel(pct)] + } + return [percentLabel(pct)] +} const buildContextLabel = ( widthSize: StatusBarWidthSize, usage: { used: number; max: number }, pct: number, ): string => { - const [barPercent] = contextLabelFallbacks(widthSize, pct) + const barPercent = barPercentLabel(widthSize, pct) + const countsThreshold = contextCountsThreshold(widthSize) - if (widthSize === 'lg' && pct >= 70) { - return `${formatStatusTokenCount(usage.used)}/${formatStatusTokenCount(usage.max)} ${barPercent}` + if (countsThreshold != null) { + return pct >= countsThreshold + ? `${contextCountsPrefix(usage)} ${barPercent}` + : barPercent } - return barPercent + return widthSize === 'sm' ? contextPercentLabel(pct) : barPercent +} + +/** + * Compaction notice label: the count alone at the narrow sizes, and a worded + * form at 'md'/'lg' that distinguishes a semantic compaction from an emergency + * mechanical trim. A pass that is still running reports the live state instead + * of a count, which may still be 0 when nothing has completed yet. + */ +const buildCompactionLabel = ( + widthSize: StatusBarWidthSize, + notice: Pick, +): string => { + const narrow = widthSize === 'xs' || widthSize === 'sm' + if (notice.pending) return narrow ? '⇲ …' : '⇲ compacting…' + if (narrow) return `⇲ ${notice.count}` + const verb = notice.action === 'mechanical_trim' ? 'trimmed' : 'compacted' + return `⇲ ${verb} ×${notice.count}` } /** @@ -248,6 +316,7 @@ export function selectStatusBarChips(input: SelectStatusBarChipsInput): { modelName, diffStats, indexChip, + compactionNotice, elapsedSeconds, showTimer, showStop, @@ -256,6 +325,7 @@ export function selectStatusBarChips(input: SelectStatusBarChipsInput): { const chips: StatusBarChip[] = [] let contextPct: number | null = null + let contextUsage: { used: number; max: number } | null = null const hasIndexError = indexChip?.tone === 'error' const omitContextForIndexError = widthSize === 'xs' && hasIndexError @@ -279,6 +349,28 @@ export function selectStatusBarChips(input: SelectStatusBarChipsInput): { label: buildContextLabel(widthSize, contextWindowUsage, contextPct), tone: contextTone(contextPct), }) + contextUsage = contextWindowUsage + } + + // A pending pass can only be live while the run is: an abort mid-compaction + // never delivers `settled` or a result, so once the run is idle the chip + // falls back to the settled form instead of reporting a compaction that is + // no longer running. A notice that never counted a completed pass then has + // nothing to report and is dropped entirely rather than rendering '⇲ 0'. + const compactionPending = compactionNotice?.pending === true && isActive + if (compactionNotice && (compactionPending || compactionNotice.count > 0)) { + chips.push({ + id: 'compaction', + label: buildCompactionLabel(widthSize, { + count: compactionNotice.count, + action: compactionNotice.action, + pending: compactionPending, + }), + // A live pass reads as in-progress, not as a failed one: a degraded + // earlier pass only tones the chip red once it has settled. + tone: + compactionNotice.degraded && !compactionPending ? 'error' : 'warning', + }) } if (indexChip) { @@ -335,15 +427,21 @@ export function selectStatusBarChips(input: SelectStatusBarChipsInput): { if (removeChip(chips, 'model')) continue if (removeChip(chips, 'git')) continue + // Mid-priority: the compaction notice is worth less than the context usage + // and the index readiness chip, but more than the model/cost/git chips. + if (removeChip(chips, 'compaction')) continue + const contextChip = chips.find((chip) => chip.id === 'context') - if (contextChip && contextPct != null) { + if (contextChip && contextPct != null && contextUsage != null) { // Step down one rendered form at a time (token counts first, then the // bar) so an intermediate label that would still fit is not skipped. // Compared by rendered width instead of scanning for bar glyphs so // shortening keeps working if the bar characters change. - const shorter = contextLabelFallbacks(widthSize, contextPct).find( - (label) => stringWidth(label) < stringWidth(contextChip.label), - ) + const shorter = contextLabelFallbacks( + widthSize, + contextUsage, + contextPct, + ).find((label) => stringWidth(label) < stringWidth(contextChip.label)) if (shorter != null) { contextChip.label = shorter continue @@ -391,8 +489,8 @@ export function selectStatusBarChips(input: SelectStatusBarChipsInput): { break } - // Chips are pushed in render order (context, index, git, model, cost, timer) - // and the overflow loop only removes or shortens them, so the array is - // already ordered here. + // Chips are pushed in render order (context, compaction, index, git, model, + // cost, timer) and the overflow loop only removes or shortens them, so the + // array is already ordered here. return { chips } } diff --git a/common/knowledge.md b/common/knowledge.md index 2bac0b05e0..f9a2963dce 100644 --- a/common/knowledge.md +++ b/common/knowledge.md @@ -12,6 +12,8 @@ This package contains code shared across the Openbuff monorepo, especially the l - Canonical `.agents/sessions//` planning artifacts may be read through the shared sensitive-path policy, while unrelated files under ignored `.agents` directories remain protected. - `printModeEventSchema` in `common/src/types/print-mode.ts` carries an additive `job_update` variant (`jobId`, `kind`, `state`, `sequence`, plus optional `label`, `outputDelta`, `exitCode`, `error`) for live background-job progress. It is additive-only: no existing variant was removed, renamed, or retyped, and consumers matching exhaustively on `event.type` must treat unknown variants as no-ops, so ignoring `job_update` is safe. - `printModeToolCallSchema` also carries optional `queued` (the write is waiting behind a per-path write barrier, which distinguishes "queued" from "running with no result yet") and `backgroundJobId` (correlates a `run_terminal_command` BACKGROUND launch to later `job_update` events for the same tool card). +- `printModeEventSchema` also carries an additive `context_compaction_status` variant (`state: 'started' | 'settled'`, required `runId` and `ancestorRunIds`, optional `agentId`/`contextTokens`/budget fields) reporting LIVE compaction state, separate from the terminal `context_compaction` result. Its emission gate is the window-derived semantic trigger alone: it is deliberately NOT conditioned on the agent having a `handleSteps` generator (a prompt-only template gets an equivalent runtime-driven pass), and it IS suppressed for an iteration where the transient anti-thrash advisory is active — a suppressed iteration emits NEITHER half, so absence of an event must not be read as "the trigger was never exceeded". Pair by `runId`, never `agentId`: subagent forwarding rewrites `agentId` to the nearest forwarding child, so at nesting depth >= 2 it does not identify the emitter. +- `AgentState.suppressSemanticCompaction` (`common/src/types/session-state.ts`) is a transient, LOOP-OWNED anti-thrash advisory, not a durable setting. `loopAgentSteps` sets it after consecutive semantic passes measurably reclaim no context space and resets it to `undefined` at loop entry, so a persisted or inherited `true` can never permanently disable semantic compaction for a later recoverable run. While set, both pruner paths (the runtime-driven pass and `spawn_agent_inline`) decline to spawn. Pruner identity is matched by BARE AGENT ID via `isContextPrunerAgentId`, so `context-pruner`, `acme/context-pruner`, `acme/context-pruner@1.2.3`, and underscore aliases are treated alike by the advisory, the inline transcript-write-back/silencing contract, and the `run-programmatic-step.ts` pruner param injection. Budgets are never lowered and pinned state is never dropped as a reaction. ## Key Areas @@ -56,6 +58,8 @@ This package contains code shared across the Openbuff monorepo, especially the l - _Knowledge refresh 2026-08-29 (followups): `JobRegistry.clear()` now resolves pending `wait()` promises instead of leaking them; `common/src/util/line-coordinates.ts` re-anchoring is additionally consumed by the SDK `replace_range` applicator ahead of its capability-bounds diagnostic._ +- _Knowledge refresh 2026-08-31: additive `context_compaction_status` print-mode variant and the transient loop-owned `AgentState.suppressSemanticCompaction` anti-thrash advisory (bare-agent-id pruner matching) documented under the shared provider/message boundaries._ + ## Scope Notes Openbuff is CLI/SDK-focused and local/BYOK. Do not add new dependencies from `common/` to hosted web, billing, credit, subscription, or BigQuery product surfaces. Provider-owned billing, quota, token usage, and OAuth flows may still be documented when they refer to the user's configured provider rather than an Openbuff-hosted product. diff --git a/common/src/types/print-mode.ts b/common/src/types/print-mode.ts index 28044152ad..233e40e4c5 100644 --- a/common/src/types/print-mode.ts +++ b/common/src/types/print-mode.ts @@ -186,14 +186,56 @@ const contextCategorySummarySchema = z.object({ userAssistantMessages: contextCategoryStatsSchema, }) +/** + * Context-compaction telemetry on the public `handleEvent` surface. + * + * ADDITIVE, non-breaking public-contract change: every field added after the + * original `before`/`after`/`removedCategories`/`retainedKnowledgeMemory`/ + * `recovery` core is optional, so persisted or replayed events emitted before + * that telemetry existed still validate and consumers that ignore the new + * fields keep their previous behavior. No consumer migration is required. The + * documented contract lives in `docs/agents-and-tools.md` under + * "Context-window-aware compaction budgets". + */ export const printModeContextCompactionSchema = z.object({ type: z.literal('context_compaction'), action: z.enum(['semantic_compaction', 'mechanical_trim']), + // Agent/run correlation for the loop that compacted. `loopAgentSteps` runs + // for the root turn, for foreground subagents, and for inline agents, so a + // consumer that keeps per-turn state MUST scope this payload — `runId` + // identifies the emitting run and `ancestorRunIds` is its lineage (empty + // ONLY for the root run). Those two are the authoritative keys: they are + // forwarded verbatim by every hop between the emitting run and the consumer. + // `agentId` is stamped by the emitting run but is NOT reliable end-to-end — + // the `spawn_agents` forwarding path rewrites it to the direct child's agent + // id on every forwarded event that is not text/tool/subagent, so for a run + // nested two or more levels deep the DELIVERED `agentId` names the nearest + // forwarding child rather than the emitter. Treat it as a display hint and + // key per-agent state off `runId`. All three are optional for compatibility + // with persisted/replayed events emitted before the fields existed; an event + // without `ancestorRunIds` predates nested-run attribution and stays + // root-attributed, which is the previous behavior. + runId: z.string().optional(), + ancestorRunIds: z.string().array().optional(), + agentId: z.string().optional(), resolvedContextWindowTokens: z.number().optional(), // Optional for compatibility with persisted/replayed events emitted before // model-aware compaction telemetry was added. triggerBudgetTokens: z.number().optional(), targetBudgetTokens: z.number().optional(), + // Also optional for compatibility with persisted/replayed events emitted + // before anti-thrash and fit-verification telemetry existed: how many + // compactions the EMITTING agent run (`runId` above) has performed — not a + // per-turn total across nested runs, so a consumer tracking one run's count + // must ignore counts correlated to another run — how many consecutive ones + // reclaimed almost nothing, and (mechanical trims only) whether the trimmed + // request actually fits the budget, by how many tokens it misses, and + // whether the escalation pass had to drop extra optional messages. + compactionCount: z.number().optional(), + consecutiveNoProgressCompactions: z.number().optional(), + shortfallTokens: z.number().optional(), + fitsBudget: z.boolean().optional(), + escalated: z.boolean().optional(), reason: z.string().optional(), before: z.object({ tokens: z.number(), @@ -221,6 +263,101 @@ export type PrintModeContextCompaction = z.infer< typeof printModeContextCompactionSchema > +/** + * Live context-compaction progress. ADDITIVE, non-breaking public-contract + * change: this is a NEW member of the {@link printModeEventSchema} + * discriminated union — no existing event variant is removed, renamed, or + * retyped, so no consumer migration or deprecation is required. In particular + * the terminal {@link printModeContextCompactionSchema} result event keeps its + * exact shape. + * + * PRODUCER CONTRACT. Every `loopAgentSteps` invocation emits this event — + * the root turn, foreground subagents, and inline agents alike — so every + * emission is correlated to its own run by the required `runId` / + * `ancestorRunIds` pair (`agentId` is supplied when the producer knows it, and + * is rewritten by subagent forwarding — see the CONSUMER CONTRACT below). + * `state: 'started'` is emitted immediately before a step that is likely to run + * semantic compaction (the pruner agent runs inline and is hidden from the + * CLI, so without this the user sees nothing until the result arrives). + * + * EMISSION GATE. The only trigger is the window-derived semantic compaction + * budget being exceeded by the pre-step context estimate. In particular: + * - It is NOT gated on the agent having a `handleSteps` generator. An + * orchestrator's generator spawns the pruner itself; a prompt-only + * template instead gets an equivalent runtime-driven pass, and both + * announce through this event. A consumer must therefore expect `started` + * from prompt-only agents too. + * - It IS suppressed for an iteration where the transient loop-owned + * anti-thrash advisory is active (`suppressSemanticCompaction` on the + * agent state, set after consecutive passes reclaimed no context space in + * the current turn and reset at loop entry). A suppressed iteration runs + * no pass and emits NEITHER half of the pair — it is not reported as a + * `started`/`settled` no-op — so a consumer must not infer "the trigger + * was never exceeded" from the absence of an event. + * - It is deliberately NOT gated on an explicit `maxContextLength` override, + * so an overridden run does not emit a `started` on every step. + * - For the runtime-driven (prompt-only) pass, the ordinary spawn-permission + * contract still applies AFTER the announcement: a template that does not + * declare `context-pruner` in its `spawnableAgents` announces a pass that + * then declines to spawn, so an announced pass is not a guarantee that any + * compaction happened. The terminal `context_compaction` result remains + * the only signal that context was actually reclaimed. + * + * `state: 'settled'` is emitted after the compaction branches for every + * `started` of the SAME `runId`, and again on that run's exit path when a step + * throws or is cancelled before reaching them, so a pass that decides NOT to + * compact cannot leave a live state stuck on screen. A run emits at most one + * unsettled `started` at a time. Both budgets and the pre-step context size are + * optional because they are informational only. + * + * CONSUMER CONTRACT. Pair `started` with `settled` by `runId`: a `settled` from + * one run never settles another run's `started`, so concurrent or nested loops + * cannot cross-settle each other. `ancestorRunIds` is empty ONLY for the root + * agent run (mirroring `printModeReasoningDelta`), so a consumer that renders + * live compaction state as root-level UI must ignore events whose + * `ancestorRunIds` is non-empty — otherwise a subagent's compaction shows up as + * a root-level 'Compacting context…' card. Consumers that nest per-agent UI can + * instead key off `runId`, which — like `ancestorRunIds` — is forwarded + * verbatim. `agentId` is NOT a per-agent key: the `spawn_agents` forwarding + * path overwrites it with the direct child's agent id on every forwarded event + * that is not text/tool/subagent, so a depth>=2 run's delivered `agentId` + * identifies the nearest forwarding child rather than the emitter. + * + * One case stays unreachable by construction: a user-initiated abort makes the + * SDK drop every post-abort event, so a consumer that renders `started` as live + * UI must be able to settle that state on its own — the CLI stamps its pending + * card with the producing process id and renders a replayed one as an + * interrupted pass, so a persisted transcript never replays a permanently live + * card. + * + * Forward-compatibility contract for `handleEvent` consumers: an exhaustive + * `switch`/match over `event.type` should treat unknown variants as no-ops + * (the SDK's own default handler only branches on `error`, and the CLI handler + * uses a catch-all `.otherwise`). Consumers that only care about the final + * compaction result can safely ignore `context_compaction_status` entirely. + */ +export const printModeContextCompactionStatusSchema = z.object({ + type: z.literal('context_compaction_status'), + state: z.enum(['started', 'settled']), + // Required agent/run correlation: this event is stateful, so an unattributed + // payload would be unusable — a consumer could not tell whose pending state + // a `settled` belongs to, nor whether a `started` came from the root run. + // `ancestorRunIds` is empty exactly for the root run. + runId: z.string(), + ancestorRunIds: z.string().array(), + // Emitting agent id as stamped by the producer. Display hint only: subagent + // forwarding rewrites it (see the CONSUMER CONTRACT above), so it is not a + // stable per-agent key at nesting depth >= 2. + agentId: z.string().optional(), + contextTokens: z.number().optional(), + resolvedContextWindowTokens: z.number().optional(), + triggerBudgetTokens: z.number().optional(), + targetBudgetTokens: z.number().optional(), +}) +export type PrintModeContextCompactionStatus = z.infer< + typeof printModeContextCompactionStatusSchema +> + /** * Live background-job update (M5). ADDITIVE, non-breaking public-contract * change: this is a NEW member of the {@link printModeEventSchema} @@ -270,6 +407,7 @@ export const printModeEventSchema = z.discriminatedUnion('type', [ printModeToolStartSchema, printModeContextCompactionSchema, + printModeContextCompactionStatusSchema, printModeContextWindowSchema, printModeJobUpdateSchema, printModeReasoningDeltaSchema, diff --git a/common/src/types/session-state.ts b/common/src/types/session-state.ts index 3d0259d350..1f04c1d0e8 100644 --- a/common/src/types/session-state.ts +++ b/common/src/types/session-state.ts @@ -272,6 +272,22 @@ export type AgentState = { * fullToolSurface when present. */ unlockedToolTiers?: string[] + /** + * Transient, loop-owned advisory set by `loopAgentSteps` when consecutive + * semantic compaction passes measurably failed to reclaim context space in + * the current turn. While true, BOTH pruner spawn paths — the runtime-driven + * pass and `spawn_agent_inline` — skip spawning the `context-pruner` instead + * of paying for another pass that thrashes. Pruner identity is matched by + * agent id, so a bare `context-pruner`, a publisher-qualified + * (`acme/context-pruner`) and a version-pinned (`acme/context-pruner@1.2.3`) + * declaration are all skipped alike. + * + * NEVER authoritative across turns: `loopAgentSteps` resets it to + * `undefined` at loop entry, so a persisted or inherited `true` can never + * disable semantic compaction for a later, recoverable run. Budgets are not + * lowered and pinned state is not dropped by this advisory. + */ + suppressSemanticCompaction?: boolean /** Ordered, resumable control-plane events that survive transcript compaction. */ orchestrationLedger?: OrchestrationLedgerV1 /** Spawn-bound writable path ownership used to prevent overlapping writers. */ diff --git a/docs/agents-and-tools.md b/docs/agents-and-tools.md index abee236c87..0876a20bc1 100644 --- a/docs/agents-and-tools.md +++ b/docs/agents-and-tools.md @@ -1386,11 +1386,19 @@ rather than blending into the orchestrator's turn. #### `context-pruner` silencing -When `agent_type === 'context-pruner'`, the handler suppresses **all** -forwarded chunks (including the child's `subagent_start` / -`subagent_finish` emitted by `executeSubagent`), so the pruner runs -silently and produces no TUI output. This is the existing behavior; the -`TODO` in source notes a future option may make this configurable. +When the resolved `agent_type` is the context-pruner, the handler suppresses +**all** forwarded chunks (including the child's `subagent_start` / +`subagent_finish` emitted by `executeSubagent`), so the pruner runs silently and +produces no TUI output. Identity here is matched by **bare agent id** through +`isContextPrunerAgentId`, not by exact string equality: `context-pruner`, +`acme/context-pruner`, `acme/context-pruner@1.2.3`, and underscore aliases such +as `context_pruner` all qualify and are silenced identically. That is a +widening of the previous exact `agent_type === 'context-pruner'` rule — see +"Spawned agents have three parent-history transfer modes" below for the rest of +the contract the same match governs (full parent transcript, forced +`inheritParentSystemPrompt`, transcript write-back, and the anti-thrash skip), +and `sdk/CHANGELOG.md` for the consumer migration note. The `TODO` in source +notes a future option may make the silencing configurable. #### Context-window-aware compaction budgets @@ -1400,18 +1408,18 @@ uses that value instead of treating every model as 200k-class: | Resolved window | Semantic trigger | History target | Provider-safe request limit | | --------------: | ---------------: | -------------: | --------------------------: | -| 8k | 2k | 1,680 | 4k | -| 16k | 6k | 3,360 | 8k | -| 32k | 18k | 10,080 | 24k | -| 64k | 42k | 23,520 | 56k | -| 128k | 96k | 72k | 112.64k | -| 200k | 160k | 84k | 176k | -| 262,144 | 209,715 | 110,100 | 230,687 | -| 500k | 400k | 210k | 440k | -| 1m | 800k | 420k | 880k | - -For 128k-and-larger windows, the trigger is bounded by both an 80% ratio and -explicit 32k–160k semantic headroom. The target is 42% of the resolved window, +| 8k | 2k | 1,400 | 4k | +| 16k | 5,600 | 2,800 | 8k | +| 32k | 16,800 | 8,400 | 24k | +| 64k | 39,200 | 19,600 | 56k | +| 128k | 89,600 | 72k | 112.64k | +| 200k | 140k | 72k | 176k | +| 262,144 | 183,500 | 91,750 | 230,687 | +| 500k | 350k | 175k | 440k | +| 1m | 700k | 350k | 880k | + +For 128k-and-larger windows, the trigger is bounded by both a 70% ratio and +explicit 32k–160k semantic headroom. The target is 35% of the resolved window, bounded to 72k–420k, and is split across assistant/tool-call summaries, user text, and tool-result facts. Unknown or invalid provider windows conservatively fall back to a 140k @@ -1431,12 +1439,20 @@ expanding the history after a successful primary request. The SDK still enforces the actual attempt's provider-safe limit at dispatch time. Explicit `maxContextLength` overrides are clamped and cannot widen the active model. -The pinned task contract retains a bounded 2,400-character beginning-and-end -view of the latest live user request plus a 1,000-character next action. This -preserves both initial requirements and trailing instructions when a user pastes -a long failure transcript. Reviewer blockers use larger bounded entries, while -inline reviewer internals are isolated before compaction so they do not consume -the orchestrator's history budget in the first place. +The pinned task contract retains a bounded beginning-and-end view of the latest +live user request plus a bounded next action. Both limits are _baseline_ caps at +the legacy 100k target (2,400 characters for the goal, 1,400 for the next +action) and are scaled by the same `clamp(targetTokens / 100_000, 0.5, 3.0)` +factor as the rest of the pinned block, so they are not fixed absolutes: the +default 200k-class window resolves a 72k semantic target (scale `0.72`) and +therefore caps the goal at ~1,728 and the next action at ~1,008 characters, +while a ~1m-token window resolves ~350k and reaches the 3x clamp (7,200 / +4,200). Size prompts against the scaled value for the active window, not against +the 100k baseline. This preserves both initial requirements and trailing +instructions when a user pastes a long failure transcript. Reviewer blockers use +larger bounded entries, while inline reviewer internals are isolated before +compaction so they do not consume the orchestrator's history budget in the first +place. Mechanical trimming remains a later emergency brake. Its provider-safe limit reserves 12% of the declared window, bounded to 8k–128k, for system prompts, @@ -1446,6 +1462,151 @@ telemetry and whether `` survived. The existing pinned control-plane, blocker, validation, review-receipt, and high-value finding extraction remains authoritative across repeated compaction. +The SDK's request-time brake (`getMessagesForModelContext`) subtracts this +request's counted system-prompt and tool-schema tokens from the resolved message +limit. Tool names, descriptions, and plain JSON Schemas are counted exactly, +while an opaque Zod/Standard Schema `inputSchema` is converted to a real JSON +Schema and counted from that projection, clamped between a per-tool floor and a +per-tool ceiling and falling back to the floor when the conversion is not +possible. Its `cache_emergency_trim` log and analytics payload separate the two +budgets explicitly: `maxTotalTokens` is the resolved _request_ budget, +`systemTokens` is the counted overhead, and `effectiveMessageBudgetTokens` is +the message-only budget actually applied. `triggerBudgetTokens` and +`targetBudgetTokens` — the pair consumers compare against message token counts, +including the `trigger=`/`target=` numbers in the log line — report that same +message-only budget, so no emitted field is ambiguous about which threshold +fired. With no counted overhead the message-only budget equals +`maxTotalTokens`, so those values are unchanged. + +`context_compaction` telemetry is additive on the public `handleEvent` surface. +`resolvedContextWindowTokens`, `triggerBudgetTokens`, `targetBudgetTokens`, +`compactionCount`, `consecutiveNoProgressCompactions`, `shortfallTokens`, +`fitsBudget`, and `escalated` are all optional in +`common/src/types/print-mode.ts`, so persisted or replayed events emitted before +this telemetry existed still validate and no consumer migration is required. +Consumers that ignore the new fields keep their previous behavior; the CLI +treats `fitsBudget: false` or two-or-more consecutive low-yield passes as a +degraded compaction. + +The CLI records each compaction pass as a typed `compaction` content block +(`CompactionContentBlock` in `cli/src/types/chat.ts`, rendered by +`CompactionBox`) instead of appending the previous concatenated `text` block. +Blocks are persisted to `chat-messages.json` and replayed on reload, so the +renderer sanitizes missing, non-finite, negative, or unknown-category values +coming back from an older session, and a replayed session that still holds the +old free-text notice keeps rendering as plain text. + +The pinned `` block's per-field caps scale with the resolved +semantic target budget instead of being fixed. The scale factor is +`targetTokens / 100_000`, clamped to `[0.5, 3.0]`, so the legacy 100k target is +the scale-factor-`1.0` baseline: 12 decisions, 25 files inspected, 25 edits, 12 +validation results, 12 review receipts, 16 post-edit anchors, 12 blockers, a +2,400-character goal, a 1,400-character next action, and 480-character entries. +That baseline is not a byte-identical replay of the previous fixed caps: this +change also raised three of them — decisions 8 -> 12, blockers 8 -> 12, and the +next action 1,000 -> 1,400 characters. Every other baseline cap is unchanged +from before: the 2,400-character goal, 25 files inspected, 25 edits, 12 +validation results, 12 review receipts, 16 post-edit anchors, and 480-character +entries. A ~1m-token window resolves to a ~350k target and +therefore retains 3x those counts, while a small BYOK window scales down to +0.5x. Per-entry character caps — including the goal and next-action caps above — +scale by the same factor, so the baseline numbers above hold only at the legacy +100k target. The default 200k-class window resolves a 72k target (scale `0.72`) +and yields 9 decisions, 18 files inspected, 18 edits, 9 validation results, 9 +review receipts, 12 post-edit anchors, 9 blockers, a ~1,728-character goal, a +~1,008-character next action, and ~346-character entries. + +Because that block is pinned verbatim and exempt from the normal budget cutoff, +deeper retention is bounded by a hard ceiling of +`max(1_500, floor(targetTokens * 0.25))` estimated tokens. When the block +exceeds it, entries are evicted oldest-first in a fixed priority order: +`postEditAnchors` -> `filesInspected` -> `decisions` -> `validationResults` -> +`reviewReceipts` -> `editsMade` -> `blockers`. `Goal:` and `Next Action:` are +never dropped; under extreme pressure they are truncated (beginning-and-end +preserving) toward floors of 480 and 240 characters respectively. These caps +live in the serialized `handleSteps` of `agents/context-pruner.ts`, outside the +machine-generated `` region that stays owned by +`scripts/generate-pruner-budgets.ts`. + +Live compaction state is reported by a separate additive +`context_compaction_status` event on the public `handleEvent` surface +(`common/src/types/print-mode.ts`), not by the `context_compaction` result. It +carries `state: 'started' | 'settled'`, the required agent/run correlation +`runId` and `ancestorRunIds` (plus an optional `agentId`), and optional +`contextTokens`, `resolvedContextWindowTokens`, `triggerBudgetTokens`, and +`targetBudgetTokens`. `packages/agent-runtime/src/run-agent-step.ts` emits +`started` immediately before the programmatic step whenever the window-derived +semantic trigger is exceeded, whether or not the agent has a `handleSteps` +generator: an orchestrator's generator spawns the pruner itself, while a +prompt-only template gets a runtime-driven pass +(`packages/agent-runtime/src/util/runtime-semantic-compaction.ts`). Two +additional gates apply, and both suppress the announcement as well as the pass: +the transient loop-owned anti-thrash advisory (`suppressSemanticCompaction`, +set after consecutive passes reclaim no space and reset at loop entry), and — for +the runtime-driven pass only — the ordinary spawn-permission contract, so a +prompt-only template that does not declare `context-pruner` in its +`spawnableAgents` announces a pass that then declines to spawn. Emission is +deliberately not gated on an explicit `maxContextLength` override. +`settled` is emitted after both compaction branches whenever a `started` was +emitted, and again on the run's exit path when a step throws or is cancelled +before reaching them, so a pass that decides not to compact cannot leave a +pending state on screen. As with `job_update`, consumers should treat unknown +event variants as no-ops; no consumer migration is required. + +Because every `loopAgentSteps` invocation emits these events — the root turn, +foreground subagents, and inline agents alike — the protocol is scoped by run +for both producers and consumers. Each emission carries the emitting run's own +`runId` and `ancestorRunIds`, `ancestorRunIds` is empty **only** for the root +run (the same convention as `reasoning_delta`), and a run has at most one +unsettled `started` at a time. Consumers must therefore pair `started` with the +`settled` of the **same** `runId`, so concurrent or nested loops cannot +cross-settle each other, and a consumer that renders live compaction state as +root-level UI must ignore events with a non-empty `ancestorRunIds` instead of +showing a subagent's compaction as a root-level card. The sibling +`compactionCount` on the `context_compaction` result is scoped the same way: it +counts the emitting run's own passes, not a per-turn total across nested runs, +so only the root run's count may be adopted as the turn total. The result event +carries the same three correlation fields, but optionally, so persisted or +replayed events emitted before they existed still validate and stay +root-attributed. + +Only `runId` and `ancestorRunIds` are authoritative on the delivered payload; +the optional `agentId` is not. The `spawn_agents` forwarding path in +`packages/agent-runtime/src/tools/handlers/tool/spawn-agents.ts` rewrites +`agentId` to the direct child's agent id on every forwarded event that is not +`text`, `tool_call`/`tool_result`, or `subagent_start`/`subagent_finish` — which +includes both compaction events — while spreading the rest of the payload +unchanged. A depth-1 child's events therefore still carry the emitter's own +agent id, but for a run nested two or more levels deep the delivered `agentId` +names the nearest forwarding child rather than the emitting agent. `runId` and +`ancestorRunIds` survive every hop, so consumers must key per-agent compaction +state off `runId`, use `ancestorRunIds` for lineage, and treat `agentId` as a +best-effort display hint. + +In the CLI, a root-run `started` appends a pending `compaction` block stamped +with that `runId` (`CompactionContentBlock.runId`) that the terminal +`context_compaction` result of the same run replaces in place (a second result +in the same iteration appends instead), a root-run `settled` drops only that +run's still-pending block, and `handleFinish` clears any stray pending block — +including uncorrelated ones replayed from an older session — at the turn +boundary. A subagent's status event is ignored for root-level state, and a +subagent result contributes one completed pass to the status-bar count without +overwriting the root turn's total or clearing its live chip. + +One cleanup path stays unreachable by construction: a user-initiated abort makes +the SDK drop every post-abort event, so neither `settled` nor `handleFinish` +can run, and the turn's blocks are persisted to `chat-messages.json` afterwards. +A pending block is therefore stamped with `liveSessionId` +(`CLI_LIVE_SESSION_ID` in `cli/src/types/chat.ts`, an opaque per-process id) and +is only rendered as live while that stamp matches the current process. +A replayed pending block — an absent stamp from an older CLI, or a foreign one +from the aborted session — renders as "Compaction interrupted" with its result +lines still suppressed, never as a permanent "Compacting context…" card. The +status-bar chip applies the same rule live: `pending` is only honored while the +run is active, so an aborted turn falls back to the settled `⇲ compacted ×N` +form and drops the chip entirely when no pass ever completed. The field is +optional and additive, so persisted blocks from before it round-trip unchanged. + Operational memory is also stored as versioned typed task memory with a revision CAS and checksum. Requirements, decisions, blockers, files, edits, validation, review receipts, workspace revision, and next actions survive @@ -1460,8 +1621,31 @@ Spawned agents have three parent-history transfer modes: `none`, `pinned`, and specialists default to the bounded `pinned` mode, which transfers only the newest `` and `` blocks. The context-pruner remains the explicit `full`-history exception because editing -the parent transcript is its job. Custom history editors must opt in with both -`messageHistoryMode: 'full'` and `propagateMessageHistoryChanges: true`. +the parent transcript is its job, and `spawn_agent_inline` matches that +exception by bare agent id rather than by exact string equality: +`isContextPrunerAgentId` normalizes the declared `agent_type` and compares only +the bare segment, so a publisher-qualified or version-pinned declaration — +`acme/context-pruner`, `acme/context-pruner@1.2.3`, or an underscore alias — is +treated identically to the bare `context-pruner`. Every agent that matches gets +the whole pruner contract: the full parent transcript +(`messageHistoryMode: 'full'`), forced `inheritParentSystemPrompt: true`, fully +silenced child output, and write-back of the child's transcript over the +parent's `messageHistory`. The operative pruner params the runtime injects into +a serialized `handleSteps` are matched the same way (`isContextPrunerAgentId` +over the template id and the resolved `agentType` in +`packages/agent-runtime/src/run-programmatic-step.ts`): the model-aware +`semanticBudget`, the parent's `taskMemory` and `workspaceState`, and the +caller's `maxContextLength` clamped to the resolved model message limit. That +injection is what lets the pruner's transactional `set_messages` publish a +matching `expectedTaskMemoryRevision`; keying it off the bare literal instead +would leave a pinned pruner on its embedded budget arithmetic and a `-1` +revision, so `commitTaskMemory` would reject the transcript replacement for +exactly the spellings documented as equivalent. The same match also governs the transient +`suppressSemanticCompaction` anti-thrash skip, which declines a pruner spawn — +after its input is validated — for the rest of a turn whose consecutive +semantic passes reclaimed no context space. Custom history editors must opt in +with both `messageHistoryMode: 'full'` and +`propagateMessageHistoryChanges: true`. Ordinary inline children have independent system prompts, tools, and `agentContext`, and their private transcripts are never copied back into the parent. Structured handoffs and child results are bounded before entering the diff --git a/evals/compaction-retention/README.md b/evals/compaction-retention/README.md new file mode 100644 index 0000000000..9a38fdfcab --- /dev/null +++ b/evals/compaction-retention/README.md @@ -0,0 +1,65 @@ +# Compaction retention eval + +Deterministic (no-LLM) scenario measuring **compaction retention quality**: +whether the context pruner's pinned `` block still carries the +evidence a run needs to continue, and how that scales with the model context +window. Drives `agents/context-pruner.ts` directly — `handleSteps` is invoked +with a mock `agentState` and the resulting `set_messages` payload is measured, +exactly as `agents/__tests__/context-pruner.test.ts` does. No network, no LLM, +no filesystem access. + +The block sections under measurement are `Goal:`, `Decisions:`, +`Files Inspected:`, `Edits Made:`, `Validation Results:`, `Review Receipts:`, +`Post-Edit Anchors:`, `Blockers:` and `Next Action:`. Per-field caps are +baselines at a 100k semantic target scaled by +`clamp(targetTokens / 100_000, 0.5, 3.0)`, and the whole block is additionally +bounded by `max(1_500, floor(target * 0.25))` estimated tokens with oldest-first +eviction; `Goal:` and `Next Action:` truncate toward their floors rather than +being dropped. + +## Scenarios + +| ID | Claim under test | +| --- | ---------------------------------------------------------------------------------------------- | +| S1 | 140k trigger / 100k target (scale 1.0) records the reference retention counts | +| S2 | An 8k-class BYOK window stays under the block ceiling and never drops `Goal:` / `Next Action:` | +| S3 | A ~1M-token window retains strictly more list entries than S1 | +| S4 | A trailing instruction after a long pasted diagnostic survives in the pinned goal | +| S5 | An open reviewer blocker and the review receipt fingerprint both survive a pass | + +## Metrics + +Each scenario records named metrics into a module-level `metrics` record and the +suite prints a compact table at the end of the run. Assertions are the +regression floor; the metrics are the deliverable. + +| Metric | Meaning | +| --------------------- | ---------------------------------------------------------------------------- | +| `recallRate` | Fraction of the seeded must-survive facts still present in the emitted block | +| `blockTokens` | Estimated tokens of the emitted `` block | +| `retainedEntryCounts` | Per-section ` -` entry counts (decisions, files, edits, validation, …) | +| `compressionRatio` | Emitted summary tokens over input history tokens | + +The must-survive set is a specific inspected file path, a specific decision +line, an open blocker line and the trailing next action; S4 additionally +requires the trailing instruction and S5 the review receipt fingerprint. + +## Run + +```bash +bun --cwd=evals test compaction-retention +``` + +No wiring change is needed: `evals/package.json` runs `bun test` (which picks up +any `**/*.test.ts` under `evals/`) and `evals/tsconfig.json` includes `**/*.ts`. + +## Out of scope / deferred + +- Pruner behavior other than the pinned block (extractive entry walk budgets, + ``) — covered by `agents/__tests__/context-pruner.test.ts`. +- Cross-session persistence of the same facts — covered by the sibling + `evals/memory-retention/` eval and the runtime task-memory suite. +- Model-quality questions (does the model _act_ on retained evidence) — needs an + LLM-in-the-loop eval and is intentionally excluded here. +- Retention under repeated compaction cycles at each window size (multi-pass + drift), which would extend S1–S3 into a cycle-count sweep. diff --git a/evals/compaction-retention/scenario.test.ts b/evals/compaction-retention/scenario.test.ts new file mode 100644 index 0000000000..30a0eb3fcf --- /dev/null +++ b/evals/compaction-retention/scenario.test.ts @@ -0,0 +1,635 @@ +/** + * Deterministic compaction retention eval (no LLM, no network, no disk I/O). + * + * Drives `agents/context-pruner.ts` directly — the same way + * `agents/__tests__/context-pruner.test.ts` does — and measures how much of the + * evidence a run needs to continue survives inside the pinned + * `` block, and how that scales with the model context window. + * + * - S1 baseline : 140k trigger / 100k target (scale 1.0) reference counts. + * - S2 small window: 8k-class BYOK window stays under the pinned-block ceiling + * while never dropping Goal / Next Action. + * - S3 large window: ~1M-token window buys strictly deeper list retention. + * - S4 trailing : long pasted diagnostic + short trailing instruction; the + * trailing instruction survives (beginning-and-end goal). + * - S5 blocker : open reviewer blocker + structured review receipt survive + * a compaction pass with the receipt fingerprint intact. + * + * The metrics are the deliverable; the assertions are the regression floor. + */ +import { afterAll, describe, expect, test } from 'bun:test' + +import contextPruner from '../../agents/context-pruner' + +import type { AgentState } from '../../agents/types/agent-definition' +import type { + JSONValue, + Message, + ToolMessage, +} from '../../agents/types/util-types' + +/** + * Estimated characters per token. This is the pruner's own estimation + * heuristic (`CHARS_PER_TOKEN`), not one of its retention cap constants: the + * eval needs it to report `blockTokens` in the same unit the pruner budgets in. + */ +const CHARS_PER_TOKEN = 3 + +const estimateTokens = (text: string): number => + Math.ceil(text.length / CHARS_PER_TOKEN) + +// ============================================================================= +// Seeded must-survive facts +// ============================================================================= + +const SEEDED_GOAL_HEAD = + 'GOAL_HEAD: measure pinned knowledge_memory retention across context windows.' +const SEEDED_TRAILING_INSTRUCTION = + 'TRAILING_INSTRUCTION: report retention metrics without editing the pruner.' +const SEEDED_INSPECTED_PATH = + 'packages/agent-runtime/src/util/context-pruning.ts' +const SEEDED_DECISION = + 'Decision: pin the knowledge_memory block verbatim instead of re-deriving it per pass.' +const SEEDED_BLOCKER = + 'BLOCKING: restore the deterministic edit guard in src/guard.ts before finalizing.' +const SEEDED_NEXT_ACTION = + 'Re-run the compaction retention suite and repair the failing retention floor.' +const SEEDED_RECEIPT_FINGERPRINT = 'f'.repeat(64) + +/** + * The pinned-block caps keep the newest entries (`slice(-cap)`), so every + * must-survive fact is seeded last within its own section. + */ +const INSPECTED_PATHS = [ + ...Array.from( + { length: 39 }, + (_, index) => + `packages/agent-runtime/src/retention/module-${index}/inspected-${index}.ts`, + ), + SEEDED_INSPECTED_PATH, +] +const DECISION_LINES = [ + ...Array.from( + { length: 19 }, + (_, index) => + `Decision: keep retention rationale ${index} for depth measurement.`, + ), + SEEDED_DECISION, +] +const VALIDATION_COMMAND_COUNT = 20 +const EDITED_PATHS = [ + 'packages/agent-runtime/src/retention/edited-a.ts', + 'packages/agent-runtime/src/retention/edited-b.ts', + 'packages/agent-runtime/src/retention/edited-c.ts', +] + +const MUST_SURVIVE_FACTS = [ + SEEDED_INSPECTED_PATH, + SEEDED_DECISION, + SEEDED_BLOCKER, + SEEDED_NEXT_ACTION, +] + +// ============================================================================= +// Message factories (mirrors agents/__tests__/context-pruner.test.ts) +// ============================================================================= + +const createMessage = ( + role: 'user' | 'assistant', + content: string, +): Message => ({ + role, + content: [ + { + type: 'text', + text: content, + }, + ], +}) + +const createToolCallMessage = ( + toolCallId: string, + toolName: string, + input: Record, +): Message => ({ + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId, + toolName, + input, + }, + ], +}) + +const createToolResultMessage = ( + toolCallId: string, + toolName: string, + value: JSONValue, +): ToolMessage => ({ + role: 'tool', + toolCallId, + toolName, + content: [ + { + type: 'json', + value, + }, + ], +}) + +/** Canonical successful `read_files` results — the pruner only records reads it can prove. */ +const createSuccessfulReadMessages = (paths: string[]): Message[] => + paths.flatMap((path, index) => [ + createToolCallMessage(`retention-read-${index}`, 'read_files', { + paths: [path], + }), + createToolResultMessage(`retention-read-${index}`, 'read_files', { + kind: 'read_files_result', + version: 1, + status: 'ok', + summary: { requested: 1, ok: 1, partial: 0, failed: 0, uniquePaths: 1 }, + results: [ + { + selector: 'file', + requestIndex: 0, + path, + status: 'ok', + content: 'export const value = 1', + complete: true, + template: false, + }, + ], + }), + ]) + +const createValidationMessages = (count: number): Message[] => + Array.from({ length: count }, (_, index) => index).flatMap((index) => { + const command = `bun test retention-suite-${index}` + return [ + createToolCallMessage(`retention-cmd-${index}`, 'run_terminal_command', { + command, + }), + createToolResultMessage( + `retention-cmd-${index}`, + 'run_terminal_command', + { + exitCode: 0, + command, + }, + ), + ] + }) + +/** + * Fully correlated committed mutation results, so the pruner persists both + * `Edits Made` and `Post-Edit Anchors` (it rejects uncorrelated receipts). + */ +const createCommittedEditMessages = (paths: string[]): Message[] => + paths.flatMap((filePath, index) => { + const operationId = `retention-edit-${index}` + const receiptId = `${operationId}:receipt` + const afterHash = `sha256:${`${index}`.padStart(64, 'a')}` + const action: Record = { + actionId: `${operationId}:0`, + index: 0, + action: 'update', + path: filePath, + outcome: 'applied', + beforeHash: `sha256:${`${index}`.padStart(64, 'b')}`, + afterHash, + editAnchor: { + startLine: 1, + endLine: 12, + contentHash: afterHash, + readCapability: `cap.v3.retention-anchor-${index}`, + }, + } + const result: Record = { + kind: 'file_mutation_result', + version: 1, + operationId, + receiptId, + outcome: 'applied', + authorityTier: 'portable_path', + actions: [action], + errors: [], + freshCapabilities: [], + authorityReceipt: { + kind: 'commit_receipt', + version: 1, + receiptId, + operationId, + callId: `${operationId}:call`, + authorityTier: 'portable_path', + status: 'committed', + actions: [{ ...action, status: 'committed' }], + finalHashes: { [filePath]: afterHash }, + }, + } + return [ + createToolCallMessage(operationId, 'str_replace', { + path: filePath, + replacements: [], + }), + createToolResultMessage(operationId, 'str_replace', result), + ] + }) + +/** Structured reviewer output: BLOCKING verdict with an attested snapshot fingerprint. */ +const createReviewReceiptMessages = (): Message[] => [ + createToolCallMessage('retention-review', 'spawn_agent_inline', { + agent_type: 'code-reviewer', + }), + createToolResultMessage('retention-review', 'spawn_agent_inline', { + schemaVersion: 3, + family: 'reviewer', + verdict: 'BLOCKING', + snapshotFingerprint: SEEDED_RECEIPT_FINGERPRINT, + reviewedFiles: [SEEDED_INSPECTED_PATH], + coverage: 'covered', + dimensions: { correctness: 'block' }, + findings: [ + { + id: 'code-reviewer:correctness:edit-guard', + severity: 'critical', + dimension: 'correctness', + summary: 'The deterministic edit guard was removed from src/guard.ts.', + evidence: ['src/guard.ts no longer checks the commit receipt'], + correction: 'Restore the guard before finalizing.', + }, + ], + requirementCoverage: [], + }), +] + +/** + * One seeded history shared by every scenario: a tagged live goal, 40 proven + * reads, 20 decision lines, 3 committed edits, 20 validation runs and an open + * blocker. Every scenario's `contextTokenCount` is set well above its resolved + * trigger so the pruner always compacts (RISK1). + */ +const seedHistory = ( + options: { goalText?: string; includeReviewReceipt?: boolean } = {}, +): Message[] => [ + { + ...createMessage('user', options.goalText ?? SEEDED_GOAL_HEAD), + tags: ['USER_PROMPT'], + }, + ...createSuccessfulReadMessages(INSPECTED_PATHS), + createMessage('assistant', DECISION_LINES.join('\n')), + ...createCommittedEditMessages(EDITED_PATHS), + ...createValidationMessages(VALIDATION_COMMAND_COUNT), + createMessage('assistant', SEEDED_BLOCKER), + ...(options.includeReviewReceipt ? createReviewReceiptMessages() : []), +] + +// ============================================================================= +// Harness: drive handleSteps and read the set_messages payload +// ============================================================================= + +interface ScenarioBudget { + /** Reported context usage; must exceed the resolved trigger. */ + contextTokenCount: number + contextWindowTokens?: number + /** + * Explicit budget injection. A window value alone does not pin the resolved + * target (the pruner clamps via min/max target bounds), so scenarios that + * need an exact scale factor inject the budget directly. + */ + semanticBudget?: { triggerBudgetTokens: number; targetBudgetTokens: number } + nextRequiredAction?: string +} + +function createMockAgentState( + messageHistory: Message[], + contextTokenCount: number, +): AgentState { + return { + agentId: 'compaction-retention-eval', + runId: 'compaction-retention-run', + parentId: undefined, + messageHistory, + output: undefined, + systemPrompt: '', + toolDefinitions: {}, + contextTokenCount, + } +} + +const runHandleSteps = (messages: Message[], budget: ScenarioBudget): any[] => { + const agentState = createMockAgentState(messages, budget.contextTokenCount) + if (budget.contextWindowTokens !== undefined) { + agentState.contextWindowTokens = budget.contextWindowTokens + } + agentState.base2ActiveWork = { + nextRequiredAction: budget.nextRequiredAction ?? SEEDED_NEXT_ACTION, + } + const logger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + } + const generator = contextPruner.handleSteps!({ + agentState, + logger, + params: { + ...(budget.semanticBudget + ? { semanticBudget: budget.semanticBudget } + : {}), + }, + }) + const results: any[] = [] + let result = generator.next() + while (!result.done) { + if (typeof result.value === 'object') { + results.push(result.value) + } + result = generator.next() + } + return results +} + +// ============================================================================= +// Section parsing (anchored on the exact header text and " - " entry prefix) +// ============================================================================= + +const SECTION_HEADERS = { + decisions: 'Decisions', + filesInspected: 'Files Inspected', + editsMade: 'Edits Made', + validationResults: 'Validation Results', + reviewReceipts: 'Review Receipts', + postEditAnchors: 'Post-Edit Anchors', + blockers: 'Blockers', +} as const + +type SectionKey = keyof typeof SECTION_HEADERS + +const extractKnowledgeMemoryBlock = (content: string): string => + content.match(/[\s\S]*?<\/knowledge_memory>/)?.[0] ?? '' + +/** Count ` - entry` lines under one section header, stopping at the next header. */ +const countSectionEntries = (block: string, header: string): number => { + const section = block.split(`${header}:\n`)[1] ?? '' + let count = 0 + for (const line of section.split('\n')) { + if (!line.startsWith(' - ')) break + count += 1 + } + return count +} + +// ============================================================================= +// Metrics +// ============================================================================= + +interface ScenarioMetrics { + id: string + claim: string + recallRate: number + missingFacts: string[] + blockTokens: number + summaryTokens: number + historyTokens: number + compressionRatio: number + retainedEntryCounts: Record +} + +interface Measurement extends ScenarioMetrics { + block: string +} + +const metrics: ScenarioMetrics[] = [] + +function measureRetention(args: { + id: string + claim: string + messages: Message[] + budget: ScenarioBudget + mustSurvive?: string[] +}): Measurement { + const results = runHandleSteps(args.messages, args.budget) + expect(results).toHaveLength(1) + expect(results[0].toolName).toBe('set_messages') + + const summaryText: string = results[0].input.messages[0].content[0].text + // RISK1: a history under the resolved trigger yields no summary at all, and + // every metric below would silently measure an empty block. + expect(summaryText).toContain('') + const block = extractKnowledgeMemoryBlock(summaryText) + expect(block).not.toBe('') + + const mustSurvive = args.mustSurvive ?? MUST_SURVIVE_FACTS + const missingFacts = mustSurvive.filter((fact) => !block.includes(fact)) + const historyTokens = estimateTokens(JSON.stringify(args.messages)) + const summaryTokens = estimateTokens(summaryText) + const retainedEntryCounts = Object.fromEntries( + Object.entries(SECTION_HEADERS).map(([key, header]) => [ + key, + countSectionEntries(block, header), + ]), + ) as Record + + const recorded: ScenarioMetrics = { + id: args.id, + claim: args.claim, + recallRate: (mustSurvive.length - missingFacts.length) / mustSurvive.length, + missingFacts, + blockTokens: estimateTokens(block), + summaryTokens, + historyTokens, + compressionRatio: summaryTokens / historyTokens, + retainedEntryCounts, + } + metrics.push(recorded) + return { ...recorded, block } +} + +/** Extract the pinned `Goal:` body, stopping at the next section header. */ +const extractGoalSection = (block: string): string | undefined => + block.match( + /Goal:\n {2}([\s\S]*?)\n(?:Decisions:|Files Inspected:|Edits Made:|Validation Results:|Review Receipts:|Post-Edit Anchors:|Blockers:|Next Action:|<\/knowledge_memory>)/, + )?.[1] + +// ============================================================================= +// Scenarios +// ============================================================================= + +/** S1/S4/S5 baseline: no window and no explicit limit => 140k trigger / 100k target. */ +const BASELINE_BUDGET: ScenarioBudget = { contextTokenCount: 200_000 } + +describe('compaction retention scenario', () => { + afterAll(() => { + const header = [ + 'scenario', + 'recall', + 'blockTok', + 'compress', + 'retainedEntries', + ] + const rows = metrics.map((metric) => [ + metric.id, + metric.recallRate.toFixed(2), + String(metric.blockTokens), + metric.compressionRatio.toFixed(3), + Object.entries(metric.retainedEntryCounts) + .map(([key, value]) => `${key}=${value}`) + .join(' '), + ]) + const widths = header.map((cell, column) => + Math.max(cell.length, ...rows.map((row) => row[column].length)), + ) + const renderRow = (cells: string[]): string => + cells.map((cell, column) => cell.padEnd(widths[column])).join(' ') + console.log('\ncompaction retention metrics') + console.log(renderRow(header)) + for (const row of rows) console.log(renderRow(row)) + }) + + test('S1 baseline records reference retention counts at the 100k target', () => { + const measurement = measureRetention({ + id: 'S1-baseline', + claim: '140k trigger / 100k target retains the scale-1.0 baseline counts', + messages: seedHistory(), + budget: BASELINE_BUDGET, + }) + + expect(measurement.recallRate).toBe(1) + expect(measurement.missingFacts).toEqual([]) + // scale = clamp(100_000 / 100_000, 0.5, 3.0) = 1.0 + expect(measurement.retainedEntryCounts.filesInspected).toBe(25) // round(25 * 1.0) = 25 of 40 seeded + expect(measurement.retainedEntryCounts.decisions).toBe(12) // round(12 * 1.0) = 12 of 20 seeded + expect(measurement.retainedEntryCounts.validationResults).toBe(12) // round(12 * 1.0) = 12 of 20 seeded + expect(measurement.retainedEntryCounts.editsMade).toBe(3) // 3 seeded, under round(25 * 1.0) = 25 + expect(measurement.retainedEntryCounts.postEditAnchors).toBe(3) // 3 seeded, under round(16 * 1.0) = 16 + expect(measurement.retainedEntryCounts.blockers).toBe(1) + // ceiling = max(1_500, floor(100_000 * 0.25)) = 25_000 estimated tokens + expect(measurement.blockTokens).toBeLessThanOrEqual(25_000) + expect(measurement.compressionRatio).toBeLessThan(1) + }) + + test('S2 small window bounds the pinned block but never drops the task contract', () => { + // 8k-class BYOK window. The explicit budget pins the resolved target so the + // scale factor is exact: clamp(2_500 / 100_000, 0.5, 3.0) = 0.5. + const measurement = measureRetention({ + id: 'S2-small-window', + claim: + '8k-class window stays under the pinned-block ceiling and keeps Goal + Next Action', + messages: seedHistory(), + budget: { + contextTokenCount: 20_000, + contextWindowTokens: 8_000, + semanticBudget: { + triggerBudgetTokens: 2_800, + targetBudgetTokens: 2_500, + }, + }, + }) + + // ceiling = max(1_500, floor(2_500 * 0.25) = 625) = 1_500 estimated tokens + expect(measurement.blockTokens).toBeLessThanOrEqual(1_500) + // The task contract is truncated toward its floor, never dropped. + expect(measurement.block).toContain('Goal:') + expect(measurement.block).toContain(SEEDED_GOAL_HEAD) + expect(measurement.block).toContain('Next Action:') + expect(measurement.block).toContain(SEEDED_NEXT_ACTION) + // Regression floor only: deeper list evidence is legitimately evicted here. + expect(measurement.recallRate).toBeGreaterThanOrEqual(0.5) + }) + + test('S3 large window buys strictly deeper list retention than the baseline', () => { + const baseline = measureRetention({ + id: 'S3-baseline-reference', + claim: 'baseline reference re-measured for the S3 comparison', + messages: seedHistory(), + budget: BASELINE_BUDGET, + }) + // ~1M-token window. Explicit budget pins the target so the scale factor is + // exact: clamp(350_000 / 100_000, 0.5, 3.0) = 3.0 (upper clamp). + const large = measureRetention({ + id: 'S3-large-window', + claim: '~1M-token window retains strictly more list entries than S1', + messages: seedHistory(), + budget: { + contextTokenCount: 800_000, + contextWindowTokens: 1_000_000, + semanticBudget: { + triggerBudgetTokens: 700_000, + targetBudgetTokens: 350_000, + }, + }, + }) + + expect(large.recallRate).toBe(1) + // Caps at scale 3.0 exceed the seeded volume, so every seeded entry is kept: + // files min(40, round(25 * 3.0) = 75), decisions min(20, round(12 * 3.0) = 36). + expect(large.retainedEntryCounts.filesInspected).toBe(40) + expect(large.retainedEntryCounts.decisions).toBe(20) + expect(large.retainedEntryCounts.validationResults).toBe(20) + for (const section of [ + 'filesInspected', + 'decisions', + 'validationResults', + ] as const) { + expect(large.retainedEntryCounts[section]).toBeGreaterThan( + baseline.retainedEntryCounts[section], + ) + } + expect(large.blockTokens).toBeGreaterThan(baseline.blockTokens) + }) + + test('S4 trailing instruction after a long pasted diagnostic survives', () => { + const goalText = [ + SEEDED_GOAL_HEAD, + 'pasted diagnostic line '.repeat(400), + SEEDED_TRAILING_INSTRUCTION, + ].join('\n') + const measurement = measureRetention({ + id: 'S4-trailing-instruction', + claim: + 'a trailing instruction after a long pasted diagnostic survives in the pinned Goal', + messages: seedHistory({ goalText }), + budget: BASELINE_BUDGET, + mustSurvive: [...MUST_SURVIVE_FACTS, SEEDED_TRAILING_INSTRUCTION], + }) + + // Beginning-and-end preservation: the pinned goal keeps both ends of a + // request far longer than its own cap, so the trailing instruction is not + // lost behind the pasted diagnostic. + const goal = extractGoalSection(measurement.block) + expect(goal).toBeDefined() + expect(goal!).toContain(SEEDED_GOAL_HEAD) + expect(goal!).toContain(SEEDED_TRAILING_INSTRUCTION) + // Goal cap at the 100k baseline target: round(2_400 * 1.0) = 2_400 chars, + // against a ~9.7k-character request, so the middle is provably dropped. + expect(goal!.length).toBeLessThanOrEqual(2_400) + expect(goal!.length).toBeLessThan(goalText.length / 2) + expect(measurement.recallRate).toBe(1) + }) + + test('S5 open blocker and structured review receipt survive a pass', () => { + const measurement = measureRetention({ + id: 'S5-blocker-receipt', + claim: + 'an open reviewer blocker and the review receipt fingerprint both survive', + messages: seedHistory({ includeReviewReceipt: true }), + budget: BASELINE_BUDGET, + mustSurvive: [...MUST_SURVIVE_FACTS, SEEDED_RECEIPT_FINGERPRINT], + }) + + expect(measurement.recallRate).toBe(1) + expect(measurement.block).toContain(SEEDED_BLOCKER) + expect(measurement.block).toContain('Review Receipts:') + expect(measurement.block).toContain( + `snapshot=${SEEDED_RECEIPT_FINGERPRINT}`, + ) + expect( + measurement.retainedEntryCounts.reviewReceipts, + ).toBeGreaterThanOrEqual(1) + // The reviewer's BLOCKING finding is pinned alongside the seeded blocker. + expect(measurement.retainedEntryCounts.blockers).toBeGreaterThanOrEqual(2) + }) +}) diff --git a/packages/agent-runtime/src/__tests__/loop-agent-steps.test.ts b/packages/agent-runtime/src/__tests__/loop-agent-steps.test.ts index 1f9d22dff4..fb71938501 100644 --- a/packages/agent-runtime/src/__tests__/loop-agent-steps.test.ts +++ b/packages/agent-runtime/src/__tests__/loop-agent-steps.test.ts @@ -11,6 +11,9 @@ import thinker from '../../../../agents/thinker/thinker' import { loopAgentSteps } from '../run-agent-step' import { clearAgentGeneratorCache } from '../run-programmatic-step' import { PLACEHOLDER } from '../templates/types' +import { handleSpawnAgentInline } from '../tools/handlers/tool/spawn-agent-inline' +import { countTokens } from '../util/token-counter' +import { commitTaskMemory } from '../util/task-memory' import { createToolCallChunk, mockFileContext } from './test-utils' import type { AgentTemplate } from '../templates/types' @@ -319,6 +322,9 @@ describe('loopAgentSteps', () => { targetBudgetTokens: 100_000, reason: expect.stringContaining('explicit maxContextLength override'), retainedKnowledgeMemory: true, + // First compaction of the turn, and it reclaimed space. + compactionCount: 1, + consecutiveNoProgressCompactions: 0, }), ) expect(events).not.toContainEqual( @@ -402,10 +408,228 @@ describe('loopAgentSteps', () => { triggerBudgetTokens: 16_800, targetBudgetTokens: 8_400, retainedKnowledgeMemory: true, + // The result carries the run correlation, so a consumer can pair it + // with the live status card this run opened. Root run: empty lineage. + // `agentId` is asserted as the EMITTER's id at the producer boundary; + // subagent forwarding may rewrite it downstream, which is why `runId` + // is the documented per-agent key. + runId: expect.any(String), + agentId: 'test-agent-id', + ancestorRunIds: [], }), ) }) + // The runtime is the producer of `context_compaction_status`. The next cases + // pin that contract: exactly one run-correlated started/settled pair per + // announced pass, a settle for a pass that declines to compact, exactly one + // settle when the programmatic step throws, and a lineage that identifies + // nested runs. + it('emits exactly one run-correlated compaction status pair for an announced pass', async () => { + setup() + const events: any[] = [] + agentState.messageHistory = [ + userMessage('small-window evidence '.repeat(8_000)), + userMessage('Continue from the retained goal.'), + ] + agentTemplate.handleSteps = + contextPruner.handleSteps as AgentTemplate['handleSteps'] + + await loopAgentSteps({ + ...baseParams, + agentState, + resolveModelContextWindow: mock(() => 32_000), + localAgentTemplates: { 'test-agent': agentTemplate }, + onResponseChunk: (event) => events.push(event), + }) + + const statusEvents = events.filter( + (event) => event.type === 'context_compaction_status', + ) + const started = statusEvents.filter((event) => event.state === 'started') + const settled = statusEvents.filter((event) => event.state === 'settled') + expect(started).toHaveLength(1) + expect(settled).toHaveLength(1) + // The pass is announced before the programmatic step and settled after the + // compaction branches, never the other way round. + expect(events.indexOf(started[0])).toBeLessThan(events.indexOf(settled[0])) + + // Both halves of the pair share one run correlation, so a consumer can + // clear exactly the card this run opened. + const runId = started[0].runId + expect(typeof runId).toBe('string') + expect(runId.length).toBeGreaterThan(0) + expect(started[0]).toMatchObject({ + runId, + agentId: 'test-agent-id', + // Root run: empty lineage, so root-level live UI may render it. + ancestorRunIds: [], + contextTokens: expect.any(Number), + resolvedContextWindowTokens: 32_000, + triggerBudgetTokens: 16_800, + targetBudgetTokens: 8_400, + }) + expect(settled[0]).toMatchObject({ + runId, + agentId: 'test-agent-id', + ancestorRunIds: [], + }) + + // The reported result is correlated to the same run as the status pair. + expect( + events.find( + (event) => + event.type === 'context_compaction' && + event.action === 'semantic_compaction', + ), + ).toMatchObject({ runId, agentId: 'test-agent-id', ancestorRunIds: [] }) + }) + + it('settles the compaction status when a pass declines to compact', async () => { + setup() + const events: any[] = [] + // A 64k window puts the semantic trigger at 39,200 tokens and the + // provider-safe mechanical ceiling at 56,000. Sizing the transcript with the + // live tokenizer lands the request inside that band, so the loop announces a + // pass while neither the semantic branch nor the mechanical brake reports a + // result. + const chunk = 'old evidence '.repeat(500) + const chunkTokens = countTokens(chunk) + agentState.messageHistory = [ + userMessage(chunk.repeat(Math.ceil(42_000 / chunkTokens))), + ] + // A step that yields straight to the model never rewrites the transcript, so + // this announced pass compacts nothing. + agentTemplate.handleSteps = function* () { + yield 'STEP' + } as () => StepGenerator + + await loopAgentSteps({ + ...baseParams, + agentState, + resolveModelContextWindow: mock(() => 64_000), + localAgentTemplates: { 'test-agent': agentTemplate }, + onResponseChunk: (event) => events.push(event), + }) + + // No compaction was reported at all, yet the announced pass is still + // settled: a pending UI state can never be left stuck when the pruner + // declines to compact. + expect( + events.filter((event) => event.type === 'context_compaction'), + ).toHaveLength(0) + const statusEvents = events.filter( + (event) => event.type === 'context_compaction_status', + ) + // A `yield 'STEP'` generator drives more than one loop iteration, and every + // over-trigger iteration announces and settles its own pass. The invariant + // is therefore one settle per announced pass rather than exactly one pass. + const started = statusEvents.filter((event) => event.state === 'started') + const settled = statusEvents.filter((event) => event.state === 'settled') + expect(started.length).toBeGreaterThanOrEqual(1) + expect(settled.length).toBe(started.length) + // No announced pass is left dangling: the run ends on a settle. + expect(statusEvents.at(-1)).toMatchObject({ state: 'settled' }) + expect(started[0]).toMatchObject({ + state: 'started', + runId: expect.any(String), + agentId: 'test-agent-id', + ancestorRunIds: [], + resolvedContextWindowTokens: 64_000, + triggerBudgetTokens: 39_200, + targetBudgetTokens: 19_600, + }) + }) + + it('settles the compaction status exactly once when the programmatic step throws', async () => { + setup() + const events: any[] = [] + agentState.messageHistory = [ + userMessage('small-window evidence '.repeat(8_000)), + userMessage('Continue from the retained goal.'), + ] + // `started` is emitted before the programmatic step runs. The generator + // error is caught by `runProgrammaticStep`, so which settle point wins (the + // in-loop settle after the compaction branches, or the outer `finally`) is + // not part of the contract; the contract is exactly one settle per + // announced pass, and never a dangling pending pass. + agentTemplate.handleSteps = function* () { + throw new Error('programmatic step exploded') + } as () => StepGenerator + + // The failure may surface as a rejection or as an error-shaped result; + // neither is part of this contract, so assert on the emitted events only. + await loopAgentSteps({ + ...baseParams, + agentState, + resolveModelContextWindow: mock(() => 32_000), + localAgentTemplates: { 'test-agent': agentTemplate }, + onResponseChunk: (event) => events.push(event), + }).catch(() => undefined) + + const statusEvents = events.filter( + (event) => event.type === 'context_compaction_status', + ) + expect( + statusEvents.filter((event) => event.state === 'started'), + ).toHaveLength(1) + // Settling is idempotent, so a second settle here would be a real + // regression rather than a harmless duplicate. + expect( + statusEvents.filter((event) => event.state === 'settled'), + ).toHaveLength(1) + }) + + it('stamps a nested run lineage on compaction status and result events', async () => { + setup() + const events: any[] = [] + agentState.messageHistory = [ + userMessage('small-window evidence '.repeat(8_000)), + userMessage('Continue from the retained goal.'), + ] + // A nested agent loop: its lineage is non-empty, so a consumer that renders + // root-level live UI must be able to tell it apart from a root run. + agentState.ancestorRunIds = ['parent-run'] + agentTemplate.handleSteps = + contextPruner.handleSteps as AgentTemplate['handleSteps'] + + await loopAgentSteps({ + ...baseParams, + agentState, + resolveModelContextWindow: mock(() => 32_000), + localAgentTemplates: { 'test-agent': agentTemplate }, + onResponseChunk: (event) => events.push(event), + }) + + const statusEvents = events.filter( + (event) => event.type === 'context_compaction_status', + ) + const started = statusEvents.filter((event) => event.state === 'started') + const settled = statusEvents.filter((event) => event.state === 'settled') + expect(started).toHaveLength(1) + expect(settled).toHaveLength(1) + const runId = started[0].runId + expect(typeof runId).toBe('string') + expect(runId.length).toBeGreaterThan(0) + expect(started[0]).toMatchObject({ + runId, + agentId: 'test-agent-id', + ancestorRunIds: ['parent-run'], + }) + expect(settled[0]).toMatchObject({ + runId, + agentId: 'test-agent-id', + ancestorRunIds: ['parent-run'], + }) + expect( + events.find((event) => event.type === 'context_compaction'), + ).toMatchObject({ + runId, + agentId: 'test-agent-id', + ancestorRunIds: ['parent-run'], + }) + }) + it('emits a recovery-rich event when emergency mechanical trim is required', async () => { setup() const events: any[] = [] @@ -430,7 +654,17 @@ describe('loopAgentSteps', () => { targetBudgetTokens: 2_000, reason: expect.stringContaining('provider-safe request budget'), retainedKnowledgeMemory: false, - recovery: expect.stringContaining('Re-gather exact constraints'), + compactionCount: 1, + consecutiveNoProgressCompactions: 0, + // A 2k ceiling is below the system+tools baseline, so the trimmed + // request cannot fit: the event must say so instead of claiming a + // clean recovery. + fitsBudget: false, + shortfallTokens: expect.any(Number), + escalated: expect.any(Boolean), + recovery: expect.stringContaining( + 'may still exceed the provider budget', + ), }), ) expect(JSON.stringify(result.agentState.messageHistory)).toContain( @@ -438,6 +672,762 @@ describe('loopAgentSteps', () => { ) }) + it('reports compaction thrash in the reason after two unproductive compactions', async () => { + setup() + const events: any[] = [] + const warn = mock((_data?: unknown, _message?: string) => {}) + agentState.messageHistory = [ + userMessage('old constraints '.repeat(2_000)), + assistantMessage('old evidence '.repeat(2_000)), + ] + // Keep the loop iterating so the mechanical brake runs repeatedly on an + // already-minimal history; each pass reclaims essentially nothing. + agentTemplate.handleSteps = function* () { + yield 'STEP' + yield 'STEP' + yield 'STEP' + yield 'STEP' + } as () => StepGenerator + + await loopAgentSteps({ + ...baseParams, + agentState, + logger: { ...baseParams.logger, warn }, + maxContextLength: 2_000, + localAgentTemplates: { 'test-agent': agentTemplate }, + onResponseChunk: (event) => events.push(event), + }) + + const compactionEvents = events.filter( + (event) => event.type === 'context_compaction', + ) + expect(compactionEvents.length).toBeGreaterThanOrEqual(3) + expect(compactionEvents[0].compactionCount).toBe(1) + expect(compactionEvents[0].consecutiveNoProgressCompactions).toBe(0) + expect(compactionEvents[0].reason).not.toContain( + 'Compaction is not reclaiming space', + ) + + const thrashEvent = compactionEvents.find( + (event) => event.consecutiveNoProgressCompactions >= 2, + ) + expect(thrashEvent).toBeDefined() + expect(thrashEvent.reason).toContain( + 'Compaction is not reclaiming space: 2 consecutive compactions reclaimed under 5%.', + ) + expect( + warn.mock.calls.filter( + (call) => + typeof call[1] === 'string' && + call[1].includes('Compaction is not reclaiming context space'), + ), + ).toHaveLength(1) + }) + + // Anti-thrash remediation. A 64k window puts the semantic trigger at 39,200 + // tokens and the provider-safe mechanical ceiling at 56,000, so a ~42k + // transcript announces an over-trigger semantic pass while the mechanical + // brake stays out of the way. The generator yields straight to the model, so + // every announced pass returns the transcript completely unchanged — the + // actual thrash case. + const seedZeroReclaimAnnouncedPasses = () => { + const chunk = 'old evidence '.repeat(500) + const chunkTokens = countTokens(chunk) + agentState.messageHistory = [ + userMessage(chunk.repeat(Math.ceil(42_000 / chunkTokens))), + ] + agentTemplate.handleSteps = function* () { + yield 'STEP' + yield 'STEP' + } as () => StepGenerator + } + + it('does not report an announced semantic pass that reclaimed nothing as a compaction', async () => { + setup() + const events: any[] = [] + seedZeroReclaimAnnouncedPasses() + + await loopAgentSteps({ + ...baseParams, + agentState, + resolveModelContextWindow: mock(() => 64_000), + localAgentTemplates: { 'test-agent': agentTemplate }, + onResponseChunk: (event) => events.push(event), + }) + + // A `yield 'STEP'` generator drives more than one loop iteration, and every + // over-trigger iteration announces and settles its own pass, so assert the + // settled-equals-started invariant rather than a hard total. + const statusEvents = events.filter( + (event) => event.type === 'context_compaction_status', + ) + const started = statusEvents.filter((event) => event.state === 'started') + const settled = statusEvents.filter((event) => event.state === 'settled') + expect(started.length).toBeGreaterThanOrEqual(2) + expect(settled.length).toBe(started.length) + + // Nothing was compacted, so no result event may be reported... + expect( + events.filter((event) => event.type === 'context_compaction'), + ).toHaveLength(0) + // ...and the shipped `compactionCount` must not have been incremented for + // a pass that never compacted. + expect( + events.some((event) => typeof event.compactionCount === 'number'), + ).toBe(false) + }) + + it('suppresses further semantic compaction after two zero-reclaim announced passes', async () => { + setup() + seedZeroReclaimAnnouncedPasses() + + const result = await loopAgentSteps({ + ...baseParams, + agentState, + resolveModelContextWindow: mock(() => 64_000), + localAgentTemplates: { 'test-agent': agentTemplate }, + }) + + expect(result.agentState.suppressSemanticCompaction).toBe(true) + }) + + it('leaves semantic compaction unsuppressed when a pass genuinely shrinks history', async () => { + setup() + const events: any[] = [] + agentState.messageHistory = [ + userMessage('small-window evidence '.repeat(8_000)), + userMessage('Continue from the retained goal.'), + ] + agentTemplate.handleSteps = + contextPruner.handleSteps as AgentTemplate['handleSteps'] + + const result = await loopAgentSteps({ + ...baseParams, + agentState, + resolveModelContextWindow: mock(() => 32_000), + localAgentTemplates: { 'test-agent': agentTemplate }, + onResponseChunk: (event) => events.push(event), + }) + + // A productive pass must never trip the anti-thrash brake: consecutive + // post-compaction sizes are flat by construction in a healthy long run, so + // gating on them would suppress compaction here. + expect(events).toContainEqual( + expect.objectContaining({ + type: 'context_compaction', + action: 'semantic_compaction', + }), + ) + expect(result.agentState.suppressSemanticCompaction).toBeFalsy() + }) + + it('resets suppressSemanticCompaction at loop entry', async () => { + setup() + // No handleSteps and a small transcript: this loop announces no semantic + // pass at all, so nothing in it could legitimately set the advisory. A + // stale `true` persisted from an earlier turn must not survive into this + // one and permanently disable semantic compaction. + const result = await loopAgentSteps({ + ...baseParams, + agentState: { ...agentState, suppressSemanticCompaction: true }, + promptAiSdkStream: runtimeParams.promptAiSdkStream, + localAgentTemplates: { 'test-agent': agentTemplate }, + }) + + expect(result.agentState.suppressSemanticCompaction).not.toBe(true) + }) + + // Runtime-driven semantic compaction: a prompt-only template (no + // `handleSteps` generator to spawn the pruner itself) must still get a + // semantic pass. The pruner is stubbed through `localAgentTemplates` so the + // pass is deterministic and needs no live LLM. `id` defaults to the bare + // `context-pruner`; pass a publisher/version-qualified id to stub a pruner a + // consumer declared with a pin. + const buildPrunerStub = ( + handleSteps: AgentTemplate['handleSteps'], + maxSpawnDepth?: number, + id = 'context-pruner', + ): AgentTemplate => + ({ + id, + displayName: 'Context Pruner', + spawnerPrompt: 'Prune context', + model: 'claude-3-5-sonnet-20241022', + inputSchema: {}, + outputMode: 'last_message', + includeMessageHistory: true, + messageHistoryMode: 'full', + inheritParentSystemPrompt: true, + propagateMessageHistoryChanges: true, + mcpServers: {}, + toolNames: ['read_files', 'write_file', 'end_turn'], + spawnableAgents: [], + systemPrompt: '', + instructionsPrompt: '', + stepPrompt: '', + handleSteps, + ...(maxSpawnDepth === undefined ? {} : { maxSpawnDepth }), + }) satisfies AgentTemplate as AgentTemplate + + // A 64k window puts the semantic trigger at 39,200 tokens and the + // provider-safe mechanical ceiling at 56,000, so a ~42k transcript announces + // an over-trigger semantic pass while the mechanical emergency brake stays + // out of the way — the runtime-driven pass is what these cases measure. + const seedPromptOnlyOverTriggerRun = () => { + agentTemplate.handleSteps = undefined + // The runtime-driven pass honors the same spawn-permission contract as the + // generator-driven inline pruner, so the parent template must declare + // `context-pruner` for the pass to be paid for at all. + agentTemplate.spawnableAgents = ['context-pruner'] + const chunk = 'old evidence '.repeat(500) + const chunkTokens = countTokens(chunk) + agentState.messageHistory = [ + userMessage(chunk.repeat(Math.ceil(42_000 / chunkTokens))), + ] + } + + const retainedMemoryTranscript = () => [ + userMessage( + '\nPinned structured knowledge memory.\nGoal: preserve discovery and resume\n', + ), + userMessage('Continue from the retained goal.'), + ] + + it('announces and settles a runtime-driven semantic pass for a prompt-only template', async () => { + setup() + const events: any[] = [] + seedPromptOnlyOverTriggerRun() + let prunerRuns = 0 + const contextPruner = buildPrunerStub(function* () { + prunerRuns++ + yield { + toolName: 'set_messages', + input: { messages: retainedMemoryTranscript() }, + includeToolCall: false, + } + } as () => StepGenerator) + + await loopAgentSteps({ + ...baseParams, + agentState, + resolveModelContextWindow: mock(() => 64_000), + localAgentTemplates: { + 'test-agent': agentTemplate, + 'context-pruner': contextPruner, + }, + onResponseChunk: (event) => events.push(event), + }) + + // The runtime drove the pruner even though the template has no generator. + expect(prunerRuns).toBe(1) + const statusEvents = events.filter( + (event) => event.type === 'context_compaction_status', + ) + const started = statusEvents.filter((event) => event.state === 'started') + const settled = statusEvents.filter((event) => event.state === 'settled') + expect(started).toHaveLength(1) + expect(settled).toHaveLength(1) + expect(events.indexOf(started[0])).toBeLessThan(events.indexOf(settled[0])) + const runId = started[0].runId + expect(typeof runId).toBe('string') + expect(started[0]).toMatchObject({ + runId, + agentId: 'test-agent-id', + ancestorRunIds: [], + resolvedContextWindowTokens: 64_000, + triggerBudgetTokens: 39_200, + targetBudgetTokens: 19_600, + }) + expect(settled[0]).toMatchObject({ + runId, + agentId: 'test-agent-id', + ancestorRunIds: [], + }) + }) + + it('reports semantic_compaction when the runtime-driven pruner shrinks history', async () => { + setup() + const events: any[] = [] + seedPromptOnlyOverTriggerRun() + const contextPruner = buildPrunerStub(function* () { + yield { + toolName: 'set_messages', + input: { messages: retainedMemoryTranscript() }, + includeToolCall: false, + } + } as () => StepGenerator) + + const result = await loopAgentSteps({ + ...baseParams, + agentState, + resolveModelContextWindow: mock(() => 64_000), + localAgentTemplates: { + 'test-agent': agentTemplate, + 'context-pruner': contextPruner, + }, + onResponseChunk: (event) => events.push(event), + }) + + // The reporting branch needs a retained block, which the + // replacement transcript carries, so the pass is visible rather than silent. + expect(events).toContainEqual( + expect.objectContaining({ + type: 'context_compaction', + action: 'semantic_compaction', + resolvedContextWindowTokens: 64_000, + retainedKnowledgeMemory: true, + compactionCount: 1, + consecutiveNoProgressCompactions: 0, + }), + ) + expect(JSON.stringify(result.agentState.messageHistory)).toContain( + '', + ) + }) + + it('stops invoking the runtime-driven pruner once semantic compaction is suppressed', async () => { + setup() + const events: any[] = [] + seedPromptOnlyOverTriggerRun() + // A pruner that returns without rewriting the transcript reclaims nothing, + // so the loop-owned `suppressSemanticCompaction` advisory trips after two + // unproductive passes and no further pruner call may be paid for this turn. + let prunerRuns = 0 + const contextPruner = buildPrunerStub(function* () { + prunerRuns++ + } as () => StepGenerator) + // Keep the parent loop iterating past the suppression trip without running + // real tools: a think-only response never ends the turn, and the fourth + // response ends it explicitly. + let llmCalls = 0 + const promptAiSdkStream = mock(async function* () { + llmCalls++ + if (llmCalls >= 4) { + yield createToolCallChunk('end_turn', {}) + } else { + yield { type: 'text' as const, text: 'still working' } + } + return promptSuccess('mock-message-id') + }) + + const result = await loopAgentSteps({ + ...baseParams, + agentState, + promptAiSdkStream, + resolveModelContextWindow: mock(() => 64_000), + localAgentTemplates: { + 'test-agent': agentTemplate, + 'context-pruner': contextPruner, + }, + onResponseChunk: (event) => events.push(event), + }) + + expect(result.agentState.suppressSemanticCompaction).toBe(true) + expect(llmCalls).toBeGreaterThanOrEqual(3) + // Two unproductive passes trip suppression; every later iteration skips the + // pruner entirely. + expect(prunerRuns).toBe(2) + // A suppressed iteration runs no pass, so it announces none either — and + // every announced pass is still settled. + const statusEvents = events.filter( + (event) => event.type === 'context_compaction_status', + ) + const started = statusEvents.filter((event) => event.state === 'started') + const settled = statusEvents.filter((event) => event.state === 'settled') + expect(started).toHaveLength(2) + expect(settled.length).toBe(started.length) + expect(statusEvents.at(-1)).toMatchObject({ state: 'settled' }) + }) + + it('skips the runtime-driven pruner when the template does not declare context-pruner', async () => { + setup() + const events: any[] = [] + seedPromptOnlyOverTriggerRun() + // A consumer-authored prompt-only agent that never declared + // `context-pruner` in `spawnableAgents` must not silently pay for an extra + // child LLM run whose output rewrites its transcript. + agentTemplate.spawnableAgents = [] + let prunerRuns = 0 + const contextPruner = buildPrunerStub(function* () { + prunerRuns++ + yield { + toolName: 'set_messages', + input: { messages: retainedMemoryTranscript() }, + includeToolCall: false, + } + } as () => StepGenerator) + + const result = await loopAgentSteps({ + ...baseParams, + agentState, + resolveModelContextWindow: mock(() => 64_000), + localAgentTemplates: { + 'test-agent': agentTemplate, + 'context-pruner': contextPruner, + }, + onResponseChunk: (event) => events.push(event), + }) + + expect(prunerRuns).toBe(0) + // The undeclared pruner never rewrote the parent transcript. + expect(JSON.stringify(result.agentState.messageHistory)).not.toContain( + 'Pinned structured knowledge memory.', + ) + expect(events).not.toContainEqual( + expect.objectContaining({ + type: 'context_compaction', + action: 'semantic_compaction', + }), + ) + // The announcement is gated on the trigger, so a declined pass still + // settles rather than leaving a pending card on screen. + const statusEvents = events.filter( + (event) => event.type === 'context_compaction_status', + ) + const started = statusEvents.filter((event) => event.state === 'started') + const settled = statusEvents.filter((event) => event.state === 'settled') + expect(started.length).toBeGreaterThanOrEqual(1) + expect(settled.length).toBe(started.length) + expect(statusEvents.at(-1)).toMatchObject({ state: 'settled' }) + }) + + it('spawns the declared publisher/version-qualified pruner for the runtime-driven pass', async () => { + setup() + seedPromptOnlyOverTriggerRun() + // The consumer declared the pruner with a publisher and a version pin. + // Permission is granted from that declaration, so the runtime-driven pass + // must resolve and spawn exactly the declared id — resolving the bare + // `context-pruner` instead would silently ignore the pin for the agent that + // rewrites this parent's transcript. + agentTemplate.spawnableAgents = ['acme/context-pruner@1.2.3'] + let qualifiedPrunerRuns = 0 + let barePrunerRuns = 0 + const qualifiedPruner = buildPrunerStub( + function* () { + qualifiedPrunerRuns++ + yield { + toolName: 'set_messages', + input: { messages: retainedMemoryTranscript() }, + includeToolCall: false, + } + } as () => StepGenerator, + undefined, + 'acme/context-pruner@1.2.3', + ) + const barePruner = buildPrunerStub(function* () { + barePrunerRuns++ + } as () => StepGenerator) + + const result = await loopAgentSteps({ + ...baseParams, + agentState, + resolveModelContextWindow: mock(() => 64_000), + localAgentTemplates: { + 'test-agent': agentTemplate, + 'acme/context-pruner@1.2.3': qualifiedPruner, + 'context-pruner': barePruner, + }, + }) + + expect(qualifiedPrunerRuns).toBe(1) + // The unpinned template must never be substituted for the declared pin. + expect(barePrunerRuns).toBe(0) + expect(JSON.stringify(result.agentState.messageHistory)).toContain( + '', + ) + }) + + it('injects the operative pruner contract for a publisher/version-qualified pruner', async () => { + setup() + seedPromptOnlyOverTriggerRun() + agentTemplate.spawnableAgents = ['acme/context-pruner@1.2.3'] + // The normal root-agent case: the parent already holds a committed task + // memory revision, which the spawn clones into the pruner child. A pruner + // that received no injected `taskMemory` would fall back to its embedded + // compatibility path and publish `expectedTaskMemoryRevision: -1`, so + // `commitTaskMemory` would raise a revision conflict and the transactional + // `set_messages` would reject the transcript replacement outright — the + // announced compaction would silently not happen for exactly the spelling + // documented as equivalent to the bare `context-pruner`. + agentState.taskMemory = commitTaskMemory({ + draft: { + schemaVersion: 1, + goal: 'Preserve discovery and resume', + requirements: [], + decisions: [], + filesInspected: [], + editsMade: [], + validationResults: [], + reviewReceipts: [], + blockers: [], + nextActions: [], + historicalSummary: '', + evidence: [], + }, + expectedRevision: -1, + }) + let injectedParams: Record | undefined + const qualifiedPruner = buildPrunerStub( + function* ({ params }: { params?: Record }) { + injectedParams = params + yield { + toolName: 'set_messages', + input: { + messages: retainedMemoryTranscript(), + // A real pruner commits the next memory revision in the same + // transaction as the transcript replacement, guarded by the + // revision it was handed — or the no-memory sentinel when it was + // handed none. + taskMemory: { + schemaVersion: 1, + goal: params?.taskMemory?.goal ?? '', + requirements: [], + decisions: [], + filesInspected: [], + editsMade: [], + validationResults: [], + reviewReceipts: [], + blockers: [], + nextActions: [], + historicalSummary: 'Compacted by the pinned pruner.', + evidence: [], + }, + expectedTaskMemoryRevision: params?.taskMemory?.revision ?? -1, + }, + includeToolCall: false, + } + } as unknown as AgentTemplate['handleSteps'], + undefined, + 'acme/context-pruner@1.2.3', + ) + + const result = await loopAgentSteps({ + ...baseParams, + agentState, + resolveModelContextWindow: mock(() => 64_000), + localAgentTemplates: { + 'test-agent': agentTemplate, + 'acme/context-pruner@1.2.3': qualifiedPruner, + }, + }) + + // The model-aware budget policy, the parent's operational memory, and the + // workspace state all reach the pinned pruner. + expect(injectedParams?.semanticBudget).toMatchObject({ + triggerBudgetTokens: 39_200, + targetBudgetTokens: 19_600, + }) + expect(injectedParams?.taskMemory?.revision).toBe(0) + expect(injectedParams?.workspaceState).toBeDefined() + // Because the revision guard matched, the transactional replacement + // committed: both the transcript and the next memory revision landed. + expect(JSON.stringify(result.agentState.messageHistory)).toContain( + '', + ) + }) + + it('honors the suppression advisory for a publisher/version-qualified inline pruner spawn', async () => { + setup() + // Generator-driven path: `validateAndGetAgentTemplate` resolves `agentType` + // to the declared publisher/version-qualified id, so the anti-thrash + // advisory must be keyed off pruner identity rather than the bare literal — + // otherwise the documented `suppressSemanticCompaction` contract silently + // does not hold for a pinned declaration. + agentTemplate.spawnableAgents = ['acme/context-pruner@1.2.3'] + agentTemplate.toolNames = ['spawn_agent_inline', 'end_turn'] + const chunk = 'old evidence '.repeat(500) + const chunkTokens = countTokens(chunk) + agentState.messageHistory = [ + userMessage(chunk.repeat(Math.ceil(42_000 / chunkTokens))), + ] + agentTemplate.handleSteps = function* () { + for (let iteration = 0; iteration < 4; iteration++) { + yield { + toolName: 'spawn_agent_inline', + input: { agent_type: 'acme/context-pruner@1.2.3', prompt: '' }, + } + yield 'STEP' + } + } as () => StepGenerator + // A pruner that returns without rewriting the transcript reclaims nothing, + // so two announced passes trip the loop-owned advisory. + let prunerRuns = 0 + const qualifiedPruner = buildPrunerStub( + function* () { + prunerRuns++ + } as () => StepGenerator, + undefined, + 'acme/context-pruner@1.2.3', + ) + // Keep the parent loop iterating past the suppression trip without running + // real tools: a think-only response never ends the turn, and the fourth + // response ends it explicitly. + let llmCalls = 0 + const promptAiSdkStream = mock(async function* () { + llmCalls++ + if (llmCalls >= 4) { + yield createToolCallChunk('end_turn', {}) + } else { + yield { type: 'text' as const, text: 'still working' } + } + return promptSuccess('mock-message-id') + }) + // The shared fixture's `startAgentRun` returns one constant run id, and + // `run-programmatic-step` caches generators by `runId` alone. This is the + // only case in this file where BOTH the parent and the inline child have a + // `handleSteps` generator, so a shared id would make the pruner child resume + // the parent's cached generator and never run its own body. Mint unique ids + // the way production `startAgentRun` does. + let runIdCounter = 0 + const startAgentRun = mock(async () => `test-run-id-${++runIdCounter}`) + + const result = await loopAgentSteps({ + ...baseParams, + agentState, + promptAiSdkStream, + startAgentRun, + resolveModelContextWindow: mock(() => 64_000), + localAgentTemplates: { + 'test-agent': agentTemplate, + 'acme/context-pruner@1.2.3': qualifiedPruner, + }, + }) + + expect(result.agentState.suppressSemanticCompaction).toBe(true) + expect(llmCalls).toBeGreaterThanOrEqual(3) + // Two unproductive passes trip suppression; every later inline spawn is + // skipped instead of paying for another thrashing pruner run. + expect(prunerRuns).toBe(2) + }) + + it('validates a suppressed pruner spawn before returning the anti-thrash skip envelope', async () => { + setup() + // Handler-level ordering: the anti-thrash skip runs AFTER + // `validateAgentInput`/`validateVersionedAgentHandoff`, so a malformed pruner + // spawn keeps failing validation instead of being reported as a successful + // skip while the advisory is active. + const prunerTemplate = { + ...buildPrunerStub(undefined), + // Requires a param the malformed spawn below omits. + inputSchema: { params: z.object({ budget: z.number() }) }, + } as AgentTemplate + const parentTemplate = { + ...agentTemplate, + spawnableAgents: ['context-pruner'], + toolNames: ['spawn_agent_inline', 'end_turn'], + } as AgentTemplate + const handlerParams = { + ...baseParams, + agentState: { ...agentState, suppressSemanticCompaction: true }, + agentTemplate: parentTemplate, + localAgentTemplates: { + 'test-agent': parentTemplate, + 'context-pruner': prunerTemplate, + }, + previousToolCallFinished: Promise.resolve(), + system: 'Test system prompt', + tools: {}, + writeToClient: () => {}, + } as unknown as Parameters[0] + + await expect( + handleSpawnAgentInline({ + ...handlerParams, + toolCall: { + toolName: 'spawn_agent_inline', + toolCallId: 'inline-malformed-pruner', + input: { agent_type: 'context-pruner', prompt: '', params: {} }, + }, + }), + ).rejects.toThrow('Invalid params for agent context-pruner') + + // A well-formed spawn under the same advisory is skipped, and returns the + // tool's standard `{ result, agentReceipt }` envelope with the declined + // spawn reported as `cancelled` rather than completed. + const { output } = await handleSpawnAgentInline({ + ...handlerParams, + toolCall: { + toolName: 'spawn_agent_inline', + toolCallId: 'inline-well-formed-pruner', + input: { + agent_type: 'context-pruner', + prompt: '', + params: { budget: 1 }, + }, + }, + }) + const skipValue = output[0].value as unknown as { + result?: { message?: string } + agentReceipt?: { + schemaVersion?: number + status?: string + agentId?: string + output?: { message?: string } + } + } + expect(skipValue.result?.message).toContain('Semantic compaction skipped') + expect(skipValue.agentReceipt).toMatchObject({ + schemaVersion: 1, + status: 'cancelled', + }) + expect(skipValue.agentReceipt?.output?.message).toContain( + 'Semantic compaction skipped', + ) + // Consumers correlate receipts to spawns by `receipt.agentId`, so the skip + // envelope must identify the declined spawn and never the parent run. + expect(skipValue.agentReceipt?.agentId).toBeTruthy() + expect(skipValue.agentReceipt?.agentId).not.toBe(agentState.agentId) + }) + + it('keeps the turn alive and settles when the runtime-driven pruner fails', async () => { + setup() + const events: any[] = [] + const warn = mock((_data?: unknown, _message?: string) => {}) + seedPromptOnlyOverTriggerRun() + let prunerRuns = 0 + // A pruner spawn depth cap of 0 makes `executeSubagent` reject the spawn + // before any pruner work runs, which is the failure the helper must absorb. + const contextPruner = buildPrunerStub( + function* () { + prunerRuns++ + yield 'STEP' + } as () => StepGenerator, + 0, + ) + + const result = await loopAgentSteps({ + ...baseParams, + agentState, + logger: { ...baseParams.logger, warn }, + resolveModelContextWindow: mock(() => 64_000), + localAgentTemplates: { + 'test-agent': agentTemplate, + 'context-pruner': contextPruner, + }, + onResponseChunk: (event) => events.push(event), + }) + + // The turn completed normally: a pruner failure is never fatal. + expect(result.output).toBeDefined() + expect(prunerRuns).toBe(0) + expect( + warn.mock.calls.filter( + (call) => + typeof call[1] === 'string' && + call[1].includes('Runtime-driven semantic compaction failed'), + ).length, + ).toBeGreaterThanOrEqual(1) + // Every announced pass still settles, even though each one failed. + const statusEvents = events.filter( + (event) => event.type === 'context_compaction_status', + ) + const started = statusEvents.filter((event) => event.state === 'started') + const settled = statusEvents.filter((event) => event.state === 'settled') + expect(started.length).toBeGreaterThanOrEqual(1) + expect(settled.length).toBe(started.length) + expect(statusEvents.at(-1)).toMatchObject({ state: 'settled' }) + }) + it('uses the structured compaction envelope and newest pinned memory for /compact', async () => { setup() agentState.messageHistory = [ diff --git a/packages/agent-runtime/src/run-agent-step.ts b/packages/agent-runtime/src/run-agent-step.ts index d86459c913..8c51c6846e 100644 --- a/packages/agent-runtime/src/run-agent-step.ts +++ b/packages/agent-runtime/src/run-agent-step.ts @@ -66,6 +66,7 @@ import { } from '@codebuff/common/tools/results/filesystem' import { countTokensJson } from './util/token-counter' import { + COMPACTION_NO_PROGRESS_FRACTION, DEFAULT_MAX_CONTEXT_TOKENS, getEffectiveContextLimits, getSemanticCompactionBudget, @@ -78,6 +79,7 @@ import { finalizeLedger, } from './util/context-budget' import { revokeImplicitReadAuthorizationsAfterCompaction } from './util/read-authorization' +import { runRuntimeSemanticCompaction } from './util/runtime-semantic-compaction' import { commitTaskMemory, compileTaskMemoryContext, @@ -1365,6 +1367,36 @@ export async function loopAgentSteps( } initialAgentState.runId = runId + // Agent/run correlation stamped on every compaction event this loop emits. + // `loopAgentSteps` runs for the root turn, for foreground subagents, and for + // inline agents, so a consumer that renders live compaction state as + // root-level UI needs to tell those apart: `ancestorRunIds` is empty only for + // the root run. Snapshotted once (and copied, since the loop mutates agent + // state in place) so every event of this run carries the same identity. + const compactionCorrelation = { + runId, + agentId: initialAgentState.agentId, + ancestorRunIds: [...initialAgentState.ancestorRunIds], + } + + // Live compaction status is announced before a programmatic step and settled + // after the compaction branches. This flag is run-scoped rather than + // per-iteration so an exception between the two (a provider crash, a + // step-cap throw, a cancellation) can still report the terminal state on the + // way out, instead of leaving a consumer with a pending compaction card that + // its session persistence would replay forever. The settle carries this run's + // correlation, so it can only ever settle the pending state this run started. + let unsettledCompactionStart = false + const settleCompactionStatus = () => { + if (!unsettledCompactionStart) return + unsettledCompactionStart = false + onResponseChunk({ + type: 'context_compaction_status', + state: 'settled', + ...compactionCorrelation, + }) + } + // Outer try/finally guarantees this run's in-memory programmatic-step state // is torn down on EVERY exit path after runId is assigned — including if the // prompt/tool setup below throws before the main loop's own try/catch is @@ -1692,6 +1724,129 @@ export async function loopAgentSteps( } } + // Anti-thrash compaction telemetry, loop-local so each turn starts clean. + // `compactionCount` counts every compaction (semantic or mechanical) in + // this loop. Two independent signals are tracked: + // + // - `consecutiveNoProgressCompactions` is the shipped reporting signal + // carried on `context_compaction`: a compaction is "no progress" when + // it reclaims less than COMPACTION_NO_PROGRESS_FRACTION of the + // PREVIOUS compaction's post-compaction history size. Report-only + // (telemetry + an honest reason clause); nothing is gated on it. + // - `consecutiveUnproductiveSemanticPasses` is per-pass and + // semantic-only: a pass is unproductive when it reclaimed less than + // COMPACTION_NO_PROGRESS_FRACTION of its OWN pre-compaction history + // size, which also covers an announced pass that returned the + // transcript unchanged. Once it reaches + // COMPACTION_NO_PROGRESS_STREAK_THRESHOLD the loop stops spawning the + // semantic pruner for the rest of this turn. + // + // Budgets are still never silently lowered and pinned state is never + // dropped as a reaction: the only remediation is to stop paying for a + // pruner pass that has been measured not to reclaim space. + let compactionCount = 0 + let consecutiveNoProgressCompactions = 0 + let previousPostCompactionHistoryTokens: number | undefined + let warnedCompactionNoProgress = false + let consecutiveUnproductiveSemanticPasses = 0 + let warnedSemanticCompactionSuppressed = false + // `suppressSemanticCompaction` is a transient, loop-owned advisory. Reset + // once here, before the loop, so a persisted or inherited `true` from an + // earlier turn can never leak in and deadlock a recoverable run. + initialAgentState.suppressSemanticCompaction = undefined + const registerUnproductiveSemanticPass = () => { + consecutiveUnproductiveSemanticPasses += 1 + if ( + consecutiveUnproductiveSemanticPasses < + COMPACTION_NO_PROGRESS_STREAK_THRESHOLD + ) { + return + } + // Advisory only: both pruner paths (an orchestrator's inline spawn and + // the runtime-driven pass) honor it for the rest of this loop, and a + // suppressed iteration announces no pass at all. No budget is lowered and + // no pinned state is dropped, and every announced pass still settles. + currentAgentState.suppressSemanticCompaction = true + if (warnedSemanticCompactionSuppressed) return + warnedSemanticCompactionSuppressed = true + logger.warn( + { + agentId: currentAgentState.agentId, + runId, + consecutiveUnproductiveSemanticPasses, + }, + 'Suppressing further semantic compaction for this turn after consecutive unproductive passes', + ) + } + const registerCompaction = (compaction: { + action: 'semantic_compaction' | 'mechanical_trim' + preCompactionHistoryTokens: number + postCompactionHistoryTokens: number + }): { + compactionCount: number + consecutiveNoProgressCompactions: number + noProgress: boolean + } => { + const { + action, + preCompactionHistoryTokens, + postCompactionHistoryTokens, + } = compaction + compactionCount += 1 + const previousTokens = previousPostCompactionHistoryTokens + if (previousTokens !== undefined) { + const reduction = previousTokens - postCompactionHistoryTokens + if (reduction < previousTokens * COMPACTION_NO_PROGRESS_FRACTION) { + consecutiveNoProgressCompactions += 1 + } else { + consecutiveNoProgressCompactions = 0 + } + } + previousPostCompactionHistoryTokens = postCompactionHistoryTokens + + // Per-pass, semantic-only streak: compare this pass's reclaim against + // its OWN pre-compaction size, so a healthy long run whose passes each + // return history to roughly the same target is not misread as thrash. + // `mechanical_trim` registrations neither increment nor reset it. + if (action === 'semantic_compaction') { + const reclaimed = + preCompactionHistoryTokens - postCompactionHistoryTokens + if ( + reclaimed < + preCompactionHistoryTokens * COMPACTION_NO_PROGRESS_FRACTION + ) { + registerUnproductiveSemanticPass() + } else { + consecutiveUnproductiveSemanticPasses = 0 + currentAgentState.suppressSemanticCompaction = undefined + } + } + + const noProgress = + consecutiveNoProgressCompactions >= + COMPACTION_NO_PROGRESS_STREAK_THRESHOLD + if (noProgress && !warnedCompactionNoProgress) { + warnedCompactionNoProgress = true + logger.warn( + { + action, + agentId: currentAgentState.agentId, + runId, + compactionCount, + consecutiveNoProgressCompactions, + previousPostCompactionHistoryTokens: previousTokens, + postCompactionHistoryTokens, + }, + 'Compaction is not reclaiming context space across consecutive compactions', + ) + } + return { + compactionCount, + consecutiveNoProgressCompactions, + noProgress, + } + } + try { while (true) { totalSteps++ @@ -1754,6 +1909,49 @@ export async function loopAgentSteps( const contextTokensBeforeProgrammatic = currentAgentState.contextTokenCount + // Semantic compaction runs inside the programmatic step (the pruner + // agent), and its `context_compaction` result only lands afterwards. + // Announce the live state up front so the UI can show a compacting + // card instead of a silent stall. `getSemanticCompactionBudget` is pure + // in `contextWindowTokens`, so this single hoisted value is the one + // authoritative budget for both this emission and the semantic branch + // below. + // + // Deliberately NOT gated on an explicit maxContextLength override: + // only the window-derived trigger may announce a start, otherwise + // every step of an overridden run would emit a spurious one. + const semanticBudget = getSemanticCompactionBudget( + currentAgentState.contextWindowTokens, + ) + const exceededSemanticTrigger = + contextTokensBeforeProgrammatic + 1_000 > + semanticBudget.triggerBudgetTokens + // Transient, loop-owned anti-thrash advisory. Read once per iteration so + // the announcement, the pass itself, and the unproductive-pass + // bookkeeping below can never disagree about whether this iteration is + // allowed to compact. + const semanticCompactionSuppressed = + currentAgentState.suppressSemanticCompaction === true + // Announced for BOTH pruner paths: an orchestrator's generator spawns + // the pruner itself, while a prompt-only template gets the + // runtime-driven pass below. A suppressed iteration runs no pass, so it + // must not announce one either. + const announceSemanticPass = + exceededSemanticTrigger && !semanticCompactionSuppressed + if (announceSemanticPass) { + unsettledCompactionStart = true + onResponseChunk({ + type: 'context_compaction_status', + state: 'started', + ...compactionCorrelation, + contextTokens: contextTokensBeforeProgrammatic, + resolvedContextWindowTokens: + semanticBudget.resolvedContextWindowTokens, + triggerBudgetTokens: semanticBudget.triggerBudgetTokens, + targetBudgetTokens: semanticBudget.targetBudgetTokens, + }) + } + // 1. Run programmatic step first if it exists let n: number | undefined = undefined const historyBeforeProgrammatic = currentAgentState.messageHistory @@ -1851,6 +2049,33 @@ export async function loopAgentSteps( countTokensJson(system) + countTokensJson(toolsForTokenCount) initialAgentState.toolDefinitions = toolDefinitions } + } else if (announceSemanticPass) { + // A prompt-only template has no generator to spawn the pruner, so the + // runtime drives exactly one semantic pass itself. Strictly the `else` + // branch: a `handleSteps` template must keep using only the generator + // path, otherwise one iteration would pay for two pruner calls. + // + // The helper still applies the ordinary spawn-permission contract, so + // a template that does not declare `context-pruner` in + // `spawnableAgents` declines the pass; the announcement above is + // trigger-gated and is settled either way. + // + // Placed between the historyTokensBefore/After measurements so the + // existing reporting branch observes the reduction unchanged, and + // before the task-memory goal capture below so the pruner's + // `set_messages` revision guard sees the same persisted revision the + // programmatic path does. Failures are absorbed by the helper. + await runRuntimeSemanticCompaction({ + ...params, + + agentState: currentAgentState, + agentTemplate, + localAgentTemplates, + logger, + system, + tools, + userInputId, + }) } // Capture the request goal once per step for the root agent. The @@ -1938,9 +2163,6 @@ export async function loopAgentSteps( ), ), ) - const semanticBudget = getSemanticCompactionBudget( - currentAgentState.contextWindowTokens, - ) const activeContextLimits = getEffectiveContextLimits( currentAgentState.contextWindowTokens, maxContextLength, @@ -1949,9 +2171,6 @@ export async function loopAgentSteps( activeContextLimits.providerSafeMessageLimit const activeContextWindowForStatus = activeContextLimits.statusWindowTokens - const exceededSemanticTrigger = - contextTokensBeforeProgrammatic + 1_000 > - semanticBudget.triggerBudgetTokens const hasExplicitMaxContextLength = maxContextLength !== undefined if ( retainedSemanticMemory && @@ -1973,16 +2192,30 @@ export async function loopAgentSteps( categoriesAfterProgrammatic[category].tokens < categoriesBeforeProgrammatic[category].tokens, ) + const compactionTelemetry = registerCompaction({ + action: 'semantic_compaction', + preCompactionHistoryTokens: historyTokensBeforeProgrammatic, + postCompactionHistoryTokens: historyTokensAfterProgrammatic, + }) + const semanticReason = exceededSemanticTrigger + ? 'Total context exceeded the model-aware semantic trigger budget.' + : 'An explicit maxContextLength override allowed semantic compaction before the model-aware trigger budget.' onResponseChunk({ type: 'context_compaction', action: 'semantic_compaction', + ...compactionCorrelation, resolvedContextWindowTokens: semanticBudget.resolvedContextWindowTokens, triggerBudgetTokens: semanticBudget.triggerBudgetTokens, targetBudgetTokens: semanticBudget.targetBudgetTokens, - reason: exceededSemanticTrigger - ? 'Total context exceeded the model-aware semantic trigger budget.' - : 'An explicit maxContextLength override allowed semantic compaction before the model-aware trigger budget.', + compactionCount: compactionTelemetry.compactionCount, + consecutiveNoProgressCompactions: + compactionTelemetry.consecutiveNoProgressCompactions, + reason: compactionTelemetry.noProgress + ? `${semanticReason} ${buildCompactionNoProgressClause( + compactionTelemetry.consecutiveNoProgressCompactions, + )}` + : semanticReason, before: { tokens: historyTokensBeforeProgrammatic, messages: historyBeforeProgrammatic.length, @@ -1999,6 +2232,17 @@ export async function loopAgentSteps( 'Resume from the retained and verify exact live files before editing.', }) compactedThisIteration = true + } else if ( + announceSemanticPass && + historyTokensAfterProgrammatic >= historyTokensBeforeProgrammatic + ) { + // A semantic pass was announced for this iteration, but the pruner + // returned the transcript unchanged (or larger) — the actual thrash + // case. Register it for the per-pass semantic streak ONLY: nothing + // was compacted, so this must not touch the shipped + // `compactionCount`/post-vs-post streak and must not emit a + // `context_compaction` event. + registerUnproductiveSemanticPass() } // Deterministic trimming is now an emergency brake after semantic @@ -2023,16 +2267,33 @@ export async function loopAgentSteps( ) currentAgentState.contextTokenCount = estimateContextTokensLocally() const report = pruningResult.report! + const compactionTelemetry = registerCompaction({ + action: 'mechanical_trim', + preCompactionHistoryTokens: report.beforeTokens, + postCompactionHistoryTokens: report.afterTokens, + }) + const mechanicalReason = + 'Total context remained above the provider-safe request budget after semantic compaction.' onResponseChunk({ type: 'context_compaction', action: 'mechanical_trim', + ...compactionCorrelation, resolvedContextWindowTokens: currentAgentState.contextWindowTokens, triggerBudgetTokens: activeMaxContextLength ?? DEFAULT_MAX_CONTEXT_TOKENS, targetBudgetTokens: activeMaxContextLength ?? DEFAULT_MAX_CONTEXT_TOKENS, - reason: - 'Total context remained above the provider-safe request budget after semantic compaction.', + compactionCount: compactionTelemetry.compactionCount, + consecutiveNoProgressCompactions: + compactionTelemetry.consecutiveNoProgressCompactions, + fitsBudget: report.fitsBudget, + shortfallTokens: report.shortfallTokens, + escalated: report.escalated, + reason: compactionTelemetry.noProgress + ? `${mechanicalReason} ${buildCompactionNoProgressClause( + compactionTelemetry.consecutiveNoProgressCompactions, + )}` + : mechanicalReason, before: { tokens: report.beforeTokens, messages: report.beforeMessageCount, @@ -2045,9 +2306,11 @@ export async function loopAgentSteps( }, removedCategories: report.removedCategories, retainedKnowledgeMemory: report.retainedKnowledgeMemory, - recovery: report.retainedKnowledgeMemory - ? 'Resume from ; re-read exact live files before editing.' - : 'Re-gather exact constraints, files, and validation evidence before continuing.', + recovery: !report.fitsBudget + ? 'This request may still exceed the provider budget: reduce pinned state (fewer keepDuringTruncation blocks, or /compact) or start a fresh turn before retrying.' + : report.retainedKnowledgeMemory + ? 'Resume from ; re-read exact live files before editing.' + : 'Re-gather exact constraints, files, and validation evidence before continuing.', }) compactedThisIteration = true } @@ -2060,6 +2323,13 @@ export async function loopAgentSteps( maybeCheckpoint(currentAgentState, true) } + // Settle the live compacting state announced before the programmatic + // step. Harmless when a real `context_compaction` result already + // arrived (the consumer has nothing pending left to drop); its purpose + // is a pass that decided NOT to compact, which must never leave a + // pending state stuck on screen. + settleCompactionStatus() + onResponseChunk({ type: 'context_window', used: currentAgentState.contextTokenCount, @@ -2296,6 +2566,12 @@ export async function loopAgentSteps( } } } finally { + // A compaction start that no normal branch settled (a throw between + // `started` and the settle point, or a cancellation) still reports its + // terminal state here. Best-effort by design: a user-initiated abort makes + // the SDK drop post-abort events, which is why the CLI additionally treats + // a replayed pending compaction card as an interrupted pass. + settleCompactionStatus() // Always tear down this run's in-memory programmatic-step state. When a // generator yields STEP/STEP_ALL it is intentionally retained across loop // iterations; if a later LLM step throws or the run is aborted, control @@ -2313,6 +2589,24 @@ const STEP_CAP_REACHED_MESSAGE = [ 'Increase maxAgentSteps in openbuff.json if this workload routinely needs a larger step budget.', ].join(' ') +/** + * How many consecutive unproductive compactions must occur before the emitted + * `reason` calls out compaction thrash, and — for the per-pass semantic-only + * streak — before the loop stops spawning the semantic pruner for the rest of + * the turn. One threshold for both signals. The loop still never reacts by + * lowering budgets or dropping pinned state; suppression only avoids paying + * for a pass that was measured not to reclaim space, and an announced pass is + * still announced and still settled. + */ +const COMPACTION_NO_PROGRESS_STREAK_THRESHOLD = 2 + +const buildCompactionNoProgressClause = ( + consecutiveNoProgressCompactions: number, +): string => + `Compaction is not reclaiming space: ${consecutiveNoProgressCompactions} consecutive compactions reclaimed under ${Math.round( + COMPACTION_NO_PROGRESS_FRACTION * 100, + )}%.` + /** * How many steps before the cap the one-time near-cap checkpoint nudge fires. * Compared with `===` against the per-step-decrementing stepsRemaining, so it diff --git a/packages/agent-runtime/src/run-programmatic-step.ts b/packages/agent-runtime/src/run-programmatic-step.ts index 87a793f5a6..571daa92be 100644 --- a/packages/agent-runtime/src/run-programmatic-step.ts +++ b/packages/agent-runtime/src/run-programmatic-step.ts @@ -15,6 +15,7 @@ import { getSemanticCompactionBudget, } from './util/context-pruning' import { remintConfirmedPostEditAnchors } from './util/read-authorization' +import { isContextPrunerAgentId } from './util/context-pruner-identity' import { createWarnLatch } from './util/warn-latch' import type { FileProcessingState } from './tools/handlers/tool/write-file' @@ -364,6 +365,23 @@ export async function runProgrammaticStep( : modelMessageLimit === undefined ? requestedContextLimit : Math.min(requestedContextLimit, modelMessageLimit) + // Pruner identity is an agent-id question, not a string-equality one: a + // consumer may declare the pruner bare, publisher-qualified, or + // version-pinned, and both spawn paths resolve and run exactly what was + // declared. Matching by bare id here keeps the operative pruner contract + // (`semanticBudget`, `taskMemory`, `workspaceState`, and the clamped + // `maxContextLength`) identical for every spelling. Without it a pinned + // pruner receives none of them, falls back to its embedded compatibility + // budget arithmetic, and publishes `expectedTaskMemoryRevision: -1`, so + // `commitTaskMemory` raises a revision conflict and the transactional + // `set_messages` rejects the transcript replacement whenever the parent + // already has task memory — the announced compaction would silently not + // happen. `agentState.agentType` is checked too, mirroring the recursion + // guard in `runtime-semantic-compaction`, so a resolved template that kept + // a bare `id` while being spawned under a pinned type still matches. + const isContextPruner = + isContextPrunerAgentId(template.id) || + isContextPrunerAgentId(agentState.agentType) // Hoisted so the blank-root warning and the control-plane injection below // can never diverge on what counts as a base2 run. const isBase2 = template.id.startsWith('base2') @@ -379,46 +397,45 @@ export async function runProgrammaticStep( `No fileContext.projectRoot for a base2 run: gate telemetry sink disabled, so no gate telemetry will be recorded. Warning once per base2 template id per process, for at most ${MISSING_BASE2_PROJECT_ROOT_WARN_KEY_CAP} ids.`, ) } - const generatorParams = - template.id === 'context-pruner' + const generatorParams = isContextPruner + ? { + ...(toolCallParams ?? {}), + ...(clampedContextLimit === undefined + ? {} + : { maxContextLength: clampedContextLimit }), + semanticBudget: getSemanticCompactionBudget( + agentState.contextWindowTokens, + ), + taskMemory: agentState.taskMemory, + workspaceState: agentState.workspaceState, + } + : isBase2 ? { ...(toolCallParams ?? {}), - ...(clampedContextLimit === undefined - ? {} - : { maxContextLength: clampedContextLimit }), - semanticBudget: getSemanticCompactionBudget( - agentState.contextWindowTokens, - ), - taskMemory: agentState.taskMemory, - workspaceState: agentState.workspaceState, + orchestrationControlPlane: { + selectSpecialistReviewers, + planDiscoveryBatch, + transitionBase2Gate, + // Durable JSONL sink for base2's gate telemetry. Injected here + // because handleSteps is serialized and cannot import it. The + // key is dropped entirely without a projectRoot — base2 + // type-guards the field — so the disabled case the warning + // above reports is explicit instead of a recorder whose every + // append is a no-op. + // Deliberately the raw backend `logger`, not `streamingLogger`: + // a sink failure is a backend filesystem diagnostic, not + // generator output, so it is not streamed to the client log. + ...(projectRoot + ? { + recordGateTelemetry: createGateTelemetryRecorder({ + projectRoot, + logger, + }), + } + : {}), + }, } - : isBase2 - ? { - ...(toolCallParams ?? {}), - orchestrationControlPlane: { - selectSpecialistReviewers, - planDiscoveryBatch, - transitionBase2Gate, - // Durable JSONL sink for base2's gate telemetry. Injected here - // because handleSteps is serialized and cannot import it. The - // key is dropped entirely without a projectRoot — base2 - // type-guards the field — so the disabled case the warning - // above reports is explicit instead of a recorder whose every - // append is a no-op. - // Deliberately the raw backend `logger`, not `streamingLogger`: - // a sink failure is a backend filesystem diagnostic, not - // generator output, so it is not streamed to the client log. - ...(projectRoot - ? { - recordGateTelemetry: createGateTelemetryRecorder({ - projectRoot, - logger, - }), - } - : {}), - }, - } - : toolCallParams + : toolCallParams // Initialize native generator const initializedGenerator = generatorFn({ diff --git a/packages/agent-runtime/src/tools/handlers/tool/spawn-agent-inline.ts b/packages/agent-runtime/src/tools/handlers/tool/spawn-agent-inline.ts index 5e259cfa1e..9a469cc565 100644 --- a/packages/agent-runtime/src/tools/handlers/tool/spawn-agent-inline.ts +++ b/packages/agent-runtime/src/tools/handlers/tool/spawn-agent-inline.ts @@ -1,3 +1,4 @@ +import { generateCompactId } from '@codebuff/common/util/string' import { mapValues } from 'lodash' import { @@ -14,6 +15,7 @@ import { } from './spawn-agent-utils' import { appendOrchestrationEvent } from '../../../util/orchestration-ledger' import { selectAgentAttempt } from '../../../orchestration/select-agent-attempt' +import { isContextPrunerAgentId } from '../../../util/context-pruner-identity' import { acquireWorkspacePathLease, releaseWorkspacePathLease, @@ -97,8 +99,71 @@ export const handleSpawnAgentInline = (async ( apiKey: params.apiKey, }) + // Pruner identity is an agent-id question, not a string-equality one: the + // parent may declare the pruner bare, publisher-qualified, or version-pinned, + // and `validateAndGetAgentTemplate` resolves `agentType` to whatever was + // declared. Every pruner-specific decision below keys off this single check so + // the anti-thrash advisory, the parent-transcript write-back and the + // silent-output contract hold identically for all three spellings. + const isContextPruner = isContextPrunerAgentId(agentType) + validateAgentInput(agentTemplate, agentType, prompt, spawnParams) validateVersionedAgentHandoff({ agentType, handoff }) + + // Anti-thrash advisory published by `loopAgentSteps` for the current turn: + // consecutive semantic passes measurably reclaimed no context space, so skip + // this pruner spawn instead of paying for another thrashing pass. + // Transient/loop-owned — never authoritative across turns. The trip itself is + // warned once by the loop, so this per-skip path only logs at debug. + // + // Deliberately placed AFTER both validators — which are pure input checks + // with no side effect this skip path depends on — so a malformed pruner spawn + // keeps reporting its validation error while suppression is active instead of + // silently reporting success. + if (isContextPruner && parentAgentState.suppressSemanticCompaction === true) { + logger.debug( + { + agentType, + runId: parentAgentState.runId ?? parentAgentState.agentId, + }, + 'Skipped context-pruner spawn: semantic compaction is suppressed for this turn', + ) + // Uniform envelope: every other return path of this handler returns + // `{ result, agentReceipt }`. The receipt is built through the single + // construction site because `agentReceiptSchema` is `.strict()`. A spawn the + // runtime declined to execute is a cancellation, not a completion, and the + // skip message travels in the receipt output so the reason survives. + const skipMessage = + 'Semantic compaction skipped: consecutive compaction passes reclaimed no context space this turn. Continue without compacting, or reduce pinned state / start a fresh turn.' + // `receipt.agentId` names the spawn, never the parent: a parent agentId here + // makes the receipt read as if it described the parent run. Unlike the + // executed path below, this id correlates with nothing persisted — the skip + // returns before any `appendOrchestrationEvent` call and never reaches + // `reconcileAgentReceiptIntoParent`, so no `spawn_started`/`spawn_finished` + // ledger pair and no task-memory receipt record ever mention it. It exists + // only so this envelope is shaped like the others; treat it as an opaque + // per-skip marker, not a spawn id that can be looked up. + const receipt = buildRuntimeAgentReceipt({ + agentType, + agentId: generateCompactId(), + handoff, + spawnParams, + output: { message: skipMessage }, + status: 'cancelled', + }) + return { + output: [ + { + type: 'json', + value: { + result: receipt.output ?? { message: skipMessage }, + agentReceipt: receipt, + }, + }, + ], + } + } + const effectiveAgentTemplate = deriveSpawnTemplateCapabilities({ agentTemplate, parentAgentTemplate, @@ -145,7 +210,7 @@ export const handleSpawnAgentInline = (async ( // This keeps each child's model window independent and avoids duplicating the // parent's system/tool baseline unless the child explicitly opts in. const editsParentMessageHistory = - agentType === 'context-pruner' || + isContextPruner || effectiveAgentTemplate.propagateMessageHistoryChanges === true const inlineMessageHistoryMode = editsParentMessageHistory ? 'full' @@ -154,10 +219,9 @@ export const handleSpawnAgentInline = (async ( ...selection.candidate.template, includeMessageHistory: inlineMessageHistoryMode !== 'none', messageHistoryMode: inlineMessageHistoryMode, - inheritParentSystemPrompt: - agentType === 'context-pruner' - ? true - : effectiveAgentTemplate.inheritParentSystemPrompt, + inheritParentSystemPrompt: isContextPruner + ? true + : effectiveAgentTemplate.inheritParentSystemPrompt, } // Create an isolated child state with the selected bounded transfer mode. @@ -217,7 +281,7 @@ export const handleSpawnAgentInline = (async ( parentTools, onResponseChunk: (chunk: string | PrintModeEvent) => { // Inherits parent's onResponseChunk, except for context-pruner (TODO: add an option for it to be silent?) - if (agentType !== 'context-pruner') { + if (!isContextPruner) { if (typeof chunk === 'string') { writeToClient(chunk) return diff --git a/packages/agent-runtime/src/util/__tests__/context-pruning.test.ts b/packages/agent-runtime/src/util/__tests__/context-pruning.test.ts index 0af53d601b..a3b2e7aa2e 100644 --- a/packages/agent-runtime/src/util/__tests__/context-pruning.test.ts +++ b/packages/agent-runtime/src/util/__tests__/context-pruning.test.ts @@ -121,6 +121,32 @@ describe('maybePruneContext', () => { expect(finalTokens).toBeLessThan(inputTokens) }) + it('passes the fit-verification report fields through untouched', () => { + const longContent = 'x'.repeat(400_000) + const messages: Message[] = [ + userMessage(longContent), + userMessage(longContent), + userMessage('recent short message'), + ] + + const result = maybePruneContext({ + messages, + systemTokens: 100, + contextTokenCount: 400_000, + maxTotalTokens: 190_000, + logger: logger as never, + }) + + expect(result.pruned).toBe(true) + const report = result.report! + // Nothing is pinned here, so the mechanical pass can reach the budget on + // its own: the report must say the trim fits and needed no escalation. + expect(report.fitsBudget).toBe(true) + expect(report.shortfallTokens).toBe(0) + expect(report.escalated).toBe(false) + expect(report.afterTokens).toBeLessThanOrEqual(190_000 - 100) + }) + it('uses DEFAULT_MAX_CONTEXT_TOKENS when maxTotalTokens is undefined', () => { const result = maybePruneContext({ messages: [userMessage('test')], diff --git a/packages/agent-runtime/src/util/__tests__/messages.test.ts b/packages/agent-runtime/src/util/__tests__/messages.test.ts index 8282af5bd5..9744519b04 100644 --- a/packages/agent-runtime/src/util/__tests__/messages.test.ts +++ b/packages/agent-runtime/src/util/__tests__/messages.test.ts @@ -18,6 +18,7 @@ import { trimMessagesToFitTokenLimit, trimMessagesToFitTokenLimitWithReport, COMPACTED_CONTEXT_POINTER, + CONTEXT_EVICTION_PRIORITY, messagesWithSystem, expireMessages, getPreviouslyReadFiles, @@ -1607,3 +1608,240 @@ describe('getPreviouslyReadFiles', () => { expect(result).toEqual([]) }) }) + +describe('trimMessagesToFitTokenLimitWithReport eviction policy', () => { + // Tool payloads must dominate the history so a budget exists where dropping + // only the tool results satisfies the removal target; otherwise every + // optional message has to go and eviction order is unobservable. + const bigToolPayload = (marker: string) => marker.repeat(5_000) + + /** + * Every surviving tool result must still have an assistant `tool-call` part, + * and every surviving assistant `tool-call` part must still have its result. + * Providers reject either kind of orphan with a 400. + */ + function assertToolCallPairingIsValid(messages: Message[]): void { + const resultToolCallIds = new Set( + messages.flatMap((message) => + message.role === 'tool' ? [message.toolCallId] : [], + ), + ) + const callToolCallIds = new Set( + messages.flatMap((message) => + message.role === 'assistant' + ? message.content.filter(isToolCallPart).map((p) => p.toolCallId) + : [], + ), + ) + for (const toolCallId of resultToolCallIds) { + expect(callToolCallIds.has(toolCallId)).toBe(true) + } + for (const toolCallId of callToolCallIds) { + expect(resultToolCallIds.has(toolCallId)).toBe(true) + } + } + + afterEach(() => { + mock.restore() + }) + + it('evicts fileReads and toolResults before user+assistant turns', () => { + spyOn(tokenCounter, 'countTokensJson').mockImplementation( + (value) => JSON.stringify(value).length, + ) + + expect(CONTEXT_EVICTION_PRIORITY).toEqual([ + 'fileReads', + 'toolResults', + 'subagents', + 'todos', + 'userAssistantMessages', + ]) + + const messages: Message[] = [ + { + role: 'tool', + toolName: 'read_outline', + toolCallId: 'outline-1', + content: jsonToolResult({ outline: bigToolPayload('O') }), + }, + userMessage('user turn that must survive'), + assistantMessage('assistant turn that must survive'), + { + role: 'tool', + toolName: 'write_file', + toolCallId: 'write-1', + content: jsonToolResult({ + file: 'src/a.ts', + message: bigToolPayload('W'), + }), + }, + userMessage({ + content: 'pinned operational goal', + keepDuringTruncation: true, + }), + ] + + const report = trimMessagesToFitTokenLimitWithReport({ + messages, + systemTokens: 0, + maxTotalTokens: 6_000, + logger, + }) + + const rendered = JSON.stringify(report.messages) + expect(rendered).toContain('user turn that must survive') + expect(rendered).toContain('assistant turn that must survive') + expect(rendered).toContain('pinned operational goal') + expect( + report.messages.some( + (message) => + message.role === 'tool' && message.toolName === 'read_outline', + ), + ).toBe(false) + expect( + report.messages.some( + (message) => + message.role === 'tool' && message.toolName === 'write_file', + ), + ).toBe(false) + expect(report.removedCategories).toEqual( + expect.arrayContaining(['fileReads', 'toolResults']), + ) + expect(report.removedCategories).not.toContain('userAssistantMessages') + expect(report.fitsBudget).toBe(true) + expect(report.shortfallTokens).toBe(0) + expect(report.escalated).toBe(false) + }) + + it('keeps tool-call pairing valid after a priority-ordered trim', () => { + spyOn(tokenCounter, 'countTokensJson').mockImplementation( + (value) => JSON.stringify(value).length, + ) + + const messages: Message[] = [ + { + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'call-outline', + toolName: 'read_outline', + input: { path: 'src/a.ts' }, + }, + ], + }, + { + role: 'tool', + toolName: 'read_outline', + toolCallId: 'call-outline', + content: jsonToolResult({ outline: bigToolPayload('O') }), + }, + { + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'call-write', + toolName: 'write_file', + input: { path: 'src/b.ts' }, + }, + ], + }, + { + role: 'tool', + toolName: 'write_file', + toolCallId: 'call-write', + content: jsonToolResult({ + file: 'src/b.ts', + message: bigToolPayload('W'), + }), + }, + userMessage('surviving conversation turn'), + userMessage({ + content: 'pinned operational goal', + keepDuringTruncation: true, + }), + ] + + const report = trimMessagesToFitTokenLimitWithReport({ + messages, + systemTokens: 0, + maxTotalTokens: 6_000, + logger, + }) + + assertToolCallPairingIsValid(report.messages) + expect(report.messages.some((message) => message.role === 'tool')).toBe( + false, + ) + expect(JSON.stringify(report.messages)).toContain( + 'surviving conversation turn', + ) + expect(JSON.stringify(report.messages)).toContain('pinned operational goal') + expect(report.messages.length).toBeLessThan(messages.length) + }) + + it('escalates and reports the shortfall honestly when the trim still does not fit', () => { + // Real tokenizer counts are not additive: a message measured on its own can + // cost far more than its marginal contribution to the whole request. This + // mock exaggerates that so the mechanical accounting believes it removed + // enough while the request is still over budget — exactly the case the + // escalation pass and fit verification must catch instead of reporting a + // successful trim. + spyOn(tokenCounter, 'countTokensJson').mockImplementation((value) => + Array.isArray(value) + ? JSON.stringify(value).length + : JSON.stringify(value).length * 100, + ) + const warn = spyOn(logger, 'warn').mockImplementation(() => {}) + + const messages: Message[] = [ + { + role: 'tool', + toolName: 'read_outline', + toolCallId: 'outline-1', + content: jsonToolResult({ outline: bigToolPayload('O') }), + }, + userMessage('U'.repeat(1_000)), + assistantMessage('S'.repeat(1_000)), + { + role: 'tool', + toolName: 'write_file', + toolCallId: 'write-1', + content: jsonToolResult({ + file: 'src/a.ts', + message: bigToolPayload('W'), + }), + }, + userMessage({ + content: 'pinned operational goal', + keepDuringTruncation: true, + }), + ] + + const report = trimMessagesToFitTokenLimitWithReport({ + messages, + systemTokens: 0, + maxTotalTokens: 2_000, + logger, + }) + + // The escalation pass dropped the remaining optional tool result, in + // eviction-priority order, after the mechanical pass ran out of options. + expect(report.escalated).toBe(true) + expect( + report.messages.some( + (message) => + message.role === 'tool' && message.toolName === 'write_file', + ), + ).toBe(false) + // Pinned state is never dropped, so the request can still exceed the + // budget — and the report must say so instead of claiming success. + expect(JSON.stringify(report.messages)).toContain('pinned operational goal') + expect(report.fitsBudget).toBe(false) + expect(report.shortfallTokens).toBe(report.afterTokens - 2_000) + expect(report.shortfallTokens).toBeGreaterThan(0) + expect(warn).toHaveBeenCalled() + }) +}) diff --git a/packages/agent-runtime/src/util/context-pruner-identity.ts b/packages/agent-runtime/src/util/context-pruner-identity.ts new file mode 100644 index 0000000000..f8f5c54e13 --- /dev/null +++ b/packages/agent-runtime/src/util/context-pruner-identity.ts @@ -0,0 +1,36 @@ +import { + normalizeAgentIdForLookup, + parseAgentId, +} from '@codebuff/common/util/agent-id-parsing' + +export const CONTEXT_PRUNER_AGENT_ID = 'context-pruner' + +/** + * Canonical pruner-identity check, shared by both pruner spawn paths. + * + * A consumer may declare the pruner bare (`context-pruner`), + * publisher-qualified (`acme/context-pruner`), or version-pinned + * (`acme/context-pruner@1.2.3`), and the spawn-permission contract resolves the + * agent type to whatever was declared. Every pruner-specific decision — the + * recursion guard in `runtime-semantic-compaction`, and the anti-thrash + * advisory, transcript write-back and silent-output contract on the + * `spawn_agent_inline` path — must compare agent IDs through this helper + * instead of string-equality against the bare literal, otherwise a declared + * publisher/version pin silently changes behavior. + * + * This lives in its own leaf module, depending only on agent-id parsing, so the + * unrelated consumers of the identity check never have to import the semantic + * compaction module (and its spawn-path dependencies) just to answer an + * identity question. + */ +export function isContextPrunerAgentId( + agentId: string | null | undefined, +): boolean { + if (!agentId) { + return false + } + const { agentId: bareAgentId } = parseAgentId( + normalizeAgentIdForLookup(agentId), + ) + return bareAgentId === CONTEXT_PRUNER_AGENT_ID +} diff --git a/packages/agent-runtime/src/util/context-pruning.ts b/packages/agent-runtime/src/util/context-pruning.ts index d8ec087444..17f5662732 100644 --- a/packages/agent-runtime/src/util/context-pruning.ts +++ b/packages/agent-runtime/src/util/context-pruning.ts @@ -62,6 +62,9 @@ export const SEMANTIC_COMPACTION_SMALL_WINDOW_MIN_HEADROOM_TOKENS = 2_000 export const DEFAULT_SEMANTIC_COMPACTION_TRIGGER_TOKENS = 140_000 export const DEFAULT_SEMANTIC_COMPACTION_TARGET_TOKENS = 100_000 +/** Reduction below this share of the previous post-compaction size counts as no progress. */ +export const COMPACTION_NO_PROGRESS_FRACTION = 0.05 + export type SemanticCompactionBudget = { resolvedContextWindowTokens?: number triggerBudgetTokens: number diff --git a/packages/agent-runtime/src/util/messages.ts b/packages/agent-runtime/src/util/messages.ts index cb18ba37f9..dade0ed117 100644 --- a/packages/agent-runtime/src/util/messages.ts +++ b/packages/agent-runtime/src/util/messages.ts @@ -253,6 +253,15 @@ function getContextCategory(message: Message): ContextCategory { return 'toolResults' } +/** Eviction order: cheapest-to-recover context first, conversation last. */ +export const CONTEXT_EVICTION_PRIORITY: readonly ContextCategory[] = [ + 'fileReads', + 'toolResults', + 'subagents', + 'todos', + 'userAssistantMessages', +] + export function getContextCategoryTelemetry( messages: Message[], ): ContextCategorySummary { @@ -290,6 +299,12 @@ export type ContextTrimReport = { afterCategories: ContextCategorySummary removedCategories: ContextCategory[] retainedKnowledgeMemory: boolean + /** True when the returned history fits `maxTotalTokens - systemTokens`. */ + fitsBudget: boolean + /** Tokens still over budget after every trimming pass (0 when it fits). */ + shortfallTokens: number + /** True when the escalation pass had to drop additional optional messages. */ + escalated: boolean } function messageContainsText(message: Message, needle: string): boolean { @@ -384,6 +399,54 @@ function buildMechanicalRecoveryMessage(params: { ) } +function collectAssistantToolCallIds(messages: Message[]): Set { + const toolCallIds = new Set() + for (const message of messages) { + if (message.role !== 'assistant') continue + for (const part of message.content) { + if (part.type === 'tool-call') { + toolCallIds.add(part.toolCallId) + } + } + } + return toolCallIds +} + +/** + * Removes `role: 'tool'` messages whose `toolCallId` lost its assistant + * `tool-call` content part during removal. Scoped to calls that were paired + * before the removal so tool results that never had an assistant call in the + * history are left exactly as the caller provided them. + */ +function dropOrphanToolResults(params: { + messages: Message[] + previouslyCalledToolCallIds: Set +}): Message[] { + const { messages, previouslyCalledToolCallIds } = params + const survivingCalledToolCallIds = collectAssistantToolCallIds(messages) + return messages.filter( + (message) => + message.role !== 'tool' || + !previouslyCalledToolCallIds.has(message.toolCallId) || + survivingCalledToolCallIds.has(message.toolCallId), + ) +} + +/** + * Re-pair tool calls and tool results in both directions after non-contiguous + * removal: assistant-side orphans via `filterUnfinishedToolCalls`, tool-side + * orphans via `dropOrphanToolResults`. + */ +function reconcileToolCallPairing(params: { + before: Message[] + after: Message[] +}): Message[] { + return dropOrphanToolResults({ + messages: filterUnfinishedToolCalls(params.after), + previouslyCalledToolCallIds: collectAssistantToolCallIds(params.before), + }) +} + /** * Trims messages from the beginning to fit within token limits while preserving * important content. Also simplifies large tool results to save tokens. @@ -433,6 +496,9 @@ export function trimMessagesToFitTokenLimitWithReport(params: { beforeCategories: categories, afterCategories: categories, removedCategories: [], + fitsBudget: true, + shortfallTokens: 0, + escalated: false, retainedKnowledgeMemory: messages.some((message) => messageContainsText( message, @@ -511,26 +577,72 @@ export function trimMessagesToFitTokenLimitWithReport(params: { let removedTokens = 0 const tokensToRemove = Math.max(0, optionalTokens - targetOptionalTokens) - const placeholder = 'deleted' - const filteredMessages: (Message | typeof placeholder)[] = [] - for (const message of shortenedMessages) { - if (removedTokens >= tokensToRemove || message.keepDuringTruncation) { - filteredMessages.push(message) + // Category-aware eviction (CONTEXT_EVICTION_PRIORITY): drop the + // cheapest-to-recover context first and conversation last, oldest-first + // within each category. Removal is no longer contiguous by construction, so + // the removals are decided as a set first and the array is then rebuilt in + // original order, collapsing each contiguous removed run into exactly one + // pointer placeholder. Placeholder token credit is tracked via the run count + // so the accounting stays equivalent to the previous contiguous walk. + const placeholderTokens = countTokensJson(replacementMessage) + // An assistant message whose tool result is pinned must stay: dropping it + // would orphan a message we are not allowed to remove, and reconciliation + // would then have to choose between a pinned drop and an invalid pairing. + const pinnedToolCallIds = new Set( + shortenedMessages.flatMap((message) => + message.role === 'tool' && message.keepDuringTruncation + ? [message.toolCallId] + : [], + ), + ) + const answersPinnedToolResult = (message: Message): boolean => + message.role === 'assistant' && + message.content.some( + (part) => + part.type === 'tool-call' && pinnedToolCallIds.has(part.toolCallId), + ) + const removedIndices = new Set() + let removedMessageTokens = 0 + let placeholderRuns = 0 + for (const category of CONTEXT_EVICTION_PRIORITY) { + if (removedTokens >= tokensToRemove) break + for (let index = 0; index < shortenedMessages.length; index++) { + if (removedTokens >= tokensToRemove) break + const message = shortenedMessages[index] + if (message.keepDuringTruncation || removedIndices.has(index)) continue + if (answersPinnedToolResult(message)) continue + if (getContextCategory(message) !== category) continue + const mergesPrevious = removedIndices.has(index - 1) + const mergesNext = removedIndices.has(index + 1) + removedIndices.add(index) + removedMessageTokens += countTokensJson(message) + placeholderRuns += + mergesPrevious && mergesNext ? -1 : mergesPrevious || mergesNext ? 0 : 1 + removedTokens = removedMessageTokens - placeholderRuns * placeholderTokens + } + } + + let trimmedMessages: Message[] = [] + for (let index = 0; index < shortenedMessages.length; index++) { + if (!removedIndices.has(index)) { + trimmedMessages.push(shortenedMessages[index]) continue } - removedTokens += countTokensJson(message) - if ( - filteredMessages.length === 0 || - filteredMessages[filteredMessages.length - 1] !== placeholder - ) { - filteredMessages.push(placeholder) - removedTokens -= countTokensJson(replacementMessage) + if (!removedIndices.has(index - 1)) { + trimmedMessages.push(replacementMessage) } } - let trimmedMessages = filteredMessages.map((m) => - m === placeholder ? replacementMessage : m, - ) + // Priority-ordered removal can strand an assistant tool-call without its + // result, or a tool result without its call. Reconcile both directions + // before the recovery-envelope substitution and the final token count so the + // report numbers describe the array we actually return. + if (removedIndices.size > 0) { + trimmedMessages = reconcileToolCallPairing({ + before: shortenedMessages, + after: trimmedMessages, + }) + } // Compute the running token total once (O(n)), then maintain it with // per-message deltas inside the simplification loop. Previously the loop @@ -573,6 +685,45 @@ export function trimMessagesToFitTokenLimitWithReport(params: { } } + // Escalation pass: tool-result simplification can run out of compressible + // content while the history is still over budget. Keep dropping remaining + // optional messages in eviction-priority order (oldest-first) instead of + // silently reporting a successful trim. Pinned messages, the retained + // , and the compacted-context pointer are never dropped. + let escalated = false + if (runningTokens > maxMessageTokens) { + const isEvictableDuringEscalation = (message: Message): boolean => + !message.keepDuringTruncation && + !answersPinnedToolResult(message) && + !messageContainsText(message, COMPACTED_CONTEXT_POINTER) && + !messageContainsText(message, '') + const escalationRemoved = new Set() + for (const category of CONTEXT_EVICTION_PRIORITY) { + if (runningTokens <= maxMessageTokens) break + for (let index = 0; index < trimmedMessages.length; index++) { + if (runningTokens <= maxMessageTokens) break + const message = trimmedMessages[index] + if (escalationRemoved.has(index)) continue + if (getContextCategory(message) !== category) continue + if (!isEvictableDuringEscalation(message)) continue + escalationRemoved.add(index) + // Maintain the running total per drop instead of recounting the whole + // array, so the escalation pass stays linear in message count. + runningTokens -= countTokensJson(message) + } + } + if (escalationRemoved.size > 0) { + escalated = true + trimmedMessages = reconcileToolCallPairing({ + before: trimmedMessages, + after: trimmedMessages.filter( + (_, index) => !escalationRemoved.has(index), + ), + }) + runningTokens = countTokensJson(trimmedMessages) + } + } + const afterCategories = getContextCategoryTelemetry(trimmedMessages) const removedCategories = ( Object.keys(initialContextCategoryTelemetry) as ContextCategory[] @@ -601,6 +752,14 @@ export function trimMessagesToFitTokenLimitWithReport(params: { }) } const finalTokens = countTokensJson(trimmedMessages) + const fitsBudget = finalTokens <= maxMessageTokens + const shortfallTokens = Math.max(0, finalTokens - maxMessageTokens) + if (!fitsBudget) { + logger.warn( + { finalTokens, maxMessageTokens, shortfallTokens, requiredTokens }, + 'Mechanical trim could not reach the message budget: pinned/required content alone exceeds the budget', + ) + } logger.debug( { @@ -625,6 +784,9 @@ export function trimMessagesToFitTokenLimitWithReport(params: { afterCategories, removedCategories, retainedKnowledgeMemory, + fitsBudget, + shortfallTokens, + escalated, } } diff --git a/packages/agent-runtime/src/util/runtime-semantic-compaction.ts b/packages/agent-runtime/src/util/runtime-semantic-compaction.ts new file mode 100644 index 0000000000..fdb6b87c5e --- /dev/null +++ b/packages/agent-runtime/src/util/runtime-semantic-compaction.ts @@ -0,0 +1,195 @@ +import { mapValues } from 'lodash' + +import { getAgentTemplate } from '../templates/agent-registry' +import { + CONTEXT_PRUNER_AGENT_ID, + isContextPrunerAgentId, +} from './context-pruner-identity' + +import type { executeSubagent } from '../tools/handlers/tool/spawn-agent-utils' +import type { AgentTemplate } from '@codebuff/common/types/agent-template' +import type { Logger } from '@codebuff/common/types/contracts/logger' +import type { ParamsExcluding } from '@codebuff/common/types/function-params' +import type { AgentState } from '@codebuff/common/types/session-state' +import type { ToolSet } from 'ai' + +/** + * Runtime-driven semantic context compaction for prompt-only agent templates. + * + * Invariant owned by this module: a template with no `handleSteps` generator + * still gets a semantic pruner pass, built exactly the way `spawn_agent_inline` + * builds the inline pruner (full parent transcript, inherited system prompt and + * tool baseline, suppressed child output, transcript written back to the + * parent) — including the same spawn-permission contract, so a template that + * never declared `context-pruner` in `spawnableAgents` never pays for the pass. + * The pass is strictly best-effort — it may never abort the agent turn, + * because the mechanical emergency brake downstream is the real guarantee. + * + * The caller owns the `suppressSemanticCompaction` anti-thrash gate: + * `run-agent-step.ts` excludes a suppressed iteration before calling this + * helper (a suppressed iteration announces no semantic pass either), so this + * helper deliberately does not re-check suppression and must not be called + * from an un-gated site. + */ +export async function runRuntimeSemanticCompaction( + params: { + /** Parent state to compact. Its `messageHistory` is replaced in place. */ + agentState: AgentState + /** Parent's resolved template, used only for the recursion guard. */ + agentTemplate: AgentTemplate + localAgentTemplates: Record + logger: Logger + /** Parent system prompt, inherited by the pruner child. */ + system: string + /** Parent tool surface, inherited by the pruner child. */ + tools: ToolSet + userInputId: string + } & ParamsExcluding< + typeof executeSubagent, + | 'agentState' + | 'agentTemplate' + | 'ancestorRunIds' + | 'clearUserPromptMessagesAfterResponse' + | 'onResponseChunk' + | 'parentAgentState' + | 'parentSystemPrompt' + | 'parentTools' + | 'prompt' + | 'spawnParams' + | 'userInputId' + >, +): Promise { + const { + agentState: parentAgentState, + agentTemplate, + localAgentTemplates, + logger, + system, + tools, + userInputId, + } = params + const runId = parentAgentState.runId ?? parentAgentState.agentId + + // Recursion guard: the pruner's own run evaluates this same semantic trigger, + // so a run that IS the pruner must never drive a nested runtime pass. Matched + // by pruner identity rather than string equality so a publisher-qualified or + // version-pinned pruner still recognizes itself. + if ( + isContextPrunerAgentId(agentTemplate.id) || + isContextPrunerAgentId(parentAgentState.agentType) + ) { + return + } + + try { + // Loaded at call time on purpose. `spawn-agent-utils` imports + // `loopAgentSteps` from `run-agent-step`, and `run-agent-step` imports this + // module, so a static import here closes a module cycle whose symptom is an + // `undefined` binding at module init rather than a clean error. Do not + // "clean this up" into a static import. + const { + createAgentState, + executeSubagent, + extractSubagentContextParams, + getMatchingSpawn, + isBaseAgent, + } = await import('../tools/handlers/tool/spawn-agent-utils') + + // Same spawn-permission contract `validateAndGetAgentTemplate` applies to + // the generator-driven `spawn_agent_inline` pruner: base agents may spawn + // anything, every other template must declare `context-pruner` in + // `spawnableAgents`. A consumer-authored agent that never declared it must + // not silently pay for a child LLM run whose output rewrites this parent's + // transcript. + const parentIsBaseAgent = isBaseAgent(agentTemplate.id) + const declaredPrunerSpawn = parentIsBaseAgent + ? undefined + : getMatchingSpawn( + agentTemplate.spawnableAgents ?? [], + CONTEXT_PRUNER_AGENT_ID, + ) + if (!parentIsBaseAgent && !declaredPrunerSpawn) { + logger.debug( + { + agentType: CONTEXT_PRUNER_AGENT_ID, + parentAgentType: agentTemplate.id, + runId, + }, + 'Skipped runtime semantic compaction: context-pruner is not declared in the parent template spawnableAgents', + ) + return + } + + // Spawn exactly what the consumer declared. `getMatchingSpawn` grants + // permission from the declaration, so the declared entry — including any + // publisher and version pin — is the id that must be resolved and run; + // resolving the bare `context-pruner` instead would silently ignore that pin + // for the agent that rewrites this parent's transcript. Base agents have no + // declaration to honor and keep the canonical bare id. + const prunerAgentId = declaredPrunerSpawn ?? CONTEXT_PRUNER_AGENT_ID + const prunerTemplate = + localAgentTemplates[prunerAgentId] ?? + (await getAgentTemplate({ ...params, agentId: prunerAgentId })) + if (!prunerTemplate) { + logger.debug( + { agentType: prunerAgentId, runId }, + 'Skipped runtime semantic compaction: context-pruner template could not be resolved', + ) + return + } + + // Mirrors the pruner-specific inline setup: the context editor needs the + // full parent transcript and the parent's system/tool baseline, otherwise it + // cannot faithfully rewrite what the parent will send next. + const prunerChildTemplate: AgentTemplate = { + ...prunerTemplate, + includeMessageHistory: true, + messageHistoryMode: 'full', + inheritParentSystemPrompt: true, + } + const childAgentState: AgentState = { + ...createAgentState( + prunerAgentId, + prunerChildTemplate, + parentAgentState, + {}, + ), + systemPrompt: system, + toolDefinitions: mapValues(tools, (tool) => ({ + description: tool.description, + inputSchema: tool.inputSchema as {}, + })), + } + + const result = await executeSubagent({ + ...extractSubagentContextParams(params), + + ancestorRunIds: parentAgentState.ancestorRunIds, + userInputId: `${userInputId}-inline-${prunerAgentId}${childAgentState.agentId}`, + prompt: '', + spawnParams: undefined, + agentTemplate: prunerChildTemplate, + parentAgentState, + agentState: childAgentState, + fingerprintId: params.fingerprintId, + parentSystemPrompt: system, + parentTools: tools, + // The pruner is infrastructure, not conversation: its output stays + // invisible, matching the inline path's pruner-identity suppression. + onResponseChunk: () => {}, + clearUserPromptMessagesAfterResponse: false, + }) + + // Only the transcript propagates back, exactly like the inline path's + // `editsParentMessageHistory` branch. + parentAgentState.messageHistory = result.agentState.messageHistory + } catch (error) { + // Non-fatal by design: a pruner failure must never abort the agent turn. + // The deterministic mechanical brake still runs downstream. + logger.warn( + { error, agentType: CONTEXT_PRUNER_AGENT_ID, runId }, + 'Runtime-driven semantic compaction failed (non-fatal)', + ) + return + } +} diff --git a/sdk/CHANGELOG.md b/sdk/CHANGELOG.md index f799f828f3..c15743d177 100644 --- a/sdk/CHANGELOG.md +++ b/sdk/CHANGELOG.md @@ -13,10 +13,28 @@ All notable changes to the @openbuff/sdk package will be documented in this file - New optional `CodebuffFileSystem` capability `streamDirectory` for lazy (bounded-memory) directory iteration, exported alongside its `CodebuffStreamDirectory` type. `list_directory` uses it to stop one entry past the entry cap instead of materializing whole directories. Implementers must (a) release the directory handle from the iterator's `return()`, which breaking out of `for await` invokes, and (b) set `streamDirectory.readdirView` to the adapter's own `readdir`. Callers ignore the capability when `readdirView` is not the adapter's current `readdir`, so an adapter that decorates `createNodeFileSystem()` (spread or `Object.create()`) and overrides `readdir` keeps serving its own view instead of the host filesystem. The capability is deliberately not named `opendir` so adapters inheriting from `fs.promises` are never auto-detected. - New exported predicate `supportsStreamDirectory(fs)` for detecting the `streamDirectory` capability. It is the supported way to observe whether an adapter provides streaming directory iteration, because presence of the member is only half the contract: the predicate also applies the `readdirView` pairing, so it always agrees with the path `list_directory` takes. `detectFilesystemCapabilities()` is unchanged and still reports member-presence capabilities only. - New exported constant `MAX_LIST_DIRECTORY_ENTRIES`, the `list_directory` entry cap. It is the supported way to obtain the cap now that the over-cap `errorMessage` no longer reports an observed entry count, so consumers that parsed the count no longer need to parse the message at all. +- New optional telemetry fields on the `context_compaction` `PrintModeEvent` variant consumed by the public `handleEvent` surface (`common/src/types/print-mode.ts`): `runId`, `ancestorRunIds`, `agentId`, `resolvedContextWindowTokens`, `triggerBudgetTokens`, `targetBudgetTokens`, `compactionCount`, `consecutiveNoProgressCompactions`, `shortfallTokens`, `fitsBudget`, and `escalated`. Every field is optional, so this is an additive, non-breaking change: persisted or replayed events emitted before the telemetry existed still validate, and consumers that ignore the fields keep their previous behavior. No migration is required. `runId`/`ancestorRunIds` identify the emitting agent run (`ancestorRunIds` is empty only for the root run) and are forwarded verbatim by every hop, which also scopes `compactionCount`: it counts the emitting run's own passes rather than a per-turn total across nested runs, so only the root run's count may be adopted as a turn total. `agentId` is stamped by the emitting run but is **not** a stable per-agent key on the delivered payload: the `spawn_agents` forwarding path overwrites it with the direct child's agent id on forwarded compaction events, so at nesting depth >= 2 it identifies the nearest forwarding child rather than the emitter. Key per-agent state off `runId` and treat `agentId` as a display hint. Full contract in `docs/agents-and-tools.md` under "Context-window-aware compaction budgets". +- New additive `context_compaction_status` `PrintModeEvent` variant on the public `handleEvent` surface (`common/src/types/print-mode.ts`), reporting live compaction state as `state: 'started' | 'settled'` with the required agent/run correlation `runId` and `ancestorRunIds` (plus optional `agentId`) and optional `contextTokens`, `resolvedContextWindowTokens`, `triggerBudgetTokens`, and `targetBudgetTokens`. It is a separate event from the terminal `context_compaction` result, whose shape is otherwise unchanged. `started` is emitted before a programmatic step whose window-derived semantic trigger is exceeded, and a matching `settled` with the same `runId` always follows, so a pass that decides not to compact leaves no pending state behind. The emission condition is the trigger alone — it does **not** require the agent to have a `handleSteps` generator, because a prompt-only template gets an equivalent runtime-driven pass — and it is additionally suppressed for an iteration where the transient anti-thrash advisory is active (consecutive passes reclaimed no context space this turn), so a suppressed iteration emits neither half of the pair. Every `loopAgentSteps` invocation emits these events (root turn, foreground subagents, inline agents), so consumers must pair `started`/`settled` by `runId` and treat only an empty `ancestorRunIds` as the root run; otherwise nested loops cross-settle each other's pending state and a subagent's compaction renders as root-level live UI. Pair and key on `runId`, never on `agentId`: subagent forwarding rewrites `agentId` to the nearest forwarding child's agent id, so it does not identify the emitter at nesting depth >= 2. Adding an event variant is non-breaking: as with `job_update`, consumers should treat unknown `event.type` values as no-ops, and consumers that only read the final compaction result can ignore it entirely. No migration is required. Full contract in `docs/agents-and-tools.md` under "Context-window-aware compaction budgets". +- New optional `AgentState.suppressSemanticCompaction` boolean (`common/src/types/session-state.ts`), reachable by consumers through `RunState.sessionState.mainAgentState` and persisted alongside the rest of agent state. It is a transient, loop-owned anti-thrash advisory: `loopAgentSteps` sets it for the remainder of a turn whose consecutive semantic compaction passes measurably reclaimed no context space, and both pruner spawn paths — the runtime-driven pass and `spawn_agent_inline` — then decline to spawn the `context-pruner` for that turn. The field is optional, so this is an additive, non-breaking change: state persisted before it existed still loads, and consumers that ignore it keep their previous behavior. No migration is required. It is never authoritative across turns — `loopAgentSteps` resets it to `undefined` at loop entry — so a persisted or inherited `true` must not be read as a durable "semantic compaction disabled" setting, and writing `true` into resumed state only affects a turn that reads it before that reset. Budgets are not lowered and pinned state is not dropped by the advisory. - The full `CodebuffFileSystem` type closure is now exported from the SDK (`CodebuffFileContent`, `CodebuffFileSystemBase`, `CodebuffRangeReadResult`, `CodebuffConditionalMoveOptions`, `CodebuffConditionalMoveResult` in addition to the previously published aliases), so a consumer's generated `.d.ts` resolves an adapter implementation without reaching into unpublished internals. ### Changed +- **Prompt-only agents can now get a semantic compaction pass, opt-in via `spawnableAgents`.** A template without a `handleSteps` generator no longer relies solely on the mechanical trim: when the window-derived semantic trigger is exceeded, the runtime drives one `context-pruner` pass itself. That spawn goes through the same spawn-permission contract as `spawn_agent_inline`, so it only happens for base agents or for a template that declares `context-pruner` in its `spawnableAgents`. A consumer-authored agent that does not declare it is unaffected: no extra child LLM run is paid for and its transcript is never rewritten by the pruner. Declare `context-pruner` in `spawnableAgents` to opt in. + +- **`spawn_agent_inline` pruner identity is now matched by bare agent id.** The pruner-specific inline contract was previously keyed off exact `agent_type === 'context-pruner'` string equality; it now goes through `isContextPrunerAgentId`, which normalizes the resolved agent id and compares only its bare segment. Any agent whose bare id is `context-pruner` — including `acme/context-pruner`, `acme/context-pruner@1.2.3`, and underscore aliases such as `context_pruner` — now receives the full pruner treatment it previously got only when spelled exactly `context-pruner`: + - the full parent transcript (`messageHistoryMode: 'full'`) instead of the bounded `pinned` inline default, plus forced `inheritParentSystemPrompt: true`; + - fully silenced child output — no text, tool, or subagent events from that child reach the client; + - write-back of the child's transcript over the parent's `messageHistory`; + - the operative pruner params the runtime injects into a serialized `handleSteps` (`run-programmatic-step.ts`): the model-aware `semanticBudget`, the parent's `taskMemory` and `workspaceState`, and the caller's `maxContextLength` clamped to the resolved model message limit. These were previously injected only for the exact `context-pruner` spelling, so a publisher-qualified or version-pinned pruner fell back to its embedded budget arithmetic and published `expectedTaskMemoryRevision: -1`; with parent task memory present (the normal root-agent case) its transactional `set_messages` was then rejected and the announced compaction silently did not happen. Identity is matched over both the resolved template id and the spawned `agentType`; + - the transient `suppressSemanticCompaction` anti-thrash skip, which declines the spawn (after its input is validated) for the rest of a turn whose consecutive semantic passes reclaimed no context space. A declined spawn returns the usual `{ result, agentReceipt }` envelope with `status: 'cancelled'` and the skip reason as its output, but its `agentReceipt.agentId` is a synthetic per-skip marker: no child ran, so it appears in no persisted `spawn_started`/`spawn_finished` orchestration-ledger pair and in no task-memory receipt record. Do not use it as a spawn-correlation key. + - Only an agent whose bare id is literally `context-pruner` is affected, so an agent published under any other bare id behaves exactly as before. Migration: if you published a non-pruner agent under the bare id `context-pruner` and do not want transcript write-back and output silencing, rename it to a different bare id. + +- **Consumer-visible request-time trim budget tightened.** The SDK's request-time emergency-brake trim (`getMessagesForModelContext`, applied on both the streaming and non-streaming request paths) now subtracts the tokens actually consumed by that request's system prompt and tool schemas from the resolved message limit, instead of giving the whole limit to messages alone. The effective message budget is therefore `resolvedMessageLimit - systemTokens` (clamped so the budget is never <= 0), which mirrors the way the runtime's `maybePruneContext` reserves system-prompt and tool tokens before trimming. Treat that as the same _shape_ of budget, not an identical number: the SDK counts this request's own tool objects (exact for names, descriptions and plain JSON Schemas; opaque Zod/Standard Schema instances are converted to a real JSON Schema and counted from that projection, clamped between a per-tool floor and a per-tool ceiling and falling back to the floor when the conversion is not possible, and 0 when the provider-compatibility layer strips tools), while the runtime counts its own serialized tool definitions against a limit that may additionally be capped by a provider `maxContextLength`. The two counts are computed from different projections of the tool surface and can diverge substantially for the same model, so do not assume a request trimmed by one brake would be trimmed by the other. Consequences to expect: + - Requests whose messages previously fit exactly under the flat limit can now have their oldest messages dropped. Nothing needs to change for correctness — the trim is still a last-resort brake below semantic compaction — but consumers that asserted on an exact retained-message count or on the total token size of a dispatched request must re-baseline against the smaller message budget. A larger system prompt or tool surface now shrinks the message budget one-for-one. + - The `CACHE_EMERGENCY_TRIM` warning log text changed: it now also reports `systemTokens=` and `messageBudget=` after the existing `trigger=`/`target=` fields. The `cache_emergency_trim` analytics event gains the matching `systemTokens` and `effectiveMessageBudgetTokens` properties; `maxTotalTokens` keeps its previous meaning (the resolved request budget, not the message-only budget). Consumers that scrape the log line must match its prefix rather than the tail. + - `triggerBudgetTokens`/`targetBudgetTokens` — in both the log payload and the `cache_emergency_trim` analytics properties, and the `trigger=`/`target=` numbers in the log text — now report the threshold actually applied to messages, i.e. the message-only budget (`effectiveMessageBudgetTokens`, equal to `maxTotalTokens - systemTokens`). They previously reported `maxTotalTokens`, which after this change is the pre-subtraction request budget and no longer the threshold a message-token comparison should use. When no system/tool overhead is supplied (`systemTokens` absent or `0`) both fields still equal `maxTotalTokens`, so those consumers see no change; a consumer that compares either field against message token counts while a system surface is counted must re-baseline against the smaller value. + - **Consumer-visible tool output:** both `list_directory` `errorMessage` texts changed. Match on the prefixes, not the tails. - Over the entry cap: `Directory listing too large: more than 5000 entries. List a specific subdirectory instead.`. The observed entry count and the previous `exceeds limit of 5000` phrasing are gone: the bounded read stops one entry past the cap, so the true total is never known. Consumers that parsed a count or matched `exceeds limit of` must match `Directory listing too large:` instead, and read the cap from the exported `MAX_LIST_DIRECTORY_ENTRIES` constant. - Any other failure: `Failed to list directory ''`, with an optional ` (ERRNO)` suffix, replacing the previous `Failed to list directory: ` shape. The raw filesystem message is no longer echoed because it can name absolute paths the call never resolved; only the caller-supplied logical path and a canonical errno token are reported. Consumers that read the filesystem message out of the tail must use the errno suffix. diff --git a/sdk/src/impl/__tests__/llm-context-window.test.ts b/sdk/src/impl/__tests__/llm-context-window.test.ts index 1a5de0fe65..8556000d7e 100644 --- a/sdk/src/impl/__tests__/llm-context-window.test.ts +++ b/sdk/src/impl/__tests__/llm-context-window.test.ts @@ -2,9 +2,12 @@ import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' import { userMessage } from '@codebuff/common/util/messages' import { COMPACTED_CONTEXT_POINTER } from '@codebuff/agent-runtime/util/messages' import { countTokensJson } from '@codebuff/agent-runtime/util/token-counter' -import { describe, expect, mock, spyOn, test } from 'bun:test' +import { describe, expect, spyOn, test } from 'bun:test' + +import z from 'zod/v4' import { + countRequestOverheadTokens, getMessagesForModelContext, getProviderContextLimitFromError, } from '../llm' @@ -131,6 +134,87 @@ describe('getMessagesForModelContext', () => { } }) + test('reports the applied message-only threshold in trigger/target trim telemetry', () => { + // `triggerBudgetTokens`/`targetBudgetTokens` are the pair consumers compare + // against message token counts, so once a system/tool surface is reserved + // they must report the message-only budget actually applied, not the + // pre-subtraction request budget still carried by `maxTotalTokens`. + const messages: Message[] = [ + userMessage('old context '.repeat(10_000)), + userMessage('middle context '.repeat(10_000)), + userMessage('recent context '.repeat(10_000)), + ] + const trackedEvents: { + event: string + properties?: Record + }[] = [] + const warnSpy = spyOn(logger, 'warn').mockImplementation(() => {}) + + try { + getMessagesForModelContext({ + messages, + contextWindowTokens: 2_000, + systemTokens: 400, + logger, + trackEvent: ({ event, properties }) => + trackedEvents.push({ event, properties }), + }) + + expect(warnSpy).toHaveBeenCalledTimes(1) + const call = warnSpy.mock.calls[0] + const payload = call[0] as Record + // Resolved request budget keeps its previous meaning. + expect(payload.maxTotalTokens).toBe(1_000) + expect(payload.systemTokens).toBe(400) + expect(payload.effectiveMessageBudgetTokens).toBe(600) + expect(payload.triggerBudgetTokens).toBe(600) + expect(payload.targetBudgetTokens).toBe(600) + expect(call[1] as string).toContain('trigger=600, target=600') + + // The analytics payload carries the same unambiguous pair. + expect(trackedEvents).toHaveLength(1) + expect(trackedEvents[0]).toMatchObject({ + event: AnalyticsEvent.CACHE_EMERGENCY_TRIM, + properties: { + maxTotalTokens: 1_000, + systemTokens: 400, + effectiveMessageBudgetTokens: 600, + triggerBudgetTokens: 600, + targetBudgetTokens: 600, + }, + }) + } finally { + warnSpy.mockRestore() + } + }) + + test('keeps trigger/target equal to the request budget when no overhead is reserved', () => { + // Callers that reserve no system/tool surface see the previous values, so + // the field pair is a pure clarification for them rather than a break. + const messages: Message[] = [ + userMessage('old context '.repeat(10_000)), + userMessage('recent context '.repeat(10_000)), + ] + const warnSpy = spyOn(logger, 'warn').mockImplementation(() => {}) + + try { + getMessagesForModelContext({ + messages, + contextWindowTokens: 2_000, + systemTokens: 0, + logger, + }) + + const payload = warnSpy.mock.calls[0][0] as Record + expect(payload.maxTotalTokens).toBe(1_000) + expect(payload.triggerBudgetTokens).toBe(1_000) + expect(payload.targetBudgetTokens).toBe(1_000) + expect(payload.effectiveMessageBudgetTokens).toBe(1_000) + } finally { + warnSpy.mockRestore() + } + }) + test('honors an adaptive provider message-limit override', () => { const messages: Message[] = [ userMessage('oversized context '.repeat(8_000)), @@ -146,6 +230,347 @@ describe('getMessagesForModelContext', () => { expect(JSON.stringify(result)).toContain(COMPACTED_CONTEXT_POINTER) }) + test('subtracts systemTokens so the brake matches the runtime message budget', () => { + const messages: Message[] = Array.from({ length: 24 }, (_, index) => + userMessage(`chunk ${index} context `.repeat(400)), + ) + const messageTokens = countTokensJson(messages) + // Window large enough that the whole history fits when the system surface + // is ignored, but not once a real system+tools cost is subtracted. + const contextWindowTokens = messageTokens * 2 + + const withoutSystemTokens = getMessagesForModelContext({ + messages, + contextWindowTokens, + systemTokens: 0, + logger, + }) + const withSystemTokens = getMessagesForModelContext({ + messages, + contextWindowTokens, + systemTokens: messageTokens, + logger, + }) + + expect(withoutSystemTokens).toBe(messages) + expect(withSystemTokens).not.toBe(messages) + expect(countTokensJson(withSystemTokens)).toBeLessThan( + countTokensJson(withoutSystemTokens), + ) + + // A system surface larger than the entire window still leaves a positive + // message budget instead of a zero/negative one. + const clamped = getMessagesForModelContext({ + messages, + contextWindowTokens, + systemTokens: Number.MAX_SAFE_INTEGER, + logger, + }) + expect(clamped.length).toBeGreaterThan(0) + }) + + test('omitting systemTokens is identical to passing 0', () => { + const messages: Message[] = [ + userMessage('old context '.repeat(10_000)), + userMessage('middle context '.repeat(10_000)), + userMessage('recent context '.repeat(10_000)), + ] + // The mechanical recovery message the trim injects carries a fresh + // `sentAt`, so two calls are never byte-identical in wall-clock terms. + // Compare the trimmed content only. + const shape = (trimmed: Message[]): string => + JSON.stringify(trimmed.map(({ sentAt, ...rest }) => rest)) + + const explicitZero = getMessagesForModelContext({ + messages, + contextWindowTokens: 2_000, + systemTokens: 0, + logger, + }) + const omitted = getMessagesForModelContext({ + messages, + contextWindowTokens: 2_000, + logger, + }) + + expect(omitted).not.toBe(messages) + expect(shape(omitted)).toBe(shape(explicitZero)) + + // Non-finite and negative inputs sanitize to 0. + for (const systemTokens of [-5_000, Number.NaN, Number.POSITIVE_INFINITY]) { + expect( + shape( + getMessagesForModelContext({ + messages, + contextWindowTokens: 2_000, + systemTokens, + logger, + }), + ), + ).toBe(shape(explicitZero)) + } + }) + + test('stays behaviorally safe for a Zod-schema toolset on the exported request paths', () => { + // The canonical AI-SDK form the published `promptAiSdkStream`/`promptAiSdk` + // param types accept: `inputSchema` is a Zod schema instance, not JSON. + const tools = { + read_file: { + description: 'Read a file from the project', + inputSchema: z.object({ + path: z.string(), + startLine: z.number().optional(), + }), + }, + write_file: { + description: 'Write a file in the project', + inputSchema: z.object({ path: z.string(), content: z.string() }), + }, + } + + const overhead = countRequestOverheadTokens({ + system: 'system prompt', + tools, + includeTools: true, + }) + + // Bounded: the schemas are converted to JSON Schema and counted (clamped + // between the per-tool floor and ceiling), so a two-tool surface of small + // schemas is still nowhere near the thousands of tokens that serializing + // the schema instances' internals would produce. + expect(overhead).toBeGreaterThan(0) + expect(overhead).toBeLessThan(1_000) + + // And the resulting budget still leaves a comfortably-fitting history + // untouched, so existing consumers keep their previous behavior. + const messages: Message[] = [userMessage('fits comfortably '.repeat(50))] + expect( + getMessagesForModelContext({ + messages, + contextWindowTokens: 200_000, + systemTokens: overhead, + logger, + }), + ).toBe(messages) + }) + + test('reserves materially more for a large Zod schema than a small one', () => { + // The reservation is size-aware: a flat per-tool estimate under-reserved a + // large tool surface, which made the request-time brake believe more + // message budget was available than really was. Assert the relationship, + // not a token count. + const smallOverhead = countRequestOverheadTokens({ + tools: { + tiny_tool: { + description: 'A tool with one small field', + inputSchema: z.object({ path: z.string() }), + }, + }, + includeTools: true, + }) + const largeOverhead = countRequestOverheadTokens({ + tools: { + tiny_tool: { + description: 'A tool with one small field', + inputSchema: z.object({ + path: z.string().describe('Absolute or project-relative file path'), + startLine: z.number().describe('First line to read, 1-indexed'), + endLine: z.number().describe('Last line to read, inclusive'), + encoding: z.string().describe('Text encoding of the file'), + includeHidden: z.boolean().describe('Include dot-prefixed entries'), + maxBytes: z.number().describe('Hard cap on bytes read'), + glob: z.string().describe('Glob filter applied to matches'), + exclude: z.array(z.string()).describe('Glob patterns to skip'), + followSymlinks: z.boolean().describe('Resolve symbolic links'), + recursive: z.boolean().describe('Walk nested directories'), + sortBy: z.string().describe('Sort key for the returned entries'), + limit: z.number().describe('Maximum number of entries returned'), + }), + }, + }, + includeTools: true, + }) + + expect(largeOverhead).toBeGreaterThan(smallOverhead) + }) + + test('counts a Zod schema in the same ballpark as its JSON-Schema equivalent', () => { + // Conversion output is not byte-identical to a hand-written schema, so the + // parity claim is a ballpark one: the Zod surface must no longer be charged + // a token order of magnitude less than the plain JSON Schema it compiles to. + const zodOverhead = countRequestOverheadTokens({ + tools: { + read_file: { + description: 'Read a file', + inputSchema: z.object({ + path: z.string().describe('File path'), + startLine: z.number().describe('First line to read'), + endLine: z.number().describe('Last line to read'), + encoding: z.string().describe('Text encoding'), + }), + }, + }, + includeTools: true, + }) + const jsonSchemaOverhead = countRequestOverheadTokens({ + tools: { + read_file: { + description: 'Read a file', + inputSchema: { + type: 'object', + properties: { + path: { type: 'string', description: 'File path' }, + startLine: { type: 'number', description: 'First line to read' }, + endLine: { type: 'number', description: 'Last line to read' }, + encoding: { type: 'string', description: 'Text encoding' }, + }, + required: ['path', 'startLine', 'endLine', 'encoding'], + }, + }, + }, + includeTools: true, + }) + + expect(zodOverhead).toBeGreaterThanOrEqual( + Math.floor(jsonSchemaOverhead / 2), + ) + }) + + test('reserves at least the fallback floor for an unconvertible schema', () => { + // An `inputSchema` that is neither plain JSON data nor convertible to JSON + // Schema still has to reserve something, or the brake would silently treat + // an opaque tool surface as free. + class OpaqueSchema { + readonly kind = 'opaque' + validate(): boolean { + return true + } + } + + const withoutSchema = countRequestOverheadTokens({ + tools: { opaque_tool: { description: 'Opaque schema' } }, + includeTools: true, + }) + const withOpaqueSchema = countRequestOverheadTokens({ + tools: { + opaque_tool: { + description: 'Opaque schema', + inputSchema: new OpaqueSchema(), + }, + }, + includeTools: true, + }) + + // A tool with no `inputSchema` at all reserves nothing for a schema. + expect(withoutSchema).toBe( + countTokensJson({ name: 'opaque_tool', description: 'Opaque schema' }), + ) + // The floor is today's flat estimate, so behavior never regresses below it. + expect(withOpaqueSchema - withoutSchema).toBeGreaterThanOrEqual(120) + }) + + test('clamps an absurdly large schema to the per-tool ceiling', () => { + // Size-awareness must not let one pathological schema collapse the message + // budget: the counted reservation is capped per tool (8_000 tokens). + const shape: Record = {} + for (let index = 0; index < 1_500; index++) { + shape[`field_${index}`] = z + .string() + .describe(`Description of field number ${index} on this absurd schema`) + } + + const overhead = countRequestOverheadTokens({ + tools: { + absurd_tool: { + description: 'A tool with a pathologically large schema', + inputSchema: z.object(shape), + }, + }, + includeTools: true, + }) + + // Materially size-aware (far above the 120-token floor) but still bounded + // by the ceiling plus the exactly-counted name/description. + expect(overhead).toBeGreaterThan(1_000) + expect(overhead).toBeLessThan(8_100) + }) + + test('counts plain JSON Schema tool surfaces exactly', () => { + const jsonSchema = { + type: 'object', + properties: { path: { type: 'string', description: 'File path' } }, + required: ['path'], + } + + const bare = countRequestOverheadTokens({ + tools: { + read_file: { description: 'Read a file', inputSchema: jsonSchema }, + }, + includeTools: true, + }) + // The AI-SDK `jsonSchema()` wrapper exposes the same schema on a + // `jsonSchema` property, so both forms must count identically. + const wrapped = countRequestOverheadTokens({ + tools: { + read_file: { description: 'Read a file', inputSchema: { jsonSchema } }, + }, + includeTools: true, + }) + + expect(bare).toBe(wrapped) + expect(bare).toBe( + countTokensJson({ + name: 'read_file', + description: 'Read a file', + input_schema: jsonSchema, + }), + ) + }) + + test('never serializes a self-referential tool schema', () => { + const cyclic: Record = { type: 'object' } + cyclic.self = cyclic + + // A cyclic non-schema object cannot be converted to JSON Schema, so the + // conversion attempt must be swallowed and the tool must fall back to the + // flat floor rather than throwing out of countRequestOverheadTokens. + + expect(() => + countRequestOverheadTokens({ + system: 'system prompt', + tools: { + looping: { description: 'Cyclic schema', inputSchema: cyclic }, + }, + includeTools: true, + }), + ).not.toThrow() + expect( + countRequestOverheadTokens({ + tools: { + looping: { description: 'Cyclic schema', inputSchema: cyclic }, + }, + includeTools: true, + }), + ).toBeLessThan(1_000) + }) + + test('omits the tool surface when the provider strips tools', () => { + const tools = { + read_file: { + description: 'Read a file', + inputSchema: z.object({ path: z.string() }), + }, + } + + expect( + countRequestOverheadTokens({ + system: 'system prompt', + tools, + includeTools: false, + }), + ).toBe(countTokensJson('system prompt')) + }) + test('extracts provider context limits from oversized-prompt errors', () => { expect( getProviderContextLimitFromError( diff --git a/sdk/src/impl/llm.ts b/sdk/src/impl/llm.ts index 9832f75b92..39803b4668 100644 --- a/sdk/src/impl/llm.ts +++ b/sdk/src/impl/llm.ts @@ -11,6 +11,7 @@ import { convertCbToModelMessages } from '@codebuff/common/util/messages' import { isExplicitlyDefinedModel } from '@codebuff/common/util/model-utils' import { StopSequenceHandler } from '@codebuff/common/util/stop-sequence' import { + asSchema, streamText, generateText, generateObject, @@ -60,10 +61,7 @@ import type { OpenRouterProviderOptions } from '@codebuff/internal/openrouter-ai import type { GenerateObjectResult, LanguageModel } from 'ai' import type z from 'zod/v4' import { trimMessagesToFitTokenLimit } from '@codebuff/agent-runtime/util/messages' -import { - DEFAULT_MAX_CONTEXT_TOKENS, - getModelContextMessageLimit, -} from '@codebuff/agent-runtime/util/context-pruning' +import { getModelContextMessageLimit } from '@codebuff/agent-runtime/util/context-pruning' import { countTokensJson } from '@codebuff/agent-runtime/util/token-counter' import type { Message } from '@codebuff/common/types/messages/codebuff-message' @@ -378,25 +376,230 @@ function emitCacheDebugUsage(params: { const POST_STREAM_METADATA_TIMEOUT_MS = 500 +/** + * Depth cap for the JSON-safety probe below. Real JSON Schemas nest far + * shallower than this, and the cap also bounds the walk on a self-referential + * object instead of recursing forever. + */ +const MAX_TOOL_SCHEMA_PROBE_DEPTH = 12 + +/** + * Floor for the per-tool reservation of an `inputSchema` that is a + * validation-library schema instance (`tool({ inputSchema: z.object({...}) })` + * — the canonical form the published `promptAiSdkStream`/`promptAiSdk` param + * types accept) rather than a plain JSON Schema. Such objects are not JSON + * data: they carry functions and self-references, so serializing them either + * throws on a cycle or emits library internals whose size bears no relation to + * the schema the provider actually receives. The schema is therefore converted + * to a real JSON Schema and counted from that projection (see + * {@link countOpaqueToolSchemaTokens}); this floor is what a tool reserves when + * the conversion is impossible or yields something smaller, so a failed + * conversion never reserves less than the previous flat estimate did. + */ +const OPAQUE_TOOL_SCHEMA_FALLBACK_TOKENS = 120 + +/** + * Per-tool ceiling on the converted schema count above. Counting the real JSON + * Schema keeps a large tool surface from being materially under-reserved, but + * `getMessagesForModelContext` subtracts this overhead from the message budget + * one-for-one, so a single absurd schema must not be able to collapse that + * budget to nothing. + */ +const MAX_COUNTED_TOOL_SCHEMA_TOKENS = 8_000 + +/** + * True when `value` is plain JSON data that `JSON.stringify` reproduces + * faithfully: primitives, arrays, and plain objects only. Class instances + * (Zod and other Standard Schema objects), functions, and anything nested + * deeper than {@link MAX_TOOL_SCHEMA_PROBE_DEPTH} — which includes every + * self-referential structure — are rejected. + */ +function isJsonData(value: unknown, depth = 0): boolean { + if (value === null) return true + const valueType = typeof value + if ( + valueType === 'string' || + valueType === 'number' || + valueType === 'boolean' + ) { + return true + } + if (valueType !== 'object') return false + if (depth >= MAX_TOOL_SCHEMA_PROBE_DEPTH) return false + if (Array.isArray(value)) { + return value.every((entry) => isJsonData(entry, depth + 1)) + } + const prototype = Object.getPrototypeOf(value) + if (prototype !== Object.prototype && prototype !== null) return false + return Object.values(value as Record).every( + (entry) => entry === undefined || isJsonData(entry, depth + 1), + ) +} + +/** + * The plain JSON Schema for a tool's `inputSchema` when one is directly + * available — either the AI-SDK `jsonSchema()` wrapper's own `jsonSchema` + * property or a bare JSON Schema object. Returns `undefined` for an opaque + * validation-library schema, which is then estimated rather than serialized. + */ +function getPlainToolInputSchema(inputSchema: unknown): unknown { + if (!inputSchema || typeof inputSchema !== 'object') return undefined + const record = inputSchema as Record + if (isJsonData(record.jsonSchema)) return record.jsonSchema + if (isJsonData(record)) return record + return undefined +} + +/** + * Size-aware token reservation for a tool whose `inputSchema` is an opaque + * validation-library schema instance. The instance itself is never + * `JSON.stringify`-ed: it is converted to a real JSON Schema with the AI SDK's + * `asSchema` helper, validated with {@link isJsonData}, counted from that + * projection, and clamped into + * `[OPAQUE_TOOL_SCHEMA_FALLBACK_TOKENS, MAX_COUNTED_TOOL_SCHEMA_TOKENS]`. + * + * Conversion is best-effort and can never propagate a failure out of + * {@link countRequestOverheadTokens}: a non-schema or self-referential object + * makes `asSchema` (or reading its `jsonSchema` property) throw, and any result + * that is not plain JSON data — a `Promise`, a wrapper, an absent property — is + * treated as a failed conversion. Both cases fall back to the floor instead of + * charging library internals against the message budget. + */ +function countOpaqueToolSchemaTokens(inputSchema: unknown): number { + let counted = 0 + try { + const { jsonSchema } = asSchema( + inputSchema as Parameters[0], + ) as { jsonSchema?: unknown } + if (isJsonData(jsonSchema)) { + // `isJsonData` returns a plain boolean rather than a type predicate, so + // narrow explicitly. It has already proven this is JSON data, and only a + // non-null object or primitive reaches `countTokensJson`. + counted = countTokensJson(jsonSchema as string | object) + } + } catch { + // Any conversion failure (non-schema input, cycle, throwing getter) leaves + // `counted` at 0 so the clamp below reserves the flat floor. + } + return Math.max( + OPAQUE_TOOL_SCHEMA_FALLBACK_TOKENS, + Math.min(MAX_COUNTED_TOOL_SCHEMA_TOKENS, counted), + ) +} + +/** + * Token cost of the tool surface of a request, counted from a JSON-safe + * projection of the caller-supplied `tools` rather than from the object + * itself. The projection mirrors the Anthropic-shaped `toolsForTokenCount` + * list the runtime counts for pruning, so both budgets measure the same + * surface. + */ +function countToolSurfaceTokens(tools: unknown): number { + if (!tools || typeof tools !== 'object') return 0 + + let total = 0 + for (const [name, tool] of Object.entries(tools as Record)) { + const definition = + tool && typeof tool === 'object' ? (tool as Record) : {} + const description = + typeof definition.description === 'string' + ? definition.description + : undefined + const inputSchema = getPlainToolInputSchema(definition.inputSchema) + + total += countTokensJson({ + name, + ...(description !== undefined && { description }), + ...(inputSchema !== undefined && { input_schema: inputSchema }), + }) + if (inputSchema === undefined && definition.inputSchema !== undefined) { + total += countOpaqueToolSchemaTokens(definition.inputSchema) + } + } + return total +} + +/** + * Token cost of the non-message request surface (system prompt + tool schemas) + * that the request-time trim must subtract from the model's message budget so + * the SDK brake reserves the same kind of overhead the runtime's + * `maybePruneContext` reserves. The two numbers are not required to be equal: + * this count is derived from the request's own tool objects (see the + * compatibility contract below), not from the runtime's serialized tool + * definitions, so for the same model the two projections can diverge. + * + * Compatibility contract for the exported request paths: the caller-supplied + * `tools` object is never serialized directly, because the published param + * types accept AI-SDK tools whose `inputSchema` is a Zod (or other Standard + * Schema) instance. Counting those by `JSON.stringify` would throw on a + * self-reference and, when it did not throw, would charge library internals + * against the message budget and collapse it. Names, descriptions, and plain + * JSON Schemas are counted exactly; an opaque schema instance is instead + * converted to a real JSON Schema with the AI SDK's `asSchema` helper and + * counted from that projection, clamped into + * `[OPAQUE_TOOL_SCHEMA_FALLBACK_TOKENS, MAX_COUNTED_TOOL_SCHEMA_TOKENS]` so the + * reservation is size-aware without being pathological. That conversion never + * throws out of this function: a non-schema or self-referential `inputSchema` + * falls back to the floor. Absent fields count 0 rather than counting + * `undefined` — a tool with no `inputSchema` at all reserves nothing for a + * schema — and `includeTools` is false when the provider-compatibility layer + * strips tools from the request. + */ +export function countRequestOverheadTokens(params: { + system?: unknown + tools?: unknown + includeTools: boolean +}): number { + const { system } = params + const systemTokens = + typeof system === 'string' + ? countTokensJson(system) + : typeof system === 'object' && system !== null && isJsonData(system) + ? countTokensJson(system) + : 0 + + return ( + systemTokens + + (params.includeTools ? countToolSurfaceTokens(params.tools) : 0) + ) +} + /** * Request-time emergency-brake trim (M4.3, SPEC R4/AC4). * - * Trims the message array to fit the active model's context window using the - * unified reserved-token policy from `@codebuff/agent-runtime/util/context-pruning` - * (`getModelContextMessageLimit`). When the model window is unknown, falls - * back to the flat `DEFAULT_MAX_CONTEXT_TOKENS` so the SDK and runtime share - * one threshold. + * The resolved message limit comes from `getModelContextMessageLimit` applied + * to the active model's context window (falling back to the flat + * `DEFAULT_MAX_CONTEXT_TOKENS` when the window is unknown). `systemTokens` — + * the token cost of the system prompt plus the tool schemas actually sent with + * the request — is subtracted from that limit, so the effective message budget + * reserves request overhead the way the runtime's `maybePruneContext` does. + * The two budgets are parallel brakes, not mirrored ones: each side counts its + * own projection of the tool surface, and the runtime's limit may additionally + * be capped by a provider `maxContextLength`, so the resulting message budgets + * can differ substantially for the same model. Omitting `systemTokens` (or + * passing 0) reproduces the previous, more permissive behavior in which the + * whole message limit was available to messages alone. * * This is the *last line of defense*: the runtime `maybePruneContext` and the * LLM-based context-pruner agent are expected to keep the conversation under * the unified threshold in steady state. When this function actually has to * drop messages, it emits a `CACHE_EMERGENCY_TRIM` telemetry event so any - * threshold regression is directly observable. + * threshold regression is directly observable. That payload separates the two + * budgets explicitly: `maxTotalTokens` is the resolved request budget, + * `systemTokens` the counted overhead, and `effectiveMessageBudgetTokens` the + * message-only budget actually applied. `triggerBudgetTokens`/ + * `targetBudgetTokens` report that same message-only budget, because they are + * the fields consumers compare against message token counts. */ export function getMessagesForModelContext(params: { messages: Message[] contextWindowTokens?: number maxTotalTokensOverride?: number + /** + * Tokens consumed by the system prompt + tool schemas of this request. + * Subtracted from the resolved message limit; defaults to 0. + */ + systemTokens?: number logger: ParamsOf['logger'] trackEvent?: ParamsOf['trackEvent'] userId?: string @@ -416,9 +619,25 @@ export function getMessagesForModelContext(params: { Math.floor(params.maxTotalTokensOverride), ), ) + // Same numeric sanitation as maxTotalTokensOverride above (non-finite or + // negative coerces to 0, value is floored), plus a floor on the resulting + // message budget: trimMessagesToFitTokenLimit derives + // `maxMessageTokens = maxTotalTokens - systemTokens`, so an oversized system + // surface must not yield a zero/negative budget. + const requestedSystemTokens = + params.systemTokens === undefined || + !Number.isFinite(params.systemTokens) || + params.systemTokens < 0 + ? 0 + : Math.floor(params.systemTokens) + const systemTokens = Math.min( + requestedSystemTokens, + Math.max(0, maxTotalTokens - 1), + ) + const effectiveMessageBudgetTokens = maxTotalTokens - systemTokens const trimmed = trimMessagesToFitTokenLimit({ messages: params.messages, - systemTokens: 0, + systemTokens, maxTotalTokens, logger: params.logger, }) @@ -432,9 +651,20 @@ export function getMessagesForModelContext(params: { const outputTokens = countTokensJson(trimmed) const telemetryProperties = { contextWindowTokens: params.contextWindowTokens, + // `maxTotalTokens` keeps its existing meaning (the resolved request + // budget). The message-only budget after subtracting the system + tool + // surface is reported separately so neither field is ambiguous. maxTotalTokens, - triggerBudgetTokens: maxTotalTokens, - targetBudgetTokens: maxTotalTokens, + systemTokens, + effectiveMessageBudgetTokens, + // `triggerBudgetTokens`/`targetBudgetTokens` are the pair consumers + // compare against message token counts, so they report the threshold + // actually applied to messages — the message-only budget — rather than + // the pre-subtraction request budget. With no system surface (or + // `systemTokens: 0`) both still equal `maxTotalTokens`, so the previous + // values are unchanged for callers that pass no overhead. + triggerBudgetTokens: effectiveMessageBudgetTokens, + targetBudgetTokens: effectiveMessageBudgetTokens, reason: 'Messages exceeded the provider-safe request budget at dispatch time.', inputTokens, @@ -453,7 +683,9 @@ export function getMessagesForModelContext(params: { }, 'Emergency request-time context trim fired (cache_emergency_trim). ' + `Resolved window=${params.contextWindowTokens ?? 'unknown'}, ` + - `trigger=${maxTotalTokens}, target=${maxTotalTokens}. ` + + `trigger=${effectiveMessageBudgetTokens}, target=${effectiveMessageBudgetTokens}, ` + + `systemTokens=${systemTokens}, ` + + `messageBudget=${effectiveMessageBudgetTokens}. ` + 'This indicates the provider-safe request budget was exceeded before ' + 'the SDK fallback; expected ~0 in steady state.', ) @@ -878,6 +1110,16 @@ export async function* promptAiSdkStream( agentProviderOptions: params.agentProviderOptions, }) + // Computed inside the attempt loop rather than hoisted: failover + // and retries can resolve a different model whose compatibility + // layer strips `tools` from the request, and the emergency brake + // must subtract the overhead of the request actually sent. + const requestOverheadTokens = countRequestOverheadTokens({ + system: streamParams.system, + tools: streamParams.tools, + includeTools: compatibility.supportsTools !== false, + }) + response = streamText({ ...streamParams, ...(compatibility.supportsTools === false @@ -891,6 +1133,7 @@ export async function* promptAiSdkStream( messages: params.messages, contextWindowTokens: contextWindowTokens ?? undefined, maxTotalTokensOverride: providerMessageLimitOverride, + systemTokens: requestOverheadTokens, logger, trackEvent, userId, @@ -1513,6 +1756,14 @@ export async function promptAiSdk( cacheDebugCorrelation: params.cacheDebugCorrelation, }) + // Same system/tool surface as the streaming path (generateText params carry + // `system` and `tools`), so the emergency brake subtracts it here too. + const requestOverheadTokens = countRequestOverheadTokens({ + system: params.system, + tools: params.tools, + includeTools: compatibility.supportsTools !== false, + }) + let response: Awaited> try { response = await generateText({ @@ -1527,6 +1778,7 @@ export async function promptAiSdk( messages: getMessagesForModelContext({ messages: params.messages, contextWindowTokens, + systemTokens: requestOverheadTokens, logger, trackEvent: params.trackEvent, userId: params.userId, @@ -1652,6 +1904,10 @@ export async function promptAiSdkStructured( output: 'object', messages: convertCbToModelMessages({ ...params, + // `PromptAiSdkStructuredInput` has no `system`/`tools` request surface + // (unlike the streamText/generateText param types), so there is + // nothing comparable to subtract and systemTokens stays at its 0 + // default here. messages: getMessagesForModelContext({ messages: params.messages, contextWindowTokens,