昨天我读完了 Pi 的多 Provider API、context handoff 和 structured tool results。今天继续阅读 What I learned building an opinionated and minimal coding agent 的后半部分,并对照 earendil-works/pi 当前源码确认实现。
读完之后,我最关心的已经不是“Pi 为什么没有某个流行功能”,而是另一个问题:一个 coding agent harness 究竟应该替用户保存多少状态?
Minimal agent scaffold:不限制 steps,就等于不需要边界吗?
Pi 的基础 agent loop 很容易描述:处理用户消息,调用模型,执行模型返回的 tool calls,把结果交还模型,直到模型不再调用工具。
User message
↓
LLM response
↓
Tool calls? ── no ──→ End
│
yes
↓
Execute tools
↓
Tool results
└──────────────→LLM response作者刻意没有提供 max_steps。他的理由不是模型永远不会循环,而是他在个人交互式工作流中没有用到这个参数:用户看着 Agent 工作,并且随时可以 abort,固定上限反而可能在复杂任务进行到一半时强行中断。
我在 Hugging Face Agent Course 学习 smolagents 时使用过 max_steps,因此第一反应是:它难道只是教学参数吗?答案是否定的。在无人值守、按量计费、多租户或工具具有副作用的 Agent 中,step limit 是重要的资源保险丝,可以限制死循环、成本和运行时间。
这让我意识到,Pi 的选择必须放回它的使用场景中理解。个人交互式 coding agent 可以依赖人工 abort;生产 Agent 则通常还需要 step、时间、Token 和费用预算。不存在脱离场景的唯一正确答案。
作者认为 Agent class 更有价值的能力是 state management、事件订阅、message queue、附件和 transport abstraction。它们解决的不是“最多循环几次”,而是 Agent 如何真正嵌入应用:UI 如何观察运行、用户如何在工作途中追加指令、图片如何进入消息,以及浏览器如何通过代理调用模型。
CLI、TUI 与 text_delta
我一开始不太理解:Codex CLI、Claude Code CLI 和 Pi 看起来都有终端界面,CLI 和 TUI 到底是什么关系?
现在我的理解是,它们描述的是两个不同层面:
- CLI(Command-Line Interface)描述用户如何通过“命令 + 参数”调用程序。
- TUI(Terminal User Interface)描述程序运行后,如何在终端中提供一个持续存在、能响应键盘输入并局部更新的界面。
CLI 的典型生命周期是:
输入一条命令
→ 程序解析参数
→ 执行任务
→ 输出结果
→ 进程退出TUI 的典型生命周期则是:
程序启动
→ 持续显示界面
→ 等待键盘输入
→ 更新消息、输入框或状态
→ 用户主动退出它们并不互斥。一个程序可以通过 CLI 启动,然后进入 TUI。下面几个场景更容易看出区别。
场景一:查看帮助,只有 CLI
pi --help这里 --help 是 CLI 参数。Pi 打印帮助文本后立即退出,没有持续交互、输入框和界面重绘,因此不需要 TUI。
Shell
→ pi 解析 --help
→ stdout 输出帮助
→ 退出场景二:执行一次任务,仍然主要是 CLI
pi --print "解释这个项目的目录结构"用户通过 CLI 参数给出 prompt。Pi 执行 Agent,打印回答,然后退出。中间可能采用 streaming output,但“逐步输出文本”本身并不等于 TUI;如果程序只是不断向 stdout 追加内容,没有输入组件和局部重绘,它仍然更接近传统 CLI。
场景三:直接启动 Pi,CLI 进入 TUI
piShell 首先通过 CLI 启动 pi。由于没有指定一次性 prompt,程序进入交互模式:
CLI:负责启动进程和解析启动参数
↓
TUI:显示聊天记录、输入框、工具调用和运行状态此时用户可以连续提问、使用快捷键、编辑多行输入并随时 abort。聊天记录虽然仍显示在终端中,但它已经不是简单的“命令执行后输出一段文本”,而是一个持续运行的终端应用。
场景四:JSON mode 有 CLI,但没有面向人的 TUI
pi --mode json "检查项目"这也是通过 CLI 启动 Pi,但输出对象是另一个程序,而不是人类用户。Pi 将 Agent events 写成 JSONL,不需要聊天气泡、输入框、颜色和快捷键:
CLI 启动
→ Headless Agent
→ JSON events 写入 stdout
→ jq、CI 或其他程序读取因此,“是否运行在终端中”不能用来区分 CLI 和 TUI。更准确的判断是:
| 问题 | CLI | TUI |
|---|---|---|
| 用户如何启动和配置程序? | 命令与参数 | 通常不负责 |
| 程序是否持续等待交互? | 不一定 | 通常是 |
| 是否处理快捷键和输入组件? | 通常不处理 | 是 |
| 是否移动光标、局部重绘? | 通常不需要 | 通常需要 |
| 是否可以输出后立即退出? | 常见 | 不常见 |
这也是为什么 “Codex CLI” 或 “Claude Code CLI” 这样的产品名称并不表示内部没有 TUI:CLI 是它们的启动和分发形态,交互模式则由 TUI 能力实现。
text_delta 如何连接 Agent 与 TUI
模型输出时,Provider 不会等完整答案生成后一次性返回,而是持续发送增量。Pi 把新到达的一小段文本统一表示成 text_delta:
text_delta: "我会"
text_delta: "先检查"
text_delta: "项目结构"
text_delta: "。"Agent core 会按顺序把 delta 累加成完整 assistant message。不同运行模式可以消费同一个事件,却采用不同呈现方式:
Provider SSE
→ pi-ai 产生 text_delta
→ Agent core 累加消息
├─ TUI:局部重绘正在生成的消息
├─ JSON mode:输出一行 JSON event
└─ Print mode:直接向 stdout 追加文本所以 text_delta 不是 TUI 专属概念,而是 Agent streaming event。TUI 只是它的一个消费者。用户在交互界面中看到的“模型正在打字”,本质是 Provider stream、Agent event 和 TUI rendering 三层协作的结果。
Session branching:对话是一棵树
Pi 的 session branching 不是 Git branch,而是对话分支。每条 session entry 都有 id 和 parentId,当前路径的末端由 active leaf 表示。
A:初始问题
├─ B:方案一
│ └─ C:继续方案一
└─ D:回到 A 后尝试方案二
└─ E:继续方案二创建新分支时,Pi 不删除旧消息,而是把 leaf 移动到历史 entry。下一条消息以该 entry 为 parentId,于是自然形成另一条 child path。构造模型 context 时,Pi 只从当前 leaf 沿 parentId 回溯到 root,不会把整棵树都交给模型。
离开一条已经积累很多工作的路径时,Pi 还可以生成 branch summary,把旧路径的重要发现附到新位置。这里再次体现了上下文工程:保留“旧分支发现了什么”,不等于重放旧分支的全部工具输出。
但 branching 只改变对话路径。旧分支已经执行的文件修改不会自动撤销;要恢复代码状态,仍然需要 Git checkpoint 或其他外部机制。
Headless operation:把 Agent core 交给其他应用
Pi 提供两种不启动 TUI 的方式:
| 模式 | 主要用途 |
|---|---|
| JSON streaming | 一次性任务、Shell、CI、事件日志 |
| RPC mode | IDE、桌面 UI、跨语言应用、长期子进程 |
JSON mode 将标准化后的 Agent 事件逐行写成 JSONL:
{"type":"agent_start"}
{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"Hello"}}
{"type":"agent_end"}它输出的不是 Provider 原始 SSE,而是 Pi 已经统一过的 Agent 事件。
RPC mode 则通过 stdin 接收 JSONL command,通过 stdout 返回 response 和异步事件:
{"id":"req-1","type":"prompt","message":"检查项目结构"}
{"id":"req-1","type":"response","command":"prompt","success":true}外部程序还能发送 steer、follow_up、abort、get_state 和 set_model 等命令。也就是说,Pi 不只是一套终端界面,还是一个可以被其他 UI 和进程驱动的 Agent runtime。
Minimal system prompt:让宿主读取 AGENTS.md
Pi 并不只是告诉模型“项目里可能有一个 AGENTS.md,需要时记得读”。它由 ResourceLoader 在启动时查找全局、父目录和当前目录的 AGENTS.md 或 CLAUDE.md,然后把文件内容直接追加到 system prompt:
<project_context>
<project_instructions path=".../AGENTS.md">
项目规则内容
</project_instructions>
</project_context>这消除了一个不稳定环节:模型不必先意识到文件存在,再决定调用 read。它从第一轮就能看到规则。
当然,“已经注入”不等于“模型必然遵守”。规则冲突、上下文过长和模型注意力仍可能导致遗漏。Pi 改善的是发现与加载的可靠性,而不是把概率模型变成确定性规则引擎。
Minimal toolset:Pi 属于哪种 Agent?
按照 Hugging Face Agent Course 的分类,Pi 默认属于 Tool Calling Agent,而不是 smolagents 那样的 Code Agent。模型的 action 是结构化 tool call:
{
"name": "read",
"arguments": { "path": "src/index.ts" }
}Code Agent 的主要 action 则是一段待执行的 Python 代码。Pi 虽然采用 Tool Calling 协议,但 bash 可以运行任意脚本,因此在实际能力上又接近 Code Agent。
Pi 默认只提供 read、write、edit 和 bash。当前源码将它们放在 packages/coding-agent/src/core/tools/,每个工具都由名称、description、TypeBox schema 和 execute() 组成。
自定义工具通过 extension 注册。下面的结构来自项目当前的最小示例:
// packages/coding-agent/examples/extensions/hello.ts
import { Type } from "@earendil-works/pi-ai";
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
const helloTool = defineTool({
name: "hello",
label: "Hello",
description: "A simple greeting tool",
parameters: Type.Object({
name: Type.String({ description: "Name to greet" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
return {
content: [{ type: "text", text: `Hello, ${params.name}!` }],
details: { greeted: params.name },
};
},
});
export default function (pi: ExtensionAPI) {
pi.registerTool(helloTool);
}它与 smolagents 的 @tool/Tool 承担同一种职责:定义名称、参数和执行函数。区别是 Pi 使用普通 TypeScript 对象和 extension API,而不是装饰器或基类。
YOLO 与 extension:Guardrail 不是安全边界
这里的 YOLO 是 “You Only Live Once” 的缩写。在 coding agent 语境中,它不是鼓励模型冒险,而是表示 harness 不在每次操作前弹出权限确认:Agent 可以直接调用文件和命令工具,工具进程继承启动 Pi 的当前用户权限。
普通权限模式:Agent action → 用户确认 → 执行或拒绝
YOLO 模式: Agent action ─────────→ 直接执行因此,Pi 的 YOLO mode 意味着它可以直接读取和修改当前用户有权访问的文件,也可以通过 bash 执行当前用户能够执行的命令。它不是管理员权限的同义词;Pi 能做多少,仍取决于启动进程本身拥有什么操作系统权限。
Guardrail 可以翻译为“护栏”或“行为防护规则”。它位于 Agent 产生 action 与工具真正执行之间,对操作进行检查:
Agent 产生 tool call
→ Guardrail 检查
→ 允许 / 拒绝 / 修改 / 请求人工确认
→ Tool 执行白名单是 guardrail 的一种,例如只允许 npm run check、git diff 和项目目录内的文件操作;其他形式还包括危险命令黑名单、敏感路径保护、参数校验、运行预算、人工审批和审计日志。
Pi 默认以当前用户权限读写文件和执行命令。作者认为,只要 Agent 同时能读取数据、运行代码和访问网络,应用层 permission prompt 很难从根本上阻止数据泄露。
我认同简单透明的默认值,但也好奇:能否通过 extension 构建更安全的 Agent?答案是可以降低风险,但不能获得真正隔离。
Extension 可以实现 guardrail:
- 只允许指定命令或路径;
- 拦截危险
bash; - 修改文件、安装依赖和 Git push 前请求确认;
- 替换内置工具,只暴露固定的
run_tests; - 清理传给子进程的环境变量;
- 记录审计日志。
但 extension 与 Pi 通常仍运行在相同进程权限下。只要保留任意 bash,不完整的字符串检查就可能被脚本或间接命令绕开。真正的安全边界仍然来自 container、VM 或 OS sandbox。
AGENTS.md 行为提示
Extension guardrail 应用层政策
Container / VM 系统权限边界更可靠的组合是让 extension 负责“政策”,让经过正确配置的 sandbox 负责保底,即确保即使 guardrail 被绕过,实际影响仍被限制在可控范围内。
一连串的 No:作者真正拒绝的是什么?
文章接下来连续讨论 No built-in to-dos、No plan mode、No MCP support、No background bash 和 No sub-agents。表面看是在删功能,实质上是在减少 harness 内部状态、上下文负担与不可观察机制。
Todo:计划状态与真实状态可能不同步
Agent 已经要跟踪代码、测试、工具结果和用户新指令。内置 todo 又增加一份声明状态:
真实工作状态 ↔ todo 状态探索性任务会不断推翻初始假设,todo 很容易过时。模型还要频繁更新 pending、in_progress 和 completed,甚至把“勾完所有项目”错当成最终目标。
作者并不反对任务记录,而是建议写到普通 TODO.md。文件对用户可见、可编辑、可版本管理,也能跨 session 使用。对于固定生产流程,结构化 todo 仍有价值;作者的判断主要针对个人 coding workflow。
Plan:规划应该是可观察的普通工作
作者认为,大部分规划只需要告诉 Agent:先调查和制定方案,不要修改文件。所有读取、搜索和推断都留在当前可观察的 session 中,用户可以随时 steer。
需要持久化时,就写 PLAN.md;需要强制只读时,就只开放 read、grep、find 和 ls。
他对 Claude Code Plan Mode 的主要批评不是 Markdown 文件,而是调研可能被交给不透明的 sub-agent。用户难以确认它读过什么、漏掉什么,也难以在错误方向形成计划之前及时干预。
MCP:不是反对协议,而是反对无条件注入
作者用 CLI tool + README 替代大多数 MCP Server:
Agent 知道某个 CLI 存在
→ 当前任务确实需要
→ 读取 README
→ 通过 bash 调用 CLI
→ 读取 stdout如果本次任务不需要该工具,就不加载完整说明。这是 progressive disclosure(渐进式披露)。
1M context window 的普及缓解了“能不能放下”的问题,却没有消除 input cost、延迟、prompt cache 和工具选择噪声。约 20K tokens 的工具定义在 256K 上下文中占比接近 8%,在 1M 上下文中约占 2%,但每一轮都让模型面对大量无关 schema 仍然不理想。
这里也需要公平区分 MCP 协议与朴素客户端实现。当前 MCP 官方最佳实践同样建议 progressive discovery:先搜索工具,只在需要时加载完整 schema。CLI 和 MCP 并非绝对二选一:简单、本地、可组合的能力适合 CLI;跨应用、远程、强结构化集成适合 MCP。
Background bash:把长期终端状态交给 tmux
普通 bash 是同步的:命令不结束,Agent 就不能继续。后台 bash 则需要管理 PID、输出、输入、清理和恢复。
作者选择 tmux,而不是在 Pi 中再实现一套进程管理器:
tmux new-session -d -s dev "npm run dev"
tmux capture-pane -t dev -p
tmux list-sessions
tmux attach-session -t devtmux 是 terminal multiplexer。程序运行在独立持久的 terminal session 中,Agent 可以查询和发送输入,用户也能 attach 进去亲自接管。即使 context compact 或 Pi 会话结束,tmux list-sessions 仍能重新发现状态。
内置 background process API 对模型更友好,也更容易自动清理;tmux 的优势则是状态在 harness 之外,透明且已有成熟查询方式。这仍然是自动化与可观察性的取舍。
Sub-agent:独立进程代替内置抽象
文章中的 sub-agent 与 smolagents multi-agent 属于同一概念家族:主 Agent 把子任务委托给另一个 Agent。Multi-agent 是更宽泛的体系,sub-agent 是常见的层级结构。
Pi 没有专门的 sub-agent tool,但主 Pi 可以通过 bash 启动另一个 Pi:
Main Pi
→ pi --print "Review this change"
→ Child Pi 独立执行
→ stdout / artifact 返回主 Pi行为上它仍是 sub-agent,区别是没有成为 agent core 的一等状态。独立 Pi 拥有自己的 context、模型、进程和 session;失败边界也比较清晰。若将 session 保存下来或放进 tmux,用户还可以打开它继续追问。
Pi 这种通过 bash 启动另一个 Pi 的方式,代价同样明显:任务分发、context handoff、重试和结果合并都要自己处理。作者也不主张多个 Agent 并行修改同一代码库;不同 Agent 对接口和最新状态的假设很容易不一致。他更认可独立 code review,或者先用研究 session 产出可复用 artifact,再在干净 session 中实现。
Benchmark:最小不等于只凭感觉
作者最后使用 Terminal-Bench 2.0 评估 Pi。Runner 把 Agent 放入隔离的终端任务环境,让它实际修改文件、配置系统或生成 artifact,最后由独立 verifier 判断任务是否完成。
Task instruction
→ Isolated container
→ Pi + Claude Opus 4.5
→ Terminal interaction
→ Hidden verifier/tests
→ Pass / fail作者对每个任务运行 5 次 trial,因为 Agent 具有随机性,一次成功或失败都不足以代表真实能力。他还注意到不同时段的 Provider error 会影响结果,这进一步说明 eval 必须记录错误率、时间、重试和模型版本。
如果自己开发 Agent,我不应该只跑公开 leaderboard,而应该建立四层 eval。这是我目前整理出的初步框架,后续还需要结合主流 Agent Eval 方法验证其可行性:
- 用 fake provider 测试 tool validation、abort、event ordering 和 session recovery。
- 用固定模型响应测试完整 agent loop 场景。
- 建立与真实业务一致的私有任务集和隐藏 verifier。
- 最后用 Terminal-Bench、SWE-bench 等公共基准比较外部水平。
核心指标也不能只有成功率:
| 指标 | 回答的问题 |
|---|---|
| Pass@1 / Pass@k | Agent 有多大概率真正完成任务? |
| Token 与费用 | 成功的成本是多少? |
| 延迟与 turns | 完成得是否足够快? |
| Tool errors | Harness 和模型在哪些调用上失败? |
| 安全违规 | 是否越权读取、联网或修改测试? |
| 失败分类 | 是缺少上下文、实现错误还是 Provider error? |
评测最重要的原则是验证外部结果,而不是相信 Agent 的完成声明。Verifier 应独立于 Agent workspace,避免 Agent 通过删除测试、修改预期或 hardcode 答案制造“成功”。
结论:Minimalism 是状态边界设计
读完全文后,我不再把 Pi 的 Minimalism 简单理解成“功能少”。作者没有消灭规划、任务追踪、外部工具、后台任务和子 Agent,而是把它们放到了不同载体中:
| 需求 | 常见 harness 内置方案 | Pi 作者偏好的外部载体 |
|---|---|---|
| Todo | Todo tool/state | TODO.md |
| Plan | Plan Mode | 普通 session + PLAN.md |
| 外部工具 | MCP tools | CLI + README |
| 后台进程 | Background process manager | tmux |
| Sub-agent | 内置 Task tool | 独立 Pi process/session |
| 行为约束 | 大型 system prompt | 最小 prompt + AGENTS.md |
这些替代物有一个共同特点:用户能够直接查看、编辑、查询、版本管理或重新接管。Pi 的核心观点不是“所有 harness 都不该有这些功能”,而是每增加一份内部状态,就必须回答:
用户看得见吗?
能够修改吗?
进程重启后能恢复吗?
context compaction 后还能发现吗?
真的比现有文件和 CLI 更好吗?我并不完全接受所有结论。生产 Agent 仍可能需要 max_steps、结构化 workflow、MCP、后台任务和 multi-agent orchestration。但 Pi 提供了一个很有价值的审查标准:不要因为某项能力流行,就默认它必须进入 harness;先确认它解决了真实问题,并且没有用方便换来更多隐藏状态。
这也是我从这篇文章中得到的最大收获:Agent harness 的质量,不只取决于它能让模型做多少事情,还取决于它能否让用户理解模型正在做什么,以及状态究竟存在于哪里。