Merge 28bf07d613 into bc432d8435
This commit is contained in:
commit
1b9da76f17
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<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(
|
||||
msg: Extract<AgentMessage, { role: "assistant" }>,
|
||||
): 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<string, unknown>)) continue;
|
||||
|
||||
toolCalls.push({
|
||||
id: rec.id,
|
||||
name: typeof rec.name === "string" ? rec.name : undefined,
|
||||
@ -54,7 +87,7 @@ function makeMissingToolResult(params: {
|
||||
} as Extract<AgentMessage, { role: "toolResult" }>;
|
||||
}
|
||||
|
||||
export { makeMissingToolResult };
|
||||
export { isIncompleteToolCall, makeMissingToolResult };
|
||||
|
||||
export function sanitizeToolUseResultPairing(messages: AgentMessage[]): AgentMessage[] {
|
||||
return repairToolUseResultPairing(messages).messages;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user