PI Agent:把工作流选择权交还给使用者
一个 Coding Agent 真正难的地方,不是写出一个能调用模型的 while,而是让模型协议、工具循环、会话历史、终端交互和工作流扩展彼此解耦;PI Agent 的回答是把系统拆成四层,并把核心压缩到 "状态、事件、工具循环和停止条件" 这些稳定职责上;其余能力通过会话层、资源加载器和扩展 API 组合出来
沿着一次请求从 CLI 或 SDK 进入、经过会话与 Agent core、流过模型适配器和工具钩子、最后落到可分支的 JSONL 历史这条路径阅读 PI,可以同时看懂它的包边界、执行控制和产品取舍
建立全局地图:
PI 的代码仓库不是一个把所有能力塞进 CLI 的大包,而是由四层职责组成的 monorepo(单体代码仓库);依赖方向从产品表面向底层协议收敛,底层包不反过来依赖终端 UI
| 层 | 主要职责 | 典型位置 |
|---|---|---|
pi-ai | 模型目录、provider 适配、统一消息与流事件 | packages/ai/src |
pi-agent-core | 有状态 Agent、turn 生命周期、工具调用与事件 | packages/agent/src |
pi-coding-agent | 会话、资源、上下文策略、扩展和 Coding 产品规则 | packages/coding-agent/src/core |
pi-tui | 终端输入、渲染和交互表面 | packages/tui |
这四层形成一条清晰的请求流程:
CLI / SDK
-> AgentSession
-> Agent core
-> pi-ai stream
-> tool hooks
-> JSONL session tree
这里最重要的不是箭头本身,而是谁拥有哪种状态;模型协议只负责把厂商差异翻译成统一事件,Agent core 只推进一次运行,AgentSession 才负责队列、资源和持久化,TUI 只把可观察状态投影给用户
PI 的总览可以浓缩成三条设计原则:
- 核心只承诺生命周期;它处理
prompt、流事件、tool call和停止条件,却不替上层决定计划模式、子任务或审批体验 - 模型先统一成事件流;Anthropic、OpenAI、Gemini 或本地路由的差异停留在 provider 适配器中
- 会话和扩展属于产品层;
AgentSession、JSONL tree 与ExtensionAPI共同提供可操控、可分支、可重载的使用体验
因此,PI 与其他 Agent 路线的差异不是功能多少,而是复杂度放在哪里:DSH 把边界做成插件树,nanobot 强调直接可读的分层,PI 则把工作流选择权交给外围层和使用者
统一模型协议:pi-ai 把多家模型压成一条流
为什么要有这一层:
不同模型在认证方式、思考块、缓存、工具调用和流式事件上各不相同;如果这些差异直接进入 Agent Loop,循环就会被 provider 分支占满,换模型也会牵动上层逻辑
pi-ai 提供模型目录和统一的 stream 函数,让上层只面对稳定的 Message、ToolCall 与 AssistantMessageEvent;常见的事件词汇包括开始、增量文本、工具调用、完成、错误、使用量和停止原因
请求怎样流过适配器:
// 根据服务商与模型ID,获取模型实例(封装apiKey、baseUrl、参数配置)
const model = models.getModel(provider, modelId)
// 发起大模型流式请求,返回异步事件流(文本分片、思考分片、工具调用分片等)
const stream = models.streamSimple(
model, llmContext, options
)
// 消费流式事件,每收到一个分片就向外派发事件(供上层回调/TUI渲染使用)
for await (const event of stream) emit(event)
这里的 Model Registry 负责选择模型,provider 负责向厂商发请求,streamSimple() 把原始响应转换成统一的助手事件流;Agent 不需要知道当前模型来自哪家服务,只需消费这些事件并更新状态
在源码中验证:
packages/ai/src/types.ts:查看AssistantMessageEvent等联合类型,确认文本、思考、工具调用和结束原因的公共表示packages/ai/src/providers:对照不同 provider,观察差异如何在适配器内被翻译packages/agent/src/stream-fn.ts:查看 Agent core 接收统一流的入口
这一层解决的是协议边界,不是 Agent 决策;模型目录、事件格式、token 和成本统计统一之后,循环才有可能保持窄而稳定
Agent Loop:状态机如何反复调用工具:
一个 turn 何时结束:
在 PI 中,一个 turn 包含一次模型响应以及由它触发的工具执行;只要工具结果仍然需要交回模型,Agent run 就继续,直到模型不再请求工具、用户取消或出现不可恢复错误
await agent.prompt(userMessage)
agent_start -> turn_start
assistant stream
tool_execution*
tool results
turn_end -> next turn? -> agent_end
agent_start、turn_start、turn_end 和 agent_end 不是装饰性日志,它们让 UI、会话层和扩展能观察并介入生命周期;取消、错误、并行工具和排队输入也都可以挂在明确的阶段上
读循环时先找什么:
先从 await agent.prompt(userMessage) 找输入,再追踪助手流怎样变成工具调用、工具结果怎样追加回上下文,最后确认什么条件让循环从 turn_end 走向下一轮或 agent_end
源码证据集中在 packages/agent/src/agent.ts 的 Agent.prompt / state、packages/agent/src/agent-loop.ts 的 agentLoop,以及 packages/agent/src/types.ts 的事件和状态类型
最容易犯的误读是把 Agent core 当成完整产品;它只负责一段可观察、可取消、能在工具后续轮的运行,模型选择、资源装配、会话写入和终端体验仍由外围层拥有
双重转换:应用消息不等于模型消息
为什么要分两次转换:
终端执行记录、自定义扩展消息和压缩摘要等内容需要留在应用历史里,却不一定符合 provider 的消息协议;如果把所有存储内容原样发送给模型,模型上下文会失控,扩展消息也可能破坏请求格式
PI 先用 transformContext 做业务级的裁剪、注入和预算治理,再用 convertToLlm 过滤并转换成模型真正能接受的 Message[]
// 转换上下文:做压缩、截断、脱敏、上下文窗口适配等处理
const transformed = await transformContext(messages)
// 将内部消息格式,转换成大模型API可识别的请求消息格式
const llmMessages = await convertToLlm(transformed)
// 仅UI渲染/自定义本地消息:保留在本地会话存储,不会发送给大模型服务商API
// UI-only/custom messages can stay persisted
// without leaking into the provider request
可以把它理解成两道工序:历史像素材库,transformContext 决定本轮取哪些镜头,convertToLlm 再把选中的内容转码成 provider 协议;因此 "保存什么" 和 "模型看到什么" 是两个独立问题
源码证据包括 packages/agent/src/types.ts 的 AgentMessage / transformContext、packages/agent/src/agent-loop.ts 的调用位置,以及 packages/coding-agent/src/core/messages.ts 的 convertToLlm
这条边界带来三个结果:应用消息可以扩展,上下文治理可以独立演进,模型协议仍保持窄;也正因为如此,压缩摘要、UI 专属消息和产品事件才不会反向污染最小循环
工具执行:并行提速,但结果仍可预测
预检、执行和收口:
多个工具可以并发执行,但权限判断、写操作和结果持久化需要稳定顺序;PI 把工具调用拆成预检、执行和后置钩子,并允许全局策略与单工具 executionMode 同时参与
Tool Calls
-> Validate
-> beforeToolCall
-> Parallel / Sequential
-> afterToolCall
-> Ordered Results
核心控制流可以写成:
// 工具调用前置钩子:执行前校验,block阻断工具执行 / allow允许继续
beforeToolCall(call) -> block | allow
// 根据工具配置决定执行模式:sequential串行 / parallel并行(多工具批量调用生效)
executeMode = tool.sequential ? "sequential" : "parallel"
// 异步执行工具,拿到原始执行结果(文件读写、shell、MCP等真实操作)
result = await tool.execute(...)
// 工具调用后置钩子:override覆写返回结果;terminate终止整个Agent任务循环
afterToolCall(call, result) -> override | terminate
预检阶段可以阻断或审计调用;执行阶段按工具的顺序要求选择并行或串行;后置钩子可以覆盖结果或提前终止;即使多个工具完成时间不同,回写给模型和会话的结果仍按模型原始顺序组织
源码证据与边界:
packages/agent/src/agent-loop.ts:executeToolCalls展示工具数组如何分组,以及await如何形成并发屏障packages/agent/src/types.ts:beforeToolCall / afterToolCall的返回类型明确支持阻断、覆盖和终止packages/coding-agent/src/core/tools:查看 Coding 产品层如何提供具体工具集合
不要把权限、超时和审计逻辑复制到每个工具 handler;工具定义能力,调用管线承载横切策略,这样并发优化不会牺牲可预测性
AgentSession:把最小内核变成可操控产品
运行中输入怎样介入:
用户经常会在 Agent 调工具时补充要求,产品必须回答新消息什么时候生效;PI 把输入分成两种队列:steering 在当前工具批次后介入,follow-up 等 Agent 完成全部工作后再送达
// 向会话提交用户输入消息,由session处理用户交互
session.prompt(message)
// 按下Enter键:将用户消息作为主轮次任务排入队列
Enter -> queueSteering(message)
// Alt+Enter快捷键:将消息作为后续补充追问排入队列,不重置当前正在运行的任务
Alt+Enter -> queueFollowUp(message)
// 等待Agent执行变为空闲状态:等待工具调用、模型推理全部跑完
await session.agent.waitForIdle()
这不是简单的 "中断或不打断" 开关,而是明确的时序契约;AgentSession 同时协调模型切换、资源加载、压缩、持久化和产品事件,Agent core 则继续专注于运行本身
为什么需要会话层:
如果让终端 UI 直接操纵 Agent core,输入队列、会话文件和资源生命周期会散落在各个入口;Session 把这些产品状态集中起来,CLI、SDK、TUI 和其他表面就能共享同一套控制面
源码证据是 packages/coding-agent/src/core/agent-session.ts 的 AgentSession、packages/coding-agent/src/core/agent-session-runtime.ts 的运行时装配,以及 packages/coding-agent/src/core/event-bus.ts 的事件桥接
PI 的交互感来自队列语义:用户不必等 Agent 完全停下,也能精确决定新消息是在当前工作后转向,还是在整轮完成后排队
JSONL Tree:一个文件里的分支历史
从线性日志到会话树:
编码任务经常需要回到旧消息尝试另一条路径;复制整份聊天既浪费空间,也会丢失分支关联;PI 的 session JSONL 让每个条目只追加,并用 parentId 指向父节点
{
"type": "message",
"id": "b2c3d4e5",
"parentId": "a1b2c3d4",
"message": {}
}
切换分支时不需要复制文件,只要改变当前 leaf,再把新消息作为该节点的子节点追加;旧分支仍然留在同一个 JSONL 中,可以回放、导出或做分支摘要
这和 Git commit graph 的思路相似:历史节点不被覆盖,当前可见路径由叶节点决定;存储事实与模型当前视图因此可以同时存在
源码证据:
packages/coding-agent/src/core/session-manager.ts:追踪append / branch / leaf的更新逻辑packages/coding-agent/docs/session-format.md:查看 JSONL entry schema 与parentId关系packages/coding-agent/src/core/compaction/branch-summarization.ts:观察离开分支后如何摘要
PI 没有把会话树藏在不可读的数据库里;一份追加式 JSONL 同时承担恢复、分支和导出,这也是它适合调试与学习的重要原因
ResourceLoader 与 ExtensionAPI:核心之外的产品空间
最小主义到底拒绝了什么:
PI 刻意不把 plan mode、subagent、权限弹窗或后台 Bash 写死在核心里;这不是功能缺失,而是避免用一套默认工作流替所有使用者做决定
DefaultResourceLoader 从全局目录、项目 .pi 和 package 发现提示、技能、主题等资源;ExtensionAPI 则提供统一入口来注册工具、事件、命令、快捷键和 TUI 行为
// 监听工具调用事件,挂载防护钩子(权限校验、拦截高危工具调用)
pi.on("tool_call", guard)
// 向插件/运行时实例注册自定义工具,Agent后续可以调用该工具
pi.registerTool(tool)
// 注册会话内部命令,用户输入 /plan 时触发对应处理函数
pi.registerCommand("plan", handler)
// 注册键盘快捷键,按下 ctrl+t 执行指定动作
pi.registerShortcut("ctrl+t", action)
// /reload 内置命令:原地刷新插件、配置、资源,无需重启整个程序
/reload // refresh resources in place
扩展可以把审批、计划、子任务或自定义界面加到产品层,/reload 允许资源在不重启进程的情况下重新发现和装配;核心因此保持小,工作流却可以持续生长
源码证据包括 packages/coding-agent/src/core/resource-loader.ts、packages/coding-agent/src/core/extensions 和 packages/coding-agent/docs/extensions.md
这里要区分两件事:资源加载器负责 "发现哪些东西",扩展 API 负责 "怎样把能力挂进运行时";前者解决来源和优先级,后者解决注册与生命周期
结语:小内核不是终点,而是选择权的起点
PI Agent 的核心答案是:让 pi-ai 统一模型语言,让 pi-agent-core 只推进可观察的 Agent 生命周期,让 AgentSession 管理交互与产品状态,再用 JSONL tree 保存可恢复、可分支的事实;工作流、审批、计划、工具和界面则通过资源与扩展层按需组装
因此,一个极小的 Agent core 仍然可以长成完整 Coding Agent,并不是因为核心偷偷包含了所有功能,而是因为它把稳定边界留在内层,把变化和选择留给外层;当你要设计自己的 Agent 时,最值得借鉴的不是某个快捷键或某段 CLI 代码,而是先决定哪些状态必须由核心拥有,哪些行为应该交给会话层和扩展生态

870

被折叠的 条评论
为什么被折叠?



