diff --git a/packages/opencode/src/tool/actor.ts b/packages/opencode/src/tool/actor.ts index eb67e87ae..376a35dc3 100644 --- a/packages/opencode/src/tool/actor.ts +++ b/packages/opencode/src/tool/actor.ts @@ -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).operation !== "string") return undefined + return recoverActorArgs(rawArgs) +} + export const ActorTool = Tool.define( id, Effect.gen(function* () { @@ -792,6 +798,7 @@ export const ActorTool = Tool.define( return { description: DESCRIPTION, parameters, + recover: recoverActorToolArgs, execute: (input: z.infer, ctx: Tool.Context) => run(input, ctx).pipe(Effect.orDie), shell: { description: SHELL_DESCRIPTION, diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 6db0673cc..b7bb82496 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -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).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). @@ -444,6 +450,7 @@ export const TaskTool = Tool.define, ctx: Tool.Context) => run(args, ctx).pipe(Effect.orDie), shell: { diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index c2a406e8d..6d0079d88 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -41,6 +41,10 @@ export interface Def, ctx: Context): Effect.Effect> 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 | undefined shell?: { description: string parse(script: string): Effect.Effect[], unknown> @@ -107,19 +111,25 @@ function wrap( ...(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 + const result = yield* execute(parsedArgs, ctx) if (result.metadata.truncated !== undefined) { return result } diff --git a/packages/opencode/test/tool/actor.test.ts b/packages/opencode/test/tool/actor.test.ts index 6b0d1c95d..509cc2b52 100644 --- a/packages/opencode/test/tool/actor.test.ts +++ b/packages/opencode/test/tool/actor.test.ts @@ -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[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)", () => { diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index 3aa710ea9..ec1356049 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -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[0], + ctx(sess.id), + ) + expect(result.metadata.count).toBe(1) + }), + ), + ) + it.live("set_status=done transitions task", () => provideTmpdirInstance(() => Effect.gen(function* () { diff --git a/packages/opencode/test/tool/tool-define.test.ts b/packages/opencode/test/tool/tool-define.test.ts index 00d1e039a..4d54cdcbf 100644 --- a/packages/opencode/test/tool/tool-define.test.ts +++ b/packages/opencode/test/tool/tool-define.test.ts @@ -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)) @@ -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 | 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 + if (typeof args.operation !== "string") return undefined + return { operation: JSON.parse(args.operation) as { action: "list" } } + }, + execute(args: z.infer) { + 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, + { + 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" } }) + }) })