Compare commits

...

1 Commits

Author SHA1 Message Date
Aiden Cline 1b98af3f9e fix(ai): validate Anthropic tool ordering 2026-08-21 13:38:19 -05:00
5 changed files with 198 additions and 119 deletions
+59 -13
View File
@@ -463,9 +463,9 @@ const supportsNativeSystemUpdates = (request: LLMRequest) => {
return match[3] !== undefined && match[3].length <= 2 && Number(match[3]) >= 8
}
const endsInServerToolUse = (message: LLMRequest["messages"][number]) => {
const endsInServerToolResult = (message: LLMRequest["messages"][number]) => {
const last = message.content.at(-1)
return message.role === "assistant" && last?.type === "tool-call" && last.providerExecuted === true
return message.role === "assistant" && last?.type === "tool-result" && last.providerExecuted === true
}
const canUseNativeSystemUpdate = (messages: LLMRequest["messages"], index: number) => {
@@ -474,22 +474,68 @@ const canUseNativeSystemUpdate = (messages: LLMRequest["messages"], index: numbe
return (
previous !== undefined &&
previous.role !== "system" &&
(previous.role === "user" || previous.role === "tool" || endsInServerToolUse(previous)) &&
(previous.role === "user" || previous.role === "tool" || endsInServerToolResult(previous)) &&
next?.role !== "system" &&
(next === undefined || next.role === "assistant")
)
}
const splitsLocalToolResults = (messages: LLMRequest["messages"], index: number) => {
const pending = new Set<string>()
for (const message of messages.slice(0, index)) {
for (const part of message.content) {
if (message.role === "assistant" && part.type === "tool-call" && part.providerExecuted !== true)
pending.add(part.id)
if (message.role === "tool" && part.type === "tool-result") pending.delete(part.id)
const RESULT_ORDER_ERROR =
"Anthropic Messages local tool calls must be followed immediately by exactly one matching result per call"
const localToolOrderError = (messages: LLMRequest["messages"]): string | undefined => {
let index = 0
while (index < messages.length) {
const message = messages[index]
if (!message) return undefined
if (message.role === "tool")
return "Anthropic Messages tool results must immediately follow their matching local tool calls"
if (message.role !== "assistant") {
index += 1
continue
}
const firstLocalCall = message.content.findIndex(
(part) => part.type === "tool-call" && part.providerExecuted !== true,
)
if (firstLocalCall === -1) {
index += 1
continue
}
if (message.content.slice(firstLocalCall + 1).some((part) => part.type !== "tool-call"))
return "Anthropic Messages local tool calls must be the final content blocks in an assistant message"
const calls = message.content
.filter((part): part is ToolCallPart => part.type === "tool-call" && part.providerExecuted !== true)
.map((part) => part.id)
if (new Set(calls).size !== calls.length)
return "Anthropic Messages assistant messages cannot contain duplicate local tool call IDs"
const wireCallIDs = message.content
.filter((part): part is ToolCallPart => part.type === "tool-call")
.map((part) => scrubToolCallID(part.id))
if (new Set(wireCallIDs).size !== wireCallIDs.length)
return "Anthropic Messages tool call IDs must remain unique after normalization"
// Consecutive canonical tool messages are batched below into one Anthropic user turn.
const resultStart = index + 1
const resultEndOffset = messages.slice(resultStart).findIndex((item) => item.role !== "tool")
const resultEnd = resultEndOffset === -1 ? messages.length : resultStart + resultEndOffset
const resultMessages = messages.slice(resultStart, resultEnd)
if (resultMessages.length === 0) return RESULT_ORDER_ERROR
const resultParts = resultMessages.flatMap((item) => item.content)
if (resultParts.some((part) => part.type !== "tool-result" || part.providerExecuted === true)) return RESULT_ORDER_ERROR
const results = resultParts.flatMap((part) => (part.type === "tool-result" ? [part.id] : []))
const resultIDs = new Set(results)
if (
resultIDs.size !== results.length ||
results.length !== calls.length ||
calls.some((id) => !resultIDs.has(id))
)
return RESULT_ORDER_ERROR
index = resultEnd
}
return pending.size > 0
return undefined
}
const lowerNativeSystemUpdate = Effect.fn("AnthropicMessages.lowerNativeSystemUpdate")(function* (
@@ -511,12 +557,12 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
request: LLMRequest,
breakpoints: Cache.Breakpoints,
) {
const toolOrderError = localToolOrderError(request.messages)
if (toolOrderError) return yield* invalid(toolOrderError)
const messages: AnthropicMessage[] = []
for (const [index, message] of request.messages.entries()) {
if (message.role === "system") {
if (splitsLocalToolResults(request.messages, index))
return yield* invalid("Anthropic Messages system updates cannot split a local tool call from its tool result")
if (supportsNativeSystemUpdates(request) && canUseNativeSystemUpdate(request.messages, index)) {
messages.push(yield* lowerNativeSystemUpdate(message, breakpoints))
continue
@@ -1,29 +0,0 @@
{
"version": 1,
"metadata": {
"name": "anthropic-messages/accepts-malformed-assistant-tool-order-with-default-patch",
"recordedAt": "2026-05-05T20:09:16.245Z",
"tags": ["prefix:anthropic-messages", "provider:anthropic", "protocol:anthropic-messages", "tool"]
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.anthropic.com/v1/messages",
"headers": {
"anthropic-version": "2023-06-01",
"content-type": "application/json"
},
"body": "{\"model\":\"claude-haiku-4-5-20251001\",\"messages\":[{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"I will check the weather.\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"call_1\",\"name\":\"get_weather\",\"input\":{\"city\":\"Paris\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_1\",\"content\":\"{\\\"temperature\\\":\\\"72F\\\"}\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use that result to answer briefly.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get weather\",\"input_schema\":{\"type\":\"object\",\"properties\":{}}}],\"stream\":true,\"max_tokens\":4096}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01SikJVFaMR1XLMtavUhvuog\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":638,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":1,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"The\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" weather in Paris is currently 72°F.\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":638,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":14} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n"
}
}
]
}
@@ -1,29 +0,0 @@
{
"version": 1,
"metadata": {
"name": "anthropic-messages/rejects-malformed-assistant-tool-order-without-patch",
"recordedAt": "2026-05-05T20:08:42.597Z",
"tags": ["prefix:anthropic-messages", "provider:anthropic", "protocol:anthropic-messages", "tool", "sad-path"]
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.anthropic.com/v1/messages",
"headers": {
"anthropic-version": "2023-06-01",
"content-type": "application/json"
},
"body": "{\"model\":\"claude-haiku-4-5-20251001\",\"messages\":[{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"call_1\",\"name\":\"get_weather\",\"input\":{\"city\":\"Paris\"}},{\"type\":\"text\",\"text\":\"I will check the weather.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_1\",\"content\":\"{\\\"temperature\\\":\\\"72F\\\"}\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use that result to answer briefly.\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get weather\",\"input_schema\":{\"type\":\"object\",\"properties\":{}}}],\"stream\":true,\"max_tokens\":4096}"
},
"response": {
"status": 400,
"headers": {
"content-type": "application/json"
},
"body": "{\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"messages.1: `tool_use` ids were found without `tool_result` blocks immediately after: call_1. Each `tool_use` block must have a corresponding `tool_result` block in the next message.\"},\"request_id\":\"req_011Cak2XdJgnzxKCY2BC2Beh\"}"
}
}
]
}
@@ -1,45 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, AIError, Message, ToolCallPart } from "../../src/index.js"
import { LLMClient } from "../../src/route.js"
import * as Anthropic from "../../src/providers/anthropic.js"
import { weatherToolName } from "../recorded-scenarios.js"
import { recordedTests } from "../recorded-test.js"
const model = Anthropic.configure({
apiKey: process.env.ANTHROPIC_API_KEY ?? "fixture",
}).model("claude-haiku-4-5-20251001")
const malformedToolOrderRequest = LLM.request({
id: "recorded_anthropic_malformed_tool_order",
model,
messages: [
Message.assistant([
ToolCallPart.make({ id: "call_1", name: weatherToolName, input: { city: "Paris" } }),
{ type: "text", text: "I will check the weather." },
]),
Message.tool({ id: "call_1", name: weatherToolName, result: { temperature: "72F" } }),
Message.user("Use that result to answer briefly."),
],
tools: [{ name: weatherToolName, description: "Get weather", inputSchema: { type: "object", properties: {} } }],
})
const recorded = recordedTests({
prefix: "anthropic-messages",
provider: "anthropic",
protocol: "anthropic-messages",
requires: ["ANTHROPIC_API_KEY"],
options: { redact: { allowRequestHeaders: ["anthropic-version"] } },
})
describe("Anthropic Messages sad-path recorded", () => {
recorded.effect.with("rejects malformed assistant tool order", { tags: ["tool", "sad-path"] }, () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(malformedToolOrderRequest).pipe(Effect.flip)
expect(error).toBeInstanceOf(AIError)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect(error.reason.message).toContain("`tool_use` ids were found without `tool_result` blocks")
}),
)
})
@@ -292,7 +292,143 @@ describe("Anthropic Messages route", () => {
}),
).pipe(Effect.flip)
expect(error.message).toContain("system updates cannot split a local tool call from its tool result")
expect(error.message).toContain("must be followed immediately by exactly one matching result per call")
}),
)
it.effect("rejects user and assistant messages between local tool calls and results", () =>
Effect.gen(function* () {
const errors = yield* Effect.forEach([Message.user("Too early."), Message.assistant("Too early.")], (middle) =>
compileRequest(
LLM.request({
model,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
middle,
Message.tool({ id: "call_1", name: "lookup", result: "Done." }),
],
}),
).pipe(Effect.flip),
)
expect(errors.every((error) => error.message.includes("must be followed immediately"))).toBe(true)
}),
)
it.effect("rejects content after a local tool call in the same assistant message", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([
ToolCallPart.make({ id: "call_1", name: "lookup", input: {} }),
{ type: "text", text: "Too late." },
]),
Message.tool({ id: "call_1", name: "lookup", result: "Done." }),
],
}),
).pipe(Effect.flip)
expect(error.message).toContain("must be the final content blocks")
}),
)
it.effect("rejects incomplete, duplicate, mismatched, and orphaned local tool results", () =>
Effect.gen(function* () {
const calls = Message.assistant([
ToolCallPart.make({ id: "call_1", name: "lookup", input: {} }),
ToolCallPart.make({ id: "call_2", name: "lookup", input: {} }),
])
const errors = yield* Effect.forEach(
[
[calls, Message.tool({ id: "call_1", name: "lookup", result: "Done." })],
[
calls,
Message.tool({ id: "call_1", name: "lookup", result: "Done." }),
Message.tool({ id: "call_1", name: "lookup", result: "Again." }),
],
[
calls,
Message.tool({ id: "call_1", name: "lookup", result: "Done." }),
Message.tool({ id: "call_3", name: "lookup", result: "Unknown." }),
],
[Message.tool({ id: "call_1", name: "lookup", result: "Orphaned." })],
],
(messages) => compileRequest(LLM.request({ model, messages })).pipe(Effect.flip),
)
expect(errors.every((error) => error.message.includes("result"))).toBe(true)
}),
)
it.effect("rejects duplicate and normalization-colliding tool call IDs", () =>
Effect.gen(function* () {
const errors = yield* Effect.forEach(
[
["call.1", "call.1"],
["call.1", "call:1"],
],
(ids) =>
compileRequest(
LLM.request({
model,
messages: [
Message.assistant(
ids.map((id) => ToolCallPart.make({ id, name: "lookup", input: {} })),
),
...ids.map((id) => Message.tool({ id, name: "lookup", result: "Done." })),
],
}),
).pipe(Effect.flip),
)
expect(errors[0]?.message).toContain("duplicate local tool call IDs")
expect(errors[1]?.message).toContain("unique after normalization")
}),
)
it.effect("places native system updates only after completed server tool use", () =>
Effect.gen(function* () {
const call = {
type: "tool-call" as const,
id: "srvtoolu_1",
name: "web_search",
input: { query: "effect" },
providerExecuted: true,
}
const unresolved = yield* compileRequest(
LLM.request({
model: opus48,
messages: [Message.assistant([call]), Message.system("Update.")],
cache: "none",
}),
)
const completed = yield* compileRequest(
LLM.request({
model: opus48,
messages: [
Message.assistant([
call,
{
type: "tool-result",
id: "srvtoolu_1",
name: "web_search",
result: { type: "json", value: [] },
providerExecuted: true,
},
]),
Message.system("Update."),
],
cache: "none",
}),
)
expect(unresolved.body.messages[1]).toEqual({
role: "user",
content: [{ type: "text", text: "<system-update>\nUpdate.\n</system-update>" }],
})
expect(completed.body.messages[1]).toEqual({ role: "system", content: [{ type: "text", text: "Update." }] })
}),
)
@@ -362,8 +498,8 @@ describe("Anthropic Messages route", () => {
ToolCallPart.make({ id: "call_paris", name: "weather", input: { city: "Paris" } }),
ToolCallPart.make({ id: "call_london", name: "weather", input: { city: "London" } }),
]),
Message.tool({ id: "call_paris", name: "weather", result: { temperature: 22 } }),
Message.tool({ id: "call_london", name: "weather", result: { temperature: 18 } }),
Message.tool({ id: "call_paris", name: "weather", result: { temperature: 22 } }),
],
cache: "none",
}),
@@ -382,8 +518,8 @@ describe("Anthropic Messages route", () => {
{
role: "user",
content: [
{ type: "tool_result", tool_use_id: "call_paris", content: '{"temperature":22}' },
{ type: "tool_result", tool_use_id: "call_london", content: '{"temperature":18}' },
{ type: "tool_result", tool_use_id: "call_paris", content: '{"temperature":22}' },
],
},
])