From 5d06e11413780f80652e9d86b2948f87c12ced6e Mon Sep 17 00:00:00 2001 From: Paul Kelly Date: Tue, 3 Mar 2026 14:14:11 +0000 Subject: [PATCH 1/3] fix(opencode): retry transient local provider connection errors --- packages/opencode/src/session/message-v2.ts | 106 ++++++++++++++++--- packages/opencode/test/session/retry.test.ts | 14 +++ 2 files changed, 106 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 5b4e7bdbc044..00191ea0aa6e 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -13,7 +13,6 @@ import { STATUS_CODES } from "http" import { Storage } from "@/storage/storage" import { ProviderError } from "@/provider/error" import { iife } from "@/util/iife" -import { type SystemError } from "bun" import type { Provider } from "@/provider/provider" export namespace MessageV2 { @@ -824,7 +823,99 @@ export namespace MessageV2 { return result } + const NET_CODE = new Set([ + "ECONNREFUSED", + "ECONNRESET", + "ETIMEDOUT", + "EHOSTUNREACH", + "ENETUNREACH", + "ENOTFOUND", + "EAI_AGAIN", + "UND_ERR_CONNECT_TIMEOUT", + "UND_ERR_HEADERS_TIMEOUT", + "UND_ERR_BODY_TIMEOUT", + "UND_ERR_SOCKET", + ]) + + const NET_RE = [/unable to connect/i, /connection refused/i, /fetch failed/i, /connect timeout/i] + + type ErrorLike = { + name?: string + message?: string + code?: string + syscall?: string + cause?: unknown + } + + function asError(input: unknown): ErrorLike | undefined { + if (typeof input !== "object" || input === null) return + return input as ErrorLike + } + + function causeChain(input: unknown) { + const chain = [] as ErrorLike[] + let cur = input + for (const _ of Array.from({ length: 8 })) { + const parsed = asError(cur) + if (!parsed) break + chain.push(parsed) + if (!parsed.cause || parsed.cause === cur) break + cur = parsed.cause + } + return chain + } + + function networkError(input: unknown) { + for (const err of causeChain(input)) { + const code = err.code ?? "" + const name = err.name ?? "" + const message = err.message ?? "" + + if (NET_CODE.has(code)) { + return { + code, + name, + message, + syscall: err.syscall ?? "", + } + } + + if (NET_RE.some((re) => re.test(message) || re.test(name))) { + return { + code, + name, + message, + syscall: err.syscall ?? "", + } + } + } + } + + function networkMessage(input: ReturnType) { + if (!input) return + if (input.code === "ECONNRESET") return "Connection reset by server" + if (input.code === "ETIMEDOUT" || input.code === "UND_ERR_CONNECT_TIMEOUT") return "Connection timed out" + if (input.code === "ECONNREFUSED") return "Unable to connect to provider" + return input.message || input.name || "Unable to connect to provider" + } + export function fromError(e: unknown, ctx: { providerID: string }) { + const network = networkError(e) + if (network) { + return new MessageV2.APIError( + { + message: networkMessage(network) ?? "Unable to connect to provider", + isRetryable: true, + metadata: { + code: network.code, + syscall: network.syscall, + message: network.message, + }, + }, + { cause: e }, + ).toObject() + } + switch (true) { case e instanceof DOMException && e.name === "AbortError": return new MessageV2.AbortedError( @@ -843,19 +934,6 @@ export namespace MessageV2 { }, { cause: e }, ).toObject() - case (e as SystemError)?.code === "ECONNRESET": - return new MessageV2.APIError( - { - message: "Connection reset by server", - isRetryable: true, - metadata: { - code: (e as SystemError).code ?? "", - syscall: (e as SystemError).syscall ?? "", - message: (e as SystemError).message ?? "", - }, - }, - { cause: e }, - ).toObject() case APICallError.isInstance(e): const parsed = ProviderError.parseAPICallError({ providerID: ctx.providerID, diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 6768e72d95a7..9e3d6bad784e 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -172,6 +172,20 @@ describe("session.message-v2.fromError", () => { expect(retryable).toBe("Connection reset by server") }) + test("converts ECONNREFUSED socket error to retryable APIError", () => { + const error = Object.assign(new Error("ConnectionRefused: Unable to connect. Is the computer able to access the url?"), { + code: "ECONNREFUSED", + syscall: "connect", + }) + + const result = MessageV2.fromError(error, { providerID: "test" }) as MessageV2.APIError + + expect(MessageV2.APIError.isInstance(result)).toBe(true) + expect(result.data.isRetryable).toBe(true) + expect(result.data.message).toBe("Unable to connect to provider") + expect(result.data.metadata?.code).toBe("ECONNREFUSED") + }) + test("marks OpenAI 404 status codes as retryable", () => { const error = new APICallError({ message: "boom", From e22a2dc6c9718649603fd37722316ee283432f2f Mon Sep 17 00:00:00 2001 From: Paul Kelly Date: Tue, 3 Mar 2026 21:30:58 +0000 Subject: [PATCH 2/3] fix(opencode): repair malformed tool inputs and retry invalid tool-call diffs --- packages/opencode/src/session/llm.ts | 60 +++++++++++++++++++- packages/opencode/src/session/retry.ts | 8 +++ packages/opencode/test/session/llm.test.ts | 35 ++++++++++++ packages/opencode/test/session/retry.test.ts | 5 ++ 4 files changed, 105 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 4e42fb0d2ec7..4af15e0a29ca 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -10,6 +10,7 @@ import { type ToolSet, tool, jsonSchema, + InvalidToolInputError, } from "ai" import { mergeDeep, pipe } from "remeda" import { ProviderTransform } from "@/provider/transform" @@ -22,6 +23,7 @@ import { SystemPrompt } from "./system" import { Flag } from "@/flag/flag" import { PermissionNext } from "@/permission/next" import { Auth } from "@/auth" +import { parse as partial } from "partial-json" export namespace LLM { const log = Log.create({ service: "llm" }) @@ -43,6 +45,46 @@ export namespace LLM { export type StreamOutput = StreamTextResult + function object(text: string) { + const parsed = (() => { + try { + return partial(text) + } catch { + return undefined + } + })() + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed as Record + if (typeof parsed !== "string") return + const nested = (() => { + try { + return partial(parsed) + } catch { + return undefined + } + })() + if (nested && typeof nested === "object" && !Array.isArray(nested)) return nested as Record + } + + function candidates(text: string) { + const value = text.trim() + if (!value) return [] as string[] + const start = value.search(/[{\[]/) + if (start <= 0) return [value] + return [value, value.slice(start)] + } + + export function repairToolInput(input: { toolInput: string; errorMessage: string }) { + const match = input.errorMessage.match(/Text:\s*([\s\S]*)$/) + const text = match?.[1] ?? "" + const list = [input.toolInput, text].flatMap(candidates).filter(Boolean) + for (const item of list) { + const parsed = object(item) + if (!parsed) continue + if (Object.keys(parsed).length === 0) continue + return JSON.stringify(parsed) + } + } + export async function stream(input: StreamInput) { const l = log .clone() @@ -177,14 +219,26 @@ export namespace LLM { }, async experimental_repairToolCall(failed) { const lower = failed.toolCall.toolName.toLowerCase() - if (lower !== failed.toolCall.toolName && tools[lower]) { + const name = lower !== failed.toolCall.toolName && tools[lower] ? lower : failed.toolCall.toolName + if (name !== failed.toolCall.toolName) { l.info("repairing tool call", { tool: failed.toolCall.toolName, - repaired: lower, + repaired: name, + }) + } + const repaired = repairToolInput({ + toolInput: InvalidToolInputError.isInstance(failed.error) ? failed.error.toolInput : failed.toolCall.input, + errorMessage: failed.error.message, + }) + if (repaired && tools[name]) { + l.info("repairing tool input", { + tool: failed.toolCall.toolName, + repaired: name, }) return { ...failed.toolCall, - toolName: lower, + toolName: name, + input: repaired, } } return { diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index 6d057f539f81..3ae93d291988 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -3,6 +3,8 @@ import { MessageV2 } from "./message-v2" import { iife } from "@/util/iife" export namespace SessionRetry { + const TOOL_CALL_DIFF_RE = /tool[\s_-]?calls?/ + export const RETRY_INITIAL_DELAY = 2000 export const RETRY_BACKOFF_FACTOR = 2 export const RETRY_MAX_DELAY_NO_HEADERS = 30_000 // 30 seconds @@ -67,6 +69,12 @@ export namespace SessionRetry { return `Free usage exceeded, add credits https://opencode.ai/zen` return error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message } + if (typeof error.data?.message === "string") { + const lower = error.data.message.toLowerCase() + if (lower.includes("invalid diff") && TOOL_CALL_DIFF_RE.test(lower)) { + return "Provider returned invalid tool call diff" + } + } const json = iife(() => { try { diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index a89a00ebc05e..9114fa7749a9 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -99,6 +99,41 @@ describe("session.llm.hasToolCalls", () => { }) }) +describe("session.llm.repairToolInput", () => { + test("repairs truncated tool input json", () => { + const repaired = LLM.repairToolInput({ + toolInput: `{"filePath":"src/app.ts","content":"hello`, + errorMessage: "Invalid input for tool write", + }) + expect(repaired).toBeDefined() + expect(JSON.parse(repaired!)).toEqual({ + filePath: "src/app.ts", + content: "hello", + }) + }) + + test("repairs from error text when tool input is empty", () => { + const repaired = LLM.repairToolInput({ + toolInput: "", + errorMessage: + 'Invalid input for tool write: JSON parsing failed: Text: {"filePath":"src/app.ts","content":"hello"}', + }) + expect(repaired).toBeDefined() + expect(JSON.parse(repaired!)).toEqual({ + filePath: "src/app.ts", + content: "hello", + }) + }) + + test("returns undefined when input is not json-like", () => { + const repaired = LLM.repairToolInput({ + toolInput: "not-json", + errorMessage: "Invalid input for tool write", + }) + expect(repaired).toBeUndefined() + }) +}) + type Capture = { url: URL headers: Headers diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 9e3d6bad784e..524065188b4f 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -108,6 +108,11 @@ describe("session.retry.retryable", () => { expect(result).toBeUndefined() }) + test("retries invalid tool-call diff stream errors", () => { + const error = wrap("Invalid diff: now finding less tool calls!") + expect(SessionRetry.retryable(error)).toBe("Provider returned invalid tool call diff") + }) + test("returns undefined for non-json message", () => { const error = wrap("not-json") expect(SessionRetry.retryable(error)).toBeUndefined() From 05f1bb889198da91e6d07ae79c25a5fbca2dd959 Mon Sep 17 00:00:00 2001 From: Paul Kelly Date: Tue, 3 Mar 2026 21:49:47 +0000 Subject: [PATCH 3/3] fix(opencode): recover write calls missing filePath --- packages/opencode/src/session/llm.ts | 27 +++++++++++++++++++--- packages/opencode/src/session/processor.ts | 8 ++++--- packages/opencode/test/session/llm.test.ts | 16 +++++++++++++ 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 4af15e0a29ca..1e63a69fb712 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -73,15 +73,35 @@ export namespace LLM { return [value, value.slice(start)] } - export function repairToolInput(input: { toolInput: string; errorMessage: string }) { + function str(input: unknown) { + if (typeof input !== "string") return + const value = input.trim() + if (!value) return + return value + } + + function normalize(input: Record, tool: string) { + const lower = tool.toLowerCase() + if (!["write", "read", "edit", "multiedit", "lsp"].includes(lower)) return input + if (str(input.filePath)) return input + const filePath = [input.filepath, input.path, input.file, input.filename].map(str).find((x) => x) + if (!filePath) return input + return { + ...input, + filePath, + } + } + + export function repairToolInput(input: { toolName: string; toolInput: string; errorMessage: string }) { const match = input.errorMessage.match(/Text:\s*([\s\S]*)$/) const text = match?.[1] ?? "" const list = [input.toolInput, text].flatMap(candidates).filter(Boolean) for (const item of list) { const parsed = object(item) if (!parsed) continue - if (Object.keys(parsed).length === 0) continue - return JSON.stringify(parsed) + const fixed = normalize(parsed, input.toolName) + if (Object.keys(fixed).length === 0) continue + return JSON.stringify(fixed) } } @@ -227,6 +247,7 @@ export namespace LLM { }) } const repaired = repairToolInput({ + toolName: failed.toolCall.toolName, toolInput: InvalidToolInputError.isInstance(failed.error) ? failed.error.toolInput : failed.toolCall.input, errorMessage: failed.error.message, }) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 67edc0ecfe35..a45c407d049a 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -203,15 +203,17 @@ export namespace SessionProcessor { case "tool-error": { const match = toolcalls[value.toolCallId] - if (match && match.state.status === "running") { + if (match && (match.state.status === "running" || match.state.status === "pending")) { + const start = match.state.status === "running" ? match.state.time.start : Date.now() + const input = value.input ?? match.state.input await Session.updatePart({ ...match, state: { status: "error", - input: value.input ?? match.state.input, + input, error: (value.error as any).toString(), time: { - start: match.state.time.start, + start, end: Date.now(), }, }, diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 9114fa7749a9..36f9439be553 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -102,6 +102,7 @@ describe("session.llm.hasToolCalls", () => { describe("session.llm.repairToolInput", () => { test("repairs truncated tool input json", () => { const repaired = LLM.repairToolInput({ + toolName: "write", toolInput: `{"filePath":"src/app.ts","content":"hello`, errorMessage: "Invalid input for tool write", }) @@ -114,6 +115,7 @@ describe("session.llm.repairToolInput", () => { test("repairs from error text when tool input is empty", () => { const repaired = LLM.repairToolInput({ + toolName: "write", toolInput: "", errorMessage: 'Invalid input for tool write: JSON parsing failed: Text: {"filePath":"src/app.ts","content":"hello"}', @@ -125,8 +127,22 @@ describe("session.llm.repairToolInput", () => { }) }) + test("normalizes filepath aliases for write tool", () => { + const repaired = LLM.repairToolInput({ + toolName: "write", + toolInput: '{"filepath":"src/app.ts","content":"hello"}', + errorMessage: + 'The write tool was called with invalid arguments: [{"expected":"string","code":"invalid_type","path":["filePath"],"message":"Invalid input: expected string, received undefined"}]', + }) + expect(repaired).toBeDefined() + const parsed = JSON.parse(repaired!) + expect(parsed.filePath).toBe("src/app.ts") + expect(parsed.content).toBe("hello") + }) + test("returns undefined when input is not json-like", () => { const repaired = LLM.repairToolInput({ + toolName: "write", toolInput: "not-json", errorMessage: "Invalid input for tool write", })