mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
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:
parent
3e42ae7607
commit
29cb33d862
9 changed files with 202 additions and 26 deletions
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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[] {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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('') ?? ''
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue