← 套装首页 新手造 Agent:跟着 Claude Code 0.2.8 源码写自己的终端 Agent

新手造 Agent:跟着 Claude Code 0.2.8 源码写自己的终端 Agent


还原缺口声明

还原缺口声明。 本文分析的源码树还原自公开安装包中携带的 sourcemap(对应 Claude Code Research Preview 0.2.8),并非官方发布的完整源码。经逐文件核对,以下被引用的文件在还原树中缺失:src/Tool.ts(工具接口定义,全仓 43 个文件以 /Tool.js 结尾的路径引用它)、src/tools/MemoryReadTool/prompt.ts 与 src/tools/MemoryWriteTool/prompt.ts(两个记忆工具的 prompt 与描述文案,各自被同目录的 .tsx 以 import { DESCRIPTION, PROMPT } from './prompt.js' 引用)、src/utils/conversationRecovery.ts(会话恢复模块,被入口 cli.tsx 和 ResumeConversation.tsx 引用,导出 loadMessagesFromLog 与 deserializeMessages)。因此,本文中关于 Tool 接口形状的描述,是依据 15 处 satisfies Tool 实现与全部调用点反推的重建,而非对原始定义的直读;关于两个记忆工具向模型呈现的具体文案,本文不作转述。这也意味着该还原树不能原样通过 TypeScript 编译,本文所有结论均为静态走读所得,未在本机运行 0.2.8。凡受缺口影响、属于推断而非直读的结论,正文均已就地注明。

(本段原文出自本套装出版前事实校验文件 verification.md 第五节,照录未改。)


目录


前言

这本书写给谁

想自己写一个终端 Agent 的新手。不需要你训过模型,也不需要你读过某个 Agent 框架的源码,但需要两样前置:

最好手边再有一份 0.2.8 的还原源码树——GitHub 上 clone TeFuirnever/claude-code-sourcemap 即可。本卷每章「读什么」表格里的行号全部对回这棵树(仓库信息见书名页),读到哪一行就打开哪个文件,这是这本书的基本读法。配套代码(mini-agent 参考实现)随白皮书套装分发,就在套装目录的 mini-agent/ 下。

七关怎么走

阿舟图解:七章七关——每关一个能跑的里程碑,终点是可运行的 mini-agent

本卷正文七章,对应「新手造 Agent 专项」学习路径的七关——章即关,「第 N 章」就是「第 N 关」,正文保留课程里「关」的说法。每关固定四件套:

七关的顺序是一条因果链,不是专题并列:

关 主题 锚定源码 概念 练习产出
1 进程怎么活起来的 entrypoints/cli.tsx(1043 行) 入口即安全边界:stdin 分家、Trust Dialog、先授读权再取 context 30 行 CLI:管道/交互双路输入
2 第一包发给模型什么 constants/prompts.ts、context.ts 系统提示三段式、静态快照 + memoize、cache 断点 生成自己项目的 0.2.8 风格 context 包
3 Agent 就是一个循环 query.ts、messages.ts tool_use → tool_result 递归生成器;循环不规划、不管安全 核心:~80 行 tool_use 循环(mini-agent 骨架)
4 工具契约与编辑引擎 tools/(16 个内置工具,35 个文件) Zod 校验、Edit 先读后改唯一匹配、Grep 只回文件名 给骨架加 Read/Edit/Bash 三工具 + schema 校验
5 权限与安全 permissions.ts、canUseTool 白名单只八条、dangerously 三道门、错误收成 assistant 消息 给骨架加命令白名单 + 确认弹窗
6 TUI 是产品的一半 components/(7961 行 Ink) 视口约束反向塑造提示词(4 行规则)、190k 警告、5 美元弹窗 只读关:画一个权限弹窗的状态机
7 从 0.2.8 到今天 官方文档(仓库外,单独标注) 什么该进内核、什么该是外层(Skills/Hooks/沙箱/自动压缩是后加的) 结业:mini-agent 跑通「读文件→改 bug→跑测试」

mini-agent 主线

主线一句话:跟着 0.2.8 的还原源码,从「敲下 claude」走到「模型改完文件」,最终动手复刻一个 ~200 行可跑的 mini-agent。第 7 章结业时,它要跑通「读文件 → 改 bug → 跑测试」全流程。

规模预算是硬约束:总行数 ~200 行,最多放宽到 220,不许超出——0.2.8 的内核证明这个规模够用。每关一个里程碑 tag:m1-cli → m2-context → m3-loop → m4-tools → m5-permissions → m7-final;第 6 关是只读关,无代码里程碑。附录 B 收参考实现的文件清单、逐里程碑的本机实测记录,以及它与 0.2.8 原作的对读对应表。

七关路线图

图 1 七关全景(手绘版):同一套 tool_use ↔ tool_result 骨架从第 3 关活到结业,逐关只加一层肉;柱子是各里程碑参考实现的真实行数。

口径与纪律(三条)

  1. 行号以出版前事实校验(P0)为准。 本套装配套校验文件 verification.md 已对 28 条行号论断逐一抽检(24 条精确命中、4 条 ±1 偏移、0 条失败),四条偏移进了勘误表:state.ts 实为 25 行(非 26);shims import 在 cli.tsx:9;「stop_reason 不可靠」注释在 query.ts:170;wizard 注释块为 query.ts:111–123。本卷正文与附录 A 均按勘误后的行号书写。
  2. 还原缺口见卷首声明。 src/Tool.ts 等四个被引用文件在还原树中缺失,凡涉及 Tool 接口形状的论述全部带「反推」限定,正文就地标注,阅读时请带着这个限定语。
  3. 仓库外材料必标注。 凡写到 0.2.8 之后的特性(Skills、Hooks、操作系统沙箱、自动压缩等),一律标注「材料来自官方文档,不在本仓库」——本仓库是 0.2.8 的还原树,里面没有这些特性的文件,连引用都没有。

素材来源:各章「读什么 / 关键概念」改写自同套装卷一的深度分析底稿与模块笔记(出处见套装 README「材料出处」一节);缺口与反推结论以出版前事实校验的「还原缺口声明」为准,卷首已照录。


第 1 章 · 进程怎么活起来的(第 1 关)

你在项目目录里敲下 claude,期待一个能改代码的 Agent 立刻出现。但在模型看见任何文件之前,进程必须先回答四个问题:这是谁、人有没有同意、这个目录能不能碰、配置能不能读。这一关读的就是这段「门厅」代码——src/entrypoints/cli.tsx,1043 行。它不推理、不调工具,只负责把人放进一个可信会话。读懂它,你的 mini-agent 就有了第一块砖:一个分得清「管道」和「人」的入口。

读什么

文件 行号 看什么
src/entrypoints/cli.tsx 1–10 shebang、尽早的 Sentry、防 Bun 树摇的 shims import(第 9 行)
src/entrypoints/cli.tsx 271–306 main():enableConfigs() 开闸(273–281)、stdin 分家(291–306)
src/entrypoints/cli.tsx 1015–1023 stdin() 函数本体:怎么把管道吸成一段字符串
src/entrypoints/cli.tsx 93–167 showSetupScreens():Onboarding 触发条件(102–105)、Trust Dialog(147–160)
src/entrypoints/cli.tsx 177–269 setup():再授读权(184–185)、跳过权限的三道门(188–213)、预取不 await(219–222)
src/entrypoints/cli.tsx 369–428 根 action:先设置屏(369)后 setup(379),再按 --print(391–410)或 REPL(414–428)分叉
src/entrypoints/cli.tsx 539–553 mcp serve 子命令,第三种活法
src/utils/config.ts 159–177 checkHasTrustDialogAccepted():信任沿父目录往上爬
src/components/TrustDialog.tsx 27–60 家目录例外(32–42)、No/Esc 直接退出(46–60)
src/utils/env.ts 16–40 Docker 探测(/.dockerenv)与外网探测(1.1.1.1)
src/utils/state.ts 1–25 全部全局状态,和第 4 行的 Boris 注释
src/entrypoints/mcp.ts 50–177 MCP server:在 stdio 上露出同一套工具

关键概念

shebang 与双运行时。 入口第一行是 #!/usr/bin/env -S node --no-warnings=ExperimentalWarning --enable-source-maps(cli.tsx:1),按 Node 启动。但第 6–8 行有一段 XXX: 注释,解释第 9 行那个看似无用的 import * as dontcare from '@anthropic-ai/sdk/shims/node' 和第 10 行的 Object.keys(dontcare):不加这两行,Bun 在 Win32 上会把这个纯副作用的 import 当成死代码删掉——打包器这种「删掉没人用的代码」的优化叫树摇(tree-shaking)。0.2.8 已经按 Node 和 Bun 两套运行时打包在跑,入口必须迁就两边的癖好,不能假设「Node 的模块语义就是运行时语义」。第 4 行 initSentry() 尽可能早,否则启动失败本身不可观测。

配置开闸。 main() 的第一件事不是解析 argv,而是 enableConfigs()(cli.tsx:274)。在此之前任何模块在 import 阶段去读 ~/.claude.json,都会直接抛错——用崩溃强迫调用顺序,比 lint 更狠。JSON 解析失败时也不是 stderr 一行字,而是全屏的 InvalidConfigDialog(276–279 行捕获 ConfigParseError 后弹出):人可以退出手修,或用默认值覆盖,两个选择都结束当前进程。配置坏了就不许进门厅。

stdin 分家。 这是本关最重要的一个概念。管道进来的内容是 prompt,不是键盘。cli.tsx:291–296 的判断是:非 TTY、没有 CI 环境变量、argv 里不含 mcp,才把 stdin 吸干(297 行调 stdin(),这个函数在 1015–1023 行,就是一个 for await 把流读成字符串)。然后在 POSIX 上重开 /dev/tty 塞进 Ink 的渲染上下文(300–301 行)——键盘输入从此走真终端,管道内容当第一句话。mcp 子命令必须排除,294–295 行的注释写得很直白:Input hijacking breaks MCP. MCP 是 Model Context Protocol,一种把程序的工具暴露给别的 Agent 调用的协议,它的 ListTools / CallTool 就走这一对标准输入输出;把 stdin 吸成 prompt 等于把协议掐死。三种启动方式的分歧从「谁拥有标准输入」开始,而不是从「调哪个函数」开始。

Onboarding 与 Trust Dialog 是两条生命周期。 交互路径才会走设置屏(NODE_ENV=test 时在 97–99 行直接短路)。Onboarding 看的是全局标记——没选过主题,或 hasCompletedOnboarding 为 false(102–105 行),内容是「人」的事:配色、登录、安全须知,五个步骤的类型定义在 Onboarding.tsx:24。Trust Dialog 看的是目录——checkHasTrustDialogAccepted()(config.ts:159–177)从当前目录出发,每层楼翻一次 ~/.claude.json 的 projects 字典,没有标记就 resolve(currentPath, '..') 爬父目录(168 行),爬到根为止。子项目开在已信任的 monorepo 里不用再点一次。家目录是例外:TrustDialog.tsx:32–42,点了「Yes, proceed」也不落盘,下次还问——家目录永远不能变成「已信任工作区」。拒绝或 Esc 是硬退出(46–60 行),没有「先看看再说」。--print 和 --dangerously-skip-permissions 跳过这个对话框(cli.tsx:148),因为这两种模式「没有坐在屏幕前的人」。

两次授读权,顺序不能倒。 Trust 被接受后,cli.tsx:153 在对话框的 onDone 里调 grantReadPermissionForOriginalDir();随后根 action 里 setup() 在第 185 行又无条件授一次(--print 路径没弹过对话框,靠这一次让 CI 能读启动目录)。授的都是读权,写权此时不存在。授完,setup 才去预取 getContext()(221 行,故意不 await,首屏不卡在 git status 上)。顺序倒了的话,目录树、git 状态、README 可能已经拼进要发给模型的系统提示,而人还没点过头——0.2.8 没有沙箱,社会层的同意必须发生在任何「为模型看世界」的动作之前。

--print 与三道门。 --print 分支在 391–410 行:prompt 和 stdin 至少要有一个(392–397),跑完 ask() 打印纯文本 exit 0,是一次性问答。--dangerously-skip-permissions 的名字本身就是文档,option 描述里写着 "Will crash otherwise"(354 行)。setup() 里的三道门(188–213 行):Unix 上 getuid() === 0 直接拒绝——sudo 加跳过权限等于把整盘交给模型;必须能看见 /.dockerenv 且系统是 Linux(env.ts:16–23);必须对 1.1.1.1 发一次 1 秒超时的 HEAD 探测不到外网(env.ts:25–40)。任一失败 exit 1。这是给内部无人值守评测留的旁路,设计思路是把旁路写得很难误用,而不是假装没有旁路。

内部用户的闸门挂在入口,不渗进循环。 设置屏里还夹着两个只对 Anthropic 员工(USER_TYPE === 'ant')开放的分支:环境变量里出现新 API Key 时弹 ApproveApiKey 批准框(cli.tsx:123–145,Key 被截成末 20 位做指纹记进批准名单);Trust 之后再过一轮 .mcprc 服务器审批(162–165 行)。外部构建里这些分支被树摇干净。原则值得记住:内外差异全部在入口分叉,Agent 循环永远不知道自己跑在哪种构建里。

默认动作就是产品。 注意根 action 里的调用顺序:369 行先 showSetupScreens,379 行才 setup,383–388 行并行拉工具表和 MCP 客户端,最后按 print 分叉。claude 和 claude "修登录 bug" 都落在这一个 action 上,config / mcp / doctor 才是真正的子命令。产品入口保持三个音节,代价是运维、内部狗食、自动更新全挤在这 1043 行里——脏留在门厅,没有脏到循环里。

