fix: full compaction

This commit is contained in:
_Kerman 2026-07-03 22:59:42 +08:00
parent 5ccfe76546
commit 0f77fa8a2e
11 changed files with 507 additions and 42 deletions

View file

@ -279,7 +279,6 @@ mcp --> toolRegistry #34495E
mcp --> record #34495E
mcp --> toolExecutor #34495E
fullCompaction --> contextMemory #34495E
fullCompaction --> contextProjector #34495E
fullCompaction --> contextSize #34495E
fullCompaction --> llmRequester #34495E
fullCompaction --> profile #34495E

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 243 KiB

After

Width:  |  Height:  |  Size: 243 KiB

Before After
Before After

View file

@ -79,15 +79,22 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte
}
private applySplice(record: AgentRecord<'context.splice'>): void {
const removedMessages =
record.deleteCount > 0 && record.start > 0
? this.history.slice(record.start, record.start + record.deleteCount)
: [];
// A boundary splice (`start === 0 && deleteCount > 0`, i.e. compaction or
// clear — see `isUndoBoundaryRecord`) never touches the replay: the removed
// transcript stays visible, and what it inserts (a compaction summary) is
// context machinery represented by its owner's record, not a message.
// Every other splice mirrors itself into the replay.
const boundary = record.start === 0 && record.deleteCount > 0;
const removedMessages = boundary
? []
: this.history.slice(record.start, record.start + record.deleteCount);
const messages = record.messages.map(ensureMessageId);
this.history.splice(record.start, record.deleteCount, ...messages);
this.record.removeLastMessages(new Set(removedMessages));
for (const message of messages) {
this.record.push({ type: 'message', message });
if (!boundary) {
this.record.removeLastMessages(new Set(removedMessages));
for (const message of messages) {
this.record.push({ type: 'message', message });
}
}
void this.hooks.onSpliced.run({
start: record.start,

View file

@ -10,8 +10,6 @@ export type FullCompactionCompleteData = Omit<CompactionResult, 'summary'>;
export interface CompactInput {
readonly source: CompactionSource;
readonly instruction?: string;
readonly customInstruction?: string;
readonly signal?: AbortSignal;
}
export interface FullCompactionWillCompactContext {

View file

@ -7,7 +7,6 @@ import { renderPrompt } from "#/_base/utils/render-prompt";
import { estimateTokens, estimateTokensForMessages } from "#/_base/utils/tokens";
import type { ContextMessage } from '#/agent/contextMemory';
import { IAgentContextMemoryService } from '#/agent/contextMemory';
import { IAgentContextProjectorService } from '#/agent/contextProjector';
import { IAgentContextSizeService } from '#/agent/contextSize';
import {
IAgentLLMRequesterService,
@ -30,6 +29,7 @@ import {
APIContextOverflowError,
APIEmptyResponseError,
createUserMessage,
type Message,
type TokenUsage
} from '#/app/llmProtocol';
import { ITelemetryService } from '#/app/telemetry';
@ -81,7 +81,6 @@ interface ActiveCompaction {
interface CompactionAttemptResult {
readonly summary: string;
readonly usage: TokenUsage | null;
readonly model: string | undefined;
}
class CompactionTruncatedError extends Error {
@ -101,11 +100,20 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
private readonly strategy: CompactionStrategy;
private compactionCountInTurn = 0;
private compacting: ActiveCompaction | null = null;
// Token count right after the last successful compaction. While nothing new
// has been appended, the history is already in its minimal compacted form;
// re-compacting would only summarize the summary again, so
// checkAutoCompaction skips in that case.
private lastCompactedTokenCount: number | null = null;
// Counts provider-overflow recoveries in this turn that have not yet been
// followed by a successful step. Trips maxOverflowCompactionAttempts to
// stop an overflow -> compact -> overflow loop when compaction can no
// longer shrink the request below the model window.
private consecutiveOverflowCompactions = 0;
constructor(
private readonly options: FullCompactionServiceOptions = {},
@IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
@IAgentContextProjectorService private readonly projector: IAgentContextProjectorService,
@IAgentContextSizeService private readonly contextSize: IAgentContextSizeService,
@IAgentLLMRequesterService private readonly llmRequester: IAgentLLMRequesterService,
@IAgentProfileService private readonly profile: IAgentProfileService,
@ -162,10 +170,11 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
this._register(
record.define('full_compaction.complete', {
resume: (r) => {
// The summary message never enters the replay (its splice is a
// boundary); the compaction record is its only replay presence.
const message = compactionSummaryMessage(this.context.get());
if (message === undefined) return;
const summary = contextMessageText(message);
this.record.removeLastMessages(new Set([message]));
this.record.patchLast('compaction', {
result: {
summary,
@ -185,7 +194,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
begin(input: CompactInput): boolean {
if (this.compacting) return false;
const data = this.beginData(input);
const data: CompactionBeginData = { source: input.source, instruction: input.instruction };
if (data.source === 'manual') {
this.compactionCountInTurn = 0;
} else {
@ -233,12 +242,23 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
private resetForTurn(): void {
this.compactionCountInTurn = 0;
this.lastCompactedTokenCount = null;
this.consecutiveOverflowCompactions = 0;
}
private async onContextOverflow(
context: TurnContextOverflowContext,
next: () => Promise<void>,
): Promise<void> {
this.consecutiveOverflowCompactions += 1;
const maxAttempts = this.strategy.maxOverflowCompactionAttempts;
if (this.consecutiveOverflowCompactions > maxAttempts) {
throw new KimiError(
ErrorCodes.CONTEXT_OVERFLOW,
`Compaction failed to bring the context under the model window after ${String(maxAttempts)} attempts.`,
{ cause: context.error instanceof Error ? context.error : undefined },
);
}
const didStartCompaction = this.beginAutoCompaction();
if (!didStartCompaction && !this.compacting) {
await next();
@ -256,6 +276,10 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
}
private async afterStep(): Promise<void> {
// A completed step means a request succeeded, so any prior
// overflow -> compact cycle produced a request that now fits; clear the
// loop guard.
this.consecutiveOverflowCompactions = 0;
if (this.strategy.checkAfterStep) {
this.checkAutoCompaction(false);
}
@ -263,6 +287,12 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
private checkAutoCompaction(throwOnLimit = true): boolean {
if (this.compacting) return true;
if (
this.lastCompactedTokenCount !== null &&
this.tokenCountWithPending() <= this.lastCompactedTokenCount
) {
return false;
}
if (!this.strategy.shouldCompact(this.tokenCountWithPending())) return false;
return this.beginAutoCompaction(throwOnLimit);
}
@ -328,6 +358,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
}
if (this.compacting !== active) return;
this.lastCompactedTokenCount = finalResult.tokensAfter;
this.markCompleted(completeData(finalResult));
this.record.signal({ type: 'compaction.completed', result: finalResult });
void this.hooks.onDidCompact.run({
@ -365,11 +396,15 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
let compactedCount = initialCompactedCount;
signal.throwIfAborted();
await this.hooks.onWillCompact.run({
trigger: data.source,
tokenCount: tokensBefore,
signal,
});
// One logical compaction fires the hook once, even when it takes
// multiple window-sized rounds to bring the context under the ratio.
if (round === 1) {
await this.hooks.onWillCompact.run({
trigger: data.source,
tokenCount: tokensBefore,
signal,
});
}
const resolvedModel = this.profile.resolveModelContext();
const maxContextTokens = resolvedModel.modelCapabilities.max_context_tokens;
@ -379,16 +414,17 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
: undefined;
const compactionMaxOutputSize = resolvedModel.maxOutputSize ?? defaultCompactionCap;
const instruction = renderPrompt(compactionInstructionTemplate, {
customInstruction: data.instruction?.trim() ?? '',
}).trimEnd();
const delays = retryBackoffDelays(MAX_COMPACTION_RETRY_ATTEMPTS);
let attempt: CompactionAttemptResult | undefined;
while (true) {
const messagesToCompact = originalHistory.slice(0, compactedCount);
const messages = [
...this.projector.project(messagesToCompact),
createUserMessage(renderPrompt(compactionInstructionTemplate, {
customInstruction: data.instruction ?? '',
})),
];
// Raw context slice — `llmRequester` projects every request once;
// projecting here too would run micro-compaction on shifted indices.
const messages: Message[] = [...messagesToCompact, createUserMessage(instruction)];
try {
attempt = collectSummary(
@ -409,7 +445,13 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
error instanceof CompactionTruncatedError ||
error instanceof APIEmptyResponseError
) {
compactedCount = this.strategy.reduceCompactOnOverflow(messagesToCompact);
const reduced = this.strategy.reduceCompactOnOverflow(messagesToCompact);
// An overflow that cannot shrink further would replay the same
// request; give up (v1: throws when the history cannot shrink).
if (error instanceof APIContextOverflowError && reduced >= compactedCount) {
throw error;
}
compactedCount = reduced;
} else {
throw error;
}
@ -443,6 +485,8 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
};
this.telemetry.track('compaction_finished', {
// Never send `data.instruction` (user-authored content) to telemetry.
source: data.source,
tokens_before: result.tokensBefore,
tokens_after: result.tokensAfter,
duration_ms: Date.now() - startedAt,
@ -451,7 +495,6 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
round,
thinking_level: this.profile.data().thinkingLevel,
...usageTelemetry(attempt.usage),
...data,
});
this.context.splice(
@ -464,7 +507,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
} catch (error) {
if (isAbortError(error)) return undefined;
this.telemetry.track('compaction_failed', {
...data,
source: data.source,
tokens_before: tokensBefore,
duration_ms: Date.now() - startedAt,
round,
@ -497,13 +540,6 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
private tokenCountWithPending(): number {
return this.contextSize.getStatus().contextTokensWithPending;
}
private beginData(input: CompactInput): CompactionBeginData {
return {
source: input.source,
instruction: input.instruction ?? input.customInstruction,
};
}
}
function collectSummary(finish: LLMRequestFinish): CompactionAttemptResult {
@ -522,7 +558,7 @@ function collectSummary(finish: LLMRequestFinish): CompactionAttemptResult {
);
}
return { summary, usage: finish.usage, model: finish.model };
return { summary, usage: finish.usage };
}
function createCompactionSummaryMessage(summary: string): ContextMessage {
@ -559,9 +595,10 @@ function historyUnchanged(
original: readonly ContextMessage[],
): boolean {
// Only the compacted prefix must be intact. Messages appended to the tail
// while the summary request was in flight are fine — the splice replaces just
// the prefix and leaves the appended tail in place (matching legacy, which
// compared `newHistory[i] !== originalHistory[i]` over the original length).
// while the summary request was in flight are fine — unlike legacy's
// whole-history rebuild (which had to cancel when non-user messages grew the
// tail), the splice replaces just the prefix and leaves the appended tail in
// place, so nothing appended concurrently can be lost.
if (current.length < original.length) return false;
return original.every((message, index) => message === current[index]);
}

View file

@ -8,6 +8,7 @@ export interface CompactionConfig {
blockRatio: number;
reservedContextSize: number;
maxCompactionPerTurn: number;
maxOverflowCompactionAttempts: number;
maxRecentMessages: number;
maxRecentUserMessages: number;
maxRecentSizeRatio: number;
@ -19,6 +20,7 @@ export const DEFAULT_COMPACTION_CONFIG: CompactionConfig = {
blockRatio: 0.85, // Same as triggerRatio to disable async compaction
reservedContextSize: 50_000,
maxCompactionPerTurn: Infinity,
maxOverflowCompactionAttempts: 3,
maxRecentMessages: 4,
maxRecentUserMessages: Infinity,
maxRecentSizeRatio: 0.2,
@ -32,6 +34,7 @@ export interface CompactionStrategy {
reduceCompactOnOverflow(messages: readonly Message[]): number;
readonly checkAfterStep: boolean;
readonly maxCompactionPerTurn: number;
readonly maxOverflowCompactionAttempts: number;
}
export class RuntimeCompactionStrategy implements CompactionStrategy {
@ -61,6 +64,10 @@ export class RuntimeCompactionStrategy implements CompactionStrategy {
return DEFAULT_COMPACTION_CONFIG.maxCompactionPerTurn;
}
get maxOverflowCompactionAttempts(): number {
return DEFAULT_COMPACTION_CONFIG.maxOverflowCompactionAttempts;
}
private delegate(): DefaultCompactionStrategy {
const model = this.context();
return new DefaultCompactionStrategy(
@ -233,6 +240,10 @@ export class DefaultCompactionStrategy implements CompactionStrategy {
get maxCompactionPerTurn(): number {
return this.config.maxCompactionPerTurn;
}
get maxOverflowCompactionAttempts(): number {
return this.config.maxOverflowCompactionAttempts;
}
}
/**

View file

@ -288,7 +288,13 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService {
this.config.get<KimiModelOverrides>('modelOverrides')?.maxCompletionTokens,
}),
capability: resolved.modelCapabilities,
usedContextTokens: this.contextSize.getStatus().contextTokens,
// The remaining-window clamp only applies to requests built from the
// live context; overridden messages (e.g. compaction) are sized
// independently and would be squeezed to nothing at high water marks.
usedContextTokens:
overrides.messages === undefined
? this.contextSize.getStatus().contextTokens
: undefined,
});
const messages = overrides.messages ?? this.context.get();

View file

@ -0,0 +1,164 @@
/**
* `AgentContextMemoryService.applySplice` replay contract, exercised without
* the full agent harness: a boundary splice (`start === 0 && deleteCount > 0`,
* i.e. compaction/clear) never touches the replay; every other splice mirrors
* itself (removes deleted messages, pushes inserted ones).
*/
import { describe, expect, it } from 'vitest';
import {
AgentContextMemoryService,
type ContextMessage,
} from '#/agent/contextMemory';
import type { AgentRecord, IAgentRecordService } from '#/agent/record';
import type { AgentReplayRecordPayload } from '#/agent/replayBuilder/types';
import { stubRecord } from './stubs';
interface RecordingRecordStub {
readonly record: IAgentRecordService;
readonly pushed: AgentReplayRecordPayload[];
readonly removed: ContextMessage[][];
readonly resume: (record: AgentRecord<'context.splice'>) => void;
}
function recordingRecord(): RecordingRecordStub {
const pushed: AgentReplayRecordPayload[] = [];
const removed: ContextMessage[][] = [];
let resume: ((record: AgentRecord<'context.splice'>) => void) | undefined;
const base = stubRecord();
const record: IAgentRecordService = {
...base,
define: (type, facets) => {
if (type === 'context.splice' && facets.resume !== undefined) {
resume = facets.resume as unknown as (record: AgentRecord<'context.splice'>) => void;
}
return base.define(type, facets);
},
push: (payload) => {
pushed.push(payload);
},
removeLastMessages: (messages) => {
if (messages.size > 0) removed.push([...messages]);
},
};
return {
record,
pushed,
removed,
resume: (spliceRecord) => {
if (resume === undefined) throw new Error('context.splice resumer not registered');
resume(spliceRecord);
},
};
}
function userMessage(text: string): ContextMessage {
return { role: 'user', content: [{ type: 'text', text }], toolCalls: [] };
}
function summaryMessage(text: string): ContextMessage {
return {
role: 'assistant',
content: [{ type: 'text', text }],
toolCalls: [],
origin: { kind: 'compaction_summary' },
};
}
describe('AgentContextMemoryService splice replay contract', () => {
it('pushes appended messages into the replay', () => {
const stub = recordingRecord();
const context = new AgentContextMemoryService(stub.record);
context.splice(0, 0, [userMessage('hello')]);
context.splice(context.get().length, 0, [userMessage('world')]);
expect(stub.pushed).toHaveLength(2);
expect(stub.pushed[1]).toMatchObject({
type: 'message',
message: expect.objectContaining({ content: [{ type: 'text', text: 'world' }] }),
});
expect(stub.removed).toHaveLength(0);
});
it('mirrors mid-history removals (undo-shaped splices) into the replay', () => {
const stub = recordingRecord();
const context = new AgentContextMemoryService(stub.record);
context.splice(0, 0, [userMessage('keep'), userMessage('drop')]);
context.splice(1, 1, []);
expect(context.get().map((m) => m.content)).toEqual([[{ type: 'text', text: 'keep' }]]);
expect(stub.removed).toHaveLength(1);
expect(stub.removed[0]![0]).toMatchObject({ content: [{ type: 'text', text: 'drop' }] });
});
it('mirrors in-place replacements (migrated step updates) into the replay', () => {
const stub = recordingRecord();
const context = new AgentContextMemoryService(stub.record);
context.splice(0, 0, [userMessage('prompt'), userMessage('partial')]);
stub.pushed.length = 0;
context.splice(1, 1, [userMessage('final')]);
expect(stub.removed).toHaveLength(1);
expect(stub.removed[0]![0]).toMatchObject({ content: [{ type: 'text', text: 'partial' }] });
expect(stub.pushed).toHaveLength(1);
expect(stub.pushed[0]).toMatchObject({
type: 'message',
message: expect.objectContaining({ content: [{ type: 'text', text: 'final' }] }),
});
});
it('leaves the replay untouched for boundary splices (compaction)', () => {
const stub = recordingRecord();
const context = new AgentContextMemoryService(stub.record);
context.splice(0, 0, [userMessage('old 1'), userMessage('old 2')]);
stub.pushed.length = 0;
context.splice(0, 2, [summaryMessage('summary')]);
expect(context.get()).toHaveLength(1);
expect(stub.removed).toHaveLength(0);
expect(stub.pushed).toHaveLength(0);
});
it('leaves the replay untouched for boundary splices (clear)', () => {
const stub = recordingRecord();
const context = new AgentContextMemoryService(stub.record);
context.splice(0, 0, [userMessage('one'), userMessage('two')]);
stub.pushed.length = 0;
context.splice(0, context.get().length, []);
expect(context.get()).toHaveLength(0);
expect(stub.removed).toHaveLength(0);
expect(stub.pushed).toHaveLength(0);
});
it('applies the same contract on the resume path', () => {
const stub = recordingRecord();
const context = new AgentContextMemoryService(stub.record);
stub.resume({
type: 'context.splice',
start: 0,
deleteCount: 0,
messages: [userMessage('restored 1'), userMessage('restored 2')],
});
stub.resume({
type: 'context.splice',
start: 0,
deleteCount: 2,
messages: [summaryMessage('restored summary')],
});
expect(context.get()).toHaveLength(1);
// Appends were pushed; the boundary splice neither removed nor pushed, so
// the restored transcript keeps the pre-compaction messages and the
// summary never appears as a plain message.
expect(stub.pushed).toHaveLength(2);
expect(stub.removed).toHaveLength(0);
});
});

View file

@ -0,0 +1,236 @@
/**
* Benchmark for the context projection rewrite (two-pass -> single-pass with
* slot backfill, and O(k²) -> O(k) adjacent user-prompt merging).
*
* `projectLegacy` below is the previous implementation, copied verbatim so the
* comparison stays runnable after the old code is gone. The "new" side goes
* through the real `AgentContextProjectorService` with micro-compaction
* stubbed to a pass-through, so it measures exactly the projection path.
*
* Run:
* pnpm --filter @moonshot-ai/agent-core-v2 exec vitest bench test/contextProjector/projector.bench.ts
*/
import { bench, describe } from 'vitest';
import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore } from '#/_base/di/lifecycle';
import { TestInstantiationService } from '#/_base/di/test';
import type { ContextMessage } from '#/agent/contextMemory';
import {
AgentContextProjectorService,
IAgentContextProjectorService,
} from '#/agent/contextProjector';
import { IAgentMicroCompactionService } from '#/agent/microCompaction';
import { ErrorCodes, KimiError } from '#/errors';
import type { ContentPart, Message, TextPart, ToolCall } from '#/app/llmProtocol';
// ---------------------------------------------------------------------------
// Legacy implementation (verbatim copy of the pre-rewrite `project`)
// ---------------------------------------------------------------------------
function projectLegacy(history: readonly ContextMessage[]): Message[] {
const openCalls = new Map<string, ToolCall>();
const answers = new Map<ToolCall, ContextMessage>();
let hasAssistant = false;
for (const message of history) {
if (message.partial === true) continue;
if (message.role === 'assistant') {
hasAssistant = true;
for (const call of message.toolCalls) openCalls.set(call.id, call);
} else if (message.role === 'tool' && message.toolCallId !== undefined) {
const call = openCalls.get(message.toolCallId);
if (call === undefined) continue;
answers.set(call, message);
openCalls.delete(message.toolCallId);
}
}
const out: Message[] = [];
let mergeSource: ContextMessage | undefined;
const emit = (source: ContextMessage): void => {
const content = source.content.some(isBlankText)
? source.content.filter((part) => !isBlankText(part))
: source.content;
if (source.role === 'tool' && content.length === 0) {
throw new KimiError(
ErrorCodes.REQUEST_INVALID,
'Tool result message content cannot be empty after removing empty text blocks.',
{ details: { toolCallId: source.toolCallId } },
);
}
if (content.length === 0 && source.toolCalls.length === 0) return;
const message = content === source.content ? source : { ...source, content };
if (mergeSource !== undefined && canMergeUserMessage(message)) {
mergeSource = mergeTwoUserMessages(mergeSource, message);
out[out.length - 1] = stripContextMetadata(mergeSource);
return;
}
mergeSource = canMergeUserMessage(message) ? message : undefined;
out.push(stripContextMetadata(message));
};
for (const message of history) {
if (message.partial === true) continue;
if (message.role === 'tool') {
if (!hasAssistant) emit(message);
continue;
}
emit(message);
for (const call of message.toolCalls) {
emit(answers.get(call) ?? createInterruptedToolResult(call.id));
}
}
return out;
}
const TOOL_INTERRUPTED_TEXT =
'<system>ERROR: Tool execution failed.</system>\n' +
'Tool execution was interrupted before its result was recorded. Do not assume the tool completed successfully.';
function createInterruptedToolResult(toolCallId: string): ContextMessage {
return {
role: 'tool',
content: [{ type: 'text', text: TOOL_INTERRUPTED_TEXT }],
toolCalls: [],
toolCallId,
isError: true,
};
}
function isBlankText(part: ContentPart): boolean {
return part.type === 'text' && part.text.trim().length === 0;
}
function canMergeUserMessage(message: ContextMessage): boolean {
return message.role === 'user' && message.origin?.kind === 'user';
}
function mergeTwoUserMessages(a: ContextMessage, b: ContextMessage): ContextMessage {
const text = [a, b].map(extractText).filter((t) => t.length > 0).join('\n\n');
const content: ContentPart[] = text === '' ? [] : [{ type: 'text', text }];
content.push(
...a.content.filter((part) => part.type !== 'text'),
...b.content.filter((part) => part.type !== 'text'),
);
return { role: 'user', content, toolCalls: [], origin: a.origin };
}
function extractText(message: ContextMessage): string {
return message.content
.filter((part): part is TextPart => part.type === 'text')
.map((part) => part.text)
.join('');
}
function stripContextMetadata(message: ContextMessage): Message {
return {
role: message.role,
name: message.name,
content: message.content.map((part) => ({ ...part })) as ContentPart[],
toolCalls: message.toolCalls.map((toolCall) => ({ ...toolCall })),
toolCallId: message.toolCallId,
partial: message.partial,
};
}
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
function makeExchangeHistory(exchanges: number, callsPerStep: number): ContextMessage[] {
const history: ContextMessage[] = [];
for (let i = 0; i < exchanges; i++) {
history.push({
role: 'user',
content: [{ type: 'text', text: `reminder ${i}` }],
toolCalls: [],
origin: { kind: 'injection', variant: 'host' },
});
const ids = Array.from({ length: callsPerStep }, (_, j) => `c${i}_${j}`);
history.push({
role: 'assistant',
content: [{ type: 'text', text: `step ${i}` }],
toolCalls: ids.map((id) => ({ type: 'function', id, name: 'Lookup', arguments: '{}' })),
});
for (const id of ids) {
history.push({
role: 'tool',
content: [{ type: 'text', text: `result for ${id} `.repeat(20) }],
toolCalls: [],
toolCallId: id,
});
}
}
return history;
}
function makeMergeHistory(count: number, textSize: number): ContextMessage[] {
const text = 'x'.repeat(textSize);
return Array.from({ length: count }, (_, i) => ({
role: 'user' as const,
content: [{ type: 'text' as const, text: `${i} ${text}` }],
toolCalls: [],
origin: { kind: 'user' as const },
}));
}
function makeMixedHistory(turns: number): ContextMessage[] {
const history: ContextMessage[] = [];
for (let i = 0; i < turns; i++) {
history.push(...makeMergeHistory(3, 200).map((m) => ({ ...m })));
history.push(...makeExchangeHistory(4, 2));
}
return history;
}
function createProjector(disposables: DisposableStore): IAgentContextProjectorService {
const ix = disposables.add(new TestInstantiationService());
ix.set(IAgentContextProjectorService, new SyncDescriptor(AgentContextProjectorService));
ix.stub(IAgentMicroCompactionService, { compact: (messages) => messages });
return ix.get(IAgentContextProjectorService);
}
// ---------------------------------------------------------------------------
// Benchmarks
// ---------------------------------------------------------------------------
const disposables = new DisposableStore();
const projector = createProjector(disposables);
const TYPICAL = makeMixedHistory(4); // ~76 messages, a normal mid-session turn
const EXCHANGE_HEAVY = makeExchangeHistory(1000, 4); // 6000 messages of tool exchanges
const MERGE_HEAVY = makeMergeHistory(2000, 500); // 2000 adjacent user prompts
// Long warmup and sample windows: the large fixtures allocate multi-thousand
// element outputs per iteration, so short runs are dominated by GC noise.
const OPTIONS = { warmupTime: 500, time: 3000 };
describe(`typical mid-session history (${TYPICAL.length} messages)`, () => {
bench('legacy (two-pass)', () => {
projectLegacy(TYPICAL);
}, OPTIONS);
bench('current (single-pass)', () => {
projector.project(TYPICAL);
}, OPTIONS);
});
describe(`tool-exchange heavy history (${EXCHANGE_HEAVY.length} messages)`, () => {
bench('legacy (two-pass)', () => {
projectLegacy(EXCHANGE_HEAVY);
}, OPTIONS);
bench('current (single-pass)', () => {
projector.project(EXCHANGE_HEAVY);
}, OPTIONS);
});
describe(`adjacent user-prompt merging (${MERGE_HEAVY.length} messages x 500 chars)`, () => {
bench('legacy (O(k²) re-merge)', () => {
projectLegacy(MERGE_HEAVY);
}, OPTIONS);
bench('current (O(k) accumulation)', () => {
projector.project(MERGE_HEAVY);
}, OPTIONS);
});

View file

@ -184,6 +184,7 @@ describe('FullCompaction', () => {
blockRatio: 0.85,
reservedContextSize: 50_000,
maxCompactionPerTurn: 3,
maxOverflowCompactionAttempts: 3,
maxRecentMessages: 3,
maxRecentUserMessages: Infinity,
maxRecentSizeRatio: 0.2,
@ -2258,6 +2259,7 @@ const alwaysCompactOnce: CompactionStrategy = {
reduceCompactOnOverflow: (messages: readonly Message[]) => messages.length,
checkAfterStep: true,
maxCompactionPerTurn: 1,
maxOverflowCompactionAttempts: 3,
};
function missingToolCall(): ToolCall {
@ -2275,6 +2277,7 @@ function testCompactionStrategy(maxSize: number = 1_000): DefaultCompactionStrat
blockRatio: 0.85,
reservedContextSize: 0,
maxCompactionPerTurn: 3,
maxOverflowCompactionAttempts: 3,
maxRecentMessages: 10,
maxRecentUserMessages: Infinity,
maxRecentSizeRatio: 0.2,
@ -2288,6 +2291,7 @@ function overflowOnlyCompactionStrategy(maxSize: number = 14): DefaultCompaction
blockRatio: Infinity,
reservedContextSize: 0,
maxCompactionPerTurn: 3,
maxOverflowCompactionAttempts: 3,
maxRecentMessages: 3,
maxRecentUserMessages: Infinity,
maxRecentSizeRatio: 0.2,

View file

@ -139,6 +139,7 @@ describe('DefaultCompactionStrategy', () => {
blockRatio: 0.85,
reservedContextSize: 50_000,
maxCompactionPerTurn: 3,
maxOverflowCompactionAttempts: 3,
maxRecentMessages: 3,
maxRecentUserMessages: Infinity,
maxRecentSizeRatio: 0.2,
@ -158,6 +159,7 @@ function testCompactionStrategy(maxSize: number = 1_000): DefaultCompactionStrat
blockRatio: 0.85,
reservedContextSize: 0,
maxCompactionPerTurn: 3,
maxOverflowCompactionAttempts: 3,
maxRecentMessages: 10,
maxRecentUserMessages: Infinity,
maxRecentSizeRatio: 0.2,
@ -171,6 +173,7 @@ function overflowOnlyCompactionStrategy(maxSize: number = 14): DefaultCompaction
blockRatio: Infinity,
reservedContextSize: 0,
maxCompactionPerTurn: 3,
maxOverflowCompactionAttempts: 3,
maxRecentMessages: 3,
maxRecentUserMessages: Infinity,
maxRecentSizeRatio: 0.2,