Skip to content

Commit 59d2447

Browse files
fix(agents): avoid synthetic results for racing tool calls
1 parent f7a1d3f commit 59d2447

4 files changed

Lines changed: 127 additions & 12 deletions

File tree

src/agents/session-tool-result-guard.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,31 @@ describe("installSessionToolResultGuard", () => {
370370
expectPersistedRoles(sm, ["assistant", "toolResult"]);
371371
});
372372

373+
it("does not synthesize older pending results before a new assistant tool-call turn", () => {
374+
const sm = SessionManager.inMemory();
375+
const guard = installSessionToolResultGuard(sm);
376+
377+
appendAssistantToolCall(sm, { id: "call_1", name: "read" });
378+
appendAssistantToolCall(sm, { id: "call_2", name: "exec" });
379+
sm.appendMessage(
380+
asAppendMessage({
381+
role: "toolResult",
382+
toolCallId: "call_1",
383+
toolName: "read",
384+
content: [{ type: "text", text: "real output" }],
385+
isError: false,
386+
}),
387+
);
388+
389+
const messages = expectPersistedRoles(sm, ["assistant", "assistant", "toolResult"]);
390+
expect((messages[2] as { toolCallId?: string; isError?: boolean }).toolCallId).toBe(
391+
"call_1",
392+
);
393+
expect((messages[2] as { isError?: boolean }).isError).toBe(false);
394+
expect(JSON.stringify(messages)).not.toContain("missing tool result");
395+
expect(guard.getPendingIds()).toStrictEqual(["call_2"]);
396+
});
397+
373398
it("clears pending when a sanitized assistant message is dropped and synthetic results are disabled", () => {
374399
const sm = SessionManager.inMemory();
375400
const guard = installSessionToolResultGuard(sm, {

src/agents/session-tool-result-guard.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -745,8 +745,15 @@ export function installSessionToolResultGuard(
745745
) {
746746
flushPendingToolResults();
747747
}
748-
// If new tool calls arrive while older ones are pending, flush the old ones first.
749-
if (pendingState.shouldFlushBeforeNewToolCalls(toolCalls.length)) {
748+
// If synthetic results are disabled, a new assistant tool-call turn is a safe
749+
// boundary to drop older pending ids. When synthetic results are enabled,
750+
// do not synthesize here: parallel tool-result appends can still be racing
751+
// this assistant append, and transcript repair can move late real results
752+
// back into strict provider order before the next replay.
753+
if (
754+
!allowSyntheticToolResults &&
755+
pendingState.shouldFlushBeforeNewToolCalls(toolCalls.length)
756+
) {
750757
flushPendingToolResults();
751758
}
752759

src/agents/session-transcript-repair.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,52 @@ describe("sanitizeToolUseResultPairing", () => {
193193
expect(result.moved).toBe(true);
194194
});
195195

196+
it("moves late real results ahead of newer assistant tool calls instead of synthesizing", () => {
197+
const input = castAgentMessages([
198+
{
199+
role: "assistant",
200+
content: [{ type: "toolCall", id: "call_read", name: "read", arguments: {} }],
201+
},
202+
{
203+
role: "assistant",
204+
content: [{ type: "toolCall", id: "call_exec", name: "exec", arguments: {} }],
205+
},
206+
{
207+
role: "toolResult",
208+
toolCallId: "call_read",
209+
toolName: "read",
210+
content: [{ type: "text", text: "real read output" }],
211+
isError: false,
212+
},
213+
{
214+
role: "toolResult",
215+
toolCallId: "call_exec",
216+
toolName: "exec",
217+
content: [{ type: "text", text: "real exec output" }],
218+
isError: false,
219+
},
220+
]);
221+
222+
const result = repairToolUseResultPairing(input);
223+
224+
expect(result.added).toHaveLength(0);
225+
expect(result.messages.map((message) => message.role)).toEqual([
226+
"assistant",
227+
"toolResult",
228+
"assistant",
229+
"toolResult",
230+
]);
231+
expect((result.messages[1] as { toolCallId?: string; isError?: boolean }).toolCallId).toBe(
232+
"call_read",
233+
);
234+
expect((result.messages[1] as { isError?: boolean }).isError).toBe(false);
235+
expect((result.messages[3] as { toolCallId?: string; isError?: boolean }).toolCallId).toBe(
236+
"call_exec",
237+
);
238+
expect(JSON.stringify(result.messages)).not.toContain("missing tool result");
239+
expect(result.moved).toBe(true);
240+
});
241+
196242
it("repairs blank tool result names from matching tool calls", () => {
197243
const input = castAgentMessages([
198244
{

src/agents/session-transcript-repair.ts

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,29 @@ function assistantHasToolCalls(message: AgentMessage): boolean {
445445
return extractToolCallsFromAssistant(message).length > 0;
446446
}
447447

448+
function findLaterMatchingToolResult(params: {
449+
messages: AgentMessage[];
450+
startIndex: number;
451+
toolCallId: string;
452+
toolName?: string;
453+
toolCalls: Array<{ id: string; name?: string }>;
454+
seenToolResultIds: Set<string>;
455+
}): Extract<AgentMessage, { role: "toolResult" }> | undefined {
456+
for (let index = params.startIndex; index < params.messages.length; index += 1) {
457+
const candidate = params.messages[index];
458+
if (!candidate || typeof candidate !== "object" || candidate.role !== "toolResult") {
459+
continue;
460+
}
461+
const normalizedLegacyResult = normalizeLegacyToolResultId(candidate, params.toolCalls);
462+
const id = extractToolResultId(normalizedLegacyResult);
463+
if (!id || id !== params.toolCallId || params.seenToolResultIds.has(id)) {
464+
continue;
465+
}
466+
return normalizeToolResultName(normalizedLegacyResult, params.toolName);
467+
}
468+
return undefined;
469+
}
470+
448471
export function repairToolUseResultPairing(
449472
messages: AgentMessage[],
450473
options?: ToolUseResultPairingOptions,
@@ -540,12 +563,12 @@ export function repairToolUseResultPairing(
540563
toolCalls,
541564
);
542565
const id = extractToolResultId(toolResult);
566+
if (id && seenToolResultIds.has(id)) {
567+
droppedDuplicateCount += 1;
568+
changed = true;
569+
continue;
570+
}
543571
if (id && toolCallIds.has(id)) {
544-
if (seenToolResultIds.has(id)) {
545-
droppedDuplicateCount += 1;
546-
changed = true;
547-
continue;
548-
}
549572
if (toolResult !== next) {
550573
changed = true;
551574
}
@@ -612,14 +635,28 @@ export function repairToolUseResultPairing(
612635
if (existing) {
613636
pushToolResult(existing);
614637
} else {
615-
const missing = makeMissingToolResult({
638+
const laterResult = findLaterMatchingToolResult({
639+
messages,
640+
startIndex: j,
616641
toolCallId: call.id,
617642
toolName: call.name,
618-
text: options?.missingToolResultText,
643+
toolCalls,
644+
seenToolResultIds,
619645
});
620-
added.push(missing);
621-
changed = true;
622-
pushToolResult(missing);
646+
if (laterResult) {
647+
moved = true;
648+
changed = true;
649+
pushToolResult(laterResult);
650+
} else {
651+
const missing = makeMissingToolResult({
652+
toolCallId: call.id,
653+
toolName: call.name,
654+
text: options?.missingToolResultText,
655+
});
656+
added.push(missing);
657+
changed = true;
658+
pushToolResult(missing);
659+
}
623660
}
624661
}
625662

0 commit comments

Comments
 (0)