π Pi Agent Study

07 SDK 和 RPC 学习记录

本节目标

本节是进阶集成章节。前面 01 到 06 看的是 Pi 内部怎么运行;07 看的是同一套 agent 能力如何通过 CLI、interactive、SDK 和 RPC 暴露给不同宿主。

重点问题:

我们怎么学的

总体图

SDK
= 代码里直接 import createAgentSession / createAgentSessionRuntime

AgentSessionRuntime
= 长期宿主,负责当前 session replacement

RPC
= 把 AgentSessionRuntime 暴露成 JSONL 长连接协议

换一个角度:

CLI
= 一个宿主

interactive mode
= TUI 宿主

print/json mode
= 一次性输出宿主

RPC mode
= 长期进程通信宿主

SDK
= 给你自己写宿主用的入口

所以 07 的核心不是“又多了一个 API”,而是:

Pi 的产品级 AgentSession 可以被不同宿主复用。

Mermaid 源码:

flowchart TD
  CLI[CLI print/json] --> S[AgentSession]
  TUI[Interactive TUI] --> Runtime[AgentSessionRuntime]
  SDK[SDK host] --> S
  RPC[RPC mode] --> Runtime
  Runtime --> S
  S --> A[Agent]
  A --> L[agent loop]

最短判断:

命令行一次性使用:print/json
终端交互:interactive
Node 应用内嵌:SDK
外部进程长期控制 Pi:RPC

SDK:创建完整 AgentSession

createAgentSession() 的定位是:

给外部代码创建一个完整的 Pi AgentSession

它不是只创建底层 Agent

它会组装:

AuthStorage
ModelRegistry
SettingsManager
SessionManager
ResourceLoader
Agent
AgentSession
tools
extensions
sessions
compaction
event stream

主线:

createAgentSession(options)
-> resolve cwd / agentDir
-> create authStorage / modelRegistry
-> create settingsManager / sessionManager
-> resourceLoader.reload()
-> sessionManager.buildSessionContext()
-> restore model / thinkingLevel
-> create Agent
-> restore messages or append initial model/thinking entries
-> create AgentSession
-> return { session, extensionsResult, modelFallbackMessage }

这说明 SDK 复用的是我们前面学过的同一个 AgentSession

所以 SDK 不是“轻量 wrapper”,而是 Pi 的产品级 agent runtime 入口。

CreateAgentSessionOptions

CreateAgentSessionOptions 允许外部宿主替换关键组件。

常见选项:

cwd / agentDir
= 控制项目和全局配置发现

authStorage / modelRegistry
= 控制鉴权和模型来源

model / thinkingLevel
= 控制初始模型状态

tools / excludeTools / noTools / customTools
= 控制工具能力

resourceLoader
= 控制 skills / prompts / extensions / context files

sessionManager
= 控制持久化或 in-memory session

settingsManager
= 控制配置来源

所以 SDK 有两种用法:

简单嵌入
= 什么都不传,用 Pi 默认发现和配置

深度集成
= 自己传 sessionManager、customTools、resourceLoader、modelRegistry

最小例子:

import { createAgentSession, SessionManager } from "@earendil-works/pi-coding-agent";

const { session } = await createAgentSession({
  sessionManager: SessionManager.inMemory(),
});

session.subscribe((event) => {
  if (event.type === "message_update") {
    // handle stream event
  }
});

await session.prompt("What files are in the current directory?");

SDK 创建 Agent 时接入的边界

createAgentSession() 创建底层 Agent 时,会塞入几个关键边界:

convertToLlm
= AgentMessage[] -> provider Message[]

streamFn
= 从 modelRegistry 拿 auth,再调用 streamSimple()

onPayload
= 触发 before_provider_request extension hook

onResponse
= 触发 after_provider_response extension hook

transformContext
= 触发 context extension hook

steeringMode / followUpMode
= 从 settings 读队列策略

这说明 06 学过的 extension hooks,在 SDK 模式里同样生效。

为什么支持 SessionManager.inMemory()

SDK 不一定只给 CLI 用,也可能用于:

测试
临时自动化
嵌入式 UI
子 agent
服务端 pipeline

如果总是写 JSONL,会带来问题:

测试不方便
= 每次都要清理文件,副作用更大

临时任务不需要持久化
= 比如一次性分析、一段 pipeline、一个子 agent

宿主可能有自己的存储
= web app / desktop app / 服务端系统可能想把历史放数据库

隐私和隔离
= 有些集成不希望默认落盘

所以 SDK 允许:

sessionManager: SessionManager.inMemory()

这表示:

我需要完整 AgentSession 能力;
但这次历史只存在内存里,不写 Pi 默认 session 文件。

这也说明 SDK 的设计目标不是“复刻 CLI”,而是让外部宿主选择自己的存储、工具、模型和资源加载方式。

AgentSessionRuntime:长期宿主管当前 session

