diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index a099b1dc1..2eefa108e 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -52,7 +52,7 @@ max_running_tasks = 4 keep_alive_on_exit = false [experimental] -micro_compaction = true +micro_compaction = false [[permission.rules]] decision = "allow" @@ -175,11 +175,11 @@ You can also switch models temporarily without touching the config file — by s ## `experimental` -`experimental` stores persistent overrides for experimental-feature flags. Currently, `micro_compaction` is the only user-facing entry and defaults to `true`; set it to `false` only when you need to disable automatic trimming of older large tool results. +`experimental` stores persistent overrides for experimental-feature flags. Currently, `micro_compaction` is the only user-facing entry and defaults to `false`; set it to `true` to enable automatic trimming of older large tool results. | Field | Type | Default | Description | | --- | --- | --- | --- | -| `micro_compaction` | `boolean` | `true` | Trim older large tool results from context while preserving recent conversation | +| `micro_compaction` | `boolean` | `false` | Trim older large tool results from context while preserving recent conversation | ## `services` diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index c0f84251c..af47849e6 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -126,7 +126,7 @@ Switches that control the behavior of subsystems such as telemetry, background t | `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` | Whether to keep background tasks when the session closes; takes higher priority than `config.toml`. The default is to stop them on exit | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | | `KIMI_CODE_PLUGIN_MARKETPLACE_URL` | Override the plugin marketplace JSON loaded by `/plugins`; useful for dev loopback servers, staging CDN files, or alternate marketplace directories | `https://code.kimi.com/kimi-code/plugins/marketplace.json`; also accepts `http://`, `file://` URLs, and local paths | | `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` | Cap how many AgentSwarm subagents run concurrently during the initial ramp; leave unset for no cap | Positive integer; invalid values fail fast | -| `KIMI_CODE_EXPERIMENTAL_FLAG` | Enable all registered experimental features for this process; `micro_compaction` is already enabled by default | `1`, `true`, `yes`, `on` | +| `KIMI_CODE_EXPERIMENTAL_FLAG` | Enable all registered experimental features for this process | `1`, `true`, `yes`, `on` | | `KIMI_CODE_EXPERIMENTAL_MICRO_COMPACTION` | Override [`[experimental].micro_compaction`](./config-files.md#experimental) for this process | Truthy or falsy | | `KIMI_SHELL_PATH` | Override the Git Bash path on Windows (used when auto-detection fails) | Absolute path | | `KIMI_MODEL_MAX_COMPLETION_TOKENS` | Hard cap on `max_completion_tokens` per LLM step; applies to the `kimi` provider only | Positive integer; `0` or negative disables clamping | diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index f9b84c58c..cdc4b6276 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -52,7 +52,7 @@ max_running_tasks = 4 keep_alive_on_exit = false [experimental] -micro_compaction = true +micro_compaction = false [[permission.rules]] decision = "allow" @@ -175,11 +175,11 @@ max_context_size = 1047576 ## `experimental` -`experimental` 存放实验功能 flag 的持久化覆盖。目前 `micro_compaction` 是唯一用户可见的字段,默认值为 `true`;只有在需要关闭自动清理较旧的大型工具结果时,才需要把它设为 `false`。 +`experimental` 存放实验功能 flag 的持久化覆盖。目前 `micro_compaction` 是唯一用户可见的字段,默认值为 `false`;如需自动清理较旧的大型工具结果,把它设为 `true`。 | 字段 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | -| `micro_compaction` | `boolean` | `true` | 清理较旧的大型工具结果内容,同时保留最近对话 | +| `micro_compaction` | `boolean` | `false` | 清理较旧的大型工具结果内容,同时保留最近对话 | ## `services` diff --git a/docs/zh/configuration/env-vars.md b/docs/zh/configuration/env-vars.md index 639f40d96..db95db08e 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -126,7 +126,7 @@ kimi | `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` | 会话关闭时是否保留后台任务,优先级高于 `config.toml`。默认会在退出时停止后台任务 | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | | `KIMI_CODE_PLUGIN_MARKETPLACE_URL` | 覆盖 `/plugins` 加载的 plugin marketplace JSON,适合 dev loopback server、测试 CDN 文件或替换 marketplace 目录 | `https://code.kimi.com/kimi-code/plugins/marketplace.json`;也接受 `http://`、`file://` URL 和本地路径 | | `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` | 限制 AgentSwarm 初始提升并发阶段可同时运行的子 Agent 数量;不设置表示不限制 | 正整数;非法值会立即失败 | -| `KIMI_CODE_EXPERIMENTAL_FLAG` | 在当前进程启用所有已注册的实验功能;`micro_compaction` 已默认开启 | `1`、`true`、`yes`、`on` | +| `KIMI_CODE_EXPERIMENTAL_FLAG` | 在当前进程启用所有已注册的实验功能 | `1`、`true`、`yes`、`on` | | `KIMI_CODE_EXPERIMENTAL_MICRO_COMPACTION` | 覆盖当前进程的 [`[experimental].micro_compaction`](./config-files.md#experimental) | 真值或假值 | | `KIMI_SHELL_PATH` | Windows 上覆盖 Git Bash 路径(自动探测失败时使用) | 绝对路径 | | `KIMI_MODEL_MAX_COMPLETION_TOKENS` | 单步 LLM 请求的 `max_completion_tokens` 硬上限,仅对 `kimi` 供应商生效 | 正整数;`0` 或负数禁用 clamp | diff --git a/packages/agent-core/src/agent/compaction/full.ts b/packages/agent-core/src/agent/compaction/full.ts index ebd8bfe18..f0a7c19eb 100644 --- a/packages/agent-core/src/agent/compaction/full.ts +++ b/packages/agent-core/src/agent/compaction/full.ts @@ -344,7 +344,7 @@ export class FullCompaction { while (true) { const messagesToCompact = originalHistory.slice(0, compactedCount); const messages = [ - ...this.agent.context.project(messagesToCompact), + ...this.agent.context.project(messagesToCompact, { synthesizeMissing: true }), createUserMessage(renderPrompt(compactionInstructionTemplate, { customInstruction: data.instruction ?? '' })), ]; const estimatedCompactionRequestTokens = this.estimateRequestTokens(messages); diff --git a/packages/agent-core/src/agent/context/index.ts b/packages/agent-core/src/agent/context/index.ts index cf4c88395..736e00a44 100644 --- a/packages/agent-core/src/agent/context/index.ts +++ b/packages/agent-core/src/agent/context/index.ts @@ -6,7 +6,7 @@ import type { ExecutableToolResult, LoopRecordedEvent } from '../../loop'; import { estimateTokensForMessages } from '../../utils/tokens'; import { escapeXml } from '../../utils/xml-escape'; import type { CompactionResult } from '../compaction'; -import { project, trimTrailingOpenToolExchange } from './projector'; +import { project, type ProjectOptions, trimTrailingOpenToolExchange } from './projector'; import { USER_PROMPT_ORIGIN, type AgentContextData, @@ -256,8 +256,8 @@ export class ContextMemory { return this._history; } - project(messages: readonly ContextMessage[]): Message[] { - return project(this.agent.microCompaction.compact(messages)); + project(messages: readonly ContextMessage[], options?: ProjectOptions): Message[] { + return project(this.agent.microCompaction.compact(messages), options); } get messages(): Message[] { diff --git a/packages/agent-core/src/agent/context/projector.ts b/packages/agent-core/src/agent/context/projector.ts index 5e2d8e99d..c10de2f9a 100644 --- a/packages/agent-core/src/agent/context/projector.ts +++ b/packages/agent-core/src/agent/context/projector.ts @@ -3,8 +3,21 @@ import type { ContentPart, Message, TextPart } from '@moonshot-ai/kosong'; import { ErrorCodes, KimiError } from '../../errors'; import type { ContextMessage } from './types'; -export function project(history: readonly ContextMessage[]): Message[] { - return repairToolExchangeAdjacency(mergeAdjacentUserMessages(history)); +export interface ProjectOptions { + /** + * When `true`, emit a synthetic `tool_result` for any assistant `tool_use` + * whose result is not present in the provided messages. Used by full + * compaction, where the compacted prefix is a slice that may exclude a + * delayed result preserved in the retained tail; the synthetic result keeps + * the exchange closed so the summary request is not rejected. Leave `false` + * for normal turns, where a missing result means the call is still in-flight + * and must not be closed prematurely. + */ + readonly synthesizeMissing?: boolean; +} + +export function project(history: readonly ContextMessage[], options?: ProjectOptions): Message[] { + return repairToolExchangeAdjacency(mergeAdjacentUserMessages(history), options); } // Strict providers (Anthropic) require every assistant `tool_use` to be answered @@ -20,13 +33,22 @@ export function project(history: readonly ContextMessage[]): Message[] { // its matching `tool_result` message(s). Matching results are moved up from // wherever they appear later in the history; any intervening messages keep their // relative order and simply follow the repaired exchange. A tool call with no -// recorded result anywhere later in the history is left untouched — it is still -// in-flight (pending) rather than orphaned, and the trailing-open-exchange trim -// plus the interrupted-result synthesis during replay own those cases. This is -// purely a projection-time fix: the underlying history is left untouched, so -// replay and transcripts keep their original order, while the model always sees -// a well-formed tool exchange. -function repairToolExchangeAdjacency(messages: readonly Message[]): Message[] { +// recorded result anywhere later in the history is left untouched by default — +// it is still in-flight (pending) rather than orphaned, and the +// trailing-open-exchange trim plus the interrupted-result synthesis during replay +// own those cases. With `synthesizeMissing`, a synthetic `tool_result` is emitted +// for such calls instead; full compaction uses this to keep a sliced prefix +// closed when a delayed result lives in the retained tail. This is purely a +// projection-time fix: the underlying history is left untouched, so replay and +// transcripts keep their original order, while the model always sees a +// well-formed tool exchange. +const SYNTHETIC_TOOL_RESULT_TEXT = + 'Tool result is not available in the current context. Do not assume the tool completed successfully.'; + +function repairToolExchangeAdjacency( + messages: readonly Message[], + options?: ProjectOptions, +): Message[] { const out: Message[] = []; const consumed = new Set(); for (let i = 0; i < messages.length; i++) { @@ -49,15 +71,30 @@ function repairToolExchangeAdjacency(messages: readonly Message[]): Message[] { pending.delete(toolCallId); } } - // If a tool call has no recorded result anywhere later in the history, it is - // still in-flight (pending) rather than orphaned, so leave it untouched — the - // trailing-open-exchange trim and the interrupted-result synthesis during - // replay own those cases, and synthesizing here would wrongly close a call - // that is simply still running. + if (options?.synthesizeMissing === true) { + // Close any tool call whose result is absent from the provided messages. + // Only used by full compaction, where the prefix is a slice that may + // exclude a delayed result preserved in the retained tail. For normal + // turns a missing result means the call is still in-flight, so it is left + // for the trailing-open-exchange trim and replay's interrupted-result + // synthesis instead of being closed here. + for (const missingId of pending) { + out.push(makeSyntheticToolResult(missingId)); + } + } } return out; } +function makeSyntheticToolResult(toolCallId: string): Message { + return { + role: 'tool', + content: [{ type: 'text', text: SYNTHETIC_TOOL_RESULT_TEXT }], + toolCalls: [], + toolCallId, + }; +} + function mergeAdjacentUserMessages(history: readonly ContextMessage[]): Message[] { const out: ContextMessage[] = []; for (const source of history) { diff --git a/packages/agent-core/test/agent/compaction/full.test.ts b/packages/agent-core/test/agent/compaction/full.test.ts index 8adf12c6e..9c8d0deab 100644 --- a/packages/agent-core/test/agent/compaction/full.test.ts +++ b/packages/agent-core/test/agent/compaction/full.test.ts @@ -288,6 +288,94 @@ describe('FullCompaction', () => { ).toBe(false); }); + // Regression: a delayed tool result can fall outside the compacted prefix + // (the split is computed on the raw, misordered history). The compaction + // request must still close the exchange so the summary request is not + // rejected by strict providers. + it('closes a tool call whose delayed result falls outside the compacted prefix', async () => { + const compactFirstTwo: CompactionStrategy = { + ...alwaysCompactOnce, + computeCompactCount: () => 2, + }; + const ctx = testAgent({ compactionStrategy: compactFirstTwo }); + ctx.configure({ + provider: CATALOGUED_PROVIDER, + modelCapabilities: CATALOGUED_MODEL_CAPABILITIES, + }); + + const stepUuid = 'delayed-result-step'; + ctx.agent.context.appendUserMessage([{ type: 'text', text: 'first prompt' }]); + ctx.dispatch({ + type: 'context.append_loop_event', + event: { type: 'step.begin', uuid: stepUuid, turnId: '0', step: 1 }, + }); + // Notification flushed before the tool call lands it between the tool_use + // and its (later) tool result. + ctx.agent.context.appendUserMessage([{ type: 'text', text: 'bg' }], { + kind: 'background_task', + taskId: 'task-1', + status: 'completed', + notificationId: 'task:task-1:completed', + }); + ctx.dispatch({ + type: 'context.append_loop_event', + event: { + type: 'tool.call', + uuid: 'call_delayed', + turnId: '0', + step: 1, + stepUuid, + toolCallId: 'call_delayed', + name: 'Run', + args: {}, + }, + }); + ctx.dispatch({ + type: 'context.append_loop_event', + event: { type: 'step.end', uuid: stepUuid, turnId: '0', step: 1, finishReason: 'tool_use' }, + }); + ctx.dispatch({ + type: 'context.append_loop_event', + event: { + type: 'tool.result', + parentUuid: 'call_delayed', + toolCallId: 'call_delayed', + result: { output: 'real result' }, + }, + }); + + // Sanity: history is [user, assistant(call_delayed), user, tool(call_delayed)]; + // the split at 2 excludes the tool result. + expect(ctx.agent.context.history.map((m) => m.role)).toEqual([ + 'user', + 'assistant', + 'user', + 'tool', + ]); + + const compacted = new Promise((resolve) => { + ctx.emitter.once('context.apply_compaction', () => resolve()); + }); + ctx.mockNextResponse({ type: 'text', text: 'Compacted summary.' }); + await ctx.rpc.beginCompaction({}); + await compacted; + + const [compactionCall] = ctx.llmCalls; + expect(compactionCall?.history.map((m) => m.role)).toEqual([ + 'user', + 'assistant', + 'tool', + 'user', + ]); + // The tool result answering call_delayed is a synthetic placeholder (the real + // result lives in the retained tail, outside the compacted prefix). + expect(compactionCall?.history[2]).toMatchObject({ + role: 'tool', + toolCallId: 'call_delayed', + }); + expect(textOf(compactionCall?.history[2])).toContain('not available'); + }); + it('micro-compacts old tool results before sending the summary request', async () => { vi.useFakeTimers(); enableMicroCompactionFlag(); @@ -2333,3 +2421,9 @@ function inputHistorySnapshot(history: readonly Message[]): string[] { function normalizeInputText(text: string): string { return text.includes('compact this conversation context') ? '' : text; } + +function textOf(message: Message | undefined): string { + return ( + message?.content.map((part) => (part.type === 'text' ? part.text : '')).join('') ?? '' + ); +} diff --git a/packages/agent-core/test/agent/context/projector.test.ts b/packages/agent-core/test/agent/context/projector.test.ts index 838520e7e..6dc2a3ab6 100644 --- a/packages/agent-core/test/agent/context/projector.test.ts +++ b/packages/agent-core/test/agent/context/projector.test.ts @@ -71,6 +71,14 @@ function textPart(text: string): ContentPart { return { type: 'text', text }; } +function textOf(message: Message | undefined): string { + return ( + message?.content + .map((part) => (part.type === 'text' ? part.text : '')) + .join('') ?? '' + ); +} + function user(text: string): ContextMessage { return { role: 'user', content: [textPart(text)], toolCalls: [] }; } @@ -226,6 +234,43 @@ describe('project tool_use/tool_result adjacency', () => { expect(projected.some((m) => m.toolCallId === 'b')).toBe(false); }); + it('synthesizes a tool result for a missing tool call when synthesizeMissing is set', () => { + const history: ContextMessage[] = [user('u1'), assistant(['a', 'b']), tool('a')]; + const projected = project(history, { synthesizeMissing: true }); + expect(projected.map((m) => [m.role, m.toolCallId])).toEqual([ + ['user', undefined], + ['assistant', undefined], + ['tool', 'a'], + ['tool', 'b'], + ]); + expect(projected.at(-1)).toMatchObject({ role: 'tool', toolCallId: 'b' }); + expect(findMisplacedToolUses(projected)).toEqual([]); + }); + + // Regression for the full-compaction prefix gap: a delayed tool result may be + // sliced out of the compacted prefix (the split is computed on the raw, + // misordered history). With synthesizeMissing the sliced projection must still + // close the exchange so the summary request is not rejected. + it('closes a tool call whose delayed result is sliced out of a compaction prefix', () => { + const fullHistory: ContextMessage[] = [ + user('u1'), + assistant(['a']), + user('middle'), + assistant(['b']), + tool('b'), + user('later'), + tool('a'), + ]; + // The strategy may split after tool('b'), excluding the distant tool('a'). + const prefix = fullHistory.slice(0, 5); + const projected = project(prefix, { synthesizeMissing: true }); + expect(findMisplacedToolUses(projected)).toEqual([]); + const aIndex = projected.findIndex((m) => m.toolCalls.some((tc) => tc.id === 'a')); + expect(projected[aIndex + 1]).toMatchObject({ role: 'tool', toolCallId: 'a' }); + // The synthesized result carries the placeholder text, not the real output. + expect(textOf(projected[aIndex + 1])).toContain('not available'); + }); + it('does not move a tool result whose toolCallId matches no assistant tool_use', () => { const history: ContextMessage[] = [ user('u1'),