让 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怎样通过中间件组合更丰富的工作能力。