diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index d38c29765ade..07c150c79240 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -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, @@ -431,6 +413,32 @@ export namespace LLM { ), ) + /** Exported for testing. */ + export function repairToolCall( + failed: { toolCall: T; error: unknown }, + tools: Record, + l?: { info: (msg: string, meta: Record) => 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) { const disabled = Permission.disabled( Object.keys(input.tools), diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 415639fbe56a..1d58dd87a889 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -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 } diff --git a/packages/opencode/src/tool/invalid.ts b/packages/opencode/src/tool/invalid.ts index aca3618b6d04..457bc85cad09 100644 --- a/packages/opencode/src/tool/invalid.ts +++ b/packages/opencode/src/tool/invalid.ts @@ -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: {}, }), }), diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 4d82096f3f9a..ec7955240c44 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -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