diff --git a/packages/agent-core/src/agent/compaction/full.ts b/packages/agent-core/src/agent/compaction/full.ts index 47925385..ade727d4 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'; @@ -233,7 +232,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..d9417611 --- /dev/null +++ b/packages/agent-core/src/agent/compaction/micro.ts @@ -0,0 +1,114 @@ +import type { ContentPart } from '@moonshot-ai/kosong'; + +import type { Agent } from '..'; +import type { ContextMessage } from '../context'; +import { estimateTokensForContentParts } from '../../utils/tokens'; +import { flags } from '../../flags'; + +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; + } + + apply(cutoff: number): void { + this.agent.records.logRecord({ + type: 'micro_compaction.apply', + cutoff, + }); + this.cutoff = cutoff; + } + + compact(messages: readonly ContextMessage[]): readonly ContextMessage[] { + if (!flags.enabled('micro-compaction')) return messages; + + const config = this.config; + const { lastAssistantAt } = this.agent.context; + const cacheAgeMs = lastAssistantAt === null ? null : Date.now() - lastAssistantAt; + const cacheMissed = cacheAgeMs !== null && cacheAgeMs >= config.cacheMissedThresholdMs; + if (cacheMissed) { + const previousCutoff = this.cutoff; + const nextCutoff = Math.max(0, messages.length - config.keepRecentMessages); + this.apply(nextCutoff); + if (previousCutoff !== nextCutoff) { + const effect = this.measureEffect(messages, nextCutoff); + this.agent.telemetry.track('micro_compaction_applied', { + ...config, + ...effect, + previous_cutoff: previousCutoff, + cutoff: nextCutoff, + message_count: messages.length, + cache_age_ms: cacheAgeMs, + }); + } + } + + const result: ContextMessage[] = []; + let i = 0; + for (const msg of messages) { + if ( + i < this.cutoff && + msg.role === 'tool' && + msg.toolCallId !== undefined && + estimateTokensForContentParts(msg.content) >= config.minContentTokens + ) { + result.push({ + ...msg, + content: [{ type: 'text', text: config.truncatedMarker } satisfies ContentPart], + }); + } else { + result.push(msg); + } + i++; + } + return result; + } + + private measureEffect( + messages: readonly ContextMessage[], + cutoff: number, + ) { + let markerTokenCount: number | undefined; + let truncatedToolResultCount = 0; + let beforeTokens = 0; + let afterTokens = 0; + for (let i = 0; i < messages.length && i < cutoff; i++) { + const message = messages[i]; + if (message?.role !== 'tool' || message.toolCallId === undefined) continue; + + const contentTokens = estimateTokensForContentParts(message.content); + if (contentTokens < this.config.minContentTokens) continue; + + markerTokenCount ??= estimateTokensForContentParts([ + { type: 'text', text: this.config.truncatedMarker }, + ]); + truncatedToolResultCount += 1; + beforeTokens += contentTokens; + afterTokens += markerTokenCount; + } + return { truncatedToolResultCount, beforeTokens, afterTokens }; + } +} diff --git a/packages/agent-core/src/agent/context/index.ts b/packages/agent-core/src/agent/context/index.ts index 2244a98f..4b3810f9 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: number | null = null; constructor(protected readonly agent: Agent) {} + get lastAssistantAt(): number | null { + 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 = null; + 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 5473f65a..d38e0c32 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -27,7 +27,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..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; @@ -94,8 +97,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 +115,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 +137,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/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/src/flags/registry.ts b/packages/agent-core/src/flags/registry.ts index 1e9f57b8..b45ed920 100644 --- a/packages/agent-core/src/flags/registry.ts +++ b/packages/agent-core/src/flags/registry.ts @@ -10,7 +10,14 @@ import type { FlagDefinitionInput } from './types'; * autocomplete and typo-checking. `env` must start with 'KIMI_CODE_EXPERIMENTAL_', be unique, and * not equal the master switch 'KIMI_CODE_EXPERIMENTAL_FLAG'; `id` must not be 'flag'. */ -export const FLAG_DEFINITIONS = [] as const satisfies readonly FlagDefinitionInput[]; +export const FLAG_DEFINITIONS = [ + { + id: 'micro-compaction', + env: 'KIMI_CODE_EXPERIMENTAL_MICRO_COMPACTION', + default: false, + surface: 'core', + }, +] as const satisfies readonly FlagDefinitionInput[]; -/** Literal union of registered flag ids (currently none → `never`). */ +/** Literal union of registered flag ids. */ export type FlagId = (typeof FLAG_DEFINITIONS)[number]['id']; 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/agent-core/test/agent/compaction.test.ts b/packages/agent-core/test/agent/compaction/full.test.ts similarity index 97% rename from packages/agent-core/test/agent/compaction.test.ts rename to packages/agent-core/test/agent/compaction/full.test.ts index cbd9c437..42fdb35b 100644 --- a/packages/agent-core/test/agent/compaction.test.ts +++ b/packages/agent-core/test/agent/compaction/full.test.ts @@ -12,13 +12,14 @@ 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 { estimateTokensForMessages } from '../../src/utils/tokens'; -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 { FLAG_DEFINITIONS, MASTER_ENV } from '../../../src/flags'; +import { HookEngine, type HookEngineTriggerArgs } from '../../../src/session/hooks'; +import { estimateTokensForMessages } from '../../../src/utils/tokens'; +import { recordingTelemetry, type TelemetryRecord } from '../../fixtures/telemetry'; +import type { TestAgentContext, TestAgentOptions } from '../harness/agent'; +import { testAgent } from '../harness/agent'; type GenerateFn = NonNullable; @@ -35,8 +36,9 @@ const CATALOGUED_MODEL_CAPABILITIES = { tool_use: true, max_context_tokens: 256_000, } as const; +const MICRO_COMPACTION_FLAG_ENV = getMicroCompactionFlagEnv(); -describe('Agent compaction', () => { +describe('FullCompaction', () => { it('keeps an oversized trailing user message as recent', () => { const strategy = testCompactionStrategy(); const messages = [ @@ -309,6 +311,38 @@ describe('Agent compaction', () => { ).toBe(false); }); + it('micro-compacts old tool results before sending the summary request', async () => { + vi.useFakeTimers(); + enableMicroCompactionFlag(); + 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[] = []; @@ -1529,8 +1563,22 @@ describe('Agent compaction', () => { afterEach(() => { vi.useRealTimers(); + vi.unstubAllEnvs(); }); +function enableMicroCompactionFlag(): void { + vi.stubEnv(MASTER_ENV, '0'); + vi.stubEnv(MICRO_COMPACTION_FLAG_ENV, '1'); +} + +function getMicroCompactionFlagEnv(): string { + const flag = FLAG_DEFINITIONS.find((definition) => definition.id === 'micro-compaction'); + if (flag === undefined) { + throw new Error('Missing micro-compaction flag definition.'); + } + return flag.env; +} + function deferred() { let resolve!: (value: T | PromiseLike) => void; let reject!: (reason?: unknown) => void; 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..4aeda82a --- /dev/null +++ b/packages/agent-core/test/agent/compaction/micro.test.ts @@ -0,0 +1,816 @@ +import type { ContentPart, Message } from '@moonshot-ai/kosong'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { AgentRecord } from '../../../src/agent'; +import { + AGENT_WIRE_PROTOCOL_VERSION, + InMemoryAgentRecordPersistence, +} from '../../../src/agent/records'; +import { FLAG_DEFINITIONS, MASTER_ENV } from '../../../src/flags'; +import { estimateTokensForMessages } from '../../../src/utils/tokens'; +import { recordingTelemetry, type TelemetryRecord } from '../../fixtures/telemetry'; +import { testAgent, type TestAgentContext } 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; + +const MINUTE = 60 * 1000; +const DEFAULT_MARKER = '[Old tool result content cleared]'; +const MICRO_COMPACTION_FLAG_ENV = getMicroCompactionFlagEnv(); + +describe('MicroCompaction', () => { + beforeEach(() => { + vi.stubEnv(MASTER_ENV, '0'); + vi.stubEnv(MICRO_COMPACTION_FLAG_ENV, '1'); + }); + + 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; + expect(messages[2]).toMatchObject({ + role: 'tool', + content: [{ type: 'text', text: DEFAULT_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(hasMarker(messages)).toBe(false); + }); + + 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: DEFAULT_MARKER }], + }); + + vi.setSystemTime(62 * 60 * 1000); + + const second = ctx.agent.context.messages; + expect(second[2]).toMatchObject({ + role: 'tool', + content: [{ type: 'text', text: DEFAULT_MARKER }], + }); + }); + + 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(hasMarker(messages)).toBe(false); + }); + + 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(hasMarker(messages)).toBe(false); + }); + + 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(hasMarker(messages)).toBe(false); + }); + + 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).toBeNull(); + }); + + 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('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({ + 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('tracks telemetry when a cache miss advances the micro-compaction cutoff', () => { + vi.useFakeTimers(); + const records: TelemetryRecord[] = []; + const microCompaction = { + keepRecentMessages: 2, + minContentTokens: 1, + cacheMissedThresholdMs: 60 * MINUTE, + }; + const ctx = testAgent({ + telemetry: recordingTelemetry(records), + microCompaction, + }); + + vi.setSystemTime(0); + appendMicroToolExchange(ctx, 1, { output: 'result one '.repeat(20) }); + appendMicroToolExchange(ctx, 2, { output: 'result two '.repeat(20) }); + appendMicroToolExchange(ctx, 3, { output: 'result three' }); + + vi.setSystemTime(61 * MINUTE); + expect(toolTexts(ctx.agent.context.messages)).toEqual([ + DEFAULT_MARKER, + DEFAULT_MARKER, + 'result three', + ]); + + const event = singleTelemetryEvent(records, 'micro_compaction_applied'); + expect(event.properties).toMatchObject({ + ...microCompaction, + truncatedMarker: DEFAULT_MARKER, + previous_cutoff: 0, + cutoff: 7, + message_count: 9, + cache_age_ms: 61 * MINUTE, + truncatedToolResultCount: 2, + beforeTokens: expect.any(Number), + afterTokens: expect.any(Number), + }); + expect(numberProperty(event, 'beforeTokens')).toBeGreaterThan( + numberProperty(event, 'afterTokens'), + ); + + expect(ctx.agent.context.messages).toHaveLength(9); + expect(records.filter((record) => record.event === 'micro_compaction_applied')).toHaveLength(1); + }); + + it('leaves context unchanged when the micro-compaction flag is disabled', () => { + vi.stubEnv(MICRO_COMPACTION_FLAG_ENV, '0'); + vi.useFakeTimers(); + const persistence = new InMemoryAgentRecordPersistence(); + const ctx = testAgent({ + microCompaction: { + keepRecentMessages: 0, + minContentTokens: 1, + cacheMissedThresholdMs: 60 * MINUTE, + }, + persistence, + }); + + vi.setSystemTime(0); + appendMicroToolExchange(ctx, 1, { output: 'result one' }); + + vi.setSystemTime(61 * MINUTE); + + expect(toolTexts(ctx.agent.context.messages)).toEqual(['result one']); + expect(lastMicroCompactionCutoff(persistence.records)).toBeUndefined(); + }); + + 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({ + 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.' }], + }); + }); + + 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(hasMarker(messages)).toBe(false); + }); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); +}); + +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 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') + .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); +} + +function getMicroCompactionFlagEnv(): string { + const flag = FLAG_DEFINITIONS.find((definition) => definition.id === 'micro-compaction'); + if (flag === undefined) { + throw new Error('Missing micro-compaction flag definition.'); + } + return flag.env; +} + +function singleTelemetryEvent( + records: readonly TelemetryRecord[], + event: string, +): TelemetryRecord { + const matches = records.filter((record) => record.event === event); + expect(matches).toHaveLength(1); + return matches[0]!; +} + +function numberProperty(record: TelemetryRecord, key: string): number { + const value = record.properties?.[key]; + expect(typeof value).toBe('number'); + return value as number; +} 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..d93a9fef --- /dev/null +++ b/packages/agent-core/test/agent/compaction/strategy.test.ts @@ -0,0 +1,157 @@ + +import { + type Message +} from '@moonshot-ai/kosong'; +import { describe, expect, it } from 'vitest'; + +import { DefaultCompactionStrategy } from '../../../src/agent/compaction'; + +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, + minOverflowReductionRatio: 0.05, + }); + + 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, + minOverflowReductionRatio: 0.05, + }); +} + +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, + minOverflowReductionRatio: 0.05, + }); +} + +function textMessage(role: 'user' | 'assistant', text: string): Message { + return { + role, + content: [{ type: 'text', text }], + toolCalls: [], + }; +} diff --git a/packages/agent-core/test/agent/harness/agent.ts b/packages/agent-core/test/agent/harness/agent.ts index 1944de83..d5b09607 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, @@ -736,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)), ), @@ -995,7 +998,7 @@ function resumeStateSnapshot(agent: Agent): ResumeStateSnapshot { }; } -function resumeContextSnapshot(agent: Agent): ReturnType { +function resumeContextSnapshot(agent: Agent) { const context = agent.context.data(); return { ...context, 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 '