25 行的全局状态。 src/utils/state.ts 一共 25 行,第 4 行注释写着 DO NOT ADD MORE STATE HERE OR BORIS WILL CURSE YOU。内存里只冻了一件事:进程从哪个目录启动(originalCwd)。之后真正使用的工作目录住在持久 shell 里,靠 getCwd() 去问它。能不下沉的状态就不下沉,这条纪律后面每一关都会再遇到。

MCP server 是第三种活法。 claude mcp serve(cli.tsx:539–553)走 setup 但不走任何设置屏,然后在 stdio 上露出八件内置工具(Agent、Bash、读写改、Glob、Grep、LS,清单在 mcp.ts:39–48)和一条 /review 命令,传输层在 mcp.ts:172–177 接上 StdioServerTransport。它把 Claude Code 自己变成别人的工具后端,所以入口必须保证这条路不碰 TTY、不弹 Ink。注意一个还原缺口:mcp.ts:24 import { Tool } from '../Tool.js',而 src/Tool.ts 在这棵还原树里缺失——这份 Tool[] 清单的类型形状是从全部调用点反推的,不是对原始定义的直读。凡涉及工具接口的论述,本课程都会带这个限定。

门厅结束时,工具表、斜杠命令表、MCP 客户端已经备好,render(<REPL />)(414 行)才发生。模型到这一步仍然没有收到任何仓库内容——那是第 2 关的事。

动手练习

里程碑 m1-cli:30 行的双路输入 CLI。

在 mini-agent/ 下新建 cli.mjs(Node 18+,零依赖),实现 Claude Code 入口的 stdin 分家逻辑的最小版:

验收标准:

  1. echo "hi" | node cli.mjs 输出 [got] hi 且进程自行退出、退出码为 0;
  2. node cli.mjs "hi" 输出 [got] hi 并退出;
  3. echo "a" | node cli.mjs "b" 输出含 b 和 a 两行拼接结果;
  4. 裸跑 node cli.mjs 进入交互,键盘输入有回显,exit 正常退出;
  5. 全文件不超过 30 行(不含空行),node --check cli.mjs 通过。

选做(不计入行数预算):管道模式下尝试 openSync('/dev/tty', 'r'),成功则交互循环改从该 fd 读——你就复刻了 cli.tsx:298–305。

自检题

  1. main() 为什么把 enableConfigs() 放在解析 argv 之前?在 import 顶层读配置会有什么后果?
  2. cli.tsx:294–295 的注释说 Input hijacking breaks MCP.——mcp serve 被 stdin 劫持具体会坏在哪?
  3. cli.tsx:153 和 :185 授了两次读权。为什么 --print 路径离得开第一次却离不开第二次?为什么两次都必须早于 getContext() 预取?
  4. --dangerously-skip-permissions 的三道门各自防的是什么误用场景?为什么 uid 0 那条排在最前、连 Docker 检查都不用等?
  5. state.ts 里只有 originalCwd 一个值。真正的工作目录存在哪?这种「薄状态」设计和 cli.tsx 的 1043 行单文件矛盾吗?

参考答案见附录 A。


门厅走完了。第 1 章结束时,进程分清了管道和键盘,人点过了 Trust Dialog,读权也授了出去——但模型还没见过这个仓库的一个字。门厅只负责「能不能进」,不负责「进去之后看见什么」。第 2 章接着走那半步:你敲下回车的一瞬间,CLI 往 API 塞的首包里到底装了什么。练习也跟着升级:写出 m2-context,给你自己的项目生成一份 0.2.8 风格的首包,亲眼看看模型将看到的世界。


第 2 章 · 第一包发给模型什么(第 2 关)

上一关结束时,进程活了,Trust Dialog 也过了,人把这个目录交给了 Agent。这一关回答紧随其后的那个问题:你敲下回车的一瞬间,CLI 到底往 Anthropic 的 API 塞了一个什么样的包?

拆开看,这个首包只有三样东西:一份系统提示(system)、一张仓库快照(context)、一组工具 schema。工具 schema 是第 4 关的事,这一关只看前两样——它们分别来自 src/constants/prompts.ts 和 src/context.ts,最后在 src/services/claude.ts 里合体、贴标、发出去。

读什么

文件:行号 内容
src/constants/prompts.ts:16–127 getSystemPrompt() 全文,三段系统提示
src/constants/prompts.ts:44、:121 「少于 4 行」规则,首尾各一遍
src/constants/prompts.ts:45–86 极端 few-shot 样例组
src/constants/prompts.ts:20–21、:124–125 恶意软件禁令,首尾各一遍
src/constants/prompts.ts:129–142 getEnvInfo(),<env> 块
src/context.ts:157–180 getContext(),五键快照总装
src/context.ts:186–224 getDirectoryStructure(),1 秒超时
src/context.ts:85–152 getGitStatus(),五条 git 命令并行
src/context.ts:24–48、:71–83 getClaudeFiles()(3 秒超时)与 getReadme()
src/context.ts:50–58 setContext(),计算键不准落盘
src/utils/style.ts:9–28 getCodeStyle(),CLAUDE.md 祖先链
src/services/claude.ts:394–400 splitSysPromptPrefix(),数组压成两块
src/services/claude.ts:426–441 formatSystemPromptWithContext(),拼 <context>
src/services/claude.ts:472–480、:642–650 system block 与消息的 cache 断点
src/services/claude.ts:58、:516 温度写死 1;beta.messages.stream 发出
src/utils/tokens.ts:4–27 countTokens(),不跑本地 tokenizer
src/components/TokenWarning.tsx:9–11 190_000 上限与 60%/80% 阈值
src/screens/REPL.tsx:327–334 每次提交并行取四件套

读法建议:prompts.ts 一共 154 行,一半是英文提示词原文,通读;context.ts 一共 224 行,通读;claude.ts 有 948 行,按表里的行号定点看。读的时候手里拿一个问题:哪些字是每场对话都一样的,哪些字是这个仓库独有的。

关键概念

系统提示是三段,不是一篇长文

getSystemPrompt()(prompts.ts:16)返回 string[],三个元素:主人格(:18–122)、<env> 环境块(:123)、再把恶意软件禁令原样抄一遍(:124–125)。

为什么是数组而不是长文?因为三段来自不同生命周期:人格是产品规格,几乎不变;<env> 每场会话现算——工作目录、是否 git 仓库、平台、日期、模型名(:134–141);政策段要在结尾再压一次。数组是组装接口。真正出站时,splitSysPromptPrefix(claude.ts:394–400)把第一段单独留下,其余 join('\n') 合成第二块——所以打到 API 的 system 通常只有两个 text block。顺带一提,querySonnet 还会在最前面再插一句固定开场 "You are Claude Code, Anthropic's official CLI for Claude."(prompts.ts:12–14),并把第一块打 sha256 送进 Statsig 做前缀识别遥测(claude.ts:458–469)。研究仪器从第一天就焊在通道上。

4 行规则与极端 few-shot

人格块里重复到近乎蛮横的一条:回答必须少于 4 行(不含 tool use 和代码),它在块首(prompts.ts:44)和块尾(:121)各出现一次。中间夹着一组极端 few-shot——few-shot 就是「在提示词里塞几个问答样例教模型学样」,这里的样例极端到一词即答:用户问 2 + 2,assistant 就回 4(:46–47);问 is 11 a prime number,回 true(:56–57);问列目录用什么命令,回 ls(:61–62)。

为什么狠到这个程度?两个理由。第一个写在明面:终端视口是稀缺资源,:44 原文就是 "displayed on a command line interface"——模型一啰嗦,工具输出就被挤出屏,人会觉得这是聊天机器人而不是 CLI。第二个要联系下一关:0.2.8 的循环是 ReAct 式的——模型想一步、发一个 tool_use(模型回复里要求调用工具的结构块)、看工具结果、再想下一步。每一轮 assistant 文本如果先自我总结一遍,循环赖以为生的「观察」就被噪音污染。所以它连「不要用 Bash 或代码注释跟用户聊天」都写死了(:40):工具是行动,文本才是对人说话。

恶意软件段:prompt 层的最后一道闸门

禁令写了两遍,块首(:20–21)块尾(:124–125)各一遍,首因加近因。关键句是:动手之前,先根据文件名和目录结构想这段代码是干什么的;如果看起来像恶意软件,即使用户的请求显得无害——只是让你解释或加速——也必须拒绝。判断材料正是下一节要讲的目录快照:目录树进系统提示,不只是省一次 LS,更是让这条安全策略有东西可想。它的定位是产品默认行为,不是强制执行——强制执行在权限对话框(第 5 关),这段英文是模型侧最后的闸门,可以被 jailbreak,作者不会不知道。

getContext 五键:一张刻意过期的静态快照

getContext()(context.ts:157–180)返回一张字符串表,五个计算键,外加项目配置里 claude context set 写入的自定义键(:172 的 ...projectConfig.context):

