refactor: make shell the canonical tool internals

This commit is contained in:
LukeParkerDev 2026-04-23 19:46:00 +10:00
parent b75f831eaa
commit 3e30068907
16 changed files with 36 additions and 60 deletions

View file

@ -12,8 +12,6 @@ import matter from "gray-matter"
import { Instance } from "../../project/instance"
import { EOL } from "os"
import type { Argv } from "yargs"
import { ShellToolID } from "../../tool/shell/id"
type AgentMode = "all" | "primary" | "subagent"
const AVAILABLE_TOOLS = ["shell", "read", "write", "edit", "glob", "grep", "webfetch", "task", "todowrite"]
@ -129,7 +127,7 @@ const AgentCreateCommand = cmd({
...new Set(
cliTools
.split(",")
.map((t) => ShellToolID.normalize(t.trim()))
.map((t) => t.trim())
.filter(Boolean),
),
]

View file

@ -880,7 +880,6 @@ export const GithubRunCommand = cmd({
const TOOL: Record<string, [string, string]> = {
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
shell: ["Shell", UI.Style.TEXT_DANGER_BOLD],
bash: ["Shell", UI.Style.TEXT_DANGER_BOLD],
edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD],
glob: ["Glob", UI.Style.TEXT_INFO_BOLD],
grep: ["Grep", UI.Style.TEXT_INFO_BOLD],

View file

@ -23,7 +23,7 @@ import { CodeSearchTool } from "../../tool/codesearch"
import { WebSearchTool } from "../../tool/websearch"
import { TaskTool } from "../../tool/task"
import { SkillTool } from "../../tool/skill"
import { ShellTool } from "../../tool/shell/tool"
import { ShellTool } from "../../tool/shell"
import { ShellToolID } from "../../tool/shell/id"
import { TodoWriteTool } from "../../tool/todo"
import { Locale } from "../../util"

View file

@ -37,7 +37,7 @@ import { Locale } from "@/util"
import type { Tool } from "@/tool"
import type { ReadTool } from "@/tool/read"
import type { WriteTool } from "@/tool/write"
import { ShellTool } from "@/tool/shell/tool"
import { ShellTool } from "@/tool/shell"
import { ShellToolID } from "@/tool/shell/id"
import type { GlobTool } from "@/tool/glob"
import { TodoWriteTool } from "@/tool/todo"

View file

@ -22,7 +22,6 @@ import { Auth } from "@/auth"
import { Installation } from "@/installation"
import { InstallationVersion } from "@/installation/version"
import { EffectBridge } from "@/effect"
import { ShellToolID } from "@/tool/shell/id"
import * as Option from "effect/Option"
import * as OtelTracer from "@effect/opentelemetry/Tracer"
@ -208,7 +207,7 @@ const live: Layer.Layer<
input.model.api.id.toLowerCase().includes("litellm")
const repair = (toolName: string) => {
const next = ShellToolID.normalize(toolName.toLowerCase())
const next = toolName.toLowerCase()
if (!tools[next]) return
return next
}

View file

@ -16,7 +16,6 @@ import type { SystemError } from "bun"
import type { Provider } from "@/provider"
import { ModelID, ProviderID } from "@/provider/schema"
import { Effect, Schema, Types } from "effect"
import { ShellToolID } from "@/tool/shell/id"
import { zod, ZodOverride } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import { namedSchemaError } from "@/util/named-schema-error"
@ -443,17 +442,6 @@ export type Part =
| RetryPart
| CompactionPart
function normalizeTool(tool: string) {
return ShellToolID.normalize(tool)
}
function normalizePart<T extends Part>(part: T): T {
if (part.type !== "tool") return part
const tool = normalizeTool(part.tool)
if (tool === part.tool) return part
return { ...part, tool } as T
}
// Errors are still NamedError-based Zod; bridge via ZodOverride so the derived
// Zod + JSON Schema emit the original discriminatedUnion shape. Migrating the
// error classes to Schema.TaggedErrorClass is a separate slice.
@ -673,12 +661,12 @@ const info = (row: typeof MessageTable.$inferSelect) =>
}) as Info
const part = (row: typeof PartTable.$inferSelect) =>
normalizePart(({
({
...row.data,
id: row.id,
sessionID: row.session_id,
messageID: row.message_id,
} as Part))
} as Part)
const older = (row: Cursor) =>
or(lt(MessageTable.time_created, row.time), and(eq(MessageTable.time_created, row.time), lt(MessageTable.id, row.id)))
@ -843,8 +831,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
role: "assistant",
parts: [],
}
for (const raw of msg.parts) {
const part = normalizePart(raw)
for (const part of msg.parts) {
if (part.type === "text")
assistantMessage.parts.push({
type: "text",

View file

@ -1,7 +1,7 @@
import { PlanExitTool } from "./plan"
import { Session } from "../session"
import { QuestionTool } from "./question"
import { ShellTool } from "./shell/tool"
import { ShellTool } from "./shell"
import { EditTool } from "./edit"
import { GlobTool } from "./glob"
import { GrepTool } from "./grep"

View file

@ -253,13 +253,13 @@ function tail(text: string, maxLines: number, maxBytes: number) {
}
}
const parse = Effect.fn("BashTool.parse")(function* (command: string, ps: boolean) {
const parse = Effect.fn("ShellTool.parse")(function* (command: string, ps: boolean) {
const tree = yield* Effect.promise(() => parser().then((p) => (ps ? p.ps : p.bash).parse(command)))
if (!tree) throw new Error("Failed to parse command")
return tree.rootNode
})
const ask = Effect.fn("BashTool.ask")(function* (ctx: Tool.Context, scan: Scan) {
const ask = Effect.fn("ShellTool.ask")(function* (ctx: Tool.Context, scan: Scan) {
if (scan.dirs.size > 0) {
const globs = Array.from(scan.dirs).map((dir) => {
if (process.platform === "win32") return AppFileSystem.normalizePathPattern(path.join(dir, "*"))
@ -336,7 +336,7 @@ export const ShellTool = Tool.define(
const trunc = yield* Truncate.Service
const plugin = yield* Plugin.Service
const cygpath = Effect.fn("BashTool.cygpath")(function* (shell: string, text: string) {
const cygpath = Effect.fn("ShellTool.cygpath")(function* (shell: string, text: string) {
const lines = yield* spawner
.lines(ChildProcess.make(shell, ["-lc", 'cygpath -w -- "$1"', "_", text]))
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
@ -345,7 +345,7 @@ export const ShellTool = Tool.define(
return AppFileSystem.normalizePath(file)
})
const resolvePath = Effect.fn("BashTool.resolvePath")(function* (text: string, root: string, shell: string) {
const resolvePath = Effect.fn("ShellTool.resolvePath")(function* (text: string, root: string, shell: string) {
if (process.platform === "win32") {
if (Shell.posix(shell) && text.startsWith("/") && AppFileSystem.windowsPath(text) === text) {
const file = yield* cygpath(shell, text)
@ -356,7 +356,7 @@ export const ShellTool = Tool.define(
return path.resolve(root, text)
})
const argPath = Effect.fn("BashTool.argPath")(function* (arg: string, cwd: string, ps: boolean, shell: string) {
const argPath = Effect.fn("ShellTool.argPath")(function* (arg: string, cwd: string, ps: boolean, shell: string) {
const text = ps ? expand(arg, cwd, shell) : home(unquote(arg))
const file = text && prefix(text)
if (!file || dynamic(file, ps)) return
@ -365,7 +365,7 @@ export const ShellTool = Tool.define(
return yield* resolvePath(next, cwd, shell)
})
const collect = Effect.fn("BashTool.collect")(function* (root: Node, cwd: string, ps: boolean, shell: string) {
const collect = Effect.fn("ShellTool.collect")(function* (root: Node, cwd: string, ps: boolean, shell: string) {
const scan: Scan = {
dirs: new Set<string>(),
patterns: new Set<string>(),
@ -396,7 +396,7 @@ export const ShellTool = Tool.define(
return scan
})
const shellEnv = Effect.fn("BashTool.shellEnv")(function* (ctx: Tool.Context, cwd: string) {
const shellEnv = Effect.fn("ShellTool.shellEnv")(function* (ctx: Tool.Context, cwd: string) {
const extra = yield* plugin.trigger(
"shell.env",
{ cwd, sessionID: ctx.sessionID, callID: ctx.callID },
@ -408,7 +408,7 @@ export const ShellTool = Tool.define(
}
})
const run = Effect.fn("BashTool.run")(function* (
const run = Effect.fn("ShellTool.run")(function* (
input: {
shell: string
name: string
@ -644,5 +644,3 @@ export const ShellTool = Tool.define(
})
}),
)
export const BashTool = ShellTool

View file

@ -21,12 +21,10 @@ export namespace ShellKind {
export namespace ShellToolID {
export const id = "shell"
export const legacy = "bash"
export type ID = typeof id | typeof legacy
const tool = new Set<string>([id, legacy])
export type ID = typeof id
export function has(value: string): value is ID {
return tool.has(value)
return value === id
}
export function normalize(value: string) {

View file

@ -1 +0,0 @@
export { ShellTool } from "../bash"

View file

@ -8,7 +8,6 @@ import { Agent } from "../agent/agent"
import type { SessionPrompt } from "../session/prompt"
import { Config } from "../config"
import { Effect } from "effect"
import { ShellToolID } from "./shell/id"
export interface TaskPromptOps {
cancel(sessionID: SessionID): void
@ -40,7 +39,7 @@ export const TaskTool = Tool.define(
const run = Effect.fn("TaskTool.execute")(function* (params: z.infer<typeof parameters>, ctx: Tool.Context) {
const cfg = yield* config.get()
const primaryTools = (cfg.experimental?.primary_tools ?? []).map((item) => ShellToolID.normalize(item))
const primaryTools = cfg.experimental?.primary_tools ?? []
if (!ctx.extra?.bypassAgentCheck) {
yield* ctx.ask({

View file

@ -607,12 +607,12 @@ describe("session.message-v2.toModelMessage", () => {
...basePart(assistantID, "a1"),
type: "tool",
callID: "call-1",
tool: "bash",
tool: "shell",
state: {
status: "completed",
input: { cmd: "ls" },
output: "abcdefghij",
title: "Bash",
title: "Shell",
metadata: {},
time: { start: 0, end: 1 },
},
@ -755,7 +755,7 @@ describe("session.message-v2.toModelMessage", () => {
...basePart(assistantID, "a1"),
type: "tool",
callID: "call-1",
tool: "bash",
tool: "shell",
state: {
status: "error",
input: { command: "for i in {1..20}; do print -- $RANDOM; sleep 1; done" },

View file

@ -414,13 +414,13 @@ describe("session-entry-stepper", () => {
(callID, title, input, output, metadata, attachments, parts) => {
const next = run(
[
SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }),
SessionEvent.Tool.Input.Started.create({ callID, name: "shell", timestamp: time(1) }),
...parts.map((x, i) =>
SessionEvent.Tool.Input.Delta.create({ callID, delta: x, timestamp: time(i + 2) }),
),
SessionEvent.Tool.Called.create({
callID,
tool: "bash",
tool: "shell",
input,
provider: { executed: true },
timestamp: time(parts.length + 2),
@ -459,10 +459,10 @@ describe("session-entry-stepper", () => {
FastCheck.property(word, dict, word, maybe(dict), (callID, input, error, metadata) => {
const next = run(
[
SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }),
SessionEvent.Tool.Input.Started.create({ callID, name: "shell", timestamp: time(1) }),
SessionEvent.Tool.Called.create({
callID,
tool: "bash",
tool: "shell",
input,
provider: { executed: true },
timestamp: time(2),
@ -496,7 +496,7 @@ describe("session-entry-stepper", () => {
FastCheck.property(word, word, (callID, title) => {
const next = run(
[
SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }),
SessionEvent.Tool.Input.Started.create({ callID, name: "shell", timestamp: time(1) }),
SessionEvent.Tool.Success.create({
callID,
title,
@ -691,10 +691,10 @@ describe("session-entry-stepper", () => {
SessionEvent.Reasoning.Started.create({ timestamp: time(2) }),
...reason.map((x, i) => SessionEvent.Reasoning.Delta.create({ delta: x, timestamp: time(i + 3) })),
SessionEvent.Reasoning.Ended.create({ text: end, timestamp: time(reason.length + 3) }),
SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(reason.length + 4) }),
SessionEvent.Tool.Input.Started.create({ callID, name: "shell", timestamp: time(reason.length + 4) }),
SessionEvent.Tool.Called.create({
callID,
tool: "bash",
tool: "shell",
input,
provider: { executed: true },
timestamp: time(reason.length + 5),
@ -771,10 +771,10 @@ describe("session-entry-stepper", () => {
FastCheck.property(dict, dict, word, word, (a, b, title, error) => {
const next = run(
[
SessionEvent.Tool.Input.Started.create({ callID: "a", name: "bash", timestamp: time(1) }),
SessionEvent.Tool.Input.Started.create({ callID: "a", name: "shell", timestamp: time(1) }),
SessionEvent.Tool.Called.create({
callID: "a",
tool: "bash",
tool: "shell",
input: a,
provider: { executed: true },
timestamp: time(2),
@ -789,7 +789,7 @@ describe("session-entry-stepper", () => {
SessionEvent.Tool.Input.Started.create({ callID: "b", name: "grep", timestamp: time(4) }),
SessionEvent.Tool.Called.create({
callID: "b",
tool: "bash",
tool: "shell",
input: b,
provider: { executed: true },
timestamp: time(5),
@ -827,13 +827,13 @@ describe("session-entry-stepper", () => {
FastCheck.property(dict, dict, word, word, text, text, (a, b, titleA, titleB, deltaA, deltaB) => {
const next = run(
[
SessionEvent.Tool.Input.Started.create({ callID: "a", name: "bash", timestamp: time(1) }),
SessionEvent.Tool.Input.Started.create({ callID: "a", name: "shell", timestamp: time(1) }),
SessionEvent.Tool.Input.Started.create({ callID: "b", name: "grep", timestamp: time(2) }),
SessionEvent.Tool.Input.Delta.create({ callID: "a", delta: deltaA, timestamp: time(3) }),
SessionEvent.Tool.Input.Delta.create({ callID: "b", delta: deltaB, timestamp: time(4) }),
SessionEvent.Tool.Called.create({
callID: "a",
tool: "bash",
tool: "shell",
input: a,
provider: { executed: true },
timestamp: time(5),

View file

@ -4,7 +4,7 @@ import os from "os"
import path from "path"
import { Shell } from "../../src/shell/shell"
import { ShellToolID } from "../../src/tool/shell/id"
import { ShellTool } from "../../src/tool/shell/tool"
import { ShellTool } from "../../src/tool/shell"
import { Instance } from "../../src/project/instance"
import { Filesystem } from "../../src/util"
import { tmpdir } from "../fixture/fixture"

View file

@ -378,7 +378,7 @@ describe("tool.task", () => {
},
},
experimental: {
primary_tools: ["bash", "read"],
primary_tools: ["shell", "read"],
},
},
},

View file

@ -35,7 +35,6 @@ export function ToolErrorCard(props: ToolErrorCardProps) {
websearch: "ui.tool.websearch",
codesearch: "ui.tool.codesearch",
shell: "ui.tool.shell",
bash: "ui.tool.shell",
apply_patch: "ui.tool.patch",
question: "ui.tool.questions",
}