前言
OpenClaw 的 Agent 系统分为两层:一层是传统的 monolithic agent,直接运行在 OpenClaw 主进程里;另一层是 Pi Agent------一个完全独立运行的嵌入式 Agent 运行时。Pi Agent 不是 OpenClaw 的插件或模块,而是一个由 OpenClaw 通过 RPC 调用的外部 Agent 引擎。
这个设计听起来反直觉------为什么一个桌面应用需要把核心 Agent 能力拆出去做成独立运行时?本文从源码出发,把 Pi Agent 的架构拆解到底层细节:Session 管理与 JSONL transcript、Bootstrap 上下文注入、Context Compaction 自动压缩机制、Overflow Recovery 溢出恢复、Command Lanes 并发隔离、工具沙箱集成、以及 Pi Agent 如何与 OpenClaw 主进程通过 RPC 通信。
一、为什么需要 Pi Agent:架构解耦的动机
在 OpenClaw 的早期架构里,Agent 是直接运行在主进程的。当用户在一个 Telegram 对话里发送消息,主进程中的 Agent 处理消息、执行工具、返回结果。这套架构简单直接,但有几个根本性问题无法回避:
1. 资源竞争 :Agent 执行工具(尤其是长时间运行的 exec)会阻塞主进程的事件循环,影响其他频道的消息处理。
2. 沙箱边界模糊:把 Docker 沙箱集成到主进程里,意味着每次工具执行都要维护沙箱生命周期,主进程和沙箱进程之间的通信协议会变得复杂。
3. Context Window 管理:当对话历史变长,context window 接近耗尽,主进程里的 Agent 没有一个好的机制去做历史压缩和重试。
4. 可测试性:把 Agent 逻辑和 OpenClaw 的 channel 层混在一起,导致单元测试几乎不可能做。
OpenClaw 的解法是把整个 Agent 运行时抽成一个独立进程 (pi-agent),由 OpenClaw 主进程通过 RPC 调用。这个独立运行时在 Node.js 环境下通过 @mariozechner/pi-coding-agent 包暴露接口,在 OpenClaw 里通过 pi-embedded-runner 封装调用逻辑。
lua
┌─────────────────────────────────────────────────────┐
│ OpenClaw 主进程 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Telegram │ │ Discord │ │ CLI │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │
│ └──────────────┼──────────────┘ │
│ │ │
│ ┌─────────▼─────────┐ │
│ │ pi-embedded-runner │ ← RPC 客户端 │
│ └─────────┬─────────┘ │
└──────────────────────┼──────────────────────────────┘
│ RPC
┌────────────▼────────────┐
│ pi-coding-agent 进程 │
│ ┌──────────────────┐ │
│ │ SessionManager │ │
│ │ Tool Executor │ │
│ │ Compaction │ │
│ │ Sandbox Bridge │ │
│ └──────────────────┘ │
└─────────────────────────┘
但 OpenClaw 最终没有走完全独立的进程路线,而是把 pi-agent 作为 embedded runtime 直接嵌入在 Node.js 进程里运行。这在 src/agents/pi-embedded-runner/run.ts 里可以看到------runEmbeddedPiAgent 是一个普通的 async 函数,在 OpenClaw 的命令队列里排队执行,而不是单独的进程。
真正让 Pi Agent 独立的是它的执行模型:每个 Pi Agent 调用都在独立的 command lane 里排队,和主进程的其他工作隔离。Session 状态(transcript)通过 JSONL 文件持久化,而不是内存中的对象。
二、pi-embedded-runner 的入口:runEmbeddedPiAgent
runEmbeddedPiAgent 是 Pi Agent 的主入口,定义在 src/agents/pi-embedded-runner/run.ts。看核心流程:
typescript
export async function runEmbeddedPiAgent(
params: RunEmbeddedPiAgentParams,
): Promise<EmbeddedPiRunResult> {
// 1. 解析 session lane(并发隔离)
const sessionLane = resolveSessionLane(params.sessionKey?.trim() || params.sessionId);
const globalLane = resolveGlobalLane(params.lane);
// 2. 在 lane 队列里排队
return enqueueSession(() =>
enqueueGlobal(async () => {
const started = Date.now();
// 3. 解析 workspace 目录
const workspaceResolution = resolveRunWorkspaceDir({ ... });
const resolvedWorkspace = workspaceResolution.workspaceDir;
// 4. 确保运行时插件加载
ensureRuntimePluginsLoaded({ config: params.config, workspaceDir: resolvedWorkspace });
// 5. 解析 provider 和 model
let provider = (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER;
let modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL;
// 6. 解析溢出恢复逻辑(核心!)
const { compactResult, overflow, retryResult } = await handleOverflowOrRetry({ ... });
// 7. 运行实际的 agent attempt
const attemptResult = await runEmbeddedAttempt({ ... });
return buildRunResult(attemptResult, compactResult, retryResult);
}),
);
}
这个入口函数本身不复杂,真正的复杂度在 handleOverflowOrRetry 和 runEmbeddedAttempt 两个子函数里。handleOverflowOrRetry 负责在 context window 溢出时触发自动压缩和重试,runEmbeddedAttempt 则负责执行一次完整的 Agent 调用。
三、Command Lanes:并发隔离的队列系统
Pi Agent 的并发模型由 Command Lanes 决定。这个设计解决了"同一个 session 的多次调用必须串行,不同 session 可以并行"的需求:
typescript
// lanes.ts
export function resolveSessionLane(key: string) {
const cleaned = key.trim() || CommandLane.Main;
return cleaned.startsWith("session:") ? cleaned : `session:${cleaned}`;
}
Session key 的格式是 agent:provider:sessionId,比如 agent:telegram:alice-123。resolveSessionLane 把 session key 映射到一个队列名称,同一个 session 的所有操作共享同一个 lane,保证串行执行。
Lane 的设计还有几个精妙的细节:
-
Cron 任务的死锁避免 :如果当前已经在 Cron lane 里执行,再次发起 cron 任务会使用
Nestedlane 而不是重新进入 Cron lane,避免自己等自己的死锁:typescriptif (cleaned === CommandLane.Cron) { return CommandLane.Nested; // 防止 cron 自己等自己 } -
Lane 优先级:Session lane 优先级低于 Global lane,保证全局操作(如 compaction)可以优先于单 session 操作执行。
四、Session 管理:JSONL 作为不可变事实
Pi Agent 的 Session 管理是整系统中设计最优雅的部分之一。Session 不是内存中的对象,而是以 JSONL(JSON Lines)文件的形式持久化在磁盘上。
4.1 Session 文件格式
看 ensureSessionHeader 的实现:
typescript
// bootstrap.ts
export async function ensureSessionHeader(params: {
sessionFile: string;
sessionId: string;
cwd: string;
}) {
const entry = {
type: "session",
version: 2,
id: params.sessionId,
timestamp: new Date().toISOString(),
cwd: params.cwd,
};
await fs.writeFile(file, `${JSON.stringify(entry)}\n`, "utf-8");
}
Session 文件是一个 JSONL(每行一个 JSON 对象),除了 header 之外,每条消息(user/assistant/system)都追加到文件末尾。文件结构如下:
json
{"type":"session","version":2,"id":"sess-abc","timestamp":"2024-01-15T10:00:00Z","cwd":"/Users/alice/project"}
{"type":"user","content":[{"type":"text","text":"帮我写一个快排"}],"timestamp":1705312800000}
{"type":"assistant","content":[{"type":"text","text":"好的,这是快排的实现:..."}],"timestamp":1705312805000}
{"type":"toolUse","name":"exec","input":{...},"timestamp":1705312806000}
{"type":"toolResult","toolUseId":"...","content":"...","timestamp":1705312808000}
这种设计有几个关键优势:
- Append-only :工具调用结果只需要
fs.appendFileSync,不需要读-改-写 - 进程重启不丢状态:Session 数据在磁盘上,主进程崩溃后可以恢复
- 可审计:每一轮对话的输入输出都是完整记录
- Session Sharing:多个进程可以同时读取同一 session 文件(只读),支持只读共享场景
4.2 Session Lock:单写多读的写锁
多个 Pi Agent 调用可能同时尝试写入同一个 session 文件(比如同时触发了 tool result 写入和 context compaction)。Pi Agent 用 proper-lockfile 实现了单写多读的文件锁:
typescript
const sessionLock = await acquireSessionWriteLock({
sessionFile: params.sessionFile,
maxHoldMs: resolveSessionLockMaxHoldFromTimeout({ timeoutMs: 30_000 }),
});
- 写操作(append turn、compaction)需要排他锁
- 读操作(load transcript)可以多个并发读取
- 锁超时
maxHoldMs防止死锁:当写操作持有锁超过 30 秒,锁自动释放
五、Bootstrap 系统:Workspace 上下文注入
当 Pi Agent 启动一个新的 session 时,它需要知道"当前工作区里有什么文件"。这个信息通过 Bootstrap 系统注入到 context 里。
5.1 Bootstrap 文件加载
loadWorkspaceBootstrapFiles 扫描 workspace 目录,收集需要注入的文件:
typescript
export async function loadWorkspaceBootstrapFiles(
workspaceDir: string,
config?: OpenClawConfig,
): Promise<WorkspaceBootstrapFile[]> {
// 读取 .openclawignore(类似 .gitignore)
// 遍历目录,收集需要注入的文件
// 跳过 node_modules、dist 等明显不需要的文件
}
Bootstrap 文件支持 .openclawignore,开发者可以精确控制哪些文件进入 context。
5.2 Bootstrap 内容截断:智能的头尾保留
单个文件的 context 注入有预算限制(默认 20,000 chars),但总 bootstrap 内容也有上限(默认 150,000 chars)。trimBootstrapContent 实现了一个精妙的"头尾保留"截断策略:
typescript
const BOOTSTRAP_HEAD_RATIO = 0.7; // 保留前 70%
const BOOTSTRAP_TAIL_RATIO = 0.2; // 保留尾 20%
// 超过预算时:截断中间,保留头尾
const head = trimmed.slice(0, headChars);
const tail = trimmed.slice(-tailChars);
const marker = "[...truncated, read file for full content...]";
const contentWithMarker = [head, marker, tail].join("\n");
这个策略比简单截断头部聪明得多------对于代码文件,尾部通常是函数定义和 import 语句,模型需要看到这些才能理解代码结构。保留头 70% + 尾 20%,只丢弃中间冗余部分。
5.3 Bootstrap 的会话头注入
Bootstrap 系统还负责在 session 文件里写入 header。Session header 包含了 session 版本、ID、时间戳和工作目录,这些信息在 compaction 和 session 恢复时会被用到。
typescript
// 在 session 文件第一行写入版本化的 header
const sessionVersion = 2;
const entry = {
type: "session",
version: sessionVersion,
id: params.sessionId,
timestamp: new Date().toISOString(),
cwd: params.cwd,
};
版本号 version: 2 是关键------如果将来 session 文件格式变了,旧版本的解析逻辑需要和这个版本号匹配。Pi Agent 在加载 session 时会检查版本号,不兼容的版本会触发迁移逻辑。
六、Context Compaction:自动压缩的完整链路
Context compaction 是 Pi Agent 最核心也最复杂的子系统。当对话历史变长,context window 接近耗尽时,Pi Agent 需要把旧的消息压缩成摘要,腾出空间给新消息继续对话。
6.1 压缩触发条件
压缩可以由两种事件触发:
- 主动压缩(manual) :用户通过
/compact命令手动触发 - 被动压缩(overflow) :模型返回
request_too_large错误,Runner 自动触发
两种触发方式走的是同一套压缩链路,只是参数不同:
typescript
export type CompactEmbeddedPiSessionParams = {
trigger?: "overflow" | "manual";
force?: boolean; // 强制压缩(忽略阈值)
tokenBudget?: number; // 压缩目标 token 数
};
6.2 压缩的三个阶段
阶段一:读取历史,加载 Compaction 模型
Compaction 不是用主模型做的------压缩是用一个专门的"compaction 模型"执行。这个模型可以从配置里覆盖:
typescript
const compactionModelOverride = params.config?.agents?.defaults?.compaction?.model;
if (compactionModelOverride) {
const slashIdx = compactionModelOverride.indexOf("/");
provider = compactionModelOverride.slice(0, slashIdx);
modelId = compactionModelOverride.slice(slashIdx + 1);
} else {
// 使用主模型
provider = params.provider ?? DEFAULT_PROVIDER;
modelId = params.model ?? DEFAULT_MODEL;
}
这个设计很合理:主模型(如 Claude Opus)适合推理,但压缩-summary 任务用一个小模型(如 GPT-4o-mini)就足够了,还能省钱省时间。
阶段二:读取完整 transcript,构建摘要 prompt
typescript
const sessionManager = guardSessionManager(SessionManager.open(params.sessionFile), {
agentId: sessionAgentId,
sessionKey: params.sessionKey,
});
// 读取所有历史消息
const { messages } = await sessionManager.getMessages({ limit: undefined });
// 构造摘要 prompt
const appendPrompt = createSystemPromptOverride(
`Summarize this conversation concisely, preserving key decisions, code changes, and user preferences. ` +
`Format: [Summary] <concise summary> [End Summary].`
);
阶段三:写入压缩后的摘要,清除旧消息
压缩成功后,旧消息被删除,替换成一条合成的人工消息(synthetic message):
typescript
// 压缩前:messages[0..n] 共 N 条消息
// 压缩后:messages[0] = synthetic summary + messages[lastKeptIndex..n]
synthetic message 的格式类似于:
json
{
"type": "assistant",
"role": "assistant",
"content": "[Summary] Previous conversation summarized: <压缩摘要> [End Summary]",
"compactCount": 1,
"tokenCountBefore": 120000,
"tokenCountAfter": 8000
}
compactCount 字段记录压缩次数,如果多次压缩后会递增,帮助模型理解对话被压缩了多少轮。
6.3 压缩质量保护:Compaction Safeguard
压缩最大的风险是摘要质量差------模型可能在摘要里丢失关键上下文。为了解决这个问题,Pi Agent 引入了 Compaction Safeguard 扩展:
typescript
setCompactionSafeguardRuntime(params.sessionManager, {
maxHistoryShare: 0.7, // 压缩后历史不超过 context 的 70%
contextWindowTokens: 200_000,
qualityGuardEnabled: true,
qualityGuardMaxRetries: 2,
recentTurnsPreserve: 5, // 至少保留最近 5 轮对话
});
recentTurnsPreserve: 5 是个很实用的设计------即使压缩后的 token 数还有很多,也要强制保留最近 5 轮对话(一个 user turn + 对应的 assistant turn + tool exchanges)。这样模型总是能看到最近的上下文,不会因为压缩丢失"上一步我们改了什么"的记忆。
6.4 Context Pruning:工具结果的智能清理
除了压缩历史消息,Pi Agent 还有一个 Context Pruning 机制,专门处理工具结果的清理:
typescript
const pruningFactory = buildContextPruningFactory(params);
factories.push(pruningFactory);
Pruning 的策略是根据工具类型决定保留哪些结果:
read:保留(用户可能需要参考之前读取的文件内容)exec:短结果保留,长输出可能被截断search:保留搜索结果(但结果可以裁剪)- 某些工具结果被标记为"可丢弃"(prunable),在 context 紧张时优先清理
Pruning 和 Compaction 的区别:Pruning 是在每次 turn 后检查并清理不需要的工具结果 ,而 Compaction 是触发时一次性压缩整个历史。两者配合实现了一个细粒度(pruning)+ 粗粒度(compaction)的 context 管理策略。
6.5 压缩失败的处理
压缩不是 100% 可靠的------如果压缩用的模型本身也超出 context window,就会产生 compaction_failure 错误。Runner 对压缩失败有完整的错误分类和处理:
typescript
classifyCompactionReason(reason: string): string {
if (text.includes("request_too_large")) return "compaction_fits_model_window";
if (text.includes("summary")) return "summary_failed";
if (text.includes("timeout")) return "timeout";
if (text.includes("400") || text.includes("401") || text.includes("429")) return "provider_error_4xx";
if (text.includes("500") || text.includes("502")) return "provider_error_5xx";
return "unknown";
}
当压缩失败时,Runner 会把这个错误标记为 compaction_failure 并返回给调用方。如果是因为模型 window 太小(连摘要都做不了),会提示用户换一个支持更大 context 的模型。
七、Overflow Recovery:溢出时的自动恢复链路
当模型调用因为 context 超出 window 而失败时,Runner 会自动尝试恢复:
typescript
// run.ts 里的 overflow 处理逻辑
if (observedOverflowTokens !== undefined) {
const compactResult = await contextEngine.compact({ tokenBudget: ctxInfo.tokens });
if (compactResult.ok) {
// 压缩成功,重试原始请求
retryResult = await runEmbeddedAttempt({ ... });
} else {
// 压缩失败,返回错误
return buildErrorResult("compaction_failed");
}
}
这个流程里有个精妙的设计:observedOverflowTokens 不是由 Runner 自己计算的,而是由 pi-agent 核心在观察到 request_too_large 时主动报告的。这样当 Runner 收到这个信号时,context 已经超限了,需要先清理再重试。
重试上限 :如果反复压缩成功但仍然溢出(说明单条消息本身就超出 window),Runner 会在 retry_limit 次重试后放弃,防止无限循环:
typescript
const maxAttempts = params.maxAttempts ?? 3;
if (attempt >= maxAttempts) {
return buildErrorResult("retry_limit");
}
八、Pi Settings:从 Config 到运行时配置的注入链路
Pi Agent 的运行时行为(compaction 参数、thinking level、工具配置等)由 SettingsManager 统一管理。这个系统在 src/agents/pi-settings.ts 里实现:
typescript
export function createPreparedEmbeddedPiSettingsManager(params: {
cwd: string;
agentDir: string;
cfg?: OpenClawConfig;
}): SettingsManager {
const fileSettingsManager = SettingsManager.create(params.cwd, params.agentDir);
// 从 OpenClaw config 读取 compaction 参数
const compactionCfg = params.cfg?.agents?.defaults?.compaction;
// 注入到 SettingsManager
if (compactionCfg?.reserveTokensFloor !== undefined) {
ensurePiCompactionReserveTokens({
settingsManager: fileSettingsManager,
minReserveTokens: compactionCfg.reserveTokensFloor,
});
}
return fileSettingsManager;
}
SettingsManager 是 pi-agent 核心提供的接口,OpenClaw 通过 createPreparedEmbeddedPiSettingsManager 把 OpenClaw 的 config 注入进去。这个设计让 Pi Agent 的核心保持 provider-agnostic,所有 OpenClaw 特定的配置都在这一层注入。
还有一个重要的设置是 Compaction Reserve Tokens:
typescript
export const DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR = 20_000;
这个值的含义是:即使 context 还有空间,也要预留 20,000 token 给新的用户消息和模型响应。如果不预留,压缩后 context 刚好填满,新消息一来又触发溢出,变成"压缩→满→压缩"的乒乓震荡。
九、Bootstrap 与 History 的协同
Bootstrap 系统和 History 管理不是独立的------它们需要协同工作才能保证模型看到正确的上下文。
9.1 History Turn 限制
对于某些长会话场景(如 Telegram DM),不需要保留全部历史。limitHistoryTurns 实现了"只保留最近 N 轮用户对话"的策略:
typescript
export function limitHistoryTurns(messages: AgentMessage[], limit: number): AgentMessage[] {
let userCount = 0;
let lastUserIndex = messages.length;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === "user") {
userCount++;
if (userCount > limit) {
return messages.slice(lastUserIndex); // 丢弃更早的轮次
}
lastUserIndex = i;
}
}
return messages;
}
关键点是按 user turn 计数而不是按 message 计数------一个完整的"用户提问 + 模型回答 + 工具调用"算一轮。这样模型始终能看到完整的对话上下文,而不是孤立的工具调用记录。
9.2 Session Key 感知的 History 限制
不同的 channel 类型有不同的 history 限制策略:
typescript
export function getHistoryLimitFromSessionKey(sessionKey: string, config: OpenClawConfig) {
const parts = sessionKey.split(":").filter(Boolean);
// agent:provider:sessionId 格式
// kind: dm | channel | group
if (kind === "dm") {
return config.dmHistoryLimit; // DM 用自己的限制
}
if (kind === "channel" || kind === "group") {
return config.historyLimit; // 群组用不同的限制
}
}
这意味着同一个用户从 Telegram DM 发起对话和从群组发起对话,可能有不同的历史保留策略------DM 可能保留更长的历史(私密对话),群组可能只保留最近几轮(节省 context)。
9.3 Bootstrap 文件与 History 的 token 预算分配
Bootstrap 文件内容和 History 消息共用了同一个 context budget。当 workspace 有很多文件需要注入时,History 可以分配到的空间就少了。buildBootstrapContextFiles 在计算 bootstrap 内容时:
typescript
let remainingTotalChars = totalMaxChars; // 总预算(按 token 估算)
for (const file of files) {
if (remainingTotalChars < MIN_BOOTSTRAP_FILE_BUDGET_CHARS) {
break; // 剩余空间太少,不再注入更多文件
}
// ...
remainingTotalChars -= contentWithinBudget.length;
}
这个机制确保 Bootstrap 不会"吃掉"所有 context,留下至少 MIN_BOOTSTRAP_FILE_BUDGET_CHARS(64 chars)给 History 使用。
十、工具执行与 pi-agent 核心的集成
Pi Agent 的工具执行通过 createAgentSession 一次性初始化好所有工具:
typescript
const { builtInTools, customTools } = splitSdkTools({
tools,
sandboxEnabled: !!sandbox?.enabled,
});
const { session } = await createAgentSession({
cwd: effectiveWorkspace,
agentDir,
authStorage,
modelRegistry,
model: effectiveModel,
thinkingLevel: mapThinkingLevel(params.thinkLevel),
tools: builtInTools,
customTools,
sessionManager,
settingsManager,
resourceLoader,
});
这里有一个关键的 splitSdkTools 逻辑:Built-in tools(内置工具) 和 Custom tools(自定义工具) 的分离。内置工具(如 exec、read、write)经过 sandbox 隔离处理;自定义工具(来自 MCP、Skill 等)可能有不同的安全策略:
typescript
// tool-split.ts
export function splitSdkTools(params: SplitSdkToolsParams) {
if (!params.sandboxEnabled) {
return { builtInTools: params.tools, customTools: [] };
}
// 内置工具走 sandbox
// 自定义工具根据工具的 sandboxed 标记决定
}
十一、Stream 修复包装器与 Multi-Provider 适配
Pi Agent 在 streaming 输出层面也做了大量的 provider 适配工作,这在 attempt.ts 的 streaming pipeline 里可以看到:
typescript
// attempt.ts
if (params.model.api === "anthropic-messages" &&
shouldRepairMalformedAnthropicToolCallArguments(params.provider)) {
activeSession.agent.streamFn = wrapStreamFnRepairMalformedToolCallArguments(
activeSession.agent.streamFn,
);
}
if (isXaiProvider(params.provider, params.modelId)) {
activeSession.agent.streamFn = wrapStreamFnDecodeXaiToolCallArguments(
activeSession.agent.streamFn,
);
}
这些包装器是在 stream 层面做修复,而不是在 response 层面。这意味着即使模型的 streaming 输出有格式错误(JSON 解析失败、tool name 有空格等),包装器可以在流式处理过程中实时修复,不需要等完整响应。
十二、Pi Agent 与 OpenClaw 的集成点
Pi Agent 不是孤立运行的,它和 OpenClaw 主进程通过多个集成点协作:
1. Session File 作为共享介质
Session JSONL 文件是 Pi Agent 和 OpenClaw 都知道如何读写的共享格式。OpenClaw 的 channel 层负责接收用户消息、追加到 session 文件;Pi Agent 读取 session 文件、执行推理、写入响应。两者通过文件系统共享状态,不需要直接的 RPC 通信。
2. Tool Result 的 Channel 层路由
Pi Agent 执行工具后的结果(tool result)通过 MessagingToolSend 等机制路由回 OpenClaw 的 channel 层:
typescript
// tool 结果通过 pi-embedded-messaging.ts 路由
export type MessagingToolSend = {
kind: "messaging";
channel: MessageChannel;
sessionKey: string;
};
这让工具执行结果可以走不同的 channel------在 Telegram 返回 Telegram 消息,在 Discord 返回 Discord 消息。
3. Hook System 的双向集成
OpenClaw 的 Hook 系统在 Pi Agent 的生命周期里插入了多个钩子:
typescript
// compaction.ts
if (hookRunner?.hasHooks("before_compaction")) {
await hookRunner.runBeforeCompaction({ messageCount, sessionFile });
}
if (hookRunner?.hasHooks("after_compaction")) {
await hookRunner.runAfterCompaction({ summary, compacted });
}
这些钩子让 OpenClaw 的扩展可以在 Pi Agent 运行时里执行自定义逻辑(比如 memory 扩展在 compaction 后更新向量索引)。
4. Sandbox Context 的延迟解析
Sandbox Context(Docker 容器信息)是在 createAgentSession 之后才解析的:
typescript
const sandbox = await resolveSandboxContext({
config: params.config,
sessionKey: params.sessionKey,
workspaceDir: resolvedWorkspace,
});
延迟解析的好处是:如果 sandbox 配置了 "enabled": false(即不使用沙箱),resolveSandboxContext 会很快返回 null,不需要实际启动 Docker daemon。
十三、Pi Agent 的设计哲学:不可变性优先
纵观整个 Pi Agent 的设计,最核心的原则是不可变性优先:
-
Session 文件是 append-only 的:不修改旧记录,只追加新记录。Compaction 时通过写入合成摘要来"替换"历史,但原始的 JSONL 行永远不会被删除或修改。
-
Message 对象是不可变的 :每个 message 有
timestamp字段,表示创建时间,但没有任何更新或删除接口。 -
Transcript Policy 是只读的 :
resolveTranscriptPolicy从配置里读取 transcript 策略,运行时不做修改。 -
Settings 是不可变的:Pi Settings 在 session 开始时固定,之后不受运行时状态影响。
不可变性的代价是某些操作(如删除某条敏感消息)需要通过 compaction 时"跳过"该消息来实现,而不是直接删除。但好处是整个系统的可测试性和可恢复性大大提升------任何时刻的 Session 状态都可以从 JSONL 文件完整重建。
小结
Pi Agent 代表了 OpenClaw 在 Agent 运行时设计上的一次深思熟虑的演进。把它拆成独立运行时带来了几个关键能力:
1. Context Window 的主动管理:Compaction + Pruning + Overflow Recovery 三层机制让 Pi Agent 可以在 context window 耗尽前主动压缩历史,而不是等到请求失败才慌乱处理。
2. Session 的进程无关持久化:JSONL append-only 格式让 Session 不绑定任何进程,主进程重启不影响正在运行的 Pi Agent session。
3. 并发隔离的 Lane 队列:Command Lanes 让不同 session 并行执行、同 session 串行排队,不需要锁或 mutex。
4. Bootstrap + History 的协同预算分配:Workspace 上下文和对话历史共用了同一个 context budget,Pi Agent 精确地管理这个分配,不会让任意一方耗尽所有空间。
5. 工具执行和 sandbox 的解耦:工具在 Pi Agent 层面执行,但 sandbox 策略由 OpenClaw config 决定,Pi Agent 核心保持对 sandbox 机制的独立。
这些设计选择组合在一起,让 OpenClaw 可以在一个进程里同时运行几十个 Pi Agent session,每个 session 有自己的 workspace、自己的 context 预算、自己的 compaction 节奏------而这一切都由同一个 pi-embedded-runner 管理。