From 8f7c6c33d933649a0ae617416b5d1561d032b4cb Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 4 Jul 2026 17:09:51 +0000 Subject: [PATCH] refactor(plugin): return tool output directly --- packages/core/src/tool/AGENTS.md | 6 +- packages/core/src/tool/apply-patch.ts | 9 ++- packages/core/src/tool/edit.ts | 10 +-- packages/core/src/tool/glob.ts | 22 +++--- packages/core/src/tool/grep.ts | 25 ++++--- packages/core/src/tool/question.ts | 8 +-- packages/core/src/tool/read.ts | 29 +++++--- packages/core/src/tool/shell.ts | 55 +++++++------- packages/core/src/tool/skill.ts | 7 +- packages/core/src/tool/subagent.ts | 16 +++-- packages/core/src/tool/todowrite.ts | 7 +- packages/core/src/tool/webfetch.ts | 9 ++- packages/core/src/tool/websearch.ts | 9 ++- packages/core/src/tool/write.ts | 9 ++- packages/core/test/plugin.test.ts | 5 +- .../test/session-runner-tool-registry.test.ts | 29 +++++--- packages/core/test/session-runner.test.ts | 11 +-- packages/plugin/src/v2/effect/README.md | 30 ++++++++ packages/plugin/src/v2/effect/tool.ts | 71 ++++++------------- packages/sdk-next/test/embedded.test.ts | 2 +- specs/v2/tools.md | 38 +++++----- 21 files changed, 235 insertions(+), 172 deletions(-) diff --git a/packages/core/src/tool/AGENTS.md b/packages/core/src/tool/AGENTS.md index 3719322fa1..80e04a9c94 100644 --- a/packages/core/src/tool/AGENTS.md +++ b/packages/core/src/tool/AGENTS.md @@ -4,7 +4,7 @@ This folder owns Core's one local tool representation, process and Location regi ## Representations -- `tool.ts` defines the opaque canonical `Tool.make({ description, input, output, execute, toModelOutput })` value. Shipped built-ins and plugin tools use the same type. +- `tool.ts` re-exports the opaque canonical `Tool.make({ description, input, output, execute })` value. Shipped built-ins and plugin tools use the same type. - `tools.ts` exposes the registration-only `Tools.Service` view used by Location producers. - `registry.ts` stores only canonical Location registrations, derives definitions, invokes tools, and applies generic output bounding. @@ -12,7 +12,7 @@ Do not add a second executable entry type, registry-owned executor, authorizatio ## Construction -Tool schemas and projection use `input` and `output` terminology. A tool value is opaque: its codecs, executor, definition derivation, and catalog permission declaration are private runtime details. +Tool schemas use `input` and `output` terminology. Executors return `{ structured, content }` directly. A tool value is opaque: its codecs, executor, definition derivation, and catalog permission declaration are private runtime details. Location-scoped built-in layers acquire `PermissionV2.Service` and every other required Location service while the layer is constructed. The executor captures those services. Permission sources are always constructed from the canonical invocation context: @@ -46,7 +46,7 @@ Definition filtering is catalog visibility, not execution authorization. A call ## Output -Built-ins return complete validated domain output. `ToolRegistry.Materialization.settle` is the only execution and generic model-output bounding boundary and owns managed retention paths. +Built-ins return complete structured output and model content together. `ToolRegistry.Materialization.settle` validates and encodes the structured value, then applies generic model-output bounding and owns managed retention paths. Producer capture limits are separate. For example, Bash keeps `AppProcess.maxOutputBytes` and accurately reports stdout/stderr capture loss, but it does not run model-output truncation or return a managed `outputPath`. diff --git a/packages/core/src/tool/apply-patch.ts b/packages/core/src/tool/apply-patch.ts index 2f481d8a0d..5d1784ab7f 100644 --- a/packages/core/src/tool/apply-patch.ts +++ b/packages/core/src/tool/apply-patch.ts @@ -70,7 +70,6 @@ export const Plugin = { "Apply one patch containing add, update, and delete file operations. All targets are resolved and approved before target contents are read. Operations apply sequentially; if a later operation fails, earlier operations remain applied and the failure reports them explicitly. Moves and atomic rollback are not supported yet.", input: Input, output: Output, - toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }], execute: (input, context) => { const applied: Array = [] const fail = (path: string) => { @@ -184,7 +183,13 @@ export const Plugin = { { discard: true }, ) return { applied, files: patchFiles } - }).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch")))) + }).pipe( + Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch"))), + Effect.map((structured) => ({ + structured, + content: [{ type: "text", text: toModelOutput(structured) }], + })), + ) }, }), "edit", diff --git a/packages/core/src/tool/edit.ts b/packages/core/src/tool/edit.ts index 17ff28cfc1..618d3f2ccf 100644 --- a/packages/core/src/tool/edit.ts +++ b/packages/core/src/tool/edit.ts @@ -101,9 +101,6 @@ export const Plugin = { "Replace exact text in one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.", input: Input, output: Output, - toModelOutput: ({ input, output }) => [ - { type: "text", text: toModelOutput(output, input.oldString, input.newString) }, - ], execute: (input, context) => { const unableToEdit = (effect: Effect.Effect) => effect.pipe( @@ -204,7 +201,12 @@ export const Plugin = { ], replacements, } satisfies Output - }) + }).pipe( + Effect.map((structured) => ({ + structured, + content: [{ type: "text", text: toModelOutput(structured, input.oldString, input.newString) }], + })), + ) }, }), "edit", diff --git a/packages/core/src/tool/glob.ts b/packages/core/src/tool/glob.ts index dcea52120f..416b9ed096 100644 --- a/packages/core/src/tool/glob.ts +++ b/packages/core/src/tool/glob.ts @@ -49,14 +49,6 @@ export const Plugin = { "Find files by glob pattern within the active Location. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.", input: Input, output: Output, - toModelOutput: ({ output }) => [ - { - type: "text", - text: toModelOutput( - output.map((entry) => ({ ...entry, path: path.resolve(location.directory, entry.path) })), - ), - }, - ], execute: (input, context) => Effect.gen(function* () { yield* permission.assert({ @@ -102,6 +94,20 @@ export const Plugin = { ? error : new ToolFailure({ message: `Unable to find files matching ${input.pattern}` }), ), + Effect.map((structured) => ({ + structured, + content: [ + { + type: "text", + text: toModelOutput( + structured.map((entry) => ({ + ...entry, + path: path.resolve(location.directory, entry.path), + })), + ), + }, + ], + })), ), }), }) diff --git a/packages/core/src/tool/grep.ts b/packages/core/src/tool/grep.ts index 35ac537c91..021e8e2734 100644 --- a/packages/core/src/tool/grep.ts +++ b/packages/core/src/tool/grep.ts @@ -63,17 +63,6 @@ export const Plugin = { "Search file contents by regular expression within the active Location or an absolute managed tool-output file. Use a path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise file resources, line numbers, and bounded line previews.", input: Input, output: Output, - toModelOutput: ({ output }) => [ - { - type: "text", - text: toModelOutput( - output.map((match) => ({ - ...match, - entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) }, - })), - ), - }, - ], execute: (input, context) => Effect.gen(function* () { yield* permission.assert({ @@ -133,6 +122,20 @@ export const Plugin = { ? error : new ToolFailure({ message: `Unable to grep for ${input.pattern}` }), ), + Effect.map((structured) => ({ + structured, + content: [ + { + type: "text", + text: toModelOutput( + structured.map((match) => ({ + ...match, + entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) }, + })), + ), + }, + ], + })), ), }), }) diff --git a/packages/core/src/tool/question.ts b/packages/core/src/tool/question.ts index 218edb57e9..8c1c4955e1 100644 --- a/packages/core/src/tool/question.ts +++ b/packages/core/src/tool/question.ts @@ -54,9 +54,6 @@ export const Plugin = { description, input: Input, output: Output, - toModelOutput: ({ input, output }) => [ - { type: "text", text: toModelOutput(input.questions, output.answers) }, - ], execute: (input, context) => permission .assert({ @@ -77,7 +74,10 @@ export const Plugin = { }) .pipe(Effect.orDie), ), - Effect.map((answers) => ({ answers })), + Effect.map((answers) => ({ + structured: { answers }, + content: [{ type: "text", text: toModelOutput(input.questions, answers) }], + })), ), }), }) diff --git a/packages/core/src/tool/read.ts b/packages/core/src/tool/read.ts index 16e229d97d..3eb45ebd89 100644 --- a/packages/core/src/tool/read.ts +++ b/packages/core/src/tool/read.ts @@ -48,14 +48,6 @@ export const Plugin = { "Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page. Relative paths resolve from the current location; absolute paths inside it are accepted, while external absolute paths require external_directory approval.", input: Input, output: Output, - toModelOutput: ({ input, output }) => { - if (!("encoding" in output) || output.encoding !== "base64" || !SUPPORTED_IMAGE_MIMES.has(output.mime)) - return [] - return [ - { type: "text", text: "Image read successfully" }, - { type: "file", data: output.content, mime: output.mime, name: input.path }, - ] - }, execute: (input, context) => { return Effect.gen(function* () { const source = { @@ -106,7 +98,9 @@ export const Plugin = { start: type === "directory" ? resolved : dirname(resolved), stop: root, }) - const candidates = (yield* Effect.forEach(discovered, fs.resolve)).filter((file) => dirname(file) !== root) + const candidates = (yield* Effect.forEach(discovered, fs.resolve)).filter( + (file) => dirname(file) !== root, + ) if (candidates.length === 0) return yield* sessionInstructions.load({ sessionID: context.sessionID, paths: candidates }) }).pipe( @@ -132,6 +126,23 @@ export const Plugin = { : `Unable to read ${input.path}` return new ToolFailure({ message }) }), + Effect.map((structured) => ({ + structured, + content: + "encoding" in structured && + structured.encoding === "base64" && + SUPPORTED_IMAGE_MIMES.has(structured.mime) + ? [ + { type: "text" as const, text: "Image read successfully" }, + { + type: "file" as const, + data: structured.content, + mime: structured.mime, + name: input.path, + }, + ] + : [], + })), ) }, }), diff --git a/packages/core/src/tool/shell.ts b/packages/core/src/tool/shell.ts index c3644e4810..706f2176b0 100644 --- a/packages/core/src/tool/shell.ts +++ b/packages/core/src/tool/shell.ts @@ -45,21 +45,17 @@ const StructuredOutput = Schema.Struct({ timeout: Schema.Boolean.pipe(Schema.optional), }) -const Output = Schema.Struct({ - ...StructuredOutput.fields, - output: Schema.String, - status: Schema.Literals(["completed", "running"]).pipe(Schema.optional), - warnings: Schema.Array(Schema.String).pipe(Schema.optional), -}) +type Result = typeof StructuredOutput.Type & { + readonly output: string + readonly status?: "completed" | "running" + readonly warnings?: ReadonlyArray +} -type Output = typeof Output.Type - -const modelOutput = (output: Output): string | undefined => { +const modelOutput = (output: Result): string | undefined => { const warnings = output.warnings?.length ? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}` : "" - if (output.status === "running") - return `${warnings.trimStart()}${warnings ? "\n\n" : ""}${BACKGROUND_INSTRUCTION}` + if (output.status === "running") return `${warnings.trimStart()}${warnings ? "\n\n" : ""}${BACKGROUND_INSTRUCTION}` if (output.timeout) return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command timed out before completion.` return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command exited with code ${output.exit}.` } @@ -144,20 +140,7 @@ export const Plugin = { [name]: Tool.make({ description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. Timeout values are milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows. Background mode (background=true) launches the command asynchronously and returns immediately; you are notified when it finishes.`, input: Input, - output: Output, - structured: StructuredOutput, - toStructuredOutput: ({ output }) => ({ - truncated: output.truncated, - ...(output.exit === undefined ? {} : { exit: output.exit }), - ...(output.shellID === undefined ? {} : { shellID: output.shellID }), - ...(output.timeout === undefined ? {} : { timeout: output.timeout }), - }), - toModelOutput: ({ output }) => { - const parts: Content[] = [{ type: "text", text: output.output }] - const model = modelOutput(output) - if (model) parts.push({ type: "text", text: model }) - return parts - }, + output: StructuredOutput, execute: (input, context) => Effect.gen(function* () { const source = { @@ -247,9 +230,9 @@ export const Plugin = { } } - const result = yield* runtime.job.block({ id: job.id, sessionID: context.sessionID }).pipe( - Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)), - ) + const result = yield* runtime.job + .block({ id: job.id, sessionID: context.sessionID }) + .pipe(Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore))) if (result?.type === "backgrounded") { yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command) return { @@ -260,14 +243,26 @@ export const Plugin = { ...(warnings.length ? { warnings } : {}), } } - if (result?.info.status === "error") return yield* Effect.fail(new Error(result.info.error ?? "Command failed")) + if (result?.info.status === "error") + return yield* Effect.fail(new Error(result.info.error ?? "Command failed")) if (result?.info.status === "cancelled") return yield* Effect.fail(new Error("Command cancelled")) return { ...(yield* settleShell()), ...(warnings.length ? { warnings } : {}), } - }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` }))), + }).pipe( + Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` })), + Effect.map((result) => { + const content: Content[] = [{ type: "text", text: result.output }] + const model = modelOutput(result) + if (model) content.push({ type: "text", text: model }) + return { + structured: result, + content, + } + }), + ), }), }) .pipe(Effect.orDie) diff --git a/packages/core/src/tool/skill.ts b/packages/core/src/tool/skill.ts index 1c5d23a047..715c531978 100644 --- a/packages/core/src/tool/skill.ts +++ b/packages/core/src/tool/skill.ts @@ -64,7 +64,6 @@ export const Plugin = { description, input: Input, output: Output, - toModelOutput: ({ output }) => [{ type: "text", text: output.output }], execute: (input, context) => Effect.gen(function* () { const current = yield* skills.list() @@ -87,11 +86,15 @@ export const Plugin = { .toSorted() .slice(0, FILE_LIMIT) : [] - return { + const structured = { name: skill.name, directory, output: toModelOutput(skill, files), } + return { + structured, + content: [{ type: "text" as const, text: structured.output }], + } }).pipe(Effect.mapError((error) => unableToLoad(input.name, error))) }), }), diff --git a/packages/core/src/tool/subagent.ts b/packages/core/src/tool/subagent.ts index 517d1ce65f..fdd9b731e3 100644 --- a/packages/core/src/tool/subagent.ts +++ b/packages/core/src/tool/subagent.ts @@ -92,13 +92,17 @@ export const Plugin = { ) }) + const output = (structured: typeof Output.Type) => ({ + structured, + content: [{ type: "text" as const, text: structured.output }], + }) + yield* ctx.tool .register({ [name]: Tool.make({ description, input: Input, output: Output, - toModelOutput: ({ output }) => [{ type: "text", text: output.output }], execute: (input, context) => Effect.gen(function* () { const parent = yield* runtime.session @@ -146,7 +150,7 @@ export const Plugin = { if (background) { yield* runtime.job.background(info.id) yield* notifyWhenDone(context.sessionID, child.id, input.description) - return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED } + return output({ sessionID: child.id, status: "running", output: BACKGROUND_STARTED }) } const result = yield* runtime.job.block({ id: child.id, sessionID: context.sessionID }).pipe( @@ -158,12 +162,16 @@ export const Plugin = { ) if (result?.type === "backgrounded") { yield* notifyWhenDone(context.sessionID, child.id, input.description) - return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED } + return output({ sessionID: child.id, status: "running", output: BACKGROUND_STARTED }) } if (result?.info.status === "error") return yield* new ToolFailure({ message: result.info.error ?? "Subagent failed" }) if (result?.info.status === "cancelled") return yield* new ToolFailure({ message: "Subagent cancelled" }) - return { sessionID: child.id, status: "completed" as const, output: result?.info.output ?? NO_TEXT } + return output({ + sessionID: child.id, + status: "completed", + output: result?.info.output ?? NO_TEXT, + }) }), }), }) diff --git a/packages/core/src/tool/todowrite.ts b/packages/core/src/tool/todowrite.ts index 5f34ebd5aa..93c17639b6 100644 --- a/packages/core/src/tool/todowrite.ts +++ b/packages/core/src/tool/todowrite.ts @@ -33,7 +33,6 @@ export const Plugin = { "Create and maintain a structured task list for the current coding session. Use it to track progress during multi-step work and keep todo statuses current.", input: Input, output: Output, - toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }], execute: (input, context) => Effect.gen(function* () { yield* permission.assert({ @@ -45,7 +44,11 @@ export const Plugin = { source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, }) yield* todos.update({ sessionID: context.sessionID, todos: input.todos }) - return { todos: input.todos } + const structured = { todos: input.todos } + return { + structured, + content: [{ type: "text" as const, text: toModelOutput(structured) }], + } }).pipe(Effect.mapError(() => new ToolFailure({ message: "Unable to update todos" }))), }), }) diff --git a/packages/core/src/tool/webfetch.ts b/packages/core/src/tool/webfetch.ts index efac4a2a75..978aeea1ac 100644 --- a/packages/core/src/tool/webfetch.ts +++ b/packages/core/src/tool/webfetch.ts @@ -124,7 +124,6 @@ export const Plugin = { description, input: Input, output: Output, - toModelOutput: ({ output }) => [{ type: "text", text: output.output }], execute: (input, context) => Effect.gen(function* () { yield* Effect.try({ @@ -170,7 +169,13 @@ export const Plugin = { format: input.format, output, } - }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to fetch ${input.url}` }))), + }).pipe( + Effect.mapError(() => new ToolFailure({ message: `Unable to fetch ${input.url}` })), + Effect.map((structured) => ({ + structured, + content: [{ type: "text", text: structured.output }], + })), + ), }), }) .pipe(Effect.orDie) diff --git a/packages/core/src/tool/websearch.ts b/packages/core/src/tool/websearch.ts index 80a2422cee..51c847aa7c 100644 --- a/packages/core/src/tool/websearch.ts +++ b/packages/core/src/tool/websearch.ts @@ -200,7 +200,6 @@ export const Plugin = { description, input: Input, output: Output, - toModelOutput: ({ output }) => [{ type: "text", text: output.text }], execute: (input, context) => { const provider = selectProvider(context.sessionID, config, config.provider) return Effect.gen(function* () { @@ -243,7 +242,13 @@ export const Plugin = { provider, text: text ?? NO_RESULTS, } - }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to search the web for ${input.query}` }))) + }).pipe( + Effect.mapError(() => new ToolFailure({ message: `Unable to search the web for ${input.query}` })), + Effect.map((structured) => ({ + structured, + content: [{ type: "text", text: structured.text }], + })), + ) }, }), }) diff --git a/packages/core/src/tool/write.ts b/packages/core/src/tool/write.ts index 73885b2187..67867fa97c 100644 --- a/packages/core/src/tool/write.ts +++ b/packages/core/src/tool/write.ts @@ -57,7 +57,6 @@ export const Plugin = { "Write content to one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.", input: Input, output: Output, - toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }], execute: (input, context) => Effect.gen(function* () { const source = { @@ -83,7 +82,13 @@ export const Plugin = { source, }) return yield* files.writeTextPreservingBom({ target, content: input.content }) - }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to write ${input.path}` }))), + }).pipe( + Effect.mapError(() => new ToolFailure({ message: `Unable to write ${input.path}` })), + Effect.map((structured) => ({ + structured, + content: [{ type: "text", text: toModelOutput(structured) }], + })), + ), }), "edit", ), diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index 42f2a768ec..7e7cb22b76 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -108,7 +108,7 @@ describe("PluginV2", () => { description: "Plugin tool", input: Schema.Struct({}), output: Schema.Struct({ ok: Schema.Boolean }), - execute: () => Effect.succeed({ ok: true }), + execute: () => Effect.succeed({ structured: { ok: true }, content: [] }), }), }) .pipe(Effect.orDie), @@ -146,7 +146,8 @@ describe("PluginV2", () => { description: "Echo", input: Schema.Struct({ text: Schema.String }), output: Schema.Struct({ text: Schema.String }), - execute: ({ text }) => Effect.sync(() => executed.push({ text })).pipe(Effect.as({ text })), + execute: ({ text }) => + Effect.sync(() => executed.push({ text })).pipe(Effect.as({ structured: { text }, content: [] })), }), }) .pipe(Effect.orDie) diff --git a/packages/core/test/session-runner-tool-registry.test.ts b/packages/core/test/session-runner-tool-registry.test.ts index 3c03094b5e..99c3e35be1 100644 --- a/packages/core/test/session-runner-tool-registry.test.ts +++ b/packages/core/test/session-runner-tool-registry.test.ts @@ -46,8 +46,7 @@ const make = (permission?: string) => { description: "Echo text", input: Schema.Struct({ text: Schema.String }), output: Schema.Struct({ text: Schema.String }), - execute: ({ text }) => Effect.succeed({ text }), - toModelOutput: ({ output }) => [{ type: "text", text: output.text }], + execute: ({ text }) => Effect.succeed({ structured: { text }, content: [{ type: "text" as const, text }] }), }) return permission ? Tool.withPermission(tool, permission) : tool } @@ -244,7 +243,8 @@ describe("ToolRegistry", () => { description: "Context", input: Schema.Struct({}), output: Schema.Struct({ ok: Schema.Boolean }), - execute: (_, context) => Effect.sync(() => contexts.push(context)).pipe(Effect.as({ ok: true })), + execute: (_, context) => + Effect.sync(() => contexts.push(context)).pipe(Effect.as({ structured: { ok: true }, content: [] })), }), }) yield* executeTool(service, { @@ -276,7 +276,7 @@ describe("ToolRegistry", () => { }), ) - it.effect("enforces transformed codecs at execution and projection boundaries", () => + it.effect("validates structured output while preserving executor-provided model content", () => Effect.gen(function* () { const service = yield* ToolRegistry.Service const executed: string[] = [] @@ -291,18 +291,23 @@ describe("ToolRegistry", () => { description: "Transform values", input: Schema.Struct({ value: Transformed }), output: Schema.Struct({ value: Transformed }), - execute: ({ value }) => Effect.sync(() => executed.push(value)).pipe(Effect.as({ value })), - toModelOutput: ({ output }) => [{ type: "text", text: String(output.value) }], + execute: ({ value }) => + Effect.sync(() => executed.push(value)).pipe( + Effect.as({ structured: { value }, content: [{ type: "text" as const, text: value }] }), + ), }), }) expect( - yield* executeTool(service, { + yield* settleTool(service, { sessionID, ...identity, call: { type: "tool-call", id: "transformed", name: "transformed", input: { value: true } }, }), - ).toEqual({ type: "text", value: "true" }) + ).toEqual({ + result: { type: "text", value: "yes" }, + output: { structured: { value: true }, content: [{ type: "text", text: "yes" }] }, + }) expect(executed).toEqual(["yes"]) expect( yield* executeTool(service, { @@ -329,7 +334,7 @@ describe("ToolRegistry", () => { }), ), }), - execute: () => Effect.succeed({ value: "invalid" }), + execute: () => Effect.succeed({ structured: { value: "invalid" }, content: [] }), }), }) expect( @@ -411,8 +416,10 @@ describe("ToolRegistry", () => { input: Schema.Struct({ text: Schema.String }), output: Schema.Struct({ text: Schema.String }), execute: ({ text }) => - Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release)), Effect.as({ text })), - toModelOutput: ({ output }) => [{ type: "text", text: output.text }], + Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as({ structured: { text }, content: [{ type: "text" as const, text }] }), + ), }), }) .pipe(Scope.provide(scope)) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 6856b6a7ce..ec74d5ff66 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -135,7 +135,6 @@ const echo = Layer.effectDiscard( description: "Echo text", input: Schema.Struct({ text: Schema.String }), output: Schema.Struct({ text: Schema.String }), - toModelOutput: ({ output }) => [{ type: "text", text: output.text }], execute: ({ text }, context) => Effect.gen(function* () { authorizations.push(context) @@ -146,7 +145,7 @@ const echo = Layer.effectDiscard( yield* Deferred.succeed(toolExecutionsStarted, undefined) } if (toolExecutionGate) yield* Deferred.await(toolExecutionGate) - return { text } + return { structured: { text }, content: [{ type: "text" as const, text }] } }).pipe(Effect.ensuring(Effect.sync(() => activeToolExecutions--))), }), defect: Tool.make({ @@ -161,7 +160,7 @@ const echo = Layer.effectDiscard( description: "Produce output that cannot be persisted", input: Schema.Struct({}), output: Schema.Any, - execute: () => Effect.succeed({ big: 1n }), + execute: () => Effect.succeed({ structured: { big: 1n }, content: [] }), }), }), ), @@ -608,7 +607,7 @@ describe("SessionRunnerLLM", () => { execute: ({ query }, context) => Effect.sync(() => { contexts.push(context) - return { answer: query.toUpperCase() } + return { structured: { answer: query.toUpperCase() }, content: [] } }), }), }) @@ -2834,7 +2833,9 @@ describe("SessionRunnerLLM", () => { input: Schema.Struct({}), output: Schema.Struct({}), execute: (_, context) => - questions.ask({ sessionID: context.sessionID, questions: [] }).pipe(Effect.as({}), Effect.orDie), + questions + .ask({ sessionID: context.sessionID, questions: [] }) + .pipe(Effect.as({ structured: {}, content: [] }), Effect.orDie), }), }) yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Ask then stop" }), resume: false }) diff --git a/packages/plugin/src/v2/effect/README.md b/packages/plugin/src/v2/effect/README.md index 3da1d7b566..6501201c07 100644 --- a/packages/plugin/src/v2/effect/README.md +++ b/packages/plugin/src/v2/effect/README.md @@ -31,6 +31,36 @@ Configuration supplied for the plugin is available as `ctx.options`. Registrations are owned by the plugin scope. Closing the scope removes them automatically; a registration may also be removed early through `dispose`. +## Tools + +Plugins register named tools through `ctx.tool`. The executor returns validated structured output and model-facing content together. + +```ts +import { define, Tool } from "@opencode-ai/plugin/v2/effect" +import { Effect, Schema } from "effect" + +export const Plugin = define({ + id: "greeting", + effect: (ctx) => + ctx.tool + .register({ + greet: Tool.make({ + description: "Greet someone", + input: Schema.Struct({ name: Schema.String }), + output: Schema.Struct({ message: Schema.String }), + execute: ({ name }) => { + const message = `Hello ${name}` + return Effect.succeed({ + structured: { message }, + content: [{ type: "text", text: message }], + }) + }, + }), + }) + .pipe(Effect.orDie), +}) +``` + ## Transform Hooks Transform hooks contribute to stateful domains: diff --git a/packages/plugin/src/v2/effect/tool.ts b/packages/plugin/src/v2/effect/tool.ts index c25f79c58b..38b013dab6 100644 --- a/packages/plugin/src/v2/effect/tool.ts +++ b/packages/plugin/src/v2/effect/tool.ts @@ -38,32 +38,19 @@ export type Content = | { readonly type: "text"; readonly text: string } | { readonly type: "file"; readonly data: string; readonly mime: string; readonly name?: string } -type Config< - Input extends SchemaType, - Output extends SchemaType, - Structured extends SchemaType = Output, -> = { +export type Result = { + readonly structured: Structured + readonly content: ReadonlyArray +} + +type Config, OutputSchema extends SchemaType> = { readonly description: string readonly input: Input - readonly output: Output - readonly structured?: Structured - readonly toStructuredOutput?: (input: { - readonly input: Schema.Schema.Type - readonly output: Output["Encoded"] - }) => Schema.Schema.Type + readonly output: OutputSchema readonly execute: ( input: Schema.Schema.Type, context: Context, - ) => Effect.Effect, ToolFailure> - readonly toModelOutput?: (input: { - readonly input: Schema.Schema.Type - readonly output: Output["Encoded"] - }) => ReadonlyArray -} - -export type DynamicOutput = { - readonly structured: unknown - readonly content: ReadonlyArray + ) => Effect.Effect>, ToolFailure> } /** @@ -75,7 +62,7 @@ type DynamicConfig = { readonly description: string readonly jsonSchema: JsonSchema.JsonSchema readonly outputSchema?: JsonSchema.JsonSchema - readonly execute: (input: unknown, context: Context) => Effect.Effect + readonly execute: (input: unknown, context: Context) => Effect.Effect } type Runtime = { @@ -86,23 +73,19 @@ type Runtime = { const runtimes = new WeakMap() -export function make< - Input extends SchemaType, - Output extends SchemaType, - Structured extends SchemaType = Output, ->(config: Config): Definition +export function make, OutputSchema extends SchemaType>( + config: Config, +): Definition export function make(config: DynamicConfig): AnyTool -export function make(config: Config | DynamicConfig): AnyTool { +export function make(config: Config | DynamicConfig): AnyTool { if ("jsonSchema" in config) return makeDynamic(config) return makeTyped(config) } -function makeTyped< - Input extends SchemaType, - Output extends SchemaType, - Structured extends SchemaType = Output, ->(config: Config): Definition { - const tool = Object.freeze({}) as Definition +function makeTyped, OutputSchema extends SchemaType>( + config: Config, +): Definition { + const tool = Object.freeze({}) as Definition const definitions = new Map() runtimes.set(tool, { definition: (name) => { @@ -112,7 +95,7 @@ function makeTyped< name, description: config.description, inputSchema: toJsonSchema(config.input), - outputSchema: toJsonSchema(config.structured ?? config.output), + outputSchema: toJsonSchema(config.output), }) definitions.set(name, definition) return definition @@ -123,14 +106,8 @@ function makeTyped< Effect.flatMap((input) => config.execute(input, context).pipe( Effect.flatMap((output) => - Schema.encodeEffect(config.output)(output).pipe( - Effect.flatMap((output) => { - if (!config.structured || !config.toStructuredOutput) - return Effect.succeed({ output, structured: output }) - return Schema.encodeEffect(config.structured)(config.toStructuredOutput({ input, output })).pipe( - Effect.map((structured) => ({ output, structured })), - ) - }), + Schema.encodeEffect(config.output)(output.structured).pipe( + Effect.map((structured) => ToolOutput.make(structured, output.content.map(toModelContent))), Effect.mapError( (error) => new ToolFailure({ @@ -139,12 +116,6 @@ function makeTyped< ), ), ), - Effect.map(({ output, structured }) => ({ - structured, - content: - config.toModelOutput?.({ input, output }).map(toModelContent) ?? - (typeof output === "string" ? [{ type: "text" as const, text: output }] : []), - })), ), ), ), @@ -171,7 +142,7 @@ function makeDynamic(config: DynamicConfig): AnyTool { settle: (call, context) => config .execute(call.input, context) - .pipe(Effect.map((output) => ({ structured: output.structured, content: output.content.map(toModelContent) }))), + .pipe(Effect.map((output) => ToolOutput.make(output.structured, output.content.map(toModelContent)))), }) return tool } diff --git a/packages/sdk-next/test/embedded.test.ts b/packages/sdk-next/test/embedded.test.ts index 4ac31b70ab..4a9d662bdd 100644 --- a/packages/sdk-next/test/embedded.test.ts +++ b/packages/sdk-next/test/embedded.test.ts @@ -47,7 +47,7 @@ it.live( description: "Embedded test tool", input: Schema.Struct({}), output: Schema.Struct({ ok: Schema.Boolean }), - execute: () => Effect.succeed({ ok: true }), + execute: () => Effect.succeed({ structured: { ok: true }, content: [] }), }), }) .pipe(Effect.orDie), diff --git a/specs/v2/tools.md b/specs/v2/tools.md index 6be14d2f7b..b8d4b7af20 100644 --- a/specs/v2/tools.md +++ b/specs/v2/tools.md @@ -7,30 +7,30 @@ V2 has one opaque type for locally executable tools: ```ts type Definition type AnyTool = Definition +type Result = { + readonly structured: Structured + readonly content: ReadonlyArray +} const make: < Input extends Schema.Codec, - Output extends Schema.Codec, + OutputSchema extends Schema.Codec, >(config: { readonly description: string readonly input: Input - readonly output: Output + readonly output: OutputSchema readonly execute: ( input: Schema.Type, context: Tool.Context, - ) => Effect.Effect, ToolFailure> - readonly toModelOutput?: (input: { - readonly input: Schema.Type - readonly output: Output["Encoded"] - }) => ReadonlyArray -}) => Definition + ) => Effect.Effect>, ToolFailure> +}) => Definition ``` Application tools, built-ins, and statically authored plugin tools use this same constructor and execution contract. `Tool.Definition` is opaque and has exactly one executor. Its schemas and executor are not public fields. The Tool module privately derives model definitions and interprets invocations for the registry; callers normally rely on `Tool.make` inference rather than naming the carrier type. -Input and output codecs are self-contained. Schema conversion cannot require services. Tool dependencies are acquired during construction and captured by `execute`. +Input and structured-output codecs are self-contained. Schema conversion cannot require services. Tool dependencies are acquired during construction and captured by `execute`. ## Invocation Context @@ -122,7 +122,11 @@ yield * metadata: { root: root.resource }, }) - return yield* filesystem.grep(input, root) + const structured = yield* filesystem.grep(input, root) + return { + structured, + content: [{ type: "text", text: formatMatches(structured) }], + } }).pipe(/* translate expected typed errors to ToolFailure */), }), }) @@ -139,22 +143,20 @@ The Location-scoped registry owns effective lookup and settlement. For each loca 1. Resolves one effective named registration. 2. Decodes provider input with the input codec. 3. Invokes the tool with the runner-supplied context. -4. Encodes the returned output with the output codec. -5. Projects encoded output into model-facing content. +4. Encodes the returned `structured` value with the output codec. +5. Preserves the executor-provided model-facing `content`. 6. Bounds the complete model-facing output. 7. Returns the settlement and managed-output references to the runner, which persists them durably. -Invalid input never invokes the tool. Invalid output never produces a successful settlement. - -`toModelOutput` is pure and total. When omitted, the encoded output remains structured output; an encoded string is also projected as text. Projection does not receive invocation identity because presentation depends only on validated input and output. +Invalid input never invokes the tool. Invalid structured output never produces a successful settlement. Step materialization captures the effective registration identity for each advertised name without retaining its handler. Settlement rejects the call as stale if that registration was removed or replaced, including when closing an overlay reveals the previously effective registration. The current handler is captured only after this check; removing or replacing its registration afterward does not affect the running invocation. ## Output Bounding -Tools return complete validated domain output. They do not truncate model-facing output or manage retention files. +Tools return complete structured output and model-facing content together. Settlement validates the structured value; tools do not truncate model-facing output or manage retention files. -After projection, one generic settlement boundary bounds the channel actually sent to the provider. When content exists, only its textual parts are measured; structured metadata is retained unchanged without being double-counted, and native media remains unchanged under producer-owned limits. When content is empty, the structured output is measured. Oversized provider-facing text or structured output is retained in managed storage and replaced with a bounded text preview while structured metadata and media are preserved; if complete retention fails, settlement fails operationally rather than publishing lossy success. Managed paths never appear in `Tool.make`, tool output schemas, or projection callbacks solely for retention bookkeeping. +One generic settlement boundary bounds the channel actually sent to the provider. When content exists, only its textual parts are measured; structured metadata is retained unchanged without being double-counted, and native media remains unchanged under producer-owned limits. When content is empty, the structured output is measured. Oversized provider-facing text or structured output is retained in managed storage and replaced with a bounded text preview while structured metadata and media are preserved; if complete retention fails, settlement fails operationally rather than publishing lossy success. Managed paths never appear in `Tool.make`, tool output schemas, or executors solely for retention bookkeeping. Model-output bounding is not producer memory management. Processes and streaming sources may need separate capture or spooling limits before a tool result exists. Those limits must be modeled at the producer boundary and must not masquerade as model-output truncation. A producer cannot claim a complete retained output after it has already discarded bytes. @@ -172,7 +174,7 @@ Leaf tools translate only errors they deliberately classify as recoverable. Broa ## Laws - **Single executor:** `Tool.make(config)` can invoke only `config.execute`. -- **Codec boundary:** execution observes decoded input; projection observes encoded output. +- **Codec boundary:** execution observes decoded input and returns decoded structured output; settlement validates and encodes that structured output. - **Durable identity:** invocation-owned records use the exact Session, agent, assistant message, and call IDs supplied by the runner. - **Scoped registration:** closing a Scope removes exactly its registration and reveals any prior active overlay. - **Captured execution:** registration changes cannot alter an invocation after effective lookup.