Skip to content

6.11 SDK:把 Pi 当作库使用

本页分析版本earendil-works/pi@c13ffe12026-07-30

本章解决什么问题:前面十章都在讲「pi 这个命令怎么跑起来」。如果我不想要那个终端界面,只想在自己的 Node 程序里调用 Pi 的 Agent 能力,该从哪个函数进去?它会替我准备好哪些东西、又允许我替换掉哪些东西?以及——我怎么在完全没有 API Key 的情况下,亲手跑通一次真实的 Pi 对话? 前置知识6.3 pi-agent-core:Agent 与循环6.5 Session 存储格式与会话树6.10 交互模式与 RPC 模式(本章的对照组)。 学习目标:读完后你能 ① 说出 createAgentSession() 会默认造出哪四个组件,以及每个组件的替换点在第几行;② 解释 SessionManager.inMemory() 为什么是「零副作用嵌入」的开关,并指出那个 return 守卫;③ 说清 SDK(软件开发工具包,Software Development Kit)嵌入与 RPC(远程过程调用,Remote Procedure Call)子进程嵌入的取舍;④ 知道为什么 AgentSession 之上还需要一层 AgentSessionRuntime;⑤ 在没有任何 API Key 的机器上,用 100 行不到的脚本跑通一次真实的 Pi 对话(含一次工具调用)

建立直觉:整机、发动机与遥控器

把前面十章拼起来,Pi 是一台整机:外壳是终端界面(TUI,Terminal User Interface),发动机是 AgentSession + Agent Loop(Agent 循环),油箱是 Provider(模型服务提供方)与凭证,仪表盘是会话文件。

现在你想把这台整机塞进自己的产品里,有两条路:

  • 路线一:把发动机拆出来装到你的车上。你的程序和 Pi 在同一个 Node 进程里,你直接拿到 AgentSession 这个对象,调它的方法、订阅它的事件。这就是 SDK。
  • 路线二:不拆,给整机装一个遥控器。Pi 作为子进程跑在旁边,你用 stdin/stdout 上的 JSON 行遥控它。这就是 6.10 讲过的 RPC 模式。
📘 概念依赖注入(Dependency Injection)
一个函数需要若干协作对象才能干活。它可以自己 new 出来(写死),也可以「你不给我就我自己造,你给了我就用你的」。后者叫依赖注入。它的好处不是抽象本身,而是可替换性:测试时换成假的,嵌入时换成不落盘的,定制时换成你自己的实现。createAgentSession() 整个函数就是一次教科书式的依赖注入。

createAgentSession:一次组装的全过程

SDK 的入口只有一个函数。它的选项接口 CreateAgentSessionOptions 有 14 个字段(sdk.ts:38-85),返回值 CreateAgentSessionResult 有 3 个字段(sdk.ts:88-95)——都是源码事实。

earendil-works/pi@c13ffe1第 169–185 行在 GitHub 查看 ↗
组装的前 17 行:解析 cwd 与 agentDir,然后依次决定四个核心组件——ModelRuntime、SettingsManager、SessionManager、ResourceLoader。每一个都是「调用方给了就用,没给才造默认的」。
ts
// packages/coding-agent/src/core/sdk.ts:169-185
export async function createAgentSession(options: CreateAgentSessionOptions = {}): Promise<CreateAgentSessionResult> {
	const cwd = resolvePath(options.cwd ?? options.sessionManager?.getCwd() ?? process.cwd());
	const agentDir = options.agentDir ? resolvePath(options.agentDir) : getDefaultAgentDir();
	let resourceLoader = options.resourceLoader;

	const authPath = options.agentDir ? join(agentDir, "auth.json") : undefined;
	const modelsPath = options.agentDir ? join(agentDir, "models.json") : undefined;
	const modelRuntime = options.modelRuntime ?? (await ModelRuntime.create({ authPath, modelsPath }));

	const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir);
	const sessionManager = options.sessionManager ?? SessionManager.create(cwd, getDefaultSessionDir(cwd, agentDir));

	if (!resourceLoader) {
		resourceLoader = new DefaultResourceLoader({ cwd, agentDir, settingsManager });
		await resourceLoader.reload();
		time("resourceLoader.reload");
	}

