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
2 changes: 0 additions & 2 deletions packages/core/src/plugin/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}),
),
)
Expand Down
31 changes: 10 additions & 21 deletions packages/core/src/tool-output-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,6 @@ export interface BoundInput {
readonly output: ToolOutput
}

export interface BoundResult {
readonly output: ToolOutput
readonly outputPaths: ReadonlyArray<string>
}

export class StorageError extends Schema.TaggedErrorClass<StorageError>()("ToolOutputStore.StorageError", {
operation: Schema.Literals(["encode", "write"]),
cause: Schema.Defect(),
Expand All @@ -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<BoundResult, Error>
readonly bound: (input: BoundInput) => Effect.Effect<ToolOutput, Error>
readonly cleanup: () => Effect.Effect<void>
}

Expand Down Expand Up @@ -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,
],
}
})

Expand Down
1 change: 0 additions & 1 deletion packages/core/src/tool/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 0 additions & 1 deletion packages/core/src/tool/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ export interface AfterEvent {
readonly input: unknown
result: ToolResultValue
output?: ToolOutput
outputPaths?: ReadonlyArray<string>
}

export interface Interface {
Expand Down
16 changes: 3 additions & 13 deletions packages/core/src/tool/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@ export interface Materialization {
export interface Settlement {
readonly result: ToolResultValue
readonly output?: ToolOutput
readonly outputPaths?: ReadonlyArray<string>
readonly error?: SessionError.Error
}

Expand Down Expand Up @@ -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,
Expand All @@ -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 } : {}),
}
})
Expand Down
8 changes: 2 additions & 6 deletions packages/core/test/session-runner-tool-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
)
},
Expand Down Expand Up @@ -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)
}),
Expand Down
49 changes: 24 additions & 25 deletions packages/core/test/tool-output-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <A, E, R>(
body: (input: { root: string; store: ToolOutputStore.Interface; fs: FSUtil.Interface }) => Effect.Effect<A, E, R>,
config?: Config.Info,
Expand Down Expand Up @@ -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)
}),
),
)
Expand All @@ -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)
}),
),
)
Expand All @@ -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",
Expand All @@ -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)
}),
),
)
Expand All @@ -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)
}),
),
)
Expand All @@ -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)
}),
),
)
Expand Down Expand Up @@ -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 }) }),
),
Expand Down
1 change: 0 additions & 1 deletion packages/core/test/tool-read.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/docs/build/plugins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 0 additions & 1 deletion packages/plugin/src/v2/effect/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,6 @@ export interface ToolExecuteAfterEvent {
readonly input: unknown
result: ToolResultValue
output?: ToolOutput
outputPaths?: ReadonlyArray<string>
}

export interface RegisterOptions {
Expand Down
30 changes: 0 additions & 30 deletions packages/sdk/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -18577,12 +18577,6 @@
"$ref": "#/components/schemas/LLMToolContent"
}
},
"outputPaths": {
"type": "array",
"items": {
"type": "string"
}
},
"result": {},
"provider": {
"type": "object",
Expand Down Expand Up @@ -26400,12 +26394,6 @@
"$ref": "#/components/schemas/LLMToolContent"
}
},
"outputPaths": {
"type": "array",
"items": {
"type": "string"
}
},
"result": {},
"provider": {
"type": "object",
Expand Down Expand Up @@ -27526,12 +27514,6 @@
"$ref": "#/components/schemas/LLMToolContent"
}
},
"outputPaths": {
"type": "array",
"items": {
"type": "string"
}
},
"structured": {
"type": "object"
},
Expand Down Expand Up @@ -29006,12 +28988,6 @@
"$ref": "#/components/schemas/LLMToolContent"
}
},
"outputPaths": {
"type": "array",
"items": {
"type": "string"
}
},
"result": {},
"provider": {
"type": "object",
Expand Down Expand Up @@ -34876,12 +34852,6 @@
"$ref": "#/components/schemas/LLMToolContent"
}
},
"outputPaths": {
"type": "array",
"items": {
"type": "string"
}
},
"result": {},
"provider": {
"type": "object",
Expand Down
2 changes: 1 addition & 1 deletion packages/www/content/docs/build/plugins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 1 addition & 2 deletions specs/v2/schema-changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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

Expand Down
Loading