refactor(agent-core-v2): remove the microCompaction domain

- delete the microCompaction domain (service, wire model/op, config section,
  experimental flag) and its dedicated tests
- stop truncating old tool results in the context projector and drop the
  projector's now-unused instantiation dependency
- remove the domain from the layer map, package exports, and the DI x Scope
  dependency diagram
- retarget the flag-registry test and skill examples at a neutral flag
This commit is contained in:
haozhe.yang 2026-07-08 20:26:27 +08:00
parent 55b206d3d8
commit 248b30efc5
25 changed files with 69 additions and 1622 deletions

View file

@ -90,7 +90,7 @@ Reference mapping (a **starting point**, not gospel — verify against the curre
| `services/session/`, `session/` | `session`, `sessionStore`, `sessionMetaStore`, `sessionActivity`, `sessionContext`, `agentLifecycle` |
| `services/tool/`, `tools/`, `agent/tool/` | `toolRegistry`, `toolStore`, `toolExecutor`, `tooldedup`, `userTool` |
| `loop/`, `agent/` (turn loop) | `loop`, `llmRequester`, `llmRequestLog`, `turn` |
| `agent/context/`, `agent/compaction/` | `contextMemory`, `contextProjector`, `contextSize`, `microCompaction`, `fullCompaction`, `dynamicInjector` |
| `agent/context/`, `agent/compaction/` | `contextMemory`, `contextProjector`, `contextSize`, `fullCompaction`, `dynamicInjector` |
| `agent/permission/` | `permission`, `permissionMode`, `permissionPolicy`, `permissionRules`, `approval`, `externalHooks` |
| `agent/goal/`, `agent/plan/`, `agent/swarm/`, `agent/cron/`, `agent/background/` | `goal`, `plan`, `swarm`, `cron`, `background`, `subagentHost` |
| `services/config/`, `agent/config/` | `config` |

View file

@ -11,7 +11,7 @@ Gate not-yet-public features behind `IFlagService.enabled(id)`, per the reposito
- `src/flag/flag.ts``IFlagService` token + resolver types (`ExperimentalFlagMap`, `ExperimentalFlagConfig`, `ExperimentalFlagSource`, `ExperimentalFeatureState`) + `ExperimentalConfigSchema` / `ExperimentalConfig` (zod).
- `src/flag/flagService.ts``FlagService` impl + `MASTER_ENV` (`KIMI_CODE_EXPERIMENTAL_FLAG`) + `EXPERIMENTAL_SECTION` (`experimental`); reads definitions from `IFlagRegistry`; self-registers at App scope.
- `src/flag/index.ts`**removed (no barrel)**; `src/index.ts` imports the `flag` leafs precisely instead (e.g. `import './flag/flagService'`).
- `src/<domain>/flag.ts` — each domain that owns a flag declares it here and calls `registerFlagDefinition` at the module top level (e.g. `src/microCompaction/flag.ts`). The directory already names the domain, so the file is just `flag.ts`.
- `src/<domain>/flag.ts` — each domain that owns a flag declares it here and calls `registerFlagDefinition` at the module top level (e.g. `src/multiServer/flag.ts`). The directory already names the domain, so the file is just `flag.ts`.
## Public surface
@ -25,7 +25,7 @@ Gate not-yet-public features behind `IFlagService.enabled(id)`, per the reposito
Highest wins; env is read live on every call (nothing cached):
1. Master env `KIMI_CODE_EXPERIMENTAL_FLAG` truthy → every flag on.
2. Per-feature `def.env` (e.g. `KIMI_CODE_EXPERIMENTAL_MICRO_COMPACTION`) → forces on/off.
2. Per-feature `def.env` (e.g. `KIMI_CODE_EXPERIMENTAL_MY_FEATURE`) → forces on/off.
3. `[experimental]` config section per-flag override.
4. Registry `default`.
@ -42,7 +42,7 @@ Config shape:
```toml
[experimental]
micro_compaction = false
my_feature = false
```
Keys are intentionally loose (`z.record(z.string(), z.boolean())`), so obsolete flags stay inert config.
@ -89,7 +89,7 @@ Inject `IFlagService` and gate on it. It is resolvable from any scope (App ances
```ts
constructor(@IFlagService private readonly flags: IFlagService) {}
// ...
if (!this.flags.enabled('micro_compaction')) return;
if (!this.flags.enabled('my_feature')) return;
```
## Layering & scope

View file

@ -104,7 +104,6 @@ package "Agent scope (per agent)" #FDF5E6 {
rectangle "<b>swarm</b>\n<size:9><i>Agent</i></size>\n IAgentSwarmService" as swarm #FDEBD0
rectangle "<b>mcp</b>\n<size:9><i>Agent</i></size>\n IAgentMcpService" as mcp #FDEBD0
rectangle "<b>fullCompaction</b>\n<size:9><i>Agent</i></size>\n IAgentFullCompactionService" as fullCompaction #FDEBD0
rectangle "<b>microCompaction</b>\n<size:9><i>Agent</i></size>\n IAgentMicroCompactionService" as microCompaction #FDEBD0
rectangle "<b>externalHooks</b>\n<size:9><i>Agent</i></size>\n IAgentExternalHooksService" as externalHooks #FDEBD0
rectangle "<b>usage</b>\n<size:9><i>Agent</i></size>\n IAgentUsageService" as usage #FDEBD0
rectangle "<b>rpc</b>\n<size:9><i>Agent</i></size>\n IAgentRPCService" as rpc #FDEBD0
@ -306,14 +305,6 @@ fullCompaction --> telemetry #34495E
fullCompaction --> wireRecord #34495E
fullCompaction --> turn #34495E
fullCompaction --> loop #34495E
microCompaction --> contextMemory #34495E
microCompaction --> contextSize #34495E
microCompaction --> wire #34495E
microCompaction --> flag #34495E
microCompaction --> profile #34495E
microCompaction --> telemetry #34495E
microCompaction --> loop #34495E
microCompaction --> config #34495E
externalHooks --> config #34495E
externalHooks --> bootstrap #34495E
externalHooks --> plugin #34495E
@ -383,7 +374,6 @@ goal ..> wireRecord #16A085 : goal.create/update/clear
goal ..> turn #16A085 : turn lifecycle hooks
goal ..> loop #16A085 : step/usage hooks
goal ..> eventSink #16A085 : hook.result / goal.updated
microCompaction ..> wire #16A085 : micro_compaction.apply
swarm ..> wire #16A085 : swarm_mode.enter/exit
swarm ..> turn #16A085 : hooks.onEnded
task ..> wire #16A085 : task.started/terminated

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 276 KiB

After

Width:  |  Height:  |  Size: 544 KiB

Before After
Before After

View file

@ -11,7 +11,7 @@ Gates not-yet-public features behind `IFlagService.enabled(id)`, per the reposit
- `src/flag/flag.ts``IFlagService` token + resolver types (`ExperimentalFlagMap`, `ExperimentalFlagConfig`, `ExperimentalFlagSource`, `ExperimentalFeatureState`) + `ExperimentalConfigSchema` / `ExperimentalConfig` (zod).
- `src/flag/flagService.ts``FlagService` impl + `MASTER_ENV` (`KIMI_CODE_EXPERIMENTAL_FLAG`) + `EXPERIMENTAL_SECTION` (`experimental`); reads definitions from `IFlagRegistry`; self-registers at App scope.
- `src/flag/index.ts` — barrel; re-exported by `src/index.ts` at the L3 block.
- `src/<domain>/flag.ts` — each domain that owns a flag declares it here and calls `registerFlagDefinition` at the module top level (e.g. `src/microCompaction/flag.ts`). The directory already names the domain, so the file is just `flag.ts`.
- `src/<domain>/flag.ts` — each domain that owns a flag declares it here and calls `registerFlagDefinition` at the module top level (e.g. `src/multiServer/flag.ts`). The directory already names the domain, so the file is just `flag.ts`.
## Public surface
@ -25,7 +25,7 @@ Gates not-yet-public features behind `IFlagService.enabled(id)`, per the reposit
Highest wins; env is read live on every call (nothing cached):
1. L1 master env `KIMI_CODE_EXPERIMENTAL_FLAG` truthy → every flag on.
2. L2 per-feature `def.env` (e.g. `KIMI_CODE_EXPERIMENTAL_MICRO_COMPACTION`) → forces on/off.
2. L2 per-feature `def.env` (e.g. `KIMI_CODE_EXPERIMENTAL_MY_FEATURE`) → forces on/off.
3. L3 `[experimental]` config section per-flag override.
4. L4 registry `default`.
@ -42,7 +42,7 @@ Config shape mirrors v1:
```toml
[experimental]
micro_compaction = false
my_feature = false
```
Keys are intentionally loose (`z.record(z.string(), z.boolean())`), so obsolete flags stay inert config.
@ -90,11 +90,9 @@ Inject `IFlagService` and gate on it. It is resolvable from any scope (App ances
```ts
constructor(@IFlagService private readonly flags: IFlagService) {}
// ...
if (!this.flags.enabled('micro_compaction')) return;
if (!this.flags.enabled('my_feature')) return;
```
Current consumer: `microCompaction` (Agent scope) gates `micro_compaction`.
## Layering & scope
- Domain `flag` is registered at **L3** (`scripts/check-domain-layers.mjs``['flag', 3]`). It imports only `config` (L2) downward.
@ -105,7 +103,7 @@ Current consumer: `microCompaction` (Agent scope) gates `micro_compaction`.
## References
- `packages/agent-core-v2/src/flag/` — implementation (`IFlagRegistry` + `IFlagService`).
- `packages/agent-core-v2/src/microCompaction/flag.ts` — example per-domain flag contribution.
- `packages/agent-core-v2/src/app/multiServer/flag.ts` — example per-domain flag contribution.
- `packages/agent-core-v2/test/flag/flag.test.ts` — precedence + config subscription tests.
- `packages/agent-core/src/flags/` — v1 source this was ported from.
- `plan/PLAN.md` §2/§3 — domain placement (`flag` at L3, not `_base/flags`).

View file

@ -281,7 +281,7 @@ setTodos(todos: TodoItem[]): void {
- **Command 不持有可折叠状态**。所有"resume 后必须还在"的状态在 view 里。
service 私有字段只允许装真正的运行时资源(进程句柄、定时器、连接)。
- **Command 不在 replay 中运行**相位机保证。resume 复用 live 命令的 hack
microCompaction消失replay 只折叠 fact。
消失replay 只折叠 fact。
- 需要"先答应再补偿"的命令plan.enter 失败后 cancel就是两次 commit——
补偿也是事实,天然可回放。
- `define()` 的 facet 机制退役:`resume` → view fold`toLive` → 定义处

View file

@ -192,7 +192,7 @@ an **event**, or a **hook**. From first principles, they answer three different
Note that the wire is the *durable record*, not the live notification channel: a live
`spliceHistory(...)` call appends a `context.splice` record to the wire *and* applies it,
and `contextMemory` then fires `hooks.onSpliced`, which `contextSize` / `loop` /
`background` / `microCompaction` / `dynamicInjector` actually subscribe to. Those listeners
`background` / `dynamicInjector` actually subscribe to. Those listeners
react to the **hook**, not the wire — the wire is what makes the splice replayable.
### One-sentence rule

View file

@ -148,7 +148,6 @@ const DOMAIN_LAYER = new Map([
['contextProjector', 4],
['contextSize', 4],
['fullCompaction', 4],
['microCompaction', 4],
['loop', 4],
['media', 4],
// `edit` spans two scopes: the App-scope `IFileEditService` capability (pure

View file

@ -13,7 +13,7 @@
* are the unmeasured tail (see `contextSizeService`). Every mutation still fires
* `onSpliced` from the live path only (replay rebuilds
* the Model silently and never invokes these methods), so existing subscribers
* (micro-compaction, context-injector, task-notification) observe the same
* (context-injector, task-notification) observe the same
* splice-shaped change events regardless of which 1.4 Op was persisted. Message
* ids are stamped at the dispatch call site so `apply` stays pure. Blob
* dehydrate/rehydrate is declared on `ContextModel.blobs`. Bound at

View file

@ -1,31 +1,20 @@
import { IInstantiationService } from "#/_base/di/instantiation";
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { renderToolResultForModel } from '#/agent/contextMemory/toolResultRender';
import type { ContextMessage } from '#/agent/contextMemory/types';
import { ErrorCodes, KimiError } from '#/errors';
import { IAgentMicroCompactionService } from '#/agent/microCompaction/microCompaction';
import type { ContentPart, Message } from '#/app/llmProtocol/message';
import { IAgentContextProjectorService } from './contextProjector';
export class AgentContextProjectorService implements IAgentContextProjectorService {
declare readonly _serviceBrand: undefined;
constructor(
@IInstantiationService private readonly instantiation: IInstantiationService,
) {}
project(messages: readonly ContextMessage[]): readonly Message[] {
return project(this.microCompaction().compact(messages));
return project(messages);
}
projectStrict(messages: readonly ContextMessage[]): readonly Message[] {
return projectStrict(this.microCompaction().compact(messages));
}
private microCompaction(): IAgentMicroCompactionService {
return this.instantiation.invokeFunction((accessor) =>
accessor.get(IAgentMicroCompactionService),
);
return projectStrict(messages);
}
}
@ -102,8 +91,7 @@ function dropLeadingNonUserMessages(messages: readonly Message[]): Message[] {
// never anchor an exchange. Tool messages are skipped where they originally
// sat — a result either lands in its call's slot or it is an orphan,
// wire-invalid and useless to the model. A history with no assistant at all
// is a bare sizing slice (micro-compaction sizes single messages this way)
// and passes through as-is. Emitting cleans each message (drops empty /
// is a bare sizing slice and passes through as-is. Emitting cleans each message (drops empty /
// whitespace-only text blocks, rejected by strict providers), merges runs of
// adjacent user prompts (accumulated and materialized once per run), and
// strips context-only metadata off the wire.

View file

@ -33,8 +33,7 @@
* re-declared). The `full_compaction.*` record shapes stay declared in
* `WireRecordMap` (see `fullCompactionService.ts`) because the records still
* ride the per-agent `wire.jsonl` log read by `wireRecord.restore()` /
* `getRecords()` `microCompaction` registers a `full_compaction.complete`
* resumer against that stream. Consumed by the Agent-scope `fullCompactionService`.
* `getRecords()`. Consumed by the Agent-scope `fullCompactionService`.
*/
import { defineModel } from '#/wire/model';

View file

@ -61,9 +61,8 @@ import { OrderedHookSlot } from '#/hooks';
// The `full_compaction.*` record shapes stay declared in `WireRecordMap`
// because the records still ride the per-agent `wire.jsonl` log read by
// `wireRecord.restore()` / `getRecords()`: `microCompaction` registers a
// `full_compaction.complete` resumer against that stream. fullCompaction itself
// no longer registers resumers here — its state rebuilds from the same log via
// `wireRecord.restore()` / `getRecords()`. fullCompaction itself no longer
// registers resumers here — its state rebuilds from the same log via
// `wire.replay` into `CompactionModel`.
declare module '#/agent/wireRecord/wireRecord' {
interface WireRecordMap {

View file

@ -1,31 +0,0 @@
/**
* `microCompaction` domain (L4) - micro-compaction config-section schema.
*
* Owns the `[micro_compaction]` tuning section consumed by
* `AgentMicroCompactionService`. Self-registered at module load via
* `registerConfigSection`.
*/
import { z } from 'zod';
import { registerConfigSection } from '#/app/config/configSectionContributions';
export const MICRO_COMPACTION_SECTION = 'microCompaction';
const microCompactionConfigShape = {
keepRecentMessages: z.number().int().min(0),
minContentTokens: z.number().int().min(0),
cacheMissedThresholdMs: z.number().int().min(0),
truncatedMarker: z.string(),
minContextUsageRatio: z.number().min(0).max(1),
};
const microCompactionConfigObject = z.object(microCompactionConfigShape);
export type MicroCompactionConfig = z.infer<typeof microCompactionConfigObject>;
export const MicroCompactionConfigSchema = microCompactionConfigObject.partial();
export type MicroCompactionConfigPatch = z.infer<typeof MicroCompactionConfigSchema>;
registerConfigSection(MICRO_COMPACTION_SECTION, MicroCompactionConfigSchema);

View file

@ -1,20 +0,0 @@
/**
* `microCompaction` domain flag contribution.
*
* Registers the `micro_compaction` experimental flag at import time, so the
* flag is available process-wide before any consumer resolves `IFlagService`.
*/
import { type FlagDefinitionInput, registerFlagDefinition } from '#/app/flag/flagRegistry';
export const microCompactionFlag: FlagDefinitionInput = {
id: 'micro_compaction',
title: 'Micro compaction',
description:
'Trim older large tool results from context while keeping recent conversation intact.',
env: 'KIMI_CODE_EXPERIMENTAL_MICRO_COMPACTION',
default: true,
surface: 'core',
};
registerFlagDefinition(microCompactionFlag);

View file

@ -1,18 +0,0 @@
/**
* `microCompaction` domain (L4) - micro-compaction service contract.
*
* Defines the Agent-scoped `IAgentMicroCompactionService` used by context
* projection. Bound at Agent scope.
*/
import { createDecorator } from "#/_base/di/instantiation";
import type { ContextMessage } from '#/agent/contextMemory/types';
export interface IAgentMicroCompactionService {
readonly _serviceBrand: undefined;
compact(messages: readonly ContextMessage[]): readonly ContextMessage[];
}
export const IAgentMicroCompactionService =
createDecorator<IAgentMicroCompactionService>('agentMicroCompactionService');

View file

@ -1,35 +0,0 @@
/**
* `microCompaction` domain (L4) wire Model (`MicroCompactionModel`) and the
* `micro_compaction.apply` (`microCompactionApply`) Op for the cache-miss
* compaction cutoff over `contextMemory`.
*
* Declares the cutoff as `{ cutoff }` (initial `{ cutoff: 0 }`): the message
* index below which old tool results are truncated. `cutoff` is a non-negative
* index (0 truncates nothing), so the reset value folds into the same Op as
* `cutoff: 0` rather than a separate record keeping the resume-time cutoff
* reset (previously a `full_compaction.complete` resumer) inside the same
* replayable stream. `apply` is pure and returns the SAME reference on a no-op
* so the wire's reference-equality gate stays quiet; it carries no
* non-determinism, so `wire.dispatch(microCompactionApply(...))` and
* `wire.replay` produce identical state replay rebuilds the cutoff, including
* the explicit `cutoff: 0` resets recorded on the live full-compaction /
* context-clear paths. Consumed by the Agent-scope `microCompactionService`.
*/
import { defineModel } from '#/wire/model';
import { defineOp } from '#/wire/op';
export interface MicroCompactionState {
readonly cutoff: number;
}
export const MicroCompactionModel = defineModel<MicroCompactionState>('microCompaction', () => ({
cutoff: 0,
}));
export const microCompactionApply = defineOp(MicroCompactionModel, 'micro_compaction.apply', {
apply: (s, p: MicroCompactionState): MicroCompactionState => {
const cutoff = Math.max(0, p.cutoff);
return s.cutoff === cutoff ? s : { cutoff };
},
});

View file

@ -1,269 +0,0 @@
/**
* `microCompaction` domain (L4) - `IAgentMicroCompactionService` implementation.
*
* Tracks cache-miss compaction cutoffs over `contextMemory` in the wire
* `MicroCompactionModel` (`{ cutoff }`): reads it through `wire.getModel`,
* writes it through `wire.dispatch(microCompactionApply(...))` (both advances
* from `detect` and the `cutoff: 0` resets recorded on the live full-compaction
* / context-clear paths), so `wire.replay` rebuilds the cutoff including the
* resume-time reset without a `full_compaction.complete` resumer. Sizes
* context via `contextSize`, resolves model capacity through `profile`, gates
* behavior through `flag`, reads tuning through `config`, emits telemetry, and
* participates in `loop` hooks. The effective cutoff is clamped to the current
* context length at read time (an undo that shortens the context cannot leave a
* dangling cutoff), so the delete-clamp needs no persisted record. Bound at
* Agent scope.
*/
import type { ContentPart } from '#/app/llmProtocol/message';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { Disposable } from "#/_base/di/lifecycle";
import {
estimateTokensForContentParts,
estimateTokensForMessages,
} from "#/_base/utils/tokens";
import type { TelemetryProperties } from '#/app/telemetry/telemetry';
import { IConfigService } from '#/app/config/config';
import { IEventBus } from '#/app/event/eventBus';
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
import { IAgentContextSizeService } from '#/agent/contextSize/contextSize';
import { IFlagService } from '#/app/flag/flag';
import { IAgentLoopService } from '#/agent/loop/loop';
import { IAgentProfileService } from '#/agent/profile/profile';
import { ITelemetryService } from '#/app/telemetry/telemetry';
import type { ContextMessage } from '#/agent/contextMemory/types';
import { IAgentWireService } from '#/wire/tokens';
import type { IWireService } from '#/wire/wireService';
import { IAgentMicroCompactionService } from './microCompaction';
import { MicroCompactionModel, microCompactionApply } from './microCompactionOps';
import {
MICRO_COMPACTION_SECTION,
type MicroCompactionConfig,
type MicroCompactionConfigPatch,
} from './configSection';
const DEFAULT_CONFIG: MicroCompactionConfig = {
keepRecentMessages: 20,
minContentTokens: 100,
cacheMissedThresholdMs: 60 * 60 * 1000,
truncatedMarker: '[Old tool result content cleared]',
minContextUsageRatio: 0.5,
};
export class AgentMicroCompactionService
extends Disposable
implements IAgentMicroCompactionService
{
declare readonly _serviceBrand: undefined;
private microConfig: MicroCompactionConfig;
private _lastAssistantAt: number | null = null;
constructor(
@IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
@IAgentContextSizeService private readonly contextSize: IAgentContextSizeService,
@IAgentWireService private readonly wire: IWireService,
@IFlagService private readonly flags: IFlagService,
@IAgentProfileService private readonly profile: IAgentProfileService,
@ITelemetryService private readonly telemetry: ITelemetryService,
@IAgentLoopService loop: IAgentLoopService,
@IConfigService private readonly config: IConfigService,
@IEventBus private readonly eventBus: IEventBus,
) {
super();
this.microConfig = this.readConfig();
this._register(
this.config.onDidSectionChange((event) => {
if (event.domain === MICRO_COMPACTION_SECTION) {
this.microConfig = this.readConfig();
}
}),
);
this._register(
loop.hooks.beforeStep.register(
'micro-compaction',
async (_ctx, next) => {
this.detect();
await next();
},
),
);
this._register(
this.eventBus.subscribe('context.spliced', (e) => this.observeSplice(e)),
);
}
get lastAssistantAt(): number | null {
return this._lastAssistantAt;
}
private get cutoff(): number {
const cutoff = this.wire.getModel(MicroCompactionModel).cutoff;
const length = (this.context.get() as readonly ContextMessage[]).length;
return cutoff <= length ? cutoff : length;
}
private reset(maxCutoff = 0): void {
const next = Math.min(this.cutoff, maxCutoff);
if (next === this.cutoff) return;
this.wire.dispatch(microCompactionApply({ cutoff: next }));
}
private apply(cutoff: number): void {
this.wire.dispatch(microCompactionApply({ cutoff }));
}
private detect(): void {
if (!this.flags.enabled('micro_compaction')) return;
const lastAssistantAt = this._lastAssistantAt;
if (lastAssistantAt === null) return;
const cacheAgeMs = Date.now() - lastAssistantAt;
if (cacheAgeMs < this.microConfig.cacheMissedThresholdMs) return;
const history = this.context.get();
if (this.contextSizeRatio() < this.microConfig.minContextUsageRatio) return;
const previousCutoff = this.cutoff;
const nextCutoff = Math.max(0, history.length - this.microConfig.keepRecentMessages);
this.apply(nextCutoff);
if (previousCutoff === nextCutoff) return;
const effect = this.measureEffect(history, nextCutoff);
const previousEffect = this.measureEffect(history, previousCutoff);
const rawContextTokens = estimateTokensForMessages(history);
const properties: TelemetryProperties = {
keep_recent_messages: this.microConfig.keepRecentMessages,
min_content_tokens: this.microConfig.minContentTokens,
cache_missed_threshold_ms: this.microConfig.cacheMissedThresholdMs,
truncated_marker: this.microConfig.truncatedMarker,
min_context_usage_ratio: this.microConfig.minContextUsageRatio,
truncated_tool_result_count: effect.truncatedToolResultCount,
truncated_tool_result_tokens_before: effect.truncatedToolResultTokensBefore,
truncated_tool_result_tokens_after: effect.truncatedToolResultTokensAfter,
tokens_before:
rawContextTokens -
previousEffect.truncatedToolResultTokensBefore +
previousEffect.truncatedToolResultTokensAfter,
tokens_after:
rawContextTokens -
effect.truncatedToolResultTokensBefore +
effect.truncatedToolResultTokensAfter,
previous_cutoff: previousCutoff,
cutoff: nextCutoff,
message_count: history.length,
cache_age_ms: cacheAgeMs,
thinking_level: this.profile.data().thinkingLevel,
};
this.telemetry.track('micro_compaction_finished', properties);
}
compact(messages: readonly ContextMessage[]): readonly ContextMessage[] {
if (!this.flags.enabled('micro_compaction')) return messages;
const result: ContextMessage[] = [];
let index = 0;
for (const message of messages) {
if (this.shouldTruncate(message, index)) {
result.push({
...message,
content: [
{ type: 'text', text: this.microConfig.truncatedMarker } satisfies ContentPart,
],
});
} else {
result.push(message);
}
index++;
}
return result;
}
private observeSplice(context: {
readonly deleteCount: number;
readonly messages: readonly ContextMessage[];
}): void {
if (this.context.get().length === 0) {
this._lastAssistantAt = null;
this.reset();
return;
}
if (context.messages.some(isCompactionSummary)) {
this.reset();
}
if (context.messages.some(isAssistantCacheAnchor)) {
this._lastAssistantAt = Date.now();
}
}
private shouldTruncate(message: ContextMessage, index: number): boolean {
return (
index < this.cutoff &&
message.role === 'tool' &&
message.toolCallId !== undefined &&
estimateTokensForContentParts(message.content) >= this.microConfig.minContentTokens
);
}
private readConfig(): MicroCompactionConfig {
const config = this.config.get<MicroCompactionConfigPatch | undefined>(MICRO_COMPACTION_SECTION);
return { ...DEFAULT_CONFIG, ...config };
}
private contextSizeRatio(): number {
const maxContextTokens = this.profile.getModelCapabilities().max_context_tokens;
if (maxContextTokens === undefined || maxContextTokens <= 0) return 1;
return this.contextSize.get().size / maxContextTokens;
}
private measureEffect(
messages: readonly ContextMessage[],
cutoff: number,
) {
let markerTokenCount: number | undefined;
let truncatedToolResultCount = 0;
let truncatedToolResultTokensBefore = 0;
let truncatedToolResultTokensAfter = 0;
for (let i = 0; i < messages.length && i < cutoff; i++) {
const message = messages[i];
if (message === undefined || message.role !== 'tool' || message.toolCallId === undefined) {
continue;
}
const contentTokens = estimateTokensForContentParts(message.content);
if (contentTokens < this.microConfig.minContentTokens) continue;
markerTokenCount ??= estimateTokensForContentParts([
{ type: 'text', text: this.microConfig.truncatedMarker },
]);
truncatedToolResultCount += 1;
truncatedToolResultTokensBefore += contentTokens;
truncatedToolResultTokensAfter += markerTokenCount;
}
return {
truncatedToolResultCount,
truncatedToolResultTokensBefore,
truncatedToolResultTokensAfter,
};
}
}
function isCompactionSummary(message: ContextMessage): boolean {
return message.origin?.kind === 'compaction_summary';
}
function isAssistantCacheAnchor(message: ContextMessage): boolean {
return message.role === 'assistant' && !isCompactionSummary(message);
}
registerScopedService(
LifecycleScope.Agent,
IAgentMicroCompactionService,
AgentMicroCompactionService,
InstantiationType.Eager,
'microCompaction',
);

View file

@ -340,12 +340,6 @@ export * from '#/agent/mcp/config-schema';
export * from '#/agent/media/mediaTools';
export * from '#/agent/media/mediaToolsRegistrar';
export * from '#/agent/media/registerMediaTools';
import '#/agent/microCompaction/flag';
export * from '#/agent/microCompaction/configSection';
export * from '#/agent/microCompaction/microCompaction';
export * from '#/agent/microCompaction/microCompactionOps';
export * from '#/agent/microCompaction/flag';
export * from '#/agent/microCompaction/microCompactionService';
export * from '#/agent/permissionMode/permissionMode';
export * from '#/agent/permissionMode/permissionModeService';
export * from '#/agent/permissionPolicy/permissionPolicy';

View file

@ -6,7 +6,6 @@ import { TestInstantiationService } from '#/_base/di/test';
import type { ContextMessage } from '#/agent/contextMemory/types';
import { IAgentContextProjectorService } from '#/agent/contextProjector/contextProjector';
import { AgentContextProjectorService } from '#/agent/contextProjector/contextProjectorService';
import { IAgentMicroCompactionService } from '#/agent/microCompaction/microCompaction';
import { toProtocolMessage } from '#/agent/contextMemory/messageProjection';
import type { Message } from '#/app/llmProtocol/message';
@ -51,7 +50,6 @@ describe('projector tool-exchange normalization', () => {
disposables = new DisposableStore();
const ix = disposables.add(new TestInstantiationService());
ix.set(IAgentContextProjectorService, new SyncDescriptor(AgentContextProjectorService));
ix.stub(IAgentMicroCompactionService, { compact: (messages) => messages });
projector = ix.get(IAgentContextProjectorService);
});
@ -201,8 +199,7 @@ describe('projector tool-exchange normalization', () => {
});
it('keeps a bare result slice with no preceding assistant (used for sizing)', () => {
// micro-compaction projects single messages to size them — a leading result
// is kept rather than treated as an orphan.
// A leading result is kept rather than treated as an orphan.
expect(shape([toolResult('c1', 'partial result')])).toEqual(['tool:c1']);
});

View file

@ -4,8 +4,8 @@
*
* `projectLegacy` below is the previous implementation, copied verbatim so the
* comparison stays runnable after the old code is gone. The "new" side goes
* through the real `AgentContextProjectorService` with micro-compaction
* stubbed to a pass-through, so it measures exactly the projection path.
* through the real `AgentContextProjectorService`, so it measures exactly the
* projection path.
*
* Run:
* pnpm --filter @moonshot-ai/agent-core-v2 exec vitest bench test/contextProjector/projector.bench.ts
@ -19,7 +19,6 @@ import { TestInstantiationService } from '#/_base/di/test';
import type { ContextMessage } from '#/agent/contextMemory/types';
import { IAgentContextProjectorService } from '#/agent/contextProjector/contextProjector';
import { AgentContextProjectorService } from '#/agent/contextProjector/contextProjectorService';
import { IAgentMicroCompactionService } from '#/agent/microCompaction/microCompaction';
import { ErrorCodes, KimiError } from '#/errors';
import type { ContentPart, Message, TextPart, ToolCall } from '#/app/llmProtocol/message';
@ -187,7 +186,6 @@ function makeMixedHistory(turns: number): ContextMessage[] {
function createProjector(disposables: DisposableStore): IAgentContextProjectorService {
const ix = disposables.add(new TestInstantiationService());
ix.set(IAgentContextProjectorService, new SyncDescriptor(AgentContextProjectorService));
ix.stub(IAgentMicroCompactionService, { compact: (messages) => messages });
return ix.get(IAgentContextProjectorService);
}

View file

@ -22,12 +22,11 @@ import { IFileSystemStorageService } from '#/persistence/interface/storage';
import { stubBootstrap } from '../bootstrap/stubs';
import { stubLog } from '../log/stubs';
const microCompactionFlag: FlagDefinitionInput = {
id: 'micro_compaction',
title: 'Micro compaction',
description:
'Trim older large tool results from context while keeping recent conversation intact.',
env: 'KIMI_CODE_EXPERIMENTAL_MICRO_COMPACTION',
const exampleFlag: FlagDefinitionInput = {
id: 'example_flag',
title: 'Example flag',
description: 'Example experimental flag used to exercise the flag registry.',
env: 'KIMI_CODE_EXPERIMENTAL_EXAMPLE_FLAG',
default: true,
surface: 'core',
};
@ -35,9 +34,9 @@ const microCompactionFlag: FlagDefinitionInput = {
describe('FlagRegistryService', () => {
it('registers and resolves by id', () => {
const reg = new FlagRegistryService();
reg.register(microCompactionFlag);
expect(reg.list().map((d) => d.id)).toEqual(['micro_compaction']);
expect(reg.get('micro_compaction')?.env).toBe('KIMI_CODE_EXPERIMENTAL_MICRO_COMPACTION');
reg.register(exampleFlag);
expect(reg.list().map((d) => d.id)).toEqual(['example_flag']);
expect(reg.get('example_flag')?.env).toBe('KIMI_CODE_EXPERIMENTAL_EXAMPLE_FLAG');
});
it('returns undefined for an unknown id', () => {
@ -47,15 +46,15 @@ describe('FlagRegistryService', () => {
it('throws on a duplicate id', () => {
const reg = new FlagRegistryService();
reg.register(microCompactionFlag);
expect(() => reg.register(microCompactionFlag)).toThrow();
reg.register(exampleFlag);
expect(() => reg.register(exampleFlag)).toThrow();
});
it('unregisters when the returned disposable is disposed', () => {
const reg = new FlagRegistryService();
const handle = reg.register(microCompactionFlag);
const handle = reg.register(exampleFlag);
handle.dispose();
expect(reg.get('micro_compaction')).toBeUndefined();
expect(reg.get('example_flag')).toBeUndefined();
});
});
@ -79,7 +78,7 @@ describe('FlagService', () => {
ix.set(IConfigService, new SyncDescriptor(ConfigService));
ix.set(IFlagRegistry, new SyncDescriptor(FlagRegistryService));
ix.set(IFlagService, new SyncDescriptor(FlagService));
ix.get(IFlagRegistry).register(microCompactionFlag);
ix.get(IFlagRegistry).register(exampleFlag);
return {
registry: ix.get(IConfigRegistry),
config: ix.get(IConfigService),
@ -97,10 +96,10 @@ describe('FlagService', () => {
it('resolves the registry default when nothing overrides it', () => {
const { flags } = makeFlags();
const state = flags.explain('micro_compaction');
const state = flags.explain('example_flag');
expect(state?.enabled).toBe(true);
expect(state?.source).toBe('default');
expect(flags.enabled('micro_compaction')).toBe(true);
expect(flags.enabled('example_flag')).toBe(true);
});
it('returns undefined for an unregistered flag', () => {
@ -111,8 +110,8 @@ describe('FlagService', () => {
it('applies config overrides above the default', async () => {
const { config, flags } = makeFlags();
await config.set(EXPERIMENTAL_SECTION, { micro_compaction: false });
const state = flags.explain('micro_compaction');
await config.set(EXPERIMENTAL_SECTION, { example_flag: false });
const state = flags.explain('example_flag');
expect(state?.enabled).toBe(false);
expect(state?.source).toBe('config');
expect(state?.configValue).toBe(false);
@ -120,10 +119,10 @@ describe('FlagService', () => {
it('lets per-feature env override config', async () => {
const { config, flags } = makeFlags({
KIMI_CODE_EXPERIMENTAL_MICRO_COMPACTION: 'true',
KIMI_CODE_EXPERIMENTAL_EXAMPLE_FLAG: 'true',
});
await config.set(EXPERIMENTAL_SECTION, { micro_compaction: false });
const state = flags.explain('micro_compaction');
await config.set(EXPERIMENTAL_SECTION, { example_flag: false });
const state = flags.explain('example_flag');
expect(state?.enabled).toBe(true);
expect(state?.source).toBe('env');
expect(state?.configValue).toBe(false);
@ -131,70 +130,70 @@ describe('FlagService', () => {
it('lets the master env switch force every flag on', async () => {
const { config, flags } = makeFlags({ [MASTER_ENV]: '1' });
await config.set(EXPERIMENTAL_SECTION, { micro_compaction: false });
const state = flags.explain('micro_compaction');
await config.set(EXPERIMENTAL_SECTION, { example_flag: false });
const state = flags.explain('example_flag');
expect(state?.enabled).toBe(true);
expect(state?.source).toBe('master-env');
});
it('refreshes overrides when the experimental config section changes', async () => {
const { config, flags } = makeFlags();
expect(flags.enabled('micro_compaction')).toBe(true);
await config.set(EXPERIMENTAL_SECTION, { micro_compaction: false });
expect(flags.enabled('micro_compaction')).toBe(false);
await config.set(EXPERIMENTAL_SECTION, { micro_compaction: true });
expect(flags.enabled('micro_compaction')).toBe(true);
expect(flags.enabled('example_flag')).toBe(true);
await config.set(EXPERIMENTAL_SECTION, { example_flag: false });
expect(flags.enabled('example_flag')).toBe(false);
await config.set(EXPERIMENTAL_SECTION, { example_flag: true });
expect(flags.enabled('example_flag')).toBe(true);
});
it('ignores unrelated config section changes', async () => {
const { config, flags } = makeFlags();
await config.set('agent', { modelAlias: 'k2' });
expect(flags.explain('micro_compaction')?.source).toBe('default');
expect(flags.explain('example_flag')?.source).toBe('default');
});
it('supports imperative setConfigOverrides', () => {
const { flags } = makeFlags();
flags.setConfigOverrides({ micro_compaction: false });
expect(flags.enabled('micro_compaction')).toBe(false);
flags.setConfigOverrides({ example_flag: false });
expect(flags.enabled('example_flag')).toBe(false);
flags.setConfigOverrides(undefined);
expect(flags.enabled('micro_compaction')).toBe(true);
expect(flags.enabled('example_flag')).toBe(true);
});
it('exposes snapshot / enabledIds / explainAll', () => {
const { flags } = makeFlags();
expect(flags.snapshot()).toEqual({ micro_compaction: true });
expect(flags.enabledIds()).toEqual(['micro_compaction']);
expect(flags.explainAll().map((s) => s.id)).toEqual(['micro_compaction']);
expect(flags.snapshot()).toEqual({ example_flag: true });
expect(flags.enabledIds()).toEqual(['example_flag']);
expect(flags.explainAll().map((s) => s.id)).toEqual(['example_flag']);
});
it('treats truthy env values case-insensitively', () => {
const { flags } = makeFlags({ KIMI_CODE_EXPERIMENTAL_MICRO_COMPACTION: 'YES' });
expect(flags.enabled('micro_compaction')).toBe(true);
const { flags } = makeFlags({ KIMI_CODE_EXPERIMENTAL_EXAMPLE_FLAG: 'YES' });
expect(flags.enabled('example_flag')).toBe(true);
});
it('treats falsy env values case-insensitively', () => {
const { flags } = makeFlags({ KIMI_CODE_EXPERIMENTAL_MICRO_COMPACTION: 'off' });
expect(flags.enabled('micro_compaction')).toBe(false);
const { flags } = makeFlags({ KIMI_CODE_EXPERIMENTAL_EXAMPLE_FLAG: 'off' });
expect(flags.enabled('example_flag')).toBe(false);
});
it('reads only the env name declared in the registry', () => {
const { flags } = makeFlags({ KIMI_CODE_EXPERIMENTAL_UNKNOWN: 'false' });
expect(flags.enabled('micro_compaction')).toBe(true);
expect(flags.enabled('example_flag')).toBe(true);
});
it('ignores garbage env values', () => {
const { flags } = makeFlags({ KIMI_CODE_EXPERIMENTAL_MICRO_COMPACTION: 'maybe' });
expect(flags.enabled('micro_compaction')).toBe(true);
const { flags } = makeFlags({ KIMI_CODE_EXPERIMENTAL_EXAMPLE_FLAG: 'maybe' });
expect(flags.enabled('example_flag')).toBe(true);
});
it('ignores obsolete config ids outside the registry', async () => {
const { config, flags } = makeFlags();
await config.set(EXPERIMENTAL_SECTION, {
obsolete_flag: false,
micro_compaction: false,
example_flag: false,
});
expect(flags.snapshot()).toEqual({ micro_compaction: false });
expect(flags.snapshot()).toEqual({ example_flag: false });
expect(flags.explain('obsolete_flag')).toBeUndefined();
});
});

View file

@ -15,15 +15,12 @@ import {
import { makeHookRunner } from '../externalHooks/runner-stub';
import type { IExternalHooksRunnerService } from '#/app/externalHooksRunner/externalHooksRunner';
import { MASTER_ENV } from '#/app/flag/flagService';
import { microCompactionFlag } from '#/agent/microCompaction/flag';
import { COMPACTION_SUMMARY_PREFIX } from '#/agent/contextMemory/compactionHandoff';
import { estimateTokensForMessages } from '#/_base/utils/tokens';
import { recordingTelemetry, type TelemetryRecord } from '../telemetry/stubs';
import type { TestAgentContext, TestAgentOptions, TestAgentServiceOverride } from '../harness';
import { appServices, createCommandRunner, execEnvServices, sessionServices, testAgent } from '../harness';
import {
IAgentFullCompactionService,
IAgentMicroCompactionService,
IOAuthService,
IAgentProfileService,
ISessionTodoService,
@ -46,7 +43,6 @@ const CATALOGUED_MODEL_CAPABILITIES = {
tool_use: true,
max_context_tokens: 256_000,
} as const;
const MICRO_COMPACTION_FLAG_ENV = getMicroCompactionFlagEnv();
const SNAPSHOT_VISIBLE_TOOLS = [
'Agent',
'AgentSwarm',
@ -352,103 +348,6 @@ describe('FullCompaction', () => {
).toBe(false);
});
it('strips dynamic tool protocol context from the summary request', async () => {
const ctx = testAgent();
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
tools: SNAPSHOT_VISIBLE_TOOLS,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
await ctx.dispatch({
type: 'context.splice',
start: ctx.context.get().length,
deleteCount: 0,
messages: [
{
role: 'system',
content: [],
toolCalls: [],
tools: [
{
name: 'LoadedDynamicTool',
description: 'schema text should not be summarized',
parameters: { type: 'object', properties: { query: { type: 'string' } } },
},
],
origin: { kind: 'injection', variant: 'dynamic_tool_schema' },
},
{
role: 'user',
content: [{ type: 'text', text: '<tools_added>\nLoadedDynamicTool\n</tools_added>' }],
toolCalls: [],
origin: { kind: 'system_trigger', name: 'loadable-tools' },
},
],
});
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
const compacted = ctx.once('full_compaction.complete');
const completed = ctx.once('compaction.completed');
ctx.mockNextResponse({ type: 'text', text: 'Summary without protocol context.' });
await ctx.rpc.beginCompaction({});
await compacted;
await completed;
const [compactionCall] = ctx.llmCalls;
const inputText = compactionCall?.history.map(messageText).join('\n') ?? '';
expect(compactionCall?.history.some((message) => message.tools !== undefined)).toBe(false);
expect(inputText).not.toContain('LoadedDynamicTool');
expect(inputText).not.toContain('<tools_added>');
expect(inputText).not.toContain('schema text should not be summarized');
expect(inputText).toContain('old user one');
expect(inputText).toContain('recent user two');
await ctx.expectResumeMatches();
});
it('micro-compacts old tool results before sending the summary request', async () => {
vi.useFakeTimers();
enableMicroCompactionFlag();
const ctx = testAgent({
microCompaction: {
config: {
keepRecentMessages: 2,
minContentTokens: 1,
cacheMissedThresholdMs: 60 * 60 * 1000,
minContextUsageRatio: 0,
}
},
});
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
tools: SNAPSHOT_VISIBLE_TOOLS,
});
// Force-construct the micro-compaction service so it registers its
// onSpliced observer before the tool exchanges are appended (otherwise the
// lazily-instantiated service never records the assistant cache anchor that
// `detect()` needs).
(ctx.get(IAgentMicroCompactionService) as any).compact([]);
vi.setSystemTime(0);
ctx.appendToolExchange();
ctx.appendToolExchange();
vi.setSystemTime(61 * 60 * 1000);
(ctx.get(IAgentMicroCompactionService) as any).detect();
const compacted = ctx.once('full_compaction.complete');
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<boolean | undefined> = [];
const authKeys: string[] = [];
@ -2516,15 +2415,6 @@ afterEach(() => {
vi.unstubAllEnvs();
});
function enableMicroCompactionFlag(): void {
vi.stubEnv(MASTER_ENV, '0');
vi.stubEnv(MICRO_COMPACTION_FLAG_ENV, '1');
}
function getMicroCompactionFlagEnv(): string {
return microCompactionFlag.env;
}
function deferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;

View file

@ -22,7 +22,6 @@ import { CronTaskPersistenceService } from '#/app/cron/cronTaskPersistenceServic
import { IAgentGoalService } from '#/agent/goal/goal';
import { AgentGoalService } from '#/agent/goal/goalService';
import type { McpServiceOptions } from '#/agent/mcp/mcp';
import { MICRO_COMPACTION_SECTION, type MicroCompactionConfig } from '#/agent/microCompaction/configSection';
import type { PermissionMode } from '#/agent/permissionPolicy/types';
import type { PermissionRule } from '#/agent/permissionRules/permissionRules';
import { IAgentPlanService } from '#/agent/plan/plan';
@ -80,7 +79,6 @@ import {
IAgentLLMRequesterService,
ILogService,
IAgentMcpService,
IAgentMicroCompactionService,
IAgentPermissionGate,
IAgentPermissionModeService,
IAgentPermissionRulesService,
@ -101,7 +99,6 @@ import {
AgentLLMRequesterService,
LifecycleScope,
AgentMcpService,
AgentMicroCompactionService,
AgentPermissionGate,
AgentPermissionRulesService,
AgentProfileService,
@ -291,11 +288,6 @@ export interface TestAgentOptions {
readonly generate?: GenerateFn | undefined;
readonly telemetry?: ITelemetryService | undefined;
readonly persistence?: WireRecordPersistence | undefined;
readonly microCompaction?:
| {
readonly config?: Partial<MicroCompactionConfig> | undefined;
}
| undefined;
readonly hookEngine?:
| Pick<IExternalHooksRunnerService, 'trigger' | 'triggerBlock' | 'fireAndForgetTrigger'>
| undefined;
@ -594,15 +586,6 @@ const noopHookRunner: IExternalHooksRunnerService = {
fireAndForgetTrigger: async () => [],
};
export function microCompactionServices(options: {
readonly config?: Partial<MicroCompactionConfig>;
}): TestAgentServiceOverride {
return configServices(() => ({
...emptyConfig(),
[MICRO_COMPACTION_SECTION]: options.config,
}));
}
export function permissionModeServices(mode: PermissionMode): TestAgentServiceOverride {
return agentService(IAgentPermissionModeService, createPermissionModeService(mode));
}
@ -725,17 +708,6 @@ function mergeTestAgentOptions(base: TestAgentOptions, next: TestAgentOptions):
return {
...base,
...next,
microCompaction:
base.microCompaction === undefined && next.microCompaction === undefined
? undefined
: {
...base.microCompaction,
...next.microCompaction,
config: {
...base.microCompaction?.config,
...next.microCompaction?.config,
},
},
initialConfig: {
...base.initialConfig,
...next.initialConfig,
@ -1085,10 +1057,6 @@ export class AgentTestContext {
IAgentExternalHooksService,
new SyncDescriptor(AgentExternalHooksService),
);
reg.defineDescriptor(
IAgentMicroCompactionService,
new SyncDescriptor(AgentMicroCompactionService),
);
reg.defineDescriptor(
IAgentFullCompactionService,
new SyncDescriptor(AgentFullCompactionService),
@ -1224,8 +1192,6 @@ export class AgentTestContext {
const swarm = this.get(IAgentSwarmService);
context.get();
const microCompaction = this.get(IAgentMicroCompactionService);
void microCompaction;
void swarm.isActive;
contextSize.get();
usage.status();
@ -2123,10 +2089,6 @@ function applyTestAgentOptionsToConfig(config: KimiConfig, options: TestAgentOpt
...config.models,
...initialConfig.models,
},
[MICRO_COMPACTION_SECTION]:
options.microCompaction?.config ??
initialConfig[MICRO_COMPACTION_SECTION] ??
config[MICRO_COMPACTION_SECTION],
};
}

View file

@ -17,7 +17,6 @@ export {
llmGenerateServices,
logServices,
mcpServices,
microCompactionServices,
modelProviderOptionServices,
modelProviderServices,
permissionModeServices,

View file

@ -1,996 +0,0 @@
import type { ContentPart } from '#/app/llmProtocol/message';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { estimateTokensForMessages } from '#/_base/utils/tokens';
import { microCompactionFlag } from '#/agent/microCompaction/flag';
import { IFlagService } from '#/app/flag/flag';
import { MASTER_ENV } from '#/app/flag/flagService';
import {
AGENT_WIRE_PROTOCOL_VERSION,
IAgentMicroCompactionService,
type PersistedWireRecord,
} from '#/index';
import { InMemoryWireRecordPersistence, testAgent, type TestAgentContext } from '../harness';
import { recordingTelemetry, type TelemetryRecord } from '../telemetry/stubs';
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('defaults the micro_compaction flag on', () => {
vi.unstubAllEnvs();
vi.stubEnv(MASTER_ENV, '0');
const ctx = testAgent();
expect(ctx.get(IFlagService).enabled('micro_compaction')).toBe(true);
});
it('truncates old tool results after cache miss', () => {
vi.useFakeTimers();
const ctx = testAgent({
microCompaction: {
config: {
keepRecentMessages: 4,
minContentTokens: 1,
cacheMissedThresholdMs: 60 * 60 * 1000,
minContextUsageRatio: 0,
},
},
});
vi.setSystemTime(0);
ctx.appendToolExchange();
ctx.appendToolExchange();
ctx.appendToolExchange();
expect(ctx.project()).toHaveLength(9);
vi.setSystemTime(61 * 60 * 1000);
(ctx.get(IAgentMicroCompactionService) as any).detect();
const messages = ctx.project();
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: {
config: {
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.project();
expect(hasMarker(messages)).toBe(false);
});
it('persists cutoff across calls until cache miss resets it', () => {
vi.useFakeTimers();
const ctx = testAgent({
microCompaction: {
config: {
keepRecentMessages: 2,
minContentTokens: 1,
cacheMissedThresholdMs: 60 * 60 * 1000,
minContextUsageRatio: 0,
},
},
});
vi.setSystemTime(0);
ctx.appendToolExchange();
ctx.appendToolExchange();
vi.setSystemTime(61 * 60 * 1000);
(ctx.get(IAgentMicroCompactionService) as any).detect();
const first = ctx.project();
expect(first[2]).toMatchObject({
role: 'tool',
content: [{ type: 'text', text: DEFAULT_MARKER }],
});
vi.setSystemTime(62 * 60 * 1000);
(ctx.get(IAgentMicroCompactionService) as any).detect();
const second = ctx.project();
expect(second[2]).toMatchObject({
role: 'tool',
content: [{ type: 'text', text: DEFAULT_MARKER }],
});
});
it('clears cutoff on reset', () => {
vi.useFakeTimers();
const ctx = testAgent({
microCompaction: {
config: {
keepRecentMessages: 4,
minContentTokens: 1,
cacheMissedThresholdMs: 60 * 60 * 1000,
},
},
});
vi.setSystemTime(0);
ctx.appendToolExchange();
ctx.appendToolExchange();
vi.setSystemTime(61 * 60 * 1000);
(ctx.get(IAgentMicroCompactionService) as any).reset();
const messages = ctx.project();
expect(hasMarker(messages)).toBe(false);
});
it('skips tool results below minContentTokens', () => {
vi.useFakeTimers();
const ctx = testAgent({
microCompaction: {
config: {
keepRecentMessages: 2,
minContentTokens: 100,
cacheMissedThresholdMs: 60 * 60 * 1000,
},
},
});
vi.setSystemTime(0);
ctx.appendToolExchange();
ctx.appendToolExchange();
vi.setSystemTime(61 * 60 * 1000);
const messages = ctx.project();
expect(hasMarker(messages)).toBe(false);
});
it('skips non-tool messages', () => {
vi.useFakeTimers();
const ctx = testAgent({
microCompaction: {
config: {
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.project();
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: {
config: {
keepRecentMessages: 2,
minContentTokens: 1,
cacheMissedThresholdMs: 60 * 60 * 1000,
},
},
});
vi.setSystemTime(0);
ctx.appendToolExchange();
ctx.appendToolExchange();
vi.setSystemTime(61 * 60 * 1000);
ctx.clearContext();
expect(ctx.project()).toHaveLength(0);
expect((ctx.get(IAgentMicroCompactionService) as any).lastAssistantAt).toBeNull();
});
it('sends truncated old tool results to the next model request without mutating history', async () => {
vi.useFakeTimers();
const ctx = testAgent({
microCompaction: {
config: {
keepRecentMessages: 4,
minContentTokens: 1,
cacheMissedThresholdMs: 60 * MINUTE,
minContextUsageRatio: 0,
},
},
});
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.context.get()[2])).toBe('old result one');
expect(textOf(ctx.context.get()[5])).toBe('middle result two');
expect(textOf(ctx.context.get()[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: {
config: {
keepRecentMessages: 0,
minContentTokens: 1,
cacheMissedThresholdMs: 60 * MINUTE,
minContextUsageRatio: 0,
},
},
persistence: new InMemoryWireRecordPersistence(
resumeToolExchangeRecords(assistantRecordTime),
),
});
vi.setSystemTime(999_999);
await ctx.restorePersisted();
expect((ctx.get(IAgentMicroCompactionService) as any).lastAssistantAt).toBe(
assistantRecordTime,
);
vi.setSystemTime(assistantRecordTime + 30 * MINUTE);
expect(hasMarker(ctx.project())).toBe(false);
vi.setSystemTime(assistantRecordTime + 61 * MINUTE);
(ctx.get(IAgentMicroCompactionService) as any).detect();
expect(toolTexts(ctx.project())).toEqual([DEFAULT_MARKER]);
});
it('preserves the restored cutoff when resuming before the next cache miss', async () => {
vi.useFakeTimers();
const persistence = new InMemoryWireRecordPersistence();
const config = {
keepRecentMessages: 2,
minContentTokens: 1,
cacheMissedThresholdMs: 60 * MINUTE,
minContextUsageRatio: 0,
};
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);
(ctx.get(IAgentMicroCompactionService) as any).detect();
expect(toolTexts(ctx.project())).toEqual([DEFAULT_MARKER, 'result two']);
await ctx.wireRecord.flush();
expect(lastMicroCompactionCutoff(persistence.records)).toBe(4);
vi.setSystemTime(62 * MINUTE);
appendMicroToolExchange(ctx, 3, { output: 'result three' });
await ctx.wireRecord.flush();
const resumed = testAgent({
microCompaction: { config },
persistence: new InMemoryWireRecordPersistence(cloneRecords(persistence.records)),
});
vi.setSystemTime(63 * MINUTE);
await resumed.restorePersisted();
expect((resumed.get(IAgentMicroCompactionService) as any).lastAssistantAt).toBe(62 * MINUTE);
expect(toolTexts(resumed.project())).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 InMemoryWireRecordPersistence();
const config = {
keepRecentMessages: 2,
minContentTokens: 1,
cacheMissedThresholdMs: 60 * MINUTE,
minContextUsageRatio: 0,
};
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);
(ctx.get(IAgentMicroCompactionService) as any).detect();
expect(toolTexts(ctx.project())).toEqual([DEFAULT_MARKER, 'result two']);
await ctx.wireRecord.flush();
expect(lastMicroCompactionCutoff(persistence.records)).toBe(4);
vi.setSystemTime(62 * MINUTE);
appendMicroToolExchange(ctx, 3, { output: 'result three' });
await ctx.wireRecord.flush();
const resumedPersistence = new InMemoryWireRecordPersistence(cloneRecords(persistence.records));
const resumed = testAgent({
microCompaction: { config },
persistence: resumedPersistence,
});
vi.setSystemTime(123 * MINUTE);
await resumed.restorePersisted();
expect((resumed.get(IAgentMicroCompactionService) as any).lastAssistantAt).toBe(62 * MINUTE);
(resumed.get(IAgentMicroCompactionService) as any).detect();
expect(toolTexts(resumed.project())).toEqual([DEFAULT_MARKER, DEFAULT_MARKER, 'result three']);
await resumed.wireRecord.flush();
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: {
config: {
keepRecentMessages: 2,
minContentTokens: 1,
cacheMissedThresholdMs: 60 * MINUTE,
minContextUsageRatio: 0,
},
},
});
vi.setSystemTime(0);
appendMicroToolExchange(ctx, 1, { output: 'result one' });
appendMicroToolExchange(ctx, 2, { output: 'result two' });
vi.setSystemTime(61 * MINUTE);
(ctx.get(IAgentMicroCompactionService) as any).detect();
expect(toolTexts(ctx.project())).toEqual([DEFAULT_MARKER, 'result two']);
vi.setSystemTime(62 * MINUTE);
appendMicroToolExchange(ctx, 3, { output: 'result three' });
vi.setSystemTime(63 * MINUTE);
(ctx.get(IAgentMicroCompactionService) as any).detect();
expect(toolTexts(ctx.project())).toEqual([DEFAULT_MARKER, 'result two', 'result three']);
vi.setSystemTime(123 * MINUTE);
(ctx.get(IAgentMicroCompactionService) as any).detect();
expect(toolTexts(ctx.project())).toEqual([DEFAULT_MARKER, DEFAULT_MARKER, 'result three']);
});
it('clamps cutoff when undo shortens the context', () => {
vi.useFakeTimers();
const ctx = testAgent({
microCompaction: {
config: {
keepRecentMessages: 2,
minContentTokens: 1,
cacheMissedThresholdMs: 60 * MINUTE,
minContextUsageRatio: 0,
},
},
});
vi.setSystemTime(0);
appendMicroToolExchange(ctx, 1, { output: 'result one' });
appendMicroToolExchange(ctx, 2, { output: 'result two' });
appendMicroToolExchange(ctx, 3, { output: 'result three' });
vi.setSystemTime(61 * MINUTE);
(ctx.get(IAgentMicroCompactionService) as any).detect();
expect(toolTexts(ctx.project())).toEqual([DEFAULT_MARKER, DEFAULT_MARKER, 'result three']);
ctx.undoHistory(2);
appendMicroToolExchange(ctx, 4, { output: 'result four' });
expect(toolTexts(ctx.project())).toEqual([DEFAULT_MARKER, 'result four']);
});
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,
minContextUsageRatio: 0,
};
const ctx = testAgent({
telemetry: recordingTelemetry(records),
microCompaction: { config: 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);
(ctx.get(IAgentMicroCompactionService) as any).detect();
expect(toolTexts(ctx.project())).toEqual([DEFAULT_MARKER, DEFAULT_MARKER, 'result three']);
const event = singleTelemetryEvent(records, 'micro_compaction_finished');
expect(event.properties).toMatchObject({
keep_recent_messages: microCompaction.keepRecentMessages,
min_content_tokens: microCompaction.minContentTokens,
cache_missed_threshold_ms: microCompaction.cacheMissedThresholdMs,
min_context_usage_ratio: microCompaction.minContextUsageRatio,
truncated_marker: DEFAULT_MARKER,
previous_cutoff: 0,
cutoff: 7,
message_count: 9,
cache_age_ms: 61 * MINUTE,
truncated_tool_result_count: 2,
truncated_tool_result_tokens_before: expect.any(Number),
truncated_tool_result_tokens_after: expect.any(Number),
tokens_before: expect.any(Number),
tokens_after: expect.any(Number),
thinking_level: 'off',
});
expect(numberProperty(event, 'truncated_tool_result_tokens_before')).toBeGreaterThan(
numberProperty(event, 'truncated_tool_result_tokens_after'),
);
expect(numberProperty(event, 'tokens_before')).toBeGreaterThan(
numberProperty(event, 'tokens_after'),
);
expect(ctx.project()).toHaveLength(9);
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: { config: 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.get(IAgentMicroCompactionService) as any).detect();
expect(toolTexts(ctx.project())).toEqual([DEFAULT_MARKER, 'result two '.repeat(20)]);
vi.setSystemTime(62 * MINUTE);
appendMicroToolExchange(ctx, 3, { output: 'result three' });
const expectedContextTokensBefore = estimateTokensForMessages(ctx.project());
vi.setSystemTime(123 * MINUTE);
(ctx.get(IAgentMicroCompactionService) as any).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,
truncated_tool_result_count: 2,
tokens_before: expectedContextTokensBefore,
tokens_after: estimateTokensForMessages(ctx.project()),
});
});
it('leaves context unchanged when the micro_compaction flag is disabled', () => {
vi.stubEnv(MICRO_COMPACTION_FLAG_ENV, '0');
vi.useFakeTimers();
const persistence = new InMemoryWireRecordPersistence();
const ctx = testAgent({
microCompaction: {
config: {
keepRecentMessages: 0,
minContentTokens: 1,
cacheMissedThresholdMs: 60 * MINUTE,
minContextUsageRatio: 0,
},
},
persistence,
});
vi.setSystemTime(0);
appendMicroToolExchange(ctx, 1, { output: 'result one' });
vi.setSystemTime(61 * MINUTE);
(ctx.get(IAgentMicroCompactionService) as any).detect();
expect(toolTexts(ctx.project())).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: {
config: {
keepRecentMessages: 0,
minContentTokens: 1,
cacheMissedThresholdMs: 60 * MINUTE,
truncatedMarker: marker,
minContextUsageRatio: 0,
},
},
});
vi.setSystemTime(0);
appendMicroToolExchange(ctx, 1, { output: 'abcd' });
vi.setSystemTime(61 * MINUTE);
(ctx.get(IAgentMicroCompactionService) as any).detect();
expect(toolTexts(ctx.project())).toEqual([marker]);
expect(textOf(ctx.context.get()[2])).toBe('abcd');
});
it('keeps raw pending token accounting even when projection truncates tool output', () => {
vi.useFakeTimers();
const ctx = testAgent({
microCompaction: {
config: {
keepRecentMessages: 0,
minContentTokens: 1,
cacheMissedThresholdMs: 60 * MINUTE,
minContextUsageRatio: 0,
},
},
});
ctx.configure();
vi.setSystemTime(0);
appendMicroToolExchange(ctx, 1, {
output: 'x'.repeat(400),
usageTokens: 50,
});
vi.setSystemTime(61 * MINUTE);
(ctx.get(IAgentMicroCompactionService) as any).detect();
const rawPending = ctx.context.get().slice(-1);
const projectedPending = (ctx.get(IAgentMicroCompactionService) as any).compact(rawPending);
expect(textOf(projectedPending[0])).toBe(DEFAULT_MARKER);
expect(ctx.contextSize.get().size).toBe(
ctx.contextSize.get().measured + estimateTokensForMessages(rawPending),
);
expect(ctx.contextSize.get().size).toBeGreaterThan(
ctx.contextSize.get().measured + estimateTokensForMessages(projectedPending),
);
});
it('replaces rich error tool content while preserving context metadata before projection', () => {
vi.useFakeTimers();
const ctx = testAgent({
microCompaction: {
config: {
keepRecentMessages: 0,
minContentTokens: 1,
cacheMissedThresholdMs: 60 * MINUTE,
minContextUsageRatio: 0,
},
},
});
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);
(ctx.get(IAgentMicroCompactionService) as any).detect();
const compacted = (ctx.get(IAgentMicroCompactionService) as any).compact(ctx.context.get());
const tool = compacted.find((message: any) => 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: {
config: {
keepRecentMessages: 0,
minContentTokens: 1,
cacheMissedThresholdMs: 60 * MINUTE,
},
},
});
vi.setSystemTime(0);
ctx.context.splice(ctx.context.get().length, 0, [
{
role: 'assistant',
content: [{ type: 'text', text: 'assistant anchor' }],
toolCalls: [],
},
]);
vi.setSystemTime(61 * MINUTE);
ctx.context.splice(ctx.context.get().length, 0, [
{
role: 'tool',
content: [{ type: 'text', text: 'orphan tool-like output' }],
toolCalls: [],
},
]);
(ctx.get(IAgentMicroCompactionService) as any).detect();
const compacted = (ctx.get(IAgentMicroCompactionService) as any).compact(ctx.context.get());
expect(toolTexts(compacted)).toEqual(['orphan tool-like output']);
});
it('clears cutoff on full compaction', async () => {
vi.useFakeTimers();
const ctx = testAgent({
microCompaction: {
config: {
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('full_compaction.complete');
ctx.mockNextResponse({ type: 'text', text: 'Summary.' });
await ctx.rpc.beginCompaction({});
await compacted;
expect(ctx.project()).toHaveLength(1);
expect(ctx.project()[0]).toMatchObject({
role: 'assistant',
content: [{ type: 'text', text: 'Summary.' }],
});
});
it('does not apply when context usage is below minContextUsageRatio', () => {
vi.useFakeTimers();
const ctx = testAgent({
microCompaction: {
config: {
keepRecentMessages: 0,
minContentTokens: 1,
cacheMissedThresholdMs: 60 * MINUTE,
minContextUsageRatio: 0.9,
},
},
});
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
vi.setSystemTime(0);
appendMicroToolExchange(ctx, 1, { output: 'result one' });
vi.setSystemTime(61 * MINUTE);
const messages = ctx.project();
expect(hasMarker(messages)).toBe(false);
});
it('applies when context usage is above minContextUsageRatio', () => {
vi.useFakeTimers();
const ctx = testAgent({
microCompaction: {
config: {
keepRecentMessages: 0,
minContentTokens: 1,
cacheMissedThresholdMs: 60 * MINUTE,
minContextUsageRatio: 0.5,
},
},
});
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: {
image_in: true,
video_in: true,
audio_in: false,
thinking: true,
tool_use: true,
max_context_tokens: 100,
},
});
vi.setSystemTime(0);
appendMicroToolExchange(ctx, 1, { output: 'x'.repeat(300) });
vi.setSystemTime(61 * MINUTE);
(ctx.get(IAgentMicroCompactionService) as any).detect();
const messages = ctx.project();
expect(hasMarker(messages)).toBe(true);
});
it('does not truncate when messages are fewer than keepRecentMessages', () => {
vi.useFakeTimers();
const ctx = testAgent({
microCompaction: {
config: {
keepRecentMessages: 20,
minContentTokens: 1,
cacheMissedThresholdMs: 60 * 60 * 1000,
},
},
});
vi.setSystemTime(0);
ctx.appendToolExchange();
ctx.appendToolExchange();
vi.setSystemTime(61 * 60 * 1000);
const messages = ctx.project();
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 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.appendUserMessage([{ type: 'text', text: `lookup ${String(index)}` }]);
ctx.context.splice(ctx.context.get().length, 0, [
{
role: 'assistant',
content: [{ type: 'text', text: `calling Lookup ${String(index)}` }],
toolCalls: [
{
type: 'function',
id: toolCallId,
name: 'Lookup',
arguments: JSON.stringify({ query: `item-${String(index)}` }),
},
],
},
]);
if (usage !== undefined) {
ctx.contextSize.measured(ctx.context.get(), [], usage);
}
ctx.context.splice(ctx.context.get().length, 0, [
{
role: 'tool',
content: typeof output === 'string' ? [{ type: 'text', text: output }] : [...output],
toolCalls: [],
toolCallId,
isError: options.isError,
},
]);
}
function resumeToolExchangeRecords(assistantRecordTime: number): PersistedWireRecord[] {
return [
{
type: 'metadata',
protocol_version: AGENT_WIRE_PROTOCOL_VERSION,
created_at: 1,
},
{
type: 'context.splice',
time: 1_000,
start: 0,
deleteCount: 0,
messages: [
{
role: 'user',
content: [{ type: 'text', text: 'lookup from restored session' }],
toolCalls: [],
origin: { kind: 'user' },
},
],
},
{
type: 'turn.launch',
time: assistantRecordTime,
turnId: 0,
origin: { kind: 'user' },
},
{
type: 'context.splice',
time: assistantRecordTime,
start: 1,
deleteCount: 0,
messages: [
{
role: 'assistant',
content: [{ type: 'text', text: 'calling restored Lookup' }],
toolCalls: [
{
type: 'function',
id: 'resume_micro_call',
name: 'Lookup',
arguments: JSON.stringify({ query: 'restored' }),
},
],
},
],
},
{
type: 'context.splice',
time: assistantRecordTime + 2,
start: 2,
deleteCount: 0,
messages: [
{
role: 'tool',
content: [{ type: 'text', text: 'restored lookup result' }],
toolCalls: [],
toolCallId: 'resume_micro_call',
},
],
},
] as PersistedWireRecord[];
}
function cloneRecords(records: readonly PersistedWireRecord[]): PersistedWireRecord[] {
return records.map((record) => structuredClone(record));
}
function lastMicroCompactionCutoff(records: readonly PersistedWireRecord[]): number | undefined {
return (records.findLast((record) => record.type === 'micro_compaction.apply') as any)?.cutoff;
}
function toolTexts(
messages: readonly { role: string; content?: readonly { type: string; text?: string }[] }[],
): string[] {
return messages.filter((message) => message.role === 'tool').map((message) => textOf(message));
}
function textOf(
message: { content?: readonly { type: string; text?: string }[] } | undefined,
): string {
return (
message?.content
?.map((part) => {
if (part.type === 'text') return part.text;
return '';
})
.join('') ?? ''
);
}
function hasMarker(
messages: readonly { role: string; content?: readonly { type: string; text?: string }[] }[],
): boolean {
return toolTexts(messages).includes(DEFAULT_MARKER);
}
function getMicroCompactionFlagEnv(): string {
return microCompactionFlag.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;
}