createAgentSession() 适合创建一个 session。

但有些宿主需要替换当前 session:

/new
/resume
/fork
/clone
/import
switch cwd-bound session
reload resources

这些操作会让当前 AgentSession 失效,换成新的 AgentSession

所以需要:

AgentSessionRuntime

它的边界:

AgentSession
= 当前这一个会话的 agent 控制器

AgentSessionRuntime
= 长期宿主,负责持有“当前 session”,并在 new/fork/switch/import 时替换它

为什么不能只清空 messages:

AgentSession 绑定了很多当前 session 状态:

sessionManager
agent.state.messages
extensionRunner
resourceLoader
settingsManager
modelRegistry
cwd
event listeners
UI / extension bindings

换 session 时,这些东西可能都要重新创建或重新绑定。

Runtime replacement 固定流程

大多数 replacement 都是这个套路:

1. emit session_before_switch / session_before_fork
2. 如果 extension cancel,停止
3. 创建目标 SessionManager
4. teardownCurrent()
   -> emit session_shutdown
   -> beforeSessionInvalidate()
   -> old session.dispose()
5. createRuntime(...)
   -> createAgentSessionServices()
   -> createAgentSessionFromServices()
6. apply(result)
   -> this._session = result.session
   -> this._services = result.services
   -> diagnostics / fallback message 更新
7. finishSessionReplacement()
   -> rebindSession(newSession)
   -> withSession(new ctx)

这就是 Runtime 比 AgentSession 多管的东西:

session replacement 生命周期

services 和 session 为什么拆开

源码里有两个函数:

createAgentSessionServices()
= 创建 cwd-bound runtime services,不创建 AgentSession

createAgentSessionFromServices()
= 用已经创建好的 services 创建 AgentSession

拆开的原因是宿主在创建 services 后,可能还要基于这些 services 决定 session 参数。

例如 CLI 在 main.ts 里会:

createAgentSessionServices()
-> collect diagnostics
-> resolveModelScope()
-> buildSessionOptions()
-> 处理 --api-key
-> createAgentSessionFromServices()

中间需要读取:

settings
resourceLoader diagnostics
extension flags
enabled models
scoped models
tools / noTools / excludeTools
thinkingLevel

所以拆开不是多余,而是给 CLI/RPC/interactive 这类宿主一个中间决策点。

Runtime 和 extension stale

06 里我们讲过 extension ctx 的 stale 检查。

Runtime replacement 正是 stale 的来源之一。

当旧 session 被替换:

teardownCurrent()
-> old session.dispose()
-> extension ctx invalidate

然后宿主通过:

rebindSession(newSession)

重新绑定 UI、订阅事件、extension UI context 等。

长期宿主要记住:

runtime.session 可能变化
旧 session 的事件订阅不自动跟着新 session
旧 extension ctx 不应该继续使用

SDK 文档也强调:

event subscriptions are attached to a specific AgentSession

所以 session replacement 后要重新订阅。

RPC:JSONL 长期控制协议

RPC 的定位:

外部进程不直接 import SDK;
而是启动 pi --mode rpc;
通过 stdin/stdout JSONL 发送命令、接收事件。

协议形状:

stdin
-> RpcCommand JSON line

stdout
-> RpcResponse JSON line
-> AgentSessionEvent JSON line
-> extension_ui_request JSON line

RpcCommand 覆盖:

Prompting
= prompt / steer / follow_up / abort / new_session

State
= get_state

Model
= set_model / cycle_model / get_available_models

Thinking
= set_thinking_level / cycle_thinking_level

Queue modes
= set_steering_mode / set_follow_up_mode

Compaction
= compact / set_auto_compaction

Retry
= set_auto_retry / abort_retry

Bash
= bash / abort_bash

Session
= get_session_stats / export_html / switch_session / fork / clone

Messages
= get_messages

Commands
= get_commands

这些命令基本都映射回:

AgentSession 方法
AgentSessionRuntime 方法

RPC 时序 Mermaid 源码:

sequenceDiagram
  participant Client
  participant RPC as RPC mode
  participant Runtime
  participant Session as AgentSession
  Client->>RPC: prompt command
  RPC->>Runtime: current session
  RPC->>Session: prompt(message)
  RPC-->>Client: response accepted
  Session-->>RPC: agent events
  RPC-->>Client: event lines

rpc-mode.ts 主线

runRpcMode(runtimeHost) 做的事:

takeOverStdout()
-> 创建 output(),保证 stdout 只输出 JSONL
-> 创建 RPC extension UI context
-> rebindSession()
-> attach stdin JSONL reader
-> handleCommand(command)
-> 输出 response
-> 同时把 session event 流输出到 stdout

rebindSession() 很重要:

session = runtimeHost.session
session.bindExtensions({ mode: "rpc", uiContext, commandContextActions })
unsubscribe old event listener
subscribe new session events