这 17 行里有三个值得停下来看的细节:

第一,cwd 的三级回退(第 170 行):显式 cwd → 从你注入的 sessionManager 里取 → process.cwd()。第二级存在是因为恢复一个旧会话时,工作目录应该跟着会话走,而不是跟着你当前 shell 走。

第二,?? 的短路是有副作用意义的(第 179 行)。getDefaultSessionDir(cwd, agentDir) 内部会 mkdirSync 建出会话目录(packages/coding-agent/src/core/session-manager.ts:483-489)。但 JavaScript 的 ?? 只在左侧为空时才求值右侧——所以只要你注入了 sessionManager,这个建目录的动作根本不会发生。这是后面「零副作用」实践任务能成立的前提之一(源码事实;本章实践任务的沙盒目录里确实只出现了一个 auth.json)。

第三,agentDir 是否显式传入会改变 authPath/modelsPath 的取值(第 174-175 行):不传就是 undefinedModelRuntime.create 会回落到 ~/.pi/agent 下的默认路径。想完全不碰用户真实配置,就必须显式传 agentDir

四个默认组件与它们的替换点

组件默认实现源码行什么时候该替换
ModelRuntimeModelRuntime.create({ authPath, modelsPath })sdk.ts:176想注入自定义 Provider、或禁止联网刷新模型目录
SettingsManagerSettingsManager.create(cwd, agentDir)sdk.ts:178想用固定设置而不读用户的 settings.json
SessionManagerSessionManager.create(cwd, getDefaultSessionDir(...))sdk.ts:179想不落盘(inMemory)、或指定会话文件
ResourceLoadernew DefaultResourceLoader({...}) + reload()sdk.ts:181-185想关掉技能(Skill)/扩展(Extension)/AGENTS.md 的自动发现

除此之外,tools / excludeTools / noTools / customTools 控制工具集合,model / thinkingLevel / scopedModels 控制模型,sessionStartEvent 传给扩展运行时。

streamFn:SDK 在这里把「设置」缝进 Agent

createAgentSession 最长的一段是构造核心 Agent。其中最关键的是 streamFn——6.3 讲过 Agent 本身是 Provider 无关的,它只认一个「给我上下文、还我事件流」的函数。SDK 在这里把 ModelRuntimeSettingsManager 缝了进去。

earendil-works/pi@c13ffe1第 302–330 行在 GitHub 查看 ↗
注入的流函数:从 SettingsManager 读超时与重试参数,再委托 modelRuntime.streamSimple;transformHeaders 里还挂了扩展的 before_provider_headers 钩子。
ts
// packages/coding-agent/src/core/sdk.ts:302-330(节选)
streamFn: async (model, context, options) => {
	const providerRetrySettings = settingsManager.getProviderRetrySettings();
	const httpIdleTimeoutMs = settingsManager.getHttpIdleTimeoutMs();
	// SDKs treat timeout=0 as 0ms (immediate timeout), not "no timeout".
	const effectiveTimeoutMs = httpIdleTimeoutMs === 0 ? 2147483647 : httpIdleTimeoutMs;
	const timeoutMs = options?.timeoutMs ?? providerRetrySettings.timeoutMs ?? effectiveTimeoutMs;
	// …(省略:websocketConnectTimeoutMs 的同款回退)
	return modelRuntime.streamSimple(model, context, {
		...options,
		timeoutMs,
		maxRetries: options?.maxRetries ?? providerRetrySettings.maxRetries,
		// …(省略:maxRetryDelayMs 与 transformHeaders 钩子)
	});
},

从源码结构看,这解释了一件对嵌入者很重要的事:你注入的 ModelRuntime 是唯一的出口。Agent 想说话,只能经过 modelRuntime.streamSimple。所以只要你的 ModelRuntime 里注册的是一个假 Provider,整条链路就一行网络请求都不会发——这正是本章实践任务的原理。

组装的最后一步是把所有东西交给 AgentSession

