diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index eb977dc811..fce8ee9cda 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -398,14 +398,12 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int input: event.input, result: event.result, output: event.output, - outputPaths: event.outputPaths, } return Reflect.apply(callback, undefined, [output]).pipe( Effect.tap(() => Effect.sync(() => { event.result = output.result event.output = output.output - event.outputPaths = output.outputPaths }), ), ) diff --git a/packages/core/src/tool-output-store.ts b/packages/core/src/tool-output-store.ts index 057c6b4c68..5a900c071c 100644 --- a/packages/core/src/tool-output-store.ts +++ b/packages/core/src/tool-output-store.ts @@ -22,11 +22,6 @@ export interface BoundInput { readonly output: ToolOutput } -export interface BoundResult { - readonly output: ToolOutput - readonly outputPaths: ReadonlyArray -} - export class StorageError extends Schema.TaggedErrorClass()("ToolOutputStore.StorageError", { operation: Schema.Literals(["encode", "write"]), cause: Schema.Defect(), @@ -41,7 +36,7 @@ export type Error = StorageError export interface Interface { readonly limits: () => Effect.Effect<{ readonly maxLines: number; readonly maxBytes: number }> - readonly bound: (input: BoundInput) => Effect.Effect + readonly bound: (input: BoundInput) => Effect.Effect readonly cleanup: () => Effect.Effect } @@ -150,26 +145,20 @@ const layer = Layer.effect( lineCount(contextual) <= outputLimits.maxLines && Buffer.byteLength(contextual, "utf-8") <= outputLimits.maxBytes ) - return { - output: input.output, - outputPaths: [], - } + return input.output const outputPath = yield* write(contextual) const marker = `... output truncated; full content saved to ${outputPath} ...` return { - output: { - structured: input.output.structured, - content: [ - { - type: "text" as const, - text: boundedPreview(contextual, marker, outputLimits.maxLines, outputLimits.maxBytes), - }, - ...media, - ], - }, - outputPaths: [outputPath], + structured: input.output.structured, + content: [ + { + type: "text" as const, + text: boundedPreview(contextual, marker, outputLimits.maxLines, outputLimits.maxBytes), + }, + ...media, + ], } }) diff --git a/packages/core/src/tool/AGENTS.md b/packages/core/src/tool/AGENTS.md index beb838a567..29b2d93fee 100644 --- a/packages/core/src/tool/AGENTS.md +++ b/packages/core/src/tool/AGENTS.md @@ -55,4 +55,3 @@ Producer capture limits are separate. For example, Bash keeps `AppProcess.maxOut ## Current Gaps - MCP and future Session-scoped registrations still need an explicit canonical registration design. -- The public Session result shape currently exposes managed `outputPaths`; full storage encapsulation requires a future opaque managed-output reference design. diff --git a/packages/core/src/tool/hooks.ts b/packages/core/src/tool/hooks.ts index b6dba58ffb..06049c8d5b 100644 --- a/packages/core/src/tool/hooks.ts +++ b/packages/core/src/tool/hooks.ts @@ -26,7 +26,6 @@ export interface AfterEvent { readonly input: unknown result: ToolResultValue output?: ToolOutput - outputPaths?: ReadonlyArray } export interface Interface { diff --git a/packages/core/src/tool/registry.ts b/packages/core/src/tool/registry.ts index 844e4896db..6e827f1815 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -55,7 +55,6 @@ export interface Materialization { export interface Settlement { readonly result: ToolResultValue readonly output?: ToolOutput - readonly outputPaths?: ReadonlyArray readonly error?: SessionError.Error } @@ -157,20 +156,13 @@ const registryLayer = Layer.effect( if ("result" in pending) { settlement = pending } else { - const bounded = yield* resources.bound({ + const output = yield* resources.bound({ sessionID: input.sessionID, callID: input.call.id, output: { structured: pending.output.structured, content: yield* normalizeImages(pending.output.content) }, }) - const result = ToolOutput.toResultValue(bounded.output) - settlement = - result.type === "error" - ? bounded.outputPaths.length > 0 - ? { result, outputPaths: bounded.outputPaths } - : { result } - : bounded.outputPaths.length > 0 - ? { result, output: bounded.output, outputPaths: bounded.outputPaths } - : { result, output: bounded.output } + const result = ToolOutput.toResultValue(output) + settlement = result.type === "error" ? { result } : { result, output } } const afterEvent: ToolHooks.AfterEvent = { tool: input.call.name, @@ -181,13 +173,11 @@ const registryLayer = Layer.effect( input: beforeEvent.input, result: settlement.result, output: settlement.output, - outputPaths: settlement.outputPaths, } yield* toolHooks.runAfter(afterEvent) return { result: afterEvent.result, ...(afterEvent.output !== undefined ? { output: afterEvent.output } : {}), - ...(afterEvent.outputPaths !== undefined ? { outputPaths: afterEvent.outputPaths } : {}), ...(settlement.error !== undefined ? { error: settlement.error } : {}), } }) diff --git a/packages/core/test/session-runner-tool-registry.test.ts b/packages/core/test/session-runner-tool-registry.test.ts index 6f7b13f6e1..322adc099d 100644 --- a/packages/core/test/session-runner-tool-registry.test.ts +++ b/packages/core/test/session-runner-tool-registry.test.ts @@ -20,11 +20,8 @@ const outputStore = Layer.mock(ToolOutputStore.Service, { return Effect.sync(() => bounds.push(input)).pipe( Effect.as( input.callID === "call-bounded" - ? { - output: { structured: {}, content: [{ type: "text" as const, text: "bounded reference" }] }, - outputPaths: ["/managed/generic"], - } - : { output: input.output, outputPaths: [] }, + ? { structured: {}, content: [{ type: "text" as const, text: "bounded reference" }] } + : input.output, ), ) }, @@ -282,7 +279,6 @@ describe("ToolRegistry", () => { ).toEqual({ result: { type: "text", value: "bounded reference" }, output: { structured: {}, content: [{ type: "text", text: "bounded reference" }] }, - outputPaths: ["/managed/generic"], }) expect(bounds).toHaveLength(1) }), diff --git a/packages/core/test/tool-output-store.test.ts b/packages/core/test/tool-output-store.test.ts index 4de63dfdcd..2082216e58 100644 --- a/packages/core/test/tool-output-store.test.ts +++ b/packages/core/test/tool-output-store.test.ts @@ -9,11 +9,19 @@ import { Config } from "@opencode-ai/core/config" import { ConfigToolOutput } from "@opencode-ai/core/config/tool-output" import { SessionV2 } from "@opencode-ai/core/session" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" +import type { ToolOutput } from "@opencode-ai/ai" import { testEffect } from "./lib/effect" import { tmpdir } from "./fixture/tmpdir" const sessionID = SessionV2.ID.make("ses_tool_output_store") +const retainedPath = (output: ToolOutput) => { + const text = output.content.find((item) => item.type === "text")?.text + const match = text && /full content saved to (.+) \.\.\./.exec(text) + if (!match) throw new Error("expected managed output path in bounded content") + return match[1] +} + const withStore = ( body: (input: { root: string; store: ToolOutputStore.Interface; fs: FSUtil.Interface }) => Effect.Effect, config?: Config.Info, @@ -61,11 +69,10 @@ describe("ToolOutputStore", () => { ], }, }) - expect(result.output.structured).toEqual({ kind: "report" }) - expect(result.outputPaths).toHaveLength(1) - expect(yield* fs.readFileString(result.outputPaths[0])).toBe(first + second) - if (result.output.content[0]?.type !== "text") throw new Error("expected text preview") - expect(Buffer.byteLength(result.output.content[0].text)).toBeLessThanOrEqual(ToolOutputStore.MAX_BYTES) + expect(result.structured).toEqual({ kind: "report" }) + expect(yield* fs.readFileString(retainedPath(result))).toBe(first + second) + if (result.content[0]?.type !== "text") throw new Error("expected text preview") + expect(Buffer.byteLength(result.content[0].text)).toBeLessThanOrEqual(ToolOutputStore.MAX_BYTES) }), ), ) @@ -75,10 +82,9 @@ describe("ToolOutputStore", () => { Effect.gen(function* () { const structured = { text: "x".repeat(ToolOutputStore.MAX_BYTES) } const result = yield* store.bound({ sessionID, callID: "call-json", output: { structured, content: [] } }) - expect(result.output.structured).toEqual(structured) - expect(result.outputPaths).toHaveLength(1) - expect(JSON.parse(yield* fs.readFileString(result.outputPaths[0]))).toEqual(structured) - expect(result.output.content).toHaveLength(1) + expect(result.structured).toEqual(structured) + expect(JSON.parse(yield* fs.readFileString(retainedPath(result)))).toEqual(structured) + expect(result.content).toHaveLength(1) }), ), ) @@ -95,10 +101,9 @@ describe("ToolOutputStore", () => { content: [{ type: "file", uri: `data:image/png;base64,${data}`, mime: "image/png", name: "pixel.png" }], }, }) - expect(result.outputPaths).toEqual([]) - expect(result.output.structured).toEqual({ caption: "pixel" }) - expect(result.output.content).toHaveLength(1) - expect(result.output.content[0]).toEqual({ + expect(result.structured).toEqual({ caption: "pixel" }) + expect(result.content).toHaveLength(1) + expect(result.content[0]).toEqual({ type: "file", uri: `data:image/png;base64,${data}`, mime: "image/png", @@ -124,9 +129,9 @@ describe("ToolOutputStore", () => { output: { structured: { caption: "pixel" }, content: [{ type: "text", text }, media] }, }) - expect(result.output.structured).toEqual({ caption: "pixel" }) - expect(result.output.content[1]).toEqual(media) - expect(yield* fs.readFileString(result.outputPaths[0])).toBe(text) + expect(result.structured).toEqual({ caption: "pixel" }) + expect(result.content[1]).toEqual(media) + expect(yield* fs.readFileString(retainedPath(result))).toBe(text) }), ), ) @@ -136,10 +141,7 @@ describe("ToolOutputStore", () => { Effect.gen(function* () { const text = "x".repeat(30_000) const output = { structured: { output: text }, content: [{ type: "text" as const, text }] } - expect(yield* store.bound({ sessionID, callID: "call-duplicated", output })).toEqual({ - output, - outputPaths: [], - }) + expect(yield* store.bound({ sessionID, callID: "call-duplicated", output })).toEqual(output) }), ), ) @@ -166,10 +168,7 @@ describe("ToolOutputStore", () => { withStore(({ store }) => Effect.gen(function* () { const output = { structured: { value: 1n }, content: [{ type: "text" as const, text: "readable text" }] } - expect(yield* store.bound({ sessionID, callID: "call-unencodable", output })).toEqual({ - output, - outputPaths: [], - }) + expect(yield* store.bound({ sessionID, callID: "call-unencodable", output })).toEqual(output) }), ), ) @@ -219,7 +218,7 @@ describe("ToolOutputStore", () => { callID: "call-config", output: { structured: {}, content: [{ type: "text", text: "one\ntwo\nthree" }] }, }) - expect(result.outputPaths).toHaveLength(1) + expect(retainedPath(result)).toContain("tool-output") }), new Config.Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }) }), ), diff --git a/packages/core/test/tool-read.test.ts b/packages/core/test/tool-read.test.ts index 6437e3f128..51843cad4f 100644 --- a/packages/core/test/tool-read.test.ts +++ b/packages/core/test/tool-read.test.ts @@ -325,7 +325,6 @@ describe("ReadTool", () => { call: { type: "tool-call", id: "call-large-image", name: "read", input: { path: "large.png" } }, }) - expect(settled.outputPaths).toBeUndefined() expect(settled.output?.structured).toMatchObject({ uri: "file:///large.png", name: "large.png", diff --git a/packages/docs/build/plugins.mdx b/packages/docs/build/plugins.mdx index 01a8b5746b..8f31a82d4b 100644 --- a/packages/docs/build/plugins.mdx +++ b/packages/docs/build/plugins.mdx @@ -248,7 +248,7 @@ mutable fields: | `ctx.aisdk.hook("language", callback)` | `language`, after inspecting `model`, `sdk`, and `options` | | `ctx.session.hook("request", callback)` | `system`, `messages`, and the `tools` record immediately before model dispatch | | `ctx.tool.hook("execute.before", callback)` | `input`, before the selected tool executes | -| `ctx.tool.hook("execute.after", callback)` | `result`, `output`, and `outputPaths`, after execution settles | +| `ctx.tool.hook("execute.after", callback)` | `result` and `output`, after execution settles | For example, remove a tool from selected model requests and normalize another tool's input: diff --git a/packages/plugin/src/v2/effect/tool.ts b/packages/plugin/src/v2/effect/tool.ts index 879e5badef..1aa5aaea82 100644 --- a/packages/plugin/src/v2/effect/tool.ts +++ b/packages/plugin/src/v2/effect/tool.ts @@ -290,7 +290,6 @@ export interface ToolExecuteAfterEvent { readonly input: unknown result: ToolResultValue output?: ToolOutput - outputPaths?: ReadonlyArray } export interface RegisterOptions { diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 04ff287bf8..ccdef761b5 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -18577,12 +18577,6 @@ "$ref": "#/components/schemas/LLMToolContent" } }, - "outputPaths": { - "type": "array", - "items": { - "type": "string" - } - }, "result": {}, "provider": { "type": "object", @@ -26400,12 +26394,6 @@ "$ref": "#/components/schemas/LLMToolContent" } }, - "outputPaths": { - "type": "array", - "items": { - "type": "string" - } - }, "result": {}, "provider": { "type": "object", @@ -27526,12 +27514,6 @@ "$ref": "#/components/schemas/LLMToolContent" } }, - "outputPaths": { - "type": "array", - "items": { - "type": "string" - } - }, "structured": { "type": "object" }, @@ -29006,12 +28988,6 @@ "$ref": "#/components/schemas/LLMToolContent" } }, - "outputPaths": { - "type": "array", - "items": { - "type": "string" - } - }, "result": {}, "provider": { "type": "object", @@ -34876,12 +34852,6 @@ "$ref": "#/components/schemas/LLMToolContent" } }, - "outputPaths": { - "type": "array", - "items": { - "type": "string" - } - }, "result": {}, "provider": { "type": "object", diff --git a/packages/www/content/docs/build/plugins.mdx b/packages/www/content/docs/build/plugins.mdx index e5adbc92d7..fa9efbc6ca 100644 --- a/packages/www/content/docs/build/plugins.mdx +++ b/packages/www/content/docs/build/plugins.mdx @@ -248,7 +248,7 @@ mutable fields: | `ctx.aisdk.hook("language", callback)` | `language`, after inspecting `model`, `sdk`, and `options` | | `ctx.session.hook("request", callback)` | `system`, `messages`, and the `tools` record immediately before model dispatch | | `ctx.tool.hook("execute.before", callback)` | `input`, before the selected tool executes | -| `ctx.tool.hook("execute.after", callback)` | `result`, `output`, and `outputPaths`, after execution settles | +| `ctx.tool.hook("execute.after", callback)` | `result` and `output`, after execution settles | For example, remove a tool from selected model requests and normalize another tool's input: diff --git a/specs/v2/schema-changelog.md b/specs/v2/schema-changelog.md index 3e760ad00d..eb4ae8261d 100644 --- a/specs/v2/schema-changelog.md +++ b/specs/v2/schema-changelog.md @@ -312,7 +312,6 @@ Compatibility: Affected schema: -- New optional managed `outputPath` and `outputPaths` fields on tool results and completed Session tool state. - Absolute managed output paths accepted by ordinary `read` and `grep` inputs. Change: @@ -327,7 +326,7 @@ Reason: Compatibility: -- Managed output is retained for a bounded period and exposed as a normal host filesystem path. +- Managed output is retained for a bounded period and exposed in bounded tool content as a normal host filesystem path. ### Location-Scoped Filesystem Read And Search Contracts