从零构建 Coding Agent
English

13. 一份内核,多种运行壳

Agent 内核完成后,你会想加更多入口:交互式 CLI、一次性 print 模式、JSON 事件模式、SDK、RPC、TUI。不要为每个入口写一套 Agent。所有运行壳都应该订阅同一个内核事件流,只在输入输出协议上不同。

运行壳的差异

交互式 CLI 关心编辑器、快捷键、历史记录、状态行。Print 模式关心“给我一个最终答案,并用退出码表示成功失败”。JSON 模式关心 stdout 纪律:每行都是机器可解析事件,不能混入人类日志。SDK 关心可组合 API。RPC 关心请求 id、连接生命周期和并发会话。

这些都是产品层差异,不应该进入 Agent loop。内核只发事件、接收用户输入、维护状态。

Stdout 纪律

机器可读模式最容易被破坏。只要你在 stdout 打一行调试日志,下游解析器就会失败。建议规则:

  • JSON 模式下 stdout 只输出 JSONL 事件。
  • 人类日志、调试信息和警告走 stderr。
  • 每条事件都有 typetimestamp 和可选 id
  • 最终结果有明确 doneerror 事件。

这条纪律会影响整个代码结构。不要让底层工具随意 console.log;工具输出应该通过事件返回,由运行壳决定如何显示。

SDK 不是内部对象泄露

SDK 不应该把内部 state 原样暴露给用户。它应该提供稳定方法:

type AgentSession = {
  prompt(text: string): Promise<void>;
  steer(text: string): Promise<void>;
  followUp(text: string): Promise<void>;
  abort(): void;
  subscribe(listener: (event: AgentEvent) => void): () => void;
};

如果 SDK 用户能直接修改 messages 数组,你就无法维护日志、压缩和事件一致性。需要高级能力时,也应通过明确 API 暴露,比如 setModelsetActiveToolscompact

RPC 的基本形状

RPC 可以用 JSONL over stdio、WebSocket 或 HTTP SSE。关键是请求和事件关联:

{"id":"1","method":"prompt","params":{"text":"修复 lint"}}
{"id":"1","event":{"type":"turn_start"}}
{"id":"1","event":{"type":"tool_execution_started","name":"bash"}}
{"id":"1","result":{"status":"completed"}}

RPC 层不应该重新解释模型消息。它只是把内核事件序列化给远端,并把远端命令转成内核方法。

TUI 是事件流的投影

终端 UI 看起来复杂,但本质仍是事件投影。assistant text delta 更新文本块,tool events 更新工具行,queue events 更新状态栏,session events 更新历史树。TUI 不应该决定 Agent 下一步要不要调用工具,它只显示内核事实并收集用户输入。

这也是为什么前面要坚持统一事件流。如果 TUI 需要私有状态才能工作,JSON 模式和 SDK 很快会落后。

练习

实现两个运行壳:

  • tiny-agent -p "task":print 模式,只输出最终回答。
  • tiny-agent --json -p "task":JSONL 模式,输出事件。

验收标准:

  • 两个模式使用同一个 Agent 内核。
  • JSON 模式 stdout 没有非 JSON 内容。
  • 工具输出通过事件传递,不由工具直接打印。
  • print 模式遇到工具错误时有非零退出码或明确错误事件。
  • SDK 订阅到的事件和 JSON 模式事件语义一致。