earendil-works/pi@c13ffe1第 376–398 行在 GitHub 查看 ↗
收尾:把 agent、四个组件、工具名单、扩展引用槽一起交给 AgentSession 构造函数,然后连同 extensionsResult 与 modelFallbackMessage 一起返回。
图加载中…

图 6.11-1 createAgentSession 的组装流程
阅读顺序:从上到下。四个并列的菱形是四个可注入点,形状完全一样——这就是「依赖注入」在源码里的样子。请特别关注 sessionManager 那一路:走「否」分支(默认)会在磁盘上建目录并写文件,走「是」分支(注入 inMemory)则一个字节都不写。图中每个方框的行号都可以在 packages/coding-agent/src/core/sdk.ts 里逐行核对:ModelRuntime 在 176 行决定,SettingsManager 178 行,SessionManager 179 行,ResourceLoader 181-185 行,汇合点是 294 行的 new Agent 与 376 行的 new AgentSession

SessionManager.inMemory:零副作用嵌入的开关

嵌入场景里最容易踩的坑是:你只想让 Pi 帮你算一次东西,结果它在用户的 ~/.pi/agent/sessions/ 下留了一堆会话文件。开关只有一行:

earendil-works/pi@c13ffe1第 1567–1570 行在 GitHub 查看 ↗
内存会话:sessionDir 传空串、persist 传 false,其余与普通会话完全一样。
ts
// packages/coding-agent/src/core/session-manager.ts:1567-1570
/** Create an in-memory session (no file persistence) */
static inMemory(cwd: string = process.cwd(), options?: NewSessionOptions): SessionManager {
	return new SessionManager(cwd, "", undefined, false, options);
}

那个 false 是构造函数的 persist 参数。它最终只在一个地方被检查:

