diff --git a/packages/ai/src/tool-history.ts b/packages/ai/src/tool-history.ts index 09a3999559c..5d09660d1f5 100644 --- a/packages/ai/src/tool-history.ts +++ b/packages/ai/src/tool-history.ts @@ -6,10 +6,17 @@ const MISSING_TOOL_RESULT = "Tool result missing" export function normalizeToolHistory(messages: ReadonlyArray) { const normalized: Message[] = [] const pending = new Map() + // System updates cannot sit between a tool call and its results, so they wait until every pending call is answered. + const held: Message[] = [] + const releaseHeld = () => { + if (pending.size > 0) return + normalized.push(...held) + held.length = 0 + } const appendMissingResults = () => { - if (pending.size === 0) return - normalized.push(missingToolResults(pending.values())) + if (pending.size > 0) normalized.push(missingToolResults(pending.values())) pending.clear() + releaseHeld() } for (const message of messages) { @@ -18,6 +25,12 @@ export function normalizeToolHistory(messages: ReadonlyArray) { if (message.role === "tool") { const tool = normalizeToolMessage(message, pending) if (tool) normalized.push(tool) + releaseHeld() + continue + } + + if (message.role === "system" && pending.size > 0) { + held.push(message) continue } diff --git a/packages/ai/test/effort-updates.test.ts b/packages/ai/test/effort-updates.test.ts index f6d3f27aab5..c7095c538c7 100644 --- a/packages/ai/test/effort-updates.test.ts +++ b/packages/ai/test/effort-updates.test.ts @@ -147,7 +147,7 @@ describe("Anthropic Messages effort updates", () => { }), ) - it.effect("accepts a marker between a tool call and its result", () => + it.effect("moves a marker between a tool call and its result after the result", () => Effect.gen(function* () { const prepared = yield* compileRequest( LLM.request({ @@ -166,8 +166,8 @@ describe("Anthropic Messages effort updates", () => { expect(prepared.body.messages).toEqual([ { role: "user", content: [{ type: "text", text: "Weather?" }] }, { role: "assistant", content: [{ type: "tool_use", id: "call_1", name: "lookup", input: {} }] }, - { role: "system", content: [], output_config: { effort: "low" } }, { role: "user", content: [{ type: "tool_result", tool_use_id: "call_1", content: '{"temp":72}' }] }, + { role: "system", content: [], output_config: { effort: "low" } }, ]) }), ) diff --git a/packages/ai/test/provider/anthropic-messages.test.ts b/packages/ai/test/provider/anthropic-messages.test.ts index 173a8c7b8f9..20d5b6bcfeb 100644 --- a/packages/ai/test/provider/anthropic-messages.test.ts +++ b/packages/ai/test/provider/anthropic-messages.test.ts @@ -588,9 +588,9 @@ describe("Anthropic Messages route", () => { }), ) - it.effect("rejects a system update between a local tool call and its result", () => + it.effect("moves a system update between a local tool call and its result after the result", () => Effect.gen(function* () { - const error = yield* compileRequest( + const prepared = yield* compileRequest( LLM.request({ model: opus48, messages: [ @@ -601,9 +601,13 @@ describe("Anthropic Messages route", () => { ], cache: "none", }), - ).pipe(Effect.flip) + ) - expect(error.message).toContain("system updates cannot split a local tool call from its tool result") + expect(prepared.body.messages.slice(1)).toEqual([ + { role: "assistant", content: [{ type: "tool_use", id: "call_1", name: "lookup", input: {} }] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "call_1", content: '"Done."' }] }, + { role: "system", content: [{ type: "text", text: "Too early.", cache_control: undefined }] }, + ]) }), ) diff --git a/packages/ai/test/tool-history.test.ts b/packages/ai/test/tool-history.test.ts index 6ec5552ef39..80eac7a3694 100644 --- a/packages/ai/test/tool-history.test.ts +++ b/packages/ai/test/tool-history.test.ts @@ -112,4 +112,51 @@ describe("tool history normalization", () => { expect(normalized[1]?.content[0]).toMatchObject({ name: "lookup", namespace: undefined }) }) + + test("moves system updates after the results of pending calls", () => { + const calls = Message.assistant([toolCall("first"), toolCall("second")]) + const first = toolResult("first", "one", "first", "text") + const second = toolResult("second", "two", "second", "text") + const update = Message.system("First update.") + const later = Message.system("Second update.") + const user = Message.user("Continue.") + const missing = (id: string) => + Message.tool(ToolResultPart.make({ id, name: id, result: "Tool result missing", resultType: "error" })) + + expect(normalizeToolHistory([calls, first, update, later, second, user])).toEqual([ + calls, + first, + second, + update, + later, + user, + ]) + expect(normalizeToolHistory([Message.assistant(toolCall("first")), update, user])).toEqual([ + Message.assistant(toolCall("first")), + missing("first"), + update, + user, + ]) + expect(normalizeToolHistory([Message.assistant(toolCall("first")), update])).toEqual([ + Message.assistant(toolCall("first")), + missing("first"), + update, + ]) + }) + + test("moves effort updates after the results of pending calls", () => { + const call = Message.assistant(toolCall("first")) + const result = toolResult("first", "one", "first", "text") + const effort = Message.effort({ effort: "low", previous: "high" }) + + expect(normalizeToolHistory([call, effort, result])).toEqual([call, result, effort]) + }) + + test("keeps system updates in place when no call is pending", () => { + const history = [Message.assistant(toolCall("first")), toolResult("first", "one", "first", "text")] + const update = Message.system("Update.") + const input = [Message.user("Start."), update, ...history, update, Message.user("Continue.")] + + expect(normalizeToolHistory(input)).toBe(input) + }) })