Compare commits

...

3 Commits

Author SHA1 Message Date
Aiden Cline 988a02ca40 refactor(core): simplify tool output cleanup 2026-08-06 16:10:13 -05:00
Aiden Cline 9353e147fc refactor(core): rename tool truncation service 2026-08-06 16:05:28 -05:00
Aiden Cline d48f1f9909 feat(core): bound tool output 2026-08-06 16:00:17 -05:00
6 changed files with 210 additions and 3 deletions
+2
View File
@@ -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,
+4
View File
@@ -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<SessionSchema.ID>()
@@ -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,
],
})
+105
View File
@@ -0,0 +1,105 @@
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 "./util/identifier"
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<Result>
readonly cleanup: () => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ToolOutput") {}
const timestamp = (id: string) => Number(BigInt(`0x${id.slice(0, 12)}`) / 0x1000n)
const cleanup = Effect.fn("ToolOutput.cleanup")(function* (fs: FSUtil.Interface, directory: string) {
const cutoff = timestamp(Identifier.create(false, 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 (timestamp(entry.slice("tool_".length)) >= 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 === true) 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 (lines.length <= maxLines && Buffer.byteLength(text, "utf-8") <= maxBytes)
return { ...result, metadata: { ...result.metadata, truncated: false } }
const kept: string[] = []
let bytes = 0
for (const line of lines.slice(0, maxLines)) {
const size = Buffer.byteLength(line, "utf-8") + (kept.length > 0 ? 1 : 0)
if (bytes + size > maxBytes) break
kept.push(line)
bytes += size
}
const file = path.join(directory, `tool_${Identifier.ascending()}`)
yield* fs.ensureDir(directory).pipe(Effect.orDie)
yield* fs.writeFileString(file, text).pipe(Effect.orDie)
return {
...result,
content: [
{
type: "text" as const,
text: `${kept.join("\n")}\n\n... output truncated; full content saved to ${file} ...`,
},
...content.filter((item) => item.type === "file"),
],
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],
})
+1
View File
@@ -119,6 +119,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
+95
View File
@@ -0,0 +1,95 @@
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/util/identifier"
import { tmpdir } from "./fixture/tmpdir"
import { it } from "./lib/effect"
const withStore = <A, E, R>(
body: (output: ToolOutput.Interface, fs: FSUtil.Interface, root: string) => Effect.Effect<A, E, R>,
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\n\n... output truncated; full content saved to ${outputPath} ...` },
])
}),
new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }) }),
),
)
it.live("skips results already marked truncated", () =>
withStore((output) =>
Effect.gen(function* () {
const result = { content: "one\ntwo", metadata: { truncated: true, source: "tool" } }
expect(yield* output.truncate(result)).toBe(result)
}),
),
)
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("removes expired managed files", () =>
withStore((output, fs, root) =>
Effect.gen(function* () {
const directory = path.join(root, ToolOutput.DIRECTORY)
const old = path.join(directory, `tool_${Identifier.create(false, Date.now() - 8 * 24 * 60 * 60 * 1_000)}`)
const recent = path.join(directory, `tool_${Identifier.ascending()}`)
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)
}),
),
)
})
+3 -3
View File
@@ -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}` },
@@ -732,6 +730,7 @@ describe("ReadTool", () => {
})
expect(result).toMatchObject({ status: "completed", 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",
@@ -806,6 +805,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",