Skip to content

Commit 62a44f9

Browse files
committed
fix: sanitize pending/running tool parts to prevent orphaned tool_use
When a session errors mid-tool-execution (e.g. context overflow), the tool part stays in pending/running state. On the next transform, the SDK generates a tool_use block for this part but has no output to generate a matching tool_result, causing Anthropic to reject with: tool_use ids were found without tool_result blocks immediately after Add sanitizeToolParts() in gradient.ts that converts pending/running tool parts to error state after transformInner(). Error state generates both tool_use + tool_result(is_error=true), eliminating the orphan while preserving conversation history. The function is a no-op (returns same array reference) when all tools are already in terminal state.
1 parent 89923f6 commit 62a44f9

3 files changed

Lines changed: 338 additions & 8 deletions

File tree

src/gradient.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,53 @@ function toolStripAnnotation(toolName: string, output: string): string {
341341
return annotation;
342342
}
343343

344+
// Ensure every tool part in the window has a terminal state (completed or error).
345+
// Pending/running tool parts produce tool_use blocks at the API level but have no
346+
// output to generate a matching tool_result — causing Anthropic to reject the request
347+
// with "tool_use ids were found without tool_result blocks immediately after".
348+
// This happens when a session errors mid-tool-execution (e.g. context overflow) and
349+
// the tool part remains in pending/running state on the next transform.
350+
// Converting to error state generates both tool_use + tool_result(is_error=true).
351+
function sanitizeToolParts(
352+
messages: MessageWithParts[],
353+
): MessageWithParts[] {
354+
let changed = false;
355+
const result = messages.map((msg) => {
356+
if (msg.info.role !== "assistant") return msg;
357+
358+
let partsChanged = false;
359+
const parts = msg.parts.map((part) => {
360+
if (part.type !== "tool") return part;
361+
const { status } = part.state;
362+
if (status === "completed" || status === "error") return part;
363+
364+
// pending or running → convert to error so SDK emits tool_result
365+
partsChanged = true;
366+
const now = Date.now();
367+
return {
368+
...part,
369+
state: {
370+
status: "error" as const,
371+
input: part.state.input,
372+
error: "[tool execution interrupted — session recovered]",
373+
metadata:
374+
"metadata" in part.state ? part.state.metadata : undefined,
375+
time: {
376+
start: "time" in part.state ? part.state.time.start : now,
377+
end: now,
378+
},
379+
},
380+
} as Part;
381+
});
382+
383+
if (!partsChanged) return msg;
384+
changed = true;
385+
return { ...msg, parts };
386+
});
387+
388+
return changed ? result : messages;
389+
}
390+
344391
function stripToolOutputs(parts: Part[]): Part[] {
345392
return parts.map((part) => {
346393
if (part.type !== "tool") return part;
@@ -1075,6 +1122,12 @@ export function transform(input: {
10751122
sessionID?: string;
10761123
}): TransformResult {
10771124
const result = transformInner(input);
1125+
1126+
// Sanitize non-terminal tool parts before the window reaches the SDK.
1127+
// Must run after transformInner (covers all layers 0-4) and before the
1128+
// trailing-drop loop in index.ts sees the messages.
1129+
result.messages = sanitizeToolParts(result.messages);
1130+
10781131
const sid = input.sessionID ?? input.messages[0]?.info.sessionID;
10791132
if (sid) {
10801133
const state = getSessionState(sid);

src/index.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -593,14 +593,14 @@ export const LorePlugin: Plugin = async (ctx) => {
593593
// This must run at ALL layers, including layer 0 (passthrough) — the error
594594
// can occur even when messages fit within the context budget.
595595
//
596-
// Crucially, assistant messages that contain tool parts (completed OR pending)
597-
// must NOT be dropped:
598-
// - Completed tool parts: OpenCode's SDK converts these into tool_result blocks
599-
// sent as user-role messages at the API level. The conversation already ends
600-
// with a user message — dropping would strip the entire current agentic turn
601-
// and cause an infinite tool-call loop (the model restarts from scratch).
602-
// - Pending tool parts: the tool call hasn't returned yet; dropping would make
603-
// the model re-issue the same tool call on the next turn.
596+
// Crucially, assistant messages that contain tool parts must NOT be dropped:
597+
// - Completed/error tool parts: OpenCode's SDK converts these into tool_result
598+
// blocks sent as user-role messages at the API level. The conversation already
599+
// ends with a user message — dropping would strip the entire current agentic
600+
// turn and cause an infinite tool-call loop (the model restarts from scratch).
601+
// - Note: pending/running tool parts are converted to error state upstream by
602+
// sanitizeToolParts() in gradient.ts, so by this point all tool parts have a
603+
// terminal state (completed or error) and will generate tool_result blocks.
604604
//
605605
// Note: at layer 0, result.messages === output.messages (same reference), so
606606
// mutating result.messages here also trims output.messages in place — which is

test/gradient.test.ts

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -753,6 +753,283 @@ function makeStepWithTool(
753753
};
754754
}
755755

756+
// ---------------------------------------------------------------------------
757+
// sanitizeToolParts: pending/running tool parts → error state
758+
// Prevents orphaned tool_use blocks (no matching tool_result) from reaching the
759+
// Anthropic API. When a session errors mid-tool-execution, the tool part stays in
760+
// pending/running state. sanitizeToolParts() converts these to error state so the
761+
// SDK generates both tool_use + tool_result(is_error=true).
762+
// ---------------------------------------------------------------------------
763+
764+
function makeStepWithPendingTool(
765+
id: string,
766+
parentUserID: string,
767+
toolName: string,
768+
sessionID = "grad-sess",
769+
): { info: Message; parts: Part[] } {
770+
const info: Message = {
771+
id,
772+
sessionID,
773+
role: "assistant",
774+
time: { created: Date.now() },
775+
parentID: parentUserID,
776+
modelID: "claude-sonnet-4-20250514",
777+
providerID: "anthropic",
778+
mode: "build",
779+
path: { cwd: "/test", root: "/test" },
780+
cost: 0,
781+
tokens: {
782+
input: 100,
783+
output: 50,
784+
reasoning: 0,
785+
cache: { read: 0, write: 0 },
786+
},
787+
};
788+
return {
789+
info,
790+
parts: [
791+
{
792+
id: `step-start-${id}`,
793+
sessionID,
794+
messageID: id,
795+
type: "step-start",
796+
} as Part,
797+
{
798+
id: `tool-${id}`,
799+
sessionID,
800+
messageID: id,
801+
type: "tool",
802+
callID: `call-${id}`,
803+
tool: toolName,
804+
state: {
805+
status: "pending",
806+
input: { command: "ls" },
807+
raw: '{"command": "ls"}',
808+
},
809+
} as unknown as Part,
810+
],
811+
};
812+
}
813+
814+
function makeStepWithRunningTool(
815+
id: string,
816+
parentUserID: string,
817+
toolName: string,
818+
sessionID = "grad-sess",
819+
): { info: Message; parts: Part[] } {
820+
const info: Message = {
821+
id,
822+
sessionID,
823+
role: "assistant",
824+
time: { created: Date.now() },
825+
parentID: parentUserID,
826+
modelID: "claude-sonnet-4-20250514",
827+
providerID: "anthropic",
828+
mode: "build",
829+
path: { cwd: "/test", root: "/test" },
830+
cost: 0,
831+
tokens: {
832+
input: 100,
833+
output: 50,
834+
reasoning: 0,
835+
cache: { read: 0, write: 0 },
836+
},
837+
};
838+
const startTime = Date.now() - 5000;
839+
return {
840+
info,
841+
parts: [
842+
{
843+
id: `step-start-${id}`,
844+
sessionID,
845+
messageID: id,
846+
type: "step-start",
847+
} as Part,
848+
{
849+
id: `tool-${id}`,
850+
sessionID,
851+
messageID: id,
852+
type: "tool",
853+
callID: `call-${id}`,
854+
tool: toolName,
855+
state: {
856+
status: "running",
857+
input: { command: "build" },
858+
title: toolName,
859+
metadata: { cwd: "/test" },
860+
time: { start: startTime },
861+
},
862+
} as unknown as Part,
863+
],
864+
};
865+
}
866+
867+
describe("gradient — sanitizeToolParts (orphaned tool_use fix)", () => {
868+
const SESSION = "sanitize-sess";
869+
870+
beforeEach(() => {
871+
resetCalibration();
872+
resetPrefixCache();
873+
resetRawWindowCache();
874+
setModelLimits({ context: 10_000, output: 2_000 });
875+
calibrate(0);
876+
ensureProject(PROJECT);
877+
});
878+
879+
test("no-op when all tool parts are completed — returns same array reference", () => {
880+
const msgs = [
881+
makeMsg("san-u1", "user", "build it", SESSION),
882+
makeStepWithTool("san-a1", "san-u1", "bash", "done", SESSION),
883+
];
884+
885+
const result = transform({ messages: msgs, projectPath: PROJECT, sessionID: SESSION });
886+
887+
// Layer 0 for small session — messages should be the same reference
888+
expect(result.layer).toBe(0);
889+
// The tool part should still be completed
890+
const toolPart = result.messages[1]!.parts.find((p) => p.type === "tool")!;
891+
expect((toolPart as any).state.status).toBe("completed");
892+
});
893+
894+
test("pending tool part is converted to error state", () => {
895+
const msgs = [
896+
makeMsg("san-u2", "user", "run something", SESSION),
897+
makeStepWithPendingTool("san-a2", "san-u2", "bash", SESSION),
898+
];
899+
900+
const result = transform({ messages: msgs, projectPath: PROJECT, sessionID: SESSION });
901+
902+
const toolPart = result.messages[1]!.parts.find((p) => p.type === "tool")! as any;
903+
expect(toolPart.state.status).toBe("error");
904+
expect(toolPart.state.error).toBe("[tool execution interrupted — session recovered]");
905+
expect(toolPart.state.input).toEqual({ command: "ls" });
906+
// Pending has no time field — both start and end should be fabricated
907+
expect(typeof toolPart.state.time.start).toBe("number");
908+
expect(typeof toolPart.state.time.end).toBe("number");
909+
});
910+
911+
test("running tool part is converted to error state, preserving time.start", () => {
912+
const msgs = [
913+
makeMsg("san-u3", "user", "build the project", SESSION),
914+
makeStepWithRunningTool("san-a3", "san-u3", "bash", SESSION),
915+
];
916+
917+
const result = transform({ messages: msgs, projectPath: PROJECT, sessionID: SESSION });
918+
919+
const toolPart = result.messages[1]!.parts.find((p) => p.type === "tool")! as any;
920+
expect(toolPart.state.status).toBe("error");
921+
expect(toolPart.state.error).toBe("[tool execution interrupted — session recovered]");
922+
expect(toolPart.state.input).toEqual({ command: "build" });
923+
// Running has time.start — should be preserved
924+
expect(toolPart.state.time.start).toBeLessThan(Date.now());
925+
expect(toolPart.state.time.end).toBeGreaterThanOrEqual(toolPart.state.time.start);
926+
// Metadata from running state should be carried over
927+
expect(toolPart.state.metadata).toEqual({ cwd: "/test" });
928+
});
929+
930+
test("mixed parts: text + completed tool + pending tool — only pending converted", () => {
931+
const msgs = [
932+
makeMsg("san-u4", "user", "do stuff", SESSION),
933+
{
934+
...makeStepWithTool("san-a4", "san-u4", "bash", "first output", SESSION),
935+
parts: [
936+
// text part
937+
{
938+
id: "text-san-a4",
939+
sessionID: SESSION,
940+
messageID: "san-a4",
941+
type: "text",
942+
text: "Let me run two commands",
943+
time: { start: Date.now(), end: Date.now() },
944+
} as Part,
945+
// completed tool part
946+
{
947+
id: "tool-completed-san-a4",
948+
sessionID: SESSION,
949+
messageID: "san-a4",
950+
type: "tool",
951+
callID: "call-completed",
952+
tool: "bash",
953+
state: {
954+
status: "completed",
955+
title: "bash",
956+
input: { command: "ls" },
957+
output: "file1.ts file2.ts",
958+
metadata: {},
959+
time: { start: Date.now(), end: Date.now() },
960+
},
961+
} as unknown as Part,
962+
// pending tool part
963+
{
964+
id: "tool-pending-san-a4",
965+
sessionID: SESSION,
966+
messageID: "san-a4",
967+
type: "tool",
968+
callID: "call-pending",
969+
tool: "bash",
970+
state: {
971+
status: "pending",
972+
input: { command: "cat file1.ts" },
973+
raw: '{"command": "cat file1.ts"}',
974+
},
975+
} as unknown as Part,
976+
],
977+
},
978+
];
979+
980+
const result = transform({ messages: msgs, projectPath: PROJECT, sessionID: SESSION });
981+
982+
const parts = result.messages[1]!.parts;
983+
// Text part unchanged
984+
const textPart = parts.find((p) => p.type === "text")!;
985+
expect((textPart as any).text).toBe("Let me run two commands");
986+
// Completed tool part unchanged
987+
const completedTool = parts.find(
988+
(p) => p.type === "tool" && (p as any).callID === "call-completed",
989+
)! as any;
990+
expect(completedTool.state.status).toBe("completed");
991+
expect(completedTool.state.output).toBe("file1.ts file2.ts");
992+
// Pending tool part → error
993+
const pendingTool = parts.find(
994+
(p) => p.type === "tool" && (p as any).callID === "call-pending",
995+
)! as any;
996+
expect(pendingTool.state.status).toBe("error");
997+
expect(pendingTool.state.error).toBe("[tool execution interrupted — session recovered]");
998+
});
999+
1000+
test("user messages are untouched", () => {
1001+
const userMsg = makeMsg("san-u5", "user", "hello", SESSION);
1002+
const msgs = [userMsg, makeStepWithPendingTool("san-a5", "san-u5", "bash", SESSION)];
1003+
1004+
const result = transform({ messages: msgs, projectPath: PROJECT, sessionID: SESSION });
1005+
1006+
// User message should be the same object reference (not cloned)
1007+
expect(result.messages[0]!.info.id).toBe("san-u5");
1008+
expect(result.messages[0]!.parts[0]!.type).toBe("text");
1009+
});
1010+
1011+
test("multiple messages: only affected messages are cloned", () => {
1012+
const msgs = [
1013+
makeMsg("san-u6", "user", "first task", SESSION),
1014+
makeStepWithTool("san-a6", "san-u6", "bash", "done", SESSION), // completed — untouched
1015+
makeMsg("san-u7", "user", "second task", SESSION),
1016+
makeStepWithPendingTool("san-a7", "san-u7", "edit", SESSION), // pending — converted
1017+
];
1018+
1019+
const result = transform({ messages: msgs, projectPath: PROJECT, sessionID: SESSION });
1020+
1021+
// Completed tool message untouched
1022+
const completedMsg = result.messages.find((m) => m.info.id === "san-a6")!;
1023+
const completedTool = completedMsg.parts.find((p) => p.type === "tool")! as any;
1024+
expect(completedTool.state.status).toBe("completed");
1025+
1026+
// Pending tool message converted
1027+
const pendingMsg = result.messages.find((m) => m.info.id === "san-a7")!;
1028+
const pendingTool = pendingMsg.parts.find((p) => p.type === "tool")! as any;
1029+
expect(pendingTool.state.status).toBe("error");
1030+
});
1031+
});
1032+
7561033
// ---------------------------------------------------------------------------
7571034
// Layer 0 trailing-drop: pure-text trailing assistant messages must be dropped
7581035
// even when gradient is not active (layer 0 passthrough). This is the fix for

0 commit comments

Comments
 (0)