diff --git a/packages/core/src/plugin/tool-input-repair.ts b/packages/core/src/plugin/tool-input-repair.ts index 984f7f343f1b..c2e1f03b3628 100644 --- a/packages/core/src/plugin/tool-input-repair.ts +++ b/packages/core/src/plugin/tool-input-repair.ts @@ -1,8 +1,9 @@ export * as ToolInputRepairPlugin from "./tool-input-repair.js" +import { ToolFailure } from "@opencode/ai/effect" import { define } from "@opencode/plugin/effect/plugin" import { Effect, JsonSchema, Option, Predicate, Schema } from "effect" -import { definition } from "../tool/runtime.js" +import { definition, normalizeContent } from "../tool/runtime.js" // Repairs apply only when the input schema unambiguously supports them: // - Stringified root or nested object: '{"limit":"20"}' -> { limit: 20 } @@ -18,21 +19,56 @@ import { definition } from "../tool/runtime.js" const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)) const maxDepth = 6 +// Tool names models carry over from other harnesses, used only when no tool has that name. +// The result tells the model which tool ran, so it calls that one next time. +const aliases = new Map([["bash", "shell"]]) + export const Plugin = define({ id: "opencode.tool.input.repair", effect: (ctx) => - ctx.tool.hook("execute.before", (event) => - Effect.gen(function* () { - // The outer Code Mode tool is built per snapshot rather than registered, so it cannot be - // looked up here. Its `{ code }` input is trivial; the tools it calls are repaired normally. - if (event.tool === "execute") return - const tool = (yield* ctx.tool.list()).find((tool) => tool.id === event.tool) - if (!tool) return - const schema = definition(tool).inputSchema - if (schema.type !== "object") return - event.input = repair(event.input, schema, schema, 0) - }), - ), + Effect.gen(function* () { + const aliased = new Map() + yield* ctx.tool.hook("execute.before", (event) => + Effect.gen(function* () { + // The outer Code Mode tool is built per snapshot rather than registered, so it cannot be + // looked up here. Its `{ code }` input is trivial; the tools it calls are repaired normally. + if (event.tool === "execute") return + const tools = yield* ctx.tool.list() + const alias = aliases.get(event.tool) + const tool = + tools.find((tool) => tool.id === event.tool) ?? + (alias === undefined ? undefined : tools.find((tool) => tool.id === alias)) + if (!tool) return + if (tool.id !== event.tool) { + aliased.set(event.id, event.tool) + event.tool = tool.id + } + const schema = definition(tool).inputSchema + if (schema.type !== "object") return + event.input = repair(event.input, schema, schema, 0) + }), + ) + yield* ctx.tool.hook("execute.after", (event) => + Effect.sync(() => { + const called = aliased.get(event.id) + if (called === undefined) return + aliased.delete(event.id) + const note = `There is no "${called}" tool, so this ran as "${event.tool}". Call "${event.tool}" directly.` + if (event.status === "error") { + event.error = new ToolFailure({ + message: `${event.error.message}\n\n${note}`, + error: event.error.error, + metadata: event.error.metadata, + }) + return + } + event.result = { + ...event.result, + content: [...normalizeContent(event.result.content, event.result.output), { type: "text", text: note }], + } + }), + ) + }), }) function repair(value: unknown, schema: JsonSchema.JsonSchema, root: JsonSchema.JsonSchema, depth: number): unknown { diff --git a/packages/core/test/plugin/tool-input-repair-integration.test.ts b/packages/core/test/plugin/tool-input-repair-integration.test.ts index 3e3f9828cc3b..38578f105c81 100644 --- a/packages/core/test/plugin/tool-input-repair-integration.test.ts +++ b/packages/core/test/plugin/tool-input-repair-integration.test.ts @@ -83,6 +83,47 @@ it.effect("repairs tool input before validating its original schema", () => }), ) +it.effect("runs a call to another harness's tool name as the matching tool and says so", () => + Effect.gen(function* () { + const plugins = yield* Plugin.Service + const registry = yield* Tool.Service + const executed: unknown[] = [] + yield* plugins.activate([ + { ...ToolInputRepairPlugin.Plugin, revision: "1" }, + { + id: "shell-tool", + revision: "1", + effect: (ctx) => + ctx.tool.transform((draft) => + draft.add({ + name: "shell", + options: { codemode: false }, + description: "Shell", + input: Schema.Struct({ command: Schema.String, timeout: Schema.optionalKey(Schema.Int) }), + execute: (input) => Effect.sync(() => executed.push(input)).pipe(Effect.as({ content: "done" })), + }), + ), + }, + ]) + const snapshot = yield* registry.snapshot() + const result = yield* snapshot.execute({ + ...identity, + call: { type: "tool-call", id: "call-bash", name: "bash", input: { command: "ls", timeout: "5000" } }, + }) + expect(executed).toEqual([{ command: "ls", timeout: 5000 }]) + expect(result.content).toEqual([ + { type: "text", text: "done" }, + { type: "text", text: 'There is no "bash" tool, so this ran as "shell". Call "shell" directly.' }, + ]) + + const direct = yield* snapshot.execute({ + ...identity, + call: { type: "tool-call", id: "call-shell", name: "shell", input: { command: "pwd" } }, + }) + expect(direct.content).toEqual([{ type: "text", text: "done" }]) + }), +) + it.effect("repairs namespaced inner tool input called from Code Mode", () => Effect.gen(function* () { const plugins = yield* Plugin.Service