第 8 讲pi-coding-agent 架构:AgentSession 与核心模块
从内核上升到产品层:看清 coding-agent 包如何把 Agent Loop 包装成一个可日常使用的编码代理——会话编排、模型解析、设置合并、事件桥接与信任门槛,全部收进一条可跟踪的调用链。
- 画出
coding-agent的目录地图:core / modes / extensions / cli四个目录各自的职责与依赖方向。 - 说清
agent-session.ts、agent-session-runtime.ts、agent-session-services.ts三件套的分工:谁管会话状态、谁管运行循环、谁管服务依赖注入。 - 理解
event-bus.ts的角色与AgentEvent → 扩展事件的桥接机制。 - 跟踪模型解析链:从
settings中的字符串到一次可用的(model, streamFn)调用。 - 掌握设置体系的三层优先级与深度合并规则,以及
trust → 项目资源 → context files的启动顺序。 - 独立跟踪一条用户输入从 TUI 编辑器到模型再回流的完整调用链。
§8.1coding-agent 目录地图:四个目录的分工
前两讲我们待在 pi-agent-core 里研究 Agent Loop 和工具系统。本讲进入 packages/coding-agent——这是 Pi 的产品层,也就是你敲 pi 命令时真正运行的包。用一句话类比:如果说 pi-agent-core 是发动机,coding-agent 就是整辆车——加上方向盘(TUI)、仪表盘(设置与状态)、油路(模型解析)和行车记录仪(诊断日志)。
先建立目录地图。下面这棵树按「读代码时的进入顺序」排列:cli/ 解析命令行并装配服务,core/ 承载所有模式共享的运行时,extensions/ 是随包发布的内置扩展,modes/ 是四种 I/O 外壳。
packages/coding-agent/src/ ├── cli/ ← 进程入口的薄层:解析参数、装配服务、启动模式 │ ├── args.ts 命令行参数与 --models/--tools/--approve 解析 │ ├── startup-ui.ts 启动横幅、首启向导 │ ├── auth-login.ts 等 /login、/logout 认证流程 │ └── file-processor.ts @file 引用展开为消息内容 │ ├── core/ ← 所有运行模式共享的核心(本讲主战场) │ ├── agent-session.ts AgentSession:会话编排(4565 行) │ ├── agent-session-runtime.ts 会话替换与生命周期:new/resume/fork/import │ ├── agent-session-services.ts cwd 绑定服务的创建与依赖注入 │ ├── settings-manager.ts 等 设置:schema / manager / defaults / resolve │ ├── model-runtime.ts 等 模型:registry / resolver / runtime / store / composer │ ├── project-trust.ts 等 信任:trust-manager + project-trust │ ├── bash-executor.ts / exec.ts / http-dispatcher.ts 基础设施 │ ├── crash-log.ts / diagnostics.ts / bug-report.ts 可观测性 │ ├── session-manager.ts JSONL 会话树读写(第 10 讲精读) │ ├── system-prompt.ts / compaction/ 提示词组装与压缩(第 9 讲精读) │ ├── extensions/ 扩展运行时:runner / types / API(第 11 讲) │ └── tools/ 内置工具 read/bash/edit/write…(第 10 讲) │ ├── extensions/ ← 随包发布的内置扩展(与第三方扩展同机制) │ ├── codemode/ 模型生成 TS 脚本批量调用工具(第 13 讲) │ ├── llama/ 本地 llama.cpp provider 接入 │ ├── mcp/ MCP 客户端与 mcp__server__tool 命名空间(第 13 讲) │ └── tool-search/ 工具检索,配合 codemode 延迟暴露工具 │ └── modes/ ← 四种 I/O 外壳,复用同一个 AgentSession ├── interactive/ TUI:interactive-mode.ts + 组件 + 主题(第 13 讲) ├── rpc/ stdin JSONL 命令 / stdout 事件流 ├── print-mode.ts pi -p 一次性执行,text 输出 └── json-event.ts --mode json 的事件序列化
依赖方向是单向的:modes/ 与 cli/ 依赖 core/,core/ 只向下依赖 pi-agent-core 与 pi-ai;extensions/ 里的四个目录虽然物理上在包内,但加载后与第三方扩展地位完全相同——通过 builtin:mcp、builtin:codemode 等名字在设置的 extensions 数组中启用或禁用。这个设计的价值在第 11 讲会看得更清楚:官方没有为内置扩展开后门。
Java 视角:core/ 就是你的 Service 层,modes/ 是 Controller 层
把 modes/interactive 想象成一个只做 I/O 转换的 Controller(把键盘事件转成方法调用、把事件流转成终端渲染),core/ 是不含表现逻辑的 Service + 基础设施层。interactive-mode.ts 有 26 万字节,但其中几乎找不到一行「模型请求」逻辑——它只调用 session.prompt()。这与 Spring MVC 中 Controller 不直接拼 SQL 是同一条纪律:分层让 print / json / rpc 三种无 TUI 模式可以复用全部会话逻辑。
§8.2agent-session 三件套:状态、循环、依赖注入
agent-session.ts 单文件 4565 行,是整个 coding-agent 的心脏。但要理解它,正确的入口不是逐行硬读,而是先看三个文件的分工——Pi 作者刻意把「会话状态」「会话替换循环」「服务装配」拆成了三层。
第一层:AgentSession——所有模式共享的会话门面
文件头注释直接说明了定位:
/**
* AgentSession - Core abstraction for agent lifecycle and session management.
*
* This class is shared between all run modes (interactive, print, rpc).
* It encapsulates:
* - Agent state access
* - Event subscription with automatic session persistence
* - Model and thinking level management
* - Compaction (manual and auto)
* - Bash execution
* - Session switching and branching
*
* Modes use this class and add their own I/O layer on top.
*/
关键信息是最后一句:Modes use this class and add their own I/O layer on top(各模式使用这个类,并在其上叠加自己的 I/O 层)。AgentSession 包着第 6 讲的 Agent 实例(config.agent),对外提供 prompt() / steer() / followUp() / compact() / setModel() / cycleModel() / navigateTree() / executeBash() 等方法,并负责把 AgentEvent 持久化进 SessionManager 的 JSONL 会话树。构造函数最能说明它的装配逻辑:
export class AgentSession {
// ……大量私有状态:队列、重试、工具注册表、扩展绑定……
constructor(config: AgentSessionConfig) {
this.agent = config.agent;
this.sessionManager = config.sessionManager;
this.settingsManager = config.settingsManager;
this._resourceLoader = config.resourceLoader;
this._cwd = config.cwd;
this._modelRuntime = config.modelRuntime;
this._cacheWarmer = config.cacheWarmer;
// ……工具白名单 / 黑名单 / 扩展引用等初始化……
// Always subscribe to agent events for internal handling
// (session persistence, extensions, auto-compaction, retry logic)
this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent);
this._installAgentToolHooks(); // 工具调用前后的扩展钩子
this._installAgentNextTurnRefresh();
this._installAgentRequestProjection();
this._installAgentBoundaryHooks(); // turn_end / before_settle 边界
this._installHiddenDeclarationsProjection();
this._installAgentForcedPromptProjection();
this._buildRuntime({
activeToolNames: this._initialActiveToolNames,
includeAllExtensionTools: true,
});
}
}
注意构造器里那一连串 _installAgent*:它们都是在给传入的 Agent 实例「加挂钩」——Agent 核心只暴露订阅点,coding-agent 把持久化、扩展桥接、自动压缩、自动重试全部挂进去。这就是为什么 pi-agent-core 能保持与产品无关。
第二层:AgentSessionRuntime——会话替换的编排者
当用户在 TUI 里执行 /new、/resume、/fork 或导入 JSONL 时,替换的不是 AgentSession 内部状态,而是整个会话及其绑定的服务。agent-session-runtime.ts 的类注释写得很清楚:
/**
* Owns the current AgentSession plus its cwd-bound services.
*
* Session replacement methods tear down the current runtime first, then create
* and apply the next runtime. If creation fails, the error is propagated to the
* caller. The caller is responsible for user-facing error handling.
*/
export class AgentSessionRuntime {
private rebindSession?: (session: AgentSession) => Promise<void>;
private beforeSessionInvalidate?: () => void;
private _session: AgentSession;
private _services: AgentSessionServices;
private readonly createRuntime: CreateAgentSessionRuntimeFactory;
// ……
}
export type CreateAgentSessionRuntimeFactory = (options: {
cwd: string;
agentDir: string;
sessionManager: SessionManager;
sessionStartEvent?: SessionStartEvent;
projectTrustContext?: ProjectTrustContext;
}) => Promise<CreateAgentSessionRuntimeResult>;
它保存一个 createRuntime 工厂闭包,switchSession / newSession / fork / importFromJsonl 都走同一个模式:先发出 session_before_switch 扩展事件(允许取消)→ teardownCurrent(发出 session_shutdown)→ createRuntime(...) 重建 → finishSessionReplacement 通知 UI 重新绑定。退出时 dispose() 会依次 abort、发 session_shutdown、session.dispose(),最后 await services.modelRuntime.settleCredentialWrites()——等 OAuth 凭据写入落盘再退出,防止刷新令牌丢失(源码注释 #11692)。
第三层:agent-session-services——cwd 绑定的依赖注入
为什么替换会话要连服务一起替换?因为模型目录、设置、资源加载器都是按工作目录绑定的。agent-session-services.ts 的 createAgentSessionServices() 是一条清晰的装配流水线:
export interface AgentSessionServices {
cwd: string;
agentDir: string;
modelRuntime: ModelRuntime;
settingsManager: SettingsManager;
resourceLoader: ResourceLoader;
diagnostics: AgentSessionRuntimeDiagnostic[];
}
export async function createAgentSessionServices(
options: CreateAgentSessionServicesOptions,
): Promise<AgentSessionServices> {
const cwd = resolvePath(options.cwd);
const agentDir = options.agentDir ? resolvePath(options.agentDir) : getAgentDir();
const modelRuntime =
options.modelRuntime ??
(await ModelRuntime.create({
authPath: join(agentDir, "auth.json"),
modelsPath: join(agentDir, "models.json"),
signal: options.modelRuntimeSignal,
}));
const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir);
const resourceLoader = new DefaultResourceLoader({ cwd, agentDir, settingsManager });
await resourceLoader.reload(options.resourceLoaderReloadOptions);
const diagnostics: AgentSessionRuntimeDiagnostic[] = [];
const extensionsResult = resourceLoader.getExtensions();
for (const { name, config, extensionPath } of extensionsResult.runtime.pendingProviderRegistrations) {
try { modelRuntime.registerProvider(name, config); }
catch (error) { /* 记入 diagnostics 而非直接退出 */ }
}
// ……native provider、virtual model 注册同理……
await modelRuntime.refresh({ allowNetwork: false });
diagnostics.push(...applyExtensionFlagValues(resourceLoader, options.extensionFlagValues));
return { cwd, agentDir, modelRuntime, settingsManager, resourceLoader, diagnostics };
}
两个细节值得注意:第一,函数返回的 diagnostics 是「非致命问题」列表——装配失败不直接 process.exit,而是把 info/warning/error 交给上层决定是否展示或中止,这让 SDK 嵌入方可以自己处理错误。第二,服务创建与 AgentSession 创建是分开的(createAgentSessionFromServices),因为模型、思考级别、工具列表等会话选项需要先对着这些服务解析,再去构造会话。
Java 视角:三件套就是容器、上下文与 Bean 装配的分离
AgentSessionServices ≈ 一个按请求(这里是按 cwd)构建的 ApplicationContext:工厂方法里 new 出一整套协作对象(ModelRuntime、SettingsManager、ResourceLoader)并互相注入。AgentSession ≈ 有状态的业务门面(Facade),持有 Agent 这个引擎并挂上持久化与扩展钩子。AgentSessionRuntime ≈ 负责上下文刷新与销毁的生命周期容器——Spring 里 refresh()/close() 的角色。拆成三个文件的理由与 Spring 一样:生命周期管理不应混进门面业务。
§8.3event-bus 与扩展事件桥接
先澄清一个容易混淆的点:core/event-bus.ts 不是 agent 事件的主通道。Agent 事件(AgentEvent)走的是 agent.subscribe() 订阅(第 6 讲);event-bus.ts 是给扩展系统用的小型发布/订阅底座。它非常短,全文只有 37 行:
import { EventEmitter } from "node:events";
export interface EventBus {
emit(channel: string, data: unknown): void;
on(channel: string, handler: (data: unknown) => void): () => void;
}
export interface EventBusController extends EventBus {
clear(): void;
}
export function createEventBus(): EventBusController {
const emitter = new EventEmitter();
return {
emit: (channel, data) => { emitter.emit(channel, data); },
on: (channel, handler) => {
const safeHandler = async (data: unknown) => {
try { await handler(data); }
catch (err) { console.error(`Event handler error (${channel}):`, err); }
};
emitter.on(channel, safeHandler);
return () => emitter.off(channel, safeHandler);
},
clear: () => { emitter.removeAllListeners(); },
};
}
三个设计要点:其一,on() 返回退订函数,扩展卸载(/reload)时统一调用,避免泄漏;其二,handler 被包成 async 且 try/catch——一个扩展抛错只打印日志,绝不打断其他订阅者(错误隔离);其三,字符串 channel 而非强类型事件,扩展之间可以自定义频道。这就是扩展 API 上 pi.on("event_name", handler) 与跨扩展通信的底座。
真正的「桥」在 AgentSession._emitExtensionEvent():它把内核的 AgentEvent 翻译成扩展系统的事件类型,并补上扩展特有字段(如 turnIndex、durationMs)。节选关键分支:
/** Emit extension events based on agent events */
private async function _emitExtensionEvent(event: AgentEvent): Promise<void> {
if (event.type === "agent_start") {
this._turnIndex = 0;
await this._extensionRunner.emit({ type: "agent_start" });
} else if (event.type === "turn_start") {
const extensionEvent: TurnStartEvent = {
type: "turn_start",
turnIndex: this._turnIndex, // 内核没有的字段,桥接层补上
timestamp: Date.now(),
};
await this._extensionRunner.emit(extensionEvent);
} else if (event.type === "message_end") {
const extensionEvent: MessageEndEvent = { type: "message_end", message: event.message };
const replacement = await this._extensionRunner.emitMessageEnd(extensionEvent);
if (replacement) { // 扩展可以请求替换消息
/* 规范化后就地替换 agent state 与会话历史中的消息 */
this._replaceMessageInPlace(event.message, normalized);
}
} else if (event.type === "tool_execution_end") {
const extensionEvent: ToolExecutionEndEvent = {
type: "tool_execution_end",
toolCallId: event.toolCallId, toolName: event.toolName,
args: event.args, result: event.result, isError: event.isError,
...(event.durationMs === undefined ? {} : { durationMs: event.durationMs }),
};
await this._extensionRunner.emit(extensionEvent);
}
// ……message_start / message_update / tool_execution_start / update / turn_end 同理
}
桥接的完整顺序在 _handleAgentEvent 中固定为:先扩展、后公共监听器、最后持久化:
private _handleAgentEvent = async (event: AgentEvent): Promise<void> => {
// 嵌套工具调用(ctx.executeTool)的记录与 usage 归并……
// steering / followUp 队列消息出队……
// Emit to extensions first, then notify public listeners.
await this._emitExtensionEvent(event);
this._emit(event.type === "agent_end"
? { ...event, willRetry: this._willRetryAfterAgentEnd(event) } : event);
// Handle session persistence
if (event.type === "message_end") {
// system / user / assistant / toolResult → SessionMessageEntry
// custom → CustomMessageEntry
entryId = this.sessionManager.appendMessage(event.message);
if (entryId) this._entryIdsByMessage.set(event.message, entryId);
}
if (event.type === "turn_end") {
this._flushPendingCustomMessages(); // 避开 toolCall/toolResult 中缝
}
};
顺序有讲究:扩展先于 UI 监听器收到事件,扩展对消息的替换(message_end 的 replacement)在 UI 渲染前生效;持久化放在最后,保证写进 JSONL 的是扩展处理后的最终形态。另外 AgentSessionEvent 还在 AgentEvent 之上扩展了自己的会话级事件类型:agent_settled、queue_update、compaction_start/end、entry_appended、auto_retry_start/end、bash_execution_update 等——TUI 的状态栏、队列显示、压缩提示都靠它们驱动。
Java 视角:EventBus ≈ ApplicationEventPublisher,桥接层 ≈ 监听器适配器
createEventBus() 就是简化版 Spring ApplicationEventMulticaster:同步分发、错误隔离、可退订。_emitExtensionEvent 则像一个 ApplicationListener 适配器——把底层事件(AgentEvent)翻译成业务事件(扩展事件)并补充上下文字段,类似你在 Spring 里把 ServletContextEvent 转成自定义 OrderCreatedEvent 再发布的写法。「先扩展后持久化」的顺序等价于 @Order 控制监听器执行顺序,保证审计日志记录的是变换后的最终状态。
§8.4模型解析链:从设置字符串到可用的 (model, streamFn)
「用户在设置里写了 defaultModel,或者在命令行敲了 --models anthropic/claude-*,Pi 如何把它变成一次真正能发的 HTTP 请求?」这是本讲最值得画图的问题。答案是一条六段流水线,涉及六个文件:
- settingsdefaultProvider / defaultModel / enabledModels 字符串
- model-resolverresolveModelReference:文本 → Model 对象,支持 provider/id:thinking 与模糊匹配
- model-runtimeModelRuntime.create:装载目录 + 刷新可用性快照
- models-storeFileModelsStore:models-store.json 锁定 JSON 缓存
- provider-composercomposeModelProvider:builtin + models.json overlay + 扩展 overlay 三层合成
- preferred-providersPREFERRED_PROVIDER_IDS 排序展示
ModelRuntime:目录、凭据与快照的持有者
ModelRuntime 实现了 pi-ai 的 Models 接口,是 coding-agent 对模型世界的唯一权威视图。它的静态工厂 create() 展示了完整装配:
static async create(options: CreateModelRuntimeOptions = {}): Promise<ModelRuntime> {
const credentials = new RuntimeCredentials(options.credentials ?? DefaultAuthStorage.create(options.authPath));
const modelsPath = options.modelsPath ?? join(getAgentDir(), "models.json");
const config = await ModelConfig.load(modelsPath);
const modelsStore = options.modelsStore ??
(modelsPath ? new FileModelsStore(options.modelsStorePath
?? join(dirname(modelsPath), "models-store.json"))
: new InMemoryCodingAgentModelsStore());
const providers = builtinProviderCatalog.builtinProviders().map((provider) => {
// OpenRouter 按用户 key 个性化目录;其余走远程目录缓存
return withRemoteCatalog(provider, options.catalogBaseUrl, builtinModelDataGeneratedAt);
});
const runtime = new ModelRuntime(credentials, config, modelsPath, modelsStore, providers, process.env.PI_OFFLINE === undefined);
runtime.rebuildProviders();
if (options.refreshOnCreate !== false) {
await runtime.refresh({ allowNetwork: refreshFromNetwork, signal });
}
return runtime;
}
FileModelsStore(models-store.ts)是「动态刷新的 provider 目录缓存」:内置 provider 的模型清单会随上游更新,Pi 把它们按 providerId 写入 ~/.pi/agent/models-store.json,用 proper-lockfile 式的 AuthStorageBackend.withLockAsync 做跨进程锁定写,用文件 revision 做读缓存去重。这层缓存让第二次启动无需全量拉取目录。
provider-composer:三层叠加出一个 Provider
每个 provider 的最终形态由三层合成(见 composeProvider):内置基础(builtin)→ models.json 覆盖(用户配置 baseUrl / headers / 自定义模型)→ 扩展覆盖(扩展注册的 oauth / streamSimple / 模型定义)。关键函数是 composeModelProvider(providerId, base, config, extension):
/** The provider without virtual models, or undefined when nothing defines it. */
private composeProvider(providerId: string): Provider | undefined {
const base = this.nativeExtensionProviders.get(providerId) ?? this.builtins.get(providerId);
const extension = this.extensionProviders.get(providerId);
if (!this.config.getProvider(providerId) && !extension) {
// No overlays: use the builtin untouched so its auth/login/stream behavior is exact.
return base;
}
try {
const provider = composeModelProvider(providerId, base, this.config, extension);
return provider;
} catch (error) {
this.compositionErrors.set(providerId, /* 错误信息 */ String(error));
return base; // 合成失败退回内置,不炸启动
}
}
export function composeModelProvider(providerId, base, config, extension) {
// 1. 校验扩展自定义 streamSimple 必须带 api 适配
// 2. applyModelsJson:把 models.json 里的 baseUrl/headers/models 覆盖到 base
// 3. applyExtension:oauth / headers / fetchDeferred / classify 等能力叠加
// 4. 校验:no authentication method configured 时抛错
}
model-resolver:字符串 → Model 对象的规则引擎
model-resolver.ts 解决「用户输入的模型字符串如何落到具体 Model」。核心 API 是 resolveModelReference(request, models, usable),返回三态结果:resolved(唯一匹配)、ambiguous(多个候选,报告而不是猜)、not-found。它处理这些细节:
provider/id前缀拆分——provider id 本身可能含/(URL 派生 id),所以按已知 id 最长前缀优先匹配(splitProviderPrefix);:thinking后缀——OpenRouter 的qwen/qwen3-coder:exacto冒号属于 id 本身,所以只有当完整文本匹配不到模型时才尝试把合法思考级别(off/minimal/low/medium/high/xhigh/max)解析出来,且由thinkingSuffix: "none" | "strict" | "lenient"控制策略;- 候选先于 provider 收集——「目录顺序不决定 provider,歧义时报告而不是落到更弱的匹配」(文件内注释原文);
usable(有无凭据)只用于打破平局,所以--api-key首次设置时无凭据的唯一匹配也能解析成功。
同文件还有 resolveModelScopeFromModels(把 --models anthropic/claude-* 这类 glob 展开成可循环的模型列表,供 Ctrl+P 循环切换)、resolveCliModel 与 findInitialModel(启动时的初始模型决策:优先恢复会话里记录的模型 → 其次 settings 的 defaultProvider/defaultModel → 再退到 defaultModelPerProvider 表中该 provider 的默认模型)。那张默认表节选:
/** Default chat model IDs for providers with built-in chat models. */
export const defaultModelPerProvider: Partial<Record<KnownProvider, string>> = {
anthropic: "claude-opus-5-5",
openai: "gpt-6.1-sol",
google: "gemini-4-argon",
openrouter: "moonshotai/kimi-k2.7-code",
zai: "glm-5.3",
// ……
};
ModelRegistry 与 preferred-providers
ModelRegistry 是暴露给扩展的同步兼容门面——类注释原话是「Synchronous compatibility facade exposed to extensions. Coding-agent internals use ModelRuntime directly.」每个方法都一行转发到 ModelRuntime(如 find() → runtime.getModel(),stream() → runtime.stream())。之所以保留这层,是因为扩展 API 需要一个稳定、同步、无需感知 runtime 生命周期的模型访问入口。
preferred-providers.ts 只做展示排序:PREFERRED_PROVIDER_IDS = [RADIUS_PROVIDER_ID, "openrouter"],compareProviders() 让合作 provider 在列表中置顶(保持声明顺序),其余按名称字母序。它不参与解析决策,只是 UI 列表的排序键——小文件,但回答了「为什么模型选择器里 Radius 和 OpenRouter 总在最上面」。
终点:streamFn 如何闭包在 Agent 上
解析链的终点在 sdk.ts 的 createAgentSession():new Agent({...}) 时传入 streamFn,它把模型调用代理到 modelRuntime.streamSimple,并在请求选项里合并 provider 重试、超时、客户端身份与扩展的 header 钩子:
const agent = new Agent({
initialState: {
systemPrompt: "", model, thinkingLevel, tools: [],
messages: existingSession.messages,
},
convertToLlm: convertToLlmWithBlockImages,
streamFn: async (model, context, options) => {
const requestOptions = buildRequestOptions(options);
if (options?.sessionId === sessionManager.getSessionId()) {
cacheWarmer.start({ model, context, options: requestOptions }, cacheContextIsCurrent(model));
}
return modelRuntime.streamSimple(model, context, requestOptions);
},
onPayload: transformProviderPayload, // before_provider_request
onResponse: handleProviderResponse, // after_provider_response
onProviderStreamEvent: handleProviderStreamEvent,
sessionId: sessionManager.getSessionId(),
transformContext: async (messages) => { // 扩展的上下文变换钩子
const runner = extensionRunnerRef.current;
if (!runner) return messages;
return runner.emitContext(messages);
},
// steeringMode / followUpMode / transport / thinkingBudgets 等均来自 SettingsManager
});
// This stream resolves auth through modelRuntime, so summaries must not pre-resolve it (#11520).
markRuntimeStreamFunction(agent.streamFunction, modelRuntime);
至此链条闭合:settings 字符串 → resolver 变成 Model 对象 → runtime 维护目录与凭据 → composer 合成最终 provider → streamFn 闭包把它交给 Agent 内核。每次模型请求实际走 streamFn → modelRuntime.streamSimple → 合成后的 provider → undici 全局 dispatcher 发 HTTP。
§8.5设置体系:schema、manager 与优先级合并
设置的四个文件各司其职:settings-schema.ts 定义 Settings 类型与校验(对应编辑器里的 https://pi.dev/schemas/settings.schema.json);settings-defaults.ts 提供内置默认值(SETTINGS_DEFAULTS);settings-manager.ts 负责读写、迁移、合并与锁;resolve-config-value.ts 让 auth.json / models.json 里的值可以写成 shell 命令或环境变量模板(如 ${MY_KEY}、$(pass show api)),并有进程级缓存。
两层文件 + 两级覆盖
合并的核心是 deepMergeSettings(base, overrides)——嵌套对象递归合并、标量整体覆盖、项目层优先;唯一的特殊规则是 defaultTools:
/**
* Merge `defaultTools` of two settings layers. A list with plain tool names replaces
* the inherited one; a list of only `+name`/`-name` entries is appended, so it
* modifies the inherited selection.
*/
function mergeDefaultTools(base, overrides) {
if (overrides === undefined) return base;
if (!Array.isArray(base) || !Array.isArray(overrides) || !overrides.every(isToolModifier)) return overrides;
return [...base, ...overrides];
}
/** Resolve a merged `defaultTools` list: plain names replace `DEFAULT_TOOL_NAMES`,
* then `+name` adds and `-name` removes a tool, in list order. */
function resolveDefaultTools(entries: string[]): string[] { /* …… */ }
/** Deep merge settings: project/overrides take precedence, nested objects merge recursively */
function deepMergeSettings(base: Settings, overrides: Settings): Settings {
const merged = deepMergeObjects(base, overrides);
const defaultTools = mergeDefaultTools(base.defaultTools, overrides.defaultTools);
return defaultTools === undefined ? merged : { ...merged, defaultTools };
}
规则读起来是:项目设置里写 ["+codemode"] 表示「在全局选择上加一个工具」;写 ["read","edit"] 表示「整个替换」。默认工具列表是 DEFAULT_TOOL_NAMES = ["read", "bash", "edit", "write"]。
另一个安全关键点:项目设置只有在项目被信任后才加载,并且有三个字段被强制限制只能在全局设置(defaultProjectTrust、httpProxy、selfUpdateCommand)——否则一个恶意仓库就能把代理指到别处或改写更新命令。加载逻辑:
private static function loadFromStorage(storage, scope: SettingsScope, projectTrusted = true): Settings {
if (scope === "project" && !projectTrusted) {
return {}; // 未信任:项目设置整体不读
}
// 读文件 → stripBom → JSON.parse → migrateSettings 迁移旧字段
}
写入侧同样讲究:FileSettingsStorage.withLock() 用 proper-lockfile 加锁(重试 10 次、每次 20ms),写操作按 scope 排队(writeQueue),并跟踪「本次会话修改过哪些字段」——保存时只写被修改的 scope,避免全局文件被项目操作意外重写。加载错误不会崩启动,而是收进 SettingsError[],由 drainErrors() 交给 UI 展示。字段迁移示例:queueMode → steeringMode(migrateSettings),保证旧配置文件平滑升级。
Java 视角:这就是 Spring Boot 的 PropertySource 链
deepMergeSettings(global, project) 的语义与 Spring Boot 的 PropertySource 优先级完全同构:内置默认 ≈ application.yml 里的默认,全局 ≈ 外部化配置,项目 ≈ profile 覆盖,CLI 参数 ≈ 命令行 PropertySource。applyOverrides() 则相当于 MapPropertySource 追加到链首。特殊规则 defaultTools 的「+/- 修饰符追加、纯名单替换」类似日志级别里 logging.level.root=WARN 覆盖而 logging.level.com.foo=DEBUG 精确叠加的混合语义——数组合并永远是配置系统里最容易踩坑的一格。
§8.6信任与启动顺序:trust → 项目资源 → context files
Pi 默认以启动用户的完整权限运行(第 14 讲详述)。因此在你 cd 进一个陌生仓库并启动 pi 时,有一道关键闸门:项目信任(project trust)。它控制「这个目录下的 .pi/ 配置与资源是否加载」。官方文档 how-pi-works.md 用一句话给出启动顺序:
Pi resolves project trust before loading project settings and resources. After the trust decision and project-resource loading, Pi loads context files.
- resolveProjectTrustedCLI override → 扩展 project_trust 事件 → trust.json 已存决策 → defaultProjectTrust → UI 询问
- 项目资源加载.pi/settings.json、mcp.json、extensions/、skills/、prompts/、themes/、SYSTEM.md
- context filesAGENTS.md / CLAUDE.md 等 · 无需信任
什么触发信任询问
trust-manager.ts 用一张白名单定义「需要信任门槛的项目资源」:
const TRUST_REQUIRING_PROJECT_CONFIG_RESOURCES = [
"settings.json",
"mcp.json",
"extensions",
"skills",
"prompts",
"themes",
"SYSTEM.md",
"APPEND_SYSTEM.md",
] as const;
/**
* Returns true when cwd has project-local resources that must be gated by
* project trust: trust-requiring entries under cwd/.pi, or .agents/skills in
* cwd or one of its ancestors.
*/
export function hasTrustRequiringProjectResources(cwd: string): boolean {
const configDir = join(canonicalizePath(resolvePath(cwd)), ".pi");
if (TRUST_REQUIRING_PROJECT_CONFIG_RESOURCES.some((entry) => existsSync(join(configDir, entry)))) {
return true;
}
// 向上遍历祖先目录检查 .agents/skills(排除用户主目录下的 ~/.agents/skills)
}
注意一个细节:一个空的 .pi/ 目录不会触发信任询问——只有里面出现了上述八种条目之一(或祖先目录有 .agents/skills)才会。已保存的决策写在 ~/.pi/agent/trust.json,按规范化路径存储,查找时从当前目录向上找最近祖先的决策(findNearestTrustEntry),所以「信任父目录」可以覆盖一整棵工作区。
决策链:resolveProjectTrusted 的五级瀑布
export async function resolveProjectTrusted(options: ResolveProjectTrustedOptions): Promise<boolean> {
// 1. 命令行 --approve / --no-approve 直接裁决
if (options.trustOverride !== undefined) return options.trustOverride;
// 没有需要门控的资源 → 读已存决策,缺省 true
if (!hasTrustRequiringProjectResources(options.cwd)) {
return options.trustStore.get(options.cwd) ?? true;
}
// 2. 用户级扩展可接管 project_trust 事件,第一个返回者拥有决策
if (options.extensionsResult) {
const { result, errors } = await emitProjectTrustEvent(options.extensionsResult,
{ type: "project_trust", cwd: options.cwd }, options.projectTrustContext);
if (result) {
const trusted = result.trusted === "yes";
if (result.remember === true) options.trustStore.set(options.cwd, trusted);
return trusted;
}
}
// 3. 已保存决策(最近祖先)
const decision = options.trustStore.get(options.cwd);
if (decision !== null) return decision;
// 4. defaultProjectTrust 设置:"always" | "never" | "ask"
switch (options.defaultProjectTrust ?? SETTINGS_DEFAULTS.defaultProjectTrust) {
case "always": return true;
case "never": return false;
}
// 5. 无 UI(print/json/rpc)直接拒绝;有 UI 弹选择框
if (!options.projectTrustContext.hasUI) return false;
const selected = await selectProjectTrustOption(options.cwd, options.projectTrustContext);
if (selected !== undefined) {
saveProjectTrustPromptResult(options.trustStore, selected);
return selected.trusted;
}
return false;
}
交互式提示的选项由 getProjectTrustOptions() 生成:Trust(记住本目录)、Trust parent folder(记住父目录,并清掉本目录记录)、Trust (this session only)(不落盘)、以及对应的 Do not trust 版本。无 UI 模式(print/json/rpc)在第 5 级直接返回 false——这正是自动化脚本需要显式 --approve 的原因。
两个容易被忽略的例外
sessionDir在信任前读取:Pi 要先定位会话目录才能创建/恢复会话,而项目设置里的sessionDir可能改变这个位置。官方 security 文档明确承认这不是完整的启动边界——拒绝信任不能撤销那次「会话目录查找」。- context files 不需要信任:
AGENTS.override.md、AGENTS.md、CLAUDE.md会照常从工作目录及其祖先加载进系统提示词(除非显式禁用 context loading)。所以「拒绝信任」≠「目录里的文本不影响模型」——信任门槛只挡可执行代码(扩展)与资源加载,不挡提示注入面。这也是文档强调「把目录内容当作不可信输入」的原因。
Java 视角:项目信任 ≈ classpath 签名门槛,但只挡「类加载」不挡「字符串」
最贴切的类比是:给一个陌生 jar 加进 classpath 前先验签——信任决策决定「哪些 Spring AutoConfiguration 会被加载」。扩展是进程内 TypeScript 模块,加载即执行,等价于 jar 里的 static 初始化块;而 AGENTS.md 只是进入提示词的字符串,相当于日志里的一段用户数据——不执行,但影响下游决策(提示注入)。理解这个区别,就理解了为什么「拒绝信任」之后 context files 依然加载:字符串不构成代码执行边界。
§8.7基础设施与可观测性
core/ 里还有一批不显眼但被所有模块依赖的基础设施,浏览目录时应能一眼认出它们。
进程与网络基础设施
bash-executor.ts——统一 bash 执行:executeBashWithOperations()支持onChunk流式输出、AbortSignal取消、ANSI 转义与二进制清理、滚动缓冲(超过DEFAULT_MAX_BYTES落临时文件、保留尾部、标记truncated)。BashOperations是可注入抽象,远程执行(SSH/容器)替换实现即可。exec.ts——扩展与自定义工具用的通用命令执行:execCommand()用spawn(shell:false)、支持超时/中止/输出上限,StringDecoder处理跨 chunk 的多字节 UTF-8 边界(源码注释 #11312),超限时关管道防后代进程挂住等待(#11360)。http-dispatcher.ts——全局网络层:用 undici 构造EnvHttpProxyAgent(支持HTTP_PROXY/HTTPS_PROXY、H2 关闭、CONNECT 隧道),由configureHttpDispatcher(timeoutMs)按设置的httpIdleTimeoutMs安装为全局 dispatcher,同时统一fetch与 dispatcher 的 undici 实现,避免 Node 内置 fetch 与 npm undici 混用导致的解压错误(注释里记录了 Node 26.0 的坑)。
可观测性三件
crash-log.ts——崩溃持久化:未捕获异常写入~/.pi/agent/crashes.json,最多保留 5 条、7 天有效;下次启动takeUnnotifiedCrash()取出最新未通知记录提示用户。最有工程味的是findExtensionStackMatches():把崩溃栈与已加载扩展的源码路径做规范化匹配,直接告诉你「崩溃来自哪个扩展」——第三方插件生态里定位责任的刚需。diagnostics.ts——资源诊断类型:ResourceDiagnostic(warning/error/collision)与ResourceCollision(两个资源注册了同名 skill/命令/工具时记录胜者与败者路径)。启动时的重名冲突不崩进程,而是记为诊断交给 UI。bug-report.ts——generateBugReportSummary()生成带最近对话摘要的 bug 报告;AgentSession.summarizeForBugReport()支持用自然语言提示聚焦问题。配合telemetry.ts(匿名安装遥测开关)与timings.ts(启动阶段计时)构成完整的「出问题时能看」链路。
另外留意 cache-stats.ts 与 cache-warmer.ts:后者在空闲期重放上一次请求前缀来保温 provider 的 prompt cache(cacheWarming: "off" | "streaming" | "idle"),只对声明了缓存寿命且估算可省 ≥ $0.05 的模型生效——这就是 sdk.ts 里那个 cacheWarmer.start() 调用的背后模块,第 9 讲讲缓存时还会回来。
§8.8一条用户输入的完整调用链
把本讲所有模块串起来。场景:交互模式下,用户在 TUI 编辑器敲下「帮我修复这个编译错误」并回车。下图从左到右五个参与者,实线为调用,虚线为事件回流:
逐站核对源码。第一站在 TUI(modes/interactive/interactive-mode.ts),编辑器提交逻辑区分空闲与流式两种状态:
// If streaming, use prompt() with steer behavior
// This handles extension commands (execute immediately), prompt template expansion, and queueing
if (this.session.isStreaming) {
this.editor.addToHistory(text);
this.editor.setText("");
await this.session.prompt(text, { streamingBehavior: "steer" });
this.updatePendingMessagesDisplay();
this.ui.requestRender();
return;
}
// Normal message submission ……
第二站 AgentSession.prompt() 的方法注释就是流程清单——扩展命令立即执行、模板默认展开、流式时按 streamingBehavior 入队、发送前校验模型与 API key:
/**
* Send a prompt to the agent.
* - Handles extension commands (registered via pi.registerCommand) immediately, even during streaming
* - Expands file-based prompt templates by default
* - During streaming, queues via steer() or followUp() based on streamingBehavior option
* - Validates model and API key before sending (when not streaming)
*/
async prompt(text: string, options?: PromptOptions): Promise<void> {
if (this._isEmittingAgentSettled) {
this._deferredSettledActions.push(async () => await this.prompt(text, options));
return;
}
// Handle extension commands first (execute immediately, even during streaming)
if (expandPromptTemplates && text.startsWith("/")) {
const handled = await this._tryExecuteExtensionCommand(text);
if (handled) { preflightResult?.("handled"); return; }
}
if (this._compactionAbortController !== undefined /* …… */) {
throw new Error("Cannot submit a prompt while compaction is in progress. ……");
}
// Reserve the context through async input hooks and auth checks so tree navigation cannot
// start meanwhile (#9154). _startRun marks the run active synchronously, so there is no gap.
this._promptPreflightCount++;
let trigger: BeforeAgentStartTrigger | undefined;
try {
trigger = await this._preparePrompt(text, options);
} finally {
this._promptPreflightCount--;
}
if (trigger) await this._startRun(trigger, () => preflightResult?.("started"));
}
第三站 _startRun 是运行循环的驱动器,值得完整读一遍——它把「run」定义为从 agent.prompt 到 agent_settled 的全过程,中间按需驱动重试、压缩、边界钩子:
private async function _startRun(trigger, onStarted?): Promise<void> {
this._isAgentRunActive = true;
this._agentRunAbortRequested = false;
let messages: AgentMessage[];
try {
messages = await this._prepareRun(trigger);
} catch (error) {
this._isAgentRunActive = false;
this._flushPendingBashMessages(); this._flushPendingCustomMessages();
this._resolveIdleWaitIfIdle();
throw error;
}
this._flushPendingBashMessages(); this._flushPendingCustomMessages();
onStarted?.();
this._failedResponse = undefined;
this._recordSelection();
this._pendingToolNames.clear();
try {
await this.agent.prompt(messages);
while (!this._agentRunAbortRequested) {
if (await this._handlePostAgentRun()) { // 重试 / 压缩 / 队列续跑
await this.agent.continue(); continue;
}
if (!(await this._runBeforeSettleBoundary())) break;
await this.agent.continue();
}
} finally {
await this._emitAgentSettled(); // agent_settled 事件
}
}
第四、五、六站已在 §8.4 的 sdk.ts 节选中出现:Agent 组装上下文、调用 streamFn、后者进 modelRuntime.streamSimple。最后把回流侧再连起来:Provider 的流式事件被 pi-ai 组合成 AssistantMessageEventStream;Agent 内核边消费边发 AgentEvent;AgentSession._handleAgentEvent 按固定顺序分发(扩展桥接 → 公共监听器 → 持久化);TUI 的订阅者把每个 AgentSessionEvent 变成渲染更新。整个过程没有轮询——一个纯粹的推模型。
工具调用也一样走这条路:Agent 在流里发现 toolCall → AgentSession._beforeToolCall(扩展拦截点,可否决或改参)→ runToolCall 真正执行 → _afterToolCall → tool_execution_start/update/end 事件回流。若工具结果触发模型再次请求,Agent 循环自动开始下一个 turn;直到没有新工具调用且队列为空,_startRun 的 while 循环退出,agent_settled 发出,TUI 收起「工作中」指示器。这就是大纲实践任务要求的「一条消息的完整路径」。
§8.9动手实践
-
浏览目录建立地图
在
packages/coding-agent/src下执行目录浏览(Get-ChildItem -Recurse -Depth 1 core,modes,extensions,cli),对照 §8.1 的目录树给每个你看到的文件标注「属于哪层、被谁调用」。重点确认core/index.ts导出了什么——它是 core 对外的公共 API 清单。 -
静态跟踪 prompt 调用链
用编辑器打开四个文件,按 §8.8 顺序各读一段:
interactive-mode.ts搜onSubmit;agent-session.ts读prompt / _preparePrompt / _startRun / _handleAgentEvent;sdk.ts读createAgentSession中new Agent一段;model-runtime.ts读streamSimple。在纸上抄下每一步的方法名与行号,形成自己的时序图。 -
运行时打断点(或加日志)
克隆仓库后
code .,在 VS Code 里用 JavaScript 调试器启动packages/coding-agent的 bin 入口,在AgentSession.prompt、_startRun、_handleAgentEvent、sdk.ts的streamFn各打一个断点,发一条消息观察调用顺序与事件类型。不想调试时,在_emit里临时console.error(event.type)也能看到完整事件序列。 -
验证设置合并
在
~/.pi/agent/settings.json写{"defaultTools":["read","bash"]},在项目.pi/settings.json(信任后)写{"defaultTools":["+edit"]},用/tools或session.getActiveToolNames()验证最终是三个工具;再把项目值换成["write"]验证「纯名单替换」规则。 -
观察信任决策
新建目录放一个
.pi/extensions/(可为空目录),启动pi应触发信任询问;选择「Trust parent folder」后退出,检查~/.pi/agent/trust.json里记录的路径;再用pi -p hello --no-approve验证无 UI + 无已存决策时的拒绝行为。
常见误区
误区一:以为 AgentSession 就是 Agent Loop
Agent Loop(请求-执行-回填循环)住在 pi-agent-core 的 Agent 类里(第 6 讲)。AgentSession 不实现循环,它做的是「围绕循环的工程」:输入预处理(命令/模板/钩子)、运行编排(_startRun 里的重试与压缩驱动)、事件桥接与持久化、模型/思考级别/工具 loadout 管理、会话树导航。判断标准很简单:如果在 agent-session.ts 里看到 while,那是在决定「要不要再让 agent 跑一轮」,而不是循环本身。
误区二:以为 event-bus 是 agent 事件的主通道
createEventBus() 是扩展系统的发布/订阅底座(37 行的小模块);模型回复与工具事件走的是 agent.subscribe() → AgentSession._handleAgentEvent → _emitExtensionEvent(桥接到扩展 runner)→ _emit(发给 AgentSessionEvent 监听器)。两条通道在 _emitExtensionEvent 处相连,不要把「扩展事件」与「Agent 事件」混为一谈。
误区三:以为拒绝信任 = 目录内容完全不影响 Pi
拒绝信任只跳过受保护资源的加载(设置、MCP、扩展、skills 等),但 sessionDir 在信任决策前已读取,且 AGENTS.md/CLAUDE.md 等 context files 无条件加载进系统提示词。目录里的文本依然能影响模型行为(提示注入),工具的权限依然是进程用户权限。信任门槛防的是「静默加载可执行扩展」,不是沙箱。
误区四:以为模型解析只看 settings.json
一次模型选择要综合:会话转录里记录的 model_change(恢复会话优先)、defaultProvider/defaultModel、enabledModels 模式、--models 的 glob 作用域、defaultModelPerProvider 兜底表、以及 provider-composer 三层合成后的目录。漏掉任何一层,都会解释不了「为什么选了这个模型」。
§8.10自测问答
Q1AgentSession 与 Agent 类的职责边界是什么?为什么 Pi 要把两者分开?
Agent(pi-agent-core)是纯粹的 Agent Loop 引擎:状态、循环、流式请求、工具执行与 AgentEvent,不含任何产品概念。AgentSession(coding-agent)是共享给 interactive/print/json/rpc 四种模式的会话编排层:输入预处理(扩展命令、模板、input 钩子)、_startRun 运行驱动(重试/压缩/边界钩子)、事件桥接(AgentEvent → 扩展事件 → AgentSessionEvent)、JSONL 持久化、模型与思考级别管理、Bash 执行、会话树导航与分支。分开的理由是让内核可复用于非编码场景(例如 pi-chat),而产品层可以自由增加持久化、扩展、压缩等产品能力,不污染内核。
Q2设置的优先级顺序是什么?defaultTools 有什么特殊合并规则?
从低到高:内置默认(SETTINGS_DEFAULTS)→ 全局 ~/.pi/agent/settings.json → 项目 <cwd>/.pi/settings.json(仅信任后加载)→ applyOverrides() 的 CLI/内存覆盖。普通字段用 deepMergeSettings 深度合并:嵌套对象递归合并、标量整体覆盖。defaultTools 例外:覆盖层给出纯工具名列表时整体替换继承值;只给 +name/-name 修饰符时追加到继承列表,解析顺序为「纯名单确定基底 → 修饰符按序增删」。另注意 defaultProjectTrust、httpProxy、selfUpdateCommand 只允许在全局设置。
Q3为什么项目级扩展必须过信任门槛?信任到底保护什么、不保护什么?
扩展是加载进 Pi 进程的 TypeScript 模块,加载即执行,与启动用户同权限。如果 cd 进任意目录就能静默执行其中的 .pi/extensions/,等于把「打开文件夹」变成「运行任意代码」。信任决策(resolveProjectTrusted)保护的是:启动时的资源加载面——项目设置、MCP 配置、扩展、skills、prompts、themes、系统提示词覆盖文件。它不保护:工具运行时的文件/网络访问(仍是进程权限),也不阻止 context files(AGENTS.md 等)进入提示词,且 sessionDir 在信任前已读取。所以它是「加载门槛」,不是运行时沙箱。
Q4描述一条用户输入从 TUI 编辑器到模型请求再回流的完整调用链(至少 8 个站点)。
① editor.onSubmit(interactive-mode.ts)→ ② AgentSession.prompt():扩展命令、压缩中拒绝 → ③ _preparePrompt:input 钩子、/skill 与模板展开、模型与 API key 校验(流式时入 steer/followUp 队列)→ ④ _startRun → _prepareRun:before_agent_start、图片规范化、组装 user 消息与工具 loadout → ⑤ agent.prompt(messages) 启动 Agent Loop:上下文 + 系统提示词 + 工具声明,transformContext 钩子 → ⑥ streamFn(model, context, options)(sdk.ts 闭包)→ ⑦ modelRuntime.streamSimple:合并重试/超时/身份/header 钩子,经 provider-composer 合成的 Provider 发 HTTP → ⑧ 流式事件回流为 AssistantMessageEventStream → ⑨ Agent 发 AgentEvent(message_*/tool_execution_*/turn_*),工具调用在循环内经 _beforeToolCall/runToolCall/_afterToolCall 执行 → ⑩ AgentSession._handleAgentEvent:先扩展桥接(_emitExtensionEvent)、再公共监听器(_emit → AgentSessionEvent)、最后 sessionManager.appendMessage 持久化 → ⑪ TUI handleEvent 渲染;run 结束时发 agent_settled。
Q5模型解析链上六个模块各做什么?「一条设置字符串」如何变成可用的模型调用?
settings 提供字符串(defaultProvider/defaultModel/enabledModels);model-resolver 把字符串解析成 Model 对象(处理 provider/id:thinking 拆分、模糊匹配、歧义报告,usable 凭据状态只用于打破平局);model-runtime 持有目录、凭据与可用性快照,提供 getModel/streamSimple/getAuth 等运行时 API;models-store(FileModelsStore)把动态刷新的 provider 目录持久化到 models-store.json(带锁与 revision 缓存);provider-composer 把内置 provider、models.json 覆盖、扩展覆盖合成为最终 Provider(失败退回内置);preferred-providers 只影响列表展示顺序(Radius、OpenRouter 置顶)。最终 sdk.ts 在 new Agent 时把 streamFn 闭包到 modelRuntime.streamSimple 上,Agent 每次请求经此发出。
Q6事件回流的顺序为什么是「扩展 → 公共监听器 → 持久化」?顺序换掉会出什么问题?
扩展的 message_end 处理器可以返回替换消息(replacement),AgentSession 会把 agent 状态与会话历史中的消息就地替换。若公共监听器(TUI)先于扩展收到事件,UI 可能渲染了即将被替换的旧消息;若持久化先于扩展,JSONL 里会写入替换前的版本,恢复会话时得到与现场不一致的历史。「扩展 → 监听器 → 持久化」保证后两者看到的都是最终形态。同理 turn_end 后才 flush 挂起的 custom 消息,是为了避开 assistant 的 toolCall 与 toolResult 之间不允许插入消息的顺序校验。