fix(core): close tool output boundary gaps

This commit is contained in:
Kit Langton
2026-07-20 14:34:50 -04:00
parent c781ef420c
commit aa1f91e0d0
14 changed files with 293 additions and 27 deletions
+23 -8
View File
@@ -40,6 +40,7 @@ const layer = Layer.effect(
const db = (yield* Database.Service).db
const compaction = yield* SessionCompaction.Service
const title = yield* SessionTitle.Service
const outputs = yield* ToolOutputStore.Service
// Title generation is a side effect of the first step; it must not delay step continuation.
// Tracked per process so repeated wakes before the second user message arrives don't
// re-fire a redundant LLM call; `SessionTitle` itself is idempotent based on durable history.
@@ -116,7 +117,7 @@ const layer = Layer.effect(
const ownedToolFibers: Array<Fiber.Fiber<void, ToolOutputStore.Error>> = []
let needsContinuation = false
const startSnapshot = yield* snapshots.capture()
const publisher = createLLMEventPublisher(events, {
const publisher = createLLMEventPublisher(events, outputs, {
sessionID: session.id,
agent: agent.id,
// The selected catalog identity, not model.id: route-level ids are provider API
@@ -165,13 +166,26 @@ const layer = Layer.effect(
call: event,
progress: (update) =>
serialized(
events.publish(SessionEvent.Tool.Progress, {
sessionID: session.id,
assistantMessageID,
callID: event.id,
structured: { ...update.structured },
content: [...update.content],
}),
Effect.gen(function* () {
const bounded = yield* outputs.bound({
sessionID: session.id,
callID: event.id,
output: update,
})
if (
typeof bounded.output.structured !== "object" ||
bounded.output.structured === null ||
Array.isArray(bounded.output.structured)
)
yield* Effect.die(new Error("Tool progress structured output must be an object"))
yield* events.publish(SessionEvent.Tool.Progress, {
sessionID: session.id,
assistantMessageID,
callID: event.id,
structured: Object.fromEntries(Object.entries(bounded.output.structured)),
content: [...bounded.output.content],
})
}).pipe(Effect.orDie),
),
}),
).pipe(
@@ -500,6 +514,7 @@ export const node = makeLocationNode({
SessionCompaction.node,
SessionTitle.node,
Snapshot.node,
ToolOutputStore.node,
Database.node,
],
})
@@ -11,6 +11,7 @@ import { AgentV2 } from "../../agent"
import { Snapshot } from "../../snapshot"
import { RelativePath } from "../../schema"
import { SessionUsage } from "../usage"
import { ToolOutputStore } from "../../tool-output-store"
type Input = {
readonly sessionID: SessionSchema.ID
@@ -34,18 +35,22 @@ const message = (value: unknown) => {
}
type SettledOutput =
| { readonly structured: Record<string, unknown>; readonly content: ToolOutput["content"] }
| ToolOutput
| { readonly error: SessionError.Error }
const settledOutput = (value: ToolOutput | undefined, result: ToolResultValue): SettledOutput => {
if (result.type === "error") return { error: { type: "tool.execution", message: message(result.value) } }
const settled = value ?? ToolOutput.fromResultValue(result)
if (!settled) throw new Error(`Unsupported tool result: ${message(result)}`)
return { structured: record(settled.structured), content: settled.content }
return settled
}
/** Persist one step without executing tools or starting a continuation step. */
export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish">, input: Input) => {
export const createLLMEventPublisher = (
events: Pick<EventV2.Interface, "publish">,
outputs: Pick<ToolOutputStore.Interface, "bound">,
input: Input,
) => {
const tools = new Map<
string,
{
@@ -421,11 +426,17 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
})
return
}
const bounded = yield* outputs.bound({
sessionID: input.sessionID,
callID: event.id,
output: result,
})
yield* events.publish(SessionEvent.Tool.Success, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID: event.id,
...result,
structured: record(bounded.output.structured),
content: bounded.output.content,
...(executed ? { result: event.result } : {}),
executed,
resultState,
+2
View File
@@ -21,6 +21,7 @@ export interface BoundInput {
readonly sessionID: SessionSchema.ID
readonly callID: string
readonly output: ToolOutput
readonly propagateTruncation?: boolean
}
export interface BoundResult {
@@ -173,6 +174,7 @@ const layer = Layer.effect(
structured: structuredPath
? { _truncated: true, _bytes: encodedBytes, _outputPath: structuredPath }
: contextualOverflow &&
input.propagateTruncation === true &&
Predicate.isObject(input.output.structured) &&
"truncated" in input.output.structured &&
typeof input.output.structured.truncated === "boolean"
+7 -2
View File
@@ -49,7 +49,7 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
) => {
const tools: Record<string, Tool.Definition<never>> = {}
for (const [name, registration] of registrations) {
const child = definition(name, registration.tool)
const child = definition(name, codeModeTool(registration.tool))
const value = Tool.make({
description: child.description,
input: child.inputSchema,
@@ -96,7 +96,7 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
Effect.gen(function* () {
const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1)
const output = yield* settle(
registration.tool,
codeModeTool(registration.tool),
{ type: "tool-call", id: context.callID, name, input },
{
sessionID: context.sessionID,
@@ -141,6 +141,11 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
})
}
function codeModeTool(tool: AnyTool): AnyTool {
if ("jsonSchema" in tool) return tool
return { ...tool, structured: undefined }
}
function displayInput(input: unknown): Record<string, unknown> | undefined {
if (input === null || input === undefined) return
if (typeof input !== "object" || Array.isArray(input)) return { input }
+3 -1
View File
@@ -239,6 +239,7 @@ export const read = Effect.fn("ReadTool.read")(function* (
let line = 1
let bytes = 0
let next: number | undefined
let lineTruncated = false
const append = (input: string) => {
if (line < offset) {
line++
@@ -249,6 +250,7 @@ export const read = Effect.fn("ReadTool.read")(function* (
return false
}
const text = input.length > MAX_LINE_LENGTH ? input.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : input
if (text !== input) lineTruncated = true
const size = Buffer.byteLength(text, "utf-8") + (lines.length > 0 ? 1 : 0)
if (bytes + size > MAX_READ_BYTES) {
next = line
@@ -314,7 +316,7 @@ export const read = Effect.fn("ReadTool.read")(function* (
content: lines.join("\n"),
mime: FSUtil.mimeType(real),
offset,
truncated: next !== undefined,
truncated: next !== undefined || lineTruncated,
...(next === undefined ? {} : { next }),
})
}),
+16 -2
View File
@@ -118,6 +118,8 @@ const registryLayer = Layer.effect(
const settleTool = Effect.fn("ToolRegistry.settleTool")(function* (input: ExecuteInput, tool: AnyTool) {
// Hooks fire only for hosted/local tools; provider-executed calls never reach settleTool.
const propagateTruncation =
!("jsonSchema" in tool) && tool.structured !== undefined && tool.toStructuredOutput !== undefined
const beforeEvent: ToolHooks.BeforeEvent = {
tool: input.call.name,
sessionID: input.sessionID,
@@ -169,6 +171,7 @@ const registryLayer = Layer.effect(
sessionID: input.sessionID,
callID: input.call.id,
output: { structured: pending.output.structured, content: yield* normalizeImages(pending.output.content) },
propagateTruncation,
})
const result = ToolOutput.toResultValue(bounded.output)
settlement =
@@ -192,10 +195,21 @@ const registryLayer = Layer.effect(
outputPaths: settlement.outputPaths,
}
yield* toolHooks.runAfter(afterEvent)
const finalOutput = afterEvent.output
? yield* resources.bound({
sessionID: input.sessionID,
callID: input.call.id,
output: afterEvent.output,
propagateTruncation,
})
: undefined
const outputPaths = [
...new Set([...(afterEvent.outputPaths ?? []), ...(finalOutput?.outputPaths ?? [])]),
]
return {
result: afterEvent.result,
...(afterEvent.output !== undefined ? { output: afterEvent.output } : {}),
...(afterEvent.outputPaths !== undefined ? { outputPaths: afterEvent.outputPaths } : {}),
...(finalOutput !== undefined ? { output: finalOutput.output } : {}),
...(outputPaths.length > 0 ? { outputPaths } : {}),
...(settlement.error !== undefined ? { error: settlement.error } : {}),
}
})
+14
View File
@@ -32,6 +32,13 @@ export const Output = Schema.Struct({
output: Schema.String,
})
const StructuredOutput = Schema.Struct({
sessionID: Output.fields.sessionID,
status: Output.fields.status,
bytes: Schema.Number,
truncated: Schema.Boolean,
})
export const description = [
"Spawn a subagent: a child session running a configured agent with fresh context.",
"Foreground (default) runs the subagent to completion and returns its final response.",
@@ -115,6 +122,13 @@ export const Plugin = {
description,
input: Input,
output: Output,
structured: StructuredOutput,
toStructuredOutput: ({ output }) => ({
sessionID: output.sessionID,
status: output.status,
bytes: Buffer.byteLength(output.output, "utf-8"),
truncated: false,
}),
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
execute: (input, context) =>
Effect.gen(function* () {
@@ -12,11 +12,15 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
import { RelativePath } from "@opencode-ai/core/schema"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { createLLMEventPublisher } from "@opencode-ai/core/session/runner/publish-llm-event"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
const sessionID = SessionV2.ID.make("ses_tool_event_test")
const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
const outputs: Pick<ToolOutputStore.Interface, "bound"> = {
bound: (input) => Effect.succeed({ output: input.output, outputPaths: [] }),
}
const capture = (providerMetadataKey = "anthropic") => {
const capture = (providerMetadataKey = "anthropic", outputStore = outputs) => {
const published: Array<{ readonly type: string; readonly data: unknown }> = []
const events: Pick<EventV2.Interface, "publish"> = {
publish: (definition, data) =>
@@ -33,7 +37,7 @@ const capture = (providerMetadataKey = "anthropic") => {
}
return {
published,
publisher: createLLMEventPublisher(events, {
publisher: createLLMEventPublisher(events, outputStore, {
sessionID,
agent: AgentV2.ID.make("build"),
model: {
@@ -92,6 +96,32 @@ test("provider-executed success retains its raw provider result", async () => {
expect(success?.data).toHaveProperty("result")
})
test("provider-executed success bounds output before durable publication", async () => {
const guarded: ToolOutputStore.BoundInput[] = []
const outputStore: Pick<ToolOutputStore.Interface, "bound"> = {
bound: (input) =>
Effect.sync(() => guarded.push(input)).pipe(
Effect.as({
output: {
structured: { _truncated: true, _bytes: 20_000, _outputPath: "/managed/provider" },
content: [{ type: "text" as const, text: "bounded provider output" }],
},
outputPaths: ["/managed/provider"],
}),
),
}
const { published, publisher } = capture("anthropic", outputStore)
await Effect.runPromise(publisher.publish(LLMEvent.toolCall({ ...call, providerExecuted: true })))
await Effect.runPromise(publisher.publish(LLMEvent.toolResult({ ...result, providerExecuted: true })))
expect(guarded).toHaveLength(1)
expect(published.find((event) => event.type === "session.tool.success.1")?.data).toMatchObject({
structured: { _truncated: true, _bytes: 20_000, _outputPath: "/managed/provider" },
content: [{ type: "text", text: "bounded provider output" }],
result: result.result,
})
})
test("provider metadata is flattened using the route key", async () => {
const { published, publisher } = capture()
await Effect.runPromise(
@@ -3,11 +3,13 @@ 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 { LayerNode } from "@opencode-ai/core/effect/layer-node"
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"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { ToolHooks } from "@opencode-ai/core/tool/hooks"
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"
@@ -130,6 +132,47 @@ describe("ToolRegistry", () => {
),
)
live.live("bounds output replaced by an execute-after hook", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
const layer = AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolHooks.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 hooks = yield* ToolHooks.Service
const text = "y".repeat(ToolOutputStore.MAX_STRUCTURED_BYTES)
yield* hooks.hook.after((event) => {
event.result = { type: "text", value: "hook result" }
event.output = { structured: { text }, content: [] }
})
yield* service.register({ hooked: make() }, { codemode: false })
const settled = yield* settleTool(service, {
sessionID,
...identity,
call: { type: "tool-call", id: "call-hooked", name: "hooked", input: { text: "original" } },
})
const encoded = JSON.stringify({ text })
expect(settled.result).toEqual({ type: "text", value: "hook result" })
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
@@ -345,7 +388,7 @@ describe("ToolRegistry", () => {
output: { structured: {}, content: [{ type: "text", text: "bounded reference" }] },
outputPaths: ["/managed/generic"],
})
expect(bounds).toHaveLength(1)
expect(bounds).toHaveLength(2)
}),
)
+9 -2
View File
@@ -840,6 +840,7 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup
const registry = yield* ToolRegistry.Service
const contexts: Tool.Context[] = []
const progress = "x".repeat(ToolOutputStore.MAX_STRUCTURED_BYTES)
yield* registry.register({
location_context: Tool.make({
description: "Read application context",
@@ -848,7 +849,7 @@ describe("SessionRunnerLLM", () => {
execute: ({ query }, context) =>
Effect.gen(function* () {
contexts.push(context)
yield* context.progress({ structured: { phase: "reading" } })
yield* context.progress({ structured: { phase: progress } })
return { answer: query.toUpperCase() }
}),
}),
@@ -875,7 +876,13 @@ describe("SessionRunnerLLM", () => {
progress: expect.any(Function),
},
])
expect(Array.from(yield* Fiber.join(progressFiber))[0]?.data.structured).toEqual({ phase: "reading" })
const update = Array.from(yield* Fiber.join(progressFiber))[0]?.data
expect(update?.structured).toMatchObject({
_truncated: true,
_bytes: Buffer.byteLength(JSON.stringify({ phase: progress })),
_outputPath: expect.any(String),
})
expect(update?.content).toEqual([{ type: "text", text: JSON.stringify({ phase: progress }) }])
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Use application context" },
{
+34
View File
@@ -93,3 +93,37 @@ test("execute supports callable namespace tools", async () => {
})
expect(result.content).toEqual([{ type: "text", text: '[\n "admin",\n "created"\n]' }])
})
test("execute exposes typed internal output instead of the persisted receipt", async () => {
const child = Tool.make({
description: "Fetch content",
input: Schema.Struct({}),
output: Schema.Struct({ content: Schema.String, bytes: Schema.Number }),
structured: Schema.Struct({ bytes: Schema.Number }),
toStructuredOutput: ({ output }) => ({ bytes: output.bytes }),
toModelOutput: ({ output }) => [{ type: "text", text: output.content }],
execute: () => Effect.succeed({ content: "full payload", bytes: 12 }),
})
const execute = ExecuteTool.create(new Map([["fetch", { tool: child, name: "fetch" }]]))
const result = await Effect.runPromise(
Tool.settle(
execute,
{
type: "tool-call",
id: "call_execute",
name: "execute",
input: { code: "return (await tools.fetch({})).content" },
},
{
sessionID: Session.ID.make("ses_execute"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_execute"),
callID: "call_execute",
progress: () => Effect.void,
},
),
)
expect(result.structured).toEqual({ toolCalls: [{ tool: "fetch", status: "completed" }] })
expect(result.content).toEqual([{ type: "text", text: "full payload" }])
})
+61 -1
View File
@@ -80,6 +80,9 @@ describe("ToolOutputStore", () => {
_bytes: Buffer.byteLength(JSON.stringify(structured)),
_outputPath: result.outputPaths[0],
})
expect(Buffer.byteLength(JSON.stringify(result.output.structured))).toBeLessThanOrEqual(
ToolOutputStore.MAX_STRUCTURED_BYTES,
)
expect(result.outputPaths).toHaveLength(1)
expect(JSON.parse(yield* fs.readFileString(result.outputPaths[0]))).toEqual(structured)
expect(result.output.content).toHaveLength(1)
@@ -107,6 +110,46 @@ describe("ToolOutputStore", () => {
),
)
it.live("measures the structured boundary in UTF-8 bytes", () =>
withStore(({ store }) =>
Effect.gen(function* () {
const structured = { text: "é".repeat(ToolOutputStore.MAX_STRUCTURED_BYTES / 2) }
const encoded = JSON.stringify(structured)
expect(structured.text.length).toBeLessThan(ToolOutputStore.MAX_STRUCTURED_BYTES)
expect(Buffer.byteLength(encoded)).toBeGreaterThan(ToolOutputStore.MAX_STRUCTURED_BYTES)
const result = yield* store.bound({
sessionID,
callID: "call-structured-utf8",
output: { structured, content: [] },
})
expect(result.output.structured).toMatchObject({
_truncated: true,
_bytes: Buffer.byteLength(encoded),
})
}),
),
)
it.live("retains independent structured and contextual overflow", () =>
withStore(({ store, fs }) =>
Effect.gen(function* () {
const structured = { detail: "s".repeat(ToolOutputStore.MAX_STRUCTURED_BYTES) }
const content = "c".repeat(ToolOutputStore.MAX_BYTES + 1)
const result = yield* store.bound({
sessionID,
callID: "call-two-channel-overflow",
output: { structured, content: [{ type: "text", text: content }] },
})
expect(result.outputPaths).toHaveLength(2)
expect(JSON.parse(yield* fs.readFileString(result.outputPaths[0]))).toEqual(structured)
expect(yield* fs.readFileString(result.outputPaths[1])).toBe(content)
}),
),
)
it.live("keeps structured output at the receipt boundary inline", () =>
withStore(({ store }) =>
Effect.gen(function* () {
@@ -173,12 +216,13 @@ describe("ToolOutputStore", () => {
),
)
it.live("marks receipt metadata truncated when bounding its contextual output", () =>
it.live("marks projected receipt metadata truncated when bounding its contextual output", () =>
withStore(({ store }) =>
Effect.gen(function* () {
const result = yield* store.bound({
sessionID,
callID: "call-receipt",
propagateTruncation: true,
output: {
structured: { bytes: ToolOutputStore.MAX_BYTES + 1, truncated: false },
content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }],
@@ -189,6 +233,22 @@ describe("ToolOutputStore", () => {
),
)
it.live("preserves ordinary truncated metadata when bounding contextual output", () =>
withStore(({ store }) =>
Effect.gen(function* () {
const result = yield* store.bound({
sessionID,
callID: "call-ordinary-truncated",
output: {
structured: { truncated: false },
content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }],
},
})
expect(result.output.structured).toEqual({ truncated: false })
}),
),
)
it.live("bounds structured data duplicated in projected text independently", () =>
withStore(({ store, fs }) =>
Effect.gen(function* () {
@@ -100,6 +100,21 @@ describe("ReadToolFileSystem", () => {
}),
)
it.effect("reports a shortened long line as truncated", () =>
Effect.gen(function* () {
const { fs, files, directory } = yield* fixture
const file = path.join(directory, "long-line.txt")
yield* files.writeFileString(file, "x".repeat(3_000))
const result = yield* ReadToolFileSystem.read(fs, file, "long-line.txt", { limit: 1 })
expect(result).toMatchObject({ type: "text-page", truncated: true })
if (!(result instanceof ReadToolFileSystem.TextPage)) throw new Error("expected text page")
expect(result.next).toBeUndefined()
expect(result.content).toEndWith("... (line truncated to 2000 chars)")
}),
)
it.effect("preserves the media ingestion limit message", () =>
Effect.gen(function* () {
const { fs, files, directory } = yield* fixture
+18 -4
View File
@@ -35,7 +35,8 @@ const childModel = ModelV2.Ref.make({ id: ModelV2.ID.make("child"), providerID:
const parentModel = ModelV2.Ref.make({ id: ModelV2.ID.make("parent"), providerID: ProviderV2.ID.make("test") })
const tokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
const outputSessionID = (value: unknown) => Schema.decodeUnknownSync(SubagentTool.Output)(value).sessionID
const outputSessionID = (value: unknown) =>
Schema.decodeUnknownSync(Schema.Struct({ sessionID: SessionV2.ID }))(value).sessionID
const executionNode = makeGlobalNode({
service: SessionExecution.Service,
@@ -229,7 +230,12 @@ describe("SubagentTool", () => {
},
})
expect(settled.output?.structured).toMatchObject({ status: "completed", output: childText })
expect(settled.output?.structured).toMatchObject({
status: "completed",
bytes: Buffer.byteLength(childText),
truncated: false,
})
expect(settled.output?.structured).not.toHaveProperty("output")
expect((yield* sessions.get(outputSessionID(settled.output?.structured))).parentID).toBe(parent.id)
}),
),
@@ -264,7 +270,13 @@ describe("SubagentTool", () => {
},
})
expect(settled.output?.structured).toMatchObject({ status: "completed", output: childText })
expect(settled.result).toEqual({ type: "text", value: childText })
expect(settled.output?.structured).toMatchObject({
status: "completed",
bytes: Buffer.byteLength(childText),
truncated: false,
})
expect(settled.output?.structured).not.toHaveProperty("output")
const child = yield* sessions.get(outputSessionID(settled.output?.structured))
expect(progress[0]?.structured).toEqual({ sessionID: child.id, status: "running" })
expect(child).toMatchObject({
@@ -358,8 +370,10 @@ describe("SubagentTool", () => {
const childID = outputSessionID(settled.output?.structured)
expect(settled.output?.structured).toMatchObject({
status: "running",
output: expect.stringContaining(`id: ${childID}`),
bytes: expect.any(Number),
truncated: false,
})
expect(settled.output?.structured).not.toHaveProperty("output")
const admission = Array.from(yield* Fiber.join(admitted))[0]
expect(admission?.data.input.data.text).toContain(`<subagent id="${childID}" state="completed"`)