fix: skip incomplete tool calls in transcript repair

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.
This commit is contained in:
kigland 2026-01-28 15:14:38 +08:00
parent 6044bf3637
commit 28bf07d613
2 changed files with 232 additions and 2 deletions

View File

@ -1,6 +1,6 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { AgentMessage } from "@mariozechner/pi-agent-core";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { sanitizeToolUseResultPairing } from "./session-transcript-repair.js"; import { isIncompleteToolCall, sanitizeToolUseResultPairing } from "./session-transcript-repair.js";
describe("sanitizeToolUseResultPairing", () => { describe("sanitizeToolUseResultPairing", () => {
it("moves tool results directly after tool calls and inserts missing results", () => { 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.some((m) => m.role === "toolResult")).toBe(false);
expect(out.map((m) => m.role)).toEqual(["user", "assistant"]); 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);
});
}); });

View File

@ -5,6 +5,35 @@ type ToolCallLike = {
name?: string; 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<string, unknown>): 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( function extractToolCallsFromAssistant(
msg: Extract<AgentMessage, { role: "assistant" }>, msg: Extract<AgentMessage, { role: "assistant" }>,
): ToolCallLike[] { ): ToolCallLike[] {
@ -18,6 +47,10 @@ function extractToolCallsFromAssistant(
if (typeof rec.id !== "string" || !rec.id) continue; if (typeof rec.id !== "string" || !rec.id) continue;
if (rec.type === "toolCall" || rec.type === "toolUse" || rec.type === "functionCall") { 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<string, unknown>)) continue;
toolCalls.push({ toolCalls.push({
id: rec.id, id: rec.id,
name: typeof rec.name === "string" ? rec.name : undefined, name: typeof rec.name === "string" ? rec.name : undefined,
@ -54,7 +87,7 @@ function makeMissingToolResult(params: {
} as Extract<AgentMessage, { role: "toolResult" }>; } as Extract<AgentMessage, { role: "toolResult" }>;
} }
export { makeMissingToolResult }; export { isIncompleteToolCall, makeMissingToolResult };
export function sanitizeToolUseResultPairing(messages: AgentMessage[]): AgentMessage[] { export function sanitizeToolUseResultPairing(messages: AgentMessage[]): AgentMessage[] {
return repairToolUseResultPairing(messages).messages; return repairToolUseResultPairing(messages).messages;