refactor(core): remove output paths metadata

This commit is contained in:
Kit Langton
2026-07-19 12:19:46 +00:00
parent c50554d907
commit d6f36bb750
13 changed files with 42 additions and 105 deletions
-2
View File
@@ -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
}),
),
)
+10 -21
View File
@@ -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(),
@@ -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>
}
@@ -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,
],
}
})
-1
View File
@@ -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
View File
@@ -26,7 +26,6 @@ export interface AfterEvent {
readonly input: unknown
result: ToolResultValue
output?: ToolOutput
outputPaths?: ReadonlyArray<string>
}
export interface Interface {
+3 -13
View File
@@ -55,7 +55,6 @@ export interface Materialization {
export interface Settlement {
readonly result: ToolResultValue
readonly output?: ToolOutput
readonly outputPaths?: ReadonlyArray<string>
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 } : {}),
}
})
@@ -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)
}),
+24 -25
View File
@@ -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,
@@ -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 }) }),
),
-1
View File
@@ -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",
+1 -1
View File
@@ -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:
-1
View File
@@ -290,7 +290,6 @@ export interface ToolExecuteAfterEvent {
readonly input: unknown
result: ToolResultValue
output?: ToolOutput
outputPaths?: ReadonlyArray<string>
}
export interface RegisterOptions {
-30
View File
@@ -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",
+1 -1
View File
@@ -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:
+1 -2
View File
@@ -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