diff --git a/packages/core/src/tool-output-store.ts b/packages/core/src/tool-output-store.ts index 057c6b4c68..c1181fdb44 100644 --- a/packages/core/src/tool-output-store.ts +++ b/packages/core/src/tool-output-store.ts @@ -1,7 +1,7 @@ export * as ToolOutputStore from "./tool-output-store" import path from "path" -import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect" +import { Context, Duration, Effect, Layer, Option, Predicate, Schedule, Schema } from "effect" import { Config } from "./config" import { FSUtil } from "./fs-util" import { Global } from "./global" @@ -12,6 +12,7 @@ import type { ToolOutput } from "@opencode-ai/ai" export const MAX_LINES = 2_000 export const MAX_BYTES = 50 * 1024 +export const MAX_STRUCTURED_BYTES = 16 * 1024 export const RETENTION = Duration.days(7) export const MANAGED_DIRECTORY = "tool-output" @@ -49,26 +50,31 @@ export class Service extends Context.Service()("@opencode/v2 const takePrefix = (input: string, maximumBytes: number) => { let bytes = 0 - let content = "" + let end = 0 for (const char of input) { const size = Buffer.byteLength(char, "utf-8") if (bytes + size > maximumBytes) break - content += char + end += char.length bytes += size } - return content + return input.slice(0, end) } const takeSuffix = (input: string, maximumBytes: number) => { let bytes = 0 - const content: string[] = [] - for (const char of Array.from(input).toReversed()) { + let start = input.length + while (start > 0) { + const codeUnit = input.charCodeAt(start - 1) + const previous = start > 1 ? input.charCodeAt(start - 2) : 0 + const next = + codeUnit >= 0xdc00 && codeUnit <= 0xdfff && previous >= 0xd800 && previous <= 0xdbff ? start - 2 : start - 1 + const char = input.slice(next, start) const size = Buffer.byteLength(char, "utf-8") if (bytes + size > maximumBytes) break - content.unshift(char) + start = next bytes += size } - return content.join("") + return input.slice(start) } const preview = (text: string, maxLines: number, maxBytes: number) => { @@ -128,7 +134,6 @@ const layer = Layer.effect( const write = Effect.fn("ToolOutputStore.write")(function* (content: string) { const file = path.join(directory, `tool_${Identifier.ascending()}`) - yield* fs.ensureDir(directory).pipe(Effect.mapError((cause) => new StorageError({ operation: "write", cause }))) yield* fs .writeFileString(file, content, { flag: "wx" }) .pipe(Effect.mapError((cause) => new StorageError({ operation: "write", cause }))) @@ -139,37 +144,58 @@ const layer = Layer.effect( const outputLimits = yield* limits() const media = input.output.content.filter((item) => item.type === "file") const text = input.output.content.filter((item) => item.type === "text") - const contextual = - input.output.content.length === 0 - ? yield* Effect.try({ - try: () => JSON.stringify(input.output.structured, null, 2) ?? String(input.output.structured), - catch: (cause) => new StorageError({ operation: "encode", cause }), - }) - : text.map((item) => item.text).join("") - if ( - lineCount(contextual) <= outputLimits.maxLines && - Buffer.byteLength(contextual, "utf-8") <= outputLimits.maxBytes - ) + const encoded = yield* Effect.try({ + try: () => JSON.stringify(input.output.structured) ?? String(input.output.structured), + catch: (cause) => new StorageError({ operation: "encode", cause }), + }) + const encodedBytes = Buffer.byteLength(encoded, "utf-8") + const contextual = input.output.content.length === 0 ? encoded : text.map((item) => item.text).join("") + const contextualBytes = input.output.content.length === 0 ? encodedBytes : Buffer.byteLength(contextual, "utf-8") + const structuredOverflow = encodedBytes > MAX_STRUCTURED_BYTES + const contextualOverflow = + contextualBytes > outputLimits.maxBytes || lineCount(contextual) > outputLimits.maxLines + if (!structuredOverflow && !contextualOverflow) return { output: input.output, outputPaths: [], } - const outputPath = yield* write(contextual) - const marker = `... output truncated; full content saved to ${outputPath} ...` + yield* fs.ensureDir(directory).pipe(Effect.mapError((cause) => new StorageError({ operation: "write", cause }))) + const structuredPath = structuredOverflow ? yield* write(encoded) : undefined + const contextualPath = contextualOverflow + ? input.output.content.length === 0 && structuredPath + ? structuredPath + : yield* write(contextual) + : undefined return { output: { - structured: input.output.structured, - content: [ - { - type: "text" as const, - text: boundedPreview(contextual, marker, outputLimits.maxLines, outputLimits.maxBytes), - }, - ...media, - ], + structured: structuredPath + ? { _truncated: true, _bytes: encodedBytes, _outputPath: structuredPath } + : contextualOverflow && + Predicate.isObject(input.output.structured) && + "truncated" in input.output.structured && + typeof input.output.structured.truncated === "boolean" + ? { ...input.output.structured, truncated: true } + : input.output.structured, + content: contextualOverflow + ? [ + { + type: "text" as const, + text: boundedPreview( + contextual, + `... output truncated; full content saved to ${contextualPath} ...`, + outputLimits.maxLines, + outputLimits.maxBytes, + ), + }, + ...media, + ] + : input.output.content.length === 0 && structuredPath + ? [{ type: "text" as const, text: contextual }] + : input.output.content, }, - outputPaths: [outputPath], + outputPaths: [...new Set([structuredPath, contextualPath].filter((value) => value !== undefined))], } }) diff --git a/packages/core/src/tool/edit.ts b/packages/core/src/tool/edit.ts index 2319d4baf2..5d0f9c6c0b 100644 --- a/packages/core/src/tool/edit.ts +++ b/packages/core/src/tool/edit.ts @@ -9,8 +9,8 @@ export * as EditTool from "./edit" import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { ToolFailure } from "@opencode-ai/ai" import { FileDiff } from "@opencode-ai/schema/file-diff" -import { createTwoFilesPatch, diffLines } from "diff" -import { Effect, Schema } from "effect" +import { diffLines } from "diff" +import { Effect, Schema, Struct } from "effect" import { FileMutation } from "../file-mutation" import { FSUtil } from "../fs-util" import { LocationMutation } from "../location-mutation" @@ -31,8 +31,10 @@ export const Input = Schema.Struct({ }), }) +const FileInfo = FileDiff.Info.mapFields(Struct.omit(["patch"])) + export const Output = Schema.Struct({ - files: Schema.Array(FileDiff.Info), + files: Schema.Array(FileInfo), replacements: Schema.Number, }) export type Output = typeof Output.Type @@ -200,7 +202,6 @@ export const Plugin = { files: [ { file: result.resource, - patch: createTwoFilesPatch(result.resource, result.resource, source.text, replaced), status: "modified" as const, ...counts, }, diff --git a/packages/core/src/tool/glob.ts b/packages/core/src/tool/glob.ts index 35dc58d6cf..05c3744c0d 100644 --- a/packages/core/src/tool/glob.ts +++ b/packages/core/src/tool/glob.ts @@ -26,6 +26,7 @@ export const Input = Schema.Struct({ export const Output = Schema.Array(FileSystem.Entry) type ModelOutput = typeof Output.Encoded +const StructuredOutput = Schema.Struct({ count: Schema.Number }) /** Format raw search results into the concise line-oriented output models expect. */ export const toModelOutput = (output: ModelOutput) => { @@ -51,6 +52,8 @@ export const Plugin = { "Find files by glob pattern within the active Location. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.", input: Input, output: Output, + structured: StructuredOutput, + toStructuredOutput: ({ output }) => ({ count: output.length }), toModelOutput: ({ output }) => [ { type: "text", diff --git a/packages/core/src/tool/grep.ts b/packages/core/src/tool/grep.ts index ef7c76c745..8012a5b7aa 100644 --- a/packages/core/src/tool/grep.ts +++ b/packages/core/src/tool/grep.ts @@ -31,6 +31,7 @@ export const Input = Schema.Struct({ export const Output = Schema.Array(FileSystem.Match) type ModelOutput = typeof Output.Encoded +const StructuredOutput = Schema.Struct({ matches: Schema.Number }) /** Format raw search matches into the familiar concise model output. */ export const toModelOutput = (output: ModelOutput) => { @@ -65,6 +66,8 @@ export const Plugin = { "Search file contents by regular expression within the active Location or an absolute managed tool-output file. Use a path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise file resources, line numbers, and bounded line previews.", input: Input, output: Output, + structured: StructuredOutput, + toStructuredOutput: ({ output }) => ({ matches: output.length }), toModelOutput: ({ output }) => [ { type: "text", diff --git a/packages/core/src/tool/patch.ts b/packages/core/src/tool/patch.ts index 6925b87793..fadbde3174 100644 --- a/packages/core/src/tool/patch.ts +++ b/packages/core/src/tool/patch.ts @@ -3,8 +3,8 @@ export * as PatchTool from "./patch" import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { ToolFailure } from "@opencode-ai/ai" import { FileDiff } from "@opencode-ai/schema/file-diff" -import { createTwoFilesPatch, diffLines } from "diff" -import { Effect, Schema } from "effect" +import { diffLines } from "diff" +import { Effect, Schema, Struct } from "effect" import { FileMutation } from "../file-mutation" import { FSUtil } from "../fs-util" import { LocationMutation } from "../location-mutation" @@ -26,9 +26,11 @@ export const Applied = Schema.Struct({ target: Schema.String, }) +const FileInfo = FileDiff.Info.mapFields(Struct.omit(["patch"])) + export const Output = Schema.Struct({ applied: Schema.Array(Applied), - files: Schema.Array(FileDiff.Info), + files: Schema.Array(FileInfo), }) export type Output = typeof Output.Type @@ -211,7 +213,7 @@ export const Plugin = { }), } -function patchFile(change: Prepared): typeof FileDiff.Info.Type { +function patchFile(change: Prepared): typeof FileInfo.Type { const counts = diffLines(change.before, change.after).reduce( (result, item) => ({ additions: result.additions + (item.added ? (item.count ?? 0) : 0), @@ -221,7 +223,6 @@ function patchFile(change: Prepared): typeof FileDiff.Info.Type { ) return { file: change.target.resource, - patch: createTwoFilesPatch(change.target.resource, change.target.resource, change.before, change.after), status: change.type === "add" ? "added" : change.type === "delete" ? "deleted" : "modified", ...counts, } diff --git a/packages/core/src/tool/read.ts b/packages/core/src/tool/read.ts index 8f73f7e90c..77224c50e0 100644 --- a/packages/core/src/tool/read.ts +++ b/packages/core/src/tool/read.ts @@ -28,6 +28,31 @@ const LocationInput = Schema.Struct({ }) const Input = LocationInput const Output = Schema.Union([FileSystem.Content, ReadToolFileSystem.TextPage, ReadToolFileSystem.ListPage]) +const StructuredOutput = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("file"), + uri: Schema.String, + name: Schema.String.pipe(Schema.optional), + encoding: FileSystem.Content.fields.encoding, + mime: Schema.String, + bytes: Schema.Number, + truncated: Schema.Boolean, + }), + Schema.Struct({ + type: Schema.Literal("text-page"), + mime: Schema.String, + offset: Schema.Number, + bytes: Schema.Number, + truncated: Schema.Boolean, + next: Schema.Number.pipe(Schema.optional), + }), + Schema.Struct({ + type: Schema.Literal("list-page"), + count: Schema.Number, + truncated: Schema.Boolean, + next: Schema.Number.pipe(Schema.optional), + }), +]) export const Plugin = { id: "opencode.tool.read", @@ -48,15 +73,37 @@ export const Plugin = { "Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page. Relative paths resolve from the current location; absolute paths inside it are accepted, while external absolute paths require external_directory approval.", input: Input, output: Output, - structured: Schema.toEncoded(Output), - // Image base64 reaches the model through content items (normalized generically - // at tool settlement); persisting a second copy in structured would store the - // original unresized bytes in the message row. - toStructuredOutput: ({ output }) => - "encoding" in output && output.encoding === "base64" ? { ...output, content: "" } : output, + structured: StructuredOutput, + toStructuredOutput: ({ output }) => { + if ("encoding" in output) + return { + type: "file" as const, + uri: output.uri, + name: output.name, + encoding: output.encoding, + mime: output.mime, + bytes: Buffer.byteLength(output.content, output.encoding === "base64" ? "base64" : "utf-8"), + truncated: false, + } + if ("content" in output) + return { + type: output.type, + mime: output.mime, + offset: output.offset, + bytes: Buffer.byteLength(output.content, "utf-8"), + truncated: output.truncated, + next: output.next, + } + return { + type: "list-page" as const, + count: output.entries.length, + truncated: output.truncated, + next: output.next, + } + }, toModelOutput: ({ input, output }) => { if (!("encoding" in output) || output.encoding !== "base64" || !SUPPORTED_IMAGE_MIMES.has(output.mime)) - return [] + return [{ type: "text", text: JSON.stringify(output) }] return [ { type: "text", text: "Image read successfully" }, { type: "file", data: output.content, mime: output.mime, name: input.path }, diff --git a/packages/core/src/tool/skill.ts b/packages/core/src/tool/skill.ts index ac7e675e46..30b930f2bf 100644 --- a/packages/core/src/tool/skill.ts +++ b/packages/core/src/tool/skill.ts @@ -22,6 +22,13 @@ export const Output = Schema.Struct({ output: Schema.String, }) +const StructuredOutput = Schema.Struct({ + name: Schema.String, + directory: Schema.String, + bytes: Schema.Number, + truncated: Schema.Boolean, +}) + export const description = [ "Load a specialized skill when the task at hand matches one of the available skills in the instructions.", "", @@ -66,6 +73,13 @@ export const Plugin = { description, input: Input, output: Output, + structured: StructuredOutput, + toStructuredOutput: ({ output }) => ({ + name: output.name, + directory: output.directory, + bytes: Buffer.byteLength(output.output, "utf-8"), + truncated: false, + }), toModelOutput: ({ output }) => [{ type: "text", text: output.output }], execute: (input, context) => Effect.gen(function* () { diff --git a/packages/core/src/tool/webfetch.ts b/packages/core/src/tool/webfetch.ts index e7a372e4b4..f95bc04515 100644 --- a/packages/core/src/tool/webfetch.ts +++ b/packages/core/src/tool/webfetch.ts @@ -38,6 +38,14 @@ const Output = Schema.Struct({ output: Schema.String, }) +const StructuredOutput = Schema.Struct({ + url: Schema.String, + contentType: Schema.String, + format: Input.fields.format, + bytes: Schema.Number, + truncated: Schema.Boolean, +}) + type Format = (typeof Input.Type)["format"] const acceptHeader = (format: Format) => { @@ -126,6 +134,14 @@ export const Plugin = { description, input: Input, output: Output, + structured: StructuredOutput, + toStructuredOutput: ({ input, output }) => ({ + url: output.url, + contentType: output.contentType, + format: input.format, + bytes: Buffer.byteLength(output.output, "utf-8"), + truncated: false, + }), toModelOutput: ({ output }) => [{ type: "text", text: output.output }], execute: (input, context) => Effect.gen(function* () { diff --git a/packages/core/src/tool/websearch.ts b/packages/core/src/tool/websearch.ts index a92ce5f8ef..70c3ffc03c 100644 --- a/packages/core/src/tool/websearch.ts +++ b/packages/core/src/tool/websearch.ts @@ -187,6 +187,12 @@ const Output = Schema.Struct({ text: Schema.String, }) +const StructuredOutput = Schema.Struct({ + provider: Provider, + bytes: Schema.Number, + truncated: Schema.Boolean, +}) + export const Plugin = { id: "opencode.tool.websearch", effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) { @@ -202,6 +208,12 @@ export const Plugin = { description, input: Input, output: Output, + structured: StructuredOutput, + toStructuredOutput: ({ output }) => ({ + provider: output.provider, + bytes: Buffer.byteLength(output.text, "utf-8"), + truncated: false, + }), toModelOutput: ({ output }) => [{ type: "text", text: output.text }], execute: (input, context) => { const provider = selectProvider(context.sessionID, config, config.provider) diff --git a/packages/core/test/session-runner-tool-registry.test.ts b/packages/core/test/session-runner-tool-registry.test.ts index 19ba17d5e6..00ac5ca5ce 100644 --- a/packages/core/test/session-runner-tool-registry.test.ts +++ b/packages/core/test/session-runner-tool-registry.test.ts @@ -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, diff --git a/packages/core/test/tool-edit.test.ts b/packages/core/test/tool-edit.test.ts index ded236e654..5b519038ee 100644 --- a/packages/core/test/tool-edit.test.ts +++ b/packages/core/test/tool-edit.test.ts @@ -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) }), diff --git a/packages/core/test/tool-output-store.test.ts b/packages/core/test/tool-output-store.test.ts index 4de63dfdcd..6256b8bed6 100644 --- a/packages/core/test/tool-output-store.test.ts +++ b/packages/core/test/tool-output-store.test.ts @@ -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", + }) }), ), ) diff --git a/packages/core/test/tool-patch.test.ts b/packages/core/test/tool-patch.test.ts index 458f8c3758..7a4136e7f2 100644 --- a/packages/core/test/tool-patch.test.ts +++ b/packages/core/test/tool-patch.test.ts @@ -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: ["*"] }, ]) diff --git a/packages/core/test/tool-read.test.ts b/packages/core/test/tool-read.test.ts index 6437e3f128..a9088a9af7 100644 --- a/packages/core/test/tool-read.test.ts +++ b/packages/core/test/tool-read.test.ts @@ -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 } }, diff --git a/packages/core/test/tool-skill.test.ts b/packages/core/test/tool-skill.test.ts index 8cc3eb25df..2301e12102 100644 --- a/packages/core/test/tool-skill.test.ts +++ b/packages/core/test/tool-skill.test.ts @@ -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"] }, diff --git a/packages/core/test/tool-webfetch.test.ts b/packages/core/test/tool-webfetch.test.ts index fc38296c9b..c44c50740f 100644 --- a/packages/core/test/tool-webfetch.test.ts +++ b/packages/core/test/tool-webfetch.test.ts @@ -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" }], }, }) diff --git a/packages/core/test/tool-websearch.test.ts b/packages/core/test/tool-websearch.test.ts index 8d1cc1e679..1671366a59 100644 --- a/packages/core/test/tool-websearch.test.ts +++ b/packages/core/test/tool-websearch.test.ts @@ -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" }], }, })