mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
Merge remote-tracking branch 'origin/kimi-code-v2' into kimi-code-v2
This commit is contained in:
commit
f060872ffc
11 changed files with 507 additions and 42 deletions
|
|
@ -281,7 +281,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 |
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -25,6 +24,7 @@ import {
|
|||
APIContextOverflowError,
|
||||
APIEmptyResponseError,
|
||||
createUserMessage,
|
||||
type Message,
|
||||
type TokenUsage
|
||||
} from '#/app/llmProtocol';
|
||||
import { ITelemetryService } from '#/app/telemetry';
|
||||
|
|
@ -76,7 +76,6 @@ interface ActiveCompaction {
|
|||
interface CompactionAttemptResult {
|
||||
readonly summary: string;
|
||||
readonly usage: TokenUsage | null;
|
||||
readonly model: string | undefined;
|
||||
}
|
||||
|
||||
class CompactionTruncatedError extends Error {
|
||||
|
|
@ -96,11 +95,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,
|
||||
|
|
@ -157,10 +165,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,
|
||||
|
|
@ -180,7 +189,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 {
|
||||
|
|
@ -228,12 +237,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();
|
||||
|
|
@ -251,6 +271,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);
|
||||
}
|
||||
|
|
@ -258,6 +282,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);
|
||||
}
|
||||
|
|
@ -323,6 +353,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({
|
||||
|
|
@ -360,11 +391,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;
|
||||
|
|
@ -374,16 +409,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(
|
||||
|
|
@ -404,7 +440,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;
|
||||
}
|
||||
|
|
@ -438,6 +480,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,
|
||||
|
|
@ -446,7 +490,6 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
|
|||
round,
|
||||
thinking_level: this.profile.data().thinkingLevel,
|
||||
...usageTelemetry(attempt.usage),
|
||||
...data,
|
||||
});
|
||||
|
||||
this.context.splice(
|
||||
|
|
@ -459,7 +502,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,
|
||||
|
|
@ -487,13 +530,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 {
|
||||
|
|
@ -512,7 +548,7 @@ function collectSummary(finish: LLMRequestFinish): CompactionAttemptResult {
|
|||
);
|
||||
}
|
||||
|
||||
return { summary, usage: finish.usage, model: finish.model };
|
||||
return { summary, usage: finish.usage };
|
||||
}
|
||||
|
||||
function createCompactionSummaryMessage(summary: string): ContextMessage {
|
||||
|
|
@ -549,9 +585,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]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
164
packages/agent-core-v2/test/contextMemory/splice-replay.test.ts
Normal file
164
packages/agent-core-v2/test/contextMemory/splice-replay.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
236
packages/agent-core-v2/test/contextProjector/projector.bench.ts
Normal file
236
packages/agent-core-v2/test/contextProjector/projector.bench.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -183,6 +183,7 @@ describe('FullCompaction', () => {
|
|||
blockRatio: 0.85,
|
||||
reservedContextSize: 50_000,
|
||||
maxCompactionPerTurn: 3,
|
||||
maxOverflowCompactionAttempts: 3,
|
||||
maxRecentMessages: 3,
|
||||
maxRecentUserMessages: Infinity,
|
||||
maxRecentSizeRatio: 0.2,
|
||||
|
|
@ -2257,6 +2258,7 @@ const alwaysCompactOnce: CompactionStrategy = {
|
|||
reduceCompactOnOverflow: (messages: readonly Message[]) => messages.length,
|
||||
checkAfterStep: true,
|
||||
maxCompactionPerTurn: 1,
|
||||
maxOverflowCompactionAttempts: 3,
|
||||
};
|
||||
|
||||
function missingToolCall(): ToolCall {
|
||||
|
|
@ -2274,6 +2276,7 @@ function testCompactionStrategy(maxSize: number = 1_000): DefaultCompactionStrat
|
|||
blockRatio: 0.85,
|
||||
reservedContextSize: 0,
|
||||
maxCompactionPerTurn: 3,
|
||||
maxOverflowCompactionAttempts: 3,
|
||||
maxRecentMessages: 10,
|
||||
maxRecentUserMessages: Infinity,
|
||||
maxRecentSizeRatio: 0.2,
|
||||
|
|
@ -2287,6 +2290,7 @@ function overflowOnlyCompactionStrategy(maxSize: number = 14): DefaultCompaction
|
|||
blockRatio: Infinity,
|
||||
reservedContextSize: 0,
|
||||
maxCompactionPerTurn: 3,
|
||||
maxOverflowCompactionAttempts: 3,
|
||||
maxRecentMessages: 3,
|
||||
maxRecentUserMessages: Infinity,
|
||||
maxRecentSizeRatio: 0.2,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue