From 041ae7be2c8f8941c766df1929863ec71242ddc6 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 29 May 2026 16:05:18 +0800 Subject: [PATCH 01/14] feat: implement MicroCompaction --- .../agent-core/src/agent/compaction/full.ts | 3 +- .../agent-core/src/agent/compaction/index.ts | 1 + .../agent-core/src/agent/compaction/micro.ts | 63 +++++++++++++++++++ .../agent-core/src/agent/context/index.ts | 19 +++++- packages/agent-core/src/agent/index.ts | 10 ++- .../agent-core/src/agent/records/index.ts | 12 ++-- packages/agent-core/src/utils/tokens.ts | 12 +++- packages/kosong/src/message.ts | 12 ++-- 8 files changed, 114 insertions(+), 18 deletions(-) create mode 100644 packages/agent-core/src/agent/compaction/micro.ts diff --git a/packages/agent-core/src/agent/compaction/full.ts b/packages/agent-core/src/agent/compaction/full.ts index 34c699aa..b169cb6f 100644 --- a/packages/agent-core/src/agent/compaction/full.ts +++ b/packages/agent-core/src/agent/compaction/full.ts @@ -25,7 +25,6 @@ import { estimateTokens, estimateTokensForMessages, } from '../../utils/tokens'; -import { project } from '../context/projector'; import compactionInstructionTemplate from './compaction-instruction.md'; import { renderMessagesToText } from './render-messages'; import type { CompactionBeginData, CompactionResult } from './types'; @@ -230,7 +229,7 @@ export class FullCompaction { while (true) { const messagesToCompact = originalHistory.slice(0, compactedCount); const messages = [ - ...project(messagesToCompact), + ...this.agent.context.project(messagesToCompact), { role: 'user', content: [ diff --git a/packages/agent-core/src/agent/compaction/index.ts b/packages/agent-core/src/agent/compaction/index.ts index 876ee14e..4f92ac9f 100644 --- a/packages/agent-core/src/agent/compaction/index.ts +++ b/packages/agent-core/src/agent/compaction/index.ts @@ -1,3 +1,4 @@ export * from './full'; +export * from './micro'; export * from './strategy'; export * from './types'; diff --git a/packages/agent-core/src/agent/compaction/micro.ts b/packages/agent-core/src/agent/compaction/micro.ts new file mode 100644 index 00000000..7cc1bbb3 --- /dev/null +++ b/packages/agent-core/src/agent/compaction/micro.ts @@ -0,0 +1,63 @@ +import type { ContentPart } from '@moonshot-ai/kosong'; + +import type { Agent } from '..'; +import type { ContextMessage } from '../context'; +import { estimateTokensForContentParts } from '../../utils/tokens'; + +export interface MicroCompactionConfig { + keepRecentMessages: number; + minContentTokens: number; + cacheMissedThresholdMs: number; + truncatedMarker: string; +} + +const DEFAULT_CONFIG: MicroCompactionConfig = { + keepRecentMessages: 10, + minContentTokens: 100, + cacheMissedThresholdMs: 60 * 60 * 1000, + truncatedMarker: '[Old tool result content cleared]', +}; + +export class MicroCompaction { + private cutoff = 0; + readonly config: MicroCompactionConfig; + + constructor( + public readonly agent: Agent, + config?: Partial, + ) { + this.config = { ...DEFAULT_CONFIG, ...config }; + } + + reset(): void { + this.cutoff = 0; + } + + compact(messages: readonly ContextMessage[]): ContextMessage[] { + const cacheMissed = + Date.now() - this.agent.context.lastAssistantAt >= this.config.cacheMissedThresholdMs; + if (cacheMissed) { + this.cutoff = Math.max(0, messages.length - this.config.keepRecentMessages); + } + + const result: ContextMessage[] = []; + let i = 0; + for (const msg of messages) { + if ( + i < this.cutoff && + msg.role === 'tool' && + msg.toolCallId !== undefined && + estimateTokensForContentParts(msg.content) >= this.config.minContentTokens + ) { + result.push({ + ...msg, + content: [{ type: 'text', text: this.config.truncatedMarker } satisfies ContentPart], + }); + } else { + result.push(msg); + } + i++; + } + return result; + } +} diff --git a/packages/agent-core/src/agent/context/index.ts b/packages/agent-core/src/agent/context/index.ts index 2244a98f..931f3c6b 100644 --- a/packages/agent-core/src/agent/context/index.ts +++ b/packages/agent-core/src/agent/context/index.ts @@ -27,9 +27,14 @@ export class ContextMemory { private openSteps: Map = new Map(); private pendingToolResultIds = new Set(); private deferredMessages: ContextMessage[] = []; + private _lastAssistantAt = 0; constructor(protected readonly agent: Agent) {} + get lastAssistantAt(): number { + return this._lastAssistantAt; + } + appendUserMessage( content: readonly ContentPart[], origin: PromptOrigin = USER_PROMPT_ORIGIN, @@ -60,6 +65,8 @@ export class ContextMemory { this.openSteps.clear(); this.pendingToolResultIds.clear(); this.deferredMessages = []; + this._lastAssistantAt = 0; + this.agent.microCompaction.reset(); this.agent.injection.onContextClear(); this.agent.emitStatusUpdated(); } @@ -82,6 +89,7 @@ export class ContextMemory { this.flushDeferredMessagesIfToolExchangeClosed(); this._tokenCount = summary.tokensAfter; this.tokenCountCoveredMessageCount = this._history.length; + this.agent.microCompaction.reset(); this.agent.injection.onContextCompacted(summary.compactedCount); this.agent.emitStatusUpdated(); } @@ -99,15 +107,19 @@ export class ContextMemory { get tokenCountWithPending(): number { const pendingMessages = this._history.slice(this.tokenCountCoveredMessageCount); - return this._tokenCount + estimateTokensForMessages(project(pendingMessages)); + return this._tokenCount + estimateTokensForMessages(pendingMessages); } get history(): readonly ContextMessage[] { return this._history; } + project(messages: readonly ContextMessage[]): Message[] { + return project(this.agent.microCompaction.compact(messages)); + } + get messages(): Message[] { - return project(this.history); + return this.project(this.history); } appendLoopEvent(event: LoopRecordedEvent): void { @@ -209,6 +221,9 @@ export class ContextMemory { private pushHistory(...messages: ContextMessage[]): void { this._history.push(...messages); for (const message of messages) { + if (message.role === 'assistant') { + this._lastAssistantAt = this.agent.records.restoring?.time ?? Date.now(); + } if (message.origin?.kind === 'background_task') { this.agent.background.markDeliveredNotification(message.origin); } diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index d7c41247..aa7ac121 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -28,7 +28,12 @@ import { } from '../utils/tokens'; import type { PromisableMethods } from '../utils/types'; import { BackgroundManager } from './background'; -import { FullCompaction, type CompactionStrategy } from './compaction'; +import { + FullCompaction, + MicroCompaction, + type CompactionStrategy, + type MicroCompactionConfig, +} from './compaction'; import { CronManager } from './cron'; import { ConfigState } from './config'; import { ContextMemory } from './context'; @@ -71,6 +76,7 @@ export interface AgentOptions { readonly generate?: typeof generate; readonly toolServices?: ToolServices; readonly compactionStrategy?: CompactionStrategy; + readonly microCompaction?: Partial; readonly modelProvider?: ModelProvider | undefined; readonly subagentHost?: SessionSubagentHost | undefined; readonly skills?: SkillRegistry; @@ -101,6 +107,7 @@ export class Agent { readonly blobStore: BlobStore | undefined; readonly records: AgentRecords; readonly fullCompaction: FullCompaction; + readonly microCompaction: MicroCompaction; readonly context: ContextMemory; readonly config: ConfigState; readonly turn: TurnFlow; @@ -148,6 +155,7 @@ export class Agent { : undefined), ); this.fullCompaction = new FullCompaction(this, options.compactionStrategy); + this.microCompaction = new MicroCompaction(this, options.microCompaction); this.context = new ContextMemory(this); this.config = new ConfigState(this); this.turn = new TurnFlow(this); diff --git a/packages/agent-core/src/agent/records/index.ts b/packages/agent-core/src/agent/records/index.ts index 4261c997..f0fc912c 100644 --- a/packages/agent-core/src/agent/records/index.ts +++ b/packages/agent-core/src/agent/records/index.ts @@ -94,8 +94,12 @@ function restoreAgentRecord(agent: Agent, input: AgentRecord): void { } } +export interface RestoringContext { + time?: number; +} + export class AgentRecords { - private _restoring = false; + private _restoring: RestoringContext | null = null; private metadataInitialized = false; constructor( @@ -108,7 +112,7 @@ export class AgentRecords { } logRecord(record: AgentRecord): void { - if (this._restoring) return; + if (this._restoring !== null) return; const stamped: AgentRecord = record.time !== undefined ? record : { ...record, time: Date.now() }; if ( @@ -130,11 +134,11 @@ export class AgentRecords { } restore(record: AgentRecord): void { - this._restoring = true; + this._restoring = { time: record.time ?? Date.now() }; try { restoreAgentRecord(this.agent, record); } finally { - this._restoring = false; + this._restoring = null; } } diff --git a/packages/agent-core/src/utils/tokens.ts b/packages/agent-core/src/utils/tokens.ts index 1b4f7955..77b3b4fb 100644 --- a/packages/agent-core/src/utils/tokens.ts +++ b/packages/agent-core/src/utils/tokens.ts @@ -41,9 +41,7 @@ export function estimateTokensForTools(tools: readonly Tool[]): number { export function estimateTokensForMessage(message: Message): number { let total = estimateTokens(message.role); - for (const part of message.content) { - total += estimateTokensForContentPart(part); - } + total += estimateTokensForContentParts(message.content); if (message.toolCalls !== undefined) { for (const call of message.toolCalls) { total += estimateTokens(call.name); @@ -53,6 +51,14 @@ export function estimateTokensForMessage(message: Message): number { return total; } +export function estimateTokensForContentParts(parts: readonly ContentPart[]): number { + let total = 0; + for (const part of parts) { + total += estimateTokensForContentPart(part); + } + return total; +} + export function estimateTokensForContentPart(part: ContentPart): number { if (part.type === 'text') { return estimateTokens(part.text); diff --git a/packages/kosong/src/message.ts b/packages/kosong/src/message.ts index 4bd6db54..611cfb8c 100644 --- a/packages/kosong/src/message.ts +++ b/packages/kosong/src/message.ts @@ -89,17 +89,17 @@ export type StreamedMessagePart = ContentPart | ToolCall | ToolCallPart; */ export interface Message { /** The role of the message sender. */ - role: Role; + readonly role: Role; /** Optional display name for the sender (used by some providers). */ - name?: string; + readonly name?: string; /** Ordered content parts (text, images, thinking, etc.). */ - content: ContentPart[]; + readonly content: ContentPart[]; /** Tool calls requested by the assistant in this message. */ - toolCalls: ToolCall[]; + readonly toolCalls: ToolCall[]; /** For `tool` role messages, the ID of the tool call this result answers. */ - toolCallId?: string; + readonly toolCallId?: string; /** When `true`, indicates the message was not fully received (e.g. stream interrupted). */ - partial?: boolean; + readonly partial?: boolean; } /** Check if a streamed part is a ContentPart (text, think, image_url, audio_url, video_url). */ From c94b96885f2e47d2097350faff9a7185b9d8b142 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 29 May 2026 16:05:24 +0800 Subject: [PATCH 02/14] test: add MicroCompaction integration tests --- .../agent-core/test/agent/compaction.test.ts | 112 ++++++++++++++++++ .../agent-core/test/agent/harness/agent.ts | 5 +- 2 files changed, 116 insertions(+), 1 deletion(-) diff --git a/packages/agent-core/test/agent/compaction.test.ts b/packages/agent-core/test/agent/compaction.test.ts index 87e955ae..d79b7403 100644 --- a/packages/agent-core/test/agent/compaction.test.ts +++ b/packages/agent-core/test/agent/compaction.test.ts @@ -1620,3 +1620,115 @@ function inputHistorySnapshot(history: readonly Message[]): string[] { function normalizeInputText(text: string): string { return text.includes('compact this conversation context') ? '' : text; } + +describe('MicroCompaction', () => { + it('truncates old tool results after cache miss', () => { + vi.useFakeTimers(); + const ctx = testAgent({ + microCompaction: { + keepRecentMessages: 4, + minContentTokens: 1, + cacheMissedThresholdMs: 60 * 60 * 1000, + }, + }); + + vi.setSystemTime(0); + ctx.appendToolExchange(); + ctx.appendToolExchange(); + ctx.appendToolExchange(); + + expect(ctx.agent.context.messages).toHaveLength(9); + + vi.setSystemTime(61 * 60 * 1000); + + const messages = ctx.agent.context.messages; + const marker = '[Old tool result content cleared]'; + + expect(messages[2]).toMatchObject({ + role: 'tool', + content: [{ type: 'text', text: marker }], + }); + expect(messages[5]).toMatchObject({ + role: 'tool', + content: [{ type: 'text', text: 'lookup result' }], + }); + expect(messages[8]).toMatchObject({ + role: 'tool', + content: [{ type: 'text', text: 'lookup result' }], + }); + }); + + it('does nothing before cache miss threshold', () => { + vi.useFakeTimers(); + const ctx = testAgent({ + microCompaction: { + keepRecentMessages: 4, + minContentTokens: 1, + cacheMissedThresholdMs: 60 * 60 * 1000, + }, + }); + + vi.setSystemTime(0); + ctx.appendToolExchange(); + ctx.appendToolExchange(); + ctx.appendToolExchange(); + + vi.setSystemTime(30 * 60 * 1000); + + const messages = ctx.agent.context.messages; + expect(messages.every((m) => m.role !== 'tool' || (m.content[0] as { text: string })?.text !== '[Old tool result content cleared]')).toBe(true); + }); + + it('persists cutoff across calls until cache miss resets it', () => { + vi.useFakeTimers(); + const ctx = testAgent({ + microCompaction: { + keepRecentMessages: 2, + minContentTokens: 1, + cacheMissedThresholdMs: 60 * 60 * 1000, + }, + }); + + vi.setSystemTime(0); + ctx.appendToolExchange(); + ctx.appendToolExchange(); + + vi.setSystemTime(61 * 60 * 1000); + + const first = ctx.agent.context.messages; + expect(first[2]).toMatchObject({ + role: 'tool', + content: [{ type: 'text', text: '[Old tool result content cleared]' }], + }); + + vi.setSystemTime(62 * 60 * 1000); + + const second = ctx.agent.context.messages; + expect(second[2]).toMatchObject({ + role: 'tool', + content: [{ type: 'text', text: '[Old tool result content cleared]' }], + }); + }); + + it('clears cutoff on reset', () => { + vi.useFakeTimers(); + const ctx = testAgent({ + microCompaction: { + keepRecentMessages: 4, + minContentTokens: 1, + cacheMissedThresholdMs: 60 * 60 * 1000, + }, + }); + + vi.setSystemTime(0); + ctx.appendToolExchange(); + ctx.appendToolExchange(); + + vi.setSystemTime(61 * 60 * 1000); + + ctx.agent.microCompaction.reset(); + + const messages = ctx.agent.context.messages; + expect(messages.every((m) => m.role !== 'tool' || (m.content[0] as { text: string })?.text !== '[Old tool result content cleared]')).toBe(true); + }); +}); diff --git a/packages/agent-core/test/agent/harness/agent.ts b/packages/agent-core/test/agent/harness/agent.ts index afe5e247..be5f21ab 100644 --- a/packages/agent-core/test/agent/harness/agent.ts +++ b/packages/agent-core/test/agent/harness/agent.ts @@ -92,6 +92,7 @@ export interface TestAgentOptions { readonly kaos?: Kaos | undefined; readonly runtime?: ToolServices | undefined; readonly compactionStrategy?: CompactionStrategy | undefined; + readonly microCompaction?: AgentOptions['microCompaction']; readonly generate?: GenerateFn | undefined; readonly hookEngine?: AgentOptions['hookEngine']; readonly type?: AgentOptions['type']; @@ -182,6 +183,7 @@ export class AgentTestContext { persistence, generate: options.generate ?? this.scriptedGenerate.generate, compactionStrategy: options.compactionStrategy, + microCompaction: options.microCompaction, modelProvider: providerManager, subagentHost: options.subagentHost, type: options.type, @@ -995,10 +997,11 @@ function resumeStateSnapshot(agent: Agent): ResumeStateSnapshot { }; } -function resumeContextSnapshot(agent: Agent): ReturnType { +function resumeContextSnapshot(agent: Agent) { const context = agent.context.data(); return { ...context, + lastAssistantAt: agent.context.lastAssistantAt, history: context.history.filter((message) => !isSystemReminderMessage(message)), }; } From 406874eb18122751d78e44b5d362d215b93e622f Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 29 May 2026 16:07:45 +0800 Subject: [PATCH 03/14] test --- .../agent-core/test/agent/compaction.test.ts | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/packages/agent-core/test/agent/compaction.test.ts b/packages/agent-core/test/agent/compaction.test.ts index d79b7403..7afa66fc 100644 --- a/packages/agent-core/test/agent/compaction.test.ts +++ b/packages/agent-core/test/agent/compaction.test.ts @@ -1731,4 +1731,108 @@ describe('MicroCompaction', () => { const messages = ctx.agent.context.messages; expect(messages.every((m) => m.role !== 'tool' || (m.content[0] as { text: string })?.text !== '[Old tool result content cleared]')).toBe(true); }); + + it('skips tool results below minContentTokens', () => { + vi.useFakeTimers(); + const ctx = testAgent({ + microCompaction: { + keepRecentMessages: 2, + minContentTokens: 100, + cacheMissedThresholdMs: 60 * 60 * 1000, + }, + }); + + vi.setSystemTime(0); + ctx.appendToolExchange(); + ctx.appendToolExchange(); + + vi.setSystemTime(61 * 60 * 1000); + + const messages = ctx.agent.context.messages; + expect( + messages.every( + (m) => m.role !== 'tool' || (m.content[0] as { text: string })?.text !== '[Old tool result content cleared]', + ), + ).toBe(true); + }); + + it('skips non-tool messages', () => { + vi.useFakeTimers(); + const ctx = testAgent({ + microCompaction: { + keepRecentMessages: 2, + minContentTokens: 1, + cacheMissedThresholdMs: 60 * 60 * 1000, + }, + }); + + vi.setSystemTime(0); + ctx.appendExchange(1, 'user one', 'assistant one', 10); + ctx.appendExchange(2, 'user two', 'assistant two', 10); + ctx.appendExchange(3, 'user three', 'assistant three', 10); + + vi.setSystemTime(61 * 60 * 1000); + + const messages = ctx.agent.context.messages; + expect(messages.every((m) => m.role === 'user' || m.role === 'assistant')).toBe(true); + expect( + messages.every( + (m) => m.role !== 'tool' || (m.content[0] as { text: string })?.text !== '[Old tool result content cleared]', + ), + ).toBe(true); + }); + + it('clears cutoff on context clear', () => { + vi.useFakeTimers(); + const ctx = testAgent({ + microCompaction: { + keepRecentMessages: 2, + minContentTokens: 1, + cacheMissedThresholdMs: 60 * 60 * 1000, + }, + }); + + vi.setSystemTime(0); + ctx.appendToolExchange(); + ctx.appendToolExchange(); + + vi.setSystemTime(61 * 60 * 1000); + + ctx.agent.context.clear(); + + expect(ctx.agent.context.messages).toHaveLength(0); + expect(ctx.agent.context.lastAssistantAt).toBe(0); + }); + + it('clears cutoff on full compaction', async () => { + vi.useFakeTimers(); + const ctx = testAgent({ + microCompaction: { + keepRecentMessages: 2, + minContentTokens: 1, + cacheMissedThresholdMs: 60 * 60 * 1000, + }, + }); + ctx.configure({ + provider: CATALOGUED_PROVIDER, + modelCapabilities: CATALOGUED_MODEL_CAPABILITIES, + }); + + vi.setSystemTime(0); + ctx.appendExchange(1, 'old user', 'old assistant', 20); + ctx.appendExchange(2, 'recent user', 'recent assistant', 80); + + vi.setSystemTime(61 * 60 * 1000); + + const compacted = ctx.once('context.apply_compaction'); + ctx.mockNextResponse({ type: 'text', text: 'Summary.' }); + await ctx.rpc.beginCompaction({}); + await compacted; + + expect(ctx.agent.context.messages).toHaveLength(1); + expect(ctx.agent.context.messages[0]).toMatchObject({ + role: 'assistant', + content: [{ type: 'text', text: 'Summary.' }], + }); + }); }); From c5ca25270d4ddc0505f93757f2e6467225583a8f Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 29 May 2026 16:10:22 +0800 Subject: [PATCH 04/14] refactor: split compaction.test.ts into dedicated test files --- .../full.test.ts} | 343 +----------------- .../test/agent/compaction/micro.test.ts | 233 ++++++++++++ .../test/agent/compaction/strategy.test.ts | 183 ++++++++++ 3 files changed, 423 insertions(+), 336 deletions(-) rename packages/agent-core/test/agent/{compaction.test.ts => compaction/full.test.ts} (85%) create mode 100644 packages/agent-core/test/agent/compaction/micro.test.ts create mode 100644 packages/agent-core/test/agent/compaction/strategy.test.ts diff --git a/packages/agent-core/test/agent/compaction.test.ts b/packages/agent-core/test/agent/compaction/full.test.ts similarity index 85% rename from packages/agent-core/test/agent/compaction.test.ts rename to packages/agent-core/test/agent/compaction/full.test.ts index 7afa66fc..9b5a77c8 100644 --- a/packages/agent-core/test/agent/compaction.test.ts +++ b/packages/agent-core/test/agent/compaction/full.test.ts @@ -12,12 +12,12 @@ import { } from '@moonshot-ai/kosong'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { AgentOptions } from '../../src/agent'; -import { DefaultCompactionStrategy, type CompactionStrategy } from '../../src/agent/compaction'; -import { HookEngine, type HookEngineTriggerArgs } from '../../src/session/hooks'; -import { recordingTelemetry, type TelemetryRecord } from '../fixtures/telemetry'; -import type { TestAgentContext, TestAgentOptions } from './harness/agent'; -import { testAgent } from './harness/agent'; +import type { AgentOptions } from '../../../src/agent'; +import { DefaultCompactionStrategy, type CompactionStrategy } from '../../../src/agent/compaction'; +import { HookEngine, type HookEngineTriggerArgs } from '../../../src/session/hooks'; +import { recordingTelemetry, type TelemetryRecord } from '../../fixtures/telemetry'; +import type { TestAgentContext, TestAgentOptions } from '../harness/agent'; +import { testAgent } from '../harness/agent'; type GenerateFn = NonNullable; @@ -35,120 +35,7 @@ const CATALOGUED_MODEL_CAPABILITIES = { max_context_tokens: 256_000, } as const; -describe('Agent compaction', () => { - it('keeps an oversized trailing user message as recent', () => { - const strategy = testCompactionStrategy(); - const messages = [ - textMessage('user', 'old user'), - textMessage('assistant', 'old assistant'), - textMessage('user', `pending user ${'x'.repeat(1_200)}`), - ]; - - expect(strategy.computeCompactCount(messages, 'auto')).toBe(2); - }); - - it('keeps consecutive trailing user messages as recent', () => { - const strategy = testCompactionStrategy(); - const messages = [ - textMessage('user', 'old user'), - textMessage('assistant', 'old assistant'), - textMessage('user', `pending user one ${'x'.repeat(1_200)}`), - textMessage('user', `pending user two ${'x'.repeat(1_200)}`), - ]; - - expect(strategy.computeCompactCount(messages, 'auto')).toBe(2); - }); - - it('compacts the prefix when the trailing exchange itself is oversized', () => { - const strategy = testCompactionStrategy(); - const messages = [ - textMessage('user', 'old user'), - textMessage('assistant', 'old assistant'), - textMessage('user', 'recent user'), - textMessage('assistant', `recent assistant ${'x'.repeat(1_200)}`), - ]; - - expect(strategy.computeCompactCount(messages, 'auto')).toBe(2); - }); - - it('returns 0 when there is nothing to compact', () => { - const strategy = testCompactionStrategy(); - expect(strategy.computeCompactCount([], 'auto')).toBe(0); - expect(strategy.computeCompactCount([textMessage('user', 'only pending')], 'auto')).toBe(0); - expect( - strategy.computeCompactCount( - [ - textMessage('user', 'a'), - textMessage('user', 'b'), - textMessage('user', 'c'), - ], - 'auto', - ), - ).toBe(0); - }); - - it('returns 0 when no intermediate split exists and the last message is also unsplittable', () => { - const strategy = testCompactionStrategy(); - const messages: Message[] = [ - textMessage('user', 'inspect'), - { - role: 'assistant', - content: [], - toolCalls: [{ type: 'function', id: 'call_a', name: 'Lookup', arguments: '{}' }], - }, - ]; - - expect(strategy.computeCompactCount(messages, 'auto')).toBe(0); - }); - - it('does not split inside a parallel tool exchange', () => { - const strategy = testCompactionStrategy(); - const messages: Message[] = [ - textMessage('user', 'old user'), - textMessage('assistant', 'old assistant'), - textMessage('user', 'run both tools'), - { - role: 'assistant', - content: [], - toolCalls: [ - { type: 'function', id: 'call_a', name: 'Lookup', arguments: '{}' }, - { type: 'function', id: 'call_b', name: 'Lookup', arguments: '{}' }, - ], - }, - { role: 'tool', content: [{ type: 'text', text: 'a' }], toolCalls: [], toolCallId: 'call_a' }, - { role: 'tool', content: [{ type: 'text', text: 'b' }], toolCalls: [], toolCallId: 'call_b' }, - textMessage('user', 'next prompt'), - ]; - - // The only valid split is before the parallel exchange (after 'old assistant'), - // never between tool_a and tool_b — that would leave tool_b as an orphan. - expect(strategy.computeCompactCount(messages, 'auto')).toBe(2); - }); - - it('reserves response context by default before the ratio threshold is reached', () => { - const strategy = new DefaultCompactionStrategy(() => 256_000); - - expect(strategy.shouldCompact(210_000)).toBe(true); - expect(strategy.shouldBlock(210_000)).toBe(true); - }); - - it('ignores reserved context when the reserve is not smaller than the model window', () => { - const strategy = new DefaultCompactionStrategy(() => 32_000, { - triggerRatio: 0.85, - blockRatio: 0.85, - reservedContextSize: 50_000, - maxCompactionPerTurn: 3, - maxRecentMessages: 3, - maxRecentUserMessages: Infinity, - maxRecentSizeRatio: 0.2, - }); - - expect(strategy.shouldCompact(1)).toBe(false); - expect(strategy.shouldBlock(1)).toBe(false); - expect(strategy.shouldCompact(28_000)).toBe(true); - expect(strategy.shouldBlock(28_000)).toBe(true); - }); - +describe('FullCompaction', () => { it('runs manual compaction and applies the compacted context', async () => { const records: TelemetryRecord[] = []; const ctx = testAgent({ telemetry: recordingTelemetry(records) }); @@ -1620,219 +1507,3 @@ function inputHistorySnapshot(history: readonly Message[]): string[] { function normalizeInputText(text: string): string { return text.includes('compact this conversation context') ? '' : text; } - -describe('MicroCompaction', () => { - it('truncates old tool results after cache miss', () => { - vi.useFakeTimers(); - const ctx = testAgent({ - microCompaction: { - keepRecentMessages: 4, - minContentTokens: 1, - cacheMissedThresholdMs: 60 * 60 * 1000, - }, - }); - - vi.setSystemTime(0); - ctx.appendToolExchange(); - ctx.appendToolExchange(); - ctx.appendToolExchange(); - - expect(ctx.agent.context.messages).toHaveLength(9); - - vi.setSystemTime(61 * 60 * 1000); - - const messages = ctx.agent.context.messages; - const marker = '[Old tool result content cleared]'; - - expect(messages[2]).toMatchObject({ - role: 'tool', - content: [{ type: 'text', text: marker }], - }); - expect(messages[5]).toMatchObject({ - role: 'tool', - content: [{ type: 'text', text: 'lookup result' }], - }); - expect(messages[8]).toMatchObject({ - role: 'tool', - content: [{ type: 'text', text: 'lookup result' }], - }); - }); - - it('does nothing before cache miss threshold', () => { - vi.useFakeTimers(); - const ctx = testAgent({ - microCompaction: { - keepRecentMessages: 4, - minContentTokens: 1, - cacheMissedThresholdMs: 60 * 60 * 1000, - }, - }); - - vi.setSystemTime(0); - ctx.appendToolExchange(); - ctx.appendToolExchange(); - ctx.appendToolExchange(); - - vi.setSystemTime(30 * 60 * 1000); - - const messages = ctx.agent.context.messages; - expect(messages.every((m) => m.role !== 'tool' || (m.content[0] as { text: string })?.text !== '[Old tool result content cleared]')).toBe(true); - }); - - it('persists cutoff across calls until cache miss resets it', () => { - vi.useFakeTimers(); - const ctx = testAgent({ - microCompaction: { - keepRecentMessages: 2, - minContentTokens: 1, - cacheMissedThresholdMs: 60 * 60 * 1000, - }, - }); - - vi.setSystemTime(0); - ctx.appendToolExchange(); - ctx.appendToolExchange(); - - vi.setSystemTime(61 * 60 * 1000); - - const first = ctx.agent.context.messages; - expect(first[2]).toMatchObject({ - role: 'tool', - content: [{ type: 'text', text: '[Old tool result content cleared]' }], - }); - - vi.setSystemTime(62 * 60 * 1000); - - const second = ctx.agent.context.messages; - expect(second[2]).toMatchObject({ - role: 'tool', - content: [{ type: 'text', text: '[Old tool result content cleared]' }], - }); - }); - - it('clears cutoff on reset', () => { - vi.useFakeTimers(); - const ctx = testAgent({ - microCompaction: { - keepRecentMessages: 4, - minContentTokens: 1, - cacheMissedThresholdMs: 60 * 60 * 1000, - }, - }); - - vi.setSystemTime(0); - ctx.appendToolExchange(); - ctx.appendToolExchange(); - - vi.setSystemTime(61 * 60 * 1000); - - ctx.agent.microCompaction.reset(); - - const messages = ctx.agent.context.messages; - expect(messages.every((m) => m.role !== 'tool' || (m.content[0] as { text: string })?.text !== '[Old tool result content cleared]')).toBe(true); - }); - - it('skips tool results below minContentTokens', () => { - vi.useFakeTimers(); - const ctx = testAgent({ - microCompaction: { - keepRecentMessages: 2, - minContentTokens: 100, - cacheMissedThresholdMs: 60 * 60 * 1000, - }, - }); - - vi.setSystemTime(0); - ctx.appendToolExchange(); - ctx.appendToolExchange(); - - vi.setSystemTime(61 * 60 * 1000); - - const messages = ctx.agent.context.messages; - expect( - messages.every( - (m) => m.role !== 'tool' || (m.content[0] as { text: string })?.text !== '[Old tool result content cleared]', - ), - ).toBe(true); - }); - - it('skips non-tool messages', () => { - vi.useFakeTimers(); - const ctx = testAgent({ - microCompaction: { - keepRecentMessages: 2, - minContentTokens: 1, - cacheMissedThresholdMs: 60 * 60 * 1000, - }, - }); - - vi.setSystemTime(0); - ctx.appendExchange(1, 'user one', 'assistant one', 10); - ctx.appendExchange(2, 'user two', 'assistant two', 10); - ctx.appendExchange(3, 'user three', 'assistant three', 10); - - vi.setSystemTime(61 * 60 * 1000); - - const messages = ctx.agent.context.messages; - expect(messages.every((m) => m.role === 'user' || m.role === 'assistant')).toBe(true); - expect( - messages.every( - (m) => m.role !== 'tool' || (m.content[0] as { text: string })?.text !== '[Old tool result content cleared]', - ), - ).toBe(true); - }); - - it('clears cutoff on context clear', () => { - vi.useFakeTimers(); - const ctx = testAgent({ - microCompaction: { - keepRecentMessages: 2, - minContentTokens: 1, - cacheMissedThresholdMs: 60 * 60 * 1000, - }, - }); - - vi.setSystemTime(0); - ctx.appendToolExchange(); - ctx.appendToolExchange(); - - vi.setSystemTime(61 * 60 * 1000); - - ctx.agent.context.clear(); - - expect(ctx.agent.context.messages).toHaveLength(0); - expect(ctx.agent.context.lastAssistantAt).toBe(0); - }); - - it('clears cutoff on full compaction', async () => { - vi.useFakeTimers(); - const ctx = testAgent({ - microCompaction: { - keepRecentMessages: 2, - minContentTokens: 1, - cacheMissedThresholdMs: 60 * 60 * 1000, - }, - }); - ctx.configure({ - provider: CATALOGUED_PROVIDER, - modelCapabilities: CATALOGUED_MODEL_CAPABILITIES, - }); - - vi.setSystemTime(0); - ctx.appendExchange(1, 'old user', 'old assistant', 20); - ctx.appendExchange(2, 'recent user', 'recent assistant', 80); - - vi.setSystemTime(61 * 60 * 1000); - - const compacted = ctx.once('context.apply_compaction'); - ctx.mockNextResponse({ type: 'text', text: 'Summary.' }); - await ctx.rpc.beginCompaction({}); - await compacted; - - expect(ctx.agent.context.messages).toHaveLength(1); - expect(ctx.agent.context.messages[0]).toMatchObject({ - role: 'assistant', - content: [{ type: 'text', text: 'Summary.' }], - }); - }); -}); diff --git a/packages/agent-core/test/agent/compaction/micro.test.ts b/packages/agent-core/test/agent/compaction/micro.test.ts new file mode 100644 index 00000000..82c9a713 --- /dev/null +++ b/packages/agent-core/test/agent/compaction/micro.test.ts @@ -0,0 +1,233 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { recordingTelemetry, type TelemetryRecord } from '../../fixtures/telemetry'; +import { testAgent } from '../harness/agent'; + +const CATALOGUED_PROVIDER = { + type: 'kimi', + apiKey: 'test-key', + model: 'kimi-code', +} as const; +const CATALOGUED_MODEL_CAPABILITIES = { + image_in: true, + video_in: true, + audio_in: false, + thinking: true, + tool_use: true, + max_context_tokens: 256_000, +} as const; + +describe('MicroCompaction', () => { + it('truncates old tool results after cache miss', () => { + vi.useFakeTimers(); + const ctx = testAgent({ + microCompaction: { + keepRecentMessages: 4, + minContentTokens: 1, + cacheMissedThresholdMs: 60 * 60 * 1000, + }, + }); + + vi.setSystemTime(0); + ctx.appendToolExchange(); + ctx.appendToolExchange(); + ctx.appendToolExchange(); + + expect(ctx.agent.context.messages).toHaveLength(9); + + vi.setSystemTime(61 * 60 * 1000); + + const messages = ctx.agent.context.messages; + const marker = '[Old tool result content cleared]'; + + expect(messages[2]).toMatchObject({ + role: 'tool', + content: [{ type: 'text', text: marker }], + }); + expect(messages[5]).toMatchObject({ + role: 'tool', + content: [{ type: 'text', text: 'lookup result' }], + }); + expect(messages[8]).toMatchObject({ + role: 'tool', + content: [{ type: 'text', text: 'lookup result' }], + }); + }); + + it('does nothing before cache miss threshold', () => { + vi.useFakeTimers(); + const ctx = testAgent({ + microCompaction: { + keepRecentMessages: 4, + minContentTokens: 1, + cacheMissedThresholdMs: 60 * 60 * 1000, + }, + }); + + vi.setSystemTime(0); + ctx.appendToolExchange(); + ctx.appendToolExchange(); + ctx.appendToolExchange(); + + vi.setSystemTime(30 * 60 * 1000); + + const messages = ctx.agent.context.messages; + expect(messages.every((m) => m.role !== 'tool' || (m.content[0] as { text: string })?.text !== '[Old tool result content cleared]')).toBe(true); + }); + + it('persists cutoff across calls until cache miss resets it', () => { + vi.useFakeTimers(); + const ctx = testAgent({ + microCompaction: { + keepRecentMessages: 2, + minContentTokens: 1, + cacheMissedThresholdMs: 60 * 60 * 1000, + }, + }); + + vi.setSystemTime(0); + ctx.appendToolExchange(); + ctx.appendToolExchange(); + + vi.setSystemTime(61 * 60 * 1000); + + const first = ctx.agent.context.messages; + expect(first[2]).toMatchObject({ + role: 'tool', + content: [{ type: 'text', text: '[Old tool result content cleared]' }], + }); + + vi.setSystemTime(62 * 60 * 1000); + + const second = ctx.agent.context.messages; + expect(second[2]).toMatchObject({ + role: 'tool', + content: [{ type: 'text', text: '[Old tool result content cleared]' }], + }); + }); + + it('clears cutoff on reset', () => { + vi.useFakeTimers(); + const ctx = testAgent({ + microCompaction: { + keepRecentMessages: 4, + minContentTokens: 1, + cacheMissedThresholdMs: 60 * 60 * 1000, + }, + }); + + vi.setSystemTime(0); + ctx.appendToolExchange(); + ctx.appendToolExchange(); + + vi.setSystemTime(61 * 60 * 1000); + + ctx.agent.microCompaction.reset(); + + const messages = ctx.agent.context.messages; + expect(messages.every((m) => m.role !== 'tool' || (m.content[0] as { text: string })?.text !== '[Old tool result content cleared]')).toBe(true); + }); + + it('skips tool results below minContentTokens', () => { + vi.useFakeTimers(); + const ctx = testAgent({ + microCompaction: { + keepRecentMessages: 2, + minContentTokens: 100, + cacheMissedThresholdMs: 60 * 60 * 1000, + }, + }); + + vi.setSystemTime(0); + ctx.appendToolExchange(); + ctx.appendToolExchange(); + + vi.setSystemTime(61 * 60 * 1000); + + const messages = ctx.agent.context.messages; + expect( + messages.every( + (m) => m.role !== 'tool' || (m.content[0] as { text: string })?.text !== '[Old tool result content cleared]', + ), + ).toBe(true); + }); + + it('skips non-tool messages', () => { + vi.useFakeTimers(); + const ctx = testAgent({ + microCompaction: { + keepRecentMessages: 2, + minContentTokens: 1, + cacheMissedThresholdMs: 60 * 60 * 1000, + }, + }); + + vi.setSystemTime(0); + ctx.appendExchange(1, 'user one', 'assistant one', 10); + ctx.appendExchange(2, 'user two', 'assistant two', 10); + ctx.appendExchange(3, 'user three', 'assistant three', 10); + + vi.setSystemTime(61 * 60 * 1000); + + const messages = ctx.agent.context.messages; + expect(messages.every((m) => m.role === 'user' || m.role === 'assistant')).toBe(true); + expect( + messages.every( + (m) => m.role !== 'tool' || (m.content[0] as { text: string })?.text !== '[Old tool result content cleared]', + ), + ).toBe(true); + }); + + it('clears cutoff on context clear', () => { + vi.useFakeTimers(); + const ctx = testAgent({ + microCompaction: { + keepRecentMessages: 2, + minContentTokens: 1, + cacheMissedThresholdMs: 60 * 60 * 1000, + }, + }); + + vi.setSystemTime(0); + ctx.appendToolExchange(); + ctx.appendToolExchange(); + + vi.setSystemTime(61 * 60 * 1000); + + ctx.agent.context.clear(); + + expect(ctx.agent.context.messages).toHaveLength(0); + expect(ctx.agent.context.lastAssistantAt).toBe(0); + }); + + it('clears cutoff on full compaction', async () => { + vi.useFakeTimers(); + const ctx = testAgent({ + microCompaction: { + keepRecentMessages: 2, + minContentTokens: 1, + cacheMissedThresholdMs: 60 * 60 * 1000, + }, + }); + ctx.configure({ + provider: CATALOGUED_PROVIDER, + modelCapabilities: CATALOGUED_MODEL_CAPABILITIES, + }); + + vi.setSystemTime(0); + ctx.appendExchange(1, 'old user', 'old assistant', 20); + ctx.appendExchange(2, 'recent user', 'recent assistant', 80); + + vi.setSystemTime(61 * 60 * 1000); + + const compacted = ctx.once('context.apply_compaction'); + ctx.mockNextResponse({ type: 'text', text: 'Summary.' }); + await ctx.rpc.beginCompaction({}); + await compacted; + + expect(ctx.agent.context.messages).toHaveLength(1); + expect(ctx.agent.context.messages[0]).toMatchObject({ + role: 'assistant', + content: [{ type: 'text', text: 'Summary.' }], + }); + }); +}); diff --git a/packages/agent-core/test/agent/compaction/strategy.test.ts b/packages/agent-core/test/agent/compaction/strategy.test.ts new file mode 100644 index 00000000..f7b4faaf --- /dev/null +++ b/packages/agent-core/test/agent/compaction/strategy.test.ts @@ -0,0 +1,183 @@ +import { existsSync, mkdtempSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'pathe'; + +import { + APIConnectionError, + APIContextOverflowError, + APIStatusError, + UNKNOWN_CAPABILITY, + type Message, + type ToolCall, +} from '@moonshot-ai/kosong'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { AgentOptions } from '../../../src/agent'; +import { DefaultCompactionStrategy, type CompactionStrategy } from '../../../src/agent/compaction'; +import { HookEngine, type HookEngineTriggerArgs } from '../../../src/session/hooks'; +import { recordingTelemetry, type TelemetryRecord } from '../../fixtures/telemetry'; +import type { TestAgentContext, TestAgentOptions } from '../harness/agent'; +import { testAgent } from '../harness/agent'; + +type GenerateFn = NonNullable; + +const CATALOGUED_PROVIDER = { + type: 'kimi', + apiKey: 'test-key', + model: 'kimi-code', +} as const; +const CATALOGUED_MODEL_CAPABILITIES = { + image_in: true, + video_in: true, + audio_in: false, + thinking: true, + tool_use: true, + max_context_tokens: 256_000, +} as const; + +describe('DefaultCompactionStrategy', () => { + it('keeps an oversized trailing user message as recent', () => { + const strategy = testCompactionStrategy(); + const messages = [ + textMessage('user', 'old user'), + textMessage('assistant', 'old assistant'), + textMessage('user', `pending user ${'x'.repeat(1_200)}`), + ]; + + expect(strategy.computeCompactCount(messages, 'auto')).toBe(2); + }); + + it('keeps consecutive trailing user messages as recent', () => { + const strategy = testCompactionStrategy(); + const messages = [ + textMessage('user', 'old user'), + textMessage('assistant', 'old assistant'), + textMessage('user', `pending user one ${'x'.repeat(1_200)}`), + textMessage('user', `pending user two ${'x'.repeat(1_200)}`), + ]; + + expect(strategy.computeCompactCount(messages, 'auto')).toBe(2); + }); + + it('compacts the prefix when the trailing exchange itself is oversized', () => { + const strategy = testCompactionStrategy(); + const messages = [ + textMessage('user', 'old user'), + textMessage('assistant', 'old assistant'), + textMessage('user', 'recent user'), + textMessage('assistant', `recent assistant ${'x'.repeat(1_200)}`), + ]; + + expect(strategy.computeCompactCount(messages, 'auto')).toBe(2); + }); + + it('returns 0 when there is nothing to compact', () => { + const strategy = testCompactionStrategy(); + expect(strategy.computeCompactCount([], 'auto')).toBe(0); + expect(strategy.computeCompactCount([textMessage('user', 'only pending')], 'auto')).toBe(0); + expect( + strategy.computeCompactCount( + [ + textMessage('user', 'a'), + textMessage('user', 'b'), + textMessage('user', 'c'), + ], + 'auto', + ), + ).toBe(0); + }); + + it('returns 0 when no intermediate split exists and the last message is also unsplittable', () => { + const strategy = testCompactionStrategy(); + const messages: Message[] = [ + textMessage('user', 'inspect'), + { + role: 'assistant', + content: [], + toolCalls: [{ type: 'function', id: 'call_a', name: 'Lookup', arguments: '{}' }], + }, + ]; + + expect(strategy.computeCompactCount(messages, 'auto')).toBe(0); + }); + + it('does not split inside a parallel tool exchange', () => { + const strategy = testCompactionStrategy(); + const messages: Message[] = [ + textMessage('user', 'old user'), + textMessage('assistant', 'old assistant'), + textMessage('user', 'run both tools'), + { + role: 'assistant', + content: [], + toolCalls: [ + { type: 'function', id: 'call_a', name: 'Lookup', arguments: '{}' }, + { type: 'function', id: 'call_b', name: 'Lookup', arguments: '{}' }, + ], + }, + { role: 'tool', content: [{ type: 'text', text: 'a' }], toolCalls: [], toolCallId: 'call_a' }, + { role: 'tool', content: [{ type: 'text', text: 'b' }], toolCalls: [], toolCallId: 'call_b' }, + textMessage('user', 'next prompt'), + ]; + + // The only valid split is before the parallel exchange (after 'old assistant'), + // never between tool_a and tool_b — that would leave tool_b as an orphan. + expect(strategy.computeCompactCount(messages, 'auto')).toBe(2); + }); + + it('reserves response context by default before the ratio threshold is reached', () => { + const strategy = new DefaultCompactionStrategy(() => 256_000); + + expect(strategy.shouldCompact(210_000)).toBe(true); + expect(strategy.shouldBlock(210_000)).toBe(true); + }); + + it('ignores reserved context when the reserve is not smaller than the model window', () => { + const strategy = new DefaultCompactionStrategy(() => 32_000, { + triggerRatio: 0.85, + blockRatio: 0.85, + reservedContextSize: 50_000, + maxCompactionPerTurn: 3, + maxRecentMessages: 3, + maxRecentUserMessages: Infinity, + maxRecentSizeRatio: 0.2, + }); + + expect(strategy.shouldCompact(1)).toBe(false); + expect(strategy.shouldBlock(1)).toBe(false); + expect(strategy.shouldCompact(28_000)).toBe(true); + expect(strategy.shouldBlock(28_000)).toBe(true); + }); +}); + +function testCompactionStrategy(maxSize: number = 1_000): DefaultCompactionStrategy { + return new DefaultCompactionStrategy(() => maxSize, { + triggerRatio: 0.85, + blockRatio: 0.85, + reservedContextSize: 0, + maxCompactionPerTurn: 3, + maxRecentMessages: 10, + maxRecentUserMessages: Infinity, + maxRecentSizeRatio: 0.2, + }); +} + +function overflowOnlyCompactionStrategy(maxSize: number = 14): DefaultCompactionStrategy { + return new DefaultCompactionStrategy(() => maxSize, { + triggerRatio: Infinity, + blockRatio: Infinity, + reservedContextSize: 0, + maxCompactionPerTurn: 3, + maxRecentMessages: 3, + maxRecentUserMessages: Infinity, + maxRecentSizeRatio: 0.2, + }); +} + +function textMessage(role: 'user' | 'assistant', text: string): Message { + return { + role, + content: [{ type: 'text', text }], + toolCalls: [], + }; +} From c13f5593e0781dae408de066ca6f5bb69b5fc884 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 29 May 2026 16:10:46 +0800 Subject: [PATCH 05/14] test: add MicroCompaction boundary case for short histories --- .../test/agent/compaction/micro.test.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/agent-core/test/agent/compaction/micro.test.ts b/packages/agent-core/test/agent/compaction/micro.test.ts index 82c9a713..606faed7 100644 --- a/packages/agent-core/test/agent/compaction/micro.test.ts +++ b/packages/agent-core/test/agent/compaction/micro.test.ts @@ -230,4 +230,28 @@ describe('MicroCompaction', () => { content: [{ type: 'text', text: 'Summary.' }], }); }); + + it('does not truncate when messages are fewer than keepRecentMessages', () => { + vi.useFakeTimers(); + const ctx = testAgent({ + microCompaction: { + keepRecentMessages: 20, + minContentTokens: 1, + cacheMissedThresholdMs: 60 * 60 * 1000, + }, + }); + + vi.setSystemTime(0); + ctx.appendToolExchange(); + ctx.appendToolExchange(); + + vi.setSystemTime(61 * 60 * 1000); + + const messages = ctx.agent.context.messages; + expect( + messages.every( + (m) => m.role !== 'tool' || (m.content[0] as { text: string })?.text !== '[Old tool result content cleared]', + ), + ).toBe(true); + }); }); From 72f2ae8e650681756a004bdf7a2af9dec93b244d Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 29 May 2026 16:11:09 +0800 Subject: [PATCH 06/14] fix --- .../test/agent/compaction/micro.test.ts | 3 +-- .../test/agent/compaction/strategy.test.ts | 18 +++--------------- 2 files changed, 4 insertions(+), 17 deletions(-) diff --git a/packages/agent-core/test/agent/compaction/micro.test.ts b/packages/agent-core/test/agent/compaction/micro.test.ts index 606faed7..31f56d20 100644 --- a/packages/agent-core/test/agent/compaction/micro.test.ts +++ b/packages/agent-core/test/agent/compaction/micro.test.ts @@ -1,5 +1,4 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { recordingTelemetry, type TelemetryRecord } from '../../fixtures/telemetry'; +import { describe, expect, it, vi } from 'vitest'; import { testAgent } from '../harness/agent'; const CATALOGUED_PROVIDER = { diff --git a/packages/agent-core/test/agent/compaction/strategy.test.ts b/packages/agent-core/test/agent/compaction/strategy.test.ts index f7b4faaf..edf2bf82 100644 --- a/packages/agent-core/test/agent/compaction/strategy.test.ts +++ b/packages/agent-core/test/agent/compaction/strategy.test.ts @@ -1,23 +1,11 @@ -import { existsSync, mkdtempSync, readFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'pathe'; import { - APIConnectionError, - APIContextOverflowError, - APIStatusError, - UNKNOWN_CAPABILITY, - type Message, - type ToolCall, + type Message } from '@moonshot-ai/kosong'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; import type { AgentOptions } from '../../../src/agent'; -import { DefaultCompactionStrategy, type CompactionStrategy } from '../../../src/agent/compaction'; -import { HookEngine, type HookEngineTriggerArgs } from '../../../src/session/hooks'; -import { recordingTelemetry, type TelemetryRecord } from '../../fixtures/telemetry'; -import type { TestAgentContext, TestAgentOptions } from '../harness/agent'; -import { testAgent } from '../harness/agent'; +import { DefaultCompactionStrategy } from '../../../src/agent/compaction'; type GenerateFn = NonNullable; From 28e72485acc021bff28673696d051fcdde5cf249 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 29 May 2026 16:31:05 +0800 Subject: [PATCH 07/14] test --- .../test/agent/compaction/full.test.ts | 31 ++ .../test/agent/compaction/micro.test.ts | 417 +++++++++++++++++- .../agent-core/test/agent/harness/agent.ts | 2 +- 3 files changed, 425 insertions(+), 25 deletions(-) diff --git a/packages/agent-core/test/agent/compaction/full.test.ts b/packages/agent-core/test/agent/compaction/full.test.ts index 9b5a77c8..3bf9ff8d 100644 --- a/packages/agent-core/test/agent/compaction/full.test.ts +++ b/packages/agent-core/test/agent/compaction/full.test.ts @@ -176,6 +176,37 @@ describe('FullCompaction', () => { ).toBe(false); }); + it('micro-compacts old tool results before sending the summary request', async () => { + vi.useFakeTimers(); + const ctx = testAgent({ + compactionStrategy: alwaysCompactOnce, + microCompaction: { + keepRecentMessages: 2, + minContentTokens: 1, + cacheMissedThresholdMs: 60 * 60 * 1000, + }, + }); + ctx.configure({ + provider: CATALOGUED_PROVIDER, + modelCapabilities: CATALOGUED_MODEL_CAPABILITIES, + }); + + vi.setSystemTime(0); + ctx.appendToolExchange(); + ctx.appendToolExchange(); + + vi.setSystemTime(61 * 60 * 1000); + + const compacted = ctx.once('context.apply_compaction'); + ctx.mockNextResponse({ type: 'text', text: 'Compacted summary.' }); + await ctx.rpc.beginCompaction({ instruction: 'Summarize tool exchanges.' }); + await compacted; + + const [compactionCall] = ctx.llmCalls; + expect(messageText(compactionCall?.history[2])).toBe('[Old tool result content cleared]'); + expect(messageText(compactionCall?.history[5])).toBe('lookup result'); + }); + it('force-refreshes OAuth credentials on compaction 401 and falls back to login_required when replay 401', async () => { const tokenCalls: Array = []; const authKeys: string[] = []; diff --git a/packages/agent-core/test/agent/compaction/micro.test.ts b/packages/agent-core/test/agent/compaction/micro.test.ts index 31f56d20..3e0bce38 100644 --- a/packages/agent-core/test/agent/compaction/micro.test.ts +++ b/packages/agent-core/test/agent/compaction/micro.test.ts @@ -1,5 +1,13 @@ -import { describe, expect, it, vi } from 'vitest'; -import { testAgent } from '../harness/agent'; +import type { ContentPart, Message } from '@moonshot-ai/kosong'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { AgentRecord } from '../../../src/agent'; +import { + AGENT_WIRE_PROTOCOL_VERSION, + InMemoryAgentRecordPersistence, +} from '../../../src/agent/records'; +import { estimateTokensForMessages } from '../../../src/utils/tokens'; +import { testAgent, type TestAgentContext } from '../harness/agent'; const CATALOGUED_PROVIDER = { type: 'kimi', @@ -15,6 +23,9 @@ const CATALOGUED_MODEL_CAPABILITIES = { max_context_tokens: 256_000, } as const; +const MINUTE = 60 * 1000; +const DEFAULT_MARKER = '[Old tool result content cleared]'; + describe('MicroCompaction', () => { it('truncates old tool results after cache miss', () => { vi.useFakeTimers(); @@ -36,11 +47,9 @@ describe('MicroCompaction', () => { vi.setSystemTime(61 * 60 * 1000); const messages = ctx.agent.context.messages; - const marker = '[Old tool result content cleared]'; - expect(messages[2]).toMatchObject({ role: 'tool', - content: [{ type: 'text', text: marker }], + content: [{ type: 'text', text: DEFAULT_MARKER }], }); expect(messages[5]).toMatchObject({ role: 'tool', @@ -70,7 +79,7 @@ describe('MicroCompaction', () => { vi.setSystemTime(30 * 60 * 1000); const messages = ctx.agent.context.messages; - expect(messages.every((m) => m.role !== 'tool' || (m.content[0] as { text: string })?.text !== '[Old tool result content cleared]')).toBe(true); + expect(hasMarker(messages)).toBe(false); }); it('persists cutoff across calls until cache miss resets it', () => { @@ -92,7 +101,7 @@ describe('MicroCompaction', () => { const first = ctx.agent.context.messages; expect(first[2]).toMatchObject({ role: 'tool', - content: [{ type: 'text', text: '[Old tool result content cleared]' }], + content: [{ type: 'text', text: DEFAULT_MARKER }], }); vi.setSystemTime(62 * 60 * 1000); @@ -100,7 +109,7 @@ describe('MicroCompaction', () => { const second = ctx.agent.context.messages; expect(second[2]).toMatchObject({ role: 'tool', - content: [{ type: 'text', text: '[Old tool result content cleared]' }], + content: [{ type: 'text', text: DEFAULT_MARKER }], }); }); @@ -123,7 +132,7 @@ describe('MicroCompaction', () => { ctx.agent.microCompaction.reset(); const messages = ctx.agent.context.messages; - expect(messages.every((m) => m.role !== 'tool' || (m.content[0] as { text: string })?.text !== '[Old tool result content cleared]')).toBe(true); + expect(hasMarker(messages)).toBe(false); }); it('skips tool results below minContentTokens', () => { @@ -143,11 +152,7 @@ describe('MicroCompaction', () => { vi.setSystemTime(61 * 60 * 1000); const messages = ctx.agent.context.messages; - expect( - messages.every( - (m) => m.role !== 'tool' || (m.content[0] as { text: string })?.text !== '[Old tool result content cleared]', - ), - ).toBe(true); + expect(hasMarker(messages)).toBe(false); }); it('skips non-tool messages', () => { @@ -169,11 +174,7 @@ describe('MicroCompaction', () => { const messages = ctx.agent.context.messages; expect(messages.every((m) => m.role === 'user' || m.role === 'assistant')).toBe(true); - expect( - messages.every( - (m) => m.role !== 'tool' || (m.content[0] as { text: string })?.text !== '[Old tool result content cleared]', - ), - ).toBe(true); + expect(hasMarker(messages)).toBe(false); }); it('clears cutoff on context clear', () => { @@ -198,6 +199,206 @@ describe('MicroCompaction', () => { expect(ctx.agent.context.lastAssistantAt).toBe(0); }); + it('sends truncated old tool results to the next model request without mutating history', async () => { + vi.useFakeTimers(); + const ctx = testAgent({ + microCompaction: { + keepRecentMessages: 4, + minContentTokens: 1, + cacheMissedThresholdMs: 60 * MINUTE, + }, + }); + ctx.configure({ + provider: CATALOGUED_PROVIDER, + modelCapabilities: CATALOGUED_MODEL_CAPABILITIES, + }); + + vi.setSystemTime(0); + appendMicroToolExchange(ctx, 1, { output: 'old result one' }); + appendMicroToolExchange(ctx, 2, { output: 'middle result two' }); + appendMicroToolExchange(ctx, 3, { output: 'recent result three' }); + + vi.setSystemTime(61 * MINUTE); + + ctx.mockNextResponse({ type: 'text', text: 'done after micro compaction' }); + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'continue' }] }); + await ctx.untilTurnEnd(); + + const call = ctx.llmCalls.at(-1); + expect(textOf(call?.history[2])).toBe(DEFAULT_MARKER); + expect(textOf(call?.history[5])).toBe(DEFAULT_MARKER); + expect(textOf(call?.history[8])).toBe('recent result three'); + + expect(textOf(ctx.agent.context.history[2])).toBe('old result one'); + expect(textOf(ctx.agent.context.history[5])).toBe('middle result two'); + expect(textOf(ctx.agent.context.history[8])).toBe('recent result three'); + await ctx.expectResumeMatches(); + }); + + it('restores lastAssistantAt from record time before applying cache-miss rules', async () => { + vi.useFakeTimers(); + const assistantRecordTime = 2_000; + const ctx = testAgent({ + microCompaction: { + keepRecentMessages: 0, + minContentTokens: 1, + cacheMissedThresholdMs: 60 * MINUTE, + }, + persistence: new InMemoryAgentRecordPersistence( + resumeToolExchangeRecords(assistantRecordTime), + ), + }); + + vi.setSystemTime(999_999); + await ctx.agent.resume(); + + expect(ctx.agent.context.lastAssistantAt).toBe(assistantRecordTime); + + vi.setSystemTime(assistantRecordTime + 30 * MINUTE); + expect(hasMarker(ctx.agent.context.messages)).toBe(false); + + vi.setSystemTime(assistantRecordTime + 61 * MINUTE); + expect(toolTexts(ctx.agent.context.messages)).toEqual([DEFAULT_MARKER]); + }); + + it('keeps an old cutoff while cache is warm and advances it on the next miss', () => { + vi.useFakeTimers(); + const ctx = testAgent({ + microCompaction: { + keepRecentMessages: 2, + minContentTokens: 1, + cacheMissedThresholdMs: 60 * MINUTE, + }, + }); + + vi.setSystemTime(0); + appendMicroToolExchange(ctx, 1, { output: 'result one' }); + appendMicroToolExchange(ctx, 2, { output: 'result two' }); + + vi.setSystemTime(61 * MINUTE); + expect(toolTexts(ctx.agent.context.messages)).toEqual([DEFAULT_MARKER, 'result two']); + + vi.setSystemTime(62 * MINUTE); + appendMicroToolExchange(ctx, 3, { output: 'result three' }); + + vi.setSystemTime(63 * MINUTE); + expect(toolTexts(ctx.agent.context.messages)).toEqual([ + DEFAULT_MARKER, + 'result two', + 'result three', + ]); + + vi.setSystemTime(123 * MINUTE); + expect(toolTexts(ctx.agent.context.messages)).toEqual([ + DEFAULT_MARKER, + DEFAULT_MARKER, + 'result three', + ]); + }); + + it('uses the custom marker at the minContentTokens boundary', () => { + vi.useFakeTimers(); + const marker = '[tool output removed for test]'; + const ctx = testAgent({ + microCompaction: { + keepRecentMessages: 0, + minContentTokens: 1, + cacheMissedThresholdMs: 60 * MINUTE, + truncatedMarker: marker, + }, + }); + + vi.setSystemTime(0); + appendMicroToolExchange(ctx, 1, { output: 'abcd' }); + + vi.setSystemTime(61 * MINUTE); + + expect(toolTexts(ctx.agent.context.messages)).toEqual([marker]); + expect(textOf(ctx.agent.context.history[2])).toBe('abcd'); + }); + + it('keeps raw pending token accounting even when projection truncates tool output', () => { + vi.useFakeTimers(); + const ctx = testAgent({ + microCompaction: { + keepRecentMessages: 0, + minContentTokens: 1, + cacheMissedThresholdMs: 60 * MINUTE, + }, + }); + ctx.configure(); + + vi.setSystemTime(0); + appendMicroToolExchange(ctx, 1, { + output: 'x'.repeat(400), + usageTokens: 50, + }); + + vi.setSystemTime(61 * MINUTE); + + const rawPending = ctx.agent.context.history.slice(-1); + const projectedPending = ctx.agent.context.project(rawPending); + expect(textOf(projectedPending[0])).toBe(DEFAULT_MARKER); + expect(ctx.agent.context.tokenCountWithPending).toBe( + ctx.agent.context.tokenCount + estimateTokensForMessages(rawPending), + ); + expect(ctx.agent.context.tokenCountWithPending).toBeGreaterThan( + ctx.agent.context.tokenCount + estimateTokensForMessages(projectedPending), + ); + }); + + it('replaces rich error tool content while preserving context metadata before projection', () => { + vi.useFakeTimers(); + const ctx = testAgent({ + microCompaction: { + keepRecentMessages: 0, + minContentTokens: 1, + cacheMissedThresholdMs: 60 * MINUTE, + }, + }); + + vi.setSystemTime(0); + appendMicroToolExchange(ctx, 1, { + output: [ + { type: 'text', text: 'large rich output' }, + { type: 'video_url', videoUrl: { url: 'ms://video-1', id: 'video-1' } }, + ], + isError: true, + }); + + vi.setSystemTime(61 * MINUTE); + + const compacted = ctx.agent.microCompaction.compact(ctx.agent.context.history); + const tool = compacted.find((message) => message.role === 'tool'); + expect(tool).toMatchObject({ + role: 'tool', + toolCallId: 'call_micro_1', + isError: true, + content: [{ type: 'text', text: DEFAULT_MARKER }], + }); + expect(tool?.content).toHaveLength(1); + }); + + it('does not truncate tool-shaped messages without a toolCallId', () => { + vi.useFakeTimers(); + const ctx = testAgent({ + microCompaction: { + keepRecentMessages: 0, + minContentTokens: 1, + cacheMissedThresholdMs: 60 * MINUTE, + }, + }); + + vi.setSystemTime(61 * MINUTE); + ctx.agent.context.appendMessage({ + role: 'tool', + content: [{ type: 'text', text: 'orphan tool-like output' }], + toolCalls: [], + }); + + expect(toolTexts(ctx.agent.context.messages)).toEqual(['orphan tool-like output']); + }); + it('clears cutoff on full compaction', async () => { vi.useFakeTimers(); const ctx = testAgent({ @@ -247,10 +448,178 @@ describe('MicroCompaction', () => { vi.setSystemTime(61 * 60 * 1000); const messages = ctx.agent.context.messages; - expect( - messages.every( - (m) => m.role !== 'tool' || (m.content[0] as { text: string })?.text !== '[Old tool result content cleared]', - ), - ).toBe(true); + expect(hasMarker(messages)).toBe(false); }); }); + +afterEach(() => { + vi.useRealTimers(); +}); + +interface MicroToolExchangeOptions { + readonly output?: string | ContentPart[] | undefined; + readonly isError?: boolean | undefined; + readonly usageTokens?: number | undefined; +} + +function appendMicroToolExchange( + ctx: TestAgentContext, + index: number, + options: MicroToolExchangeOptions = {}, +): void { + const stepUuid = `micro-tool-step-${String(index)}`; + const toolCallId = `call_micro_${String(index)}`; + const output = options.output ?? `lookup result ${String(index)}`; + const usage = + options.usageTokens === undefined + ? undefined + : { + inputOther: options.usageTokens - 1, + output: 1, + inputCacheRead: 0, + inputCacheCreation: 0, + }; + + ctx.agent.context.appendUserMessage([{ type: 'text', text: `lookup ${String(index)}` }]); + ctx.dispatch({ + type: 'context.append_loop_event', + event: { type: 'step.begin', uuid: stepUuid, turnId: '', step: index }, + }); + ctx.dispatch({ + type: 'context.append_loop_event', + event: { + type: 'content.part', + uuid: `micro-tool-part-${String(index)}`, + turnId: '', + step: index, + stepUuid, + part: { type: 'text', text: `calling Lookup ${String(index)}` }, + }, + }); + ctx.dispatch({ + type: 'context.append_loop_event', + event: { + type: 'tool.call', + uuid: toolCallId, + turnId: '', + step: index, + stepUuid, + toolCallId, + name: 'Lookup', + args: { query: `item-${String(index)}` }, + }, + }); + ctx.dispatch({ + type: 'context.append_loop_event', + event: { + type: 'step.end', + uuid: stepUuid, + turnId: '', + step: index, + usage, + finishReason: 'tool_use', + }, + }); + ctx.dispatch({ + type: 'context.append_loop_event', + event: { + type: 'tool.result', + parentUuid: toolCallId, + toolCallId, + result: { output, isError: options.isError }, + }, + }); +} + +function resumeToolExchangeRecords(assistantRecordTime: number): AgentRecord[] { + return [ + { + type: 'metadata', + protocol_version: AGENT_WIRE_PROTOCOL_VERSION, + created_at: 1, + }, + { + type: 'context.append_message', + time: 1_000, + message: { + role: 'user', + content: [{ type: 'text', text: 'lookup from restored session' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }, + { + type: 'context.append_loop_event', + time: assistantRecordTime, + event: { type: 'step.begin', uuid: 'resume-micro-step', turnId: '0', step: 1 }, + }, + { + type: 'context.append_loop_event', + time: assistantRecordTime + 1, + event: { + type: 'content.part', + uuid: 'resume-micro-part', + turnId: '0', + step: 1, + stepUuid: 'resume-micro-step', + part: { type: 'text', text: 'calling restored Lookup' }, + }, + }, + { + type: 'context.append_loop_event', + time: assistantRecordTime + 2, + event: { + type: 'tool.call', + uuid: 'resume-micro-call', + turnId: '0', + step: 1, + stepUuid: 'resume-micro-step', + toolCallId: 'resume_micro_call', + name: 'Lookup', + args: { query: 'restored' }, + }, + }, + { + type: 'context.append_loop_event', + time: assistantRecordTime + 3, + event: { + type: 'step.end', + uuid: 'resume-micro-step', + turnId: '0', + step: 1, + finishReason: 'tool_use', + }, + }, + { + type: 'context.append_loop_event', + time: assistantRecordTime + 4, + event: { + type: 'tool.result', + parentUuid: 'resume-micro-call', + toolCallId: 'resume_micro_call', + result: { output: 'restored lookup result' }, + }, + }, + ]; +} + +function toolTexts(messages: readonly Message[]): string[] { + return messages + .filter((message) => message.role === 'tool') + .map((message) => textOf(message)); +} + +function textOf(message: Message | undefined): string { + return ( + message?.content + .map((part) => { + if (part.type === 'text') return part.text; + return ''; + }) + .join('') ?? '' + ); +} + +function hasMarker(messages: readonly Message[]): boolean { + return toolTexts(messages).includes(DEFAULT_MARKER); +} diff --git a/packages/agent-core/test/agent/harness/agent.ts b/packages/agent-core/test/agent/harness/agent.ts index be5f21ab..398e25f2 100644 --- a/packages/agent-core/test/agent/harness/agent.ts +++ b/packages/agent-core/test/agent/harness/agent.ts @@ -738,6 +738,7 @@ export class AgentTestContext { providerManagerOverrides: this.options.providerManagerOverrides, generate: failOnResumeGenerate, compactionStrategy: this.options.compactionStrategy, + microCompaction: this.options.microCompaction, persistence: new InMemoryAgentRecordPersistence( withMetadata(this.recordHistory.map(cloneRecord)), ), @@ -1001,7 +1002,6 @@ function resumeContextSnapshot(agent: Agent) { const context = agent.context.data(); return { ...context, - lastAssistantAt: agent.context.lastAssistantAt, history: context.history.filter((message) => !isSystemReminderMessage(message)), }; } From 2408fbe821dd2ca9b2d04f386ef77998a48b40f9 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 29 May 2026 16:36:40 +0800 Subject: [PATCH 08/14] refactor: default lastAssistantAt to null and guard micro compaction --- .../agent-core/src/agent/compaction/micro.ts | 4 ++-- packages/agent-core/src/agent/context/index.ts | 6 +++--- .../test/agent/compaction/micro.test.ts | 2 +- .../test/agent/compaction/strategy.test.ts | 17 ----------------- 4 files changed, 6 insertions(+), 23 deletions(-) diff --git a/packages/agent-core/src/agent/compaction/micro.ts b/packages/agent-core/src/agent/compaction/micro.ts index 7cc1bbb3..caec8cd2 100644 --- a/packages/agent-core/src/agent/compaction/micro.ts +++ b/packages/agent-core/src/agent/compaction/micro.ts @@ -34,8 +34,8 @@ export class MicroCompaction { } compact(messages: readonly ContextMessage[]): ContextMessage[] { - const cacheMissed = - Date.now() - this.agent.context.lastAssistantAt >= this.config.cacheMissedThresholdMs; + const { lastAssistantAt } = this.agent.context; + const cacheMissed = lastAssistantAt !== null && Date.now() - lastAssistantAt >= this.config.cacheMissedThresholdMs; if (cacheMissed) { this.cutoff = Math.max(0, messages.length - this.config.keepRecentMessages); } diff --git a/packages/agent-core/src/agent/context/index.ts b/packages/agent-core/src/agent/context/index.ts index 931f3c6b..4b3810f9 100644 --- a/packages/agent-core/src/agent/context/index.ts +++ b/packages/agent-core/src/agent/context/index.ts @@ -27,11 +27,11 @@ export class ContextMemory { private openSteps: Map = new Map(); private pendingToolResultIds = new Set(); private deferredMessages: ContextMessage[] = []; - private _lastAssistantAt = 0; + private _lastAssistantAt: number | null = null; constructor(protected readonly agent: Agent) {} - get lastAssistantAt(): number { + get lastAssistantAt(): number | null { return this._lastAssistantAt; } @@ -65,7 +65,7 @@ export class ContextMemory { this.openSteps.clear(); this.pendingToolResultIds.clear(); this.deferredMessages = []; - this._lastAssistantAt = 0; + this._lastAssistantAt = null; this.agent.microCompaction.reset(); this.agent.injection.onContextClear(); this.agent.emitStatusUpdated(); diff --git a/packages/agent-core/test/agent/compaction/micro.test.ts b/packages/agent-core/test/agent/compaction/micro.test.ts index 3e0bce38..6a91841b 100644 --- a/packages/agent-core/test/agent/compaction/micro.test.ts +++ b/packages/agent-core/test/agent/compaction/micro.test.ts @@ -196,7 +196,7 @@ describe('MicroCompaction', () => { ctx.agent.context.clear(); expect(ctx.agent.context.messages).toHaveLength(0); - expect(ctx.agent.context.lastAssistantAt).toBe(0); + expect(ctx.agent.context.lastAssistantAt).toBeNull(); }); it('sends truncated old tool results to the next model request without mutating history', async () => { diff --git a/packages/agent-core/test/agent/compaction/strategy.test.ts b/packages/agent-core/test/agent/compaction/strategy.test.ts index edf2bf82..2f6c6ce9 100644 --- a/packages/agent-core/test/agent/compaction/strategy.test.ts +++ b/packages/agent-core/test/agent/compaction/strategy.test.ts @@ -4,25 +4,8 @@ import { } from '@moonshot-ai/kosong'; import { describe, expect, it } from 'vitest'; -import type { AgentOptions } from '../../../src/agent'; import { DefaultCompactionStrategy } from '../../../src/agent/compaction'; -type GenerateFn = NonNullable; - -const CATALOGUED_PROVIDER = { - type: 'kimi', - apiKey: 'test-key', - model: 'kimi-code', -} as const; -const CATALOGUED_MODEL_CAPABILITIES = { - image_in: true, - video_in: true, - audio_in: false, - thinking: true, - tool_use: true, - max_context_tokens: 256_000, -} as const; - describe('DefaultCompactionStrategy', () => { it('keeps an oversized trailing user message as recent', () => { const strategy = testCompactionStrategy(); From 3346564ccb52d55cee103ca855a8fb9cb3cdc94c Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 29 May 2026 18:51:26 +0800 Subject: [PATCH 09/14] fix --- .../agent-core/src/agent/compaction/micro.ts | 10 +- .../agent-core/src/agent/records/index.ts | 3 + .../src/agent/records/migration/index.ts | 4 + .../agent-core/src/agent/records/types.ts | 1 + .../test/agent/compaction/micro.test.ts | 92 +++++++++++++++++++ .../test/agent/harness/snapshots.ts | 5 + packages/agent-core/test/agent/turn.test.ts | 2 +- 7 files changed, 115 insertions(+), 2 deletions(-) diff --git a/packages/agent-core/src/agent/compaction/micro.ts b/packages/agent-core/src/agent/compaction/micro.ts index caec8cd2..69772282 100644 --- a/packages/agent-core/src/agent/compaction/micro.ts +++ b/packages/agent-core/src/agent/compaction/micro.ts @@ -33,11 +33,19 @@ export class MicroCompaction { this.cutoff = 0; } + apply(cutoff: number): void { + this.agent.records.logRecord({ + type: 'micro_compaction.apply', + cutoff, + }); + this.cutoff = cutoff; + } + compact(messages: readonly ContextMessage[]): ContextMessage[] { const { lastAssistantAt } = this.agent.context; const cacheMissed = lastAssistantAt !== null && Date.now() - lastAssistantAt >= this.config.cacheMissedThresholdMs; if (cacheMissed) { - this.cutoff = Math.max(0, messages.length - this.config.keepRecentMessages); + this.apply(Math.max(0, messages.length - this.config.keepRecentMessages)); } const result: ContextMessage[] = []; diff --git a/packages/agent-core/src/agent/records/index.ts b/packages/agent-core/src/agent/records/index.ts index f0fc912c..cf79270f 100644 --- a/packages/agent-core/src/agent/records/index.ts +++ b/packages/agent-core/src/agent/records/index.ts @@ -58,6 +58,9 @@ function restoreAgentRecord(agent: Agent, input: AgentRecord): void { case 'full_compaction.complete': agent.fullCompaction.markCompleted(); return; + case 'micro_compaction.apply': + agent.microCompaction.apply(input.cutoff); + return; case 'plan_mode.enter': agent.planMode.restoreEnter(input); return; diff --git a/packages/agent-core/src/agent/records/migration/index.ts b/packages/agent-core/src/agent/records/migration/index.ts index 6bd443dc..b677aa4d 100644 --- a/packages/agent-core/src/agent/records/migration/index.ts +++ b/packages/agent-core/src/agent/records/migration/index.ts @@ -3,6 +3,10 @@ import { migrateV1_1ToV1_2 } from './v1.2'; import { migrateV1_2ToV1_3 } from './v1.3'; // Wire protocol versions currently support only the `number.number` format. +// Bump this only for changes that require migration of existing records or +// change how existing records must be interpreted. Do not bump it only because +// a new feature adds a new wire record type: older versions do not implement +// that feature and do not need to understand the new record type. export const AGENT_WIRE_PROTOCOL_VERSION = '1.3'; export interface WireMigrationRecord { diff --git a/packages/agent-core/src/agent/records/types.ts b/packages/agent-core/src/agent/records/types.ts index ca869e30..c8acefb7 100644 --- a/packages/agent-core/src/agent/records/types.ts +++ b/packages/agent-core/src/agent/records/types.ts @@ -64,6 +64,7 @@ export interface AgentRecordEvents { 'full_compaction.cancel': {}; 'full_compaction.complete': {}; + 'micro_compaction.apply': { cutoff: number }; 'context.append_message': { message: ContextMessage }; 'context.append_loop_event': { event: LoopRecordedEvent }; diff --git a/packages/agent-core/test/agent/compaction/micro.test.ts b/packages/agent-core/test/agent/compaction/micro.test.ts index 6a91841b..63de0915 100644 --- a/packages/agent-core/test/agent/compaction/micro.test.ts +++ b/packages/agent-core/test/agent/compaction/micro.test.ts @@ -261,6 +261,90 @@ describe('MicroCompaction', () => { expect(toolTexts(ctx.agent.context.messages)).toEqual([DEFAULT_MARKER]); }); + it('preserves the restored cutoff when resuming before the next cache miss', async () => { + vi.useFakeTimers(); + const persistence = new InMemoryAgentRecordPersistence(); + const config = { + keepRecentMessages: 2, + minContentTokens: 1, + cacheMissedThresholdMs: 60 * MINUTE, + }; + const ctx = testAgent({ + microCompaction: config, + persistence, + }); + + vi.setSystemTime(0); + appendMicroToolExchange(ctx, 1, { output: 'result one' }); + appendMicroToolExchange(ctx, 2, { output: 'result two' }); + + vi.setSystemTime(61 * MINUTE); + expect(toolTexts(ctx.agent.context.messages)).toEqual([DEFAULT_MARKER, 'result two']); + expect(lastMicroCompactionCutoff(persistence.records)).toBe(4); + + vi.setSystemTime(62 * MINUTE); + appendMicroToolExchange(ctx, 3, { output: 'result three' }); + + const resumed = testAgent({ + microCompaction: config, + persistence: new InMemoryAgentRecordPersistence(cloneRecords(persistence.records)), + }); + + vi.setSystemTime(63 * MINUTE); + await resumed.agent.resume(); + + expect(resumed.agent.context.lastAssistantAt).toBe(62 * MINUTE); + expect(toolTexts(resumed.agent.context.messages)).toEqual([ + DEFAULT_MARKER, + 'result two', + 'result three', + ]); + }); + + it('recomputes the restored cutoff when resuming after the cache-miss threshold', async () => { + vi.useFakeTimers(); + const persistence = new InMemoryAgentRecordPersistence(); + const config = { + keepRecentMessages: 2, + minContentTokens: 1, + cacheMissedThresholdMs: 60 * MINUTE, + }; + const ctx = testAgent({ + microCompaction: config, + persistence, + }); + + vi.setSystemTime(0); + appendMicroToolExchange(ctx, 1, { output: 'result one' }); + appendMicroToolExchange(ctx, 2, { output: 'result two' }); + + vi.setSystemTime(61 * MINUTE); + expect(toolTexts(ctx.agent.context.messages)).toEqual([DEFAULT_MARKER, 'result two']); + expect(lastMicroCompactionCutoff(persistence.records)).toBe(4); + + vi.setSystemTime(62 * MINUTE); + appendMicroToolExchange(ctx, 3, { output: 'result three' }); + + const resumedPersistence = new InMemoryAgentRecordPersistence( + cloneRecords(persistence.records), + ); + const resumed = testAgent({ + microCompaction: config, + persistence: resumedPersistence, + }); + + vi.setSystemTime(123 * MINUTE); + await resumed.agent.resume(); + + expect(resumed.agent.context.lastAssistantAt).toBe(62 * MINUTE); + expect(toolTexts(resumed.agent.context.messages)).toEqual([ + DEFAULT_MARKER, + DEFAULT_MARKER, + 'result three', + ]); + expect(lastMicroCompactionCutoff(resumedPersistence.records)).toBe(7); + }); + it('keeps an old cutoff while cache is warm and advances it on the next miss', () => { vi.useFakeTimers(); const ctx = testAgent({ @@ -603,6 +687,14 @@ function resumeToolExchangeRecords(assistantRecordTime: number): AgentRecord[] { ]; } +function cloneRecords(records: readonly AgentRecord[]): AgentRecord[] { + return records.map((record) => structuredClone(record)); +} + +function lastMicroCompactionCutoff(records: readonly AgentRecord[]): number | undefined { + return records.findLast((record) => record.type === 'micro_compaction.apply')?.cutoff; +} + function toolTexts(messages: readonly Message[]): string[] { return messages .filter((message) => message.role === 'tool') diff --git a/packages/agent-core/test/agent/harness/snapshots.ts b/packages/agent-core/test/agent/harness/snapshots.ts index 48679a07..5c6fef72 100644 --- a/packages/agent-core/test/agent/harness/snapshots.ts +++ b/packages/agent-core/test/agent/harness/snapshots.ts @@ -1,6 +1,8 @@ import type { Message, Tool as LLMTool } from '@moonshot-ai/kosong'; import { expect } from 'vitest'; +import { AGENT_WIRE_PROTOCOL_VERSION } from '../../../src/agent/records'; + const IS_EVENT_ARRAY = Symbol('isEventArray'); const IS_GENERATE_INPUT_SNAPSHOT = Symbol('isGenerateInputSnapshot'); const IS_GENERATE_INPUTS_SNAPSHOT = Symbol('isGenerateInputsSnapshot'); @@ -284,6 +286,9 @@ function normalizeObjectField( uuidLabels: Map, ): unknown { if ((key === 'time' || key === 'created_at') && typeof value === 'number') return '