diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index d38c29765ade..b255e81ff325 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -245,7 +245,7 @@ export namespace LLM { return { result: "", error: `Unknown tool: ${toolName}` } } try { - const result = await t.execute!(JSON.parse(argsJson), { + const result = await t.execute!(parseWorkflowToolArgs(argsJson), { toolCallId: _requestID, messages: input.messages, abortSignal: input.abort, @@ -422,14 +422,14 @@ export namespace LLM { export const layer = live.pipe(Layer.provide(Permission.defaultLayer)) - export const defaultLayer = Layer.suspend(() => - layer.pipe( - Layer.provide(Auth.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(Provider.defaultLayer), - Layer.provide(Plugin.defaultLayer), - ), - ) +export const defaultLayer = Layer.suspend(() => + layer.pipe( + Layer.provide(Auth.defaultLayer), + Layer.provide(Config.defaultLayer), + Layer.provide(Provider.defaultLayer), + Layer.provide(Plugin.defaultLayer), + ), +) function resolveTools(input: Pick) { const disabled = Permission.disabled( @@ -450,4 +450,48 @@ export namespace LLM { } return false } + + function parseCandidate(text: string) { + const parsed = JSON.parse(text) + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Tool arguments must be a JSON object") + } + return parsed as Record + } + + export function parseWorkflowToolArgs(input: string) { + const text = input.trim() + const rawErr = (() => { + try { + return parseCandidate(text) + } catch (err) { + return err + } + })() + if (!(rawErr instanceof Error)) return rawErr + + const fence = /```(?:json)?\s*([\s\S]*?)\s*```/gi + for (const match of text.matchAll(fence)) { + const body = match[1]?.trim() + if (!body) continue + try { + return parseCandidate(body) + } catch { + continue + } + } + + const first = text.indexOf("{") + const last = text.lastIndexOf("}") + if (first >= 0 && last > first) { + const body = text.slice(first, last + 1).trim() + try { + return parseCandidate(body) + } catch { + // no-op, throw below + } + } + + throw rawErr + } } diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 4d82096f3f9a..7e9f8c38418b 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -119,6 +119,25 @@ describe("session.llm.hasToolCalls", () => { }) }) +describe("session.llm.parseWorkflowToolArgs", () => { + test("parses strict json object", () => { + expect(LLM.parseWorkflowToolArgs('{"path":"a.txt"}')).toEqual({ path: "a.txt" }) + }) + + test("parses fenced json object", () => { + expect(LLM.parseWorkflowToolArgs('```json\n{"path":"a.txt"}\n```')).toEqual({ path: "a.txt" }) + }) + + test("parses json object wrapped in prose", () => { + const input = 'Use this args payload:\n```json\n{"path":"a.txt","old":"x","new":"y"}\n```\nThanks.' + expect(LLM.parseWorkflowToolArgs(input)).toEqual({ path: "a.txt", old: "x", new: "y" }) + }) + + test("rejects non-object json", () => { + expect(() => LLM.parseWorkflowToolArgs('[1,2,3]')).toThrow("Tool arguments must be a JSON object") + }) +}) + type Capture = { url: URL headers: Headers