Skip to content
Closed
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
81 changes: 78 additions & 3 deletions packages/opencode/src/session/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type ToolSet,
tool,
jsonSchema,
InvalidToolInputError,
} from "ai"
import { mergeDeep, pipe } from "remeda"
import { ProviderTransform } from "@/provider/transform"
Expand All @@ -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" })
Expand All @@ -43,6 +45,66 @@ export namespace LLM {

export type StreamOutput = StreamTextResult<ToolSet, unknown>

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<string, unknown>
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<string, unknown>
}

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)]
}

function str(input: unknown) {
if (typeof input !== "string") return
const value = input.trim()
if (!value) return
return value
}

function normalize(input: Record<string, unknown>, 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
const fixed = normalize(parsed, input.toolName)
if (Object.keys(fixed).length === 0) continue
return JSON.stringify(fixed)
}
}

export async function stream(input: StreamInput) {
const l = log
.clone()
Expand Down Expand Up @@ -177,14 +239,27 @@ 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({
toolName: failed.toolCall.toolName,
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 {
Expand Down
106 changes: 92 additions & 14 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<typeof networkError>) {
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(
Expand All @@ -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,
Expand Down
8 changes: 5 additions & 3 deletions packages/opencode/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
},
},
Expand Down
8 changes: 8 additions & 0 deletions packages/opencode/src/session/retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
51 changes: 51 additions & 0 deletions packages/opencode/test/session/llm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,57 @@ 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",
})
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({
toolName: "write",
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("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",
})
expect(repaired).toBeUndefined()
})
})

type Capture = {
url: URL
headers: Headers
Expand Down
19 changes: 19 additions & 0 deletions packages/opencode/test/session/retry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -172,6 +177,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",
Expand Down
Loading