Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/compaction-context-budget.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

Remind the model of its context budget before automatic compaction, and after compaction point it at the session's event log for exact details.
8 changes: 7 additions & 1 deletion packages/agent-core-v2/docs/state-manifest.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
// references become '(circular)', and class instances collapse to a '(ClassName)'
// marker — the wire shape of an entry is the JSON projection of the type here.
//
// Index (App: 0 keys · Workspace: 6 keys · Session: 9 keys · Agent: 82 keys)
// Index (App: 0 keys · Workspace: 6 keys · Session: 9 keys · Agent: 83 keys)
// App
// Workspace
// workspaceDirs.ephemeralDirs src/workspace/workspaceDirs/workspaceDirsService.ts
Expand Down Expand Up @@ -66,6 +66,7 @@
// fullCompaction.consecutiveOverflowCompactions src/agent/fullCompaction/fullCompactionService.ts
// fullCompaction.lastCompactedTokenCount src/agent/fullCompaction/fullCompactionService.ts
// fullCompaction.observedMaxContextTokensByModel src/agent/fullCompaction/fullCompactionService.ts
// fullCompaction.wireRanges src/agent/fullCompaction/compactionOps.ts
// interruptionReminder src/agent/interruptionReminder/interruptionReminderOps.ts
// llm.requestTrace src/agent/llmRequester/llmRequestOps.ts
// llmRequester.emittedThinkingEffortWarnings src/agent/llmRequester/llmRequesterService.ts
Expand Down Expand Up @@ -1176,6 +1177,11 @@ export interface AgentStateSnapshot {
'fullCompaction': /* CompactionState — packages/agent-core-v2/src/agent/fullCompaction/compactionOps.ts */ {
readonly phase: /* CompactionPhase — packages/agent-core-v2/src/agent/fullCompaction/compactionOps.ts */ 'completed' | 'cancelled' | 'running' | 'idle';
};
// replayable · durable — folds: ContextApplyCompaction, ContextClear
'fullCompaction.wireRanges': readonly /* WireLineRange — packages/agent-core-v2/src/wire/record.ts */ {
readonly start: number;
readonly end: number;
}[];
// src/agent/fullCompaction/fullCompactionService.ts
'fullCompaction.activeTurnId': number | undefined;
'fullCompaction.compactionCountInTurn': number;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ const contextCompactionBaseShape = {
keptHeadUserMessageCount: z.number().optional(),
droppedCount: z.number().optional(),
legacyTail: z.boolean().optional(),
wireLines: z
.object({ start: z.number().int().nonnegative(), end: z.number().int().nonnegative() })
.optional(),
};

const contextApplyCompactionSchema = z.union([
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createDecorator } from "#/_base/di/instantiation";
import type { WireLineRange } from '#/wire/record';

import type { UndoCut } from './contextOps';
import type { LoopRecordedEvent } from './loopEventFold';
Expand All @@ -15,6 +16,7 @@ export interface ContextCompactionInput {
readonly keptUserMessageCount?: number;
readonly keptHeadUserMessageCount?: number;
readonly droppedCount?: number;
readonly wireLines?: WireLineRange;
}

export interface ContextCompactionResult {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte
keptUserMessageCount: result.keptUserMessageCount,
keptHeadUserMessageCount: result.keptHeadUserMessageCount,
droppedCount: result.droppedCount,
wireLines: input.wireLines,
}),
);
this.tokenCounting.rebase(this.scopeContext.agentContext, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ continue:
here is one less thing the next turn must rediscover. Include any required
format for the final answer.

This conversation's event log stays on disk and a recovery pointer is appended below your note automatically, so you need not reproduce long outputs verbatim — keep exact identifiers, key values and error lines, and name anything the next turn should look up.

Your TODO list is re-attached automatically below this note from its live
source, so do not transcribe it — copying it wastes space and can contradict the
live version. What that list cannot hold is the reasoning between tasks — why one
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { renderPrompt } from '#/_base/utils/render-prompt';

import compactionInstructionTemplate from './compaction-instruction.md?raw';

export interface CompactionInstructionInput {
readonly customInstruction?: string;
}

export function renderCompactionInstruction(input: CompactionInstructionInput): string {
const customInstruction = input.customInstruction?.trim() ?? '';
return renderPrompt(compactionInstructionTemplate, {
custom_instruction_block:
customInstruction.length > 0 ? `\nOptional user instruction:\n${customInstruction}\n` : '',
}).trimEnd();
}
17 changes: 17 additions & 0 deletions packages/agent-core-v2/src/agent/fullCompaction/compactionOps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ import { z } from 'zod';

import { AgentEvent2, type AgentDomainTrait } from '#/app/event/event2';
import { defineState } from '#/state/state';
import {
ContextApplyCompaction,
ContextClear,
type ContextApplyCompactionPayload,
} from '#/agent/contextMemory/contextEvents';
import type { WireLineRange } from '#/wire/record';

import type { CompactionBeginData, CompactionResult, CompactionSource } from './types';

Expand Down Expand Up @@ -123,3 +129,14 @@ export const fullCompactionKey = defineState(
s.phase = 'idle';
}
});

