dsh 官方文档
docs/tool-catalog.md是由脚本"真的把每个工具插件启动起来、读取ctx.tools.schemas()"生成的,而不是手写的静态清单------这保证了它和源码不会脱节。本篇先给出这份清单的精简版,再挑bash和edit/str_replace_editor两组最有代表性的工具深入到实现细节:命令执行的进程隔离、超时升级与输出溢出机制,以及文件编辑"字面量替换 + 原子写 + 乐观并发版本控制"的完整链路。
学习目标
- 掌握 dsh 内置工具的完整版图:按所在包、模型可见名称、一句话职责分类。
- 理解
bash工具背后LocalBashExecutor/subprocess-local两层如何实现进程组隔离、SIGTERM→SIGKILL 超时升级、输出内存尾部窗口加落盘 spill。 - 理解
edit/str_replace_editor为什么选择字面量字符串替换而不是统一 diff 格式作为编辑输入,以及原子写(writeFileAtomic)和乐观并发版本控制(CAS)如何共同保证并发安全。 - 理解"没有
undo_edit"这一设计现象背后,dsh 用什么机制替代了"记录历史可撤销"的安全网。
背景与设计动机
一个 Agent Harness 的内置工具集直接决定了模型能触碰现实世界的方式和边界。dsh 选择把每一类能力(执行命令、读写文件、搜索代码、管理终端、调度任务......)都做成独立的插件包,任何一个部署都可以按需组合;同时坚持"工具的模型可见 schema 不是手写文档,而是从真实注册表里跑出来的"这条原则,docs/tool-catalog.md 顶部的生成器说明写得很直接:
这个生成器真的启动每个工具插件、挂到一个真实的 context 上,读取
ctx.tools.schemas(),因为一个工具的 schema 不是静态可推断的(运行时展开的枚举、拼接的描述文本、配置驱动的名称、原始 JSON Schema 的 MCP 工具)。一个完整性守卫会扫描packages/*/tool-*整个目录,如果有任何包没有出现在生成器的启动清单里就直接报错------保证新增的工具不会被悄悄漏掉文档。
这也是为什么本篇的清单可以直接对照源码目录结构来核实------每一行都能在仓库里找到对应的包。
核心机制详解
完整工具清单
下表按工具包分组,列出模型可见的工具名、所在包路径、一句话职责(职责描述提炼自 docs/tool-catalog.md 的"Tool Package Map"表和各工具小节;完整 schema 以生成产物为准):
| 工具包 | 模型可见名称 | 所在路径 | 一句话职责 |
|---|---|---|---|
| dsh-plugin-manager | plugin_manager | packages/boot/plugin-manager | 安装/启停插件包(持久变更的正规入口) |
| dsh-mcp-resources | list_mcp_resource_templates、list_mcp_resources、read_mcp_resource | packages/mcp/mcp-resources | 读取 MCP 服务器暴露的资源与资源模板 |
| dsh-tool-ask-user | ask_user_question | packages/interaction/tool-ask-user | 暂停当前调用,等 UI/人工提供方给出回答 |
| dsh-tools(PTC 传输,早期称为 Code Mode) | run_code | packages/core/tools | 让模型写一段程序,通过 SDK 绑定串联多个工具调用 |
| dsh-plan-mode | exit_plan_mode | packages/plan/plan-mode | 结束"计划模式",提交计划供用户审阅/批准 |
| dsh-tool-bash | bash | packages/shell/tool-bash | 一次性执行 bash 命令,支持后台运行、超时迁移、沙箱升级 |
| dsh-tool-present | present | packages/deliverables/tool-present | 把交付物呈现给当前会话(Web 卡片/打开源文件) |
| dsh-tool-pwsh | pwsh | packages/shell/tool-pwsh | Windows 场景下的 PowerShell 方言版 bash |
| dsh-tool-cordis | cordis_inspect_list、cordis_inspect_query | packages/extensions/tool-cordis | 只读的运行时检视:列出/查询 Cordis 插件与配置 schema |
| dsh-tool-bash-persistent | bash | packages/shell/tool-bash-persistent | 基于 PTY 的持久化 bash(cwd/环境跨调用保留) |
| dsh-tool-pwsh-persistent | pwsh | packages/shell/tool-pwsh-persistent | 持久化 PowerShell(与上者同构,Windows 侧) |
| dsh-tool-str-replace-editor | str_replace_editor | packages/fs/tool-str-replace-editor | Anthropic 风格四命令编辑器:view/create/str_replace/insert |
| dsh-tool-fs | edit、read、read_image、write | packages/fs/tool-fs | 基础文件读写编辑,read_image 需 ctx.attachments,执行时还要求所选路由声明图像输入 |
| dsh-tool-fs-search | glob、grep | packages/fs/tool-fs-search | 基于打包版 ripgrep 的路径/内容搜索,不依赖宿主 rg |
| dsh-tool-terminal | terminal_open 等 6 个 | packages/terminal/tool-terminal | 管理持久化 PTY 终端会话 |
| dsh-tool-goal | create_goal、get_goal、update_goal | packages/goal/tool-goal | 会话级持久目标的创建/查询/状态迁移 |
| dsh-schedule | schedule_create 等 3 个 | packages/schedule/schedule | 为未来的根 Agent 创建一次性/周期性调度 |
| dsh-tool-lsp | lsp | packages/lsp/tool-lsp | 通过语言服务器做定义跳转等 4 种标准化查询 |
| dsh-tool-ralph | ralph | packages/workflow/tool-ralph | 固定的"全新 agent 循环"工作流(Ralph Loop) |
| dsh-tool-skill | skill | packages/skill/tool-skill | 加载技能目录里的完整技能正文 |
| dsh-tool-session-query | session_search 等 5 个 | packages/session-query/tool-session-query | 只读地检索/追踪历史会话事件 |
| dsh-tool-subagent | subagent(可配置别名 subagent_fork)、list_subagent_models | packages/subagent/tool-subagent | 委派任务给子 agent,一次性或可续接;附模型发现清单 |
| dsh-tool-subagent-control | interrupt_agent、list_agents、send_message | packages/subagent/tool-subagent-control | 对已委派的后台子 agent 做控制 |
| dsh-experimental-tool-agent-team | spawn_teammate、team_task_*、wait_agent 等 9 个 | packages/experimental/tool-agent-team | 实验性的多 agent 团队协作(共享任务清单) |
| dsh-tool-jobs | job_kill、job_list、job_output | packages/jobs/tool-jobs | 统一的后台任务控制器(bash/PTY/subagent 通用) |
| dsh-tool-todo | todo_write | packages/todo/tool-todo | 会话自有的任务清单状态 |
| dsh-tool-workflow | workflow | packages/workflow/tool-workflow | 通用工作流脚本引擎的模型入口 |
| dsh-tool-workspace-dependencies | load_workspace_dependencies | packages/skill/tool-workspace-dependencies | 按需加载工作区的依赖视图 |
| dsh-tool-web | web_fetch、web_search | packages/web/tool-web | 网页抓取与搜索,后端可插拔 |
这份表里能看出几个共性:一是几乎每一类工具背后都有独立的能力 seam 撑着(bash/pwsh 背后是 ctx.shell,edit/read/write 背后是 ctx.fs,web_search/web_fetch 背后是 ctx.web),工具包本身通常很薄,真正的实现细节都在 Provider 层;二是几乎所有工具都要求 ctx.systemPrompt,用于向模型解释这个工具的使用约定,而不是只靠 JSON Schema 的 description 字段。
最近一次目录再生成带动的三处结构调整值得单独说明,因为它们是"面向模型的工具面"和"内部机制面"解耦的直接证据:其一,dsh-tool-cordis 从原来"cordis_define、cordis_run 等 7 个动态定义/运行工具"收缩为只有两个只读检视工具(cordis_inspect_list、cordis_inspect_query,能查询活插件的 Config schema),一切持久变更都收敛到 plugin_manager 这一个入口;其二,独立包 dsh-tool-subagent-report(子 agent 向父级汇报的 report 工具)被整体移除------汇报改为委派链路(子 agent manager)自己投递,wires 面少了一个工具;其三,dsh-tool-subagent 在委派主工具之外附了固定的 list_subagent_models 配对工具,专门负责"这个会话里可选哪些模型"的发现查询。
深入 bash:进程隔离、超时升级与输出溢出
bash 工具执行链路是"tool-bash(消费方)→ ctx.shell(seam,LocalBashExecutor 或 SandboxBashExecutor 实现)→ ctx.subprocess(又一层 seam)→ subprocess-local 的真正 spawn"------第一篇已经讲过前两层,这里聚焦最底层 packages/subprocess/subprocess-local/src/spawn.ts 的进程管理细节。
进程组隔离 :spawnSubprocess(spec, internals) 把子进程作为"一棵进程树"来管理------POSIX 上 detached: true 让子进程自成一个进程组,Windows 上则用 taskkill /T 按根 pid 终止整棵树:
// packages/subprocess/subprocess-local/src/spawn.ts
export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInternals = {}): LocalSubprocessHandle {
// ...(组装 stdio:stdin/stdout/stderr 按 spec 的处置模式,需要时再附加控制 FD)...
const child = (internals.spawn ?? spawn)(program as string, args, {
cwd: spec.cwd,
env: controlEnvironment(childEnv(spec.env), spec.stdio.control),
stdio,
detached: platform !== 'win32',
windowsHide: platform === 'win32',
})
// ...(fallbackOwner 把 POSIX 进程组/Windows taskkill 包装成统一的
// BoundProcessOwner:signal / waitForExit / terminateForHostExit)...
}
这样一来,如果命令自己又 fork 出孙进程(比如一个 shell 脚本调用了另一个后台进程),终止信号可以通过同文件里导出的 killGroup(pid, sig) 发送给整棵进程组(内部是 process.kill(-pid, sig),注意 pid 前的负号,POSIX 语义是发给整个进程组而不是单个进程),一次性覆盖整棵进程树,不会留下孤儿进程。env 这一行还有一层新包装:childEnv(spec.env) 先按目标平台的键语义合并环境,controlEnvironment(...) 再按 spec 决定是否附加上下文 FD 用的环境标记------两个名字一看就知道是"先算完、再补控制面"的两步,别把它当成一次普通 merge。
SIGTERM → SIGKILL 超时升级 :terminateWithReason() 函数体现了完整的两级升级策略:
// packages/subprocess/subprocess-local/src/spawn.ts
const kill = (sig: 'SIGTERM' | 'SIGKILL', cancellationReason?: unknown): void => {
if (rangeExitObserved) return
launch.owner.signal(sig, cancellationReason)
}
const terminateWithReason = (cancellationReason: unknown): void => {
if (rangeExitObserved || terminationStarted) return
terminationStarted = true
// Keep the shared observation rejection available to waitForExit() without
// leaking an unhandled rejection when a caller only invokes terminate().
void observeRangeExit().catch(() => {})
kill('SIGTERM', cancellationReason)
graceTimer = setTimeout(() => {
graceTimer = undefined
kill('SIGKILL')
}, spec.graceMs)
}
先发 SIGTERM 给整棵进程树一个"体面退出"的机会,同时启动一个 graceMs(在 bash-local 里默认 3000ms,和 OpenCode 的默认值保持一致)宽限计时器;宽限期结束后如果进程树依然存活,才升级成 SIGKILL 强制终止。这版实现相对旧版有两处重要的工程进化:信号不再直接调 process.kill(-pid, ...),而是通过 launch.owner.signal(sig, cancellationReason)------一个名为 BoundProcessOwner 的"进程拥有者"抽象(在 managed-owner.ts 里由 fallbackOwner/linuxScopeOwner/windowsJobOwner 等具体实现提供),把" POSIX 进程组 vs Windows Job 对象 vs Linux cgroup scope"的平台差异完全藏在一个方法后面,连杀停原因(cancellationReason,比如 abort 的那个 reason)也随信号带下去 ;而"进程树还活着吗"这个判断也不再是"就地重读进程表"(treeAlive()),而是换成了显式的事件订阅------observeRangeExit() 通过 launch.owner.waitForExit() 等待整棵进程树(不止 leader)蒸发,rangeExitObserved 一旦被置位就成为第一个确认的死亡边界:任何迟到或陈旧的 pid 信号都不再被发出 ,宽限计时器也会同步清掉,从根上杜绝了"把信号发给一个 pid 复用之后的新进程"这类竞态。外层的取消路径也升级成同一套语义:spec.signal 上挂的 onAbort 直接调 terminateWithReason(spec.signal?.reason)------取消原因一路滚到结果分类里。
输出内存尾部窗口 + 落盘 spill :命令的 stdout/stderr 由 OutputCollector 收集,策略是"内存里只保留尾部窗口,超出后落盘保留完整内容":
// packages/subprocess/subprocess-local/src/output.ts
private spillAll(chunk: Buffer): void {
if (this.maxSpillBytes !== undefined && this.total > this.maxSpillBytes) {
this.discardSpill()
return
}
if (this.spillFd === undefined) {
// Random suffix + O_EXCL + no-follow-equivalent ('wx' fails on any
// existing path, symlink or not) + owner-only mode: defeats spill-path
// prediction and symlink planting in shared tmp dirs.
this.spillFile = join(
this.spillDir,
`dsh-subprocess-${process.pid}-${++spillCounter}-${randomBytes(6).toString('hex')}-${this.label}.log`,
)
this.spillFd = openSync(this.spillFile, 'wx', 0o600)
for (const prior of this.chunks) writeSync(this.spillFd, prior)
}
writeSync(this.spillFd, chunk)
}
每个流(stdout、stderr 各自独立)的内存缓冲一旦超过配置的字节上限(bash-local 默认 64000 字节),就会打开一个 spill 文件把完整输出流式落盘,内存里只继续保留最新的尾部片段;push() 方法会在超出上限时持续丢弃头部、保留尾部,保证模型最终看到的是"最新的输出",而不是"最早的输出"。spill 文件名混入了进程 PID、自增计数器和 6 字节随机十六进制串,用 'wx'(等价于 O_CREAT | O_EXCL)标志加 0o600 权限打开------'wx' 在目标路径已存在时(无论是普通文件还是符号链接)都会直接失败,配合随机文件名,这是专门用来防御"符号链接攻击"(攻击者预先在可预测的临时文件路径放一个指向敏感文件的符号链接,诱使进程把内容写穿)的组合设计。spill 文件本身也有独立的上限(maxSpillBytes,bash-local 默认 64MiB)------超过这个上限,spill 机制会主动停止并删除文件,承认"这个流真的太大了,记不全"。
这三层机制------进程组隔离、两级超时升级、内存窗口加落盘 spill------合起来构成了 bash 工具"既不会让一条失控命令拖垮整个宿主进程,又不会让海量输出撑爆模型上下文"的完整承诺。第三篇讲的沙箱只是在这套机制之外再加一层文件访问限制,两者是正交叠加的关系,不是互相替代。
深入 edit/str_replace_editor:字面量替换、原子写、乐观并发
dsh 的编辑工具没有走"统一 diff 格式作为输入"这条路------全仓库搜索 apply_patch、applyPatch、multi_edit 均无命中。edit 和 str_replace_editor 的 str_replace 命令,底层用的都是同一个字面量字符串替换函数 applyLiteralEdit(packages/fs/fs-local/src/fsio.ts):
// packages/fs/fs-local/src/fsio.ts
export function applyLiteralEdit(
content: string, oldString: string, newString: string,
replaceAll: boolean, displayPath: string,
): { content: string; replacements: number } {
const oldNorm = normalizeLineEndings(oldString)
if (oldNorm.length === 0) {
throw new FsError('old_string must be a non-empty string', 'FS_EDIT_NOT_FOUND')
}
const newNorm = normalizeLineEndings(newString)
const replacements = countOccurrences(content, oldNorm)
if (replacements === 0) {
throw new FsError(`old_string was not found in "${displayPath}"`, 'FS_EDIT_NOT_FOUND')
}
if (!replaceAll && replacements > 1) {
throw new FsError(`old_string matched ${replacements} times in "${displayPath}"; provide a more specific old_string or set replace_all to true`, 'FS_AMBIGUOUS_EDIT')
}
return { content: content.split(oldNorm).join(newNorm), replacements }
}
模型必须提供一段"在文件里精确匹配一次"的 old_string(除非显式传 replace_all: true),匹配零次或多次(且未要求全部替换)都会直接失败,并在错误信息里给出"提供更具体的 old_string,或者设置 replace_all"的纠正指引。这种设计把"编辑是否精确命中"这个校验完全前置到应用补丁之前,模型不需要理解 unified diff 的 hunk 语法,只需要保证自己引用的原文片段足够独特。真正的 diff------用来给人看、给审计用------是结果时 才计算的展现层产物(packages/fs/tool-fs/src/diff.ts):
// packages/fs/tool-fs/src/diff.ts
export function computeHunkDiffs(path: string, before: string, after: string): FileDiff[] {
const patch = structuredPatch('', '', before, after, undefined, undefined, { context: DIFF_CONTEXT })
// ...把 patch.hunks 转换成 { path, oldText, newText } 数组...
}
computeHunkDiffs 用 diff 库的 structuredPatch 反推出 unified-diff 风格的 hunk,塞进 tool/result 事件的 meta.diffs 字段------这份数据只用于 UI 渲染和会话重放时复原当时的 diff 卡片,和真正驱动文件修改的字面量替换逻辑完全独立、互不影响。
写入落盘走的是 writeFileAtomic(同一个 fsio.ts 文件):在目标目录旁边建一个私有的暂存子目录(0o700),在其中以 0o600 权限写入临时文件,fsync 落盘后再 rename 原子发布到最终路径:
// packages/fs/fs-local/src/fsio.ts(节选)
await mkdir(stagingDir, { mode: 0o700 })
await chmod(stagingDir, 0o700)
handle = await open(tempPath, 'wx', 0o600)
await handle.chmod(0o600)
if (platform === 'win32' && mode !== undefined) {
await copyFileDacl(absolutePath, tempPath) // 替换时把原文件的 DACL 复制给暂存文件
}
await handle.writeFile(content, { encoding: 'utf8', ...signal ? { signal } : {} })
await handle.sync()
if (mode !== undefined) await handle.chmod(mode)
await handle.close()
// ...
if (createIfAbsent !== undefined) {
await linkFile(tempPath, absolutePath) // 硬链接"不存在才发布"原语:并发创建者胜出
} else {
await rename(tempPath, absolutePath) // Windows 的替换路径用 replaceFile 专用 API,ENOENT 时再回落到 rename
}
rename 在同一文件系统内是原子操作,这保证了任何时刻查看目标文件,看到的都是"完全没改"或者"完全是新内容"两种状态之一,不会出现"写了一半"的中间态------即便进程在写入过程中被杀掉。这版和旧版有四处值得一提的加固:mkdir/open 的 mode 参数之后都紧跟了显式 chmod(POSIX 的 mode 参数会被 umask 收窄规则改写,显式 chmod 是"指定权限必须严格落地"的双保险);Windows 替换路径会在写入内容之前先把原文件的 DACL(访问控制列表)复制给暂存文件(copyFileDaclWin32),发布时用 Win32 的 ReplaceFile-风格 API 而不是 rename,保证"新文件继承原文件的安全描述符"而不是继承目标目录的默认 DACL;createIfAbsent(也就是 write 工具"只能创建原本不存在的文件"语义)的发布原语不是 rename 而是硬链接 ------link() 在目标已存在时必然失败,使得"并发下两个 writer 都想创建同一个文件"的胜者由文件系统原子地决定,败者拿到 FS_NOT_OBSERVED 提示先读再写,而且并发创建者已经写好的文件内容原样保留(链接原语不覆盖),这是硬链接比"TOCTOU 式检查-然后写"高明的地方。
真正的并发安全网,不是撤销机制,而是乐观并发版本控制 。packages/fs/fs-observation-policy/src/index.ts 强制"先读后写":
// packages/fs/fs-observation-policy/src/index.ts
writeIntent(target: FsTarget, actor: object | undefined): FsWriteIntent {
const owner = this.owner(actor)
const prior = owner ? this.get(owner, target.targetKey) : undefined
return prior?.kind === 'present'
? { kind: 'replaceIfVersion', version: prior.version }
: { kind: 'createIfAbsent' }
}
editIntent(target: FsTarget, actor: object | undefined): { version: FsVersion } {
const owner = this.owner(actor)
const prior = owner ? this.get(owner, target.targetKey) : undefined
if (!owner || prior === undefined) {
throw new FsError(`edit requires reading "${target.displayPath}" first`, 'FS_NOT_OBSERVED')
}
if (prior.kind === 'absent') {
throw new FsError(`cannot edit "${target.displayPath}": not found`, 'FS_NOT_FOUND')
}
return { version: prior.version }
}
这个策略插件按"会话"这个 owner、按文件的 targetKey 记录"这个会话上一次观察到这个文件是什么版本"。edit 工具执行时,如果这个会话从没读过这个文件(或者上次观察到的是"文件不存在"),直接拒绝并给出 FS_NOT_OBSERVED/FS_NOT_FOUND 的明确错误,提示模型先去读一遍;如果读过,拿到的版本号(FsVersion,由 dev:ino:size:mtimeNs:ctimeNs 拼成的不透明字符串,任何一项变化都会让它变化)会作为"比较并交换"(CAS)的基准传给底层 Provider------底层实现要保证"只有当前磁盘上的版本和这个基准完全一致时才真正执行替换",否则说明文件在"这个会话读它"和"这个会话写它"之间被别的进程改动过,写入必须失败而不是覆盖别人的修改。write 工具走的是对称的 createIfAbsent/replaceIfVersion 分支------没读过就只能"创建一个原本不存在的文件",读过就必须"替换成读到的那个版本",两条路径都杜绝了"凭空覆盖一个我没看过的文件"的可能性。
这也是为什么全仓库搜索不到 undo_edit:dsh 的编辑安全模型选择在写入之前用版本号拦住"基于过期认知的修改",而不是在写入之后提供"改错了可以撤销"的补救。两种思路各有取舍------前者防止的是并发场景下的"覆盖别人刚写的内容",后者防止的是"模型自己判断错误导致的破坏性修改"------从源码里能确认的只是 dsh 选择了前者且没有实现后者,具体的设计权衡原因没有在代码注释里说明,这里同样只陈述调研到的事实。
str_replace_editor 的四个命令
packages/fs/tool-str-replace-editor/src/index.ts 是 Anthropic 风格的独立编辑器工具,command 参数是一个四选一的枚举:
// packages/fs/tool-str-replace-editor/src/index.ts(节选)
command: {
type: 'string',
required: true,
enum: ['view', 'create', 'str_replace', 'insert'],
description: 'The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`.',
},
view 既能看文件内容(可选传 [start_line, end_line] 只看一段)也能看目录列表,是四个命令里唯一允许作用在目录上的;create 专门用来创建全新文件,如果目标路径已经存在就直接拒绝("Cannot overwrite files using command create"),不会误伤已有内容;str_replace 和 dsh-tool-fs 的 edit 工具共享同一套 applyLiteralEdit 底层逻辑,只是参数名从 old_string/new_string 换成了 Anthropic 惯用的 old_str/new_str;insert 则是在指定行号之后插入一段新内容,不需要匹配任何已有文本。这个工具和 dsh-tool-fs 的 edit/write/read 是完全独立的两套模型可见 schema,但共享同一个 ctx.fs seam 和同一套原子写、乐观并发机制------这再次印证了"能力 seam 与面向模型的工具是多对多关系":同一个能力可以被包装成风格完全不同的多套工具接口,供不同的提示词风格或客户端习惯选择。
补充一例:glob/grep 为什么不走 ctx.shell
细心的读者可能会问:既然已经有 ctx.shell 这个执行命令的 seam,为什么 glob/grep 不直接拼一条 rg ... 命令交给 bash 工具背后的执行器,而要单独走一遍 ctx.subprocess?packages/fs/tool-fs-search/src/search-core.ts 的模块文档给出了明确理由:
// packages/fs/tool-fs-search/src/search-core.ts
/**
* Both tools execute as ordinary foreground spawns through `ctx.subprocess` ---
* never `ctx.shell`, never `ctx.shell.start()`, never a model-visible background
* task. The ripgrep binary ships inside the npm package, so no system `rg`
* install is required, and no shell layer exists between the argv vector and
* ripgrep, so no shell quoting is involved.
*/
第一个原因是分发依赖 :glob/grep 用的是 @vscode/ripgrep 这个 npm 包自带的二进制文件,不依赖宿主机是否装了系统级的 rg------如果走 ctx.shell 拼一条 rg pattern 字符串命令,就必须假设宿主 PATH 里能找到 rg,这个假设在很多部署环境下不成立。第二个原因是没有 shell 转义层 :ctx.subprocess 接收的是一个 argv 数组(程序名加参数列表),直接对应 execve 语义,不经过任何 shell 去解析这串文本------这意味着搜索模式(pattern)里包含的任何特殊字符(引号、反斜杠、$ 变量引用符)都不需要做 shell 转义,也不存在"模式字符串被 shell 意外展开"这类注入风险。这是"一个能力可以有多个 Consumer,各自按自己的需求选择合适的底层 seam"的又一个例子------bash 工具选择 ctx.shell 是因为它本来就是要执行任意 shell 语法的命令,而 glob/grep 需要的只是"spawn 一个固定的可执行文件、传一份确定的参数列表",ctx.subprocess 这一层更底层、更贴合它们的真实需求,没有必要多绕一层 shell 解析。
glob 工具还有一处值得注意的容量控制:sampleOverCapGlobResults 这个部署级配置决定了"结果超过上限时怎么办"------false 时直接截断保留最新的前 N 条(paths.slice(0, caps.maxResults)),true 时则会在超限结果里做抽样展示,同时把完整的格式化结果列表存进可选的 ctx.spillStore 后端,返回一个"后续可读/可搜索的定位符"。这和 bash 工具"内存尾部窗口 + 落盘 spill"的思路是同一个母题在不同工具上的变体:永远不因为一次输出过大就丢失信息,只是把"完整信息"移到模型上下文之外、留一个可以按需取回的定位符。
一次性 vs 持久化:bash 与 terminal_*/tool-bash-persistent 的分工
清单里同时出现了三种"执行命令"的工具家族,容易让人疑惑它们之间是不是重复建设:bash(一次性,每次调用都是全新 shell,不保留 cwd/变量)、terminal_*(六个工具,packages/terminal/tool-terminal,基于持久 PTY 会话)、bash(dsh-tool-bash-persistent 提供的同名工具,packages/shell/tool-bash-persistent,也基于 PTY,但只暴露一个工具而不是六个)。三者背后的能力 seam 不同:bash 走 ctx.shell,terminal_* 和 tool-bash-persistent 都走 ctx.terminals(持久 PTY 会话注册表)。tool-terminal 的模块文档点出了它和后台任务体系(ctx.jobs)的关系:
// packages/terminal/tool-terminal/src/index.ts
/**
* Six model-facing persistent terminal tools. Owner identity comes from the exact
* tool execution Agent; generic `ctx.jobs` owns background ids and collection.
*/
"owner identity 来自发起调用的确切 Agent"这句话点出了持久终端的核心约束:一个终端会话一旦被某个 agent 打开,就归属于这个 agent,terminal_list/terminal_read 等操作只能看到自己 agent 名下的终端,不会串到别的 agent 的终端会话里去。terminal_send(run_in_background: true) 的行为和 bash 的 run_in_background 是同一套复用机制------都注册进 ctx.jobs,用统一的 job_kill/job_list/job_output 三个工具收尾,这也是为什么 dsh-tool-jobs 在官方 Tool Schema Catalog 里被称为"kind-agnostic"(不关心具体是什么类型的后台任务)的控制器:背景 bash 命令、PTY 发送、subagent 委派这三种完全不同的东西,只要都通过 ctx.jobs.start() 注册,就能被同一套 job_* 工具统一读取和终止,不需要为每一种后台任务类型单开一套控制工具。
ctx.jobs 这一侧近期还有两处演进值得记录:其一,job_kill 原来对子 agent(subagent)job 的归属栅栏已被撤掉 ------现在唯一的访问规则是"这个 job 所属的会话你能看见"(owner 会话栅栏),子会话自己的 job 从它自己的列表里就能杀掉,和任何别的 job 一样;其二,人为停止 与模型要求的停止 被明确区分------从 Web/界面侧(JobController.kill)停止一个 job 时,注册表会以 cancelled by the user 为原因记录,因为这不是模型请求的杀停,所以拥有这个 job 的 agent 仍会照常收到完成通知,而正在等待这个 job 的 shell 工具(第一篇讲过的 waitOnJob;有 ctx.jobs 注册表时,bash 的前台命令在启动的那一刻就已被注册为 job)会在自己的结果里读到这个原因,渲染成 [stopped: <reason>],模型读到的是"被人停止的原因"而不是一个莫名其妙的失败。三个执行命令的工具家族之间不是重复,而是"要不要跨调用保留 shell 状态"这一个选择在三种粒度上的具体化:完全不保留(bash)、保留且暴露六个精细操作(terminal_*)、保留但只暴露一个和 bash 同名的简化接口(tool-bash-persistent),部署方按需选择加载哪一套,甚至可以同时加载多套供模型按场景挑选。
常见问题/易踩坑
Q:bash 工具超时之后,OutputCollector 里已经收集到的输出会不会丢失?
不会。超时和输出收集是两条独立的机制:超时(deadline/graceMs)只负责什么时候该终止进程,OutputCollector 从进程 spawn 那一刻起就持续收集数据,和进程是否超时无关。即便命令因为超时被 SIGTERM/SIGKILL 强制终止,OutputCollector 已经收集到的内存尾部窗口和已经落盘的 spill 文件依然完整可读------bash 工具最终返回给模型的结果里,timedOut: true 这个标记和 stdout/stderr 的内容是两个独立的字段,模型可以同时知道"命令被判定超时"和"超时之前它实际打印了什么"。
Q:applyLiteralEdit 的 old_string 匹配是大小写敏感、还要求空白字符完全一致吗?
是的,content.split(oldNorm).join(newNorm) 是纯字符串层面的精确匹配,不做任何大小写归一化,也不做空白折叠------唯一做的归一化是把 CRLF 统一成 LF(normalizeLineEndings),这是为了让 Windows 风格的换行文件和 Unix 风格的换行文件在编辑逻辑层面表现一致,写回时再按 readForEdit 记录下来的原始 LineEndings 转换回去(restoreLineEndings)。除了换行符这一处专门处理,old_string 里的每一个字符(包括缩进用的 tab/空格)都必须和文件内容逐字节对应。
Q:如果两个工具(比如 edit 和 str_replace_editor 的 str_replace)在同一次调用批次里并发修改同一个文件,乐观并发版本控制真的能防住冲突吗?
能防住"基于过期版本的覆盖",但要注意 isConcurrencySafe 在文件写入类工具上通常没有声明(参见第二篇),调度器默认就会把它们分类为 exclusive,同一批里不会真的并发执行------所以在单个 agent 的单次调度 范围内,写入操作本身就是串行的,乐观并发版本控制真正要防的是"两个不同的会话(或者一个会话和它委派出去又汇合回来的子 agent)在不同时间点分别读过同一个文件,后写入的一方版本已经过期"这类跨调用、跨会话的场景。理解这一点能避免误以为"CAS 检查就是为了防止同一批内的并发竞争"------那一层竞争其实在更早的调度阶段就已经被 exclusive 屏障挡住了。
小结与思考题
dsh 的内置工具集按能力域拆分成二十多个可独立组合的包,每个工具的 schema 都从真实注册表动态生成、由脚本校验不会漏收。bash 工具的可靠性来自三层正交机制的叠加:进程组隔离保证终止信号能覆盖整棵进程树,两级 SIGTERM→SIGKILL 超时升级保证顽固进程最终会被清理,内存尾部窗口加落盘 spill 保证海量输出既不会撑爆上下文也不会真的丢失。edit/str_replace_editor 选择字面量字符串替换而不是 diff 格式作为编辑输入,把"匹配是否精确"这个校验完全前置;并发安全靠的是"先读后写"的乐观并发版本控制(CAS),而不是事后可撤销的历史记录------这是调研中确认的一处和某些同类工具不同的取舍。
思考题:
applyLiteralEdit要求old_string在文件里精确匹配且默认恰好一次,这对模型的"引用原文能力"提出了较高要求。如果模型经常因为空白字符差异(比如 tab 和空格混用)匹配失败,应该在applyLiteralEdit内部做归一化处理,还是应该保持严格匹配、把提示词写得更明确?两种方向各会牺牲什么?fs-observation-policy的"先读后写"状态是按会话(session)这个 owner 记录的。如果同一个会话委派了一个子 agent 去编辑同一批文件,子 agent 该不该继承父会话已经观察过的版本记录?继承和不继承分别会带来什么后果?