Skip to content

Part III · 实现篇 / Ch 06

实现篇第6章 开一扇可靠的闸——CLI ​

小A把空白提问卡送入闸口,老Z守着控制杆,发光回答沿三条通道呈现完成、轮次耗尽和中止的不同结果。

本章导读: 你会写 src/cli.ts 的 runCli、runInteractive 和纯函数 exitCodeForStopReason。这一步的关键是分离契约:可测试的 CLI 函数靠注入 ask/write 摆脱终端,退出码 0/2/130 各有含义,而 main() 才是把异常变成 stderr 的边界。

会话和循环都有了。小A兴冲冲地想把 main.ts 当成所有入口,不管什么命令,都从这里进。老Z拦住他:“先别急着合并。可测试的 CLI 函数和生产 argv 入口承担的是不同契约,揉成一个,测试没法写,退出码也没法保证。”

“那到底要几个入口?”

“三个,各管各的。本章只实现一次问答,不实现 REPL、流式界面或诊断框架。”

三个入口,三个责任 ​

符号输入输出/退出语义不负责什么
main.tsprocess.argv、环境变量只打印最后一条 assistant content,并把 stop reason 映射为进程退出码signal、session、REPL
runCli()注入 client、工具、ask、write输出每条非空 assistant content,并返回 Promise<number>创建真实 Provider
runInteractive()标准输入输出调一次 runCli() 后关闭 readline连续 REPL

argv 的生产路径 ​

“先看 argv 进来以后发生什么。”老Z打开 main.ts:

ts
user: process.argv.slice(2).join(" ") || "Describe the workspace.",
maxTurns: 8,
beforeToolCall: denyBashByDefault,

“这里把 argv 余项拼成一条用户消息,并装配真实 AnthropicModelClient、工作区工具和默认 bash 拒绝策略,调用一次 runAgent,再筛选最后一条 assistant 文本打印。argv 为空时不进对话循环,用的是默认问题。”

小A:“那 API key 怎么处理?测试的时候可不能读真实 key。”

“这就是凭据边界。”老Z敲出同文件的另一段:

ts
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) throw new Error("ANTHROPIC_API_KEY is required for production use; npm test uses no API key.");

“正常路径要求同时提供 key 与模型名,缺任一项都会在调用网络之前失败。main() 捕获启动或运行异常后,以稳定的 mini-agent: failed to start or run: stderr 前缀输出受控诊断,并设置退出码 1;格式化时会替换已配置的 API key,key 的值不能写入 stderr。异常消息也不会被静默吞掉。”

“为什么要把 key 从错误消息里 redact?异常又不是模型吐的。”

