From 5fb0d7c99cc13a275c2cafa909e163203094d0f7 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 7 Aug 2026 00:02:14 -0500 Subject: [PATCH] feat(core): bound tool output (#40929) --- packages/core/src/location-services.ts | 2 + packages/core/src/session/runner/llm.ts | 4 + packages/core/src/tool-output.ts | 131 +++++++++++++++++++ packages/core/src/tool/plugin/read.ts | 1 + packages/core/test/tool-output.test.ts | 164 ++++++++++++++++++++++++ packages/core/test/tool-read.test.ts | 6 +- 6 files changed, 305 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/tool-output.ts create mode 100644 packages/core/test/tool-output.test.ts diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 547cdd0e455..38317b7d783 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -46,6 +46,7 @@ import { SessionGenerateNode } from "./session/generate-node" import { McpTool } from "./tool/mcp" import { ReadToolFileSystem } from "./tool/read-filesystem" import { Tool } from "./tool" +import { ToolOutput } from "./tool-output" import { Vcs } from "./vcs" export { LocationServiceMap } from "./location-service-map" @@ -78,6 +79,7 @@ const locationServiceNodes = [ MCP.node, Permission.node, Tool.node, + ToolOutput.node, Image.node, SkillInstructions.node, ReferenceInstructions.node, diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 04bf95ad07e..2fbd713131c 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -32,6 +32,7 @@ import { StepFailedError } from "../error" import { toSessionError } from "../to-session-error" import { SessionRunnerRetry } from "./retry" import { SessionUsage } from "../usage" +import { ToolOutput } from "../../tool-output" /** How one model call ended: settled, awaiting a scheduled retry, or restarted by compaction. */ type CallOutcome = Data.TaggedEnum<{ @@ -107,6 +108,7 @@ const layer = Layer.effect( const db = (yield* Database.Service).db const compaction = yield* SessionCompaction.Service const title = yield* SessionTitle.Service + const toolOutput = yield* ToolOutput.Service // Title generation is a side effect of a successful step; it must not delay continuation. // The in-flight set coalesces overlapping steps while title presence records success durably. const titlesRunning = new Set() @@ -334,6 +336,7 @@ const layer = Layer.effect( ).pipe( // The fiber owns its call: it publishes its own completion, masked so a // finished execution always reaches its durable settlement. + Effect.flatMap(toolOutput.truncate), Effect.flatMap((outcome) => publisher.toolExecution(event.id, event.name, outcome)), Effect.catchTag("Tool.Error", (error) => publisher.failTool(event.id, toSessionError(error)).pipe(Effect.asVoid), @@ -562,6 +565,7 @@ export const node = makeLocationNode({ SessionCompaction.node, SessionTitle.node, Snapshot.node, + ToolOutput.node, Database.node, ], }) diff --git a/packages/core/src/tool-output.ts b/packages/core/src/tool-output.ts new file mode 100644 index 00000000000..fb63a783c33 --- /dev/null +++ b/packages/core/src/tool-output.ts @@ -0,0 +1,131 @@ +export * as ToolOutput from "./tool-output" + +import path from "path" +import type { Tool } from "@opencode-ai/schema/tool" +import { Context, Duration, Effect, Layer, Schedule } from "effect" +import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node" +import { FSUtil } from "@opencode-ai/util/fs-util" +import { Global } from "@opencode-ai/util/global" +import { Config } from "./config" +import { Identifier } from "./id/id" + +export const MAX_LINES = 2_000 +export const MAX_BYTES = 50 * 1024 // 50 KiB +export const RETENTION = Duration.days(7) +export const DIRECTORY = "tool-output" + +type Result = Tool.Result + +export interface Interface { + readonly truncate: (result: Result) => Effect.Effect + readonly cleanup: () => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/ToolOutput") {} + +const cleanup = Effect.fn("ToolOutput.cleanup")(function* (fs: FSUtil.Interface, directory: string) { + const cutoff = Identifier.timestamp( + Identifier.create("tool", "ascending", Date.now() - Duration.toMillis(RETENTION)), + ) + const entries = yield* fs.readDirectory(directory).pipe( + Effect.map((entries) => entries.filter((entry) => /^tool_[0-9a-f]{12}/.test(entry))), + Effect.catch(() => Effect.succeed([])), + ) + for (const entry of entries) { + if (Identifier.timestamp(entry) >= cutoff) continue + yield* fs.remove(path.join(directory, entry)).pipe(Effect.catch(() => Effect.void)) + } +}) + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const config = yield* Config.Service + const fs = yield* FSUtil.Service + const global = yield* Global.Service + const directory = path.join(global.data, DIRECTORY) + + const truncate = Effect.fn("ToolOutput.truncate")(function* (result: Result) { + if (result.metadata?.truncated !== undefined) return result + const content = + typeof result.content === "string" ? [{ type: "text" as const, text: result.content }] : (result.content ?? []) + const text = content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n") + const configured = Config.latest(yield* config.entries(), "tool_output") + const maxLines = configured?.max_lines ?? MAX_LINES + const maxBytes = configured?.max_bytes ?? MAX_BYTES + const lines = text.split("\n") + if (text.endsWith("\n")) lines.pop() + const totalBytes = Buffer.byteLength(text, "utf-8") + if (lines.length <= maxLines && totalBytes <= maxBytes) + return { ...result, metadata: { ...result.metadata, truncated: false } } + + const kept: string[] = [] + let bytes = 0 + let hitBytes = false + for (const line of lines.slice(0, maxLines)) { + const size = Buffer.byteLength(line, "utf-8") + (kept.length > 0 ? 1 : 0) + if (bytes + size > maxBytes) { + hitBytes = true + break + } + kept.push(line) + bytes += size + } + if (!hitBytes && kept.length === lines.length && totalBytes > bytes) hitBytes = true + const removed = hitBytes ? totalBytes - bytes : lines.length - kept.length + const unit = hitBytes ? (removed === 1 ? "byte" : "bytes") : removed === 1 ? "line" : "lines" + const file = path.join(directory, Identifier.ascending("tool")) + yield* fs.ensureDir(directory).pipe(Effect.orDie) + yield* fs.writeFileString(file, text).pipe(Effect.orDie) + const marker = `... ${removed} ${unit} truncated; full content saved to ${file} ...` + const bounded: Tool.Content[] = [] + let remaining = kept.join("\n").length + let seenText = false + let marked = false + for (const item of content) { + if (item.type === "file") { + bounded.push(item) + continue + } + if (seenText && remaining > 0) remaining-- + seenText = true + if (remaining >= item.text.length) { + bounded.push(item) + remaining -= item.text.length + continue + } + if (remaining > 0) bounded.push({ ...item, text: item.text.slice(0, remaining) }) + if (!marked) bounded.push({ type: "text", text: marker }) + remaining = 0 + marked = true + } + if (!marked) bounded.push({ type: "text", text: marker }) + return { + ...result, + content: bounded, + metadata: { ...result.metadata, truncated: true, outputPath: file }, + } + }) + + return Service.of({ truncate, cleanup: () => cleanup(fs, directory) }) + }), +) + +const cleanupLayer = Layer.effectDiscard( + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const global = yield* Global.Service + yield* cleanup(fs, path.join(global.data, DIRECTORY)).pipe( + Effect.repeat(Schedule.spaced(Duration.hours(1))), + Effect.forkScoped, + ) + }), +) + +const cleanupNode = makeGlobalNode({ name: "tool-output-cleanup", layer: cleanupLayer, deps: [FSUtil.node, Global.node] }) + +export const node = makeLocationNode({ + service: Service, + layer, + deps: [Config.node, FSUtil.node, Global.node, cleanupNode], +}) diff --git a/packages/core/src/tool/plugin/read.ts b/packages/core/src/tool/plugin/read.ts index 9cd2c9b4672..da5eff9574a 100644 --- a/packages/core/src/tool/plugin/read.ts +++ b/packages/core/src/tool/plugin/read.ts @@ -114,6 +114,7 @@ export const Plugin = { Effect.map((output) => ({ output, content: toModelContent(input.path, input.offset, output), + metadata: { truncated: output.type === "file" ? false : output.truncated }, })), Effect.mapError((error) => { if (error instanceof ToolFailure) return error diff --git a/packages/core/test/tool-output.test.ts b/packages/core/test/tool-output.test.ts new file mode 100644 index 00000000000..1f2c5af2f2c --- /dev/null +++ b/packages/core/test/tool-output.test.ts @@ -0,0 +1,164 @@ +import { describe, expect } from "bun:test" +import path from "path" +import { Effect, Layer, Stream } from "effect" +import { Config } from "@opencode-ai/core/config" +import { Document, Info } from "@opencode-ai/schema/config" +import { ConfigToolOutput } from "@opencode-ai/schema/config/tool-output" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/util/effect/layer-node" +import { ToolOutput } from "@opencode-ai/core/tool-output" +import { FSUtil } from "@opencode-ai/util/fs-util" +import { Global } from "@opencode-ai/util/global" +import { Identifier } from "@opencode-ai/core/id/id" +import { tmpdir } from "./fixture/tmpdir" +import { it } from "./lib/effect" + +const withStore = ( + body: (output: ToolOutput.Interface, fs: FSUtil.Interface, root: string) => Effect.Effect, + info = new Info(), +) => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => { + const config = Layer.succeed( + Config.Service, + Config.Service.of({ + entries: () => Effect.succeed([new Document({ type: "document", info })]), + changes: () => Stream.empty, + }), + ) + const layer = AppNodeBuilder.build(LayerNode.group([ToolOutput.node, FSUtil.node]), [ + [Config.node, config], + [Global.node, Global.layerWith({ data: tmp.path })], + ]) + return Effect.gen(function* () { + return yield* body(yield* ToolOutput.Service, yield* FSUtil.Service, tmp.path) + }).pipe(Effect.provide(layer)) + }, + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + +describe("ToolOutput", () => { + it.live("writes oversized text and returns a bounded preview", () => + withStore( + (service, fs) => + Effect.gen(function* () { + const output = { items: [1, 2, 3] } + const result = yield* service.truncate({ output, content: "one\ntwo\nthree" }) + expect(result.output).toBe(output) + expect(result.metadata).toMatchObject({ truncated: true }) + const outputPath = result.metadata?.outputPath + expect(typeof outputPath).toBe("string") + if (typeof outputPath !== "string") return + expect(yield* fs.readFileString(outputPath)).toBe("one\ntwo\nthree") + expect(result.content).toEqual([ + { type: "text", text: "one\ntwo" }, + { type: "text", text: `... 1 line truncated; full content saved to ${outputPath} ...` }, + ]) + }), + new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }) }), + ), + ) + + it.live("reports bytes omitted by the byte limit", () => + withStore( + (output) => + Effect.gen(function* () { + const result = yield* output.truncate({ content: "one\ntwo" }) + expect(result.content).toEqual([ + { type: "text", text: "one" }, + { + type: "text", + text: expect.stringMatching(/^\.\.\. 4 bytes truncated; full content saved to .+ \.\.\.$/), + }, + ]) + }), + new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 100, max_bytes: 5 }) }), + ), + ) + + it.live("preserves mixed content ordering", () => + withStore( + (output) => + Effect.gen(function* () { + const file = { type: "file" as const, uri: "file:///image.png", mime: "image/png" } + const result = yield* output.truncate({ + content: [{ type: "text", text: "before" }, file, { type: "text", text: "after\nomitted" }], + }) + expect(result.content).toEqual([ + { type: "text", text: "before" }, + file, + { type: "text", text: "after" }, + { type: "text", text: expect.stringMatching(/^\.\.\. 1 line truncated; full content saved to /) }, + ]) + }), + new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }) }), + ), + ) + + it.live("skips results that report a truncation state", () => + withStore((output) => + Effect.gen(function* () { + const truncated = { content: "one\ntwo", metadata: { truncated: true, source: "tool" } } + const retained = { content: "one\ntwo", metadata: { truncated: false, source: "tool" } } + expect(yield* output.truncate(truncated)).toBe(truncated) + expect(yield* output.truncate(retained)).toBe(retained) + }), + ), + ) + + it.live("marks results that fit without changing their content", () => + withStore((output) => + Effect.gen(function* () { + const content = [{ type: "text" as const, text: "small" }] + expect(yield* output.truncate({ content })).toEqual({ content, metadata: { truncated: false } }) + }), + ), + ) + + it.live("does not count a trailing newline as another line", () => + withStore( + (output) => + Effect.gen(function* () { + expect(yield* output.truncate({ content: "one\ntwo\n" })).toEqual({ + content: "one\ntwo\n", + metadata: { truncated: false }, + }) + }), + new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }) }), + ), + ) + + it.live("reports a trailing newline omitted by the byte limit", () => + withStore( + (output) => + Effect.gen(function* () { + const result = yield* output.truncate({ content: "one\n" }) + expect(result.content).toEqual([ + { type: "text", text: "one" }, + { type: "text", text: expect.stringMatching(/^\.\.\. 1 byte truncated; full content saved to /) }, + ]) + }), + new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 3 }) }), + ), + ) + + it.live("removes expired managed files", () => + withStore((output, fs, root) => + Effect.gen(function* () { + const directory = path.join(root, ToolOutput.DIRECTORY) + const old = path.join( + directory, + Identifier.create("tool", "ascending", Date.now() - 8 * 24 * 60 * 60 * 1_000), + ) + const recent = path.join(directory, Identifier.ascending("tool")) + yield* fs.ensureDir(directory) + yield* fs.writeFileString(old, "old") + yield* fs.writeFileString(recent, "recent") + yield* output.cleanup() + expect(yield* fs.exists(old)).toBe(false) + expect(yield* fs.exists(recent)).toBe(true) + }), + ), + ) +}) diff --git a/packages/core/test/tool-read.test.ts b/packages/core/test/tool-read.test.ts index 650808e4d6a..3cd9ee8d492 100644 --- a/packages/core/test/tool-read.test.ts +++ b/packages/core/test/tool-read.test.ts @@ -311,9 +311,7 @@ describe("ReadTool", () => { }) expect(settled.status).toBe("completed") if (settled.status !== "completed") return - // Image base64 is carried by the content file item only; read produces no - // metadata, so the original bytes are never persisted twice. - expect(settled.metadata).toBeUndefined() + expect(settled.metadata).toEqual({ truncated: false }) expect(settled.content).toMatchObject([ { type: "text", text: "Image read successfully" }, { type: "file", mime: "image/png", uri: `data:image/png;base64,${png}` }, @@ -731,6 +729,7 @@ describe("ReadTool", () => { output: { entries: listResult.entries, truncated: true, next: 4 }, }) if (result.status !== "completed") return + expect(result.metadata).toEqual({ truncated: true }) expect(result.content).toEqual([ { type: "text", @@ -805,6 +804,7 @@ describe("ReadTool", () => { output: { type: "text-page", content: "hello", mime: "text/plain", offset: 2, truncated: true, next: 3 }, }) if (result.status !== "completed") return + expect(result.metadata).toEqual({ truncated: true }) expect(result.content).toEqual([ { type: "text",