一旦:

new_session
switch_session
fork
clone

换了 session,RPC 必须重新 bind 当前 session。

prompt command 为什么特殊

RPC 的 prompt command 不像 get_state 那样同步返回完整结果。

它的逻辑是:

收到 prompt command
-> session.prompt(...) 异步跑
-> preflightResult(true) 后先输出 success response
-> 后续 message_update / tool_execution_update / agent_end 等事件继续流式输出

所以 RPC 的 prompt response 只代表:

这个 prompt 被接受了

不代表:

模型已经回答完了

真正完成要看事件流里的:

agent_end

对外部 host 来说:

response success
= command 通过 preflight,agent run 已经开始或已排队

agent_end
= 这一轮 agent run 结束

这和 RpcClient.promptAndWait() 对应:

collectEvents()
-> prompt()
-> 等 agent_end

extension UI 在 RPC 中如何处理

RPC 没有 TUI,但 extension 可能调用:

ctx.ui.select()
ctx.ui.confirm()
ctx.ui.input()
ctx.ui.notify()
ctx.ui.setStatus()
ctx.ui.setWidget()

所以 RPC mode 把这些变成:

extension_ui_request

发给外部 host。

host 再回:

extension_ui_response

这意味着 RPC 不是“没有 UI”,而是:

UI 能力由外部宿主实现。

但有些 TUI 专属能力 RPC 不支持,例如:

custom component
footer/header factory
autocomplete provider
theme switching
custom editor component

这些需要真实 TUI 环境。

RpcClient:typed wrapper

RpcClient 是 RPC 的 typed wrapper。

它做的事:

spawn node dist/cli.js --mode rpc
-> 给每个 command 加 id
-> 写 stdin JSONL
-> 读 stdout JSONL
-> response 用 id 匹配 pending request
-> 非 response 当成 AgentEvent 分发给 listeners

外部代码可以这样:

const client = new RpcClient({ cwd });
await client.start();

client.onEvent((event) => {
  // message_update / tool_execution_update / agent_end ...
});

await client.prompt("Read README.md");
await client.waitForIdle();

也可以用:

await client.promptAndWait("Read README.md");

它内部就是:

collectEvents()
-> prompt()
-> 等 agent_end

为什么 RPC 是长期 agent server

print/json 更像一次性运行:

启动 pi
-> 创建 session/runtime
-> 接收一个 prompt
-> 输出文本或 JSON event
-> 跑完退出

它主要服务:

pi -p "..."
pi --mode json -p "..."

即使 json 会流式输出事件,它仍然围绕一次 prompt 生命周期。

RPC 是长期进程:

启动 pi --mode rpc
-> 保持进程常驻
-> stdin 持续接收多个 command
-> stdout 持续输出 response + events
-> session 可以被远程切换/替换
-> extension UI 请求也走协议往返

外部程序不是只拿一次输出,而是在控制一个活着的 agent runtime。

关键差异:

print/json
= 一次运行的输出模式

RPC
= 长期 agent server / remote control protocol

尤其是这些行为有长期控制特征:

streaming 中发 steer / follow_up
切 session 后 rebind
extension UI request / response
用 id 匹配多次 command response
持续订阅 AgentSessionEvent

07 能力对照

| 层 | 本质 | 典型使用者 | 关键文件 |

| --- | --- | --- | --- |

| SDK | 直接嵌入 Pi AgentSession | Node 应用、测试、自定义 UI | core/sdk.ts |

| AgentSessionRuntime | 长期宿主的当前 session 管理器 | interactive / RPC / 自定义长期宿主 | core/agent-session-runtime.ts |

| Services split | 创建 cwd-bound services,再决定 session options | CLI / RPC / interactive | core/agent-session-services.ts |

| RPC mode | JSONL agent server | 外部进程、编辑器、桌面应用 | modes/rpc/rpc-mode.ts |

| RpcClient | typed process wrapper | Node 集成方 | modes/rpc/rpc-client.ts |

集成方式决策表

| 场景 | 选择 | 原因 | 完成信号 |

| --- | --- | --- | --- |

| 命令行一次性使用 | print/json | 启动一次、处理一个 prompt、输出后退出 | 进程退出或 agent_end |

| 终端交互 | interactive | 需要 TUI、快捷键、session replacement | UI 事件流 |

| Node 应用内嵌 | SDK | 同进程直接创建 AgentSession,可替换 storage/tools/resources | agent_end 或宿主订阅 |

| 外部进程长期控制 Pi | RPC | JSONL 长连接,远程 command、events、extension UI request | agent_end,不是 prompt response |

常见误解

自测题

本节完成度

本节已经完成:

这一节结束后,Pi agent 的主线已经闭环:

CLI 入口
-> 模型接口
-> agent loop
-> tools
-> session
-> extensions
-> SDK/RPC

Source: 07-sdk-rpc-notes.md