export const fullCompactionWireRangesKey = defineState<readonly WireLineRange[]>(
'fullCompaction.wireRanges',
() => [],
)
.replayable({ schema: z.custom<readonly WireLineRange[]>() })
.on(ContextApplyCompaction, (s, e) => {
const wireLines = (e as unknown as ContextApplyCompactionPayload).wireLines;
return wireLines === undefined ? undefined : [...s, wireLines];
})
.on(ContextClear, (s) => (s.length === 0 ? undefined : []));
Comment thread
RealKai42 marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
## Context Recovery
Everything before this note is still on disk in this agent's event log (read-only, append-only):
${wire_path}
${window_lines}
If you need exact command output, file contents, error text, or the wording of an earlier request, look it up there instead of guessing. How to read it:
- Layout: one file per agent. agents/main/ is the main agent; each subagent has its own agents/<agentId>/wire.jsonl. A parent's log holds only the Agent tool call and the subagent's returned result — the subagent's own steps are in its own file.
- Format: one JSON record per line, append-only; `type` says what it is. The conversation is in `context.append_message` (user prompts) and `context.append_loop_event` (event.type: step.begin | content.part [text|think] | tool.call | tool.result | step.end). Every other type (llm.request, usage.record, token_counting.measured, metadata, profile.bind, …) is bookkeeping — skip it.
- Boundaries: `context.apply_compaction` marks a compaction (older lines stay in the file; grep for it to find exact boundaries). `context.undo` count=N retracts the previous N messages — treat retracted content as never having happened. `context.clear` resets the conversation.
- Externalized content: tool results over 50k chars are stored truncated, with an `output_path` to a tool-results/*.txt file holding the full text. Media parts are blob references, not inline.
- Reading: lines are long JSON (often 10k+ chars). Grep the file for a keyword to get line numbers, then Read exactly that line (line_offset=N, n_lines=1) — Read returns wire.jsonl lines whole up to ~150k chars. To pull one field with real newlines: sed -n 'Np' wire.jsonl | jq -r '.event.result.output'. Never Read large ranges — a handful of records can exceed the per-call byte cap.
28 changes: 28 additions & 0 deletions packages/agent-core-v2/src/agent/fullCompaction/contextRecovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { renderPrompt } from '#/_base/utils/render-prompt';
import type { WireLineRange } from '#/wire/record';

import contextRecoveryTemplate from './context-recovery-footer.md?raw';

export const CONTEXT_RECOVERY_HEADING = '## Context Recovery';

export interface ContextRecoveryPointer {
readonly journalPath: string;
readonly windows: readonly WireLineRange[];
}

export function renderContextRecoveryPointer(pointer: ContextRecoveryPointer): string {
const windows = pointer.windows;
const summarized = windows.length - 1;
const lines = windows.map((range, index) => {
const label = `window ${String(index + 1)}: lines ${String(range.start)}–${String(range.end)}`;
return index === summarized ? `${label} ← the conversation this note summarizes` : label;
});
const nextStart = windows[summarized]!.end + 1;
lines.push(
`window ${String(windows.length + 1)} (the one you are in now) starts at line ${String(nextStart)} with the \`context.apply_compaction\` record that carries this note — it is already in your context; no need to read it.`,
);
return renderPrompt(contextRecoveryTemplate, {
wire_path: pointer.journalPath,
window_lines: lines.map((line) => ` ${line}`).join('\n'),
}).trimEnd();
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {
CompactionResult,
CompactionSource,
} from './types';
import type { CompactionTriggerBudget } from './strategy';
import { createDecorator } from "#/_base/di/instantiation";
import type { Event } from '#/_base/event';
import type { Hooks } from '#/hooks';
Expand All @@ -11,6 +12,10 @@ export interface FullCompactionInput {
readonly instruction?: string;
}

export interface CompactionBudget extends CompactionTriggerBudget {
readonly used: number;
}

export interface FullCompactionTask {
readonly abortController: AbortController;
readonly promise: Promise<CompactionResult>;
Expand All @@ -25,6 +30,7 @@ export interface IAgentFullCompactionService {
readonly compacting: FullCompactionTask | null;
begin(input: FullCompactionInput): boolean;
cancel(): void;
budget(): CompactionBudget;

readonly hooks: Hooks<{
onWillCompact: FullCompactionTask;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { Service } from "#/_base/di/service";
import { LifecycleScope } from '#/app/scopes';
import { ScopeActivation, registerScopedService } from '#/_base/di/scope';
import { defineState } from '#/state/state';
import { renderPrompt } from "#/_base/utils/render-prompt";
import { estimateTokensForMessage } from "#/kosong/contract/tokens";
import { buildCompactionSummaryText, isRealUserInput } from '#/agent/contextMemory/compactionHandoff';
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
Expand All @@ -27,6 +26,13 @@ import { stripDynamicToolContext } from '#/agent/toolSelect/dynamicTools';
import { IAgentToolSelectService } from '#/agent/toolSelect/toolSelect';
import { IAgentTodoService } from '#/features/todo/todoService';
import { renderTodoList } from '#/features/todo/todoItem';
import {
isContextBudgetReminder,
summarizeCompactionAheadFollowUp,
} from '#/features/contextBudget/contextBudgetReminder';
import { onUnexpectedError } from '#/_base/errors/unexpectedError';
import type { WireLineRange } from '#/wire/record';
import { IWireService } from '#/wire/wire';
import {
APIContextOverflowError,
APIEmptyResponseError,
Expand All @@ -42,9 +48,11 @@ import { ITelemetryService } from '#/app/telemetry/telemetry';
import { ErrorCodes, Error2, isCodedError, isError2, toKimiErrorPayload, unwrapErrorCause } from "#/errors";
import { AgentErrorEvent } from '#/agent/mcp/mcpEvents';
import { IEventDispatcher } from '#/state/eventDispatcher';
import compactionInstructionTemplate from './compaction-instruction.md?raw';
import { renderCompactionInstruction } from './compactionInstruction';
import { renderContextRecoveryPointer } from './contextRecovery';
import {
IAgentFullCompactionService,
type CompactionBudget,
type FullCompactionInput,
type FullCompactionTask,
} from './fullCompaction';
Expand All @@ -57,6 +65,7 @@ import {
CompactionCancelled,
CompactionCompleted,
fullCompactionKey,
fullCompactionWireRangesKey,
FullCompactionBegin,
FullCompactionCancel,
FullCompactionComplete,
Expand Down Expand Up @@ -150,9 +159,11 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom
@IEventBus private readonly eventBus: IEventBus,
@IAgentLoopService private readonly loopService: IAgentLoopService,
@IAgentStateService private readonly states: IAgentStateService,
@IWireService private readonly wire: IWireService,
) {
super();
this.states.contributeState(fullCompactionKey);
this.states.contributeState(fullCompactionWireRangesKey);
this.states.contributeState(fullCompactionCompactionCountInTurnKey);
this.states.contributeState(fullCompactionObservedMaxContextTokensByModelKey);
this.states.contributeState(fullCompactionLastCompactedTokenCountKey);
Expand Down Expand Up @@ -237,6 +248,10 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom
return this._compacting;
}

budget(): CompactionBudget {
return { used: this.tokenCountWithPending(), ...this.strategy.budget() };
}

cancel(): void {
const active = this._compacting;
if (active !== null) {
Expand Down Expand Up @@ -633,15 +648,13 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom
: undefined;
const compactionMaxOutputSize = resolvedModel.maxOutputSize ?? defaultCompactionCap;

const customInstruction = data.instruction?.trim() ?? '';
const instruction = renderPrompt(compactionInstructionTemplate, {
custom_instruction_block:
customInstruction.length > 0 ? `\nOptional user instruction:\n${customInstruction}\n` : '',
}).trimEnd();
const instruction = renderCompactionInstruction({ customInstruction: data.instruction });

const delays = retryBackoffDelays(MAX_COMPACTION_RETRY_ATTEMPTS);
let attempt: CompactionAttemptResult | undefined;
let historyForModel: readonly ContextMessage[] = stripDynamicToolContext(originalHistory);
let historyForModel: readonly ContextMessage[] = stripDynamicToolContext(originalHistory).filter(
(message) => !isContextBudgetReminder(message),
);
let droppedCount = 0;
let overflowShrinkCount = 0;
let emptyOrTruncatedShrinkCount = 0;
Expand Down Expand Up @@ -688,6 +701,7 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom
overflowShrinkCount,
(message) => this.tokenCounting.estimateMessage(message),
);
if (historyForModel.length === 0) throw error;
droppedCount += before - historyForModel.length;
retryCount = 0;
continue;
Expand Down Expand Up @@ -735,14 +749,23 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom
}

const summary = await this.postProcessSummary(attempt.summary);
const wireLines = await this.captureWireLines();
const recoveryFooter = this.renderRecoveryFooter(wireLines);
const summaryText = buildCompactionSummaryText(summary);
const result = this.context.applyCompaction({
summary,
contextSummary: buildCompactionSummaryText(summary),
contextSummary:
recoveryFooter === undefined ? summaryText : `${summaryText}\n\n${recoveryFooter}`,
compactedCount: originalHistory.length,
tokensBefore,
summaryOutputTokens: attempt.usage?.output,
summaryOutputTokens:
attempt.usage === null
? undefined
: attempt.usage.output +
(recoveryFooter === undefined ? 0 : this.tokenCounting.estimateText(recoveryFooter)),
requestOverheadTokens: this.requestTokens([]),
droppedCount: droppedCount === 0 ? undefined : droppedCount,
wireLines,
});

const properties: CompactionFinishedEvent = {
Expand All @@ -758,6 +781,7 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom
thinking_effort: thinkingEffort,
trace_id: attempt.traceId,
...usageTelemetry(attempt.usage),
...aheadReminderTelemetry(originalHistory),
};
this.telemetry.track2('compaction_finished', properties);
return result;
Expand Down Expand Up @@ -794,11 +818,56 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom
return `${summary.trim()}\n\n${renderTodoList(todos, '## TODO List')}`;
}

private async captureWireLines(): Promise<WireLineRange | undefined> {
try {
await this.wire.flush();
} catch (error) {
onUnexpectedError(error);
return undefined;
}
const end = this.wire.lineCount();
const previous = this.states.get(fullCompactionWireRangesKey).at(-1);
const start = Math.max(previous?.end ?? 0, this.wire.lastContextClearLine() ?? 0) + 1;
if (end < start) return undefined;
return { start, end };
}

private renderRecoveryFooter(wireLines: WireLineRange | undefined): string | undefined {
if (wireLines === undefined) return undefined;
const journalPath = this.wire.journalPath();
if (journalPath === undefined) return undefined;
const windows = [...this.states.get(fullCompactionWireRangesKey), wireLines];
return renderContextRecoveryPointer({ journalPath, windows });
}

private tokenCountWithPending(): number {
return this.tokenCounting.get(agentContextOfScope(this.agent)).size;
}
}

type CompactionAheadTelemetryProperties = Pick<
CompactionFinishedEvent,
| 'ahead_reminder_delivered'
| 'ahead_steps_count'
| 'ahead_write_calls_count'
| 'ahead_bash_calls_count'
| 'ahead_todo_calls_count'
>;

function aheadReminderTelemetry(
history: readonly ContextMessage[],
): CompactionAheadTelemetryProperties {
const followUp = summarizeCompactionAheadFollowUp(history);
if (followUp === undefined) return { ahead_reminder_delivered: false };
return {
ahead_reminder_delivered: true,
ahead_steps_count: followUp.stepCount,
ahead_write_calls_count: followUp.writeCallCount,
ahead_bash_calls_count: followUp.bashCallCount,
ahead_todo_calls_count: followUp.todoCallCount,
};
}

function findAPIStatusError(error: unknown): APIStatusError | undefined {
let current: unknown = error;
const seen = new Set<unknown>();
Expand Down
Loading
Loading