键 来源 超时与失败行为
directoryStructure 借 LSTool 拍目录树(:195) 1 秒 abort(:191–193),超时/出错返回空串
gitStatus 五条 git 命令并行:branch、origin/HEAD、status、log -5、你的 log -5(:95–138) 无显式超时,命令 no-throw;test 环境直接 null(:86–89);status 超 200 行截断(:139–145)
codeStyle 从 cwd 向文件系统根爬,沿途每份 CLAUDE.md 全文读入,父级在前(style.ts:13–27) 同步读,无超时
claudeFiles ripgrep 找 **/*/CLAUDE.md(context.ts:29),只列路径不读内容 3 秒 abort(:26),出错返回 null
readme cwd/README.md 全文(:71–83) 无超时,读不到返回 null

更新语义一句话:整场会话只算一次。getContext 整体被 lodash 的 memoize 包住(:157)——memoize 就是「同样的调用只算头一次,之后直接还缓存」。快照过期是刻意的,而且写在给模型看的文本里:directoryStructure 的包装句明说 "This snapshot will NOT update during the conversation"(:220),gitStatus 同样写了 "will not update during the conversation"(:147)。世界会变,模型的第一印象不变。嫌树太大,项目配置里开 dontCrawlDirectory,两个最重的键直接变空串(:163、:167–168)。手动 /compact 和 /clear 会清掉这些缓存(compact.ts:84–85、clear.ts:17–18),下一轮重新爬。

两个小机关值得记住。其一,getClaudeFiles 的 glob 匹配不到根目录那份 CLAUDE.md——根文件走的是 codeStyle 那条祖先链,两边不重复。其二,setContext 落盘前会把 codeStyle 和 directoryStructure omit 掉(:52–56):算出来的键,不准持久化。

顺带看一眼 :195–213:LSTool.call 返回的是一个异步生成器(async function* 的产物,可以中途 yield 多次结果、调用方逐条拉取的对象),这里用 lastX 只取它最后一条结果。生成器是第 3 关的主角,这里先混个脸熟。

合体贴标:两个 system block,末两条消息

REPL 每次提交并行取四件套(REPL.tsx:327–334):系统提示、context、模型名、thinking 预算。然后 formatSystemPromptWithContext(claude.ts:426–441)把 context 表挂到系统提示后面:一句「你可以用这些上下文」,每个键包成 <context name="键名">值</context>(:438)。

接着是 cache。prompt cache 的意思是:Anthropic API 能记住你发过的前缀,下次相同的前缀少算钱、回得更快。cache_control: { type: 'ephemeral' }——ephemeral 字面义「短暂的」——就是打在文本块上的标记:这块可以进缓存。0.2.8 的标法很素:两个 system block 各打一遍(claude.ts:472–480);消息侧 addCacheBreakpoints(:642–650)只给最后两条打,判断式是 index > messages.length - 3(:647–648)。总闸是环境变量 DISABLE_PROMPT_CACHING(:45)。现在回看「快照过期是刻意的」:系统提示整场不变,前缀才稳,缓存才中——这两件事是同一个设计。后来的 2.x 把 system 按变更频率切成更多段、还加了自动压缩,那些材料来自官方文档,不在本仓库,第 7 关再谈。

发送在 claude.ts:516(anthropic.beta.messages.stream)。两个细节值得新手记住。其一,温度写死 MAIN_QUERY_TEMPERATURE = 1(:58),注释 "to get more variation for binary feedback":采样更散,内部员工做 binary feedback(两个候选回答左右对照挑一个)时,两次采样才容易长得不一样。其二,工具 schema 在这里才展开成 {name, description, input_schema}(:482–493),description 来自 tool.prompt(...)——注意 Tool 接口的定义文件在还原树里缺失,这句话是对调用点的直读,接口形状本身属于反推。

字数不用自己数:countTokens 与 190k 警告

上下文用了多少 token,countTokens(tokens.ts:4–27)不在本地跑 tokenizer。它从消息列表尾部往前找最近一条带 usage 的非合成 assistant 消息,把 input、cache_creation、cache_read、output 四个数加起来(:17–22)。usage 是 API 每次响应白送的,何必自己估。合成的打断消息会被跳过(:11–14)。输入框下方的警告读的就是这个数(PromptInput.tsx:448):TokenWarning.tsx:9 把上限写成 190_000,注释说给 /compact 留余量;用到 60% 开始显示(:10),80% 变红(:11、:24),文案直接让你跑 /compact。

动手练习

里程碑 m2-context:给 mini-agent 加「首包生成器」,为你自己的项目生成一份 0.2.8 风格的 context 包。

在 mini-agent/(与上一关 m1-cli 同目录)新建 context.mjs,预算 ≤60 行,实现四个函数:

  1. getSystemPrompt():照抄三段式结构——人格段(自定,但必须含一条你自己的「少于 N 行」规则和至少两组一词即答的 few-shot)、<env> 块(cwd、是否 git、日期)、结尾再压一条你自己的政策句。返回 string[]。
  2. getContext():抓五键。directoryStructure 用 find . -maxdepth 2 或等价物,1 秒超时则空串;gitStatus 至少 branch + status --short + log -5,status 超 200 行截断;codeStyle 从 cwd 向根爬找 CLAUDE.md(或你的等价物);claudeFiles 用 glob 找子目录同名文件,只列路径;readme 读 README.md 前 100 行。整个函数 memoize(手写一个缓存变量即可,不准引 lodash)。
  3. formatSystemPromptWithContext(systemPrompt, context):把每个键拼成 <context name="..."> 追加到数组尾部。
  4. addCacheBreakpoints(messages):不用真调 API,给最后两条消息的对象打上 cache: true 标记后返回新数组。

最后让 node context.mjs 把完整首包打印到终端,肉眼检查一遍模型将会看到什么。

验收标准:在你自己一个 git 项目根目录跑 node context.mjs,输出里能数出三段系统提示和五个 <context name> 块(gitStatus 必须非空);在同一进程内连续调用两次 getContext,第二次零文件系统读取(注意:memoize 是内存缓存,跑两次进程不算数——用计数器或时间戳证明);文件不超过 60 行。

自检题

  1. getSystemPrompt() 返回的三段各是什么?恶意软件禁令出现两次,位置分别在哪,为什么这样安排?
  2. 0.2.8 为什么故意让目录快照整场会话不更新?这个决定和 addCacheBreakpoints 只标最后两条,共同服务的目标是什么?
  3. countTokens 的数从哪来?如果会话里只有开头一条真实 assistant 响应、之后全是合成打断消息,TokenWarning 读到的数会停在哪?
  4. getClaudeFiles 的 glob 找不到哪份 CLAUDE.md?那份文件实际经哪条路径进系统提示?
  5. MAIN_QUERY_TEMPERATURE 为什么是 1?注释里的 binary feedback 指什么场景?

参考答案见附录 A。


首包发出去了,模型也回话了。回话里如果带着 tool_use,事情才刚刚开始:那是模型在请求程序替它跑工具,得有人接住、执行、把结果喂回去,再问它下一步想干什么。第 3 章拆的就是这个「接住」的机构——一个递归异步生成器,全部状态就是一个消息数组。这一关你要交出 mini-agent 的心脏:约 80 行、能自己跑两轮工具调用、然后自己停下来的循环骨架。形状这一关定死,后面几关只加肉、不改形。


第 3 章 · Agent 就是一个循环(第 3 关)

主线里程碑 m3-loop:本关交出 mini-agent 的心脏——一个能自己跑两轮工具调用、然后自己停下来的循环骨架,约 80 行。

第 2 关把第一包发给了模型。模型回了话,然后呢?本关回答这个问题。把「模型说话」和「程序干活」接起来的,是 src/query.ts——一个 516 行的文件,核心只有一个递归异步生成器。所谓 Agent,扒到底就是一个循环。

读什么

文件 行号 看点
src/query.ts 124–242 query() 主体:一次模型往返 + 一轮工具 + 一次递归
src/query.ts 111–123 query() 正上方的 thinking「wizard」注释
src/query.ts 68、184–216 MAX_TOOL_USE_CONCURRENCY = 10;全部只读才并发
src/query.ts 285–345、365–495 runToolUse / checkPermissionsAndCallTool:校验、权限、执行三段
src/utils/messages.tsx 156–233、236 起 processUserInput 的 bash 分支与斜杠分支
src/utils/messages.tsx 36–43、605 四句写死的合成语;stop_reason 不可靠的第二处注释
src/services/claude.ts 66–162 MAX_RETRIES、shouldRetry、withRetry
src/services/claude.ts 206–224、249、543–558、618–640 TODO(ben) 与 finalMessage;SDK maxRetries: 0;错误收成 assistant
src/messages.ts 全文 24 行 getter/setter 桥

阅读顺序建议:先从头到尾读一遍 query() 本体(:124–242,约 120 行),把「拿回复 → 判 tool_use → 跑工具 → 递归」这条脊柱记住;再回头看 runToolUse 和 checkPermissionsAndCallTool 这两段「关节」;最后跳到 claude.ts 看重试和错误收敛。messages.tsx 只读 processUserInput 附近即可,其余留到第 6 关。

关键概念

先把术语落地:tool_use 与 tool_result

tool_use 是模型回复 content 数组里 type: 'tool_use' 的块,带 name(工具名)和 input(参数)。它不是函数调用,是一段数据——模型在请求程序替它跑这个工具。程序跑完,把结果写成 type: 'tool_result' 的块,带上 tool_use_id 指回那个 tool_use——所以 tool_use 块自己要带一个唯一 id,两者靠这对字段配对。一轮有多个 tool_use 时,每个 tool_result 各包成一条 user 消息、逐个发回,不合并,连同整段对话再发回模型。模型读到结果,决定下一步:再发 tool_use,或者回纯文本收工。循环的全部内容就这一件事:

flowchart LR U[人在终端打字] --> PI[processUserInput] PI -->|普通句子| Q[query 递归生成器] PI -->|感叹号 bash / 本地斜杠| LOCAL[本地跑完就停] Q --> API[querySonnet] API --> M{content 里有 tool_use?} M -->|没有| DONE[这一轮结束] M -->|有| GATE[Zod / validateInput / canUseTool] GATE -->|通过| CALL[tool.call] GATE -->|拒绝或失败| TR[写成 is_error 的 tool_result] CALL --> TR TR --> Q

图 2 一次输入如何进入循环(复刻自分析报告图 1,数据来自 query.ts 与 messages.tsx 的 processUserInput)。

注意收尾的判据不是 API 信封上的 stop_reason:query.ts:170 的注释明说它不可靠,messages.tsx:605 写了同一句。程序看的是 content 里还有没有 tool_use 块——相信块,不相信信封。

进循环之前:processUserInput 的三分叉

人敲下回车,文字先过 processUserInput(messages.tsx:156),此刻还没进 query:

三分叉的含义:不是所有输入都值得惊动模型。

递归生成器形态

export async function* query( 从 query.ts:124 开始。async function* 是异步生成器:函数可以中途 yield 一条消息交还调用方,调用方(REPL)用 for await 一条条拉。一次 query 调用 = 一次模型往返 + 本轮全部工具 + 一次递归。收尾在 :234–241:

yield* await query(
  [...messages, assistantMessage, ...orderedToolResults],
  systemPrompt, context, canUseTool, toolUseContext, getBinaryFeedbackResponse,
)

yield* 把「下一轮的产出」接到自己身上。没有 while (true)。每轮的局部量(本轮 assistant、本轮 toolResults)天然是这一层栈帧;Esc 取消时 return 就停。消息数组就是全部状态——0.2.8 没有自动压缩,工具轮数通常是个位数,递归深度够用。文件里也没有递归上限,这更像预览期没补的洞,不是有意设计。

query() 正上方(:111–123)有一段 wizard 口气的注释,讲 thinking 块的三条规则。写在循环旁边而非 claude.ts,是因为违规的惩罚发生在下一轮回放整条消息数组时——这恰好提醒你:递归每轮都在原样回放历史。

循环不规划,也不管安全

读完 516 行会发现这个循环刻意不做两件事。它不规划任务:下一步干什么由模型的 tool_use 决定,不由任何状态机的边决定——引入图,等于把模型已经在做的路由再写一遍。它也不判断安全:每个工具执行前过 canUseTool(:424),但那是注入的函数——REPL 传带弹窗的 useCanUseTool,--print 传不带 UI 的 hasPermissionsToUseTool。循环只认「放行 / 把拒绝写成 is_error 的 tool_result 喂回模型」。安全是第 5 关的事。

循环对工具的全部了解只有三个问题:isReadOnly() 决定并发(本轮全部只读才走并发,:184–216,上限 :68 的 10 路);inputSchema 做 Zod 校验;call 跑活。不认识任何具体工具,41 个工具共用同一扇门。要说明:src/Tool.ts 在这份还原里缺失,这里及以下关于 Tool 接口的描述都是依据 15 处 satisfies Tool 和调用点的反推,不是直读。

为什么混着读写时不按段切开、只读的先并行?文件里留了 TODO 承认「可以更激进」,但没做。从旁边代码能看到三个具体障碍:权限弹窗是单槽的,两个写工具同时问人会互相覆盖;Bash 共享一份持久 shell,并行写是数据竞争;MCP 工具的 isReadOnly() 恒为 false,任何一个 MCP 调用都会把整批打成串行。保守调度让循环保持薄——这是 0.2.8 全篇反复出现的取舍。

重试自己写,错误收成一句话

SDK 的自动重试被关掉了:claude.ts:249 写死 maxRetries: 0,注释说「换成手写」。手写的 withRetry(:120–162)换来两样东西:终端能打出 Retrying in Ns…,Statsig 能记 tengu_api_retry。重试判据在 shouldRetry(:86–118):408(请求超时)、409(锁超时)、429(限流)、5xx 和连接错误;overloaded_error 只对 SWE_BENCH 重试。上限写在 :66——普通用户 10 次,USER_TYPE=SWE_BENCH 100 次。尊重 retry-after 响应头(:142),否则 500ms 起步指数退避,封顶 32 秒。

重试耗尽之后,getAssistantMessageFromError(:618–640)把失败收成固定的 assistant 短句:prompt 太长、余额不足、Key 无效各有文案,其余套 API Error: 前缀。querySonnet 的 catch(:543–558)return 的是一条普通的 type: 'assistant' 消息。循环不需要处理半打异常类型——失败也走同一条路。

流式传输,但整段出现

HTTP 层走的是流式:anthropic.beta.messages.stream(:516)。但 handleMessageStream(:206–224)把流消费完、等到 finalMessage 才返回;:212 留着 TODO(ben): Consider showing an incremental progress indicator.。所以 0.2.8 里传输在流,屏幕上文字仍整段突然出现。写 mini-agent 时这是最容易「超越原作」的地方,但本关先别做——增量渲染会把界面和消息规范化搅在一起。

messages.ts:24 行的桥

本关锚定的第三个文件最短:src/messages.ts 全文 24 行,只有四个函数,注册和取回消息数组的 getter/setter。它存在的唯一理由是让非 React 代码(比如 /compact)摸得到 REPL 的消息数组。状态仍旧只有一份——那个消息数组。

动手练习

m3-loop:约 80 行的 tool_use 循环骨架。 这是 mini-agent 的心脏,后续关卡只往里加东西,不改它的形状。

新建 mini-agent/loop.mjs(Node 18+,零依赖,不需要 API key),写四块:

  1. 假模型 fakeModel(messages)(~15 行):按 messages 里已有几条 assistant 依次回——第一轮回 tool_use read_file,第二轮回 tool_use list_dir,第三轮回纯文本收工。
  2. 两个工具(各 ~10 行):read_file 读指定文件前 200 字符;list_dir 列当前目录。
  3. async function* query(messages)(~30 行):yield assistant → content 里没 tool_use 就 return → 逐个跑工具,每个结果各包成一条带 tool_result 的 user 消息、逐个 yield(多个 tool_use 不合并成一条)→ yield* query([...messages, assistant, ...results]) 递归。
  4. 入口(~10 行):for await 拉取并打印每条消息。

验收标准(逐条可查):

提示:先抄形状再填肉。query.ts 的递归只有 8 行(:234–241),你的版本也不会更长。

这个骨架就是后面所有关卡的底座:第 4 关把 read_file / list_dir 换成真正的 Read/Edit/Bash 三工具并加上 schema 校验(对应 checkPermissionsAndCallTool 的前两段),第 5 关在 query 和工具之间插入 canUseTool 白名单与确认弹窗,第 7 关结业时它要跑通「读文件 → 改 bug → 跑测试」三轮以上的真实循环。形状本关定死,后面只加肉。

自检题

  1. 0.2.8 判断「这一轮结束了」为什么不看 API 返回的 stop_reason?它改看什么地方?
  2. 模型一轮回复里同时有 1 个 Bash(写)和 3 个 View(读),0.2.8 会怎样调度这批工具?规则写在 query.ts 哪几行?
  3. 用户在 REPL 里输入 !ls -la 和 /clear,这两次输入会进 query 吗?REPL 依据什么决定不再调用 query?
  4. SDK 的 maxRetries 被设成了几?既然 SDK 自带重试,为什么还要手写 withRetry?手写版本尊重服务器的哪个响应头?
  5. API 连续报错把重试耗尽之后,querySonnet 会把异常抛给 query 吗?循环实际「看见」的是什么?

参考答案见附录 A。


上一章的循环只认工具名做分发,不认识任何具体工具——read_file 和 list_dir 只是教学替身。真正的问题还悬着:一次 tool_use 怎么变成改文件、跑命令、搜代码?第 4 章把答案落到契约上:循环能保持薄,是因为契约够厚。你会给骨架装上 Read/Edit/Bash 三个真工具,配上校验和「先读后改」的规矩。也是从这一关起,「反推」会反复出现——接口定义文件缺失,记得卷首声明。


第 4 章 · 工具契约与编辑引擎(第 4 关)

上一关我们把 query.ts 读成了一个薄循环:模型吐 tool_use(模型说「我要调用某个工具」的结构化请求),循环按名字分发,把结果包成 tool_result 喂回去,再递归。这一关回答剩下的问题:那一次 tool_use 究竟怎么变成改文件、跑命令、搜代码? 答案不在循环里,在 src/tools/ 的 41 个实现文件里。循环能保持薄,是因为契约够厚。

接口文件缺失,本章关于 Tool 形状的论述全部为反推。 src/Tool.ts 在还原树中不存在(同批缺失的还有 MemoryReadTool/prompt.ts、MemoryWriteTool/prompt.ts、src/utils/conversationRecovery.ts,还原树因此编译不过)。契约形状是从 15 处 satisfies Tool 实现、query.ts 的调用点交叉反推的,凡涉及接口字段的表述都请带着这个限定语来读。

读什么

文件 行号 看什么
src/query.ts 365–495 checkPermissionsAndCallTool:一次 tool_use 落地的完整流水线
src/query.ts 377–394 Zod safeParse 先拦,失败直接回 is_error 的 tool_result
src/query.ts 399–420 第二道闸 validateInput,失败文案是给模型的下一步指令
src/query.ts 441–493 tool.call() 是异步生成器;成功与异常都统一收成 tool_result
src/tools/FileEditTool/FileEditTool.tsx 115–217 validateInput 八连拒:空操作、创建冲突、未读、被改、找不到、不唯一
src/tools/FileEditTool/FileEditTool.tsx 218–257 call 落盘,写完后回填时间戳(238 行)
src/tools/FileEditTool/prompt.ts 19–38 写给模型看的说明书:3–5 行上下文、一次只改一处
src/tools/FileReadTool/FileReadTool.tsx 181 读成功写入 readFileTimestamps[path] = Date.now()——「先读后改」链的起点
src/tools/GrepTool/GrepTool.tsx 103–143 call 里拼 rg -li,只回文件名,按 mtime 排序
src/tools/GrepTool/prompt.ts 3–11 prompt 自称「搜索文件内容」——与实现的分叉点
src/tools/ArchitectTool/ArchitectTool.tsx 51–53 isEnabled() 写死 return false
src/tools/BashTool/BashTool.tsx 82–85 needsPermissions() 恒为 true
src/tools.ts 23、41 getAllTools() 同步清单;getTools 被 memoize 包住
src/Tool.ts — 不存在。接口靠反推,读每个工具文件末尾的 satisfies Tool

关键概念

1. Tool 接口:一份对象,五个消费者(反推)

每个工具是一个对象字面量,末尾写着 satisfies Tool<...>——这个写法全仓出现 15 次(每个工具文件恰好一次),是我们反推接口形状的主要证据。satisfies 是 TypeScript 的「检查但不改变类型」标注:对象必须满足 Tool 的形状,否则编译报错。反推出来的契约大致是:name(API 名)、userFacingName(TUI 显示名)、description / prompt(短描述与长说明书,模型真正读到的是后者)、inputSchema(Zod schema)、isEnabled()(门控)、isReadOnly()(调度依据)、needsPermissions()(权限门闩)、validateInput()(业务校验)、call()(异步生成器)、外加几个 render* 渲染函数。

一份对象同时服务五个消费者:模型读 prompt + schema;循环调 call;人看 render*;调度器问 isReadOnly;注册表问 isEnabled。这就是「薄循环」能成立的全部原因——细节全焊在工具上,循环只做分发。

2. 两道校验:Zod 在前,validateInput 在后

query.ts:365 的 checkPermissionsAndCallTool 是一次 tool_use 落地的流水线,顺序固定:

  1. Zod 拦参数形状(query.ts:377):tool.inputSchema.safeParse(input)。旁边注释很坦白:「the model is not great at generating valid input」。schema 全部用 z.strictObject 写(如 GrepTool.tsx:17、FileEditTool.tsx:27-31),多一个字段都算非法。失败不打异常、不崩循环,而是回一条 is_error: true 的 tool_result(query.ts:385-392),把 Zod 的错误文案交给模型自我纠正。
  2. validateInput 拦业务语义(query.ts:399):形状对了还不够,每个工具有自己的规矩。失败同样回 is_error tool_result(query.ts:411-418),文案是命令式语句,不是日志。

两道都在 call()(query.ts:441)之前。工具本体永远假设输入已合法——脏活在边界上做完。

3. Edit:必须先 Read,且只改唯一一处

Edit 的输入只有三件事:file_path、old_string、new_string。不是行号补丁,不是整文件覆写,是唯一字串替换。FileEditTool.tsx 的 validateInput(115–217 行)是执行引擎,依次拒绝:old 等于 new(空操作);文件已存在却给了空 old_string(创建冲突);文件不存在;.ipynb 改错工具;然后是最关键的两条——

语义校验的最后两刀是「找不到」(195–203 行)和「找到多处」(205–214 行):old_string 在文件里出现超过一次就失败,报错文案是「Found N matches… Add more lines of context」。prompt 侧把同一规则写成说明书(prompt.ts:21-33):前后至少 3–5 行上下文、一次只改一处。失败文案本身就是给下一轮模型的指令,这是整个编辑引擎最重要的产品决策:把模型的不精确变成多一轮对话,而不是一次静默错改。

4. Grep 只回文件名,不回内容

0.2.8 最容易看走眼的工具。prompt 写着「Searches file contents using regular expressions」(GrepTool/prompt.ts:5),实现却在 call 里写死 const args = ['-li', pattern](GrepTool.tsx:107):-l 只打印文件名,-i 忽略大小写。模型拿不到行号、拿不到匹配行,只拿到一串按 mtime 降序的文件路径,上限 100 条。定位候选文件靠 Grep,读内容靠 View,改靠 Edit——分工是刻意的上下文卫生:grep -r 一次能吐出几万行,只回文件名就灌不爆上下文。(顺带一提:后来版本让 Grep 返回 path:line:text,材料来自官方文档,不在本仓库。)

5. Architect:一扇焊死的门

ArchitectTool.tsx:51-53 的 isEnabled() 写死 return false。就算 CLI flag 或项目配置把它 push 进工具清单,注册表的 isEnabled() 过滤也会把它再摘掉。0.2.8 快照里这个「架构师子代理」实际不可达——是「用 Tool 包装一次规划循环」的实验位,门被焊死了。读它的意义在于:同一套契约连「默认不存在的工具」都表达得了,门控也是契约的一部分。

6. Bash:契约里的「永远要批」

BashTool.tsx:82-85 的 needsPermissions() 不看输入、恒为 true——注释写明「Always check per-project permissions」。对照着看:文件工具按路径问 hasReadPermission / hasWritePermission,Grep 只在搜的目录没授过读权时才要批(GrepTool.tsx:59-61),而 Bash 每一条命令都必须过权限层。契约把「这个工具有多危险」表达成一个逐输入求值的函数,而不是工具级的开关。权限层本身(白名单、前缀记忆、弹窗)是第 5 关的内容,本关只需记住:工具自己申报危险性,循环照单转交。

7. 结果只有一个出口:tool_result

无论成功、Zod 拦截、业务拒绝、权限拒绝还是 call() 抛异常,循环最终都 yield 一条 type: 'tool_result' 的用户消息(query.ts:385-493 共五处构造点):成功的带 resultForAssistant 内容(449–455 行),失败的额外带 is_error: true。模型看到的永远是同一形状的回执,区别只在文案。循环不规划、不兜底、不崩溃——「出错了」也只是下一轮对话的输入。

动手练习

里程碑 m4-tools:给第 3 关的 ~80 行骨架装上 Read / Edit / Bash 三个工具,带 schema 校验。 本关预算约 60 行,mini-agent 总行数控制在 ~140 行。

在上关的 tool_use 循环基础上做四件事:

  1. 定义契约:每个工具是 { name, inputSchema, validateInput?, call } 的对象。三个 Zod schema:read_file { path: string }、edit_file { path, old_string, new_string }(都用 z.strictObject)、run_command { command: string }。

    Zod 是一个 TypeScript schema 校验库,原作用它把「模型生成参数不可靠」挡在工具调用之前。但本练习的硬约束是零 npm 依赖,没有真 Zod 可装——你需要手写一个 ~10 行的迷你 safeParse:检查必填字段在不在、类型对不对、有没有多出来的键,返回 { success, data | error } 形状即可(参考实现 agent.mjs 示范了一种写法,附录 B 的对读表有对应一行)。

  2. 循环先校验再调用:拿到 tool_use 后先 safeParse,失败就把 Zod 错误包成 { type: 'tool_result', is_error: true, content } 喂回模型,continue 循环;过了再走 validateInput,再过才 call。本关不做权限弹窗(那是第 5 关),Bash 先直接跑。

  3. 实现先读后改:维护 readFileTimestamps: Record<string, number>。read_file 成功后写入时间戳;edit_file 的 validateInput 依次拒:未读过的文件、mtimeMs > readTimestamp、old_string 找不到、old_string 出现多于一次;通过后用函数式 String.replace(old, () => new) 落盘并回填时间戳。

  4. 写一组冒烟脚本(不算入 200 行预算,可直接 node 跑):在临时目录里放一个测试文件,依次模拟模型的 tool_use 输入,打印每轮的 tool_result。

验收标准:

全部通过后打 tag m4-tools。

自检题

  1. 为什么 src/Tool.ts 缺失的情况下,我们仍然能描述 Tool 接口的形状?依据是什么,表述上必须加什么限定?
  2. checkPermissionsAndCallTool 里 Zod 校验和 validateInput 各拦什么?为什么两道都要把失败包成 is_error: true 的 tool_result,而不是抛异常?
  3. Edit 工具的「先读后改」是靠哪张表、哪两次比较实现的?写完文件后为什么还要回填一次时间戳?
  4. Grep 的 prompt 说「搜索文件内容」,实现却只回文件名。这个分叉对模型的使用方式产生了什么影响?(提示:Grep → View → Edit 的分工)
  5. ArchitectTool 的 isEnabled() 写死 false,与 CLI flag / 项目配置的关系是什么?这说明了「门控」在契约里的什么位置?

参考答案见附录 A。


能读、能改、能跑 shell——第 4 章结束时,你的 mini-agent 第一次有了真本事,也第一次有了真危险:模型吐出一句 rm -rf ~,谁来拦?第 5 章的回答不在模型里,而是一层「默认拒绝」的代码:每次 tool_use 落地前先过闸门,过不了就问人,人的答案记成下次不用再问的契约。这一关的验收只有一句话:白名单外的命令,必须人工确认才能执行。


第 5 章 · 权限与安全(第 5 关)

第 4 关结束时,你的 mini-agent 已经能读文件、改文件、跑 shell 了。问题立刻来了:模型吐出一句 rm -rf ~,谁来拦?

0.2.8 的答案不在模型里。系统提示里确实写着安全要求,但真正的闸门是 harness 里一层「默认拒绝」的代码:每一次 tool_use(模型在回复里发起的工具调用)真正执行前,都要先过 canUseTool 这一关。prompt 可以改变模型想做什么,不能扩大 harness 允许做什么。这套方案的实质是「社会层」而非「系统层」:没有容器和沙箱把破坏关住,靠的是每一次可能改变世界的动作都可能要问人,再把人的答案小心记成契约。本关把这一层拆开,然后给 mini-agent 装上同款闸门。

读什么

文件 行号 读它看什么
src/permissions.ts 18–27 SAFE_COMMANDS:恰八条整句的白名单
src/permissions.ts 154–222 hasPermissionsToUseTool:默认拒绝的决策主干
src/permissions.ts 224–266 savePermission 与 getPermissionKey:通行证怎么记
src/query.ts 365–441 checkPermissionsAndCallTool:校验 → 权限 → 执行的次序
src/hooks/useCanUseTool.ts 22–136 Promise 桥:把人的按键接回循环
src/utils/permissions/filesystem.ts 6–7, 33–60 读、写两个内存目录 Set
src/utils/config.ts 332–375 allowedTools 按项目绝对路径分桶存取
src/entrypoints/cli.tsx 187–213 --dangerously-skip-permissions 的三道门
src/constants/prompts.ts 20, 124–125 恶意软件禁令(模型层的产品默认)
src/tools/BashTool/BashTool.tsx 86–117 validateInput:不给 Yes 机会的硬拒绝

关键概念

弹窗是强制执行点,模型层只是产品默认

系统提示里有一段恶意软件禁令(prompts.ts:20):拒绝写或解释可能被恶意使用的代码,即使用户声称是出于教育用途——教育用途免责声明被明确关掉了。下一句更进一步:开工前先根据文件名和目录结构判断代码是干什么的,看起来像恶意软件,哪怕只是让你「解释一下」「加速一下」,也必须拒绝。同一句话在 prompts.ts:124–125 又原样抄了一遍,可见作者多怕模型忘。

但注意它的位置:这是写给模型看的文本,是产品默认行为,不是强制执行。模型可以被 jailbreak,提示词会被注入、会被忘掉。真正的强制执行发生在权限对话框——那是 TypeScript 代码,模型碰不到它。

弹窗之前还有一道更硬的、不给人 Yes 机会的拒绝:BashTool.validateInput(BashTool.tsx:86–117)。BANNED_COMMANDS(src/tools/BashTool/prompt.ts:10–28,curl、wget、nc、telnet、各浏览器等 17 条联网命令)和「cd 不得离开启动目录」在这里直接打回,错误收成一条 tool_result 回给模型。权限层管「这一下能不能跑」,这一层管「这类命令存在都不存在」。

SAFE_COMMANDS 恰八条整句

permissions.ts:18–27,一个 Set,八个精确字符串:

git status / git diff / git log / git branch / pwd / tree / date / which

是整句相等,不是前缀匹配。git status --porcelain 不过,which rm 也不过。为什么这么短?因为下面没有地板——0.2.8 没有容器、没有沙箱,cat、ls、echo 一旦带参数就可以读密钥、覆盖文件、拼管道。宁可再问一次人,也不把「看起来只读」写进自动放行。后来的版本把安全名单做大,是因为下面已经有了 OS 沙箱(材料来自官方文档,不在本仓库);把那套宽名单抄回 0.2.8 是错的。

canUseTool 的决策流

query.ts:365 的 checkPermissionsAndCallTool 定了次序:先 zod 校验输入结构(:377),再 validateInput 校验取值(:399),然后 :424–426 才轮到 canUseTool,全部通过才 tool.call(:441)。

canUseTool 决策树

图 3 canUseTool 的决策树(手绘版):三条直接放行路径、Bash 专用子流程、写文件类与其他工具两个分支;所有拒绝收敛到同一句文案,然后弹窗问人。数据来自 permissions.ts 第 154–222 行。

hasPermissionsToUseTool(permissions.ts:154–222)默认拒绝,放行只有几条极窄的路,按检查顺序:

  1. :161 dangerouslySkipPermissions 为真 → 全部放行(见下文三道门);
  2. :171 tool.needsPermissions(input) 为 false → 放行(注意:src/Tool.ts 在还原树中缺失,Tool 接口的形状是从 15 处 satisfies Tool 实现反推的,接口字段描述均属反推);
  3. :182 配置里写了 allowedTools: ["Bash"] → 放行全部 bash。这是 blanket 放行(整工具放行),注释明说不在 UI 暴露——知道这条手写配置的人,等于有第二套跳过开关;
  4. Bash 走 bashToolHasPermission:先 SAFE 八条整句,再查配置里的整句 key 和前缀 key;
  5. 其余工具:getPermissionKey 拼出的 key 命中 allowedTools 即放行。

全不过,返回 { result: false, message }。useCanUseTool(hooks/useCanUseTool.ts:22–136)把 false 变成弹窗::86 调 setToolUseConfirm 挂起 Promise,等 Ink 对话框里的人按键。人拒绝时 resolve 一条 REJECT_MESSAGE 并 abortController.abort()(:35–44)——同批还没开跑的 tool_use 一起停,不是只停当前这一下。被拒绝的命令不会执行,拒绝原因收成一条 is_error 的 tool_result 发回模型(query.ts:427–437),模型看到的只是「你还没得到授权」,不是 UI 里发生了什么。

还有一处失败关闭(fail closed:出错时默认关闸,而不是默认放行):Bash 的前缀不是正则抽的,是丢给一个更小的模型(Haiku)按 policy spec 分类的。分类失败、或报了 command_injection_detected 时(permissions.ts:88–107),只接受整句精确命中,宁可再问人。用一个小模型守一个大模型,仍然是社会层;但失败的方向是关。

通行证怎么记:key、分桶、内存授权

permission key(权限通行证在配置里的字符串形式)拼法在 permissions.ts:253–266,三种粒度并排:Bash(git commit:*) 是一类命令,Bash(git status) 是一句命令,其他工具直接用工具名(如 mcp__foo__bar)。

落盘位置是 ~/.claude.json(env.ts:11–13),按项目绝对路径分桶:getCurrentProjectConfig 用 resolve(getCwd()) 当 key 去 projects 表里查(config.ts:337, 345),saveCurrentProjectConfig 写回同一个桶(:370)。在 A 项目批准的 Bash(npm test:*) 不会漏到 B 项目;claude approved-tools remove 能从桶里删掉单条。

文件写授权是例外:savePermission 对 Edit/Write/NotebookEdit 短路(permissions.ts:231–238),不落盘,改调 grantWritePermissionForOriginalDir() 把整个启动目录写进内存里的目录 Set(filesystem.ts:6–7),会话结束作废。匹配用 startsWith 前缀包含(filesystem.ts:38, 55):授了 /proj 等于授了整棵子树。磁盘上永远不会出现一张永不过期的 FileWrite 通行证——这是刻意的:写权是 ephemeral(随会话生灭)的,bash 前缀才是可累积的契约。

读和写是不对称的。读权在启动时就被无条件授出:Trust Dialog 点完接受会调一次 grantReadPermissionForOriginalDir()(cli.tsx:153),setup() 里又无条件授一次(cli.tsx:184–185)。所以项目树内的 Read、Grep、Glob、LS 常常根本不弹窗——不是因为「只读所以安全」,而是因为读已经是授出的权利;出了启动目录,读一样要走对话框。写权则默认没有,必须等人在编辑工具的弹窗里点头,且如上段所说只活在这个会话里。

dangerously 三道门:旁路写得难误用,不藏起来

--dangerously-skip-permissions 不藏。它过了 cli.tsx:188–212 的三道门才生效:不是 root(:190–199)、必须在 Docker 容器里(:202–207)、必须无外网(:207–212)。任何一道不过,打印原因并 process.exit(1)——不降级成「那还是问人」,直接退出。

设计哲学值得抄:跳过权限是合法需求(无网容器里的 CI、内部狗食),所以提供旁路;但旁路必须难到不可能误开。三道环境检测把「能用这个 flag 的场景」收窄到「炸了也无所谓」的环境,名字里的 dangerously 是诚实的。过了门之后,Trust Dialog 不出现、所有 needsPermissions 失效、子代理都升格为可写可 bash——社会层被整层拆掉,只剩环境隔离,所以环境必须先足够小。

动手练习:m5-permissions,给 mini-agent 装闸门

在第 3 关的循环骨架和第 4 关的 Read/Edit/Bash 三工具之上,给 bash 工具加一层 canUseTool。预算 ~40 行,mini-agent 总量不许超 200 行。

要求:

  1. 文件顶部定义 SAFE_COMMANDS,照抄 0.2.8 那八条整句。
  2. 循环里调用 bash 工具之前,先过 canUseBash(command):整句命中白名单 → 直接执行;命中配置里存的前缀或整句 → 直接执行;否则用 readline 弹一行确认:允许执行 "npm test"? [y]仅本次 / [a]记住前缀 / [n]拒绝。
  3. y 执行这一次;a 把 命令首词:* 写进项目目录下的 mini-agent.json,此后同前缀命令静默通过;n 不执行,把「用户拒绝了这条命令」作为 tool_result 回给模型,循环继续而不是退出。
  4. 弹窗逻辑只许出现在代码里,不许写进系统提示——它是闸门,和模型想做什么无关。

验收标准(逐条可测):

提示:mini-agent 不调真模型,「让它发起一条指定命令」靠给假模型接一个 JSON 脚本文件——参考实现用 MINI_AGENT_SCRIPT=/path/steps.json 环境变量驱动(见 mini-agent/README.md);验收「重开进程不再弹窗」时用同一脚本复跑即可稳定复现。

一句话验收:白名单外的命令,必须人工确认才能执行。

自检题

  1. SAFE_COMMANDS 为什么只有八条、而且必须整句匹配?git status --porcelain 能过吗,为什么?
  2. hasPermissionsToUseTool 默认拒绝。从 permissions.ts:154–222 数出所有能让它返回 { result: true } 的路径,并说明各自的作用范围。
  3. 人在弹窗里按「拒绝」时,同一批还没开始执行的 tool_use 会怎样?这个行为是 useCanUseTool.ts 里哪几行代码保证的?
  4. --dangerously-skip-permissions 的环境检查失败时为什么 process.exit(1),而不是退回「继续问人」的模式?
  5. 在配置里手写 allowedTools: ["Bash"] 会发生什么?0.2.8 为什么不在 UI 里提供这个选项?

参考答案见附录 A。


至此骨架齐了:入口、首包、循环、三工具、权限闸门,两百行以内就位。第 6 章换个姿势——只读,不给 mini-agent 加一行代码。回头称一称 0.2.8 的体量:界面代码是循环代码的十几倍。这不是浪费,是「产品的厚度在交互面」的决策。这一关的产出是一张图:挑一个权限弹窗画出它的状态机,画到不看代码也能答出「按 Esc 之后循环发生了什么」为止。


第 6 章 · TUI 是产品的一半(第 6 关)

前五关读完,你已经有了 mini-agent 的骨架:CLI 入口、context 包、tool_use 循环、三个工具、命令白名单加确认弹窗。回头看 0.2.8 的体量分配,会发现一件反直觉的事:query.ts 那个循环约 480 行,而 src/components/ 合计 7961 行——界面代码是循环代码的十几倍。这不是浪费,是设计决策:产品的厚度在交互面,不在循环。

这一关是只读关,不给 mini-agent 加代码。任务是搞懂 0.2.8 怎么把异步生成器钉在一块可打断的终端屏幕上,顺便回答几个「为什么」:为什么人格里写死「回答少于 4 行」?为什么 190k token 就报警?为什么花到 5 美元要弹窗?

先交代底座:0.2.8 用 Ink,一个把 React 组件渲染到终端的库。你在 src/components/ 里看到的 <Box> <Text> 不是 HTML,是终端里的盒子和字符。会话状态全部放在 React state 里,query() 这个生成器(第 3 关讲过:一个 AsyncGenerator<Message>,每次 yield 吐出一条消息)只管 yield,屏幕上发生什么它一概不知道。

读什么

文件 行号 看什么
src/screens/REPL.tsx 541–644 渲染优先级:谁出现,谁就把输入框挤掉
src/screens/REPL.tsx 162–173 onCancel:权限弹窗挂起时走 onAbort()
src/screens/REPL.tsx 133–136、195–199、592–603 5 美元弹窗的触发与「只弹一次」
src/hooks/useCanUseTool.ts 35–44、86–122 权限 Promise 挂进 state;三个回调
src/hooks/useCancelRequest.ts 16–38 Esc 的分流逻辑
src/components/permissions/PermissionRequest.tsx 22–40、59–70、79–84 按工具分发弹窗;ToolUseConfirm 类型;Ctrl-C
src/components/permissions/BashPermissionRequest/BashPermissionRequest.tsx 38–120 一个具体弹窗的完整结构
src/components/permissions/toolUseOptions.ts 48–58 三个选项的文案与取值
src/components/TokenWarning.tsx 9–11、22–29 190_000、60% 黄、80% 红、/compact 引导
src/components/CostThresholdDialog.tsx 11–46 5 美元弹窗本体(全文件 46 行)
src/services/claude.ts 206–224、516 传输在流、屏幕整段出现的证据
src/constants/prompts.ts 44、121 「少于 4 行」规则,首尾各一遍

关键概念

6.1 为什么人格里写「少于 4 行」

第 2 关读系统提示时你见过这句话,现在从 TUI 的角度重新看它。src/constants/prompts.ts:44 写得很死:回答必须少于 4 行(不含工具调用和生成的代码),一词回答最好,禁止「The answer is...」这类开场白。同一规则在文件尾部的 prompts.ts:121 又抄了一遍,中间夹着极端的 few-shot 例子:问 2 + 2,答 4;问 is 11 a prime number,答 true。

为什么是 4 行?终端视口一行大约 80 到 120 个字符,一屏能放的行数有限。模型如果先自我总结一段——「我已经帮你查看了这个文件,发现问题出在第 42 行,接下来我将……」——真正的工具输出和 diff 就被挤出了屏幕。而在 ReAct 循环里,每一轮 assistant 文本还会作为下一轮的消息送回模型,客套话不止占屏幕,还污染下一轮推理。所以「少于 4 行」同时是在保护视口和保护循环。

注意这条约束的方向:是界面约束反向写进了模型人格。不是先有提示词后有 UI,是 80 列的终端决定了提示词该怎么写。你自己造 Agent 时如果换成交互富一点的环境(比如 Web 界面),这条规则就可以松——它是产品决策,不是宇宙真理。

6.2 TokenWarning:190_000、60% 黄、80% 红

src/components/TokenWarning.tsx 一共 31 行,值得整段读完。第 9 行:

const MAX_TOKENS = 190_000 // leave wiggle room for /compact

上限不写 200k 而写 190k,注释说得很坦白:给 /compact 留余量。/compact 在本仓库真实存在(src/commands/compact.ts),作用是把对话历史压缩成摘要再开新会话——压缩本身要再发一次请求,得留出这次请求的 token 空间,所以警戒线画在 190k 而不是顶到上限。

第 10–11 行两档阈值:WARNING_THRESHOLD = MAX_TOKENS * 0.6(114k,黄色),ERROR_THRESHOLD = MAX_TOKENS * 0.8(152k,红色)。用量低于 60% 时组件直接 return null(第 16–18 行),什么都不画。过了阈值就在输入框右下角显示一句 Context low (N% remaining) · Run /compact to compact & continue(第 26–27 行),红黄的判断在第 20 行。它挂在 PromptInput 页脚(src/components/PromptInput.tsx:448),每帧用 countTokens(messages) 现算。

设计要点:警告不是打断,是一行常驻的小字,还顺手把解决方案(/compact)写在同一行里。用户不看也不碍事,看了就知道下一步干什么。

6.3 CostThresholdDialog:5 美元,只弹一次

这个弹窗分两半看。本体在 src/components/CostThresholdDialog.tsx,46 行:一个圆角框,一行粗体 You've spent $5 on the Anthropic API this session.(第 28 行),一条指向费用文档的链接,下面一个只有 Got it, thanks! 一个选项的 Select(第 34–42 行)。它还自己接管了按键:Ctrl-C、Ctrl-D、Esc 都等同确认(第 13–17 行)——这个弹窗不需要你做决定,只需要你「知道了」。

触发逻辑在 REPL 那边。src/screens/REPL.tsx:195:每次 messages 变化后检查 totalCost >= 5 /* $5 */,满足就 setShowCostDialog(true)。「只弹一次」靠两层:点掉弹窗时 onDone 把 hasAcknowledgedCostThreshold: true 写进全局配置(REPL.tsx:592–603,字段定义在 src/utils/config.ts:113);下次启动时 haveShownCostDialog 从这个字段初始化(REPL.tsx:133–136),确认过的人这辈子不会再见到它。还有一个小细节:弹窗只在 !isLoading 时真正显示(REPL.tsx:539),循环还在跑就不抢屏——花钱提醒再重要,也不该插在工具执行中间。

