Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions packages/agent-core/src/agent/compaction/full.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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: [
Expand Down
1 change: 1 addition & 0 deletions packages/agent-core/src/agent/compaction/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './full';
export * from './micro';
export * from './strategy';
export * from './types';
114 changes: 114 additions & 0 deletions packages/agent-core/src/agent/compaction/micro.ts
Original file line number Diff line number Diff line change
@@ -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<MicroCompactionConfig>,
) {
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
Comment thread
kermanx marked this conversation as resolved.
) {
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 };
}
}
19 changes: 17 additions & 2 deletions packages/agent-core/src/agent/context/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,14 @@ export class ContextMemory {
private openSteps: Map<string, ContextMessage> = new Map();
private pendingToolResultIds = new Set<string>();
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,
Expand Down Expand Up @@ -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();
}
Expand All @@ -82,6 +89,7 @@ export class ContextMemory {
this.flushDeferredMessagesIfToolExchangeClosed();
this._tokenCount = summary.tokensAfter;
this.tokenCountCoveredMessageCount = this._history.length;
this.agent.microCompaction.reset();
Comment thread
kermanx marked this conversation as resolved.
this.agent.injection.onContextCompacted(summary.compactedCount);
this.agent.emitStatusUpdated();
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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);
}
Expand Down
10 changes: 9 additions & 1 deletion packages/agent-core/src/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -71,6 +76,7 @@ export interface AgentOptions {
readonly generate?: typeof generate;
readonly toolServices?: ToolServices;
readonly compactionStrategy?: CompactionStrategy;
readonly microCompaction?: Partial<MicroCompactionConfig>;
readonly modelProvider?: ModelProvider | undefined;
readonly subagentHost?: SessionSubagentHost | undefined;
readonly skills?: SkillRegistry;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
15 changes: 11 additions & 4 deletions packages/agent-core/src/agent/records/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand All @@ -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 (
Expand All @@ -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;
}
}

Expand Down
4 changes: 4 additions & 0 deletions packages/agent-core/src/agent/records/migration/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions packages/agent-core/src/agent/records/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
11 changes: 9 additions & 2 deletions packages/agent-core/src/flags/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
12 changes: 9 additions & 3 deletions packages/agent-core/src/utils/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down
Loading
Loading