earendil-works/pi@c13ffe1第 1015–1016 行在 GitHub 查看 ↗
所有落盘动作的唯一守卫:persist 为 false 时直接 return,后面的 appendFileSync/openSync 全都不会执行。
ts
// packages/coding-agent/src/core/session-manager.ts:1015-1016
_persist(entry: SessionEntry): void {
	if (!this.persist || !this.sessionFile) return;
	// …(省略:真正的写文件逻辑)

这一行的意义是:内存会话不是「另一套简化实现」,而是同一套实现关掉了最后一步写文件。会话树、分支(branch)、上下文构造、压缩(Context Compaction)判定全都照常工作,你依然可以 getBranch()buildSessionContext(),只是没有文件。

⚠️ 常见误解以为不传 sessionManager 也不会写盘
默认路径 SessionManager.create(cwd, getDefaultSessionDir(cwd, agentDir)) 里的 getDefaultSessionDirmkdirSync 建目录(session-manager.ts:483-489),而 _persist 在出现第一条 assistant 消息后就会真的写文件。要「不留痕」,必须显式注入 SessionManager.inMemory();只把 agentDir 指到临时目录只能限制痕迹的位置,不能消除痕迹。

相关测试:packages/coding-agent/test/sdk-session-manager.test.ts 用两个用例把这对行为钉住——第 28 行 "uses agentDir for the default persisted session path" 验证默认落盘路径,第 49 行 "keeps an explicit sessionManager override" 验证注入的 SessionManager.inMemory(cwd) 会被原样保留。

会话替换:为什么还需要 AgentSessionRuntime

AgentSession 是「一次会话」的对象。但真实产品里用户会点「新建会话」「切换会话」「从某条消息分叉」。这些操作在 Pi 里不是 AgentSession 的方法,而是上面一层的 AgentSessionRuntime 的方法。

官方文档说明(来源:packages/coding-agent/docs/sdk.md:114):「Session replacement APIs such as new-session, resume, fork, and import live on AgentSessionRuntime, not on AgentSession.」同文件第 119 行进一步说明,这一层正是内建的 interactive / print / RPC 三种模式共用的那一层——6.10 已经在 main.ts:793-797 见过它。

earendil-works/pi@c13ffe1第 414–432 行在 GitHub 查看 ↗
运行时工厂:接收一个「重建函数」和初始的 cwd/会话目标,把首次创建的 session、services、diagnostics 一起包进 AgentSessionRuntime。此后的 newSession/fork/import 都复用同一个重建函数。

它带来一个必须记住的使用约定:runtime.session 会在替换后变成另一个对象。官方文档(sdk.md:161-167 的 “Important behavior” 列表)明确列出的后果是——事件订阅绑在具体的 AgentSession 上,替换后要重新 subscribe;用了扩展的话要重新 bindExtensions。这也是 6.10 里三种模式各自实现一个 rebindSession 回调的原因。

如果你的嵌入只需要「一问一答」,用 createAgentSession 就够了;只有当你要做一个带会话列表的界面时,才需要爬到 createAgentSessionRuntime 这一层。

进程内 SDK vs 子进程 RPC

两条嵌入路线的入口都在同一个包的同一份导出清单里(源码事实):进程内路线的 createAgentSessioncreateAgentSessionRuntime,以及更细粒度的 createAgentSessionServices / createAgentSessionFromServices,都在 packages/coding-agent/src/index.ts:195-221 这一个 export { ... } from "./core/sdk.ts" 块里;再往下 100 多行就是子进程路线:

earendil-works/pi@c13ffe1第 328–344 行在 GitHub 查看 ↗
子进程路线的导出:注释写明「Run modes for programmatic SDK usage」,RpcClient 与全套 Rpc 类型都在这里。同一个包,两条路。

RpcClient 做的事非常朴素——它就是 spawn 一个 pi 子进程:

earendil-works/pi@c13ffe1第 73–97 行在 GitHub 查看 ↗
官方 TypeScript 子进程客户端的启动:拼出 --mode rpc 参数,spawn node 执行 CLI,三个标准流全部走管道。
图加载中…

图 6.11-2 两条嵌入路线
阅读顺序:左右两块独立看。请关注两块的下半部分是一样的——不管走哪条路,最终跑的都是同一个 AgentSession 和同一个 Agent Loop。差别只在上半部分:路线一是函数调用与对象引用,路线二多了一层进程边界和 JSONL(每行一个 JSON)序列化。createAgentSession 对应 sdk.ts:169RpcClient 对应 rpc-client.ts:73,子进程里的 runRpcMode 对应 rpc-mode.ts:53(6.10 已详述)。

取舍对比(带 path:line 的格子是源码事实,其余是从源码结构与官方说明得出的分析):

维度进程内 SDK子进程 RPC
调用方语言必须是 Node/TypeScript任意语言,只要能读写 JSON 行
类型安全完整的 TS 类型靠协议文档,运行时才发现类型错误
访问粒度直接拿到 AgentSession 对象与全部 getter只能用 30 种 RpcCommandrpc-types.ts:20-73
自定义工具/扩展customTools 选项直接传函数只能靠子进程自己加载的扩展
故障隔离Agent 崩了你的进程也崩子进程崩溃可重启,主进程不受影响
资源开销无额外进程每个会话一个 Node 进程
事件传递session.subscribe 直接拿到对象事件被序列化成 JSON 行,函数/类实例会丢失

官方文档说明(packages/coding-agent/docs/sdk.md:1123-1133)给出的选型建议与上表一致:需要类型安全、同进程、直接访问 Agent 状态、想用代码定制工具与扩展时选 SDK;从其他语言集成、需要进程隔离、要做语言无关客户端时选 RPC。docs/rpc.md:5 也从 RPC 那一侧提醒 Node/TypeScript 用户「考虑直接用 AgentSession 而不是 spawn 子进程」。

实践任务:不用任何 Key,跑通一次真实的 Pi 对话

下面这个任务是本书的一个小高光时刻:不需要任何 API Key、不发一个网络包,但跑的是真的 Pi 核心——真的 createAgentSession、真的 AgentSession、真的 Agent Loop、真的工具执行。做法是往你注入的 ModelRuntime 里注册一个 pi-ai 自带的 faux(假)Provider——它把「模型回复」变成一段你自己写好的脚本。

earendil-works/pi@c13ffe1第 523–541 行在 GitHub 查看 ↗
假 Provider:auth 是一个永远成功的假 apiKey 解析器(第 527 行),响应用队列脚本化。它不在 builtinProviders 名单里,需要显式注册。
earendil-works/pi@c13ffe1第 541–548 行在 GitHub 查看 ↗
把一个现成的 Provider 对象塞进 ModelRuntime 的公开方法。faux Provider 正是通过它进入 pi 的模型体系。
🛠 实践任务零 API Key 嵌入:用 SDK 跑一次真实对话

目标:在 Pi 仓库之外写一个只读脚本,用 createAgentSession + faux Provider + SessionManager.inMemory() 完成一次真实对话,并确认整个过程既没有联网、也没有在磁盘上留下会话文件。

前提:本地有 Pi 源码仓库(下面记作 $PI,需要是绝对路径),并且已经在仓库根跑过 npm install(本任务只用到仓库自带的 node_modules/.bin/tsx)。不需要任何 API Key。

步骤 1:新建目录与脚本(脚本放在 Pi 仓库之外,全程不修改 Pi 仓库)。

mkdir -p /tmp/pi-sdk-demo
# 用编辑器把下面的代码存成 /tmp/pi-sdk-demo/demo.mts

扩展名必须是 .mts:目录里没有 package.json,tsx 会按 CommonJS 处理 .ts 文件,而本脚本用了顶层 await

text
// /tmp/pi-sdk-demo/demo.mts
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

// 脚本在仓库外,所以直接按绝对路径 import 源码文件
const PI = process.env.PI_SRC ?? "/path/to/pi";

const { fauxProvider, fauxAssistantMessage } = await import(`${PI}/packages/ai/src/providers/faux.ts`);
const { ModelRuntime } = await import(`${PI}/packages/coding-agent/src/core/model-runtime.ts`);
const { SessionManager } = await import(`${PI}/packages/coding-agent/src/core/session-manager.ts`);
const { createAgentSession } = await import(`${PI}/packages/coding-agent/src/core/sdk.ts`);

const sandbox = mkdtempSync(join(tmpdir(), "pi-sdk-demo-"));

// 1. 假 Provider:脚本化一条回复,永远不会发出真实 HTTP 请求
const faux = fauxProvider();
faux.setResponses([fauxAssistantMessage("Hello from the faux provider. No API key was used.")]);

// 2. ModelRuntime:不读默认 ~/.pi,不联网
const modelRuntime = await ModelRuntime.create({
  authPath: join(sandbox, "auth.json"),
  modelsPath: null,
  allowModelNetwork: false,
});
modelRuntime.registerNativeProvider(faux.provider);

// 3. 组装 AgentSession:内存 SessionManager + 关掉全部工具
const { session } = await createAgentSession({
  cwd: sandbox,
  agentDir: sandbox,
  modelRuntime,
  model: faux.getModel(),
  sessionManager: SessionManager.inMemory(sandbox),
  noTools: "all",
});

console.log("model      :", session.model?.provider + "/" + session.model?.id);
console.log("sessionFile:", session.sessionFile ?? "(in-memory, no file)");

// 4. 订阅事件并发一次 prompt
let deltas = 0;
let finalText = "";
session.subscribe((event: any) => {
  if (event.type === "message_update" && event.assistantMessageEvent?.type === "text_delta") deltas++;
  if (event.type === "message_end" && event.message?.role === "assistant") {
    for (const block of event.message.content ?? []) if (block.type === "text") finalText += block.text;
  }
});

await session.prompt("Say hello");

console.log("text_delta :", deltas, "个");
console.log("assistant  :", finalText);
console.log("fauxCalls  :", faux.state.callCount);
session.dispose();

步骤 2:运行(把三处 /abs/path/to/pi 都换成你的绝对路径;--tsconfig 不能省,仓库内部的 @earendil-works/pi-ai 等 import 靠它的 paths 映射到 src)。

env PI_SRC=/abs/path/to/pi \
  /abs/path/to/pi/node_modules/.bin/tsx \
  --tsconfig /abs/path/to/pi/tsconfig.json \
  /tmp/pi-sdk-demo/demo.mts

预期现象(本书在锁定版本上实测,真实输出):

model      : faux/faux-1
sessionFile: (in-memory, no file)
text_delta : 3 个
assistant  : Hello from the faux provider. No API key was used.
fauxCalls  : 1

text_delta 的个数每次运行会变(实测在 3~4 之间):faux 把文本按「3~5 个 token」的随机块切开,而它按 4 字符≈1 token 估算,所以 49 个字符会被切成 3~4 块(packages/ai/src/providers/faux.ts:253-263)。其余四行每次都完全一样。

如何判断成功:① assistant 那行是你在 setResponses 里写的原文——说明事件确实穿过了完整的 Agent Loop 而不是被你自己打印出来的;② sessionFile(in-memory, no file);③ 去沙盒目录看一眼 ls -a $(ls -d ${TMPDIR:-/tmp}/pi-sdk-demo-* | tail -1),里面只有一个 auth.json,没有 sessions/ 目录(对应正文说的 ?? 短路);④ 在 Pi 仓库里跑 git status --porcelain,输出为空。

加分项(本书同样实测跑通):让假模型真的调用一次工具。改四处——① 顶部补上 import { mkdtempSync, writeFileSync } from "node:fs";;② 从 faux.ts 的那行 await import 里多解构一个 fauxToolCall;③ 把 noTools: "all" 换成 tools: ["read"];④ 把写文件与响应队列换成下面这样:

writeFileSync(join(sandbox, "hello.txt"), "pi reads real files\n");
faux.setResponses([
  fauxAssistantMessage([fauxToolCall("read", { path: "hello.txt" })], { stopReason: "toolUse" }),
  fauxAssistantMessage("The file says: pi reads real files"),
]);

再把订阅回调改成打印工具调用与工具结果(在 message_end 里分别按 role === "assistant"toolCall 块和 role === "toolResult" 的消息打印)。本书实测的关键输出:

[toolCall] read {"path":"hello.txt"}
[toolResult] read -> "pi reads real files\n"
[text] The file says: pi reads real files
fauxCalls: 2

注意 fauxCalls: 2:一次 prompt 触发了两轮模型调用,中间那次真的读了磁盘上的文件——这是真实的 Agent Loop 回环,只是模型是假的。

常见错误:① 忘了设 PI_SRC,会报找不到 /path/to/pi/...;② 存成 .ts 而不是 .mts,esbuild 会报 Top-level await is currently not supported with the "cjs" output format;③ 漏了 --tsconfig,仓库内部的 @earendil-works/pi-ai 解析不到(该包的 exports 指向尚未构建的 dist/);④ 队列里的响应用完后再 prompt,会收到 errorMessage: "No more faux responses queued"(本书实测确认,源码见 faux.ts:453-464);⑤ 不传 agentDirModelRuntimeSettingsManager 会去读你真实的 ~/.pi/agent

对应源码位置packages/coding-agent/src/core/sdk.ts:169-185(组装)、:302-330(streamFn 出口)、packages/coding-agent/src/core/model-runtime.ts:541-548(registerNativeProvider)、packages/ai/src/providers/faux.ts:523-541(fauxProvider)、packages/coding-agent/src/core/session-manager.ts:1567-1570:1015-1016(inMemory 与落盘守卫)。

官方 sdk.md 对照

packages/coding-agent/docs/sdk.md 是 Pi 自带的 SDK 文档,共 1180 余行。本章核对的结论:

  • Quick Start(sdk.md:16-34)给的三行组装——ModelRuntime.create() + createAgentSession({ sessionManager: SessionManager.inMemory(), modelRuntime }) + session.subscribe 里过滤 text_delta——与 sdk.ts:169 的签名和本章实践任务的写法完全一致。
  • sdk.md:46-64createAgentSession() 的描述(默认使用 DefaultResourceLoader 做标准发现)与 sdk.ts:181-185 一致。
  • sdk.md:114:119:1123-1133 三处结论(会话替换在 runtime 层、runtime 层是三种内建模式共用的层、SDK 与 RPC 的选型)都能在源码里找到对应,前两条已在本章给出行号。
  • 一处文档漂移packages/coding-agent/examples/sdk/README.md:18 把第 8 个示例列为 08-slash-commands.ts,而目录里实际的文件名是 08-prompt-templates.ts。这类小漂移在 6.10 讲 docs/json.md 时也出现过——以源码为准是读 Pi 文档的默认姿势。

顺便一提,packages/coding-agent/examples/sdk/ 下有 13 个从最小到全控的可运行示例(01-minimal.ts13-session-runtime.ts),官方 README 给的跑法是在 packages/coding-agent 目录下 npx tsx examples/sdk/01-minimal.ts。注意它们需要真实模型才能跑出内容——本章的实践任务是这些示例的「零 Key 版」。

本章小结

  • SDK 的入口是 createAgentSession()sdk.ts:169)。它的本质是一次依赖注入式的组装:ModelRuntimeSettingsManagerSessionManagerResourceLoader 四个组件都遵循「你不给我就我自己造」。
  • streamFnsdk.ts:302-330)是 Agent 通向外界的唯一出口,它把 SettingsManager 的超时/重试设置与扩展的 header 钩子缝进了每一次请求。换掉 ModelRuntime 就能换掉整条模型链路。
  • SessionManager.inMemory()session-manager.ts:1567-1570)关掉的只是 _persist 里的最后一步写盘(:1015-1016);同时,注入 sessionManager 会让 ?? 短路,连会话目录都不会被创建。
  • 会话替换(新建/切换/分叉/导入)在 AgentSessionRuntime 而不是 AgentSession 上;替换后 runtime.session 会换对象,事件订阅与扩展绑定都要重做。
  • 两条嵌入路线共享同一套核心:进程内用 createAgentSession,跨语言/需隔离用 RpcClient spawn 子进程(rpc-client.ts:73-97),两者的导出都在 packages/coding-agent/src/index.ts
  • 关键术语:SDK(软件开发工具包)依赖注入(Dependency Injection)内存会话(in-memory session)faux Provider(假 Provider)会话替换(session replacement)
  • 关键源码索引:packages/coding-agent/src/core/sdk.ts:169(组装)/:302-330(streamFn)/:376-398(收尾);packages/coding-agent/src/core/session-manager.ts:1567(inMemory)/:1015(落盘守卫);packages/coding-agent/src/core/model-runtime.ts:541(registerNativeProvider);packages/coding-agent/src/core/agent-session-runtime.ts:414(runtime 工厂);packages/coding-agent/src/modes/rpc/rpc-client.ts:73(子进程客户端);packages/ai/src/providers/faux.ts:523(faux Provider);测试 packages/coding-agent/test/sdk-session-manager.test.ts:28,49packages/coding-agent/test/sdk-stream-options.test.ts:132
  • 自测问题:① 你只传了 agentDir(临时目录)而没传 sessionManager,磁盘上会发生什么?为什么?② 想让嵌入的 Pi 只能读文件、绝不能写文件和执行命令,CreateAgentSessionOptions 里有哪两个字段可以做到,各自的语义差别是什么?③ 你的产品要做一个「会话列表 + 点开继续聊」的界面,应该用 createAgentSession 还是 createAgentSessionRuntime?替换会话后有哪两件事必须重做?
  • 下一章:第六部分到此结束,接下来进入 7.1 Extension 系统——本章反复出现的 extensionRunnerRefbefore_provider_headersextensionsResult 会在那里展开。
  • 尚未展开的内容createAgentSessionServices / createAgentSessionFromServices 这对更细粒度的组装函数(sdk.tsagent-session-runtime.ts 再导出)、scopedModels 的模型轮换语义、sessionStartEvent 的扩展启动协议,以及 packages/server 这个 experimental(实验性)包如何用 Unix domain socket 把 RPC 协议再包一层(6.10 已提及其定位)。

本书分析的 Pi 版本:earendil-works/pi@c13ffe1(2026-07-30)