Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 填进去再委托给它)负责组装,不负责运行:

  1. 从会话分支恢复模型和 thinking level,恢复不了就按 settings 默认值 / provider 默认值挑,并生成 modelFallbackMessage;
  2. 算出初始工具集:defaultTools 设置(默认 read, bash, edit, write)经 tools / +name -name 修饰 / excludeTools 过滤;
  3. new Agent(...):注入 streamFn(走 modelRuntime.streamSimple,按请求解析认证、超时、provider 级重试参数,并驱动 cache warmer)、transformContext(转发给扩展的 context 事件)、convertToLlm(AgentMessage → LLM Message,按设置过滤图片),以及 steering/follow-up 模式等;
  4. 新会话写入初始 model_change 和 thinking_level_change 条目,便于将来恢复;
  5. 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 消息就得到当前状态。

为什么这样设计?两个原因:

  1. 保住 prefix cache。如果改了工具就重写 system prompt 开头,后面整段历史的缓存全部失效;改成在末尾追加补丁,前缀保持不变。
  2. 可回放、可分叉。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(),因为会话树才是权威。

模块地图

源码入口(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/