6.4 传输在流,屏幕整段出现

0.2.8 里你看到的 assistant 回复是「整段突然出现」的,没有打字机效果。这不是网络不行,是代码就这么写的,而且证据就在同一个函数里。

src/services/claude.ts:516,HTTP 层用的是 anthropic.beta.messages.stream(...)——传输确实是流式的,token 一个个到达。但接着看 handleMessageStream(claude.ts:206–224):第 213–217 行的 for await (const part of stream) 把流整个消费了一遍,却只在 message_start 时记了一下首 token 时间(ttft),中间到达的文本块全部丢弃;第 219 行 await stream.finalMessage() 拿的是攒完的整条消息。流的上面还留着一行注释,claude.ts:212:

// TODO(ben): Consider showing an incremental progress indicator.

所以「传输在流、屏幕整段出现」不是 bug,是一个标了 TODO 的取舍:0.2.8 的流式是消息级,不是 token 级。等待的空白靠什么填?Spinner 和那一串幽默动词(Clauding、Reticulating……)。token 级流式渲染是后来版本才补上的(材料来自官方文档,不在本仓库)。

6.5 权限弹窗:组件结构与状态流转

第 5 关讲了权限的安全语义(白名单、默认拒绝),这里只看 UI 这一半:一个弹窗从挂起到消失,走了哪些组件、落在哪几个终态。

