mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 00:13:41 +00:00
fix(core): run calls to a bash tool as shell (#60687)
This commit is contained in:
parent
ad71144054
commit
ea982d7d8f
2 changed files with 90 additions and 13 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue