refactor(core): move tool output config into state (#43422)
This commit is contained in:
@@ -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 }),
|
||||
})
|
||||
})
|
||||
}),
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Limits>) => void
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly truncate: (result: Result) => Effect.Effect<Result>
|
||||
readonly cleanup: () => Effect.Effect<void>
|
||||
}
|
||||
@@ -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<Limits, Draft>({
|
||||
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],
|
||||
})
|
||||
|
||||
@@ -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 }) }),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -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 = <A, E, R>(
|
||||
body: (output: ToolOutput.Interface, fs: FSUtil.Interface, root: string) => Effect.Effect<A, E, R>,
|
||||
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 },
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user