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: 27 additions & 19 deletions packages/opencode/src/session/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,25 +326,7 @@ export namespace LLM {
})
},
async experimental_repairToolCall(failed) {
const lower = failed.toolCall.toolName.toLowerCase()
if (lower !== failed.toolCall.toolName && tools[lower]) {
l.info("repairing tool call", {
tool: failed.toolCall.toolName,
repaired: lower,
})
return {
...failed.toolCall,
toolName: lower,
}
}
return {
...failed.toolCall,
input: JSON.stringify({
tool: failed.toolCall.toolName,
error: failed.error.message,
}),
toolName: "invalid",
}
return repairToolCall(failed, tools, l)
},
temperature: params.temperature,
topP: params.topP,
Expand Down Expand Up @@ -431,6 +413,32 @@ export namespace LLM {
),
)

/** Exported for testing. */
export function repairToolCall<T extends { toolName: string }>(
failed: { toolCall: T; error: unknown },
tools: Record<string, unknown>,
l?: { info: (msg: string, meta: Record<string, unknown>) => void },
) {
const name = failed.toolCall.toolName
const lower = name.toLowerCase()
if (lower !== name && tools[lower]) {
l?.info("repairing tool call", { tool: name, repaired: lower })
return { ...failed.toolCall, toolName: lower }
}
// Build a clean error that never leaks internal tool names or the
// raw "Available tools: ..." list from the AI SDK's NoSuchToolError.
const isNoSuchTool =
typeof failed.error === "object" && failed.error !== null && "toolName" in failed.error
const msg = isNoSuchTool
? `Tool "${name}" is not available. Please use one of the tools provided to you.`
: `Tool "${name}" was called with invalid arguments. Please review the tool schema and retry.`
return {
...failed.toolCall,
input: JSON.stringify({ tool: name, error: msg }),
toolName: "invalid",
}
}

function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "permission" | "user">) {
const disabled = Permission.disabled(
Object.keys(input.tools),
Expand Down
21 changes: 17 additions & 4 deletions packages/opencode/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,16 +306,29 @@ export namespace SessionProcessor {
const parts = MessageV2.parts(ctx.assistantMessage.id)
const recentParts = parts.slice(-DOOM_LOOP_THRESHOLD)

if (
recentParts.length !== DOOM_LOOP_THRESHOLD ||
!recentParts.every(
const isExactLoop =
recentParts.length === DOOM_LOOP_THRESHOLD &&
recentParts.every(
(part) =>
part.type === "tool" &&
part.tool === value.toolName &&
part.state.status !== "pending" &&
JSON.stringify(part.state.input) === JSON.stringify(value.input),
)
) {

// Circuit-break repeated invalid tool calls even when inputs vary.
// The "invalid" tool is an internal fallback for malformed calls;
// N consecutive hits signal the model is stuck regardless of the
// specific error each time.
const isInvalidLoop =
!isExactLoop &&
value.toolName === "invalid" &&
recentParts.length === DOOM_LOOP_THRESHOLD &&
recentParts.every(
(part) => part.type === "tool" && part.tool === "invalid" && part.state.status !== "pending",
)

if (!isExactLoop && !isInvalidLoop) {
return
}

Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/tool/invalid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export const InvalidTool = Tool.define(
execute: (params: { tool: string; error: string }) =>
Effect.succeed({
title: "Invalid Tool",
output: `The arguments provided to the tool are invalid: ${params.error}`,
output: `Tool "${params.tool}" was called incorrectly. ${params.error.replace(/\s*Available tools:.*$/, "")}`,
metadata: {},
}),
}),
Expand Down
56 changes: 56 additions & 0 deletions packages/opencode/test/session/llm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,62 @@ describe("session.llm.hasToolCalls", () => {
})
})

describe("session.llm.repairToolCall", () => {
test("case-insensitive match repairs tool name", () => {
const result = LLM.repairToolCall(
{ toolCall: { toolName: "Read" }, error: { message: "...", toolName: "Read" } },
{ read: {}, bash: {} },
)
expect(result).toMatchObject({ toolName: "read" })
expect((result as any).input).toBeUndefined()
})

test("unknown tool produces clean error without Available tools list", () => {
const result = LLM.repairToolCall(
{
toolCall: { toolName: "nonexistent" },
error: {
message: "Model tried to call unavailable tool 'nonexistent'. Available tools: invalid, bash, read.",
toolName: "nonexistent",
},
},
{ bash: {}, read: {}, invalid: {} },
)
expect(result.toolName).toBe("invalid")
const input = JSON.parse((result as any).input)
expect(input.tool).toBe("nonexistent")
expect(input.error).not.toContain("Available tools")
expect(input.error).not.toContain("invalid")
expect(input.error).toContain("not available")
})

test("invalid arguments produces argument-specific error", () => {
const result = LLM.repairToolCall(
{
toolCall: { toolName: "bash" },
error: {
message: "Invalid input for tool bash: expected string got number",
toolInput: '{"command": 123}',
},
},
{ bash: {}, read: {}, invalid: {} },
)
expect(result.toolName).toBe("invalid")
const input = JSON.parse((result as any).input)
expect(input.tool).toBe("bash")
expect(input.error).toContain("invalid arguments")
expect(input.error).not.toContain("Available tools")
})

test("exact match tool name is not repaired by case logic", () => {
const result = LLM.repairToolCall(
{ toolCall: { toolName: "bash" }, error: { message: "bad args", toolInput: "{}" } },
{ bash: {}, read: {} },
)
expect(result.toolName).toBe("invalid")
})
})

type Capture = {
url: URL
headers: Headers
Expand Down
Loading