diff --git a/packages/agent-core/src/agent/compaction/micro.ts b/packages/agent-core/src/agent/compaction/micro.ts index da352db69..65a045a3d 100644 --- a/packages/agent-core/src/agent/compaction/micro.ts +++ b/packages/agent-core/src/agent/compaction/micro.ts @@ -2,7 +2,10 @@ import type { ContentPart } from '@moonshot-ai/kosong'; import type { Agent } from '..'; import type { ContextMessage } from '../context'; -import { estimateTokensForContentParts } from '../../utils/tokens'; +import { + estimateTokensForContentParts, + estimateTokensForMessages, +} from '../../utils/tokens'; export interface MicroCompactionConfig { keepRecentMessages: number; @@ -65,9 +68,24 @@ export class MicroCompaction { this.apply(nextCutoff); if (previousCutoff !== nextCutoff) { const effect = this.measureEffect(history, nextCutoff); - this.agent.telemetry.track('micro_compaction_applied', { + const previousEffect = this.measureEffect(history, previousCutoff); + const rawContextTokens = estimateTokensForMessages(history); + // Whole-context length before/after this cutoff change, mirroring the + // `tokensBefore`/`tokensAfter` fields on `compaction_finished` so the + // two compaction paths can be compared on the same axis. + const tokensBefore = + rawContextTokens - + previousEffect.truncatedToolResultTokensBefore + + previousEffect.truncatedToolResultTokensAfter; + const tokensAfter = + rawContextTokens - + effect.truncatedToolResultTokensBefore + + effect.truncatedToolResultTokensAfter; + this.agent.telemetry.track('micro_compaction_finished', { ...config, ...effect, + tokensBefore, + tokensAfter, previous_cutoff: previousCutoff, cutoff: nextCutoff, message_count: history.length, @@ -107,8 +125,8 @@ export class MicroCompaction { ) { let markerTokenCount: number | undefined; let truncatedToolResultCount = 0; - let beforeTokens = 0; - let afterTokens = 0; + let truncatedToolResultTokensBefore = 0; + let truncatedToolResultTokensAfter = 0; for (let i = 0; i < messages.length && i < cutoff; i++) { const message = messages[i]; if (message?.role !== 'tool' || message.toolCallId === undefined) continue; @@ -120,9 +138,13 @@ export class MicroCompaction { { type: 'text', text: this.config.truncatedMarker }, ]); truncatedToolResultCount += 1; - beforeTokens += contentTokens; - afterTokens += markerTokenCount; + truncatedToolResultTokensBefore += contentTokens; + truncatedToolResultTokensAfter += markerTokenCount; } - return { truncatedToolResultCount, beforeTokens, afterTokens }; + return { + truncatedToolResultCount, + truncatedToolResultTokensBefore, + truncatedToolResultTokensAfter, + }; } } diff --git a/packages/agent-core/test/agent/compaction/micro.test.ts b/packages/agent-core/test/agent/compaction/micro.test.ts index bcdeb327e..34254d58c 100644 --- a/packages/agent-core/test/agent/compaction/micro.test.ts +++ b/packages/agent-core/test/agent/compaction/micro.test.ts @@ -469,7 +469,7 @@ describe('MicroCompaction', () => { 'result three', ]); - const event = singleTelemetryEvent(records, 'micro_compaction_applied'); + const event = singleTelemetryEvent(records, 'micro_compaction_finished'); expect(event.properties).toMatchObject({ ...microCompaction, truncatedMarker: DEFAULT_MARKER, @@ -478,15 +478,64 @@ describe('MicroCompaction', () => { message_count: 9, cache_age_ms: 61 * MINUTE, truncatedToolResultCount: 2, - beforeTokens: expect.any(Number), - afterTokens: expect.any(Number), + truncatedToolResultTokensBefore: expect.any(Number), + truncatedToolResultTokensAfter: expect.any(Number), + tokensBefore: expect.any(Number), + tokensAfter: expect.any(Number), }); - expect(numberProperty(event, 'beforeTokens')).toBeGreaterThan( - numberProperty(event, 'afterTokens'), + expect(numberProperty(event, 'truncatedToolResultTokensBefore')).toBeGreaterThan( + numberProperty(event, 'truncatedToolResultTokensAfter'), + ); + expect(numberProperty(event, 'tokensBefore')).toBeGreaterThan( + numberProperty(event, 'tokensAfter'), ); expect(ctx.agent.context.messages).toHaveLength(9); - expect(records.filter((record) => record.event === 'micro_compaction_applied')).toHaveLength(1); + expect(records.filter((record) => record.event === 'micro_compaction_finished')).toHaveLength(1); + }); + + it('reports context token deltas from the previously compacted projection', () => { + vi.useFakeTimers(); + const records: TelemetryRecord[] = []; + const microCompaction = { + keepRecentMessages: 2, + minContentTokens: 1, + cacheMissedThresholdMs: 60 * MINUTE, + minContextUsageRatio: 0, + }; + 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) }); + + vi.setSystemTime(61 * MINUTE); + ctx.agent.microCompaction.detect(); + expect(toolTexts(ctx.agent.context.messages)).toEqual([ + DEFAULT_MARKER, + 'result two '.repeat(20), + ]); + + vi.setSystemTime(62 * MINUTE); + appendMicroToolExchange(ctx, 3, { output: 'result three' }); + const expectedContextTokensBefore = estimateTokensForMessages(ctx.agent.context.messages); + + vi.setSystemTime(123 * MINUTE); + ctx.agent.microCompaction.detect(); + + const events = records.filter((record) => record.event === 'micro_compaction_finished'); + expect(events).toHaveLength(2); + const secondEvent = events[1]!; + expect(secondEvent.properties).toMatchObject({ + previous_cutoff: 4, + cutoff: 7, + truncatedToolResultCount: 2, + tokensBefore: expectedContextTokensBefore, + tokensAfter: estimateTokensForMessages(ctx.agent.context.messages), + }); }); it('leaves context unchanged when the micro_compaction flag is disabled', () => {