From 28bf07d613fa5500b7e5b43c2c71b04c46b5af61 Mon Sep 17 00:00:00 2001 From: kigland Date: Wed, 28 Jan 2026 15:14:38 +0800 Subject: [PATCH] fix: skip incomplete tool calls in transcript repair MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a streaming response is terminated mid-tool-call, the assistant message contains tool call blocks with `partialJson` but no valid `arguments`. The existing repair logic treats these as complete tool calls and inserts synthetic error `toolResult` messages, which causes Anthropic's API to reject the request with "unexpected tool_use_id" on subsequent turns — corrupting the session. Add `isIncompleteToolCall()` to detect these terminated tool calls by checking for `partialJson` present AND `arguments` missing/empty. Incomplete tool calls are skipped during extraction so no synthetic result is generated, while the block itself remains in the assistant message preserving conversational context. This is more surgical than alternative approaches: - Unlike skipping the entire assistant message on stopReason "error", this preserves text content from the same turn. - Unlike deleting the incomplete block, this keeps the partial tool call visible for debugging/context. Includes 9 new tests covering incomplete, complete-with-partialJson, mixed, and empty-arguments scenarios. --- src/agents/session-transcript-repair.test.ts | 199 ++++++++++++++++++- src/agents/session-transcript-repair.ts | 35 +++- 2 files changed, 232 insertions(+), 2 deletions(-) diff --git a/src/agents/session-transcript-repair.test.ts b/src/agents/session-transcript-repair.test.ts index ccc63ec7f..342c37e4f 100644 --- a/src/agents/session-transcript-repair.test.ts +++ b/src/agents/session-transcript-repair.test.ts @@ -1,6 +1,6 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; import { describe, expect, it } from "vitest"; -import { sanitizeToolUseResultPairing } from "./session-transcript-repair.js"; +import { isIncompleteToolCall, sanitizeToolUseResultPairing } from "./session-transcript-repair.js"; describe("sanitizeToolUseResultPairing", () => { it("moves tool results directly after tool calls and inserts missing results", () => { @@ -109,4 +109,201 @@ describe("sanitizeToolUseResultPairing", () => { expect(out.some((m) => m.role === "toolResult")).toBe(false); expect(out.map((m) => m.role)).toEqual(["user", "assistant"]); }); + + it("skips incomplete tool calls (partialJson, no arguments) and drops their synthetic results", () => { + // Simulates a terminated streaming response: the assistant message has a + // tool call with partialJson but no arguments, followed by a synthetic + // error toolResult inserted by session persistence. The repair must skip + // the incomplete tool call entirely so no synthetic result is emitted, and + // the orphaned synthetic result is dropped. + const input = [ + { + role: "assistant", + content: [ + { type: "text", text: "Let me write that file:" }, + { + type: "toolCall", + id: "toolu_terminated", + name: "write", + partialJson: '{"path": "/tmp/test.md", "content": "# Hello', + }, + ], + }, + { + role: "toolResult", + toolCallId: "toolu_terminated", + toolName: "write", + content: [ + { + type: "text", + text: "[moltbot] missing tool result in session history; inserted synthetic error result for transcript repair.", + }, + ], + isError: true, + }, + { role: "user", content: "what happened?" }, + ] satisfies AgentMessage[]; + + const out = sanitizeToolUseResultPairing(input); + // The incomplete tool call should be treated as having zero tool calls, + // so no toolResult should appear in the output. + expect(out.some((m) => m.role === "toolResult")).toBe(false); + expect(out.map((m) => m.role)).toEqual(["assistant", "user"]); + }); + + it("keeps complete tool calls even when partialJson is present", () => { + // A tool call that completed successfully may still carry partialJson + // from the streaming buffer. It must be treated normally. + const input = [ + { + role: "assistant", + content: [ + { + type: "toolCall", + id: "call_ok", + name: "read", + arguments: { path: "/tmp/test.md" }, + partialJson: '{"path": "/tmp/test.md"}', + }, + ], + }, + { + role: "toolResult", + toolCallId: "call_ok", + toolName: "read", + content: [{ type: "text", text: "file contents" }], + isError: false, + }, + ] satisfies AgentMessage[]; + + const out = sanitizeToolUseResultPairing(input); + expect(out).toHaveLength(2); + expect(out[0]?.role).toBe("assistant"); + expect(out[1]?.role).toBe("toolResult"); + }); + + it("handles mixed complete and incomplete tool calls in one assistant message", () => { + // The assistant issued two parallel tool calls but was terminated during + // the second. The first call completed and has a real result; the second + // is incomplete. Only the first should be paired. + const input = [ + { + role: "assistant", + content: [ + { type: "toolCall", id: "call_done", name: "read", arguments: { path: "/a" } }, + { + type: "toolCall", + id: "call_partial", + name: "write", + partialJson: '{"path": "/b", "content": "half...', + }, + ], + }, + { + role: "toolResult", + toolCallId: "call_done", + toolName: "read", + content: [{ type: "text", text: "ok" }], + isError: false, + }, + { + role: "toolResult", + toolCallId: "call_partial", + toolName: "write", + content: [{ type: "text", text: "[moltbot] synthetic" }], + isError: true, + }, + { role: "user", content: "continue" }, + ] satisfies AgentMessage[]; + + const out = sanitizeToolUseResultPairing(input); + const results = out.filter((m) => m.role === "toolResult"); + // Only the completed tool call should have a result + expect(results).toHaveLength(1); + expect((results[0] as { toolCallId?: string }).toolCallId).toBe("call_done"); + expect(out.map((m) => m.role)).toEqual(["assistant", "toolResult", "user"]); + }); + + it("handles incomplete tool call with empty arguments object", () => { + // Some providers set arguments to {} when the stream was interrupted + // before any argument parsing completed. + const input = [ + { + role: "assistant", + content: [ + { + type: "toolCall", + id: "call_empty_args", + name: "exec", + arguments: {}, + partialJson: '{"command": "ls', + }, + ], + }, + { role: "user", content: "?" }, + ] satisfies AgentMessage[]; + + const out = sanitizeToolUseResultPairing(input); + expect(out.some((m) => m.role === "toolResult")).toBe(false); + expect(out.map((m) => m.role)).toEqual(["assistant", "user"]); + }); +}); + +describe("isIncompleteToolCall", () => { + it("returns true for partialJson with no arguments", () => { + expect( + isIncompleteToolCall({ + type: "toolCall", + id: "x", + name: "write", + partialJson: '{"path": "/tmp', + }), + ).toBe(true); + }); + + it("returns true for partialJson with empty arguments", () => { + expect( + isIncompleteToolCall({ + type: "toolCall", + id: "x", + name: "exec", + arguments: {}, + partialJson: '{"command": "ls', + }), + ).toBe(true); + }); + + it("returns false for complete tool call with partialJson", () => { + expect( + isIncompleteToolCall({ + type: "toolCall", + id: "x", + name: "read", + arguments: { path: "/tmp/test" }, + partialJson: '{"path": "/tmp/test"}', + }), + ).toBe(false); + }); + + it("returns false for tool call without partialJson", () => { + expect( + isIncompleteToolCall({ + type: "toolCall", + id: "x", + name: "read", + arguments: { path: "/tmp/test" }, + }), + ).toBe(false); + }); + + it("returns false when partialJson is empty string", () => { + expect( + isIncompleteToolCall({ + type: "toolCall", + id: "x", + name: "read", + partialJson: "", + }), + ).toBe(false); + }); }); diff --git a/src/agents/session-transcript-repair.ts b/src/agents/session-transcript-repair.ts index d680beb4d..4ec30813b 100644 --- a/src/agents/session-transcript-repair.ts +++ b/src/agents/session-transcript-repair.ts @@ -5,6 +5,35 @@ type ToolCallLike = { name?: string; }; +/** + * Check whether a tool call block is incomplete (terminated mid-stream). + * + * When a streaming response is interrupted (`stopReason: "error"`, + * `errorMessage: "terminated"`), the assistant message may contain tool call + * blocks with only `partialJson` and no valid `arguments`. These were never + * executed, so inserting a synthetic `toolResult` for them causes Anthropic's + * API to reject the request with "unexpected tool_use_id". + * + * We detect incompleteness by checking: + * 1. `partialJson` is present (streaming was in progress), AND + * 2. `arguments` is missing, empty, or not a non-empty object. + * + * If `arguments` is fully populated the call completed successfully and should + * be treated normally, even if `partialJson` happens to be set. + */ +function isIncompleteToolCall(block: Record): boolean { + if (typeof block.partialJson !== "string" || !block.partialJson) return false; + + const args = block.arguments; + // No arguments at all → incomplete + if (args === undefined || args === null) return true; + // Empty object {} → incomplete (arguments were not parsed) + if (typeof args === "object" && !Array.isArray(args) && Object.keys(args as object).length === 0) + return true; + + return false; +} + function extractToolCallsFromAssistant( msg: Extract, ): ToolCallLike[] { @@ -18,6 +47,10 @@ function extractToolCallsFromAssistant( if (typeof rec.id !== "string" || !rec.id) continue; if (rec.type === "toolCall" || rec.type === "toolUse" || rec.type === "functionCall") { + // Skip incomplete tool calls from terminated/errored streaming responses. + // These were never executed, so they must not receive a synthetic toolResult. + if (isIncompleteToolCall(rec as Record)) continue; + toolCalls.push({ id: rec.id, name: typeof rec.name === "string" ? rec.name : undefined, @@ -54,7 +87,7 @@ function makeMissingToolResult(params: { } as Extract; } -export { makeMissingToolResult }; +export { isIncompleteToolCall, makeMissingToolResult }; export function sanitizeToolUseResultPairing(messages: AgentMessage[]): AgentMessage[] { return repairToolUseResultPairing(messages).messages;