fix(core): bound tool structured output

This commit is contained in:
Kit Langton 2026-07-20 13:09:57 -04:00
commit 2deaa7786c
17 changed files with 440 additions and 165 deletions

View file

@ -3,6 +3,7 @@ import { Tool } from "@opencode-ai/core/tool/tool"
import { AgentV2 } from "@opencode-ai/core/agent"
import type { PermissionV2 } from "@opencode-ai/core/permission"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Global } from "@opencode-ai/core/global"
import { Image } from "@opencode-ai/core/image"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
@ -11,6 +12,7 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { executeTool, settleTool, toolDefinitions } from "./lib/tool"
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, SchemaGetter, SchemaIssue, Scope } from "effect"
import { testEffect } from "./lib/effect"
import { tmpdir } from "./fixture/tmpdir"
const bounds: ToolOutputStore.BoundInput[] = []
const retentionFailure = new ToolOutputStore.StorageError({ operation: "write", cause: new Error("disk full") })
@ -52,6 +54,7 @@ const registryLayer = AppNodeBuilder.build(ToolRegistry.node, [
[Image.node, imageStore],
])
const it = testEffect(registryLayer)
const live = testEffect(Layer.empty)
const identity = {
agent: AgentV2.ID.make("build"),
messageID: SessionMessage.ID.make("msg_registry"),
@ -84,6 +87,52 @@ const constant = (text: string) =>
})
describe("ToolRegistry", () => {
live.live("bounds structured-only output before deriving the model result", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
const layer = AppNodeBuilder.build(ToolRegistry.node, [
[Global.node, Global.layerWith({ data: tmp.path })],
[Image.node, imageStore],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
])
return Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const text = "x".repeat(ToolOutputStore.MAX_STRUCTURED_BYTES)
yield* service.register(
{
structured_only: Tool.make({
description: "Return structured output",
input: Schema.Struct({}),
output: Schema.Struct({ text: Schema.String }),
execute: () => Effect.succeed({ text }),
}),
},
{ codemode: false },
)
const settled = yield* settleTool(service, {
sessionID,
...identity,
call: { type: "tool-call", id: "call-structured-only", name: "structured_only", input: {} },
})
const encoded = JSON.stringify({ text })
expect(settled.result).toEqual({ type: "text", value: encoded })
expect(settled.outputPaths).toHaveLength(1)
expect(settled.output).toEqual({
structured: {
_truncated: true,
_bytes: Buffer.byteLength(encoded),
_outputPath: settled.outputPaths?.[0],
},
content: [{ type: "text", text: encoded }],
})
}).pipe(Effect.provide(layer))
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.effect("rejects invalid dotted namespaces", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
@ -113,12 +162,15 @@ describe("ToolRegistry", () => {
it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({
question: make(),
bash: make(),
edit: make("edit"),
write: make("edit"),
}, { codemode: false })
yield* service.register(
{
question: make(),
bash: make(),
edit: make("edit"),
write: make("edit"),
},
{ codemode: false },
)
const names = (permissions: PermissionV2.Ruleset) =>
toolDefinitions(service, permissions).pipe(Effect.map((definitions) => definitions.map((tool) => tool.name)))
@ -191,14 +243,17 @@ describe("ToolRegistry", () => {
it.effect("returns model errors without swallowing interruption or defects", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({
failed: Tool.make({
description: "Failed",
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.fail(new Tool.Failure({ message: "Denied" })),
}),
}, { codemode: false })
yield* service.register(
{
failed: Tool.make({
description: "Failed",
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.fail(new Tool.Failure({ message: "Denied" })),
}),
},
{ codemode: false },
)
expect(
yield* executeTool(service, {
sessionID,
@ -214,14 +269,17 @@ describe("ToolRegistry", () => {
}),
).toEqual({ type: "error", value: "Unknown tool: missing" })
yield* service.register({
defect: Tool.make({
description: "Defect",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: () => Effect.die("unexpected executor defect"),
}),
}, { codemode: false })
yield* service.register(
{
defect: Tool.make({
description: "Defect",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: () => Effect.die("unexpected executor defect"),
}),
},
{ codemode: false },
)
expect(
yield* service.materialize().pipe(
Effect.flatMap((materialized) =>
@ -264,22 +322,23 @@ describe("ToolRegistry", () => {
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const contexts: Tool.Context[] = []
yield* service.register({
context: Tool.make({
description: "Context",
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: (_, context) => Effect.sync(() => contexts.push(context)).pipe(Effect.as({ ok: true })),
}),
}, { codemode: false })
yield* service.register(
{
context: Tool.make({
description: "Context",
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: (_, context) => Effect.sync(() => contexts.push(context)).pipe(Effect.as({ ok: true })),
}),
},
{ codemode: false },
)
yield* executeTool(service, {
sessionID,
...identity,
call: { type: "tool-call", id: "call-context", name: "context", input: {} },
})
expect(contexts).toEqual([
{ sessionID, ...identity, callID: "call-context", progress: expect.any(Function) },
])
expect(contexts).toEqual([{ sessionID, ...identity, callID: "call-context", progress: expect.any(Function) }])
}),
)
@ -306,20 +365,23 @@ describe("ToolRegistry", () => {
it.effect("normalizes image tool output at settlement and drops unresizable images", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({
snapshot: Tool.make({
description: "Return images",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: ({ text }) => Effect.succeed({ text }),
toModelOutput: ({ output }) => [
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "frame.png" },
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "too-large.png" },
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "corrupt.png" },
{ type: "text", text: output.text },
],
}),
}, { codemode: false })
yield* service.register(
{
snapshot: Tool.make({
description: "Return images",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: ({ text }) => Effect.succeed({ text }),
toModelOutput: ({ output }) => [
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "frame.png" },
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "too-large.png" },
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "corrupt.png" },
{ type: "text", text: output.text },
],
}),
},
{ codemode: false },
)
const settlement = yield* settleTool(service, call("snapshot"))
expect(settlement.output?.content).toEqual([
@ -334,23 +396,26 @@ describe("ToolRegistry", () => {
it.effect("normalizes image progress content before it is published", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({
progressive: Tool.make({
description: "Emit image progress",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: ({ text }, context) =>
context
.progress({
structured: { stage: "capture" },
content: [
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "frame.png" },
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "too-large.png" },
],
})
.pipe(Effect.as({ text })),
}),
}, { codemode: false })
yield* service.register(
{
progressive: Tool.make({
description: "Emit image progress",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: ({ text }, context) =>
context
.progress({
structured: { stage: "capture" },
content: [
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "frame.png" },
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "too-large.png" },
],
})
.pipe(Effect.as({ text })),
}),
},
{ codemode: false },
)
const updates: ToolRegistry.Progress[] = []
yield* settleTool(service, {
@ -382,15 +447,18 @@ describe("ToolRegistry", () => {
encode: SchemaGetter.transform((value) => value === "yes"),
}),
)
yield* service.register({
transformed: Tool.make({
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) }],
}),
}, { codemode: false })
yield* service.register(
{
transformed: Tool.make({
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) }],
}),
},
{ codemode: false },
)
expect(
yield* executeTool(service, {
@ -409,25 +477,28 @@ describe("ToolRegistry", () => {
).toMatchObject({ type: "error", value: expect.stringContaining("Invalid tool input") })
expect(executed).toEqual(["yes"])
yield* service.register({
invalid_output: Tool.make({
description: "Return invalid output",
input: Schema.Struct({}),
output: Schema.Struct({
value: Schema.Boolean.pipe(
Schema.decodeTo(Schema.String, {
decode: SchemaGetter.transform((value) => String(value)),
encode: SchemaGetter.transformOrFail((value) =>
value === "valid"
? Effect.succeed(true)
: Effect.fail(new SchemaIssue.InvalidValue(Option.some(value), { message: "invalid output" })),
),
}),
),
yield* service.register(
{
invalid_output: Tool.make({
description: "Return invalid output",
input: Schema.Struct({}),
output: Schema.Struct({
value: Schema.Boolean.pipe(
Schema.decodeTo(Schema.String, {
decode: SchemaGetter.transform((value) => String(value)),
encode: SchemaGetter.transformOrFail((value) =>
value === "valid"
? Effect.succeed(true)
: Effect.fail(new SchemaIssue.InvalidValue(Option.some(value), { message: "invalid output" })),
),
}),
),
}),
execute: () => Effect.succeed({ value: "invalid" }),
}),
execute: () => Effect.succeed({ value: "invalid" }),
}),
}, { codemode: false })
},
{ codemode: false },
)
expect(
yield* executeTool(service, {
sessionID,

View file

@ -158,10 +158,10 @@ describe("EditTool", () => {
status: "modified",
additions: 1,
deletions: 1,
patch: expect.stringContaining("-before\n+after"),
},
],
})
expect(settled.output?.structured).not.toHaveProperty("files.0.patch")
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\nrest\n")
expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["hello.txt"], save: ["*"] }])
expect(writes).toEqual([yield* Effect.promise(() => fs.realpath(target))])
@ -366,6 +366,7 @@ describe("EditTool", () => {
Effect.andThen((settled) =>
Effect.gen(function* () {
expect(settled.output?.structured).toMatchObject({ replacements: 3 })
expect(settled.output?.structured).not.toHaveProperty("files.0.patch")
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after after after")
expect(writes).toHaveLength(1)
}),

View file

@ -75,7 +75,11 @@ 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.output.structured).toEqual({
_truncated: true,
_bytes: Buffer.byteLength(JSON.stringify(structured)),
_outputPath: result.outputPaths[0],
})
expect(result.outputPaths).toHaveLength(1)
expect(JSON.parse(yield* fs.readFileString(result.outputPaths[0]))).toEqual(structured)
expect(result.output.content).toHaveLength(1)
@ -83,6 +87,44 @@ describe("ToolOutputStore", () => {
),
)
it.live("projects guarded structured-only output into the bounded content channel", () =>
withStore(({ store, fs }) =>
Effect.gen(function* () {
const structured = { text: "x".repeat(ToolOutputStore.MAX_STRUCTURED_BYTES) }
const result = yield* store.bound({
sessionID,
callID: "call-structured-content",
output: { structured, content: [] },
})
expect(result.output.structured).toEqual({
_truncated: true,
_bytes: Buffer.byteLength(JSON.stringify(structured)),
_outputPath: result.outputPaths[0],
})
expect(result.output.content).toEqual([{ type: "text", text: JSON.stringify(structured) }])
expect(JSON.parse(yield* fs.readFileString(result.outputPaths[0]))).toEqual(structured)
}),
),
)
it.live("keeps structured output at the receipt boundary inline", () =>
withStore(({ store }) =>
Effect.gen(function* () {
const empty = JSON.stringify({ text: "" })
const structured = { text: "x".repeat(ToolOutputStore.MAX_STRUCTURED_BYTES - Buffer.byteLength(empty)) }
expect(Buffer.byteLength(JSON.stringify(structured))).toBe(ToolOutputStore.MAX_STRUCTURED_BYTES)
const result = yield* store.bound({
sessionID,
callID: "call-structured-boundary",
output: { structured, content: [] },
})
expect(result).toEqual({ output: { structured, content: [] }, outputPaths: [] })
}),
),
)
it.live("preserves native media and structured metadata without applying a settlement media limit", () =>
withStore(({ store }) =>
Effect.gen(function* () {
@ -131,15 +173,35 @@ describe("ToolOutputStore", () => {
),
)
it.live("does not double-count structured data duplicated in projected text", () =>
it.live("marks receipt metadata truncated when bounding its contextual output", () =>
withStore(({ store }) =>
Effect.gen(function* () {
const result = yield* store.bound({
sessionID,
callID: "call-receipt",
output: {
structured: { bytes: ToolOutputStore.MAX_BYTES + 1, truncated: false },
content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }],
},
})
expect(result.output.structured).toEqual({ bytes: ToolOutputStore.MAX_BYTES + 1, truncated: true })
}),
),
)
it.live("bounds structured data duplicated in projected text independently", () =>
withStore(({ store, fs }) =>
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: [],
const result = yield* store.bound({ sessionID, callID: "call-duplicated", output })
expect(result.output.content).toEqual(output.content)
expect(result.output.structured).toEqual({
_truncated: true,
_bytes: Buffer.byteLength(JSON.stringify(output.structured)),
_outputPath: result.outputPaths[0],
})
expect(JSON.parse(yield* fs.readFileString(result.outputPaths[0]))).toEqual(output.structured)
}),
),
)
@ -162,14 +224,17 @@ describe("ToolOutputStore", () => {
),
)
it.live("does not encode ignored structured metadata when projected content exists", () =>
it.live("rejects unencodable structured metadata even when projected content exists", () =>
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: [],
})
const exit = yield* store.bound({ sessionID, callID: "call-unencodable", output }).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit))
expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))).toMatchObject({
_tag: "ToolOutputStore.StorageError",
operation: "encode",
})
}),
),
)

View file

@ -181,24 +181,24 @@ describe("PatchTool", () => {
status: "added",
additions: 1,
deletions: 0,
patch: expect.stringContaining("+created"),
},
{
file: "update.txt",
status: "modified",
additions: 1,
deletions: 1,
patch: expect.stringContaining("-before\n+after"),
},
{
file: "remove.txt",
status: "deleted",
additions: 0,
deletions: 1,
patch: expect.stringContaining("-remove"),
},
],
})
expect(settled.output?.structured).not.toHaveProperty("files.0.patch")
expect(settled.output?.structured).not.toHaveProperty("files.1.patch")
expect(settled.output?.structured).not.toHaveProperty("files.2.patch")
expect(assertions).toMatchObject([
{ sessionID, action: "edit", resources: ["nested/new.txt", "update.txt", "remove.txt"], save: ["*"] },
])

