diff --git a/.changeset/dedupe-provider-tool-results.md b/.changeset/dedupe-provider-tool-results.md new file mode 100644 index 000000000..f5f9e7916 --- /dev/null +++ b/.changeset/dedupe-provider-tool-results.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Fixed a turn failure when a provider-executed tool (e.g. gateway `web_search` on Opus 5) reported its result both inline in the assistant message and as a separately synthesized tool-error for the same call. The harness now keeps a single `tool_result` per tool-use id, so the next model call no longer fails with "each tool_use must have a single result". diff --git a/packages/eve/src/harness/provider-tool-history.test.ts b/packages/eve/src/harness/provider-tool-history.test.ts index 94a517378..e980c0aa0 100644 --- a/packages/eve/src/harness/provider-tool-history.test.ts +++ b/packages/eve/src/harness/provider-tool-history.test.ts @@ -1,7 +1,10 @@ import type { ModelMessage } from "ai"; import { describe, expect, it } from "vitest"; -import { normalizeProviderToolHistory } from "#harness/provider-tool-history.js"; +import { + dedupeToolResultsByCallId, + normalizeProviderToolHistory, +} from "#harness/provider-tool-history.js"; describe("normalizeProviderToolHistory", () => { it("preserves text ordering around provider-executed tool results", () => { @@ -142,3 +145,82 @@ describe("normalizeProviderToolHistory", () => { expect(normalized.outcomeEndsResponse).toBe(true); }); }); + +describe("dedupeToolResultsByCallId", () => { + it("drops a second result for a provider call that already has an inline result", () => { + // Captured from a live Opus 5 web_search whose arguments failed to parse: + // the provider returns its own error result inline, and the AI SDK adds a + // second synthesized tool-error message for the same id. + const messages: ModelMessage[] = [ + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "toolu_dup", + toolName: "web_search", + input: {}, + providerExecuted: true, + }, + { + type: "tool-result", + toolCallId: "toolu_dup", + toolName: "web_search", + output: { type: "error-json", value: { error: "invalid_input" } }, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "toolu_dup", + toolName: "web_search", + output: { type: "error-text", value: "AI_InvalidToolInputError" }, + }, + ], + }, + ]; + + const result = dedupeToolResultsByCallId(messages); + + expect(result.droppedCallIds).toEqual(["toolu_dup"]); + // The inline provider result survives; the empty tool message is pruned. + expect(result.messages).toEqual([messages[0]]); + }); + + it("keeps distinct client tool results untouched", () => { + const messages: ModelMessage[] = [ + { + role: "assistant", + content: [{ type: "tool-call", toolCallId: "a", toolName: "add", input: {} }], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "a", + toolName: "add", + output: { type: "json", value: 1 }, + }, + { + type: "tool-result", + toolCallId: "b", + toolName: "sub", + output: { type: "json", value: 2 }, + }, + ], + }, + ]; + + const result = dedupeToolResultsByCallId(messages); + + expect(result.droppedCallIds).toEqual([]); + expect(result.messages).toEqual(messages); + // Untouched messages keep their identity (no needless copies). + expect(result.messages[0]).toBe(messages[0]); + expect(result.messages[1]).toBe(messages[1]); + }); +}); diff --git a/packages/eve/src/harness/provider-tool-history.ts b/packages/eve/src/harness/provider-tool-history.ts index b95d70039..bf2648ffe 100644 --- a/packages/eve/src/harness/provider-tool-history.ts +++ b/packages/eve/src/harness/provider-tool-history.ts @@ -83,6 +83,63 @@ export function normalizeProviderToolHistory(input: { }; } +/** + * Enforces the provider invariant that each `tool_use` id carries at most one + * `tool_result`. + * + * Provider SDKs can emit two results for one provider-executed call whose + * arguments failed to parse: the provider's own outcome inline in the + * assistant message, plus a separately synthesized tool-error message. Both + * reference the same id, and replaying them fails the next model call + * ("each tool_use must have a single result"). This keeps the first result + * for each id — the provider's authoritative inline outcome, which appears + * before any synthesized follow-up — and drops later duplicates, pruning any + * message left empty. + */ +export function dedupeToolResultsByCallId(messages: readonly ModelMessage[]): { + readonly messages: ModelMessage[]; + readonly droppedCallIds: readonly string[]; +} { + const seen = new Set(); + const droppedCallIds: string[] = []; + const deduped: ModelMessage[] = []; + + for (const message of messages) { + if (!Array.isArray(message.content)) { + deduped.push(message); + continue; + } + + let dropped = false; + const content = (message.content as { type: string; toolCallId?: string }[]).filter((part) => { + if ( + (part.type !== "tool-result" && part.type !== "tool-error") || + part.toolCallId === undefined + ) { + return true; + } + if (seen.has(part.toolCallId)) { + droppedCallIds.push(part.toolCallId); + dropped = true; + return false; + } + seen.add(part.toolCallId); + return true; + }); + + if (!dropped) { + deduped.push(message); + continue; + } + if (content.length === 0) { + continue; + } + deduped.push({ ...message, content } as ModelMessage); + } + + return { messages: deduped, droppedCallIds }; +} + function findUnmarkedProviderToolCalls(input: { readonly messages: readonly ModelMessage[]; readonly providerExecutedOutcomeIds: ReadonlySet; diff --git a/packages/eve/src/harness/tool-loop.ts b/packages/eve/src/harness/tool-loop.ts index a9f205249..946227829 100644 --- a/packages/eve/src/harness/tool-loop.ts +++ b/packages/eve/src/harness/tool-loop.ts @@ -117,7 +117,10 @@ import { } from "#harness/stale-input-responses.js"; import { getInstrumentationConfig } from "#harness/instrumentation-config.js"; import { normalizeUserContent, resolveAssistantStepText } from "#harness/messages.js"; -import { normalizeProviderToolHistory } from "#harness/provider-tool-history.js"; +import { + dedupeToolResultsByCallId, + normalizeProviderToolHistory, +} from "#harness/provider-tool-history.js"; import { type AuthorizationSignal, isAuthorizationSignal, @@ -1834,7 +1837,13 @@ async function handleStepResult(input: { messages: rawResponseMessages, providerExecutedOutcomeIds, }); - const responseMessages = normalizedProviderHistory.messages; + const deduped = dedupeToolResultsByCallId(normalizedProviderHistory.messages); + if (deduped.droppedCallIds.length > 0) { + log.warn("dropped duplicate tool results before persisting history", { + callIds: deduped.droppedCallIds, + }); + } + const responseMessages = deduped.messages; const baseSession: HarnessSession = { ...session,