Zhanbo's Blog
Back to home

读完 Pi Coding Agent:一个 Agent Harness 应该拥有多少状态?

AI Agent/Learning Notes
2026-08-02
15 min read
0 reads
PiCoding AgentAgent HarnessTool CallingTUIMCPMulti-AgentContext EngineeringAgent Eval

In brief

继续阅读 Pi Coding Agent 的设计文章后,我发现作者反复强调的并不只是“功能少”,而是尽量不让 harness 拥有隐藏状态。Pi 保留最小 agent loop、事件流和工具协议,却把 todo、plan、MCP、后台进程与 sub-agent 等能力交给普通文件、CLI、tmux 和独立 session。本文沿着我阅读时提出的问题,梳理这种设计的收益、代价,以及它对自建 Agent 和 eval 的启发。

昨天我读完了 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

pi

Shell 首先通过 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。更准确的判断是:

问题CLITUI
用户如何启动和配置程序?命令与参数通常不负责
程序是否持续等待交互?不一定通常是
是否处理快捷键和输入组件?通常不处理
是否移动光标、局部重绘?通常不需要通常需要
是否可以输出后立即退出?常见不常见

这也是为什么 “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 都有 idparentId,当前路径的末端由 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 modeIDE、桌面 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}

外部程序还能发送 steerfollow_upabortget_stateset_model 等命令。也就是说,Pi 不只是一套终端界面,还是一个可以被其他 UI 和进程驱动的 Agent runtime。

Minimal system prompt:让宿主读取 AGENTS.md

Pi 并不只是告诉模型“项目里可能有一个 AGENTS.md,需要时记得读”。它由 ResourceLoader 在启动时查找全局、父目录和当前目录的 AGENTS.mdCLAUDE.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 默认只提供 readwriteeditbash。当前源码将它们放在 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 checkgit 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 很容易过时。模型还要频繁更新 pendingin_progresscompleted,甚至把“勾完所有项目”错当成最终目标。

作者并不反对任务记录,而是建议写到普通 TODO.md。文件对用户可见、可编辑、可版本管理,也能跨 session 使用。对于固定生产流程,结构化 todo 仍有价值;作者的判断主要针对个人 coding workflow。

Plan:规划应该是可观察的普通工作

作者认为,大部分规划只需要告诉 Agent:先调查和制定方案,不要修改文件。所有读取、搜索和推断都留在当前可观察的 session 中,用户可以随时 steer。

需要持久化时,就写 PLAN.md;需要强制只读时,就只开放 readgrepfindls

他对 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 dev

tmux 是 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 方法验证其可行性:

  1. 用 fake provider 测试 tool validation、abort、event ordering 和 session recovery。
  2. 用固定模型响应测试完整 agent loop 场景。
  3. 建立与真实业务一致的私有任务集和隐藏 verifier。
  4. 最后用 Terminal-Bench、SWE-bench 等公共基准比较外部水平。

核心指标也不能只有成功率:

指标回答的问题
Pass@1 / Pass@kAgent 有多大概率真正完成任务?
Token 与费用成功的成本是多少?
延迟与 turns完成得是否足够快?
Tool errorsHarness 和模型在哪些调用上失败?
安全违规是否越权读取、联网或修改测试?
失败分类是缺少上下文、实现错误还是 Provider error?

评测最重要的原则是验证外部结果,而不是相信 Agent 的完成声明。Verifier 应独立于 Agent workspace,避免 Agent 通过删除测试、修改预期或 hardcode 答案制造“成功”。

结论:Minimalism 是状态边界设计

读完全文后,我不再把 Pi 的 Minimalism 简单理解成“功能少”。作者没有消灭规划、任务追踪、外部工具、后台任务和子 Agent,而是把它们放到了不同载体中:

需求常见 harness 内置方案Pi 作者偏好的外部载体
TodoTodo tool/stateTODO.md
PlanPlan Mode普通 session + PLAN.md
外部工具MCP toolsCLI + README
后台进程Background process managertmux
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 的质量,不只取决于它能让模型做多少事情,还取决于它能否让用户理解模型正在做什么,以及状态究竟存在于哪里。

参考资料

ZB

Zhanbo Chen

Java Backend & AI Agent Developer

Back to home
Comments