OpenClaw Pi Agent 深度解析:嵌入式 Agent 运行时的架构设计与实现

前言

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);
    }),
  );
}

这个入口函数本身不复杂,真正的复杂度在 handleOverflowOrRetryrunEmbeddedAttempt 两个子函数里。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-123resolveSessionLane 把 session key 映射到一个队列名称,同一个 session 的所有操作共享同一个 lane,保证串行执行。

Lane 的设计还有几个精妙的细节:

  • Cron 任务的死锁避免 :如果当前已经在 Cron lane 里执行,再次发起 cron 任务会使用 Nested lane 而不是重新进入 Cron lane,避免自己等自己的死锁:

    typescript 复制代码
    if (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 压缩触发条件

压缩可以由两种事件触发:

  1. 主动压缩(manual) :用户通过 /compact 命令手动触发
  2. 被动压缩(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(自定义工具) 的分离。内置工具(如 execreadwrite)经过 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 的设计,最核心的原则是不可变性优先

  1. Session 文件是 append-only 的:不修改旧记录,只追加新记录。Compaction 时通过写入合成摘要来"替换"历史,但原始的 JSONL 行永远不会被删除或修改。

  2. Message 对象是不可变的 :每个 message 有 timestamp 字段,表示创建时间,但没有任何更新或删除接口。

  3. Transcript Policy 是只读的resolveTranscriptPolicy 从配置里读取 transcript 策略,运行时不做修改。

  4. 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 管理。

相关推荐
twl2 小时前
从 RAG 到可持续演化的知识库:llm-wiki 介绍
前端
傻小胖2 小时前
Object.defineProperty() 完整指南
开发语言·前端·javascript
里欧跑得慢2 小时前
Flutter 导航路由:构建流畅的应用导航体验
前端·css·flutter·web
@二进制2 小时前
vue3+vant4+ts出现页面空白?甚至在App.vue的<template></template>中随便输入都无法显示?
前端·vue.js·typescript
桂森滨2 小时前
Vue3+Pinia+Vite+TS 还原高性能外卖APP项目 4️⃣首页开发
前端·typescript·vue
我就是马云飞2 小时前
大专毕业两年,我如何进入大厂,并逆袭八年的技术与认知成长
前端·程序员·全栈
小陈工2 小时前
Python Web开发入门(十六):前后端分离架构设计——从“各自为政”到“高效协同”
开发语言·前端·数据库·人工智能·python
欣然~2 小时前
FachuanHybridSystem 项目 Windows 完整安装启动文档
前端
anyup2 小时前
uView Pro 的主题系统有多强大?3 分钟设计 uni-app 企业级 UI 主题
前端·vue.js·uni-app