refactor(core): preserve normalized tool results (#46927)

This commit is contained in:
Kit Langton
2026-09-02 20:39:55 -04:00
committed by GitHub
Unverified
parent 5e38101fba
commit b257d476c9
8 changed files with 66 additions and 27 deletions
+3 -1
View File
@@ -53,7 +53,9 @@ export interface Prepared {
* One request-scoped execution operation. Unknown and hook-removed calls
* fail individually through the same seam.
*/
readonly executeTool: (input: Parameters<Tool.Snapshot["execute"]>[0]) => Effect.Effect<Tool.Result, ExecuteError>
readonly executeTool: (
input: Parameters<Tool.Snapshot["execute"]>[0],
) => Effect.Effect<Tool.NormalizedResult, ExecuteError>
}
interface PrepareInput {
@@ -4,7 +4,7 @@ import type { Model } from "@opencode-ai/schema/model"
import type { RelativePath } from "@opencode-ai/schema/schema"
import type { Snapshot } from "@opencode-ai/schema/snapshot"
import { Effect, Fiber, Iterable } from "effect"
import { isArrayNonEmpty, isReadonlyArrayNonEmpty } from "effect/Array"
import { isReadonlyArrayNonEmpty } from "effect/Array"
import { Bus } from "../../bus.js"
import { SessionEvent } from "../event.js"
import { SessionMessage } from "../message.js"
@@ -12,7 +12,7 @@ import { SessionSchema } from "../schema.js"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Money } from "@opencode-ai/schema/money"
import { SessionUsage } from "../usage.js"
import { Tool } from "@opencode-ai/schema/tool"
import type { Tool } from "../../tool.js"
type Input = {
readonly sessionID: SessionSchema.ID
@@ -557,20 +557,15 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
})
/** Publishes one canonical terminal event for a locally executed tool call. */
const toolExecution = Effect.fnUntraced(function* (id: string, name: string, result: Tool.Result) {
const toolExecution = Effect.fnUntraced(function* (id: string, name: string, result: Tool.NormalizedResult) {
const tool = tools.get(id)
if (!tool?.called) return yield* Effect.die(new Error(`Tool execution before call: ${id}`))
if (tool.name !== name)
return yield* Effect.die(new Error(`Tool execution name changed for ${id}: ${tool.name} -> ${name}`))
if (tool.settled) return yield* Effect.die(new Error(`Duplicate tool execution: ${id}`))
tool.settled = true
const content =
typeof result.content === "string"
? [{ type: "text" as const, text: result.content }]
: result.content === undefined
? []
: [...result.content]
if (!isArrayNonEmpty(content)) return yield* Effect.die(new Error(`Tool execution has no content: ${id}`))
const content = result.content
if (!isReadonlyArrayNonEmpty(content)) return yield* Effect.die(new Error(`Tool execution has no content: ${id}`))
yield* bus.publish(SessionEvent.Tool.Success, {
sessionID: input.sessionID,
assistantMessageID,
+3 -4
View File
@@ -1,7 +1,7 @@
export * as ToolOutput from "./tool-output.js"
import path from "path"
import type { Tool } from "@opencode-ai/schema/tool"
import type { Tool } from "./tool.js"
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"
@@ -15,7 +15,7 @@ export const MAX_BYTES = 50 * 1024 // 50 KiB
export const RETENTION = Duration.days(7)
export const DIRECTORY = "tool-output"
type Result = Tool.Result
type Result = Tool.NormalizedResult
type Limits = {
maxLines: number
@@ -64,8 +64,7 @@ const layer = Layer.effect(
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 content = result.content
const text = content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n")
const limits = state.get()
const lines = text.split("\n")
+6 -1
View File
@@ -42,6 +42,11 @@ export interface Interface extends State.Transformable<Editor> {
readonly snapshot: (permissions?: Permission.Ruleset) => Effect.Effect<Snapshot>
}
/** A local execution result after hooks and content normalization. */
export interface NormalizedResult extends Tool.Result {
readonly content: ReadonlyArray<Tool.Content>
}
export interface Snapshot {
readonly definitions: ReadonlyArray<ToolDefinition>
readonly codeModeCatalog?: CodeModeCatalog.Inventory
@@ -53,7 +58,7 @@ export interface Snapshot {
readonly progress?: (update: Tool.Metadata) => Effect.Effect<void>
/** Surviving request definitions, keyed by the names advertised after session context hooks. */
readonly definitions?: ReadonlyMap<string, ToolDefinition>
}) => Effect.Effect<Tool.Result & { readonly content: ReadonlyArray<Tool.Content> }, Tool.Error>
}) => Effect.Effect<NormalizedResult, Tool.Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Tool") {}
@@ -26,7 +26,9 @@ describe("ConfigToolOutputPlugin.Plugin", () => {
const plugins = yield* Plugin.Service
yield* ConfigToolOutputPlugin.Plugin.effect(yield* PluginHost.make(plugins))
expect((yield* output.truncate({ content: "one\ntwo" })).metadata?.truncated).toBe(true)
expect((yield* output.truncate({ content: [{ type: "text", text: "one\ntwo" }] })).metadata?.truncated).toBe(
true,
)
yield* config.setEntries([
new Document({
@@ -38,7 +40,7 @@ describe("ConfigToolOutputPlugin.Plugin", () => {
])
yield* bus.publish(Event.Updated, {})
for (let attempt = 0; attempt < 200; attempt++) {
const result = yield* output.truncate({ content: "one\ntwo" })
const result = yield* output.truncate({ content: [{ type: "text", text: "one\ntwo" }] })
if (result.metadata?.truncated === false) return
yield* Effect.sleep("10 millis")
}
+1 -1
View File
@@ -111,7 +111,7 @@ for (const fixture of [
executeTool: () =>
Effect.sync(() => {
executions++
return { content: "Completed tool" }
return { content: [{ type: "text", text: "Completed tool" }] }
}),
},
retry: (_cause, _error, retry) =>
+9 -7
View File
@@ -4,6 +4,7 @@ import { Effect } from "effect"
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 type { Tool } from "@opencode-ai/core/tool"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Identifier } from "@opencode-ai/core/id/id"
@@ -36,7 +37,7 @@ describe("ToolOutput", () => {
(service, fs) =>
Effect.gen(function* () {
const output = { items: [1, 2, 3] }
const result = yield* service.truncate({ output, content: "one\ntwo\nthree" })
const result = yield* service.truncate({ output, content: [{ type: "text", text: "one\ntwo\nthree" }] })
expect(result.output).toBe(output)
expect(result.metadata).toMatchObject({ truncated: true })
const outputPath = result.metadata?.outputPath
@@ -56,7 +57,7 @@ describe("ToolOutput", () => {
withStore(
(output) =>
Effect.gen(function* () {
const result = yield* output.truncate({ content: "one\ntwo" })
const result = yield* output.truncate({ content: [{ type: "text", text: "one\ntwo" }] })
expect(result.content).toEqual([
{ type: "text", text: "one" },
{
@@ -91,8 +92,9 @@ describe("ToolOutput", () => {
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" } }
const content: Tool.NormalizedResult["content"] = [{ type: "text", text: "one\ntwo" }]
const truncated = { content, metadata: { truncated: true, source: "tool" } }
const retained = { content, metadata: { truncated: false, source: "tool" } }
expect(yield* output.truncate(truncated)).toBe(truncated)
expect(yield* output.truncate(retained)).toBe(retained)
}),
@@ -112,8 +114,8 @@ describe("ToolOutput", () => {
withStore(
(output) =>
Effect.gen(function* () {
expect(yield* output.truncate({ content: "one\ntwo\n" })).toEqual({
content: "one\ntwo\n",
expect(yield* output.truncate({ content: [{ type: "text", text: "one\ntwo\n" }] })).toEqual({
content: [{ type: "text", text: "one\ntwo\n" }],
metadata: { truncated: false },
})
}),
@@ -125,7 +127,7 @@ describe("ToolOutput", () => {
withStore(
(output) =>
Effect.gen(function* () {
const result = yield* output.truncate({ content: "one\n" })
const result = yield* output.truncate({ content: [{ type: "text", text: "one\n" }] })
expect(result.content).toEqual([
{ type: "text", text: "one" },
{ type: "text", text: expect.stringMatching(/^\.\.\. 1 byte truncated; full content saved to /) },
+35 -1
View File
@@ -3,11 +3,13 @@ import { Agent } from "@opencode-ai/core/agent"
import type { Permission } from "@opencode-ai/core/permission"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Image } from "@opencode-ai/core/image"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { Session } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { State } from "@opencode-ai/core/state"
import { Tool } from "@opencode-ai/core/tool"
import type { Info } from "@opencode-ai/schema/tool"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { codeModeListings, executeTool, toolDefinitions } from "./lib/tool"
import { Deferred, Effect, Exit, Fiber, Layer, Logger, Schema, SchemaGetter, SchemaIssue, Scope } from "effect"
import { z } from "zod"
@@ -35,7 +37,9 @@ const imageStore = Layer.mock(Image.Service, {
})
},
})
const registryLayer = AppNodeBuilder.build(Tool.node, [Image.node.replace(imageStore)])
const registryLayer = AppNodeBuilder.build(LayerNode.group([Tool.node, PluginHooks.node]), [
Image.node.replace(imageStore),
])
const it = testEffect(registryLayer)
const identity = {
agent: Agent.ID.make("build"),
@@ -843,6 +847,36 @@ describe("Tool", () => {
])
}),
)
;[
{ name: "string", content: "hooked", text: "hooked" },
{ name: "empty string", content: "", text: "" },
{ name: "missing content", content: undefined, text: '{"text":"hooked"}' },
{ name: "empty content array", content: [], text: '{"text":"hooked"}' },
].forEach((input) => {
it.effect(`normalizes ${input.name} after the final tool hook`, () =>
Effect.gen(function* () {
const service = yield* Tool.Service
const hooks = yield* PluginHooks.Service
yield* transform(service, { echo: make() }, { codemode: false })
yield* hooks.register("tool", "execute.after", (event) =>
Effect.sync(() => {
if (event.status !== "completed") return
event.result = {
output: { text: "hooked" },
content: input.content,
metadata: { source: "hook" },
}
}),
)
const snapshot = yield* service.snapshot()
expect(yield* snapshot.execute(call("echo"))).toEqual({
output: { text: "hooked" },
content: [{ type: "text", text: input.text }],
metadata: { source: "hook" },
})
}),
)
})
it.effect("normalizes image tool output once and drops unresizable images", () =>
Effect.gen(function* () {