diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index a99f8acff20c..1283b5626b16 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -6,7 +6,8 @@ import { SessionV1 } from "@opencode-ai/core/v1/session" import { serviceUse } from "@opencode-ai/core/effect/service-use" import { Context, Effect, Layer } from "effect" import * as Stream from "effect/Stream" -import { streamText, wrapLanguageModel, type ModelMessage, type Tool } from "ai" +import { streamText, wrapLanguageModel, type ModelMessage, type Tool, type ToolCallRepairFunction, type ToolSet } from "ai" +import { ulid } from "ulid" import type { LLMEvent } from "@opencode-ai/llm" import { LLMClient } from "@opencode-ai/llm/route" import type { LLMClientService } from "@opencode-ai/llm/route" @@ -32,6 +33,31 @@ import { LLMRequestPrep } from "./llm/request" export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX +export function makeToolCallRepair(tools: TOOLS): ToolCallRepairFunction { + return async (failed) => { + const lower = failed.toolCall.toolName.toLowerCase() + // a provider that streams tool calls without an id would otherwise persist + // callID: "" and every later request replays it, which providers reject + const toolCallId = failed.toolCall.toolCallId || `call_${ulid()}` + if (lower !== failed.toolCall.toolName && tools[lower]) { + return { + ...failed.toolCall, + toolCallId, + toolName: lower, + } + } + return { + ...failed.toolCall, + toolCallId, + input: JSON.stringify({ + tool: failed.toolCall.toolName, + error: failed.error.message, + }), + toolName: "invalid", + } + } +} + export type StreamInput = { user: SessionV1.User sessionID: string @@ -293,23 +319,7 @@ const live: Layer.Layer< }, // Copilot returns the authoritative billed amount only in provider-specific response fields. includeRawChunks: input.model.providerID.includes("github-copilot"), - async experimental_repairToolCall(failed) { - const lower = failed.toolCall.toolName.toLowerCase() - if (lower !== failed.toolCall.toolName && prepared.tools[lower]) { - return { - ...failed.toolCall, - toolName: lower, - } - } - return { - ...failed.toolCall, - input: JSON.stringify({ - tool: failed.toolCall.toolName, - error: failed.error.message, - }), - toolName: "invalid", - } - }, + experimental_repairToolCall: makeToolCallRepair(prepared.tools), temperature: prepared.params.temperature, topP: prepared.params.topP, topK: prepared.params.topK, diff --git a/packages/opencode/test/session/llm-repair.test.ts b/packages/opencode/test/session/llm-repair.test.ts new file mode 100644 index 000000000000..052e76eb2234 --- /dev/null +++ b/packages/opencode/test/session/llm-repair.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "bun:test" +import { makeToolCallRepair } from "@/session/llm" + +const tools = { bash: {}, edit: {} } as unknown as Parameters[0] + +function repairInput(toolCall: Record, message: string) { + return { + system: undefined, + messages: [], + tools: tools as never, + inputSchema: () => ({}) as never, + toolCall: toolCall as never, + error: { message } as never, + } +} + +describe("session.llm.makeToolCallRepair", () => { + it("synthesizes a toolCallId when the model emitted an empty one", async () => { + const repair = makeToolCallRepair(tools) + const repaired = await repair( + repairInput({ toolCallId: "", toolName: "", input: "" }, "Model tried to call unavailable tool ''."), + ) + + expect(repaired).not.toBeNull() + expect(repaired!.toolName).toBe("invalid") + expect(repaired!.toolCallId.startsWith("call_")).toBe(true) + expect(repaired!.toolCallId.length).toBeGreaterThan(10) + }) + + it("keeps an existing non-empty toolCallId", async () => { + const repair = makeToolCallRepair(tools) + const repaired = await repair(repairInput({ toolCallId: "call_existing", toolName: "Bash", input: "{}" }, "No such tool capacity.")) + + expect(repaired).not.toBeNull() + expect(repaired!.toolCallId).toBe("call_existing") + expect(repaired!.toolName).toBe("bash") + }) +})