“防的就是我们自己的错误日志。”老Z说,“SDK 在认证失败时会怎么报错?它可能把请求头打进去,而请求头里就有 x-api-key: sk-ant-...。main() 要是把原始 error.message 直接写进 stderr,key 就泄漏到日志里了。所以 formatMainError 做了一件事:detail.split(apiKey).join("[REDACTED]"),把 key 字面量替换掉。测试里专门构造了一个 new Error(\provider rejected ${apiKey}`),断言输出里能匹配到 [REDACTED]` 且匹配不到原始 key。”

“那为什么不在请求阶段就脱敏,要等到 catch?”小A追问。

“因为 key 是用来发请求的,发的时候不能改。”老Z说,“脱敏只能发生在要把信息写出去的那一刻,也就是 stderr。在内存里 key 必须是原值,否则 SDK 没法用它认证。”

小A:“那 formatMainError 为什么是导出的纯函数,而不是嵌在 catch 里?”

“为了能直接测。”老Z说,“cli-exit.test.ts 里就是直接调 formatMainError(new Error(...), apiKey)。这要求它不碰 process.env、不写终端,输入输出都是值。要是嵌进 main 的 catch,测试就得真的启动进程,还要伪造 ANTHROPIC_API_KEY,单测就背上了集成测试的负担。拆完之后,main 里只剩读 env、调 runAgent、打印、设退出码四件事,其余都是纯函数。”

“退出码呢?”小A接着问,“总不能全都返回 0 吧?”

“在打印最后一条 assistant content 后,main() 和 runCli() 都调用同一个纯映射:complete 为 0,max_turns 为 2,aborted 为 130。所以 npm start 遇到轮次耗尽不会再以 0 结束。main 目前没有装配 signal;aborted 在这里只表示映射完整性,不代表已经支持 Ctrl-C。”

四个退出码的完整语义摊开看:

退出码stop reason含义脚本怎么判断
0complete循环完整结束正常收尾
2max_turns轮次耗尽,没跑完结果可能不完整
130aborted调用方主动中止$? -eq 130 就是被中断
1(main 捕获异常)启动或运行失败要看 stderr 诊断

“注意 1 不在 exitCodeForStopReason 里。”老Z说,“1 是 main() 的 catch 分支设的(见 main.ts 的 process.exitCode = 1),对应的是异常,不是 stop reason。四种结局里,三种正常退出的 stop reason 由纯函数映射,剩下一种异常由 main 兜底。”

main 的生产路径画成一张图:

process.argv.slice(2).join(" ") 或默认问题
        │
        ▼
main(): 校验 ANTHROPIC_API_KEY / ANTHROPIC_MODEL
        │ 缺任一 → throw → formatMainError → stderr + exit 1
        ▼
runAgent({ client, tools, user, maxTurns: 8, beforeToolCall })
        │
        ▼
筛选最后一条 assistant content ── 打印(空则 ?? "")
        │
        ▼
exitCodeForStopReason(stopReason) → process.exitCode

可测试的一次问答 ​

“runCli() 这层,总不能在测试里起个真终端吧?”

“靠注入。”老Z切到 cli.ts,先指签名:

ts
// examples/mini-agent/src/cli.ts,节选
export async function runCli(options: {
  client: ModelClient;
  tools: Tool[];
  ask: (prompt: string) => Promise<string>;
  write: (text: string) => void;
  signal?: AbortSignal;
  beforeToolCall?: BeforeToolCall;
}): Promise<number>

“注意 ask 返回 Promise<string>,它可以是终端、可以是 fake、可以是任何输入源;write 是同步的 void,只负责把文本吐出去。测试就靠注入这两个,完全不碰真实终端。”

“为什么 write 是同步的?模型输出明明是异步的。”

“因为 write 接的不是模型流,是 runAgent 跑完之后的整条消息。”老Z指了指后面那段循环,“runCli 先 await runAgent(...) 拿到完整的 result,再遍历 result.messages,把每条 assistant content 写出去。流式过程在 loop 内部消化掉了,到 write 这一层已经是一个字符串,它只是个副作用接口,不需要 async。这样测试就能拿一个数组当 write,把所有输出收进来断言。”

“那 ask 呢?也同步就行了吧?”

“不行,ask 背后可能是终端,终端 I/O 是异步的。”老Z说,“runInteractive 里 ask 接的是 rl.question(p),readline/promises 的 question 返回 Promise<string>。测试里它可以接 async () => 'hello',甚至接一个同步函数的 Promise 包装。一个接口是 async 还是 sync,看真实实现里最重的那个:ask 最重的实现是终端,所以是异步的;write 最重的实现是 stdout.write,同步就够。”

ts
const input = await options.ask("> ");
if (!input.trim()) return 0;
const result = await runAgent({ client: options.client, tools: options.tools, user: input,
  maxTurns: 8, signal: options.signal, beforeToolCall: options.beforeToolCall });

小A盯着第一行问:“空输入返回 0,这不算撒谎吗?明明什么都没干。”

“因为这是‘用户主动放弃’,不是‘系统出错’。”老Z说,“空回车不在三种 stop reason 里,也没有异常发生,就是没给输入。返回 0 是告诉脚本‘这次没有要处理的’,不用报警。测试里专门传 ask: async () => " " 验证这一条,还断言 client.calls === 0:返回发生在任何模型调用之前。”

“signal 被传入 loop。取消在 runAgent() 的 checkpoint 生效;客户端或工具若不响应 signal,调用方只能等待它们交还控制权。”

小A:“那 runCli 的系统提示词是什么?它自己定的?”

“对,它内置了一句:Careful coding assistant. Never include credentials in messages.”老Z说,“这句强调凭据不写进消息,呼应安全章的最小暴露原则,也是 CLI 层唯一自己写死的规则。每一层都可以有自己的 system 补充,但下笔前先想清楚为什么要这一句。”

小A抢着推理:“它输出的应该是 result.messages 里所有消息吧?”

“只挑 assistant 的非空 content。看这行:for (const message of result.messages) if (message.role === "assistant" && message.content) options.write(message.content)。而 main 只打印最后一条。”

两个入口的契约差异并排放在一起:

维度runCli()main()
输入ask(可注入)process.argv
输出每条非空 assistant content最后一条,空则 ?? ""
退出码返回 number设 process.exitCode
异常原样 reject格式化到 stderr + exit 1
凭据不碰校验并 redact
典型用途多轮展示、测试脚本取最终结果

“注意最后一行:runCli 不校验 API key,因为 client 是注入的,它不创建。main 才管凭据,因为它要 new AnthropicModelClient。谁创建资源,谁负责校验。测试能跑通也是这个原因:注入 fake client 之后,runCli 从头到尾不知道有 API key 这回事。”

“退出码映射在哪?”小A问。

“同一个纯函数,测试直接断言它:”

ts
export function exitCodeForStopReason(stopReason: RunResult["stopReason"]) {
  switch (stopReason) {
    case "complete": return 0;
    case "max_turns": return 2;
    case "aborted": return 130;
  }
}

“runCli() 仍把模型/工具异常以 reject 交给调用者;生产 main() 才是把这类异常格式化进 stderr、设退出码 1 的边界。”

“为什么是 130,不是别的数字?”

“Unix 的约定。”老Z说,“进程被信号杀掉,退出码通常是 128 加信号编号,SIGINT 是 2,所以 128+2=130。这里的 aborted 对应‘调用方主动中止’,取 130,外层脚本就能沿用同一套习惯:$? -eq 130 就是‘被中断了’。但你要记住,main 现在没装配 signal,130 在 npm start 路径里实际不会出现,只在 runCli 接到已 abort 的 signal 时才返回。exitCodeForStopReason 把三种 stop reason 完整覆盖,这不等于三种路径都能在生产入口触发。”

为什么 runInteractive 不是 REPL ​

小A看到第三个函数的名字,问:“这个 runInteractive 是不是就是交互式命令行?”

“你看它的实现。”老Z翻到 cli.ts:

ts
return await runCli({ ...options,
  ask: (p) => rl.question(p),
  write: (text) => stdout.write(`${text}\n`),
});

“finally 里的 rl.close() 保证无论成功、异常还是中止都会关闭句柄。但注意,它只调用一次 runCli。”

“那它为什么叫 interactive?”

“名字说的是输入来自终端,不是说它维护多轮会话。”

小A不甘心:“那我加个 while 循环包住 runCli,不就是 REPL 了吗?”

“表面上是。但 REPL 要回答四个问题,现在一个都没有答案。”老Z说,“第一,会话怎么保持:runInteractive 每次都新开一个 runAgent,前一轮的消息不会自动带进下一轮。第二,屏幕怎么刷新:现在是 stdout.write 一次性吐整条消息,没有流式刷新。第三,取消怎么传递:runInteractive 会透传调用方提供的 signal,但没有把 Ctrl-C 的 SIGINT 接到 AbortController,终端取消不会自动传进 loop。第四,历史和命令补全:这里没有配置 completer,也没有自定义持久历史策略。终端层可能保留自己的输入历史,但不能把它当成 mini-agent 的功能承诺。加一个 while 循环,只是把这几个‘没有’包进循环里。”

理想 REPL 和现状之间的差距,用一张表量化:

REPL 该有的现状缺口在哪
多轮会话保持每轮新 runAgent,消息不延续要 sessionPath 或循环内传 messages
流式输出stdout.write 一次性吐整条要接入 client.stream 而非 runAgent
Ctrl-C 取消可透传 signal,未连接 SIGINT要把 SIGINT 接到 AbortController
历史、补全readline 默认行为要配 prompt 策略,未动

“每一格都是独立的工作项,加个 while 覆盖不了。”老Z说,“第三格要注意:runInteractive 通过 ...options 把调用方提供的 signal 传给 runCli,后者再传给 runAgent,取消通道已经存在。缺的是终端入口的装配:创建 AbortController,监听 SIGINT 并调用 abort(),把它的 signal 交给这条通道。”

正常与失败路径 ​

“那从头捋一遍:正常怎么走,失败又怎么走?”

“正常路径有两条。”老Z说,“main 是 argv 输入 → runAgent → 打印最后一条 assistant content → 设置 0、2 或 130;runCli() 是问答输入 → runAgent → 依次输出每条非空 assistant content,返回同一个映射。失败路径收在下面这张表里。”

“approval 的时候取消呢?”

“approval await 内发生取消时,循环不会启动工具,会补齐取消结果并返回 aborted,runCli() 因而返回 130,这个竞态已有独立的 fake 回归测试。”老Z顿了顿,补了一句,“还有一种容易看漏:文本输出可能为空,main 用 ?? "" 打印,别把空输出当成回答成功的证明。”

“那 session 呢?这个入口会自动续接吗?”

“不会。main.ts 和 runCli() 都没有传入 sessionPath,当前一次性入口既不自动恢复,也不写入 JSONL;会话 round-trip 只在直接调用 runAgent({ sessionPath }) 时成立。把会话能力写进核心,和把它装配到产品入口,是两项不同的工作。”

这一章所有路径的走向和退出码,验收时逐条对:

场景走向退出码
空输入任何模型调用前返回0
正常回答complete0
轮次耗尽max_turns2
signal 在 checkpoint 触发aborted(补齐取消结果)130
缺 API key 或模型名formatMainError 到 stderr1
模型/工具异常runCli reject → main 格式化1

“表里每个场景的退出码,都能在代码里找到对应的映射点。”老Z说,“空输入在 runCli 开头,max_turns 在 exitCodeForStopReason,异常在 main 的 catch。”

signal 在 loop 里的传播路径,画成时序:

runCli({ signal }) ──► runAgent({ signal })
                            │
   checkpoint 1(turn 前)   ←── signal.aborted? ──► return { aborted }
                            │
   checkpoint 2(call 前)   ←── signal.aborted? ──► recordAbortedCalls
                            │                        return { aborted }
   checkpoint 3(call 后)   ←── signal.aborted? ──► recordAbortedCalls
                            │                        return { aborted }
   client.stream({ signal }) ←─ SDK 自己观察 signal
                            ▼
  runCli 收到 stopReason "aborted" → 返回 130

“注意 runAgent 在 aborted() 为真时立即返回,不进入下一轮,也不开新流(见 agent.ts)。”老Z说,“测试 returns aborted before opening a model stream(见 agent-branches.test.ts)断言的就是这一点:signal 已中止时 client.calls === 0,模型一次都没被调到,和空输入走的是同一条早退路。”

小结 ​

CLI 入口这一步的价值,是把依赖装配和退出语义显式暴露出来,而不是藏在全局状态里。runCli 通过注入 ask、write、signal 和 beforeToolCall 实现无终端测试,空输入在任何模型调用之前就以 0 返回。退出码的映射是一个纯函数:0 表示循环完整结束,2 表示轮次耗尽,130 表示外部中止,三个数字都遵循 Unix 进程退出语义的约定,脚本可以据此判断发生了什么。模型或工具抛出的异常以 reject 交给调用者,只有 main() 负责把它们格式化为 stderr 并设置退出码 1。

三个入口的分工是这一章的骨架:可测试的 CLI 函数靠注入摆脱终端,生产 argv 入口由 main 校验凭据、格式化错误,runInteractive 只做一次 readline 包装。校验的责任跟着资源创建走,runCli 只消费注入的 client 和工具,因此不用碰凭据。同一个 exitCodeForStopReason 被两个入口共享,脚本路径和问答路径的退出语义保持一致;formatMainError 是导出的纯函数,测试直接断言 [REDACTED],全程不碰 process.env。

退出码上还分着两份契约:映射完整是一份,入口装配是另一份,别把前者说成一句“支持 Ctrl-C”。

当前入口只适合脚本式的一次问答。持续交互需要全新的契约:会话保持、屏幕刷新、取消传递,加上历史和补全,runInteractive 的四个缺口每一项都是独立工作项。

阶段验收 ​

sh
cd examples/mini-agent
npm test
npm run typecheck

离线测试为 runCli 注入 fake client,并直接断言共享纯映射的 complete/max turns/aborted 三种退出码及 API key 脱敏错误格式;它们不启动真实子进程,也不调用 Provider。不要把 npm start 加入无凭据测试。