Pi 的 RPC 模式与 JSON 事件流
pi 是一个跑在终端里的 coding agent,但它并不只有 TUI。同一个 agent、同一套 session 与工具,可以用四种方式驱动:交互式 TUI、print、--mode json、--mode rpc。后两种是给人写程序用的——当你的脚本、Web 后端、IDE 插件需要「结构化进度」而不是一段最终文本时,就该看这一篇。
下面所有事件样本都是我在本机 pi 0.87.1 上真跑出来的(模型 deepseek-v4-pro,工作目录 /tmp/pi-demo),为了排版把过长的字段折成了 …,其余保持原样。
1 · 先选对接口
| 模式 | 输出 | 进程生命周期 | 什么时候用 |
|---|---|---|---|
| Interactive | 终端 UI | 人退出为止 | 人在跟它对话 |
Print --print | stdout 一段最终文本 | 一次调用 | 脚本只要结果,命令替换/管道 |
JSON --mode json | stdout JSONL 事件流 | 一次调用 | 需要结构化的进度,但不再发指令 |
RPC --mode rpc | stdin 收命令,stdout 出 response + 事件 | 长驻子进程 | 需要双向控制:换模型、打断、追加 steer、查状态 |
还有一条路是 SDK:进程内嵌,直接拿 TypeScript 方法和事件,没有进程边界。判断标准很简单——同语言且能接受同一个进程里跑 agent,用 SDK;跨语言、要隔离、要做自定义客户端,用 RPC。
另外:不给 --print 时,只要 stdin/stdout 不是 TTY,pi 也会自动落到 print 模式。所以 pi "…" 在管道里不会弹出 TUI。
2 · JSON 模式:一条真实的事件流
pi --mode json "只回复两个字母:OK" > events.jsonl # 15 行
把它按类型摊开(左边是行号,中间是 type):
1 session {"version":3,"id":"01a0dccb-…","cwd":"/tmp/pi-demo"}
2 agent_start
3 turn_start
4 message_start role=system content="" sections={preamble, tools, …}
5 message_end role=system
6 message_start role=user [{"type":"text","text":"只回复两个字母:OK"}]
7 message_end role=user
8 message_start role=assistant content=[] stopReason="pending" usage.totalTokens=0
9 message_update assistantMessageEvent={"type":"text_start","contentIndex":0} usage.totalTokens=0
10 message_update assistantMessageEvent={"type":"text_delta","contentIndex":0,"delta":"OK"} usage.totalTokens=0
11 message_update assistantMessageEvent={"type":"text_end","contentIndex":0,"content":"OK"} usage.totalTokens=3554
12 message_end role=assistant [{"type":"text","text":"OK"}] stopReason="stop"
13 turn_end {message, toolResults:[]}
14 agent_end messages=[3 条] willRetry=false
15 agent_settled
几个直接从数据里读出来的事实:
- 第一行是 session header,只在 JSON 模式出现(RPC 没有,因为它长驻,用
get_state拿 sessionId / sessionFile)。 - system prompt 也会以
message_start/end的形式进流里,别把它当成助手输出。 usage在text_end那一拍才从 0 跳到3554:input 3552 + output 2。也就是说「回复两个字母」的账单是 3.5k input tokens(系统提示 + 工具定义),cost.total = 0.0047USD。流式过程中 usage 可能一直是 0,不能当成「还没花钱」。- 整个
agent_end里带的是本次跑生成的全部消息,等于一份 mini transcript。
只想要完成的文本,一行 jq 就够(stderr 记得丢掉,stdout 是协议专用):
pi --mode json "List files" 2>/dev/null | jq -c 'select(.type == "message_end")'
3 · 分帧:只按 LF 切
两个模式共用同一套 framing:一条记录 = 一个完整 JSON 对象 + \n。听起来 trivial,但这里有个真会咬人的坑:
readline 除了 \n/\r\n,还把 U+2028(LINE SEPARATOR)和 U+2029(PARAGRAPH SEPARATOR)当行边界。而这两个字符在 JSON 字符串里是合法的——它们出现在模型输出、抓取的网页文本、或者任何带日文的正文里,你的记录就会被从中间劈开,JSON.parse 当场炸。
正确做法:拿字节流 / UTF-8 decoder,只按 LF 切,容忍一个可选的前导 \r。
另外三条纪律:
- 持续读 stdout。pi 尊重 stdout 反压,你不读,管道缓冲一满它就被卡住——表现是「agent 静默不动了」。
- stdout 只属于协议。诊断、日志、扩展的输出全部走 stderr。别把 stderr 当 JSON 解析,也别往 stdout 写日志。
- 写命令时同样尊重 stdin 反压。
4 · 事件词表
下面是 AgentSessionEvent 在 JSON/RPC 里会出现的全集(JSON 模式与 RPC 模式共享这套形状,RPC 另有两条独有的)。
骨架
| 事件 | 字段 | 含义 |
|---|---|---|
agent_start | — | 一次低层 agent run 开始 |
turn_start | — | 一个 turn 开始(= 一次助手响应 + 它引发的工具调用与结果) |
turn_end | message, toolResults | 该 turn 结束 |
agent_end | messages, willRetry | 该低层 run 结束,messages 是本次生成的消息 |
agent_settled | — | pi 不再有自动后续动作(重试/溢出恢复/压缩/排队消息都处理完) |
消息与流式
| 事件 | 字段 | 含义 |
|---|---|---|
message_start | message | 消息开始(assistant 的 stopReason 起初是 "pending") |
message_update | usage, assistantMessageEvent | 内容块增量,见下一节 |
message_end | message | 消息完成,权威最终版本 |
工具执行
| 事件 | 字段 | 含义 |
|---|---|---|
tool_execution_start | toolCallId, toolName, args | 开始执行 |
tool_execution_update | + partialResult | 工具自己上报的部分结果(替换还是追加取决于该工具的契约) |
tool_execution_end | result, isError | 结束 |
用 toolCallId 串生命周期,别按顺序猜。
状态、压缩、重试
| 事件 | 含义 |
|---|---|
queue_update {steering, followUp} | 待处理队列变了,两个字段是完整当前队列,不是增量 |
entry_appended | 扩展通过 pi.appendEntry() 写了自定义 session 条目 |
session_info_changed / thinking_level_changed | 会话显示名 / thinking 等级变了 |
compaction_start / compaction_end | 压缩开始 / 结束,reason ∈ manual|threshold|overflow;成功带 result(summary、tokensBefore、…),被中止 aborted:true,失败则 errorMessage |
auto_retry_start / auto_retry_end | 助手 turn 的自动重试(attempt/maxAttempts/delayMs;最终失败时 success:false + finalError) |
summarization_retry_* | 压缩 / 分支摘要的重试三连 |
仅 RPC 有
| 事件 | 含义 |
|---|---|
bash_execution_update | 直接发的 bash 命令每块输出一条,id 回指命令 ID。命令 response 里的输出可能被截断,流里的不会 |
extension_error | 扩展 handler 抛异常:extensionPath / event / error |
扩展 UI(对话框、通知)是 RPC 的另一个子协议,不属于 AgentSessionEvent。
5 · 重建流式消息
线上格式的 message_update 是纯增量:SDK 里那种累积 message 快照和 assistantMessageEvent.partial 都被剥掉了,所以流量是线性的。代价是客户端要自己拼。
嵌套的 assistantMessageEvent.type:
| type | 额外字段 |
|---|---|
text_start / thinking_start / toolcall_start | contentIndex(toolcall 另有 id、toolName) |
text_delta / thinking_delta / toolcall_delta | contentIndex, delta(toolcall 的 delta 是序列化后的参数片段) |
text_end / thinking_end | contentIndex, content ← 权威 |
toolcall_end | contentIndex, toolCall ← 完整的 ToolCall |
start / done / error | provider 层事件;正常 agent loop 会把它们翻译成 message_start/message_end,一般见不到 |
规则只有一句:用 contentIndex 认块;delta 拿来实时显示,一旦对应的 *_end 到了就用它覆盖;message_end.message 到了就整条覆盖。任何「自己算出来的最终文本」都是错的,权威版本在 end 里。
6 · agent_end 不等于「结束了」
这是最容易写错的一处。
prompt response # 只代表:命令被接受 / 入队 / 立刻处理了
↓
agent_end # 一次低层 run 结束
↓ # 但自动重试 / 溢出恢复 / 压缩 / steer / follow-up 还能继续
agent_settled # 这里才是「pi 不会再自己动了」
要「跑完」的语义,就等 agent_settled。上面那条最小的两次字母回复里,第 14、15 行正好说明了这对:agent_end 之后紧接着才是 agent_settled。
事件是异步发的:先订阅,再发 prompt,否则回复快到在你装 listener 之前就 agent_settled 了。官方 RpcClient.promptAndWait() 内部就是按这个顺序做的;如果你分开调 prompt() + waitForIdle(),记得 waitForIdle() 只在确实有 run 在跑时调用。
7 · RPC:把单向流变成双向控制
pi --mode rpc --no-session
普通 CLI 参数照常生效(--provider、--model、--session-dir、--name…)。唯一被明确拒绝的是 @file 形式的 prompt 参数—— prompt 得走 prompt 命令。
协议一共四类记录:
| 方向 | 记录 | 作用 |
|---|---|---|
| stdin | Command | 发 prompt、查状态、换模型、管会话 |
| stdout | response | 某条命令成功与否 + 数据 |
| stdout | Session event | 第 4 节那一整套 |
| 双向 | Extension UI record | 扩展的对话框/通知子协议 |
用 id 关联,不要按顺序猜
命令处理是异步的,响应顺序不保证。给每条可能并发的命令一个字符串 id,对应 response 会原样回显:
{"id":"req-1","type":"get_state"}
{"id":"req-1","type":"response","command":"get_state","success":true,"data":{…}}
事件一般没有 id(它描述会话,不描述某次请求);例外是 bash_execution_update 会带上发起命令的 id,以及 extension_ui_response 会带回 extension_ui_request 的 id(它不产生普通 response)。
这是我从真进程里拿回来的 get_state 响应(截断):
{"id":"r1","type":"response","command":"get_state","success":true,"data":{
"model":{"id":"deepseek-v4-pro","provider":"deepseek","api":"openai-completions",
"contextWindow":1000000,"maxTokens":384000,"cost":{…}, …},
"thinkingLevel":"high",
"isStreaming":false,"isCompacting":false,
"steeringMode":"one-at-a-time","followUpMode":"one-at-a-time",
"sessionId":"01a0dccb-5694-71b5-9605-37fc1730a673",
"autoCompactionEnabled":true,
"messageCount":0,"pendingMessageCount":0}}
注意 no-session 下 sessionFile 就不出现,而 sessionId 仍然有;model 是完整对象,不是字符串。
prompt 及其并发语义
{"id":"req-2","type":"prompt","message":"Review this repository"}
{"id":"req-2","type":"response","command":"prompt","success":true}
success:true= 被接受/入队/立刻处理;false= 接受前就被拒。接受之后出的错一律回到事件流里,不会再给同一个 id 补一条 response。- agent 正在流式时发 prompt,必须带
streamingBehavior,否则直接报错:{"type":"prompt","message":"New instruction","streamingBehavior":"steer"}"steer":当前 turn 的工具跑完、下一次 LLM 调用之前插进去(改方向);"followUp":等 agent 停下来再说。也可以直接发steer/follow_up命令,语义相同但不带扩展命令支持。 - 可以带
images:[{type:"image",data:"<base64>",mimeType:"image/png"}]。 - skill 命令
/skill:name和模板/template会在入队前展开;扩展命令(/mycommand)立刻执行,流式期间也不等。
命令分组速查
| 组 | 命令 |
|---|---|
| Prompting | prompt · steer · follow_up · abort · clear_queue · new_session |
| State | get_state · get_messages |
| Model / Thinking | set_model · cycle_model · get_available_models · set_thinking_level · cycle_thinking_level · get_available_thinking_levels |
| Queue / 压缩 / 重试 | set_steering_mode · set_follow_up_mode · compact · set_auto_compaction · set_auto_retry · abort_retry |
| Bash | bash · abort_bash |
| Session | get_session_stats · export_html · switch_session · fork · clone · get_fork_messages · get_entries · get_tree · get_last_assistant_text · set_session_name |
| 自省 | get_commands(列出当前可用的扩展/模板命令) |
abort 会等会话真的 idle 再回 response;要做 TUI 里 Esc 那种「清空队列 + 打断 + 把文本还给输入框」,先 clear_queue(它会返回被清掉的 steering/followUp 文本),再 abort。
8 · 最小客户端
Python 版(官方示例,注意用的是二进制管道,天然只按 LF 切):
import json, subprocess
process = subprocess.Popen(
["pi", "--mode", "rpc", "--no-session"],
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
)
command = {"id": "prompt-1", "type": "prompt", "message": "Hello"}
process.stdin.write(json.dumps(command).encode("utf-8") + b"\n")
process.stdin.flush()
while line := process.stdout.readline():
record = json.loads(line)
if record.get("type") == "message_update":
update = record["assistantMessageEvent"]
if update["type"] == "text_delta":
print(update["delta"], end="", flush=True)
elif record.get("type") == "agent_settled":
print()
break
process.stdin.close()
process.wait()
TypeScript 侧不必手写——包里导出了 RpcClient,它负责起子进程、关联 id、给类型化的命令方法和事件监听:
import { RpcClient } from "@earendil-works/pi-coding-agent";
const client = new RpcClient({ cliPath: "../dist/cli.js", args: ["--no-session"] });
const unsubscribe = client.onEvent((event) => {
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
process.stdout.write(event.assistantMessageEvent.delta);
} else if (event.type === "tool_execution_start") {
process.stderr.write(`\n[tool: ${event.toolName}]\n`);
}
});
try {
await client.start();
await client.promptAndWait("Explain this repository"); // 先订阅后发,无竞态
} finally {
unsubscribe();
await client.stop();
}
仓库里的 examples/rpc-client.ts 就是这个;它指向 dist/cli.js,所以从 checkout 跑要先 build 包。
9 · 错误与关闭
- 命令失败 = 一条
success:false的 response:{"id":"req-3","type":"response","command":"set_model","success":false, "error":"Model not found: invalid/model"} - JSON 写坏了 = 一条没有 id的 parse response,所以你的解析器不能在「等不到 id」时卡死:
{"type":"response","command":"parse","success":false, "error":"Failed to parse command: Unexpected token..."} - 成功只覆盖「命令被正确处理」。provider 挂了、prompt 接受后被中断,都只在消息/事件流里体现。
- 还要自己处理:子进程起不来、意外退出、stderr 诊断、取消、以及你自己的超时。
- 正常关闭 = 关掉子进程的 stdin,pi 会 dispose 当前 runtime 再退出;扩展也能请求关闭(等当前命令或
agent_settled之后)。信号和意外退出仍要自己兜。
10 · 一页速记
- 只按
\n切记录;readline会因为U+2028/2029把记录劈开。 - stdout 只放协议,stderr 放日志;不读 stdout 会把 pi 卡住。
- 每条命令带
id,按 id 关联,别按顺序猜。 prompt的success:true只是「收到了」;跑完看agent_settled,不是agent_end。- 先订阅事件再发 prompt。
- 增量 显示,
*_end/message_end覆盖;流式期间usage可能是 0。 - 要双向控制才上 RPC;同进程能搞定就用 SDK;只跑一次的 JSON 就够。
· 相关
- 拆解 OpenAI 的 SSE 数据流事件流的上游:模型输出的字节是怎么到达的
pi 0.87.1 自带文档:docs/cli-integration.md、docs/rpc.md、docs/rpc-commands.md、docs/json.md、docs/rpc-extension-ui.md(源码仓库 earendil-works/pi)。第 2 节与第 7 节的 JSON 是本机实跑抓取。协议类型定义看 packages/coding-agent/src/modes/rpc/rpc-types.ts 与 json-event.ts。