diff --git a/packages/agent-core-v2/docs/di-scope-domains.puml b/packages/agent-core-v2/docs/di-scope-domains.puml
index 54dcede08..ce3b3abed 100644
--- a/packages/agent-core-v2/docs/di-scope-domains.puml
+++ b/packages/agent-core-v2/docs/di-scope-domains.puml
@@ -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
diff --git a/packages/agent-core-v2/docs/di-scope-domains.svg b/packages/agent-core-v2/docs/di-scope-domains.svg
index 39ab3b3d0..6e9632437 100644
--- a/packages/agent-core-v2/docs/di-scope-domains.svg
+++ b/packages/agent-core-v2/docs/di-scope-domains.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts b/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts
index 6e83881c7..c0f32e960 100644
--- a/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts
+++ b/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts
@@ -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,
diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompaction.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompaction.ts
index 671627b5a..6942c986a 100644
--- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompaction.ts
+++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompaction.ts
@@ -10,8 +10,6 @@ export type FullCompactionCompleteData = Omit;
export interface CompactInput {
readonly source: CompactionSource;
readonly instruction?: string;
- readonly customInstruction?: string;
- readonly signal?: AbortSignal;
}
export interface FullCompactionWillCompactContext {
diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts
index e2cc2d503..6811ac784 100644
--- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts
+++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts
@@ -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,
): Promise {
+ 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 {
+ // 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]);
}
diff --git a/packages/agent-core-v2/src/agent/fullCompaction/strategy.ts b/packages/agent-core-v2/src/agent/fullCompaction/strategy.ts
index bcf290e65..0659b8360 100644
--- a/packages/agent-core-v2/src/agent/fullCompaction/strategy.ts
+++ b/packages/agent-core-v2/src/agent/fullCompaction/strategy.ts
@@ -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;
+ }
}
/**
diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts
index 42e20d392..d1cd276eb 100644
--- a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts
+++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts
@@ -288,7 +288,13 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService {
this.config.get('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();
diff --git a/packages/agent-core-v2/test/contextMemory/splice-replay.test.ts b/packages/agent-core-v2/test/contextMemory/splice-replay.test.ts
new file mode 100644
index 000000000..65ef60d84
--- /dev/null
+++ b/packages/agent-core-v2/test/contextMemory/splice-replay.test.ts
@@ -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);
+ });
+});
diff --git a/packages/agent-core-v2/test/contextProjector/projector.bench.ts b/packages/agent-core-v2/test/contextProjector/projector.bench.ts
new file mode 100644
index 000000000..dc9fbb12e
--- /dev/null
+++ b/packages/agent-core-v2/test/contextProjector/projector.bench.ts
@@ -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();
+ const answers = new Map();
+ 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 =
+ 'ERROR: Tool execution failed.\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);
+});
diff --git a/packages/agent-core-v2/test/fullCompaction/full.test.ts b/packages/agent-core-v2/test/fullCompaction/full.test.ts
index 1df90eec2..076df5b6f 100644
--- a/packages/agent-core-v2/test/fullCompaction/full.test.ts
+++ b/packages/agent-core-v2/test/fullCompaction/full.test.ts
@@ -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,
diff --git a/packages/agent-core-v2/test/fullCompaction/strategy.test.ts b/packages/agent-core-v2/test/fullCompaction/strategy.test.ts
index e6a29eff6..fdcf527a9 100644
--- a/packages/agent-core-v2/test/fullCompaction/strategy.test.ts
+++ b/packages/agent-core-v2/test/fullCompaction/strategy.test.ts
@@ -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,