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
62 changes: 53 additions & 9 deletions packages/opencode/src/session/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<StreamInput, "tools" | "agent" | "permission" | "user">) {
const disabled = Permission.disabled(
Expand All @@ -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<string, unknown>
}

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
}
}
19 changes: 19 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,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
Expand Down
Loading