Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 28 additions & 18 deletions packages/opencode/src/session/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -32,6 +33,31 @@ import { LLMRequestPrep } from "./llm/request"

export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX

export function makeToolCallRepair<TOOLS extends ToolSet>(tools: TOOLS): ToolCallRepairFunction<TOOLS> {
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
Expand Down Expand Up @@ -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,
Expand Down
38 changes: 38 additions & 0 deletions packages/opencode/test/session/llm-repair.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, it } from "bun:test"
import { makeToolCallRepair } from "@/session/llm"

const tools = { bash: {}, edit: {} } as unknown as Parameters<typeof makeToolCallRepair>[0]

function repairInput(toolCall: Record<string, unknown>, 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")
})
})
Loading