Skip to content
Open
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
7 changes: 7 additions & 0 deletions packages/opencode/src/tool/actor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,12 @@ export function recoverActorArgs(rawArgs: unknown): ActorShellArgs | undefined {
return undefined
}

function recoverActorToolArgs(rawArgs: unknown): ActorShellArgs | undefined {
if (rawArgs == null || typeof rawArgs !== "object") return undefined
if (typeof (rawArgs as Record<string, unknown>).operation !== "string") return undefined
return recoverActorArgs(rawArgs)
}

export const ActorTool = Tool.define(
id,
Effect.gen(function* () {
Expand Down Expand Up @@ -792,6 +798,7 @@ export const ActorTool = Tool.define(
return {
description: DESCRIPTION,
parameters,
recover: recoverActorToolArgs,
execute: (input: z.infer<typeof parameters>, ctx: Tool.Context) => run(input, ctx).pipe(Effect.orDie),
shell: {
description: SHELL_DESCRIPTION,
Expand Down
7 changes: 7 additions & 0 deletions packages/opencode/src/tool/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,12 @@ export function recoverTaskArgs(rawArgs: unknown): TaskOperation | undefined {
return undefined
}

function recoverTaskToolArgs(rawArgs: unknown): TaskOperation | undefined {
if (rawArgs == null || typeof rawArgs !== "object") return undefined
if (typeof (rawArgs as Record<string, unknown>).operation !== "string") return undefined
return recoverTaskArgs(rawArgs)
}

// Extract a fixed set of `--name value` / `--name=value` string flags and
// boolean presence flags from a verb's args, leaving positionals in `rest`.
// Synchronous (task's mapVerb is sync, unlike actor's Effect-returning extractor).
Expand Down Expand Up @@ -444,6 +450,7 @@ export const TaskTool = Tool.define<typeof parameters, Metadata, TaskRegistry.Se
return {
description: DESCRIPTION,
parameters,
recover: recoverTaskToolArgs,
execute: (args: z.infer<typeof parameters>, ctx: Tool.Context<Metadata>) =>
run(args, ctx).pipe(Effect.orDie),
shell: {
Expand Down
36 changes: 23 additions & 13 deletions packages/opencode/src/tool/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ export interface Def<Parameters extends z.ZodType = z.ZodType, M extends Metadat
parameters: Parameters
execute(args: z.infer<Parameters>, ctx: Context): Effect.Effect<ExecuteResult<M>>
formatValidationError?(error: z.ZodError): string
// Optional recovery for direct JSON tool calls that arrive in a known
// malformed shape. If the recovered value validates, execute receives the
// validated recovered args; otherwise the original validation error is shown.
recover?(rawArgs: unknown): z.infer<Parameters> | undefined
shell?: {
description: string
parse(script: string): Effect.Effect<z.infer<Parameters>[], unknown>
Expand Down Expand Up @@ -107,19 +111,25 @@ function wrap<Parameters extends z.ZodType, Result extends Metadata>(
...(ctx.callID ? { "tool.call_id": ctx.callID } : {}),
}
return Effect.gen(function* () {
yield* Effect.try({
try: () => toolInfo.parameters.parse(args),
catch: (error) => {
// Bad arguments are always agent-recoverable: the model sees the
// message and rewrites the call next turn. Mark it so the TUI
// renders it muted instead of alarming the user with a red block.
if (error instanceof z.ZodError && toolInfo.formatValidationError) {
return new RecoverableError(toolInfo.formatValidationError(error), { cause: error })
}
return new RecoverableError(validationErrorMessage(id, error), { cause: error })
},
})
const result = yield* execute(args, ctx)
const parsed = toolInfo.parameters.safeParse(args)
const recovered = parsed.success ? undefined : toolInfo.recover?.(args)
const recoveredParsed =
recovered === undefined ? undefined : toolInfo.parameters.safeParse(recovered)
if (!parsed.success && !recoveredParsed?.success) {
// Bad arguments are always agent-recoverable: the model sees the
// message and rewrites the call next turn. Mark it so the TUI
// renders it muted instead of alarming the user with a red block.
if (toolInfo.formatValidationError) {
return yield* Effect.fail(
new RecoverableError(toolInfo.formatValidationError(parsed.error), { cause: parsed.error }),
)
}
return yield* Effect.fail(
new RecoverableError(validationErrorMessage(id, parsed.error), { cause: parsed.error }),
)
}
const parsedArgs = (parsed.success ? parsed.data : recoveredParsed!.data) as z.infer<Parameters>
const result = yield* execute(parsedArgs, ctx)
if (result.metadata.truncated !== undefined) {
return result
}
Expand Down
31 changes: 31 additions & 0 deletions packages/opencode/test/tool/actor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,37 @@ describe("tool.actor", () => {
},
),
)

it.live("execute recovers a stringified operation envelope before validation", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
yield* installMockSpawn()
const { chat, assistant } = yield* seed()
const tool = yield* ActorTool
const def = yield* tool.init()

const result = yield* def.execute(
{
operation:
'{"action":"run","description":"inspect bug","prompt":"look into the cache key path","subagent_type":"general"}',
} as unknown as Parameters<typeof def.execute>[0],
{
sessionID: chat.id,
messageID: assistant.id,
agent: "build",
abort: new AbortController().signal,
extra: {},
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
},
)

expect(result.metadata.sessionId).toBe(chat.id)
expect(result.metadata.actorId).toBeDefined()
}),
),
)
})

describe("Actor tool subagent_type enum (F36)", () => {
Expand Down
18 changes: 18 additions & 0 deletions packages/opencode/test/tool/task.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,24 @@ describe("task tool", () => {
),
)

it.live("recovers a stringified operation envelope before validation", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const session = yield* Session.Service
const reg = yield* TaskRegistry.Service
const sess = yield* session.create({ title: "Test" })
yield* reg.create({ session_id: sess.id, summary: "a" })
const info = yield* TaskTool
const tool = yield* info.init()
const result = yield* tool.execute(
{ operation: '{"action":"list"}' } as unknown as Parameters<typeof tool.execute>[0],
ctx(sess.id),
)
expect(result.metadata.count).toBe(1)
}),
),
)

it.live("set_status=done transitions task", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
Expand Down
48 changes: 48 additions & 0 deletions packages/opencode/test/tool/tool-define.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import z from "zod"
import { Agent } from "../../src/agent/agent"
import { Tool } from "../../src/tool"
import { Truncate } from "../../src/tool"
import { MessageID, SessionID } from "../../src/session/schema"

const runtime = ManagedRuntime.make(Layer.mergeAll(Truncate.defaultLayer, Agent.defaultLayer))

Expand Down Expand Up @@ -56,4 +57,51 @@ describe("Tool.define", () => {

expect(first).not.toBe(second)
})

test("execute validates recovered args when direct JSON tool args use a stringified envelope", async () => {
const parameters = z.object({
operation: z.object({
action: z.literal("list"),
}),
})
let received: z.infer<typeof parameters> | undefined
const info = await runtime.runPromise(
Tool.define(
"recovering-tool",
Effect.succeed({
description: "test tool",
parameters,
recover(rawArgs: unknown) {
if (rawArgs == null || typeof rawArgs !== "object") return undefined
const args = rawArgs as Record<string, unknown>
if (typeof args.operation !== "string") return undefined
return { operation: JSON.parse(args.operation) as { action: "list" } }
},
execute(args: z.infer<typeof parameters>) {
received = args
return Effect.succeed({ title: "test", output: "ok", metadata: { truncated: false } })
},
}),
),
)
const def = await Effect.runPromise(info.init())

const result = await Effect.runPromise(
def.execute(
{ operation: '{"action":"list"}' } as unknown as z.infer<typeof parameters>,
{
sessionID: SessionID.descending(),
messageID: MessageID.ascending(),
agent: "build",
abort: new AbortController().signal,
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
},
),
)

expect(result.output).toBe("ok")
expect(received).toEqual({ operation: { action: "list" } })
})
})