fix(agent-core): close open tool calls in compacted prefixes and sync docs

Full compaction slices the raw history, which can exclude a delayed tool result and leave its tool_use open in the compacted prefix, triggering the same strict-provider 400 on the summary request. Synthesize a placeholder tool_result for such calls when projecting the compacted prefix.

Also sync the micro_compaction default to false in the en/zh configuration and env-var docs.
This commit is contained in:
Kaiyi 2026-06-30 13:55:20 +08:00
parent 3e42ae7607
commit 29cb33d862
9 changed files with 202 additions and 26 deletions

View file

@ -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`

View file

@ -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 |

View file

@ -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`

View file

@ -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 |

View file

@ -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);

View file

@ -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[] {

View file

@ -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<number>();
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) {

View file

@ -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: '<notification>bg</notification>' }], {
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<void>((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') ? '<compaction-instruction>' : text;
}
function textOf(message: Message | undefined): string {
return (
message?.content.map((part) => (part.type === 'text' ? part.text : '')).join('') ?? ''
);
}

View file

@ -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'),