桥:工具执行前调 canUseTool,它是 useCanUseTool 这个 hook 造出来的函数。配置里已有权限就直接 resolve({ result: true }),UI 无感;否则走到 src/hooks/useCanUseTool.ts:86,把一个对象塞进 React state:

setToolUseConfirm({ assistantMessage, tool, description, input,
  commandPrefix, riskScore: null, onAbort() {...}, onAllow(type) {...}, onReject() {...} })

循环就此停在一个 Promise 上。这个对象的类型 ToolUseConfirm 定义在 src/components/permissions/PermissionRequest.tsx:59–70。提醒一句:里面引用的 Tool 类型来自 src/Tool.ts,该文件在本 sourcemap 中确认缺失,所以关于 Tool 接口形状的所有论述都是反推,下同。

抢屏:REPL 的渲染区是一段隐式优先级(REPL.tsx:556–611):toolJSX 最高,然后依次是 BinaryFeedback、PermissionRequest、CostThresholdDialog,全空才轮到 PromptInput。toolUseConfirm 非空时弹窗出现(REPL.tsx:577–586),输入框同时被卸下——你不能在 Agent 等批准时再塞一句话。

分发:PermissionRequest.tsx:22–40 的 permissionComponentForTool 按工具选弹窗:FileEdit 和 FileWrite 各有带 diff 预览的专属弹窗,Bash 用 BashPermissionRequest,Glob/Grep/LS/读文件一族共用 FilesystemPermissionRequest,其余一律落到 FallbackPermissionRequest。

