Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/dedupe-provider-tool-results.md
Original file line number Diff line number Diff line change
@@ -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".
84 changes: 83 additions & 1 deletion packages/eve/src/harness/provider-tool-history.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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]);
});
});
57 changes: 57 additions & 0 deletions packages/eve/src/harness/provider-tool-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
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<string>;
Expand Down
13 changes: 11 additions & 2 deletions packages/eve/src/harness/tool-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading