mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-15 04:27:20 +03:00
refactor(core): preserve normalized tool results (#46927)
This commit is contained in:
committed by
GitHub
Unverified
parent
5e38101fba
commit
b257d476c9
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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 /) },
|
||||
|
||||
@@ -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* () {
|
||||
|
||||
Reference in New Issue
Block a user