mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
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:
parent
55b206d3d8
commit
248b30efc5
25 changed files with 69 additions and 1622 deletions
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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` → 定义处
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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');
|
||||
|
|
@ -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 };
|
||||
},
|
||||
});
|
||||
|
|
@ -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',
|
||||
);
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ export {
|
|||
llmGenerateServices,
|
||||
logServices,
|
||||
mcpServices,
|
||||
microCompactionServices,
|
||||
modelProviderOptionServices,
|
||||
modelProviderServices,
|
||||
permissionModeServices,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue