fix(core): preserve bounded tool receipts

This commit is contained in:
Kit Langton
2026-07-20 14:58:58 -04:00
parent aa1f91e0d0
commit f7a72fdf32
22 changed files with 278 additions and 17 deletions
+17 -8
View File
@@ -172,17 +172,16 @@ const layer = Layer.effect(
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"))
const structured = bounded.output.structured
const entries =
typeof structured === "object" && structured !== null && !Array.isArray(structured)
? Object.entries(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)),
structured: Object.fromEntries(entries),
content: [...bounded.output.content],
})
}).pipe(Effect.orDie),
@@ -275,6 +274,11 @@ const layer = Layer.effect(
}
yield* serialized(publisher.failAssistant(error))
}
if (streamFailure instanceof ToolOutputStore.StorageError) {
const error = toSessionError(streamFailure)
yield* serialized(publisher.failUnsettledTools(error))
yield* serialized(publisher.failAssistant(error))
}
// Provider error events only arrive from the stream, so the flag is final here.
const providerFailed = publisher.hasProviderError()
@@ -301,8 +305,13 @@ const layer = Layer.effect(
// settles so the model may recover. A typed infrastructure failure (tool output
// could not be persisted) also fails the assistant and then fails the drain.
const settledFailure = settledCauses.find((cause) => !Cause.hasInterrupts(cause) && !isUserDeclined(cause))
const infraError =
const typedError =
settledFailure === undefined ? undefined : Option.getOrUndefined(Cause.findErrorOption(settledFailure))
const storageDefect = settledFailure?.reasons.find(
(reason) => Cause.isDieReason(reason) && reason.defect instanceof ToolOutputStore.StorageError,
)
const infraError =
typedError ?? (storageDefect && Cause.isDieReason(storageDefect) ? storageDefect.defect : undefined)
if (settledFailure !== undefined) {
const failure = infraError ?? Cause.squash(settledFailure)
const error = toSessionError(failure)
@@ -410,7 +410,6 @@ export const createLLMEventPublisher = (
if (event.result.type === "error") return
return yield* Effect.die(new Error(`Duplicate tool result: ${event.id}`))
}
tool.settled = true
const result = error ? { error } : settledOutput(event.output, event.result)
const executed = event.providerExecuted === true || tool.providerExecuted
const resultState = providerState(event.providerMetadata)
@@ -424,12 +423,13 @@ export const createLLMEventPublisher = (
executed,
resultState,
})
tool.settled = true
return
}
const bounded = yield* outputs.bound({
sessionID: input.sessionID,
callID: event.id,
output: result,
output: { ...result, structured: record(result.structured) },
})
yield* events.publish(SessionEvent.Tool.Success, {
sessionID: input.sessionID,
@@ -437,10 +437,12 @@ export const createLLMEventPublisher = (
callID: event.id,
structured: record(bounded.output.structured),
content: bounded.output.content,
// Provider-executed results remain verbatim for provider-history replay.
...(executed ? { result: event.result } : {}),
executed,
resultState,
})
tool.settled = true
return
}
case "tool-error": {
+10
View File
@@ -16,6 +16,7 @@ import { FSUtil } from "../fs-util"
import { LocationMutation } from "../location-mutation"
import { PermissionV2 } from "../permission"
import { Tool } from "./tool"
import { ToolStructured } from "./structured"
export const name = "edit"
@@ -103,6 +104,15 @@ export const Plugin = {
"Replace exact text in one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.",
input: Input,
output: Output,
structured: Output,
toStructuredOutput: ({ output }) =>
ToolStructured.fit((maximumBytes) => ({
...output,
files: output.files.map((file) => ({
...file,
patch: ToolStructured.truncate(file.patch, maximumBytes),
})),
})),
toModelOutput: ({ input, output }) => [
{ type: "text", text: toModelOutput(output, input.oldString, input.newString) },
],
+1 -1
View File
@@ -142,7 +142,7 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
}
function codeModeTool(tool: AnyTool): AnyTool {
if ("jsonSchema" in tool) return tool
if ("jsonSchema" in tool || tool.codeModeOutput !== "output") return tool
return { ...tool, structured: undefined }
}
+1
View File
@@ -53,6 +53,7 @@ export const Plugin = {
input: Input,
output: Output,
structured: StructuredOutput,
codeModeOutput: "output",
toStructuredOutput: ({ output }) => ({ count: output.length }),
toModelOutput: ({ output }) => [
{
+1
View File
@@ -67,6 +67,7 @@ export const Plugin = {
input: Input,
output: Output,
structured: StructuredOutput,
codeModeOutput: "output",
toStructuredOutput: ({ output }) => ({ matches: output.length }),
toModelOutput: ({ output }) => [
{
+10
View File
@@ -11,6 +11,7 @@ import { LocationMutation } from "../location-mutation"
import { Patch } from "../patch"
import { PermissionV2 } from "../permission"
import { Tool } from "./tool"
import { ToolStructured } from "./structured"
export const name = "patch"
@@ -72,6 +73,15 @@ export const Plugin = {
"Apply one patch containing add, update, and delete file operations. All targets are resolved and approved before target contents are read. Operations apply sequentially; if a later operation fails, earlier operations remain applied and the failure reports them explicitly. Moves and atomic rollback are not supported yet.",
input: Input,
output: Output,
structured: Output,
toStructuredOutput: ({ output }) =>
ToolStructured.fit((maximumBytes) => ({
...output,
files: output.files.map((file) => ({
...file,
patch: ToolStructured.truncate(file.patch, maximumBytes),
})),
})),
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
execute: (input, context) => {
const applied: Array<typeof Applied.Type> = []
+8
View File
@@ -7,6 +7,7 @@ import { Form } from "../form"
import { PermissionV2 } from "../permission"
import { QuestionV2 } from "../question"
import { Tool } from "./tool"
import { ToolStructured } from "./structured"
export const name = "question"
@@ -63,6 +64,13 @@ export const Plugin = {
description,
input: Input,
output: Output,
structured: Output,
toStructuredOutput: ({ output }) =>
ToolStructured.fit((maximumBytes) => ({
answers: output.answers.map((answers) =>
answers.map((answer) => ToolStructured.truncate(answer, maximumBytes)),
),
})),
toModelOutput: ({ input, output }) => [
{ type: "text", text: toModelOutput(input.questions, output.answers) },
],
+1
View File
@@ -74,6 +74,7 @@ export const Plugin = {
input: Input,
output: Output,
structured: StructuredOutput,
codeModeOutput: "output",
toStructuredOutput: ({ output }) => {
if ("encoding" in output)
return {
+1 -3
View File
@@ -118,8 +118,7 @@ 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 propagateTruncation = !("jsonSchema" in tool) && tool.contentTruncation === true
const beforeEvent: ToolHooks.BeforeEvent = {
tool: input.call.name,
sessionID: input.sessionID,
@@ -200,7 +199,6 @@ const registryLayer = Layer.effect(
sessionID: input.sessionID,
callID: input.call.id,
output: afterEvent.output,
propagateTruncation,
})
: undefined
const outputPaths = [
+1
View File
@@ -148,6 +148,7 @@ export const Plugin = {
input: Input,
output: Output,
structured: StructuredOutput,
codeModeOutput: "output",
toStructuredOutput: ({ output }) => ({
truncated: output.truncated,
...(output.exit === undefined ? {} : { exit: output.exit }),
+2
View File
@@ -74,6 +74,8 @@ export const Plugin = {
input: Input,
output: Output,
structured: StructuredOutput,
codeModeOutput: "output",
contentTruncation: true,
toStructuredOutput: ({ output }) => ({
name: output.name,
directory: output.directory,
+39
View File
@@ -0,0 +1,39 @@
export * as ToolStructured from "./structured"
import { ToolOutputStore } from "../tool-output-store"
export function fit<A>(project: (maxStringBytes: number) => A) {
const full = project(ToolOutputStore.MAX_STRUCTURED_BYTES)
if (Buffer.byteLength(JSON.stringify(full), "utf-8") <= ToolOutputStore.MAX_STRUCTURED_BYTES) return full
let minimum = 0
let maximum = ToolOutputStore.MAX_STRUCTURED_BYTES
let result = project(0)
while (minimum <= maximum) {
const middle = Math.floor((minimum + maximum) / 2)
const candidate = project(middle)
if (Buffer.byteLength(JSON.stringify(candidate), "utf-8") <= ToolOutputStore.MAX_STRUCTURED_BYTES) {
result = candidate
minimum = middle + 1
continue
}
maximum = middle - 1
}
return result
}
export function truncate(input: string, maximumBytes: number) {
if (Buffer.byteLength(input, "utf-8") <= maximumBytes) return input
const marker = " ... truncated ..."
const markerBytes = Buffer.byteLength(marker, "utf-8")
const available = Math.max(0, maximumBytes - markerBytes)
let bytes = 0
let end = 0
for (const char of input) {
const size = Buffer.byteLength(char, "utf-8")
if (bytes + size > available) break
bytes += size
end += char.length
}
return input.slice(0, end).replace(/\r?\n$/, "") + (maximumBytes >= markerBytes ? marker : "")
}
+1
View File
@@ -123,6 +123,7 @@ export const Plugin = {
input: Input,
output: Output,
structured: StructuredOutput,
contentTruncation: true,
toStructuredOutput: ({ output }) => ({
sessionID: output.sessionID,
status: output.status,
+2
View File
@@ -135,6 +135,8 @@ export const Plugin = {
input: Input,
output: Output,
structured: StructuredOutput,
codeModeOutput: "output",
contentTruncation: true,
toStructuredOutput: ({ input, output }) => ({
url: output.url,
contentType: output.contentType,
+2
View File
@@ -209,6 +209,8 @@ export const Plugin = {
input: Input,
output: Output,
structured: StructuredOutput,
codeModeOutput: "output",
contentTruncation: true,
toStructuredOutput: ({ output }) => ({
provider: output.provider,
bytes: Buffer.byteLength(output.text, "utf-8"),
@@ -122,6 +122,45 @@ test("provider-executed success bounds output before durable publication", async
})
})
test("normalizes structured output to its durable record shape before bounding", async () => {
const guarded: ToolOutputStore.BoundInput[] = []
const outputStore: Pick<ToolOutputStore.Interface, "bound"> = {
bound: (input) => Effect.sync(() => guarded.push(input)).pipe(Effect.as({ output: input.output, outputPaths: [] })),
}
const { publisher } = capture("anthropic", outputStore)
await Effect.runPromise(publisher.publish(call))
await Effect.runPromise(
publisher.publish(
LLMEvent.toolResult({
...result,
output: { structured: "x".repeat(ToolOutputStore.MAX_STRUCTURED_BYTES - 2), content: [] },
}),
),
)
expect(guarded[0]?.output.structured).toEqual({
value: "x".repeat(ToolOutputStore.MAX_STRUCTURED_BYTES - 2),
})
})
test("a bounding failure leaves the tool eligible for durable failure", async () => {
const storage = new ToolOutputStore.StorageError({ operation: "write", cause: new Error("disk full") })
const outputStore: Pick<ToolOutputStore.Interface, "bound"> = {
bound: () => Effect.fail(storage),
}
const { published, publisher } = capture("anthropic", outputStore)
await Effect.runPromise(publisher.publish(call))
const exit = await Effect.runPromiseExit(publisher.publish(result))
expect(exit._tag).toBe("Failure")
await Effect.runPromise(publisher.failUnsettledTools({ type: "unknown", message: storage.message }))
expect(published.some((event) => event.type === "session.tool.success.1")).toBe(false)
expect(published.find((event) => event.type === "session.tool.failed.1")?.data).toMatchObject({
callID: call.id,
error: { type: "unknown", message: storage.message },
})
})
test("provider metadata is flattened using the route key", async () => {
const { published, publisher } = capture()
await Effect.runPromise(
+41 -1
View File
@@ -2,7 +2,8 @@ import fs from "fs/promises"
import path from "path"
import { fileURLToPath } from "url"
import { describe, expect, test } from "bun:test"
import { Effect, Layer } from "effect"
import { Effect, Layer, Schema } from "effect"
import { parsePatch } from "diff"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { FileMutation } from "@opencode-ai/core/file-mutation"
@@ -174,6 +175,45 @@ describe("EditTool", () => {
),
)
it.live("retains a bounded patch for large edits", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const target = path.join(tmp.path, "large.txt")
const before = "x".repeat(9_000)
const after = "y".repeat(9_000)
return Effect.promise(() => fs.writeFile(target, before)).pipe(
Effect.andThen(
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
const settled = yield* settleTool(
registry,
call({ path: "large.txt", oldString: before, newString: after }),
)
const structured = Schema.decodeUnknownSync(EditTool.Output)(settled.output?.structured)
expect(Buffer.byteLength(JSON.stringify(structured))).toBeLessThanOrEqual(
ToolOutputStore.MAX_STRUCTURED_BYTES,
)
expect(() => parsePatch(structured.files[0]?.patch ?? "")).not.toThrow()
expect(structured).toMatchObject({
replacements: 1,
files: [
{
file: "large.txt",
patch: expect.stringContaining("... truncated ..."),
},
],
})
}),
),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("accepts an absolute file path inside the active Location", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
+33
View File
@@ -100,6 +100,7 @@ test("execute exposes typed internal output instead of the persisted receipt", a
input: Schema.Struct({}),
output: Schema.Struct({ content: Schema.String, bytes: Schema.Number }),
structured: Schema.Struct({ bytes: Schema.Number }),
codeModeOutput: "output",
toStructuredOutput: ({ output }) => ({ bytes: output.bytes }),
toModelOutput: ({ output }) => [{ type: "text", text: output.content }],
execute: () => Effect.succeed({ content: "full payload", bytes: 12 }),
@@ -127,3 +128,35 @@ test("execute exposes typed internal output instead of the persisted receipt", a
expect(result.structured).toEqual({ toolCalls: [{ tool: "fetch", status: "completed" }] })
expect(result.content).toEqual([{ type: "text", text: "full payload" }])
})
test("execute keeps the structured projection unless a tool opts into internal output", 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 }),
execute: () => Effect.succeed({ content: "internal", bytes: 8 }),
})
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({})" },
},
{
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.content).toEqual([{ type: "text", text: '{\n "bytes": 8\n}' }])
})
+36 -1
View File
@@ -1,7 +1,8 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Deferred, Effect, Exit, Fiber, Layer } from "effect"
import { Deferred, Effect, Exit, Fiber, Layer, Schema } from "effect"
import { parsePatch } from "diff"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { FileMutation } from "@opencode-ai/core/file-mutation"
@@ -217,6 +218,40 @@ describe("PatchTool", () => {
),
)
it.live("retains a bounded patch for large changes", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const target = path.join(tmp.path, "large.txt")
const before = "x".repeat(9_000)
const after = "y".repeat(9_000)
return Effect.promise(() => fs.writeFile(target, `${before}\n`)).pipe(
Effect.andThen(
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
const settled = yield* settleTool(
registry,
call(`*** Begin Patch\n*** Update File: large.txt\n@@\n-${before}\n+${after}\n*** End Patch`),
)
const structured = Schema.decodeUnknownSync(PatchTool.Output)(settled.output?.structured)
expect(Buffer.byteLength(JSON.stringify(structured))).toBeLessThanOrEqual(
ToolOutputStore.MAX_STRUCTURED_BYTES,
)
expect(() => parsePatch(structured.files[0]?.patch ?? "")).not.toThrow()
expect(structured).toMatchObject({
applied: [{ type: "update", resource: "large.txt" }],
files: [{ file: "large.txt", patch: expect.stringContaining("... truncated ...") }],
})
}),
),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("rejects moves before applying any hunk", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
+24 -1
View File
@@ -19,6 +19,7 @@ const assertions: PermissionV2.AssertInput[] = []
let captured: Form.CreateInput | undefined
let reject = false
let deny = false
let firstAnswer = "Build"
const capturedInput = () => captured
const questionInput = {
questions: [
@@ -63,7 +64,7 @@ const form = Layer.succeed(
Effect.andThen(
Effect.sync(
(): Form.TerminalState =>
reject ? { status: "cancelled" } : { status: "answered", answer: { q0: "Build", q1: ["Dev"] } },
reject ? { status: "cancelled" } : { status: "answered", answer: { q0: firstAnswer, q1: ["Dev"] } },
),
),
),
@@ -122,6 +123,7 @@ describe("QuestionTool", () => {
captured = undefined
reject = false
deny = false
firstAnswer = "Build"
const registry = yield* ToolRegistry.Service
const questions = [
{
@@ -200,6 +202,27 @@ describe("QuestionTool", () => {
}),
)
it.effect("retains bounded answers for long custom responses", () =>
Effect.gen(function* () {
reject = false
deny = false
firstAnswer = "x".repeat(ToolOutputStore.MAX_STRUCTURED_BYTES)
const registry = yield* ToolRegistry.Service
const settled = yield* settleTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-question-large", name: "question", input: questionInput },
})
expect(Buffer.byteLength(JSON.stringify(settled.output?.structured))).toBeLessThanOrEqual(
ToolOutputStore.MAX_STRUCTURED_BYTES,
)
expect(settled.output?.structured).toMatchObject({
answers: [[expect.stringContaining("... truncated ...")]],
})
}).pipe(Effect.ensuring(Effect.sync(() => (firstAnswer = "Build")))),
)
it.effect("does not invent tool ownership metadata without a durable registry source", () =>
Effect.gen(function* () {
captured = undefined
+4
View File
@@ -97,6 +97,10 @@ export type Definition<
readonly input: Input
readonly output: Output
readonly structured?: Structured
/** Expose encoded output to CodeMode instead of the persisted structured projection. */
readonly codeModeOutput?: "output"
/** Mark a receipt's `truncated` field when its model-facing text is bounded. */
readonly contentTruncation?: true
readonly permission?: string
readonly toStructuredOutput?: (input: {
readonly input: InputValue<Input>