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
16 changes: 13 additions & 3 deletions packages/opencode/src/session/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,11 +294,11 @@ 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]) {
const repaired = repairToolName(prepared.tools, failed.toolCall.toolName)
if (repaired) {
return {
...failed.toolCall,
toolName: lower,
toolName: repaired,
}
}
return {
Expand Down Expand Up @@ -401,6 +401,16 @@ export const defaultLayer = Layer.suspend(() =>

export const hasToolCalls = LLMRequestPrep.hasToolCalls

// Match case/hyphen variants back to the registered key; ambiguous collisions resolve to undefined.
const normalizeToolName = (name: string) => name.toLowerCase().replaceAll("-", "_")

export function repairToolName(tools: Record<string, unknown>, name: string) {
if (tools[name]) return name
const target = normalizeToolName(name)
const matches = Object.keys(tools).filter((key) => normalizeToolName(key) === target)
return matches.length === 1 ? matches[0] : undefined
}

export const node = LayerNode.make(layer, [
Auth.node,
Config.node,
Expand Down
29 changes: 29 additions & 0 deletions packages/opencode/test/session/llm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,35 @@ describe("session.llm.hasToolCalls", () => {
})
})

describe("session.llm.repairToolName", () => {
const stub = tool({
description: "",
inputSchema: z.object({}),
execute: async () => "",
})
const tools = (...names: string[]) => Object.fromEntries(names.map((name) => [name, stub]))

test("matches a lowercased capitalized-prefix MCP tool (#31506)", () => {
expect(LLM.repairToolName(tools("Tool_get_prop"), "tool_get_prop")).toBe("Tool_get_prop")
})

test("matches an all-underscore variant of a hyphenated MCP tool (#27396)", () => {
expect(LLM.repairToolName(tools("server_get-by-id"), "server_get_by_id")).toBe("server_get-by-id")
})

test("returns the exact name when it already matches", () => {
expect(LLM.repairToolName(tools("Tool_get_prop"), "Tool_get_prop")).toBe("Tool_get_prop")
})

test("returns undefined for an unknown tool", () => {
expect(LLM.repairToolName(tools("Tool_get_prop"), "totally_unknown")).toBeUndefined()
})

test("returns undefined when the normalized name is ambiguous", () => {
expect(LLM.repairToolName(tools("Tool_get_prop", "tool_get_prop"), "TOOL_GET_PROP")).toBeUndefined()
})
})

describe("session.llm.ai-sdk adapter", () => {
type AISDKAdapterEvent = Parameters<typeof LLMAISDK.toLLMEvents>[1]

Expand Down
Loading