From 8a402d3f033c8f4dfd0946ad2629f9aec6ce0b87 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 19 Aug 2026 18:25:22 +0530 Subject: [PATCH] refactor(core): move tool output config into state (#43422) --- .../core/src/config/plugin/tool-output.ts | 33 ++++++++++ packages/core/src/plugin/internal.ts | 6 ++ packages/core/src/tool-output.ts | 43 +++++++++---- packages/core/test/config/tool-output.test.ts | 62 +++++++++++++++++++ packages/core/test/tool-output.test.ts | 21 +++---- 5 files changed, 142 insertions(+), 23 deletions(-) create mode 100644 packages/core/src/config/plugin/tool-output.ts create mode 100644 packages/core/test/config/tool-output.test.ts diff --git a/packages/core/src/config/plugin/tool-output.ts b/packages/core/src/config/plugin/tool-output.ts new file mode 100644 index 0000000000..cfbb49e5cc --- /dev/null +++ b/packages/core/src/config/plugin/tool-output.ts @@ -0,0 +1,33 @@ +export * as ConfigToolOutputPlugin from "./tool-output.js" + +import { define } from "@opencode-ai/plugin/effect/plugin" +import { Effect, Stream } from "effect" +import { Config } from "../../config.js" +import { ToolOutput } from "../../tool-output.js" + +export const Plugin = define({ + id: "opencode.config.tool-output", + effect: Effect.fn(function* (ctx) { + const config = yield* Config.Service + const output = yield* ToolOutput.Service + const loaded = { entries: yield* config.entries() } + const reload = config.entries().pipe( + Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))), + Effect.andThen(output.reload()), + ) + yield* ctx.event.subscribe().pipe( + Stream.filter((event) => event.type === "config.updated"), + Stream.runForEach(() => reload), + Effect.forkScoped({ startImmediately: true }), + ) + loaded.entries = yield* config.entries() + yield* output.transform((draft) => { + const configured = Config.latest(loaded.entries, "tool_output") + if (!configured) return + draft.configure({ + ...(configured.max_lines === undefined ? {} : { maxLines: configured.max_lines }), + ...(configured.max_bytes === undefined ? {} : { maxBytes: configured.max_bytes }), + }) + }) + }), +}) diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index 98d0c59f1b..26d96a3aa5 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -21,6 +21,7 @@ import { ConfigProviderPlugin } from "../config/plugin/provider.js" import { ConfigPolicyPlugin } from "../config/plugin/policy.js" import { ConfigReferencePlugin } from "../config/plugin/reference.js" import { ConfigSkillPlugin } from "../config/plugin/skill.js" +import { ConfigToolOutputPlugin } from "../config/plugin/tool-output.js" import { ConfigPluginSource } from "../config/plugin/source.js" import { ConfigWebSearchPlugin } from "../config/plugin/websearch.js" import { Bus } from "../bus.js" @@ -60,6 +61,7 @@ import { ShellTool } from "../tool/plugin/shell.js" import { SkillTool } from "../tool/plugin/skill.js" import { SubagentTool } from "../tool/plugin/subagent.js" import { Tool } from "../tool.js" +import { ToolOutput } from "../tool-output.js" import { WebFetchTool } from "../tool/plugin/webfetch.js" import { WebSearchTool } from "../tool/plugin/websearch.js" import { WellKnown } from "../wellknown.js" @@ -115,6 +117,7 @@ const services = Effect.fn("PluginInternal.services")(function* () { const skill = yield* Skill.Service const skillDiscovery = yield* SkillDiscovery.Service const tools = yield* Tool.Service + const toolOutput = yield* ToolOutput.Service const watcher = yield* Watcher.Service const wellknown = yield* WellKnown.Service return Context.mergeAll( @@ -154,6 +157,7 @@ const services = Effect.fn("PluginInternal.services")(function* () { Context.make(Skill.Service, skill), Context.make(SkillDiscovery.Service, skillDiscovery), Context.make(Tool.Service, tools), + Context.make(ToolOutput.Service, toolOutput), Context.make(Watcher.Service, watcher), Context.make(WellKnown.Service, wellknown), ) @@ -200,6 +204,7 @@ export const requirements = LayerNode.group([ Skill.node, SkillDiscovery.node, Tool.node, + ToolOutput.node, Watcher.node, WellKnown.node, ]) @@ -240,6 +245,7 @@ const post = [ ConfigCommandPlugin.Plugin, ConfigFormatterPlugin.Plugin, ConfigImagePlugin.Plugin, + ConfigToolOutputPlugin.Plugin, ConfigSkillPlugin.Plugin, ConfigProviderPlugin.Plugin, ConfigWebSearchPlugin.Plugin, diff --git a/packages/core/src/tool-output.ts b/packages/core/src/tool-output.ts index 4f926fd405..17fe252dff 100644 --- a/packages/core/src/tool-output.ts +++ b/packages/core/src/tool-output.ts @@ -6,8 +6,8 @@ import { Context, Duration, Effect, Layer, Option, 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.js" import { Identifier } from "./id/id.js" +import { State } from "./state.js" export const MAX_LINES = 2_000 export const MAX_BYTES = 50 * 1024 // 50 KiB @@ -16,7 +16,16 @@ export const DIRECTORY = "tool-output" type Result = Tool.Result -export interface Interface { +type Limits = { + maxLines: number + maxBytes: number +} + +export type Draft = { + configure: (limits: Partial) => void +} + +export interface Interface extends State.Transformable { readonly truncate: (result: Result) => Effect.Effect readonly cleanup: () => Effect.Effect } @@ -46,31 +55,38 @@ const cleanup = Effect.fn("ToolOutput.cleanup")(function* (fs: FSUtil.Interface, 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 state = State.create({ + name: "tool-output", + initial: () => ({ maxLines: MAX_LINES, maxBytes: MAX_BYTES }), + draft: (draft) => ({ + configure: (limits) => { + if (limits.maxLines !== undefined) draft.maxLines = limits.maxLines + if (limits.maxBytes !== undefined) draft.maxBytes = limits.maxBytes + }, + }), + }) const truncate = Effect.fnUntraced(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 limits = state.get() const lines = text.split("\n") if (text.endsWith("\n")) lines.pop() const totalBytes = Buffer.byteLength(text, "utf-8") - if (lines.length <= maxLines && totalBytes <= maxBytes) + if (lines.length <= limits.maxLines && totalBytes <= limits.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)) { + for (const line of lines.slice(0, limits.maxLines)) { const size = Buffer.byteLength(line, "utf-8") + (kept.length > 0 ? 1 : 0) - if (bytes + size > maxBytes) { + if (bytes + size > limits.maxBytes) { hitBytes = true break } @@ -113,7 +129,12 @@ const layer = Layer.effect( } }) - return Service.of({ truncate, cleanup: () => cleanup(fs, directory) }) + return Service.of({ + transform: state.transform, + reload: state.reload, + truncate, + cleanup: () => cleanup(fs, directory), + }) }), ) @@ -137,5 +158,5 @@ const cleanupNode = makeGlobalNode({ export const node = makeLocationNode({ service: Service, layer, - deps: [Config.node, FSUtil.node, Global.node, cleanupNode], + deps: [FSUtil.node, Global.node, cleanupNode], }) diff --git a/packages/core/test/config/tool-output.test.ts b/packages/core/test/config/tool-output.test.ts new file mode 100644 index 0000000000..b6920a9776 --- /dev/null +++ b/packages/core/test/config/tool-output.test.ts @@ -0,0 +1,62 @@ +import { describe, expect } from "bun:test" +import { Bus } from "@opencode-ai/core/bus" +import { Config } from "@opencode-ai/core/config" +import { ConfigToolOutputPlugin } from "@opencode-ai/core/config/plugin/tool-output" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { Plugin } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" +import { ToolOutput } from "@opencode-ai/core/tool-output" +import { Document, Event, Info } from "@opencode-ai/schema/config" +import { ConfigToolOutput } from "@opencode-ai/schema/config/tool-output" +import { Global } from "@opencode-ai/util/global" +import { Effect } from "effect" +import { tmpdir } from "../fixture/tmpdir" +import { it } from "../lib/effect" +import { PluginTestLayer } from "../plugin/fixture" + +describe("ConfigToolOutputPlugin.Plugin", () => { + it.live("applies limits and reloads changed config", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => + Effect.gen(function* () { + const output = yield* ToolOutput.Service + const bus = yield* Bus.Service + const config = yield* Config.Test + const plugins = yield* Plugin.Service + yield* ConfigToolOutputPlugin.Plugin.effect(yield* PluginHost.make(plugins)) + + expect((yield* output.truncate({ content: "one\ntwo" })).metadata?.truncated).toBe(true) + + yield* config.setEntries([ + new Document({ + type: "document", + info: new Info({ + tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }), + }), + }), + ]) + yield* bus.publish(Event.Updated, {}) + for (let attempt = 0; attempt < 200; attempt++) { + const result = yield* output.truncate({ content: "one\ntwo" }) + if (result.metadata?.truncated === false) return + yield* Effect.sleep("10 millis") + } + yield* Effect.die(new Error("Timed out waiting for tool output config reload")) + }).pipe( + Effect.provide(AppNodeBuilder.build(ToolOutput.node, [[Global.node, Global.layerWith({ data: tmp.path })]])), + ), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.provide(PluginTestLayer), + Effect.provide( + Config.testLayer([ + new Document({ + type: "document", + info: new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 1 }) }), + }), + ]), + ), + ), + ) +}) diff --git a/packages/core/test/tool-output.test.ts b/packages/core/test/tool-output.test.ts index b0bc3ba676..86b9d8eec3 100644 --- a/packages/core/test/tool-output.test.ts +++ b/packages/core/test/tool-output.test.ts @@ -1,9 +1,6 @@ import { describe, expect } from "bun:test" import path from "path" import { Effect } 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" @@ -15,18 +12,18 @@ import { it } from "./lib/effect" const withStore = ( body: (output: ToolOutput.Interface, fs: FSUtil.Interface, root: string) => Effect.Effect, - info = new Info(), + limits?: { maxLines?: number; maxBytes?: number }, ) => Effect.acquireUseRelease( Effect.promise(() => tmpdir()), (tmp) => { - const config = Config.testLayer([new Document({ type: "document", info })]) 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) + const output = yield* ToolOutput.Service + if (limits) yield* output.transform((draft) => draft.configure(limits)) + return yield* body(output, yield* FSUtil.Service, tmp.path) }).pipe(Effect.provide(layer)) }, (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), @@ -50,7 +47,7 @@ describe("ToolOutput", () => { { 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 }) }), + { maxLines: 2, maxBytes: 1_000 }, ), ) @@ -67,7 +64,7 @@ describe("ToolOutput", () => { }, ]) }), - new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 100, max_bytes: 5 }) }), + { maxLines: 100, maxBytes: 5 }, ), ) @@ -86,7 +83,7 @@ describe("ToolOutput", () => { { 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 }) }), + { maxLines: 2, maxBytes: 1_000 }, ), ) @@ -119,7 +116,7 @@ describe("ToolOutput", () => { metadata: { truncated: false }, }) }), - new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }) }), + { maxLines: 2, maxBytes: 1_000 }, ), ) @@ -133,7 +130,7 @@ describe("ToolOutput", () => { { 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 }) }), + { maxLines: 2, maxBytes: 3 }, ), )