pi-coding-agent:产品级 Coding Agent Harness
TL;DR:
pi-agent-core只会跑“模型 → 工具 → 模型”的循环,pi-coding-agent把它装配成能在真实仓库里长期工作的产品:会话树持久化、配置、资源发现(AGENTS.md / skills / 模板 / 扩展)、内置编码工具、自动压缩与重试、模型与认证,以及 TUI / print / JSON / RPC / SDK 几种外壳。它是一个普通的 Node 进程,没有常驻 daemon,扩展也跑在同一个进程里。启动链是main.ts→ cwd 绑定的AgentSessionServices→createAgentSession()工厂 → 负责换会话的AgentSessionRuntime→ 总协调者AgentSession。1.x 的关键设计是 SessionManager 是模型上下文的唯一权威:每次请求都从会话树重新投影出消息,system prompt 和工具声明也作为 system 消息记在会话里,变化只追加补丁,不重写开头,从而保住 prompt cache。
它解决什么问题
pi-ai 解决“怎么和不同 provider 说话”,pi-agent-core 解决“怎么跑一轮 agent loop、怎么调工具”。但一个能每天在代码仓库里用的 coding agent 还缺很多东西:
- 对话要能落盘、恢复、分叉,长对话要能压缩;
- 用户级、项目级配置要合并,项目里的
AGENTS.md、skills、prompt 模板、扩展要被发现并装进 system prompt; - 要有一套好用的文件/命令工具,并且输出不能撑爆上下文;
- 网络抖动、限流、上下文溢出要能自动恢复;
- 同一套能力既要给人用(终端 UI),也要给脚本和其他程序用(print / JSON / RPC / SDK);
- 模型选择、API key / OAuth 登录、自定义 provider 要统一管理。
pi-coding-agent(npm 包 @earendil-works/pi-coding-agent,CLI 名 pi)就是这层 harness。它本身不重写 agent loop,而是通过 agent-core 预留的钩子把产品逻辑插进去:请求前改上下文、每轮之间刷新状态、工具调用前后拦截、回合结束时决定要不要继续。
一句话:agent-core 提供“循环 + 钩子”,coding-agent 提供“会话 + 资源 + 工具 + 恢复 + 外壳”,二者靠钩子而不是继承连接。
运行形态:一个进程,几种外壳
执行 pi 时只有一个 Node(或 Bun 打包产物)进程:
cli.ts(setupCli)→ main(args)
├─ 解析参数、决定模式:--mode rpc / --mode json / -p 或 stdin/stdout 非 TTY → print,否则 interactive
├─ 选定会话文件和最终 cwd,解决项目信任
├─ 创建 AgentSessionRuntime(内部创建 services 和 AgentSession)
└─ 分发:InteractiveMode.run() / runPrintMode() / runRpcMode()
几个要说清的运行事实:
- 没有后台服务。会话、配置、认证都是磁盘上的文件(会话默认是
~/.pi/agent/sessions/<按 cwd 编码的目录>/下的 JSONL,凭据在 agentDir 的auth.json,模型配置在models.json),进程退出就结束。下次启动通过--continue/--resume/--session重新打开文件恢复。 - 扩展是进程内的 TypeScript 模块。它们由 resource loader 加载,工厂函数在同一进程里注册工具、命令、provider、事件处理器和 UI,所以扩展和内置工具一样拥有 Pi 进程的全部 OS 权限。
- RPC 模式也不是 daemon:它是被宿主程序拉起的长生命周期子进程,宿主关闭 stdin 它就退出。SDK 则完全没有进程边界,宿主直接在自己的进程里
createAgentSession()。 - 交互模式 1.0 起默认全屏 TUI(
tuiMode: "regular"或--tui-mode regular可回到普通滚动缓冲)。
所有模式共用同一个 AgentSession,区别只在“输入从哪来、事件怎么输出、进程活多久”,详见 运行模式。
分层:从 main.ts 到 AgentSession
main.ts:先定 cwd,再造依赖 cwd 的东西
main.ts 是启动编排器。它最重要的一个顺序约束写在源码注释里:先确定最终 cwd,再创建与 cwd 绑定的服务。因为 --session / --resume 可能选中另一个项目的会话,而项目级 settings、资源、extension 注册的 provider 都依赖 cwd,所以 main 先用启动目录的 settings 只做“会话目录查找”,选定 SessionManager 后以 sessionManager.getCwd() 为准。
然后 main 不直接造 session,而是定义一个 createRuntime({ cwd, agentDir, sessionManager, sessionStartEvent }) 闭包。闭包里依次做:判断项目信任 → 创建 AgentSessionServices → 解析 --models 作用域 → buildSessionOptions() 决定模型、thinking、工具 → createAgentSessionFromServices()。最后把这个工厂交给 createAgentSessionRuntime()。
为什么要工厂而不是一次性对象?因为会话切换时 cwd 可能变,cwd 一变 settings、资源、provider 注册都必须重建。runtime 需要一个“如何为某个 cwd 重新创建自己”的配方,在 /new、/resume、/fork、/import 时反复调用。
项目信任也在这里处理:如果项目目录里有需要信任才能加载的资源(项目级 settings、扩展等)且没有记录过信任决定,Pi 在加载项目资源之前做决定:defaultProjectTrust 为 always / never 时直接按配置,默认 ask 时在交互模式弹出询问,没有 UI 的模式(print / JSON / RPC)则按不信任处理,命令行覆盖优先。信任结果按 cwd 缓存,切换会话时复用。顺序是:信任决定 → 加载项目设置与资源 → 加载 context files。
AgentSessionServices:cwd 绑定的基础设施
createAgentSessionServices() 产出一组只和“当前工作目录”有关的服务:
ModelRuntime:模型目录与认证的统一入口(1.x 用它取代了旧笔记里分开描述的 AuthStorage + ModelRegistry 组合,详见 模型与认证);SettingsManager:全局 + 项目 settings 合并(配置体系);ResourceLoader:发现扩展、skills、prompt 模板、主题、AGENTS.md / SYSTEM.md(资源发现);diagnostics:创建过程中收集的问题。
它会先 resourceLoader.reload()(扩展在这一步被加载),再把扩展登记的 provider、原生 provider、虚拟模型注册进 ModelRuntime,最后把 CLI 上未识别的 --xxx 交给扩展声明的 flag。它只返回诊断,不打印也不退出,是否中止启动由 app 层决定——这让 SDK 宿主可以自己处理错误。
createAgentSession:装出 Agent 和 AgentSession
sdk.ts 的 createAgentSession()(createAgentSessionFromServices() 只是把 services 填进去再委托给它)负责组装,不负责运行:
- 从会话分支恢复模型和 thinking level,恢复不了就按 settings 默认值 / provider 默认值挑,并生成
modelFallbackMessage; - 算出初始工具集:
defaultTools设置(默认read, bash, edit, write)经tools/+name-name修饰 /excludeTools过滤; new Agent(...):注入streamFn(走modelRuntime.streamSimple,按请求解析认证、超时、provider 级重试参数,并驱动 cache warmer)、transformContext(转发给扩展的context事件)、convertToLlm(AgentMessage → LLM Message,按设置过滤图片),以及 steering/follow-up 模式等;- 新会话写入初始
model_change和thinking_level_change条目,便于将来恢复; new AgentSession({ agent, sessionManager, settingsManager, resourceLoader, modelRuntime, ... })。
agent-core 自己是 provider 无关的,它不知道 pi-ai 的认证和 provider 配置;streamFn 这层就是 coding-agent 对 pi-ai 的产品化适配。
AgentSessionRuntime:会话宿主
AgentSessionRuntime 持有“当前的 AgentSession + 它的 services + 创建工厂”。newSession()、switchSession()、fork()、importFromJsonl() 的套路一致:发 session_before_switch 等扩展事件(可取消)→ 关闭旧会话(session_shutdown)→ 用工厂为目标 cwd 重建 services 和 session → 替换。替换之后旧 session 上的订阅失效,所以各个 mode 都通过 setRebindSession() 注册一个回调,在替换后重新 bindExtensions() 并重新订阅事件。
AgentSession:总协调者
AgentSession 拥有一段对话及其模型、工具、消息队列、压缩状态和扩展运行时。它在构造函数里把自己挂到底层 Agent 上:
agent.subscribe(...):所有底层事件先经过 AgentSession,做持久化、扩展转发、重试计数;prepareRequest:每次请求前用会话树投影替换上下文(下面细讲);prepareNextTurnWithContext:每轮之间检查是否该压缩、刷新 system prompt 与工具;beforeToolCall/afterToolCall:桥接扩展的tool_call/tool_result;finishTurn:桥接扩展的turn_end边界,扩展可以要求继续;transformContext:叠加隐藏工具声明、强制 system prompt 等投影。
所以准确的说法不是“AgentSession 持有一个 Agent”,而是 AgentSession 通过订阅和钩子把一个通用 Agent 升级成了产品会话。扩展钩子的完整语义见 扩展系统,这里只需要记住桥接点就在上面这几个钩子和事件订阅里。
一句话:main 决定“在哪个 cwd、用哪个会话、什么模式”,services 提供 cwd 绑定的环境,工厂负责装配,runtime 负责换会话,AgentSession 负责跑对话。
1.x 的上下文模型:会话树是唯一权威
这是读旧资料最容易踩的坑。旧版本里 agent.state.messages 基本就是发给模型的历史,prompt() 往里追加即可。从 0.87 起(1.x 延续),SessionManager 才是 provider 上下文的权威:
- 每次模型请求前,
prepareRequest调sessionManager.buildSessionProjection(),从 JSONL 会话树的当前分支重新算出消息:应用压缩摘要、context_edit条目(替换或省略某条消息)、分支摘要。 agent.state.messages只是这个投影的缓存,直接给它赋值不会改变后续请求,也不会改变持久化内容。要改历史,得往 SessionManager 追加条目再refreshContext(),或者用navigateTree()。- 数据最终在哪:会话 JSONL 文件是唯一事实来源;内存里的 messages、UI 渲染的 transcript 都是派生物。
system prompt 也进了会话。会话首次请求时持久化一条 system 消息,里面是 system prompt 的各个 section 和全部工具声明;之后无论是切换工具、/reload 重新加载资源,还是扩展在 before_agent_start 里加了一段说明,AgentSession 都会把“当前应有的 prompt sections”与“会话里回放出来的 sections”做 diff,只在有变化时追加一条补丁 system 消息(追加/替换/删除 section,toolsAdded / toolsRemoved)。回放所有 system 消息就得到当前状态。
为什么这样设计?两个原因:
- 保住 prefix cache。如果改了工具就重写 system prompt 开头,后面整段历史的缓存全部失效;改成在末尾追加补丁,前缀保持不变。
- 可回放、可分叉。prompt 和工具状态跟着会话分支走,fork 或回到旧节点时自然恢复当时的工具集,不需要额外记录。
例外是扩展在 before_agent_start 返回了完整的 systemPrompt:它需要那段文字出现在请求最前面,所以 Pi 只在请求投影时把 system 消息折叠成一个开头,不写进会话记录,run 结束即失效。
旧笔记里那条“工具在会话启动时基本固定”的结论在 1.x 仍成立,只是表述要更新:启动时扫描并注册工具、确定初始 active tools;之后每轮只是复用内存里的状态,不会重新扫描扩展目录。只有 setActiveTools()、refreshTools()、reload() 或扩展主动改动工具时才会变,而且变化以补丁消息的形式声明给模型,不会重写已有前缀。
一句话:会话树是事实来源,内存消息是投影缓存,system prompt 和工具是会话里可回放的 system 消息,变化只追加补丁。
内置编码工具
内置工具有 read、bash、edit、write、grep、find、ls(Windows 上另有 powershell)。默认只启用 read、bash、edit、write,其余要手动打开。选择方式:
--tools read,grep,find,ls:纯名字列表是白名单,整个替换默认集合(支持*通配);--tools +grep,-write:只含+name/-name时是修饰默认集合,与defaultTools设置的语义一致;两种写法不能混用;--exclude-tools:最后按名字或模式剔除;--no-builtin-tools只关内置;--no-tools全关。- 内置扩展还提供
codemode和tool_search两个工具,不在默认工具集里,配置了 MCP 服务器时codemode会自动启用,见 MCP 与 Codemode。
几个值得在面试里讲的工具设计:
- edit 是精确文本替换,不是 diff/patch。参数是
edits[],每个oldText必须在原文件中唯一匹配且互不重叠。模型输出“原文片段 → 新片段”比输出行号或统一 diff 稳定得多;唯一性约束让错误的编辑直接失败、返回给模型修正,而不是悄悄改错位置。 - 输出截断有上限,并告诉模型怎么继续。
read默认最多 2000 行或 50KB,截断时附上Use offset=N to continue;bash保留输出尾部(错误通常在最后),并把完整输出写到临时文件、在结果里给出路径。目的都是防止一个工具结果撑爆上下文,同时不丢信息。 - 同一文件的写操作串行化。并行工具调用时,
edit/write按文件真实路径排队,不同文件仍然并行。 - 没有内置沙箱。工具以 Pi 进程的 OS 权限运行,需要隔离时由外部容器或扩展(
tool_call拦截)实现。
一次 session.prompt() 的完整链路
场景:用户在交互模式里输入“把 utils.ts 里的 parseDate 改成支持时区”,期间会话已经很长、网络还抖了一下。
1. 入口预处理(还没碰模型)。 prompt(text) 先看是不是 /xxx 扩展命令,是就直接执行并返回 handled。若正在压缩则拒绝。然后发 input 事件,扩展可以改写输入或直接吞掉。接着展开 /skill:name 和 prompt 模板。如果 agent 正在运行,调用方必须指明 steer(当前 assistant 回合及其工具调用结束后插入)或 followUp(agent 本来要停下时再处理,仍在同一个 run 里),否则直接报错而不是猜。最后检查模型和认证是否可用。
2. 准备 run。
- 先拿最后一条 assistant 消息检查是否需要压缩(包括上次被中止的回复),会话太长就在发请求前压缩;
- 发
before_agent_start:扩展可以追加自定义消息、修改本次 run 的 system prompt 选项、调setActiveTools(); - 构造用户消息(图片在这一步按目标模型的限制归一化),附上待注入的 nextTurn 消息和扩展消息;
- 如果本次 run 的 prompt/工具与会话里回放出的状态不同,在最前面加一条补丁 system 消息。
3. 进入 agent loop。 agent.prompt(messages) 交给 agent-core 的双层循环:
while 还有工作:
prepareRequest → 从 SessionManager 投影出 canonical 上下文
streamFn → pi-ai → provider,流式产出 assistant 消息
有 tool call → beforeToolCall(扩展 tool_call 可拦截) → 执行 → afterToolCall(扩展 tool_result 可改写)
finishTurn → 扩展 turn_end 边界
prepareNextTurn → 超阈值就先压缩,刷新 prompt/工具补丁
处理 steering / follow-up 队列
模型读 utils.ts(read),用 edit 精确替换 parseDate,再用 bash 跑测试。注意压缩可以发生在 run 中间:每轮之间的 prepareNextTurn 会估算投影后的 token,超过阈值就在下一次 assistant 回复前压缩,避免长任务跑到一半溢出。
4. 事件与持久化。 底层每个事件都先经过 AgentSession:先发给扩展,再发给 session 订阅者(TUI 渲染、JSON 输出、RPC 转发都在这一层)。message_end 时把完整消息 appendMessage() 进 JSONL——流式 delta 只用于显示,落盘的是 message_end 的权威消息。成功的 assistant 回复会把重试计数清零。
5. run 结束后的自动恢复。 底层发出 agent_end 不代表结束。AgentSession 在一个循环里做 run 后处理:
- 最后一条是可重试错误(比如
fetch failed、429、overloaded)→ 按 2s、4s、8s 退避,把失败回复用context_edit从投影中省略(原始记录仍保留)→agent.continue(); - 是上下文溢出 → 不走普通重试,先压缩再继续,只尝试一次;
- 正常结束但超过阈值 → 压缩但不继续;
agent_end处理器又排了消息 → 再跑一轮;- 最后给扩展一次
agent_before_settle机会,可以要求继续。
都处理完后发 agent_settled,表示 Pi 不会再自动做任何事。prompt() 的 Promise 在这之后才 resolve,所以 SDK 调用方 await session.prompt() 拿到的是包含重试和压缩恢复之后的最终状态。错误与重试的细节见 错误处理与重试。
把上面压缩成面试版:
Agent.prompt() = 跑一次 agent loop
AgentSession.prompt() = 输入预处理(命令/input/模板/队列)
+ run 准备(压缩检查、before_agent_start、prompt 补丁)
+ Agent.prompt()(每次请求从会话树投影上下文)
+ 事件转发与 JSONL 持久化
+ 自动恢复循环(重试、溢出压缩、队列)直到 agent_settled
常见追问
为什么不让 agent-core 直接管持久化和重试? agent-core 定位是通用 runtime,只负责一次 loop 的语义并在错误时干净地结束;重试策略、压缩时机、落盘格式都是产品决策,放在 harness 里,其他宿主可以用 agent-core 实现自己的策略。
agent_end 和 agent_settled 有什么区别? agent_end 是一次底层 loop 结束,之后可能还有重试、溢出恢复、排队消息;agent_settled 才表示整个会话级 run 不会再自动继续。写集成时等前者会过早收尾。
为什么 system prompt 变化要追加补丁而不是重建? 重建开头会让整段历史的 provider prompt cache 失效,补丁追加在末尾只影响新增部分;同时 prompt 状态随分支回放,fork 后自动正确。代价是会话里多了 system 消息,需要投影逻辑正确地折叠和压缩它们。
扩展跑在进程内有什么取舍? 好处是零 IPC、能直接注册工具/UI/provider、能同步拦截工具调用;代价是没有隔离,扩展崩溃或恶意代码与 Pi 同权限,所以才有项目信任:项目自带的扩展和设置必须先被信任才加载。
SDK 宿主想恢复外部历史怎么办? 不能给 agent.state.messages 赋值,要构造一个包含那些条目的 SessionManager(例如 SessionManager.inMemory(...))传进 createAgentSession(),因为会话树才是权威。
模块地图
- 扩展系统:生命周期事件与 control hook,AgentSession 在上文那些钩子处把扩展接进 agent-core。
- 会话树与上下文压缩:JSONL 条目树、分支投影、
context_edit、压缩与分支摘要的切点算法。 - 资源发现、项目信任与系统提示词:ResourceLoader 如何找到 AGENTS.md / SYSTEM.md / 模板 / 主题,以及 prompt sections 怎么拼。
- Skills:按需加载的能力说明,system prompt 里只放描述,正文由模型自己读取。
- 配置体系:全局与项目 settings 的路径、合并和信任边界。
- 模型与认证:ModelRuntime、凭据存储、模型解析与作用域。
- Pi Packages 与包管理:用 npm/git 分发扩展、skills、模板和主题。
- 运行模式:Interactive / Print / JSON / RPC / SDK 如何共用同一个 AgentSession。
- 错误处理与重试:provider 错误如何一路变成自动重试或压缩恢复。
- MCP 与 Codemode:内置扩展形式的 MCP 接入、
codemode与tool_search。 - 底层依赖:pi-agent-core、pi-ai;终端 UI 见 pi-tui。
源码入口(v1.5):packages/coding-agent/src/main.ts、packages/coding-agent/src/core/agent-session-services.ts、packages/coding-agent/src/core/sdk.ts、packages/coding-agent/src/core/agent-session-runtime.ts、packages/coding-agent/src/core/agent-session.ts、packages/coding-agent/src/core/tools/