一个具体弹窗:BashPermissionRequest.tsx(121 行)的结构是所有弹窗的模板——圆角边框(主题色 permission)、标题、工具自己渲染的命令预览、一句 Do you want to proceed?、一个 Select。选项由 toolUseOptions.ts:48–58 生成,最多三个:Yes、Yes, and don't ask again for ...(前缀或整条命令,危险复合命令会藏起这项)、No, and tell Claude what to do differently (esc)。

三终态:不管哪个弹窗,故事的结局只有三种——

另外一个产品细节:弹窗挂起 6 秒没人理,就弹一个桌面通知(PermissionRequest.tsx:89 的 useNotifyAfterTimeout)——人去倒水了,Agent 不能干等。

动手练习

这一关不改 mini-agent 的代码,产出是一张图:从 src/components/permissions/ 里挑一个权限弹窗组件,画出它的状态机。建议选 BashPermissionRequest.tsx(结构最典型);想多看 diff 预览可以选 FileEditPermissionRequest,想看兜底逻辑选 FallbackPermissionRequest。

要求用 mermaid stateDiagram-v2,画图前先通读三处接线:useCanUseTool.ts:86–122(Promise 怎么挂)、PermissionRequest.tsx:22–40、79–84(分发与 Ctrl-C)、REPL.tsx:162–173、577–586(Esc 路径与渲染时机)。

验收标准:

  1. 图里必须覆盖授权、拒绝、取消三个终态,且能看出三者殊途同归到哪一个 resolve 值;
  2. 每条迁移边标注触发来源(选 Yes / 选 No / Esc / Ctrl-C / 进入时已 abort);
  3. 标出对循环的副作用:resolve({ result: true }) 还是 resolve({ result: false, message: REJECT_MESSAGE }),以及是否伴随 abortController.abort();
  4. 起点必须是 setToolUseConfirm 挂起 Promise,终点必须是 Promise resolve(循环继续或停下)。

一句话版:图能让人不看代码就答出「按 Esc 之后循环发生了什么」就算过。

画完之后回头看你的 mini-agent:第 5 关的确认弹窗就是这张图的最小实现——三终态加一个 resolve。0.2.8 用 8k 行 Ink 做的事,骨架里那几行已经够用了,规模预算维持 ~200 行不动。

自检题

  1. 「少于 4 行」这条提示词规则,保护的到底是哪两样东西?为什么说它是界面约束反向塑造人格的例子?
  2. TokenWarning 的三档(不显示 / 黄 / 红)边界各是多少 token?上限为什么写 190_000 而不是顶到模型上限?
  3. 「传输在流、屏幕整段出现」——指出同时证明前半句和后半句的代码位置,并说明那行 TODO 注释承认了什么。
  4. CostThresholdDialog 为什么关掉一次就永远不再弹?说清楚涉及的两个 state、一个配置字段,以及「只在 !isLoading 时显示」这条时序约束。
  5. 在权限弹窗里按 Esc 和选 No,最终对 query() 循环的效果有何异同?(提示:对比 onAbort 与 onReject 的函数体。)

参考答案见附录 A


零件拆完了,最后一章做两件事:把零件装回去,看清「最小内核」小在哪;再抬头看后来的产品往内核外面堆了什么、为什么堆在外面。这一关要动用来源纪律——0.2.8 之后的材料全部来自官方文档,不在本仓库,读到时注意标注。然后是结业考:让 mini-agent 跑通「读文件 → 改 bug → 跑测试」,七条验收逐项打勾。全过,你手里这两百来行就和 0.2.8 的内核同构了。


第 7 章 · 从 0.2.8 到今天(第 7 关)

前六关把 0.2.8 拆成了零件:入口、context 包、循环、工具、权限、TUI。这一关不拆新零件,做两件事:把零件装回去,看清「最小内核」到底小在哪;再抬头看后来的产品往内核外面堆了什么——以及为什么堆在外面,而不是里面。

先交代两条纪律,它们正好就是本关的主题。

第一,本关写到 0.2.8 之后的东西——Skills、Hooks、操作系统沙箱、自动压缩上下文——材料全部来自官方文档,不在本仓库。本仓库是 0.2.8 的 sourcemap 还原树,211 个文件共 25979 行,里面没有这些特性的文件,连引用都没有。

第二,2026 年 3 月另有一次更出名的泄露,对应 npm 上的 2.1.88,约 1900 个文件。那份材料和本仓库这份 0.2.8 不是同一件事,引用时不要混。本关所有关于「今天的 Claude Code」的句子,都是对官方文档与公开泄露分析(例如 Siddhant Khare 读 2.x 源码的文章)的转述;本仓库能证明的,只有 0.2.8 的骨架长什么样。

读什么

本关几乎没有新代码要读——它是回头看的关。重读下面几处,带一个问题:这件东西如果在 2025 年 2 月不存在,产品还能不能给人用?

文件 位置 重读什么
src/query.ts 全文 516 行 一个文件塞进了多少责任:并发调度、校验、权限、错误截断、左右对照。这就是内核的上限
src/query.ts 111–123、124 wizard 口气的 thinking 三规则注释,和 query() 生成器签名
src/query.ts 169–177 第 170 行写 stop_reason === 'tool_use' 不可靠;没有 tool_use 就 return,循环结束
src/commands/compact.ts 30–45、64–83 0.2.8 唯一的压缩:人手动下的命令,独立调一次 querySonnet,清屏,把摘要塞回消息列表
src/permissions.ts 18–27 SAFE_COMMANDS 八条整句——下面没有地板时的诚实长度
src/tools.ts 23、41 getAllTools() 注册表和 memoize:MCP 从第一天就进同一张工具表
src/utils/state.ts 全文 25 行 第 4 行 Boris 的诅咒:内核不许长新状态
src/components/TokenWarning.tsx 9 190_000 和它的注释:余量是留给 /compact 的——压缩在 0.2.8 是人的动作

仓库外材料(材料来自官方文档,不在本仓库):现行 Claude Code 的 Skills、Hooks、沙箱、自动压缩相关文档;Siddhant Khare 的 2.x 源码分析。

关键概念

最小内核:刚能给人用

0.2.8 是产品刚能给人用的那一版。说「能用」,不只是因为循环能转——它已经有了一整套对付真实用户的东西:Trust Dialog 先问信不信这个目录,八条白名单管命令,TokenWarning 在 190k token 时提醒,CostThresholdDialog 在花到 5 美元时弹窗(CostThresholdDialog.tsx:28),自动更新和 claude doctor 管版本,CLAUDE.md 当记忆,/compact 让人手动给上下文续命。剥掉这层产品壳,剩下的内核小得惊人:一个 516 行的递归生成器,一张工具表,一个权限函数。

换句话说,0.2.8 的内核 = 模型每说一句话都要经过的最短路径。tool_use(模型回复里的工具调用块)抽出来,tool_result(工具执行结果包装成的用户消息)拼回去,权限在调用点问一次,人按 Esc 就让生成器 return。循环、工具契约、权限、给人看的界面,四件事焊在一起。模型可以换——后来的 anon-kode 真把这套 harness 换成过别的模型——但这四件换了,产品就不再是它自己。

当时没有的四件事

以下全部材料来自官方文档,不在本仓库:

注意这四件的共同点:没有一个需要改 0.2.8 的循环签名才能存在。这不是巧合,是下一节的框架。

什么进内核,什么做外层

给你三问,以后看到任何 Agent 新功能都可以套:

  1. 每个回合都经过它吗? 是,进内核。tool_use 的抽取、tool_result 的拼回、权限调用点、取消——模型每说一句话都要过这些,它们写在循环的签名和主干里。
  2. 不改循环签名能加上吗? 能,就是外层。/compact 在 0.2.8 就是这么活的:它不动 query.ts,独立调一次 querySonnet(compact.ts:34–45),换一句系统提示,再把摘要连同一条说明塞回消息列表(compact.ts:78–83)。后来的自动压缩(材料来自官方文档,不在本仓库)本质上是同一个动作,只是触发者从人变成了阈值。Skills 是 context 的新来源,Hooks 是消息流上的监听器,同样够不着循环签名。
  3. 它失效时谁兜底? 权限问人兜不住时,爆炸半径是整台机器——第 5 关见过,前缀记忆省弹窗的代价就是批准一个前缀后拼接命令不再问。需要操作系统兜底的东西是地板,地板在内核下面而不是里面:沙箱该包在 Bash 工具的执行之外,而不是长进 query.ts。反过来看,0.2.8 的白名单只能有八条,恰恰因为当时下面没有地板——名单短是诚实的,把后来的宽名单抄回 0.2.8 反而是错的。

三问合成一句口诀:换模型不换的是内核;能后装、能拆掉、失效时有别人兜底的,是外层。

0.2.8 自己也在防内核膨胀。query.ts 516 行已经塞了五件事,state.ts 用一句诅咒(第 4 行,全文 25 行)禁止新全局状态。据公开分析转述,等压缩、钩子都进去之后,同一个文件长成了另一个名字——内核的成本从来不是写出来,是守住边界。

一处诚实声明:「统一工具对象」的接口定义文件 src/Tool.ts 在还原树中缺失,其形状是从 15 处 satisfies Tool 与全部调用点反推的。本关所有关于工具契约的说法都带着这个限定。

对照表:从 0.2.8 内核到今天产品

层 0.2.8(本仓库可见) 今天(材料来自官方文档,不在本仓库)
循环 递归 query() 生成器,516 行,无递归上限 queryLoop:while 加多处 continue,多层自动压缩(公开分析转述)
工具 统一工具对象,16 个内置工具;MCP 第一天进同一张表 WebFetch、LSP、Todo、内容级 Grep 等,四十多个工具;企业托管 MCP、工具定义按需加载
权限 Trust Dialog、八条白名单、前缀记忆 deny/ask/allow 规则、bash AST 解析、操作系统沙箱、classifier
记忆与扩展 CLAUDE.md 祖先链 Skills、Hooks、PreToolUse、auto memory
上下文 手动 /compact,独立 querySonnet 摘要 到阈值自动压缩
界面 Ink REPL,消息只追加,重说就 fork token 打字机、虚拟滚动;桌面与 IDE 复用同一循环
仪器 Statsig、binary feedback、USER_TYPE=ant 构建期 DCE、client attestation、cache break 检测

读这张表的方式:左栏每一行你在前六关都亲手摸过;右栏每一行都能用三问归到「外层」。公开文档里的现行产品仍然跑工具调用循环、仍在运行时做权限、仍读 CLAUDE.md——但这只是产品文档层面的连续性,不能反证 2.x 的 query.ts 还是这份 516 行递归生成器。能下的结论是:0.2.8 押的骨架形状,今天看仍然成立。

动手练习

结业练习(里程碑 m7-final):让你的 mini-agent 跑通「读文件 → 改 bug → 跑测试」全流程。规模预算不变:mini-agent 本体总行数不超 ~200 行,最多放宽到 220。

准备 fixture(不算进 200 行预算):一个含 bug 的 calc.py(例如 def add(a, b): return a - b),配一个针对它的测试文件,初始状态测试必红。

