fix(provider): preserve anthropic provider-executed tool pairs
This commit is contained in:
parent
811954880e
commit
5c1c3b74b1
2 changed files with 126 additions and 8 deletions
|
|
@ -122,7 +122,7 @@ function normalizeMessages(
|
|||
})
|
||||
}
|
||||
if (["@ai-sdk/anthropic", "@ai-sdk/google-vertex/anthropic"].includes(model.api.npm)) {
|
||||
// Anthropic rejects assistant turns where tool_use blocks are followed by non-tool
|
||||
// Anthropic rejects assistant turns where client tool_use blocks are followed by non-tool
|
||||
// content, e.g. [tool_use, tool_use, text], with:
|
||||
// `tool_use` ids were found without `tool_result` blocks immediately after...
|
||||
//
|
||||
|
|
@ -130,19 +130,26 @@ function normalizeMessages(
|
|||
// assistant messages are later merged by the provider/SDK, so preserving the
|
||||
// original [tool_use...] then [text] order still produces the invalid payload.
|
||||
//
|
||||
// The root cause appears to be somewhere upstream where the stream is originally
|
||||
// processed. We were unable to locate an exact narrower reproduction elsewhere,
|
||||
// so we keep this transform in place for the time being.
|
||||
// Provider-executed tools are different: the AI SDK intentionally represents
|
||||
// their tool-call and tool-result together in assistant content.
|
||||
msgs = msgs.flatMap((msg) => {
|
||||
if (msg.role !== "assistant" || !Array.isArray(msg.content)) return [msg]
|
||||
|
||||
const parts = msg.content
|
||||
const first = parts.findIndex((part) => part.type === "tool-call")
|
||||
const providerExecuted = new Set(
|
||||
parts.flatMap((part) => (part.type === "tool-call" && part.providerExecuted === true ? [part.toolCallId] : [])),
|
||||
)
|
||||
const isClientToolPart = (part: (typeof parts)[number]) => {
|
||||
if (part.type === "tool-call") return part.providerExecuted !== true
|
||||
if (part.type === "tool-result") return !providerExecuted.has(part.toolCallId)
|
||||
return false
|
||||
}
|
||||
const first = parts.findIndex((part) => part.type === "tool-call" && part.providerExecuted !== true)
|
||||
if (first === -1) return [msg]
|
||||
if (!parts.slice(first).some((part) => part.type !== "tool-call")) return [msg]
|
||||
if (!parts.slice(first).some((part) => !isClientToolPart(part))) return [msg]
|
||||
return [
|
||||
{ ...msg, content: parts.filter((part) => part.type !== "tool-call") },
|
||||
{ ...msg, content: parts.filter((part) => part.type === "tool-call") },
|
||||
{ ...msg, content: parts.filter((part) => !isClientToolPart(part)) },
|
||||
{ ...msg, content: parts.filter(isClientToolPart) },
|
||||
]
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1498,6 +1498,117 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
|
|||
])
|
||||
})
|
||||
|
||||
test("keeps tool-call and tool-result paired when splitting anthropic messages", () => {
|
||||
const msgs = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } },
|
||||
{ type: "tool-result", toolCallId: "toolu_1", toolName: "read", output: { type: "text", value: "ok" } },
|
||||
{ type: "text", text: "I checked your home directory." },
|
||||
],
|
||||
},
|
||||
] as any[]
|
||||
|
||||
const result = ProviderTransform.message(msgs, anthropicModel, {}) as any[]
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "I checked your home directory." }],
|
||||
})
|
||||
expect(result[1]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } },
|
||||
{ type: "tool-result", toolCallId: "toolu_1", toolName: "read", output: { type: "text", value: "ok" } },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("leaves provider-executed anthropic tool results with trailing text unchanged", () => {
|
||||
const msgs = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "srvtoolu_1",
|
||||
toolName: "code_execution",
|
||||
input: { code: "print('ok')", type: "programmatic-tool-call" },
|
||||
providerExecuted: true,
|
||||
},
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: "srvtoolu_1",
|
||||
toolName: "code_execution",
|
||||
output: {
|
||||
type: "json",
|
||||
value: { type: "code_execution_result", stdout: "ok", stderr: "", return_code: 0 },
|
||||
},
|
||||
},
|
||||
{ type: "text", text: "The code ran successfully." },
|
||||
],
|
||||
},
|
||||
] as any[]
|
||||
|
||||
const result = ProviderTransform.message(msgs, anthropicModel, {}) as any[]
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].content).toMatchObject(msgs[0].content)
|
||||
})
|
||||
|
||||
test("keeps provider-executed pairs together when splitting mixed anthropic tool parts", () => {
|
||||
const msgs = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } },
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "srvtoolu_1",
|
||||
toolName: "code_execution",
|
||||
input: { code: "print('ok')", type: "programmatic-tool-call" },
|
||||
providerExecuted: true,
|
||||
},
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: "srvtoolu_1",
|
||||
toolName: "code_execution",
|
||||
output: {
|
||||
type: "json",
|
||||
value: { type: "code_execution_result", stdout: "ok", stderr: "", return_code: 0 },
|
||||
},
|
||||
},
|
||||
{ type: "text", text: "The server-side tool ran successfully." },
|
||||
],
|
||||
},
|
||||
] as any[]
|
||||
|
||||
const result = ProviderTransform.message(msgs, anthropicModel, {}) as any[]
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0].content).toMatchObject([
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "srvtoolu_1",
|
||||
toolName: "code_execution",
|
||||
input: { code: "print('ok')", type: "programmatic-tool-call" },
|
||||
providerExecuted: true,
|
||||
},
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: "srvtoolu_1",
|
||||
toolName: "code_execution",
|
||||
output: { type: "json", value: { type: "code_execution_result", stdout: "ok", stderr: "", return_code: 0 } },
|
||||
},
|
||||
{ type: "text", text: "The server-side tool ran successfully." },
|
||||
])
|
||||
expect(result[1].content).toMatchObject([
|
||||
{ type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } },
|
||||
])
|
||||
})
|
||||
|
||||
test("splits vertex anthropic assistant messages when text trails tool calls", () => {
|
||||
const model = {
|
||||
...anthropicModel,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue