Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/record-unexecuted-tool-calls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix repeated request rejections after an interrupted model response by recording tool calls that never ran and closing them with an interrupted result.
59 changes: 59 additions & 0 deletions packages/agent-core/src/loop/tool-call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,18 @@ const GRACE_TIMEOUT_MS = 2_000;
const TOOL_OUTPUT_EMPTY = 'Tool output is empty.';
const TOOL_OUTPUT_NON_TEXT = 'Tool returned non-text content.';

/**
* Output for a tool call the step never executed: the provider stream broke
* off (paused / overloaded / token limit), so running the call — whose
* arguments may be truncated mid-stream — would be unsafe. The wording tells
* the model the call did not run and invites a clean re-issue instead of
* assumptions about the outcome.
*/
const UNEXECUTED_TOOL_CALL_OUTPUT =
'This tool call was not executed: the model response ended before tool execution could start ' +
'(the provider stream was interrupted). Do not assume the tool ran — ' +
're-issue the call if it is still needed.';

const validators = new WeakMap<ExecutableTool, ToolArgsValidator>();

/**
Expand Down Expand Up @@ -173,6 +185,53 @@ export async function runToolCallBatch(
return { stopTurn };
}

/**
* Record tool calls from a response the step will NOT execute: the provider
* stream broke off (paused / overloaded / token limit), so running the calls
* — whose arguments may be truncated mid-stream — would be unsafe. Dropping
* them silently is not an option either: it loses the model's intent and,
* when the response carried no other usable content, persists an assistant
* message strict providers reject as empty. Each call is recorded with
* sanitized arguments (unparseable JSON, e.g. truncated by an interrupted
* stream, becomes `{}`) and immediately closed with a synthetic error result,
* so the exchange stays wire-valid and the model learns the calls never ran.
*/
export async function recordUnexecutedToolCalls(
step: ToolCallStepContext,
response: LLMChatResponse,
): Promise<void> {
for (const toolCall of response.toolCalls) {
const parsedArgs = parseToolCallArguments(toolCall.arguments);
if (parsedArgs.parseFailed) {
step.log?.debug('recording unexecuted tool call with unparseable arguments', {
toolName: toolCall.name,
toolCallId: toolCall.id,
rawLength: toolCall.arguments?.length ?? 0,
error: parsedArgs.error,
});
}
await step.dispatchEvent({
type: 'tool.call',
uuid: toolCall.id,
turnId: step.turnId,
step: step.currentStep,
stepUuid: step.stepUuid,
toolCallId: toolCall.id,
name: toolCall.name,
args: parsedArgs.data,
extras: toolCall.extras,
traceId: step.trace.traceId,
});
await step.dispatchEvent({
type: 'tool.result',
parentUuid: toolCall.id,
toolCallId: toolCall.id,
result: { output: UNEXECUTED_TOOL_CALL_OUTPUT, isError: true },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Run finalize hooks for interrupted tool results

In contexts with a finalizeToolResult hook, a provider response that ends as paused, unknown, or max_tokens with tool calls now persists this synthetic error result directly, so the hook never sees it. Other error result paths, including preflight failures, are finalized before persistence; bypassing that here skips host redaction/budgeting and the Agent's PostToolUseFailure hook path for exactly these recorded tool.results. Please route the synthetic interrupted result through the same finalization path before dispatching it.

Useful? React with 👍 / 👎.

traceId: step.trace.traceId,
});
}
}

/**
* Provider-order validation pass. It does not run hooks, spawn tools, or write
* events. Validator compilation may populate the local cache.
Expand Down
15 changes: 14 additions & 1 deletion packages/agent-core/src/loop/turn-step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import {
type LLMRequestTrace,
} from './llm';
import { chatWithRetry } from './retry';
import { runToolCallBatch, type ToolCallStepContext } from './tool-call';
import { recordUnexecutedToolCalls, runToolCallBatch, type ToolCallStepContext } from './tool-call';
import type {
ExecutableTool,
LoopHooks,
Expand Down Expand Up @@ -403,6 +403,19 @@ export async function executeLoopStep(deps: ExecuteLoopStepDeps): Promise<{
if (effectiveStopReason === 'tool_use') {
const toolBatch = await runToolCallBatch(step, response);
if (toolBatch.stopTurn) effectiveStopReason = 'end_turn';
} else if (
(stopReason === 'paused' || stopReason === 'unknown' || stopReason === 'max_tokens') &&
response.toolCalls.length > 0
) {
// The provider stream broke off (paused / overloaded / token limit) while
// the response still carries tool calls — possibly cut off mid-arguments.
// Record each call and close it with a synthetic interrupted result:
// dropping them would lose the model's intent and can persist an
// assistant message strict providers reject as empty. Filtered responses
// keep their existing behavior (calls vanish): persisting flagged content
// risks re-triggering the filter on every resend, and a well-formed
// tool_use response stopped by usage recording keeps its skip behavior.
await recordUnexecutedToolCalls(step, response);
}

// When a tool batch runs, it drains paired `tool.result` events even when
Expand Down
117 changes: 116 additions & 1 deletion packages/agent-core/test/loop/tool-call.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,13 @@ import { describe, expect, it } from 'vitest';

import { ToolAccesses } from '../../src/loop';
import type { Logger } from '../../src/logging';
import type { ExecutableTool, ExecutableToolResult, LoopHooks, ToolExecution } from '../../src/loop';
import type { ExecutableTool, ExecutableToolResult, LoopHooks, ToolCall, ToolExecution } from '../../src/loop';
import { PathSecurityError } from '../../src/tools/policies/path-access';
import {
makeEndTurnResponse,
makeResponse,
makeTextParts,
makeThinkingParts,
makeToolCall,
makeToolUseResponse,
} from './fixtures/fake-llm';
Expand Down Expand Up @@ -878,3 +879,117 @@ class StopSuccessTool implements ExecutableTool<Record<string, unknown>> {
};
}
}


describe('runTurn — unexecuted tool calls (abnormal step end)', () => {
it('records and closes a complete tool call when the step ends paused', async () => {
const echo = new EchoTool();
const { result, context, sink } = await runTurn({
tools: [echo],
responses: [
makeResponse(makeThinkingParts('let me run a tool'), [makeToolCall('echo', { text: 'hi' }, 'tc-1')], 'paused'),
],
});

// The call must not execute, but it is recorded and immediately closed
// with a synthetic interrupted result so the exchange stays wire-valid.
expect(echo.calls.length).toBe(0);
expect(result.stopReason).toBe('paused');
expect(context.toolCalls().map((e) => [e.toolCallId, e.name, e.args])).toEqual([
['tc-1', 'echo', { text: 'hi' }],
]);
const results = context.toolResults();
expect(results.map((e) => e.toolCallId)).toEqual(['tc-1']);
expect(results[0]?.result.isError).toBe(true);
expect(expectTextOutput(results[0]?.result.output)).toContain('not executed');
// Events pair up in the live stream too, and the step seals as paused.
expect(sink.byType('tool.call').map((e) => e.toolCallId)).toEqual(['tc-1']);
expect(sink.byType('tool.result').map((e) => e.toolCallId)).toEqual(['tc-1']);
expect(context.stepEnds()[0]?.finishReason).toBe('paused');
});

it('sanitizes truncated (unparseable) arguments to {} when recording the call', async () => {
// The provider stream was cut mid-arguments: id and name arrived, the JSON
// did not. The recorded call must carry sanitized args — the raw fragment
// would crash the next request's wire conversion.
const partialCall: ToolCall = {
type: 'function',
id: 'tc-partial',
name: 'echo',
arguments: '{"text":"unterminated',
};
const echo = new EchoTool();
const { context } = await runTurn({
tools: [echo],
responses: [
// The realistic poison shape: an empty signed thinking block plus the
// partial call (what a pause_turn response carries).
makeResponse([{ type: 'think', think: '', encrypted: 'sig' }], [partialCall], 'paused'),
],
});

expect(echo.calls.length).toBe(0);
expect(context.toolCalls().map((e) => [e.toolCallId, e.args])).toEqual([['tc-partial', {}]]);
const results = context.toolResults();
expect(results.map((e) => e.toolCallId)).toEqual(['tc-partial']);
expect(results[0]?.result.isError).toBe(true);
});

it('records and closes tool calls when the stream fails overloaded (other finish)', async () => {
const echo = new EchoTool();
const { result, context } = await runTurn({
tools: [echo],
responses: [
makeResponse(makeTextParts(''), [makeToolCall('echo', { text: 'hi' }, 'tc-ovl')], 'unknown'),
],
});

expect(echo.calls.length).toBe(0);
expect(result.stopReason).toBe('unknown');
expect(context.toolCalls().map((e) => e.toolCallId)).toEqual(['tc-ovl']);
expect(context.toolResults().map((e) => e.toolCallId)).toEqual(['tc-ovl']);
expect(context.toolResults()[0]?.result.isError).toBe(true);
});

it('records and closes a tool call cut off by max_tokens', async () => {
const echo = new EchoTool();
const { result, context } = await runTurn({
tools: [echo],
responses: [
makeResponse(makeTextParts('partial answer'), [makeToolCall('echo', { text: 'hi' }, 'tc-max')], 'max_tokens'),
],
});

expect(echo.calls.length).toBe(0);
expect(result.stopReason).toBe('max_tokens');
expect(context.toolCalls().map((e) => e.toolCallId)).toEqual(['tc-max']);
expect(context.toolResults().map((e) => e.toolCallId)).toEqual(['tc-max']);
expect(context.toolResults()[0]?.result.isError).toBe(true);
});

it('closes every unexecuted call in provider order', async () => {
const echo = new EchoTool();
const { context } = await runTurn({
tools: [echo],
responses: [
makeResponse(
makeThinkingParts('two calls'),
[makeToolCall('echo', { text: 'a' }, 'tc-a'), makeToolCall('echo', { text: 'b' }, 'tc-b')],
'paused',
),
],
});

expect(echo.calls.length).toBe(0);
expect(context.toolCalls().map((e) => e.toolCallId)).toEqual(['tc-a', 'tc-b']);
expect(context.toolResults().map((e) => e.toolCallId)).toEqual(['tc-a', 'tc-b']);
});

it('does not record synthetic results for a normal end_turn without tool calls', async () => {
const { context } = await runTurn({
responses: [makeEndTurnResponse('done')],
});
expect(context.toolCalls()).toEqual([]);
expect(context.toolResults()).toEqual([]);
});
});
Loading