执行任务:一条命令启动,例如 node mini-agent.mjs "修复 calc.py 让测试通过"——管道模式,第 1 关的双路输入在这里一并验收。

逐项验收清单(全部打勾才算结业):

  1. 规模:wc -l 统计 mini-agent 本体 ≤ 220 行。
  2. 先读后改:日志里对 calc.py 的 Read 发生在 Edit 之前;若模型试图直接 Edit 未读文件,工具拒绝并把原因写回 tool_result,循环继续。
  3. 唯一匹配:Edit 的 old_string 在文件中恰好匹配一处才落盘;匹配多处时错误消息回灌给模型,模型补上下文后重试。
  4. 权限在场:跑测试的命令触发一次人工确认(或被白名单精确命中);你故意拒绝一次,agent 收到拒绝消息并能说人话收尾,不抛栈。
  5. 失败即消息:第一次测试失败时,失败输出原样进了 tool_result,模型据此发起第二轮 Edit——日志里看得见这条因果链。
  6. 收敛:最终测试全绿;agent 以一条不含 tool_use 的 assistant 消息结束循环(对应 query.ts:176–177 的 return)。
  7. 打断:跑到一半按 Ctrl-C,打印写死的中断消息退出,不留下写了一半的坏文件。

七条全过,你就拥有了一个和 0.2.8 同构的内核:薄循环、三工具、权限在调用点、失败写成消息。剩下的——压缩、沙箱、Skills、Hooks——你现在已经知道它们该装在哪一层,以及为什么。

自检题

  1. 0.2.8 的 SAFE_COMMANDS 只有八条整句,后来的版本敢把只读名单放宽。用「地板」的概念解释:变的是名单本身,还是名单下面的东西?
  2. 用本关的三问判断「自动压缩上下文」该进内核还是外层,并指出它在 0.2.8 里的对应物是什么。
  3. 2026 年 3 月的 2.1.88 泄露和本仓库的 0.2.8 还原树有什么区别?为什么引用 Skills、沙箱时必须标注材料来源?
  4. 本仓库能不能证明 2.x 的 query.ts 仍然是 516 行递归生成器?能证明的和不能证明的各是什么?
  5. 结业清单第 5 条要求「失败输出进 tool_result」而不是抛异常。如果改成抛异常,循环要付出什么代价?

参考答案见附录 A。

附录 A · 自检题参考答案(35 题全收录)

对应七关正文末尾的全部 35 道自检题(每关 5 道)。答案结论与关卡正文一致;每题附「答案要点 + 源码依据」,源码依据中的行号均在撰写时对回过 0.2.8 还原树 src/ 的当前文件状态(仓库见书名页)。

