mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-21 18:26:09 +00:00
refactor(core): remove output paths metadata
This commit is contained in:
@@ -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
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -26,7 +26,6 @@ export interface AfterEvent {
|
||||
readonly input: unknown
|
||||
result: ToolResultValue
|
||||
output?: ToolOutput
|
||||
outputPaths?: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
|
||||
@@ -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 }) }),
|
||||
),
|
||||
|
||||
@@ -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",
|
||||
|
||||
Vendored
+1
-1
@@ -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:
|
||||
|
||||
@@ -290,7 +290,6 @@ export interface ToolExecuteAfterEvent {
|
||||
readonly input: unknown
|
||||
result: ToolResultValue
|
||||
output?: ToolOutput
|
||||
outputPaths?: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
export interface RegisterOptions {
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user