From f7a72fdf32c7e10bf37436995a9fa290a499cb0d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 20 Jul 2026 14:58:58 -0400 Subject: [PATCH] fix(core): preserve bounded tool receipts --- packages/core/src/session/runner/llm.ts | 25 +++++++---- .../src/session/runner/publish-llm-event.ts | 6 ++- packages/core/src/tool/edit.ts | 10 +++++ packages/core/src/tool/execute.ts | 2 +- packages/core/src/tool/glob.ts | 1 + packages/core/src/tool/grep.ts | 1 + packages/core/src/tool/patch.ts | 10 +++++ packages/core/src/tool/question.ts | 8 ++++ packages/core/src/tool/read.ts | 1 + packages/core/src/tool/registry.ts | 4 +- packages/core/src/tool/shell.ts | 1 + packages/core/src/tool/skill.ts | 2 + packages/core/src/tool/structured.ts | 39 +++++++++++++++++ packages/core/src/tool/subagent.ts | 1 + packages/core/src/tool/webfetch.ts | 2 + packages/core/src/tool/websearch.ts | 2 + .../test/session-runner-tool-events.test.ts | 39 +++++++++++++++++ packages/core/test/tool-edit.test.ts | 42 ++++++++++++++++++- packages/core/test/tool-execute.test.ts | 33 +++++++++++++++ packages/core/test/tool-patch.test.ts | 37 +++++++++++++++- packages/core/test/tool-question.test.ts | 25 ++++++++++- packages/plugin/src/v2/effect/tool.ts | 4 ++ 22 files changed, 278 insertions(+), 17 deletions(-) create mode 100644 packages/core/src/tool/structured.ts diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 3f78db090f..959005754a 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -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) diff --git a/packages/core/src/session/runner/publish-llm-event.ts b/packages/core/src/session/runner/publish-llm-event.ts index 0762ca85ad..abc3b20a07 100644 --- a/packages/core/src/session/runner/publish-llm-event.ts +++ b/packages/core/src/session/runner/publish-llm-event.ts @@ -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": { diff --git a/packages/core/src/tool/edit.ts b/packages/core/src/tool/edit.ts index 2319d4baf2..69b1dc592f 100644 --- a/packages/core/src/tool/edit.ts +++ b/packages/core/src/tool/edit.ts @@ -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) }, ], diff --git a/packages/core/src/tool/execute.ts b/packages/core/src/tool/execute.ts index e49739eb05..b4002091f6 100644 --- a/packages/core/src/tool/execute.ts +++ b/packages/core/src/tool/execute.ts @@ -142,7 +142,7 @@ export const create = (registrations: ReadonlyMap) => { } function codeModeTool(tool: AnyTool): AnyTool { - if ("jsonSchema" in tool) return tool + if ("jsonSchema" in tool || tool.codeModeOutput !== "output") return tool return { ...tool, structured: undefined } } diff --git a/packages/core/src/tool/glob.ts b/packages/core/src/tool/glob.ts index 05c3744c0d..81b4da662e 100644 --- a/packages/core/src/tool/glob.ts +++ b/packages/core/src/tool/glob.ts @@ -53,6 +53,7 @@ export const Plugin = { input: Input, output: Output, structured: StructuredOutput, + codeModeOutput: "output", toStructuredOutput: ({ output }) => ({ count: output.length }), toModelOutput: ({ output }) => [ { diff --git a/packages/core/src/tool/grep.ts b/packages/core/src/tool/grep.ts index 8012a5b7aa..b9dfc424f1 100644 --- a/packages/core/src/tool/grep.ts +++ b/packages/core/src/tool/grep.ts @@ -67,6 +67,7 @@ export const Plugin = { input: Input, output: Output, structured: StructuredOutput, + codeModeOutput: "output", toStructuredOutput: ({ output }) => ({ matches: output.length }), toModelOutput: ({ output }) => [ { diff --git a/packages/core/src/tool/patch.ts b/packages/core/src/tool/patch.ts index 6925b87793..399a0029d5 100644 --- a/packages/core/src/tool/patch.ts +++ b/packages/core/src/tool/patch.ts @@ -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 = [] diff --git a/packages/core/src/tool/question.ts b/packages/core/src/tool/question.ts index aa343c9652..70c81a9db3 100644 --- a/packages/core/src/tool/question.ts +++ b/packages/core/src/tool/question.ts @@ -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) }, ], diff --git a/packages/core/src/tool/read.ts b/packages/core/src/tool/read.ts index 77224c50e0..eff11b206d 100644 --- a/packages/core/src/tool/read.ts +++ b/packages/core/src/tool/read.ts @@ -74,6 +74,7 @@ export const Plugin = { input: Input, output: Output, structured: StructuredOutput, + codeModeOutput: "output", toStructuredOutput: ({ output }) => { if ("encoding" in output) return { diff --git a/packages/core/src/tool/registry.ts b/packages/core/src/tool/registry.ts index 77dc539b83..ec341fab31 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -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 = [ diff --git a/packages/core/src/tool/shell.ts b/packages/core/src/tool/shell.ts index 300acf8cd5..89e9ea381c 100644 --- a/packages/core/src/tool/shell.ts +++ b/packages/core/src/tool/shell.ts @@ -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 }), diff --git a/packages/core/src/tool/skill.ts b/packages/core/src/tool/skill.ts index 30b930f2bf..30c5573419 100644 --- a/packages/core/src/tool/skill.ts +++ b/packages/core/src/tool/skill.ts @@ -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, diff --git a/packages/core/src/tool/structured.ts b/packages/core/src/tool/structured.ts new file mode 100644 index 0000000000..2d34610ecb --- /dev/null +++ b/packages/core/src/tool/structured.ts @@ -0,0 +1,39 @@ +export * as ToolStructured from "./structured" + +import { ToolOutputStore } from "../tool-output-store" + +export function fit(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 : "") +} diff --git a/packages/core/src/tool/subagent.ts b/packages/core/src/tool/subagent.ts index 5f53b0e95f..6e07e9de3b 100644 --- a/packages/core/src/tool/subagent.ts +++ b/packages/core/src/tool/subagent.ts @@ -123,6 +123,7 @@ export const Plugin = { input: Input, output: Output, structured: StructuredOutput, + contentTruncation: true, toStructuredOutput: ({ output }) => ({ sessionID: output.sessionID, status: output.status, diff --git a/packages/core/src/tool/webfetch.ts b/packages/core/src/tool/webfetch.ts index f95bc04515..80a713a392 100644 --- a/packages/core/src/tool/webfetch.ts +++ b/packages/core/src/tool/webfetch.ts @@ -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, diff --git a/packages/core/src/tool/websearch.ts b/packages/core/src/tool/websearch.ts index 70c3ffc03c..400a5b7eb0 100644 --- a/packages/core/src/tool/websearch.ts +++ b/packages/core/src/tool/websearch.ts @@ -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"), diff --git a/packages/core/test/session-runner-tool-events.test.ts b/packages/core/test/session-runner-tool-events.test.ts index 736af419e2..44308500c9 100644 --- a/packages/core/test/session-runner-tool-events.test.ts +++ b/packages/core/test/session-runner-tool-events.test.ts @@ -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 = { + 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 = { + 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( diff --git a/packages/core/test/tool-edit.test.ts b/packages/core/test/tool-edit.test.ts index ded236e654..cd8b0c6d47 100644 --- a/packages/core/test/tool-edit.test.ts +++ b/packages/core/test/tool-edit.test.ts @@ -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()), diff --git a/packages/core/test/tool-execute.test.ts b/packages/core/test/tool-execute.test.ts index 074ea3b907..00aebaf513 100644 --- a/packages/core/test/tool-execute.test.ts +++ b/packages/core/test/tool-execute.test.ts @@ -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}' }]) +}) diff --git a/packages/core/test/tool-patch.test.ts b/packages/core/test/tool-patch.test.ts index 458f8c3758..1f7f5b42b3 100644 --- a/packages/core/test/tool-patch.test.ts +++ b/packages/core/test/tool-patch.test.ts @@ -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()), diff --git a/packages/core/test/tool-question.test.ts b/packages/core/test/tool-question.test.ts index f3ef03bf79..2aa001e9b9 100644 --- a/packages/core/test/tool-question.test.ts +++ b/packages/core/test/tool-question.test.ts @@ -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 diff --git a/packages/plugin/src/v2/effect/tool.ts b/packages/plugin/src/v2/effect/tool.ts index 879e5badef..b537c9d3db 100644 --- a/packages/plugin/src/v2/effect/tool.ts +++ b/packages/plugin/src/v2/effect/tool.ts @@ -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