行号口径。 本附录应用 P0 校验(本套装校验文件 verification.md)的四条勘误:state.ts 为 25 行(非 26);shims import 在 cli.tsx:9(第 6–8 行是 XXX: 注释);「stop_reason 不可靠」注释在 query.ts:170(:169 是 @see 链接行);wizard 注释块为 query.ts:111–123(/** 起于 :111)。

反推限定。 src/Tool.ts 在还原树中缺失,凡涉及 Tool 接口字段(isReadOnly / needsPermissions / validateInput / call 等)形状的论述均为反推——依据是全仓 15 处 satisfies Tool(本附录复核:15 个文件各一次)与全部调用点。同批缺失的还有 src/tools/MemoryReadTool/prompt.ts、src/tools/MemoryWriteTool/prompt.ts(两个记忆工具的文案,不作转述)与 src/utils/conversationRecovery.ts(被 cli.tsx:52、screens/ResumeConversation.tsx:4 引用)。凡受影响的题目,答案中就地标注。

题面勘误。 逐题比对后,35 道题的题面与关卡正文、源码三者一致,本附录 0 条题面勘误。一处口径说明:verification.md 按宽口径记「53 个文件 import Tool.js」;本附录复核时按「import 路径恰好以 /Tool.js 结尾」的严口径数得 43 个文件——宽口径多出的约 10 个命中的是 BashTool.js、MCPTool.js 这类工具实现文件的路径,并非缺失的 src/Tool.ts 本体。下文引用时用严口径数字。


第 1 关 · 进程怎么活起来的

1.1 main() 为什么把 enableConfigs() 放在解析 argv 之前?在 import 顶层读配置会有什么后果?

答案要点

源码依据

1.2 cli.tsx:294–295 的注释说 Input hijacking breaks MCP.——mcp serve 被 stdin 劫持具体会坏在哪?

答案要点

源码依据

1.3 cli.tsx:153 和 :185 授了两次读权。为什么 --print 路径离得开第一次却离不开第二次?为什么两次都必须早于 getContext() 预取?

答案要点

源码依据

1.4 --dangerously-skip-permissions 的三道门各自防的是什么误用场景?为什么 uid 0 那条排在最前、连 Docker 检查都不用等?

答案要点

源码依据

1.5 state.ts 里只有 originalCwd 一个值。真正的工作目录存在哪?这种「薄状态」设计和 cli.tsx 的 1043 行单文件矛盾吗?

答案要点

源码依据


第 2 关 · 第一包发给模型什么

2.1 getSystemPrompt() 返回的三段各是什么?恶意软件禁令出现两次,位置分别在哪,为什么这样安排?

答案要点

源码依据

2.2 0.2.8 为什么故意让目录快照整场会话不更新?这个决定和 addCacheBreakpoints 只标最后两条,共同服务的目标是什么?

答案要点

源码依据

2.3 countTokens 的数从哪来?如果会话里只有开头一条真实 assistant 响应、之后全是合成打断消息,TokenWarning 读到的数会停在哪?

答案要点

源码依据

2.4 getClaudeFiles 的 glob 找不到哪份 CLAUDE.md?那份文件实际经哪条路径进系统提示?

答案要点

源码依据

2.5 MAIN_QUERY_TEMPERATURE 为什么是 1?注释里的 binary feedback 指什么场景?

答案要点

源码依据


第 3 关 · Agent 就是一个循环

3.1 0.2.8 判断「这一轮结束了」为什么不看 API 返回的 stop_reason?它改看什么地方?

答案要点

源码依据

3.2 模型一轮回复里同时有 1 个 Bash(写)和 3 个 View(读),0.2.8 会怎样调度这批工具?规则写在 query.ts 哪几行?

答案要点

源码依据

3.3 用户在 REPL 里输入 !ls -la 和 /clear,这两次输入会进 query 吗?REPL 依据什么决定不再调用 query?

答案要点

源码依据

3.4 SDK 的 maxRetries 被设成了几?既然 SDK 自带重试,为什么还要手写 withRetry?手写版本尊重服务器的哪个响应头?

答案要点

源码依据

3.5 API 连续报错把重试耗尽之后,querySonnet 会把异常抛给 query 吗?循环实际「看见」的是什么?

答案要点

源码依据


第 4 关 · 工具契约与编辑引擎

4.1 为什么 src/Tool.ts 缺失的情况下,我们仍然能描述 Tool 接口的形状?依据是什么,表述上必须加什么限定?

答案要点

源码依据

4.2 checkPermissionsAndCallTool 里 Zod 校验和 validateInput 各拦什么?为什么两道都要把失败包成 is_error: true 的 tool_result,而不是抛异常?

答案要点

源码依据

4.3 Edit 工具的「先读后改」是靠哪张表、哪两次比较实现的?写完文件后为什么还要回填一次时间戳?

答案要点

源码依据

4.4 Grep 的 prompt 说「搜索文件内容」,实现却只回文件名。这个分叉对模型的使用方式产生了什么影响?(提示:Grep → View → Edit 的分工)

答案要点

源码依据

4.5 ArchitectTool 的 isEnabled() 写死 false,与 CLI flag / 项目配置的关系是什么?这说明了「门控」在契约里的什么位置?

答案要点

源码依据


第 5 关 · 权限与安全

5.1 SAFE_COMMANDS 为什么只有八条、而且必须整句匹配?git status --porcelain 能过吗,为什么?

答案要点

源码依据

5.2 hasPermissionsToUseTool 默认拒绝。从 permissions.ts:154–222 数出所有能让它返回 { result: true } 的路径,并说明各自的作用范围。

答案要点

按检查顺序,放行路径共五条(随后才是 Bash 与默认分支的细分):

  1. :161–163 dangerouslySkipPermissions 为真 → 全放行。作用范围:整个进程、所有工具(过了入口三道门的无人值守模式)。
  2. :171–173 tool.needsPermissions(input) 为 false → 放行。作用范围:这一次输入。读类工具在已授读权的目录内、文件写工具在已授写权的目录内,都从这里过(文件写授权在内存里,filesystem.ts:6–7、:38、:55 的 startsWith 前缀匹配)。注意 :174–177:这一步抛异常时失败关闭(返回 false),不是放行。
  3. :182–184 项目配置 allowedTools 含 "Bash" → Bash 整工具 blanket 放行。作用范围:该项目所有 bash 命令;:181 注释明说不在 UI 暴露。
  4. :189–193 Bash 细分(bashToolHasPermission):SAFE 八条整句(:34)→ 本句放行;配置里的整句 key Bash(git status)(:38、:42 的精确匹配)→ 本句放行;配置里的前缀 key Bash(git commit:*)(:58)→ 该前缀的一类命令放行。两个失败关闭分支:前缀分类查询失败(:88–95)直接拒;commandInjectionDetected 时(:97–107)只接受整句精确命中。
  5. :210–220 其余工具:getPermissionKey 拼出的 key 命中项目配置 allowedTools(:211–213)→ 放行。作用范围:该项目下该工具(key 即工具名,如 mcp__foo__bar,:265)。

全部不过,返回 { result: false, message } 交给弹窗。(needsPermissions 是 Tool 接口字段,接口文件缺失,此处为反推。)

源码依据

5.3 人在弹窗里按「拒绝」时,同一批还没开始执行的 tool_use 会怎样?这个行为是 useCanUseTool.ts 里哪几行代码保证的?

答案要点

源码依据

5.4 --dangerously-skip-permissions 的环境检查失败时为什么 process.exit(1),而不是退回「继续问人」的模式?

答案要点

源码依据

5.5 在配置里手写 allowedTools: ["Bash"] 会发生什么?0.2.8 为什么不在 UI 里提供这个选项?

答案要点

源码依据


第 6 关 · TUI 是产品的一半

6.1 「少于 4 行」这条提示词规则,保护的到底是哪两样东西?为什么说它是界面约束反向塑造人格的例子?

答案要点

源码依据

6.2 TokenWarning 的三档(不显示 / 黄 / 红)边界各是多少 token?上限为什么写 190_000 而不是顶到模型上限?

答案要点

源码依据

6.3 「传输在流、屏幕整段出现」——指出同时证明前半句和后半句的代码位置,并说明那行 TODO 注释承认了什么。

答案要点

源码依据

6.4 CostThresholdDialog 为什么关掉一次就永远不再弹?说清楚涉及的两个 state、一个配置字段,以及「只在 !isLoading 时显示」这条时序约束。

答案要点

源码依据

6.5 在权限弹窗里按 Esc 和选 No,最终对 query() 循环的效果有何异同?(提示:对比 onAbort 与 onReject 的函数体。)

答案要点

源码依据


第 7 关 · 从 0.2.8 到今天

7.1 0.2.8 的 SAFE_COMMANDS 只有八条整句,后来的版本敢把只读名单放宽。用「地板」的概念解释:变的是名单本身,还是名单下面的东西?

答案要点

源码依据

7.2 用本关的三问判断「自动压缩上下文」该进内核还是外层,并指出它在 0.2.8 里的对应物是什么。

答案要点

三问过一遍:

  1. 每个回合都经过它吗? 不经过。正常回合的 tool_use 抽取、tool_result 拼回、权限调用点都与压缩无关;压缩只在上下文逼近阈值时才动作。
  2. 不改循环签名能加上吗? 能。0.2.8 的手动 /compact 就是这么活的:不动 query.ts,独立调一次 querySonnet(compact.ts:34–45),换一句「你是摘要助手」的系统提示(:36),拿到摘要后清屏清消息(:75–77),把摘要连同一条说明塞回消息列表(:78–83),再清掉 context 缓存(:84–85)。自动压缩只是把触发者从人换成阈值,动作本身不变——循环签名原样。
  3. 它失效时谁兜底? 摘要做不好,代价是上下文质量下降,人可以再压一次或 /clear 重开——不需要操作系统级兜底,不是地板。

源码依据

7.3 2026 年 3 月的 2.1.88 泄露和本仓库的 0.2.8 还原树有什么区别?为什么引用 Skills、沙箱时必须标注材料来源?

答案要点

源码依据

7.4 本仓库能不能证明 2.x 的 query.ts 仍然是 516 行递归生成器?能证明的和不能证明的各是什么?

答案要点

源码依据

7.5 结业清单第 5 条要求「失败输出进 tool_result」而不是抛异常。如果改成抛异常,循环要付出什么代价?

答案要点

抛异常把「模型能纠正的错误」升格成「程序的控制流」,循环要付出四类代价:

  1. 循环长胖:每个调用点外围要长 try/catch 和异常分类逻辑——区分「模型犯的错(应喂回去)」「权限拒绝(应停下)」「真 bug(应崩)」。0.2.8 的循环只有 516 行,靠的就是「结果只有一个出口」:成功、Zod 拦截、业务拒绝、权限拒绝、call() 异常,五处全部收成同一形状的 tool_result(query.ts:385–493,成功的 :449–455 带内容,失败的带 is_error: true)。
  2. 模型失明:异常沿栈上抛,模型看不到失败原因,下一轮无从自我纠正。「失败文案本身就是给下一轮模型的指令」这个编辑引擎最重要的产品决策直接失效——模型收不到「Found N matches… Add more lines of context」,就不会补上下文重试。
  3. 并发与中止复杂化:并发分支(runToolsConcurrently)里一个工具抛异常,要么拖垮整批,要么要为每个工具包一层隔离——现在这层隔离天然存在,因为每个工具的结果本来就是一条消息。Esc 取消、弹窗拒绝也复用同一通道(REJECT_MESSAGE),异常路径会让「取消」和「失败」分叉成两套机制。
  4. API 错误也要另开一条路:0.2.8 连重试耗尽的 API 错误都收成 assistant 消息(claude.ts:543–558 → getAssistantMessageFromError :618–640),循环全程只处理一种数据。改成抛异常,这条「失败即消息」的统一性就破了——循环要同时懂消息流和异常流两种语言。

源码依据


附:本附录行号核对说明

附录 B · 参考实现与实测(mini-agent)

第 1–7 章的练习最终要搭出一个约 200 行的小 Agent。本附录收它的参考实现:纯 Node.js(>=18)ESM,零 npm 依赖,不调真实 LLM——假模型把多轮 tool_use 响应脚本化(第 3 章验收原话如此)。代码不进本书,完整文件随白皮书套装分发:在套装目录的 mini-agent/ 下,cd 进去直接 node 跑(六个里程碑文件与 fixtures 齐全,本附录的实测记录就是在那里产生的)。实测环境:macOS,node v24.15.0,python3 3.14.6(m7 fixture 是 Python 文件)。

结构说明:章节练习里 m3→m4→m5→m7 是在同一份骨架上逐关演化(课程用里程碑 tag 标记);参考实现把每个里程碑冻结成独立可跑的文件。diff agent.mjs mini-agent.mjs 即可看到第 5、7 章各加了什么肉。第 6 章是只读关,无代码里程碑。

文件清单与里程碑对照

文件 行数 里程碑(关卡) 说明
cli.mjs 29(非空行 25,预算 ≤30) m1-cli(第 1 关) 双路输入 CLI:argv / 管道拼接 / 裸跑交互
context.mjs 59(预算 ≤60) m2-context(第 2 关) 0.2.8 风格首包生成器:三段式系统提示 + 五键 context + cache 断点
loop.mjs 77(预算 ≤90) m3-loop(第 3 关) tool_use 递归生成器骨架 + 假模型 + 两个教学工具
agent.mjs 141(预算 ~140) m4-tools(第 4 关) 骨架 + Read/Edit/Bash 三工具 + 手写迷你 Zod 校验
smoke-m4.mjs 54(不计预算) m4 冒烟脚本 逐条模拟模型输入并断言第 4 关四条验收标准
mini-agent.mjs 210(预算 ≤220) m5-permissions(第 5 关)+ m7-final(第 7 关) 结业版:m4 全部 + canUseBash 权限闸门 + 结业流程
fixtures/calc.py 6 m7 fixture 含 bug 的计算器(add 写成了减法),初始测试必红
fixtures/test_calc.py 24 m7 fixture unittest,自带 sys.path 修正,与调用目录无关

怎么跑

cd mini-agent/

# m1:双路输入
echo "hi" | node cli.mjs          # [got] hi
node cli.mjs "hi"                 # [got] hi
node cli.mjs                      # 交互:回显 [prompt] ...,exit 退出

# m2:首包生成器(请在一个 git 项目根目录里跑,gitStatus 才非空)
cd /path/to/any-git-repo && node /path/to/mini-agent/context.mjs

# m3:循环骨架
node loop.mjs                     # 两轮工具调用后纯文本收工
LOOP_DEMO_BAD_TOOL=1 node loop.mjs  # 演示未知工具 → is_error tool_result,不崩

# m4:三工具 + schema 校验
node agent.mjs                    # 临时文件上演示 跑命令→读→改→看
node smoke-m4.mjs                 # 冒烟:5 passed, 0 failed 为全过

# m5/m7:结业版(在 mini-agent/ 目录下跑,fixture 是相对路径)
echo "修复 calc.py 让测试通过" | MINI_AGENT_ANSWERS=n,y,y node mini-agent.mjs
# 或 argv 形式:MINI_AGENT_ANSWERS=n,y,y node mini-agent.mjs "修复 calc.py 让测试通过"

三个环境变量(都是假模型/管道模式的替身,非原作概念):

注意:结业跑会真实改写 fixtures/calc.py(这正是练习目的)。重跑前先恢复初始 bug 状态:

printf 'def add(a, b):\n    return a - b\n\n\ndef sub(a, b):\n    return a - b\n' > fixtures/calc.py
rm -rf fixtures/__pycache__

实测记录

以下每条都在本机真实跑过(六个 .mjs 的 node --check 全部通过,从略)。m1–m5 只留关键输出,m7 结业跑保留全量日志。

m1-cli(25 非空行 ≤ 30 ✓)

$ echo "hi" | node cli.mjs
[got] hi                         # exit=0
$ node cli.mjs "hi"
[got] hi                         # exit=0
$ echo "a" | node cli.mjs "b"
[got] b
a                                # argv 在前、stdin 在后,换行拼接;exit=0
$ node cli.mjs                   # pty 实测:输入 hello world
[prompt] hello world             # 回显正常;输入 exit → 退出码 0

m2-context(59 行 ≤ 60 ✓,在 git 项目根目录运行)

输出里能数出三段系统提示(人格段含两组一词即答 few-shot;<env> 块含 cwd/isGit/date;政策句收尾)和五个 <context name> 块,gitStatus 非空(main + 未跟踪文件 + log -5)。memoize 生效有硬证据:

[memoize] 第一次 getContext 文件系统读取 6 次;第二次新增 0 次
[cache] [{"role":"user"},{"role":"assistant","cache":true},{"role":"user","cache":true}]

m3-loop(77 行 ≤ 90 ✓)

$ node loop.mjs                                        # exit=0
assistant| tool_use read_file({"path":"loop.mjs"})
user     | tool_result: #!/usr/bin/env node
assistant| tool_use list_dir({})
user     | tool_result: cli.mjs
assistant| 两轮工具调用完成,收工。
[done] fakeModel 被调用 3 次                            # 恰好 3 次,纯文本收尾
$ LOOP_DEMO_BAD_TOOL=1 node loop.mjs                   # exit=0
user     | tool_result [is_error]: Error: unknown tool "no_such_tool"
assistant| tool_use list_dir({})                        # 不崩,回 is_error 后继续

递归生成器形态:query 内无 while (true),下一轮是 yield* query([...messages, asst, ...results])。

m4-tools(agent.mjs 141 行 ≈ 140 预算 ✓)

$ node agent.mjs        # echo hello → 读 /tmp/m4-demo.txt → 1-1 改 1+1 → cat 验证,exit=0
$ node smoke-m4.mjs     # exit=0
PASS  循环不抛,模型收到 is_error 的 tool_result(Zod 迷你版拦下)      # 少传 new_string + 多传 bogus
PASS  被拒且回执含 "has not been read yet"                            # 未读先改
PASS  多处匹配被拒并提示补充上下文                                     # old_string 出现 2 次
PASS  唯一匹配落盘成功                                                # 先读后改
PASS  hello 作为 tool_result 回到对话                                  # run_command echo
5 passed, 0 failed

m5-permissions(结业版内 canUseBash,用 MINI_AGENT_SCRIPT 驱动实测)

m7-final(mini-agent.mjs 210 行 ≤ 220 ✓)

结业命令全量日志(fixture 恢复初始 bug 后):

$ echo "修复 calc.py 让测试通过" | MINI_AGENT_ANSWERS=n,y,y node mini-agent.mjs   # exit=0
assistant| tool_use edit_file({"path":"fixtures/calc.py","old_string":"return a - b",...})
user     | tool_result [is_error]: File has not been read yet. ...        # ② 未读先改被拒,循环继续
assistant| tool_use read_file({"path":"fixtures/calc.py"})
user     | tool_result: def add(a, b): ⏎     return a - b ⏎ ...           # Read 在成功的 Edit 之前
assistant| tool_use edit_file({... "old_string":"return a - b" ...})
user     | tool_result [is_error]: Found 2 matches of the string ...      # ③ 多处匹配被拒
assistant| tool_use edit_file({... "def add(a, b):\n    return a - b" → "a * b"})
user     | tool_result: File updated.                                     # 补上下文后唯一匹配落盘(但改错方向)
assistant| tool_use run_command({"command":"python3 -B fixtures/test_calc.py"})
[confirm] "python3 -B fixtures/test_calc.py" -> n(预置答案)              # ④ 权限在场:故意拒绝一次
user     | tool_result [is_error]: 用户拒绝了这条命令,没有执行:...
assistant| tool_use run_command({"command":"python3 -B fixtures/test_calc.py"})
[confirm] "python3 -B fixtures/test_calc.py" -> y(预置答案)
user     | tool_result: Exit code 1 ⏎ F. ⏎ === ⏎ FAIL: test_add (...)      # ⑤ 失败输出原样进 tool_result
assistant| tool_use edit_file({... "a * b" → "a + b"})                     # 模型据失败输出发起第二轮 Edit
user     | tool_result: File updated.
assistant| tool_use run_command({"command":"python3 -B fixtures/test_calc.py"})
[confirm] "python3 -B fixtures/test_calc.py" -> y(预置答案)
user     | tool_result: .. ⏎ --- ⏎ Ran 2 tests in 0.000s                   # 全绿
assistant| 已修复 calc.py:add(a, b) 从 return a - b 改为 return a + b,测试全部通过。  # ⑥ 无 tool_use 收尾

逐项对照第 7 章结业清单:

  1. 规模 ✓ wc -l mini-agent.mjs → 210 ≤ 220。
  2. 先读后改 ✓ 日志第 1–3 行:直接 Edit 未读文件被拒(原因写回 tool_result),Read 之后 Edit 才落盘。
  3. 唯一匹配 ✓ return a - b 两处匹配被拒并提示补上下文;带函数签名的 old_string 唯一匹配才落盘。
  4. 权限在场 ✓ 测试命令触发确认;预置 n 拒绝一次,agent 收到拒绝消息继续。全拒变体 MINI_AGENT_ANSWERS=n,n,n 实测:三次拒绝后以「测试命令被连续拒绝,我停止尝试。修改已落盘,请人工运行 python3 -B fixtures/test_calc.py 核对。」说人话收尾,exit=0,无栈。
  5. 失败即消息 ✓ 第一次测试的 Exit code 1 / FAIL: test_add 原样进了 tool_result,下一轮就是针对性 Edit——日志里因果链可见。
  6. 收敛 ✓ 跑后独立复跑 python3 -B fixtures/test_calc.py 输出 OK;收尾消息不含 tool_use(对应 query.ts:176–177 的 return)。
  7. 打断 ✓ pty 实测:确认弹窗处按 Ctrl-C → 打印写死的 [Request interrupted by user],退出码 130;此刻 calc.py 处于 a * b 中间态但语法完整(python3 -m py_compile 通过)——写入是单次 writeFileSync,不存在写了一半的文件。

与 0.2.8 原作的对应关系(对读对应表)

mini-agent 原作(src/) 对应点
cli.mjs 双路输入 entrypoints/cli.tsx:294–305、:390 !stdin.isTTY 吸 stdin 当 prompt;argv + stdin 过滤空值换行拼接
cli.mjs / mini-agent.mjs 交互循环 cli.tsx → render(<REPL/>)(:414) 两路皆空才进 readline
context.mjs getSystemPrompt() constants/prompts.ts 三段式:人格段 / <env> 块 / 政策句;few-shot 一词即答
context.mjs getContext() context.ts 五键快照、手写 memoize(原作 lodash-es memoize)、抓不到就空串
context.mjs addCacheBreakpoints() context.ts / cache 断点策略 只给最后两条消息打 cache: true
loop.mjs / 两处 query() query.ts:176–177、:234–241 无 tool_use 即 return 收尾;yield* query(...) 递归
未知工具/异常 → is_error tool_result query.ts:297–313、:385–493 五种失败共用一个出口:包成消息喂回模型,循环不崩
工具契约 {name, inputSchema, validateInput?, call} src/Tool.ts(还原树缺失,形状从 41 个调用点反推)+ tools/* schema 校验 → validateInput → call 的调用顺序对回 checkPermissionsAndCallTool 前两段
迷你 Zod(z.strictObject(...).safeParse) 原作依赖 Zod(z.strictObject + safeParse) 零依赖约束下手写 ~10 行迷你版,返回形状 `{success, data
edit_file 先读后改 tools/FileEditTool/FileEditTool.tsx:117、:170–216;同款文案见 FileWriteTool.tsx:178 readFileTimestamps 表;未读拒改、读后变更拒改、找不到拒改、多处匹配拒改(文案照抄原作风味)
edit_file 函数式 replace(old, () => new) FileEditTool 的 applyEdit 防 new_string 里 $ 特殊串被当替换模式
run_command 失败即消息 tools/BashTool/BashTool.tsx(needsPermissions 恒 true)+ query.ts tool_result 构造点 非零退出不抛出,Exit code N + 输出 原样回灌
canUseBash / SAFE_COMMANDS permissions.ts:18–27、:29–46 八条整句白名单照抄;整句或 首词:* 前缀命中配置即放行
mini-agent.json(选 a 写入) utils/config.js 项目级 allowedTools 前缀记忆持久化到项目目录,跨进程生效
确认弹窗只在代码里 hooks/useCanUseTool.ts 闸门不进系统提示;拒绝也是 tool_result,循环继续
INTERRUPT_MESSAGE utils/messages.tsx:36 写死的 [Request interrupted by user],Ctrl-C 打印后退出

刻意不做的(教学简化声明)


附录 C · 术语速查