跳到正文
Elaine Blog
返回

读懂 Pi Agent:一个精简的 Coding Harness 怎样工作

更新于:
Agent Harness

让 Agent 修改一个配置文件,理论上只需要读取、修改和检查。为什么有的系统需要大量专用工具,Pi 却从少量基础工具出发?理解它的关键,是看模型之外的循环和会话管理,而不是只数默认工具。

本文固定源码提交 62129190d81067ec86ae5a5fc907c96bfe435a78,其中 Coding Agent 包版本为 0.85.1。旧地址 badlogic/pi-mono 已跳转至 earendil-works/pi;本篇使用固定提交与该版本的包名。这里只进行了源码阅读,没有安装、启动或调用模型。

从用户入口向内走三步

先区分模型接口、Agent 核心和交互应用。pi-ai 处理模型提供方差异和消息表达,pi-agent-core 处理动作循环与事件,Coding Agent 在其上组织工作区资源、会话和交互方式。CLI 和 SDK 是不同入口,不意味着它们一定采用不同的执行逻辑。

阅读入口选createAgentSession。它解析工作目录,准备 SessionManager 和 ResourceLoader,加载资源,重建已有会话上下文,决定模型和启用工具,再创建 Agent 与 AgentSession。默认工具是 read、bash、edit、write,扩展还可以注册其他工具。

这说明启动时有两个不同过程:准备“模型可用什么”,以及恢复“会话已经发生什么”。创建一个 Agent 对象并不会自动把任意目录里的所有内容送给模型;ResourceLoader 与后续读取决定可见信息。

一次读取如何进入下一轮回答

底层agent-loop.ts使用 AgentMessage 作为内部消息,到调用模型边界才转换为提供方可接受的消息。streamAssistantResponse 先应用上下文转换,再执行 convertToLlm,最后提交系统提示、消息和工具。

例如用户要求查看报告,模型返回 read 调用。循环收集工具调用,执行后把 ToolResultMessage 加入当前上下文,随后继续生成。message_update 一类事件供界面逐步显示;最终消息和工具结果则进入下一步决策。

下面是根据源码重新组织的控制流程,不是可直接导入的原函数:

接收新消息
  → 准备本轮上下文并转换模型消息
  → 接收完整 assistant 消息
  → 若输出截断,拒绝执行该消息中的工具调用
  → 否则分派工具,加入对应结果
  → 处理下一轮准备与用户插入消息
  → 没有工具和待处理消息时结束

源码特别处理 stopReason=length:即使流式参数经过宽容解析形成了一个对象,也可能缺失尾部关键字段,因此不直接执行。这个细节说明,能解析 JSON 只是必要条件,完整动作还依赖响应结束状态。

把内部消息变成模型请求的两行代码

下面摘录自上述固定提交的 streamAssistantResponse,只保留关键语句并增加中文注释;变量来自原函数上下文,不能独立运行。

// 内部状态可能包含应用自定义消息,在模型边界转换成供应商可接受的结构。
const llmMessages = await config.convertToLlm(messages);
// 每轮解析凭证,有助于处理过期令牌;这不是把凭证写进模型消息。
const resolvedApiKey =
  (config.getApiKey ? await config.getApiKey(config.model.provider) : undefined) || config.apiKey;

第一行解释为什么业务事件不一定原样进入模型,第二行解释为什么认证处理属于 Host。若读取源码只关注提示字符串,很容易漏掉这两个边界。

为什么循环分为内外两层

内层处理工具调用和 steering 消息;外层在 Agent 原本准备停止时检查 follow-up 消息。用户一边等待,一边补充“不要修改原文件”,这类方向修正需要在适当边界进入下一轮;“完成后再补一个说明”则可以排在后续。

AgentSession维护对应队列和状态,调用底层循环时接入这些机制。steering 不是时间倒流:已经写入的文件不会因为用户补充一句话自动恢复,正在运行的外部操作也要有自己的取消或补偿方式。

因此,写集成时应分清提交新消息、排队跟进和中止当前动作。把所有输入都当作新一轮并发 prompt,可能破坏会话顺序;框架的队列机制正是在为这个问题建立边界。

会话分支与文件版本并不相同

Pi 的会话管理允许保留和选择历史分支。分支帮助用户从某个讨论位置尝试另一种方向;压缩则生成更小的可用上下文。这些操作处理的是会话信息,不等同于回滚磁盘。

假设先把配置改为 A,再从更早的会话节点尝试 B,实际文件可能已经处于 A 状态。模型必须重新读取并确认,不能以为切换对话分支已经撤销文件改动。Git、工作区快照和工具回执仍有各自作用。

源码中的 prepareNextTurn 提供轮次之间更新上下文或模型配置的位置,AgentSession 的压缩逻辑也会参与下一轮准备。压缩不是永久记忆保证,关键业务状态仍应由应用保存。

Skill、模板与 Extension 怎样分工

Prompt Template 是复用输入表达,Skill 是按需加载工作方法,Extension 是运行在宿主中的可执行扩展。读者可以沿skills.ts观察发现与内容加载,再沿扩展目录观察注册和事件。

Extension 能注册工具和监听事件,因此权限比一段说明更实际。一个扩展可以在 tool_call 返回阻止结果,但若只是监听结果事件,动作已经发生,不能把事后日志当成事前授权。

这里也要跟随固定版本源码:当前扩展包装器主要适配扩展工具的上下文;工具调用与结果拦截由 AgentSession 接入 agent-core hooks。仅凭文件名猜“所有拦截一定在 wrapper 中”会读错调用链。

增加一个项目工具,再看它的限制

配套order-tool.ts注册一个只接受 A1042 的教学查询工具。Schema 解释参数,执行体返回结构化内容;代码带中文注释,可对照官方最小工具示例阅读。

block-bash.ts展示在工具事件中明确阻止 bash。这个扩展只演示拦截机制,并不宣称所有命令入口、扩展代码和宿主网络都被隔离。若 UI 的其他入口或其他扩展可以直接运行程序,仍需单独控制。

Pi 官方仓库说明明确没有内置覆盖文件、进程、网络和凭证的完整权限系统,默认使用启动进程的权限。基础工具数量少不意味着危险能力少;bash 本身就能组合大量动作。

从这个项目学到的设计取舍

Pi 把可理解的循环与扩展能力放在核心位置。计划模式和子 Agent 等工作方式可以由扩展提供,不能在文章里把社区扩展能力写成核心默认行为。另一方面,当前版本还在演进,本文的结论只绑定列出的源码提交。

对自建 Harness 最值得借鉴的是消息到工具结果的清晰边界、事件与会话的分离、以及把扩展接到实际控制位置。是否加入更多默认能力,应由任务需要决定。下一篇看Deep Agents怎样通过中间件组合更丰富的工作能力。


分享这篇文章:

上一篇
从头实现一个 Harness:把指令、工具、工作区与验证串起来
下一篇
读懂 Deep Agents:规划、文件与子 Agent 怎样组成工作系统