fix(core): run calls to a bash tool as shell (#60687)

This commit is contained in:
Kit Langton 2026-10-30 12:53:17 -07:00 • committed by GitHub
parent ad71144054
commit ea982d7d8f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 90 additions and 13 deletions

View file

@ -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<string, string>()
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 {

View file

@ -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