从 0 开始实现一个 Coding Agent · 第 4 章:真实工具
/ 9 min read
第 4 章 · 真实工具:bash、读、写、改
对应代码:code/mini-agent/tools.ts(同时被 mini-agent 工程引用)
学习目标
- 把“能跑命令的玩具”升级成一套真实可用的工具集:
bash / read_file / write_file / edit_file / list_dir。 - 掌握 bash 执行器的工程细节:超时、进程组击杀、输出截断、退出码 marker。
- 理解工具结果的两条失败通道:marker(业务失败,给模型看) 与 isError(基础设施失败,给系统看)。
从本章起,教程代码从单文件升级为模块化工程 code/mini-agent/,后续章节每章给这个工程增加一个模块。
学习目标外的第一件事:工具定义的结构
每个工具 = 模型面(三件套)+ 执行面(一个函数):
export interface Tool { name: string description: string parameters: Record<string, unknown> // JSON Schema,给模型看 run: (args: Record<string, unknown>) => Promise<string> // 给程序跑}模型面要窄。超时上限、并发安全性、展示逻辑……这些都不该出现在发往模型的 schema 里:模型面越大,提示词越贵、模型分心越多。工具定义里可以有十个字段,但 toolSchemas() 只投影 name/description/parameters 三个。
bash 执行器:细节决定成败
runBash 的完整实现在 tools.ts,四个关键点:
1. 超时 + 进程组击杀。bash -c 启动的命令会派生子进程孙进程,只 kill 直接子进程会留下孤儿。解法:detached: true 建立独立进程组,超时时 process.kill(-pid, 'SIGTERM') 整组击杀,再留 3 秒宽限后 SIGKILL 兜底:
const child = spawn('bash', ['-c', command], { detached: true, // 独立进程组,超时时能整组杀掉 env: { ...process.env, NO_COLOR: '1', TERM: 'dumb', PAGER: 'cat', GIT_PAGER: 'cat' }, stdio: ['ignore', 'pipe', 'pipe'],})2. 模型可见环境变量。NO_COLOR=1、TERM=dumb、PAGER=cat:不给模型的环境里留 ANSI 转义码和交互式分页器——模型读到 \x1b[32m 只会困惑,遇到 (END) 会卡住。
3. 输出截断。单次命令可能输出几十 MB,直接塞进历史就是烧钱 + 爆上下文。超过上限就停止累积,并打上 [output truncated] marker——模型知道被截了,会主动用 head/grep 收窄查询。
4. 退出码是 marker,不是异常。命令退出码非零是正常的业务事实(grep 没找到匹配也是退出码 1!):
if (timedOut) markers.push(`[timed out after ${timeoutMs}ms]`)if (exit.signal !== null) markers.push(`[killed by signal: ${exit.signal}]`)else if (exit.code !== null && exit.code !== 0) markers.push(`[exit code: ${exit.code}]`)if (truncated) markers.push('[output truncated]')stdout 先出,[stderr] 段其次,marker 收尾。只有 spawn 失败这类基础设施故障才走异常通道,由执行器转成 isError 的工具结果。
5. 一个诚实的局限:取消没有接入工具执行。教程的 Ctrl+C 只中断流式请求,已派发的命令会跑到自然结束(或超时)。真实 harness 把调用者的 abort signal 融合进执行器,中断时连进程组一起终止(agent.ts 的 executeToolCall 注释标明了这个简化)。
read / write / edit:防误伤三件套
- read_file:带行号输出(
cat -n风格),支持 1 起始的offset/limit。行号不是装饰——模型汇报“第 42 行有问题”就靠它,下一章的编辑工具也靠它定位。 - write_file:整文件覆盖,自动创建父目录,末尾补换行。
- edit_file:精确字符串替换,并且不唯一就报错——
old_string出现多次而没有replace_all: true时拒绝执行,逼模型扩大上下文锚点。这是把“改错地方”这类事故挡在执行前:
const second = text.indexOf(oldString, first + 1)if (second >= 0 && !replaceAll) throw new Error('old_string 出现多次且不唯一,请扩大上下文或传 replace_all')todo_write:把任务状态记进日志
多步骤任务里,模型需要一个地方维护“计划与进度”。TODO_TOOL(tools.ts)是覆盖式写入:每次传完整列表,工具校验后渲染成稳定的文本格式:
任务清单已更新,共 3 项:- [x] 调查目录结构- [~] 修复 bug- [ ] 写回归测试真正值得注意的是状态存在哪:不在内存、不在单独的文件,就在会话日志里。currentTodos(session) 找最后一条 todo_write 的 tool/call,按 callId 配对出它的 tool/result,解析回结构化清单——第 6 章“日志是唯一事实源”的直接应用:--resume 之后零额外存储即可恢复。prompt.ts 的 todoSection(第 10 章)再把它注入提示词,模型睁眼就知道“做到哪一步了”。
诚实地说一个权衡:教程的“存储格式 = 渲染格式”靠正则解析,脆弱;真实 harness 保存结构化 canonical 值、按需渲染成文本(第 4 章对照的 output: { schema, render })——两层分离后,状态重建不依赖措辞。
接入循环
第 2 章的循环只需要把 run_command 换成整个工具表。执行骨架(完整版在 agent.ts 的 executeToolCall,那里还叠加了第 5 章的权限门和第 6 章的落账):
const tool = tools.find((candidate) => candidate.name === call.function.name)if (tool === undefined) return deny(`unknown tool ${call.function.name}`)let args: Record<string, unknown>try { args = JSON.parse(call.function.arguments) as Record<string, unknown>} catch { return deny('参数不是合法 JSON')}try { const content = await tool.run(args) return { role: 'tool', tool_call_id: call.id, content }} catch (error) { return { role: 'tool', tool_call_id: call.id, content: `Error: ${msg(error)}` } // isError}对照 DeepSeek Harness
- 模型可见工具的完整清单见
docs/tool-catalog.md:bash(packages/shell/tool-bash)、read/write/edit/read_image(packages/fs/tool-fs)、glob/grep(packages/fs/tool-fs-search,走打包的 ripgrep)、todo_write(packages/todo,状态同样存于会话日志)、web_search/web_fetch、skill、subagent等。教程的六件套(bash/读/写/改/列目录/任务清单)是它们的骨干子集。 - 定义结构多一层输出投影。真实工具定义里还有
output: { schema, render }:工具先产出结构化的 canonical 值,再由render投影成模型可见文本(如read工具返回{path, offset, lines, totalLines}再渲染成行号文本,packages/fs/tool-fs/src/read.ts:77-145)。结构化值给 UI 复用(渲染卡片),文本给模型——一鱼两吃。教程直接返回字符串,丢掉的是 UI 那一半。 - bash 工具与执行器分离。工具定义(
tool-bash/src/index.ts:373-533)只管 schema 和结果渲染;真正 spawn 进程的是bash-local执行器(packages/shell/bash-local/src/index.ts):默认超时 120s、模型传值被clampTimeout封顶到 600s;每流 64KB 内存上限,溢出落 spill 临时文件(上限 64MB),返回truncated + spillPath——比教程的“直接丢掉”更完整。超时前台命令还会被“晋升”为后台任务而不是杀掉,模型可以稍后用job_output查看。 - 沙箱是另一章的主角。真实 bash 在 spawn 前还会经过沙箱包裹(
ctx.sandbox.confine(['bash', '-c', command], policy)),以及超时/审批等策略——第 5 章展开。
练习
- 实现
grep_file工具(参数:pattern、file_path),要求输出带行号且默认截断到 200 条匹配。 - 给
read_file加“图片检测”:读到二进制内容时返回一条明确的错误,而不是让模型看到乱码。 - (思考)为什么
edit_file用“唯一字符串替换”而不是“行号区间替换”两种都提供?提示:模型数错行号的概率,和模型抄错上下文的概率哪个高?
本章留下的问题
现在工具能改文件、跑命令了——谁来拦住模型跑 rm -rf?下一章:权限与审批。