View file

@ -206,14 +206,14 @@ describe("ReadTool", () => {
call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } },
}),
).toEqual({
type: "json",
value: {
type: "text",
value: JSON.stringify({
uri: "file:///README.md",
name: "README.md",
content: "hello",
encoding: "utf8",
mime: "text/plain",
},
}),
})
expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["README.md"], save: ["*"] }])
expect(readCalls).toEqual([
@ -236,7 +236,7 @@ describe("ReadTool", () => {
...toolIdentity,
call: { type: "tool-call", id: "call-external-read", name: "read", input: { path: external } },
}),
).toMatchObject({ type: "json" })
).toMatchObject({ type: "text" })
expect(assertions).toMatchObject([
{
sessionID,
@ -287,14 +287,15 @@ describe("ReadTool", () => {
call: { type: "tool-call", id: "call-image-settle", name: "read", input: { path: "pixel.png" } },
})
expect(settled.output?.structured).toMatchObject({
type: "file",
uri: "file:///pixel.png",
name: "pixel.png",
mime: "image/png",
encoding: "base64",
// Image base64 is carried by the content file item only; structured is slimmed
// so the original bytes are never persisted twice.
content: "",
bytes: Buffer.byteLength(png, "base64"),
truncated: false,
})
expect(settled.output?.structured).not.toHaveProperty("content")
expect(settled.output?.content).toMatchObject([
{ type: "text", text: "Image read successfully" },
{ type: "file", mime: "image/png", uri: `data:image/png;base64,${png}` },
@ -626,7 +627,7 @@ describe("ReadTool", () => {
input: { path: "src", offset: 2, limit: 10 },
},
}),
).toEqual({ type: "json", value: { entries: [], truncated: false } })
).toEqual({ type: "text", value: JSON.stringify({ entries: [], truncated: false }) })
expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["src"], save: ["*"] }])
expect(listCalls).toEqual([{ offset: 2, limit: 10 }])
}),
@ -692,8 +693,15 @@ describe("ReadTool", () => {
},
}),
).toEqual({
type: "json",
value: { type: "text-page", content: "hello", mime: "text/plain", offset: 2, truncated: true, next: 3 },
type: "text",
value: JSON.stringify({
type: "text-page",
content: "hello",
mime: "text/plain",
offset: 2,
truncated: true,
next: 3,
}),
})
expect(readCalls).toEqual([
{ input: AbsolutePath.make(path.join(process.cwd(), "large.txt")), page: { offset: 2, limit: 1 } },

View file

@ -121,7 +121,14 @@ describe("SkillTool", () => {
}),
).toMatchObject({
result: { type: "text", value: SkillTool.toModelOutput(info, [reference]) },
output: { structured: { name: "Effect" } },
output: {
structured: {
name: "Effect",
directory,
bytes: Buffer.byteLength(SkillTool.toModelOutput(info, [reference])),
truncated: false,
},
},
})
expect(assertions).toMatchObject([
{ sessionID, action: "skill", resources: ["effect"], save: ["effect"] },

View file

@ -96,7 +96,7 @@ describe("WebFetchTool registration", () => {
expect(yield* settleTool(registry, call({ url, format: "text", timeout: 4 }))).toEqual({
result: { type: "text", value: "hello" },
output: {
structured: { url, contentType: "text/plain", format: "text", output: "hello" },
structured: { url, contentType: "text/plain", format: "text", bytes: 5, truncated: false },
content: [{ type: "text", text: "hello" }],
},
})

View file

@ -244,7 +244,7 @@ describe("WebSearchTool registration", () => {
expect(settled).toEqual({
result: { type: "text", value: "parallel results" },
output: {
structured: { provider: "parallel", text: "parallel results" },
structured: { provider: "parallel", bytes: 16, truncated: false },
content: [{ type: "text", text: "parallel results" }],
},
})