第七部:同一套运行时怎样服务不同入口
从 SDK 的进程内嵌入开始,依次拆开 print/JSON 的一次性投影、RPC 的长驻控制协议、pi-server 的子进程托管与 Evals 的行为归一化,分清复用的 AgentSession 和各入口新增的宿主责任。

展开阅读路线与实验入口
第六部的最后一个问题是 registry 里的同名能力由谁裁决。到了这一部,能力表不再增加,宿主却连续换了五次:Node 程序直接调用 SDK,shell 等一次回答,日志管道消费 JSONL,外部程序控制一个长驻 Agent 进程,server 再托管多个这样的进程,评测套件则只留下可断言的行为结果。
这些入口不是五套 Agent。它们最终都围绕 AgentSession 或 AgentSessionRuntime 工作,共用模型选择、prompt 管线、工具、扩展、会话树和结算语义。差别落在运行时外面:谁发起输入,谁订阅事件,谁处理 extension UI,谁负责 stdout 协议,谁在 session replacement 后重绑,谁终止进程,以及最终保留完整事件还是最后一段文本。
flowchart LR
accTitle: 第七部的宿主与所有权地图
accDescr: SDK 调用方直接拥有 AgentSession;print 和 JSON 在同一进程中消费一次运行;RPC 把控制面投到标准输入输出;server 通过本地 IPC 管理 RPC 子进程;Evals 将隔离会话归一化为可断言结果。
HOST["host application"] --> SDK["SDK"]
SHELL["shell / pipeline"] --> PRINT["print or JSON"]
CLIENT["RPC client"] --> RPC["JSONL process"]
LOCAL["local IPC client"] --> SERVER["pi-server supervisor"]
SUITE["Vitest eval case"] --> EVAL["Pi harness"]
SDK --> SESSION["AgentSession / Runtime"]
PRINT --> SESSION
RPC --> SESSION
SERVER --> CHILD["RPC child process"] --> SESSION
EVAL --> SESSION
图里最重要的不是五条箭头都指向 session,而是 server -> child -> session 多了一层进程所有权,Evals 又把 session 状态转换成另一份结果对象。读代码时如果把包装层省掉,容易把“能发送 RPC 命令”写成“server 自己实现 Agent”,也容易把“评测拿到 transcript”写成“Agent 原生输出评测报告”。
从最薄的入口开始
第 38 章先读 createAgentSession()。最短示例只有创建、订阅、prompt 和 dispose,但它的默认值并不轻:如果调用方没有显式替换,SDK 仍会解析 cwd 与 agentDir,创建 ModelRuntime、SettingsManager、SessionManager 和 DefaultResourceLoader,发现本地资源,再选择模型与工具。所谓“不启动 TUI”,只是没有 interactive view;文件读写、Bash、扩展代码与会话持久化仍按传入配置发生。
SDK 同时给了两层入口。只操作当前会话时,createAgentSession() 已经够用;需要 new、resume、fork 或改变 cwd 时,应当让 AgentSessionRuntime 持有当前 session 与 cwd-bound services。replacement 会让旧 session 失效,所以调用方自己的订阅和扩展绑定也必须跟着换。这不是 UI 的细节,而是嵌入程序必须承担的生命周期责任。
同一次运行,可以只换观察面
第 39 章把 text 与 JSON 放在一起读,因为二者都由 runPrintMode() 驱动同一个 runtime,按顺序执行初始 prompt 和后续 messages,最后 dispose。text 等所有输入结束,只检查当前 session 的最后一条消息,并写出其中的文本块;JSON 则在订阅建立后逐条写出 session event,并在已有 header 时先写 header。JSON mode 因而是事件流,不是把最终回答包成一个 JSON object。
这个区别会直接影响 shell 集成。text 适合人读或把最后答案交给下一条命令,却看不见中间 tool event;JSON 适合记录过程和统计,但消费者必须按行解析、理解事件类型和 session replacement。两者都使用 raw stdout writer,而普通 process.stdout.write 会在 takeover 后改送 stderr,避免 extension 或依赖库的一句日志破坏机器协议。退出前还要等待异步写队列 flush,stdout 才算真正交付。
第 40 章再把一次性命令改成长驻协议。RPC 仍复用 AgentSessionRuntime,只是把 command、correlated response、session event 与 extension UI request 都编码成严格 JSONL。一次 prompt 的成功 response 表示 preflight 已接受输入,不表示 Agent 已完成;真正的终点要从后续 agent_settled 事件判断。协议同时允许多个带 id 的请求存在,进程退出时,client 只能拒绝尚未完成的请求,不能从 transcript 恢复正在运行的 Promise。
server 与 evals 都加了一层,但方向相反
第 41 章处理仓库里标为 experimental 的 @earendil-works/pi-server。它的可执行命令叫 server,通过本地 IPC socket 接受 spawn、list、status、stop、rpc 与 rpc-stream。ServerSupervisor 为每个 instance 启动一个 Coding Agent RPC 子进程,保存少量 instance/session metadata,转发 response、event 和 extension UI request。Agent loop、工具执行、当前 Context 和 AbortController 仍活在子进程中。
这层 supervisor 也不是进程恢复系统。固定版本启动时会把磁盘上残留的 online 或 starting record 改成 stopped,不会自动重新拉起子进程;子进程意外退出时,它将 instance 置为 error、清理绑定并移出 live map。instances.json 保存的是可列出的记录,不是正在执行的 Agent 状态。
第 42 章从另一个方向增加包装:Evals 不延长进程生命,而是缩小环境并提取证据。Pi harness 为每次 run 建临时 workspace 与 agentDir,使用内存 settings 和 session,明确要求 provider/model,执行 prompt 或 reload 序列,再把 assistant text、tool call、tool result 与 token/tool usage 归一化给 vitest-evals。结束后 session dispose,临时目录删除。
评测结果因此是一份选择后的观察面。它可以断言最后回答、工具名称与参数、结果状态、extension reload 后的定义,以及本轮用量;它没有自动证明 UI 可用、持久化目录耐久、跨进程恢复或其他 provider 一致。第 42 章会把 smoke case 与 extension case 当作两种最小范式,并保留真实模型波动与凭据的测试边界。
查阅时先问自己拥有什么
进程内产品集成先读第 38 章;只需要 shell 输出读第 39 章;需要跨语言、长驻控制与事件订阅读第 40 章;要在一个本地服务下管理多个实例,再读第 41 章;准备为 Agent 行为建立回归门槛,则从第 42 章开始。五章之间没有“后者全面取代前者”的关系。能直接调用 SDK 时,多一层 RPC 只会增加协议和进程故障面;需要隔离子进程时,SDK 的进程内便利又不是目标。
所有结论都限定在 v0.83.0 与固定 commit。pi-server 的 README 已明确声明不稳定,Evals 包也标为 private;它们值得读,是因为源码把产品边界写得很直白,不代表这两个表面已经承诺稳定公共 API。第 38 章先从最小 SDK 示例进入,看一句 createAgentSession() 背后到底替你创建了多少东西。
不启动 TUI,怎样把 Pi 嵌进自己的程序
从 createAgentSession 的默认装配、完全显式依赖与 AgentSessionRuntime replacement,划清 SDK 宿主必须接管的事件、持久化、副作用和重绑责任。
第 37 章把工具、Provider 与 renderer 的覆盖顺序理清以后,一个自然的问题是:这些能力能不能离开 Pi 自带的 TUI?可以。SDK 暴露的不是一套缩水后的问答接口,而是 TUI 同样依赖的 AgentSession。宿主可以是桌面应用、Web 后端、测试程序,也可以只是一个 Node.js 脚本。
但“没有 TUI”和“没有 Pi 的默认环境”是两回事。createAgentSession() 的空参数调用很短,背后仍会读取配置、发现项目资源、选择模型、建立会话并启用能改文件的工具。嵌入时最先要决定的,不是界面画成什么样,而是哪些默认行为可以继承,哪些必须由宿主明确接管。
一行 factory 带进来的环境
createAgentSession() 先确定 cwd 与 agentDir,再创建或复用 ModelRuntime。调用方没提供 manager 时,它会创建 SettingsManager 和持久化的 SessionManager;没提供 ResourceLoader 时,则构造 DefaultResourceLoader 并立即 reload()。因此项目里的 extensions、skills、prompt templates、themes、context files,以及全局 agent 目录下的对应资源,都可能进入这场运行。
接着才是状态恢复与初始能力选择。已有 session 可以恢复 model 与 thinking level;否则 factory 从 settings 和可用 Provider 中选择初始模型。没有显式 tools 或 noTools 时,初始 built-in 列表是 read、bash、edit、write。第 37 章讨论的 extension tools 与 SDK custom tools 也会在稍后的 AgentSession registry 中参与装配。
这里有一个很实际的安全边界:SessionManager.inMemory() 只让 session entry 不落 JSONL,不会把 bash、edit、write 变成内存操作。工具仍以 session cwd 为工作目录。想做只读代码浏览,应当显式限制为 read、grep、find、ls;想彻底禁止默认工具,则使用 noTools: "all" 或空 allowlist,并继续审查 custom 与 extension tools。
源码自带的最小示例也暴露了宿主责任。它订阅 message_update 才能把 text delta 写到 stdout,await session.prompt() 等待这一轮结束,最后在 finally 中 dispose()。TUI 原先替用户做的流式投影、错误展示与退出清理由调用方接手了;Agent loop 本身没有改变。
flowchart LR
accTitle: SDK 嵌入没有改变 AgentSession 主链
accDescr: 宿主配置并创建 AgentSession,AgentSession 继续使用模型、资源和工具;事件回到宿主,由宿主投影成自己的界面或协议。
HOST["host application"] --> FACTORY["createAgentSession"]
FACTORY --> CONFIG["ModelRuntime + settings + resources"]
CONFIG --> SESSION["AgentSession"]
SESSION --> MODEL["model stream"]
SESSION --> TOOLS["tool execution in cwd"]
SESSION --> STORE["SessionManager"]
SESSION --> EVENTS["AgentSession events"]
EVENTS --> HOST
“完全控制”其实是一张依赖清单
只传 model 和 tools 还不算隔离。源码的 full-control 示例同时做了这些事:把认证与 model catalog 指到自有目录;用 runtime API key 注入凭据;创建内存 SettingsManager;提供不做磁盘 discovery 的 ResourceLoader;显式选择工具;使用内存 SessionManager。少替换一项,就可能继续继承 Pi 的默认配置或项目资源。
这份清单也说明了 Pi SDK 的抽象位置。createAgentSession() 最终仍构造底层 Agent,为它接入消息转换、ModelRuntime streaming、retry/timeout settings、Provider headers 与 extension hooks,再把 Agent、manager、loader、tools 和 model runtime 封进 AgentSession。SDK 没绕过 coding-agent 层直接调用模型;它保留了 session control plane,只把产品界面交给调用方。
凭据也应留在 ModelRuntime 或宿主自己的 secret provider 中,不要塞进 prompt、session metadata 或自定义事件。内存会话解决的是历史持久化,不是 secret 生命周期;自定义 ResourceLoader 解决的是资源来源,不是工具权限。Pi 没提供一个笼统的“嵌入安全模式”,调用方要按依赖逐项建立边界。
单个 AgentSession 不负责换掉自己
如果产品从创建到退出只使用一个 session,直接持有 AgentSession 足够。newSession、resume、fork 和 import 会替换 active session,并可能切换有效 cwd,此时应当持有 AgentSessionRuntime。第 28 章已经拆过 replacement 的内部顺序;放到 SDK 宿主视角,关键是 runtime.session 不是稳定引用。
AgentSessionRuntime 同时拥有当前 session、cwd-bound services、diagnostics 与创建下一份 runtime 的 factory。services 明确包含 cwd、agentDir、ModelRuntime、SettingsManager 和 ResourceLoader。factory 在目标 cwd 上重建这些服务,再创建 AgentSession;这避免了恢复另一个项目的 session 后还沿用旧项目 extensions、system prompt 和工具路径。
事件订阅绑定在某个具体 AgentSession 上。runtime 替换成功后,旧 unsubscribe 只对应旧 session,extension UI binding 也已失效。宿主要么通过 setRebindSession() 集中重绑,要么像仓库示例那样每次从 runtime.session 重新取引用、重新 bindExtensions()、重新 subscribe。把初始 const session = runtime.session 永久保存在组件闭包里,是最常见的 stale reference。
replacement 也没有事务式回滚。旧 session 会先 abort、发出 shutdown、失效并 dispose,随后 factory 才创建新 runtime;创建失败时异常交给宿主。此时不能假装旧 session 仍可继续。应用要把它当成 runtime replacement failure,停止向旧引用发 prompt,并决定重建、退出还是向用户暴露恢复入口。
用两份官方示例核对默认边界
下面的检查只读取固定 tag,不安装依赖,也不调用真实模型。先看最小示例省略了哪些参数,再看 full-control 示例为哪些依赖给出显式实现:
repo="${PI_SOURCE_DIR:-/tmp/pi-handbook-qbQTcA}"
git -C "$repo" show v0.83.0:packages/coding-agent/examples/sdk/01-minimal.ts |
nl -ba | sed -n '8,26p'
git -C "$repo" show v0.83.0:packages/coding-agent/examples/sdk/12-full-control.ts |
nl -ba | sed -n '17,61p'
git -C "$repo" show v0.83.0:packages/coding-agent/src/core/agent-session-runtime.ts |
nl -ba | sed -n '67,95p;167,194p;398,431p'
对照时可以逐项回答四个问题:资源从哪里发现,会话写到哪里,哪些工具能产生副作用,session 被替换后谁负责重绑。四项都能落到明确代码,SDK 才真正进入了你的产品边界。
这一章仍把事件留给宿主自行消费。Pi 自带的非交互入口也在做同一件事,只是已经替调用方选好了投影规则。第 39 章继续看 print mode:为什么 text 只留下最后一条回答,而 JSON 会把整场 session event stream 写成 JSONL。