diff --git a/packages/ai/src/route/client.ts b/packages/ai/src/route/client.ts index f2fb5b4fa9c5..186c20143f84 100644 --- a/packages/ai/src/route/client.ts +++ b/packages/ai/src/route/client.ts @@ -7,6 +7,7 @@ import { HttpTransport } from "./transport/index.js" import type { HttpMiddleware, Transport, TransportRuntime, WebSocketChannelExecutor } from "./transport/index.js" import type { Protocol } from "./protocol.js" import { applyCachePolicy } from "../cache-policy.js" +import { normalizeToolHistory } from "../tool-history.js" import { sanitizeSurrogates } from "../utils/sanitize.js" import * as ProviderShared from "../protocols/shared.js" import type { ProtocolID, ProviderOptions } from "../schema/index.js" @@ -169,17 +170,19 @@ export interface GenerateMethod { export class Service extends Context.Service()("@opencode/LLMClient") {} const resolveRequestOptions = (request: LLMRequest) => { - const routeDefaults = request.model.route.defaults - const modelDefaults = request.model.defaults - const generation = mergeGenerationOptions(routeDefaults.generation, modelDefaults?.generation, request.generation) - return LLMRequest.update(request, { + const messages = normalizeToolHistory(request.messages) + const normalized = messages === request.messages ? request : LLMRequest.update(request, { messages }) + const routeDefaults = normalized.model.route.defaults + const modelDefaults = normalized.model.defaults + const generation = mergeGenerationOptions(routeDefaults.generation, modelDefaults?.generation, normalized.generation) + return LLMRequest.update(normalized, { generation: generation ?? new GenerationOptions({}), providerOptions: mergeProviderOptions( routeDefaults.providerOptions, modelDefaults?.providerOptions, - request.providerOptions, + normalized.providerOptions, ), - http: mergeHttpOptions(routeDefaults.http, modelDefaults?.http, request.http), + http: mergeHttpOptions(routeDefaults.http, modelDefaults?.http, normalized.http), }) } diff --git a/packages/ai/src/tool-history.ts b/packages/ai/src/tool-history.ts new file mode 100644 index 000000000000..a36bbbee3cc1 --- /dev/null +++ b/packages/ai/src/tool-history.ts @@ -0,0 +1,74 @@ +import { Message, ToolResultPart, type ToolCallPart } from "./schema/messages.js" + +const EMPTY_TOOL_OUTPUT = "(no tool output)" +const MISSING_TOOL_RESULT = "Tool result missing" + +export function normalizeToolHistory(messages: ReadonlyArray) { + const normalized: Message[] = [] + const pending = new Map() + const appendMissingResults = () => { + if (pending.size === 0) return + normalized.push(missingToolResults(pending.values())) + pending.clear() + } + + for (const message of messages) { + if (message.role === "user" || message.role === "assistant") appendMissingResults() + + if (message.role === "tool") { + const tool = normalizeToolMessage(message, pending) + if (tool) normalized.push(tool) + continue + } + + normalized.push(message) + if (message.role !== "assistant") continue + for (const part of message.content) { + if (part.type === "tool-call" && part.providerExecuted !== true) pending.set(part.id, part) + } + } + + return normalized.length === messages.length && normalized.every((message, index) => message === messages[index]) + ? messages + : normalized +} + +function missingToolResults(calls: Iterable) { + return new Message({ + role: "tool", + content: [...calls].map((call) => + ToolResultPart.make({ id: call.id, name: call.name, result: MISSING_TOOL_RESULT, resultType: "error" }), + ), + }) +} + +function normalizeToolMessage(message: Message, pending: Map): Message | undefined { + const content = message.content.map((part) => { + if (part.type !== "tool-result" || part.providerExecuted === true) return part + const call = pending.get(part.id) + if (call) pending.delete(part.id) + return normalizeToolResult(part, call?.name ?? part.name) + }) + if (content.length === 0) return undefined + if (content.every((part, index) => part === message.content[index])) return message + return new Message({ + id: message.id, + role: message.role, + content, + metadata: message.metadata, + native: message.native, + }) +} + +function normalizeToolResult(part: ToolResultPart, name: string): ToolResultPart { + const named = part.name === name ? part : { ...part, name } + if (named.result.type === "text" && named.result.value === "") + return { ...named, result: { type: "text", value: EMPTY_TOOL_OUTPUT } } + if (named.result.type === "error" && named.result.value === "") + return { ...named, result: { type: "error", value: EMPTY_TOOL_OUTPUT } } + if (named.result.type !== "content") return named + const value = named.result.value.filter((item) => item.type !== "text" || item.text !== "") + if (value.length === 0) return { ...named, result: { type: "text", value: EMPTY_TOOL_OUTPUT } } + if (value.length === named.result.value.length) return named + return { ...named, result: { type: "content", value } } +} diff --git a/packages/ai/test/compile.test.ts b/packages/ai/test/compile.test.ts index d0d374c689e9..502381e864b8 100644 --- a/packages/ai/test/compile.test.ts +++ b/packages/ai/test/compile.test.ts @@ -106,6 +106,26 @@ describe("request option precedence", () => { }), ) + it.effect("normalizes tool history before protocol lowering", () => + Effect.gen(function* () { + const prepared = yield* compileRequest( + LLM.request({ + model: OpenAIChat.route.model({ id: "gpt-4o-mini" }), + messages: [ + Message.assistant(ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })), + Message.user("Continue."), + ], + }), + ) + + expect(prepared.body.messages).toMatchObject([ + { role: "assistant", tool_calls: [{ id: "call_1", function: { name: "lookup" } }] }, + { role: "tool", tool_call_id: "call_1", content: "Tool result missing" }, + { role: "user", content: "Continue." }, + ]) + }), + ) + it.effect("applies model HTTP defaults before request HTTP overlays", () => LLMClient.generate( LLM.request({ diff --git a/packages/ai/test/tool-history.test.ts b/packages/ai/test/tool-history.test.ts new file mode 100644 index 000000000000..302e3f4b334e --- /dev/null +++ b/packages/ai/test/tool-history.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from "bun:test" +import { Message, ToolCallPart, ToolResultPart } from "../src/schema/messages.js" +import { normalizeToolHistory } from "../src/tool-history.js" + +const toolCall = (id: string, name = id) => ToolCallPart.make({ id, name, input: {} }) +const toolResult = (id: string, value: unknown, name = id, resultType?: "text" | "content" | "error") => + Message.tool(ToolResultPart.make({ id, name, result: value, resultType })) + +describe("tool history normalization", () => { + test("fills missing local results before the next step", () => { + const normalized = normalizeToolHistory([ + Message.assistant([toolCall("first"), toolCall("second")]), + toolResult("first", "done", "wrong", "text"), + Message.user("Continue."), + Message.assistant(toolCall("trailing")), + ]) + + expect(normalized.map((message) => message.role)).toEqual([ + "assistant", + "tool", + "tool", + "user", + "assistant", + ]) + expect(normalized[1]?.content[0]).toMatchObject({ type: "tool-result", id: "first", name: "first" }) + expect(normalized[2]?.content).toEqual([ + { type: "tool-result", id: "second", name: "second", result: { type: "error", value: "Tool result missing" } }, + ]) + expect(normalized[4]?.content).toEqual([toolCall("trailing")]) + }) + + test("normalizes empty results without changing whitespace or media", () => { + const media = { type: "file" as const, uri: "data:image/png;base64,AQID", mime: "image/png" } + const normalized = normalizeToolHistory([ + Message.assistant([ + toolCall("text"), + toolCall("content"), + toolCall("error"), + toolCall("mixed"), + toolCall("whitespace"), + ]), + toolResult("text", "", "text", "text"), + toolResult("content", [], "content", "content"), + toolResult("error", "", "error", "error"), + toolResult("mixed", [{ type: "text", text: "" }, media], "mixed", "content"), + toolResult("whitespace", " ", "whitespace", "text"), + ]) + + expect(normalized.slice(1).map((message) => message.content[0])).toEqual([ + { type: "tool-result", id: "text", name: "text", result: { type: "text", value: "(no tool output)" } }, + { type: "tool-result", id: "content", name: "content", result: { type: "text", value: "(no tool output)" } }, + { type: "tool-result", id: "error", name: "error", result: { type: "error", value: "(no tool output)" } }, + { type: "tool-result", id: "mixed", name: "mixed", result: { type: "content", value: [media] } }, + { type: "tool-result", id: "whitespace", name: "whitespace", result: { type: "text", value: " " } }, + ]) + }) + + test("leaves unmatched and provider-executed history unchanged", () => { + const hostedCall = ToolCallPart.make({ + id: "hosted", + name: "web_search", + input: {}, + providerExecuted: true, + }) + const hostedResult = ToolResultPart.make({ + id: "hosted", + name: "web_search", + result: "", + resultType: "text", + providerExecuted: true, + }) + const hosted = Message.assistant([hostedCall, hostedResult]) + const orphan = toolResult("orphan", "ignored", "orphan", "text") + + expect(normalizeToolHistory([orphan, hosted])).toEqual([orphan, hosted]) + }) +})