侧边栏壁纸
博主头像
KubeAI Engineering

行动起来,活在当下

  • 累计撰写 1 篇文章
  • 累计创建 7 个标签
  • 累计收到 0 条评论

目 录CONTENT

文章目录

OpenClaw Gateway 源码解析

一、项目概览

OpenClaw 自定义为 "Multi-channel AI gateway with extensible messaging integrations"——一个多通道 AI 网关,核心能力是把来自不同消息平台(Telegram、Discord、Slack、WhatsApp、Signal、iMessage、LINE 等)的消息统一接入,路由给 AI Agent 处理,并将回复分发回对应通道。

技术栈

  • 语言:TypeScript(Node.js >= 22)

  • 模块系统:ESM("type": "module"

  • 构建:编译到 dist/,入口 openclaw.mjsdist/entry.js

  • 包管理:pnpm

  • 测试:Vitest(多套配置:gateway / channels / extensions / e2e / live)

核心设计理念

  1. Gateway 即中枢:所有通道消息最终汇聚到 Gateway 进程,通过 WebSocket 协议与客户端(CLI、Control UI、Mobile Node)交互

  2. Agent 是执行者:Gateway 不直接调 LLM,而是委托给 Agent 子系统(支持内嵌 Pi Agent、ACP 协议代理等)

  3. Channel 即插件:每个消息平台是一个 Channel Plugin,遵循统一接口,支持热加载和多账户

  4. Session 是路由键:所有消息路由基于 sessionKey(格式 agent:<agentId>:<rest>),实现多 Agent、多通道、多用户的隔离


二、启动流程

2.1 入口链路

openclaw.mjs
  → src/entry.ts          # 进程初始化、环境变量、respawn 守护
    → src/cli/program.ts  # Commander.js CLI 框架
      → `openclaw gateway start`
        → src/gateway/server.impl.ts → startGatewayServer()

entry.ts 关键职责:

  • 设置 process.title = "openclaw"

  • 安装编译缓存(enableCompileCache

  • 抑制实验性警告(ExperimentalWarning),必要时 respawn 子进程

  • 处理 --version / --help 快速路径

  • 加载 CLI profile 环境变量

  • 最终调用 runCli(process.argv)

index.ts 是库入口,导出了核心 API(loadConfigrunExecwaitForever 等),也能直接作为 CLI 入口运行。

2.2 Gateway 启动(startGatewayServer

这是整个系统的核心启动函数server.impl.ts,约 1000 行),启动流程如下:

1. 加载 & 校验配置(loadConfig → readConfigFileSnapshot)
2. 自动迁移旧配置(migrateLegacyConfig)
3. 自动启用插件(applyPluginAutoEnable)
4. 激活 Secrets 运行时(密钥解析、引用替换)
5. 确保 Gateway Auth(token/password 自动生成)
6. 初始化子系统:
   ├── 子代理注册表(initSubagentRegistry)
   ├── 插件系统(loadGatewayPlugins)
   ├── Channel 管理器(createChannelManager)
   ├── 模型目录(loadGatewayModelCatalog)
   ├── Node 注册表(NodeRegistry)
   ├── Cron 服务(buildGatewayCronService)
   ├── 并发控制(applyGatewayLaneConcurrency)
   ├── Health Monitor
   └── Browser Control Server
7. 创建 HTTP/HTTPS 服务器
8. 挂载 WebSocket 处理器(attachGatewayWsHandlers)
9. 启动 Sidecar 服务:
   ├── Channel 连接(Telegram polling、Discord bot 等)
   ├── Hooks(Gmail watcher 等)
   ├── 浏览器控制服务
   ├── Plugin 服务
   └── Memory 后端
10. 启动 Discovery(Bonjour/mDNS)
11. 启动 Tailscale 暴露(可选)
12. 启动 Config Reloader(文件监听热重载)
13. 运行 BOOT.md(启动后一次性任务)
14. 启动 Heartbeat Runner
15. 启动 Maintenance Timers(清理过期媒体等)

三、核心架构

3.1 整体架构图

┌─────────────────────────────────────────────────────────────┐
│                      OpenClaw Gateway                        │
│                                                              │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌──────────────┐ │
│  │ Telegram  │  │ Discord  │  │  Slack   │  │ WhatsApp/... │ │
│  │ Channel   │  │ Channel  │  │ Channel  │  │  Channels    │ │
│  └────┬─────┘  └────┬─────┘  └────┬─────┘  └──────┬───────┘ │
│       │              │              │               │         │
│       └──────────────┼──────────────┼───────────────┘         │
│                      ▼                                        │
│              ┌───────────────┐                                │
│              │  Session Router │  ← sessionKey 路由           │
│              └───────┬───────┘                                │
│                      ▼                                        │
│  ┌───────────────────────────────────────────┐               │
│  │              Agent 子系统                   │               │
│  │  ┌─────────┐  ┌──────────┐  ┌──────────┐ │               │
│  │  │Pi Agent │  │ACP Agent │  │Subagent  │ │               │
│  │  │(内嵌)   │  │(外部)    │  │(子代理)  │ │               │
│  │  └────┬────┘  └────┬─────┘  └────┬─────┘ │               │
│  └───────┼────────────┼─────────────┼────────┘               │
│          └────────────┼─────────────┘                         │
│                       ▼                                       │
│              ┌───────────────┐                                │
│              │ LLM Providers  │  Anthropic / OpenAI / ...     │
│              └───────────────┘                                │
│                                                              │
│  ┌────────────────┐  ┌──────┐  ┌────────┐  ┌──────────────┐ │
│  │ Control UI (Web)│  │ Cron │  │ Hooks  │  │ Node Registry│ │
│  └────────────────┘  └──────┘  └────────┘  └──────────────┘ │
│                                                              │
│  ┌──────────┐  ┌──────────┐  ┌───────────┐  ┌────────────┐  │
│  │ Secrets  │  │ Plugins  │  │  Browser  │  │  Memory    │  │
│  └──────────┘  └──────────┘  └───────────┘  └────────────┘  │
└─────────────────────────────────────────────────────────────┘
         ▲                              ▲
         │ WebSocket (JSON-RPC)         │ HTTP
         ▼                              ▼
  ┌──────────────┐              ┌──────────────┐
  │  CLI Client  │              │ OpenAI API   │
  │  Control UI  │              │ Compat       │
  │  Mobile Node │              │ (chat/compl) │
  └──────────────┘              └──────────────┘

3.2 关键模块职责

目录

职责

关键文件

src/gateway/

Gateway 核心:服务器启动、WS/HTTP 处理、认证、方法路由

server.impl.ts, server-methods.ts, server-http.ts

src/agents/

Agent 运行时:模型选择、工具调用、Session 管理、子代理

agent-scope.ts, cli-runner.ts, bash-tools.ts

src/commands/

命令层agent 命令(核心对话入口)、认证选择、多 Agent 管理

agent.ts, agents.ts

src/channels/

通道插件系统:统一 Channel 抽象、消息动作、配置 schema

plugins/index.ts, plugins/types.ts

src/config/

配置管理:加载、校验、迁移、Session 存储

config.ts, sessions.ts

src/cron/

定时任务:调度服务、隔离 Agent 执行、Webhook 投递

service.ts, isolated-agent.ts

src/sessions/

会话管理:Session ID、模型覆盖、发送策略

session-key-utils.ts, model-overrides.ts

src/providers/

LLM 提供商适配:GitHub Copilot、Google、通义千问等

各 provider 适配文件

src/plugins/

插件系统:加载器、运行时、Plugin SDK

loader.ts, runtime/, registry.ts

src/infra/

基础设施:错误处理、环境检测、TLS、Tailscale、心跳

众多基础工具文件

src/hooks/

外部触发:HTTP Hooks、Gmail Watcher、内部 Hook

internal-hooks.ts, gmail-watcher-lifecycle.ts

src/secrets/

密钥管理:运行时快照、引用解析、命令级密钥分配

runtime.ts, command-config.ts

src/acp/

ACP 协议:Agent Communication Protocol,支持外部 Agent 接入

control-plane/, runtime/, policy.ts

src/browser/

浏览器控制:Playwright 驱动的浏览器自动化

浏览器控制相关

src/routing/

路由层:Session Key 构造、账户 ID 标准化

session-key.ts, account-id.ts


四、消息处理流程

4.1 入站消息(以 Telegram 为例)

Telegram Bot API (polling/webhook)
  → src/telegram/  (Channel 适配层)
    → 消息标准化(提取 sender、chat_id、text、attachments)
    → 构造 sessionKey: "agent:creator:telegram:5534067368"
    → 调用 agentCommand()  (src/commands/agent.ts)
      → Session 管理:加载/创建 session entry
      → 构建 System Prompt(SOUL.md + AGENTS.md + 工具定义)
      → 调用 Agent 运行时(Pi Agent 内嵌 / ACP 外部代理)
        → LLM 调用(Anthropic/OpenAI/...)
        → 工具调用循环(exec、read、write、web_search...)
        → 生成最终回复
      → 回复路由:通过 Channel 适配层回发到 Telegram

4.2 agentCommand 核心逻辑

src/commands/agent.ts最核心的命令,负责:

  1. Session 解析:根据 sessionKey 确定 agentId、加载 session 历史

  2. 模型选择resolveDefaultModelForAgent → 支持 per-session override、failover

  3. Prompt 构建:组装 system prompt(身份、工具、技能、约束)

  4. Agent 执行

    • 内嵌模式:runEmbeddedPiAgent()(@mariozechner/pi-coding-agent)

    • ACP 模式:通过 ACP Control Plane 委托外部代理

  5. 回复投递normalizeReplyPayload → Channel 分发

4.3 Session Key 体系

Session Key 是整个路由系统的核心标识:

格式:agent:<agentId>:<rest>
示例:
  agent:creator:telegram:5534067368     # Telegram 私聊
  agent:creator:discord:guild:channel   # Discord 频道
  agent:worker:main                     # 主 session
  agent:creator:subagent:<uuid>         # 子代理 session
  agent:creator:cron:<jobId>            # Cron 任务 session
  agent:creator:acp:<sessionId>         # ACP 协议 session

Session Store(JSON 文件)维护每个 session 的状态:

  • 对话 ID(sessionId,映射到 LLM conversation)

  • 历史记录引用

  • 模型覆盖

  • 认证 profile


五、Gateway 通信协议

5.1 WebSocket 协议

Gateway 通过 WebSocket 暴露 JSON-RPC 风格的方法调用接口:

连接认证

  • Token 模式(gateway.auth.token

  • Password 模式

  • 设备认证(Mobile Node)

  • Rate Limiting 防暴力破解

核心方法分类

类别

方法

说明

连接

connect

客户端握手、角色声明、能力协商

聊天

chat.send, chat.abort

发送消息、中止生成

会话

sessions.*

会话列表、历史、重置、发送

配置

config.*

获取/应用/patch 配置

定时

cron.*

管理定时任务

节点

nodes.*

移动节点管理(相机、屏幕、位置)

Agent

agent.*, agents.*

Agent 管理、多 Agent 配置

工具

tools.*

工具目录、调用

系统

health, update.*, system.*

健康检查、更新、系统信息

5.2 HTTP 端点

除 WebSocket 外,Gateway 还提供 HTTP 服务:

  • Control UI:Web 管理界面(React SPA,通过 CSP 保护)

  • OpenAI 兼容 APIPOST /v1/chat/completions

  • OpenResponses APIPOST /v1/responses

  • HooksPOST /hook/:id — 外部系统触发 Agent

  • Slack HTTP:Slack Events API 回调

  • Canvas A2UI:Canvas 渲染 WebSocket


六、插件与扩展系统

6.1 Channel Plugin 体系

每个消息通道实现 ChannelPlugin 接口:

// 简化示意
interface ChannelPlugin {
  id: ChannelId;           // "telegram" | "discord" | ...
  meta: { order: number }; // 排序权重
  status?: { defaultRuntime: ChannelAccountSnapshot };
  gatewayMethods?: string[];  // 额外的 Gateway 方法
  // 启动/停止、消息发送、动作处理...
}

多账户支持:每个 Channel 可配置多个账户(如多个 Telegram bot),每个账户独立连接、独立路由。

6.2 Plugin SDK

OpenClaw 提供 Plugin SDK(openclaw/plugin-sdk),允许第三方开发:

  • 自定义 Channel 插件

  • 自定义 Gateway 方法

  • Hook 处理器

导出路径:

openclaw/plugin-sdk       # 核心
openclaw/plugin-sdk/core  # 基础类型
openclaw/plugin-sdk/compat  # 兼容层
openclaw/plugin-sdk/telegram  # Telegram 专用
openclaw/plugin-sdk/discord   # Discord 专用

6.3 Skills 系统

Skills 是 Agent 级别的能力扩展:

  • 每个 Skill 包含 SKILL.md(指令)和可选脚本

  • 运行时通过 buildWorkspaceSkillSnapshot 扫描并注入 system prompt

  • 支持远程 Skill 注册表(skills-remote.ts


七、关键设计亮点(源码级解析)

7.1 Lane 并发控制——全局任务调度器

问题:Gateway 同时处理用户消息、Cron 任务、子代理调用,如果无控制地并发执行 Agent,会出现 stdin/stdout 交错、上下文混乱、LLM token 超支等问题。

解决方案src/process/command-queue.ts 实现了一个多车道任务队列,不同类型的工作隔离到不同 Lane。

核心数据结构

// src/process/lanes.ts — 4个车道,枚举值即 Map key
export const enum CommandLane {
  Main = "main",        // 用户主对话
  Cron = "cron",        // 定时任务
  Subagent = "subagent", // 子代理
  Nested = "nested",    // 嵌套调用
}

每个 Lane 维护独立状态:

type LaneState = {
  lane: string;
  queue: QueueEntry[];           // 等待队列
  activeTaskIds: Set<number>;    // 正在执行的任务 ID 集合
  maxConcurrent: number;         // 最大并发数(Main 默认 1)
  draining: boolean;             // 是否正在泵送
  generation: number;            // 代际计数器(restart 时递增)
};

调度核心:pump() 函数

调度逻辑在 drainLane() 内部的 pump() 闭包中:

const pump = () => {
  try {
    // 只要活跃任务数 < 最大并发 且 队列非空,就继续取任务
    while (state.activeTaskIds.size < state.maxConcurrent && state.queue.length > 0) {
      const entry = state.queue.shift() as QueueEntry;
      // 检查等待时间,超阈值则触发 onWait 回调 + 诊断日志
      const waitedMs = Date.now() - entry.enqueuedAt;
      if (waitedMs >= entry.warnAfterMs) {
        entry.onWait?.(waitedMs, state.queue.length);
      }
      const taskId = queueState.nextTaskId++;
      const taskGeneration = state.generation;
      state.activeTaskIds.add(taskId);
      // 异步执行,完成后递归调用 pump() 泵送下一个
      void (async () => {
        try {
          const result = await entry.task();
          // generation 校验:如果中途 restart 了,旧任务的完成不影响新 generation
          const completedCurrentGeneration = completeTask(state, taskId, taskGeneration);
          if (completedCurrentGeneration) { pump(); }
          entry.resolve(result);
        } catch (err) {
          // ... 错误处理,同样 pump()
        }
      })();
    }
  } finally { state.draining = false; }
};

关键设计点

  • Generation 机制state.generation):Gateway 通过 SIGUSR1 信号原地重启时,调用 resetAllLanes() 递增所有 Lane 的 generation。旧任务的 completeTask() 会因 generation 不匹配而返回 false,不会触发 pump,避免「僵尸任务完成后把新队列的任务提前弹出」的竞态。

  • Draining 标记:Gateway 关闭时调用 markGatewayDraining(),此后所有 enqueueCommandInLane() 立即 reject GatewayDrainingError,实现优雅退出。

  • 全局单例:通过 Symbol.for("openclaw.commandQueueState") 挂载到 globalThis,确保即使被多个 bundle chunk 导入,也共享同一份队列状态。

启动时的并发配置

// src/gateway/server-lanes.ts
export function applyGatewayLaneConcurrency(cfg) {
  setCommandLaneConcurrency(CommandLane.Cron, cfg.cron?.maxConcurrentRuns ?? 1);
  setCommandLaneConcurrency(CommandLane.Main, resolveAgentMaxConcurrent(cfg));
  setCommandLaneConcurrency(CommandLane.Subagent, resolveSubagentMaxConcurrent(cfg));
}

Main Lane 默认并发 1,意味着用户消息严格串行处理——这是有意为之,保证单用户场景下对话上下文的一致性。Cron 和 Subagent 可独立配置更高并发。


7.2 Config 热重载——变更精细到字段级

问题:修改配置后需要重启整个 Gateway 吗?对于改个 Telegram token 就要中断所有通道连接,代价太大。

解决方案src/gateway/config-reload.ts + config-reload-plan.ts 实现了字段级变更检测 + 分级重载策略

三层重载策略

// config-reload-plan.ts 中的 ReloadRule
type ReloadRule = {
  prefix: string;                    // 配置路径前缀
  kind: "restart" | "hot" | "none";  // restart=需要全量重启 | hot=热重载 | none=忽略
  actions?: ReloadAction[];          // hot 时执行的具体动作
};

具体规则举例:

配置路径

策略

动作

hooks.gmail

hot

restart-gmail-watcher

cron

hot

restart-cron

browser

hot

restart-browser-control

agents.defaults.heartbeat

hot

restart-heartbeat

telegram.*

hot

restart-channel:telegram(由 Channel Plugin 动态注册)

gateway.*(其他)

restart

全量重启

agents, tools, identity

none

不需要重载(下次 Agent 执行自然读到新值)

变更检测:深度 diff

// 递归比较两份配置的每个字段路径
export function diffConfigPaths(prev: unknown, next: unknown, prefix = ""): string[] {
  if (prev === next) return [];
  if (isPlainObject(prev) && isPlainObject(next)) {
    // 逐 key 递归,收集所有变更路径
    const keys = new Set([...Object.keys(prev), ...Object.keys(next)]);
    const paths: string[] = [];
    for (const key of keys) {
      const childPaths = diffConfigPaths(prev[key], next[key], `${prefix}.${key}`);
      paths.push(...childPaths);
    }
    return paths;
  }
  // 数组使用 deepStrictEqual 避免顺序相同但引用不同的误报
  if (Array.isArray(prev) && Array.isArray(next)) {
    if (isDeepStrictEqual(prev, next)) return [];
  }
  return [prefix || "<root>"];
}

输出示例:["telegram.token", "cron.maxConcurrentRuns"]——只有这两个字段变了。

Reload Plan 生成

export function buildGatewayReloadPlan(changedPaths: string[]): GatewayReloadPlan {
  // 对每个变更路径,匹配规则列表
  for (const path of changedPaths) {
    const rule = matchRule(path);
    if (!rule) {
      // 没有匹配的规则?安全起见,要求全量重启
      plan.restartGateway = true;
      continue;
    }
    if (rule.kind === "restart") { plan.restartGateway = true; }
    if (rule.kind === "hot") {
      // 执行对应的热重载动作
      for (const action of rule.actions ?? []) { applyAction(action); }
    }
    // rule.kind === "none" → 忽略
  }
  return plan;
}

生成的 Plan 是一个精确的「操作清单」:

type GatewayReloadPlan = {
  restartGateway: boolean;           // 是否需要全量重启
  restartReasons: string[];          // 导致全量重启的路径
  hotReasons: string[];              // 可以热重载的路径
  reloadHooks: boolean;              // 重载 Hook 配置
  restartGmailWatcher: boolean;      // 重启 Gmail 监听
  restartBrowserControl: boolean;    // 重启浏览器控制
  restartCron: boolean;              // 重启 Cron 服务
  restartHeartbeat: boolean;         // 重启心跳
  restartHealthMonitor: boolean;     // 重启健康监控
  restartChannels: Set<ChannelKind>; // 需要重启的通道(精确到单个通道)
  noopPaths: string[];               // 不需要任何操作的变更
};

文件监听:chokidar + 防抖

const watcher = chokidar.watch(opts.watchPath, {
  ignoreInitial: true,
  awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 },
});
// 变更事件触发 300ms 防抖(可配置)后执行 reload
watcher.on("change", schedule);

还有写入抖动容错:配置文件可能被编辑器写入时短暂清空,handleMissingSnapshot 会重试最多 2 次(间隔 150ms),避免误判为配置丢失。

重载模式:四档可选

mode: "off"     // 关闭自动重载
     | "restart" // 所有变更都触发全量重启
     | "hot"     // 尽量热重载,需要重启的变更被忽略
     | "hybrid"  // 默认:能热重载就热重载,否则全量重启

7.3 Secrets 运行时——Snapshot + 降级保护

问题:API Key、Token 等敏感信息分散在配置文件、环境变量、Auth Profile Store 中,运行时频繁读取既慢又危险(文件可能被临时锁定/损坏)。

解决方案src/secrets/runtime.ts 实现了预解析 → 快照激活 → 降级保护三步机制。

第一步:预解析(prepareSecretsRuntimeSnapshot

启动时扫描所有需要密钥的地方,统一解析:

export async function prepareSecretsRuntimeSnapshot(params) {
  const sourceConfig = structuredClone(params.config);     // 原始配置(含 $ref)
  const resolvedConfig = structuredClone(params.config);   // 将被替换为真实值

  // 1. 收集配置中的所有密钥引用
  collectConfigAssignments({ config: resolvedConfig, context });

  // 2. 收集每个 Agent 的 Auth Profile Store 中的引用
  for (const agentDir of candidateDirs) {
    const store = structuredClone(loadAuthStore(agentDir));
    collectAuthStoreAssignments({ store, context, agentDir });
    authStores.push({ agentDir, store });
  }

  // 3. 批量解析所有引用(一次性调外部密钥源)
  if (context.assignments.length > 0) {
    const refs = context.assignments.map(a => a.ref);
    const resolved = await resolveSecretRefValues(refs, { config, env, cache });
    applyResolvedAssignments({ assignments: context.assignments, resolved });
  }

  return { sourceConfig, config: resolvedConfig, authStores, warnings, webTools };
}

关键sourceConfig 保留原始引用($ref:vault/xxx),resolvedConfig 包含真实值。运行时所有模块读 resolvedConfig,不需要知道密钥来自哪里。

第二步:激活快照

export function activateSecretsRuntimeSnapshot(snapshot) {
  const next = cloneSnapshot(snapshot);  // 深拷贝,防止外部修改
  // 注入到全局运行时配置
  setRuntimeConfigSnapshot(next.config, next.sourceConfig);
  // 注入到所有 Agent 的 Auth Profile
  replaceRuntimeAuthProfileStoreSnapshots(next.authStores);
  activeSnapshot = next;

  // 注册刷新回调——config 变更时可重新解析密钥
  setRuntimeConfigSnapshotRefreshHandler({
    refresh: async ({ sourceConfig }) => {
      const refreshed = await prepareSecretsRuntimeSnapshot({
        config: sourceConfig, env, agentDirs, loadAuthStore,
      });
      activateSecretsRuntimeSnapshot(refreshed);
      return true;
    },
  });
}

第三步:降级保护

Config 热重载时重新解析密钥可能失败(外部密钥源不可达),此时的处理策略:

// server.impl.ts 中的 activateRuntimeSecrets
try {
  const prepared = await prepareSecretsRuntimeSnapshot({ config });
  activateSecretsRuntimeSnapshot(prepared);
  // 如果之前降级过,现在恢复了
  if (secretsDegraded) {
    emitSecretsStateEvent("SECRETS_RELOADER_RECOVERED", ...);
  }
  secretsDegraded = false;
} catch (err) {
  if (!secretsDegraded) {
    // 第一次降级:告警
    emitSecretsStateEvent("SECRETS_RELOADER_DEGRADED",
      "Secret resolution failed; runtime remains on last-known-good snapshot.");
  }
  secretsDegraded = true;
  if (params.reason === "startup") {
    throw err;  // 启动时不能降级,直接挂掉
  }
  // 运行时降级:继续用旧快照
}

设计哲学:启动时 fail-fast(密钥必须可用),运行时 fail-safe(降级到上一次已知良好的快照)。


7.4 BOOT.md——启动后的一次性 Agent 任务

问题:有些操作需要在 Gateway 启动后自动执行一次(比如发送「我上线了」通知、检查环境状态),但不应该污染用户的主对话历史。

解决方案src/gateway/boot.ts 实现了一个隔离的启动任务机制。

核心流程

export async function runBootOnce(params) {
  // 1. 读取 BOOT.md
  const result = await loadBootFile(params.workspaceDir);
  if (result.status === "missing" || result.status === "empty") {
    return { status: "skipped", reason: result.status };
  }

  // 2. 快照当前 session mapping(保存主会话的 session 状态)
  const mappingSnapshot = snapshotMainSessionMapping({ cfg, sessionKey });

  // 3. 生成临时 session ID,执行 Agent
  const sessionId = generateBootSessionId();  // "boot-2026-03-13_10-00-00-a1b2c3d4"
  const message = buildBootPrompt(result.content);
  await agentCommand({
    message,
    sessionKey,
    sessionId,
    deliver: false,       // 不投递到通道
    senderIsOwner: true,  // 以 owner 身份执行
  });

  // 4. 恢复 session mapping(Boot 执行不应改变主会话的 session 指针)
  await restoreMainSessionMapping(mappingSnapshot);
}

精妙之处:通过 snapshotMainSessionMappingrestoreMainSessionMapping 的快照/恢复机制,确保 Boot 执行期间的 session 变更(新 sessionId 写入 store)不会影响主会话。用户下次发消息时,session 仍然指向 Boot 前的对话。


7.5 Heartbeat——不只是心跳,是 Agent 自主巡检系统

问题:Agent 如何在没有用户输入的情况下主动检查任务、响应定时事件?

解决方案src/infra/heartbeat-runner.ts(约 700 行)实现了一个远超「ping-pong」的自主巡检系统。

多 Agent 调度

Heartbeat Runner 不是简单的 setInterval,而是为每个需要心跳的 Agent 维护独立调度状态:

type HeartbeatAgentState = {
  agentId: string;
  heartbeat?: HeartbeatConfig;
  intervalMs: number;
  lastRunMs?: number;
  nextDueMs: number;          // 下次应执行的时间
};
// state.agents = Map<agentId, HeartbeatAgentState>

scheduleNext() 遍历所有 Agent,找到最近的 nextDueMs,设置一个 setTimeout

const scheduleNext = () => {
  let nextDue = Number.POSITIVE_INFINITY;
  for (const agent of state.agents.values()) {
    if (agent.nextDueMs < nextDue) nextDue = agent.nextDueMs;
  }
  const delay = Math.max(0, nextDue - Date.now());
  state.timer = setTimeout(() => {
    requestHeartbeatNow({ reason: "interval", coalesceMs: 0 });
  }, delay);
  state.timer.unref?.();  // 不阻止进程退出
};

执行前的多层门控(Preflight)

每次心跳执行前,resolveHeartbeatPreflight 会做一连串检查:

1. areHeartbeatsEnabled()            → 全局开关
2. isHeartbeatEnabledForAgent()      → 该 Agent 是否启用心跳
3. resolveHeartbeatIntervalMs()      → 间隔是否有效
4. isWithinActiveHours()             → 是否在活跃时段(可配安静时段)
5. getQueueSize(CommandLane.Main)    → 主车道是否有任务在跑(有则跳过,不打断用户对话)
6. HEARTBEAT.md 文件内容检查          → 文件为空/纯注释 → 跳过

第 5 点尤其关键:心跳执行会让位于用户消息。如果 Main Lane 有活跃任务,心跳直接返回 "requests-in-flight",不入队等待。

三种触发源

心跳不只是定时触发,还有事件驱动触发:

function resolveHeartbeatReasonFlags(reason?: string) {
  return {
    isExecEventReason: ...,   // 子代理执行完成事件
    isCronEventReason: ...,   // Cron 任务完成事件
    isWakeReason: ...,        // 外部 wake 唤醒或 Hook 触发
  };
}

事件触发时,即使 HEARTBEAT.md 为空也会执行(shouldBypassFileGates),因为此时心跳是为了处理待投递的系统事件。

Transcript 清理

HEARTBEAT_OK(无事可做)的心跳回复不应污染对话历史:

// 心跳前记录 transcript 文件大小
const transcriptState = await captureTranscriptState({ storePath, sessionKey, agentId });

// 心跳执行...

// 如果结果是 HEARTBEAT_OK,truncate 回原来的大小
if (shouldSkip) {
  await pruneHeartbeatTranscript(transcriptState);
  // 同时恢复 session 的 updatedAt 时间戳
  await restoreHeartbeatUpdatedAt({ storePath, sessionKey, updatedAt: previousUpdatedAt });
}

设计哲学:心跳是幽灵——如果没事发生,它来过的痕迹会被完全抹除(transcript 回滚、updatedAt 恢复)。

去重机制

Agent 可能在连续几次心跳中重复输出相同的提醒文本(「你有一个未完成的任务」),系统内置了去重:

const isDuplicateMain =
  normalized.text.trim() === prevHeartbeatText.trim() &&   // 文本完全相同
  startedAt - prevHeartbeatAt < 24 * 60 * 60 * 1000;       // 且距离上次发送不到 24 小时
if (isDuplicateMain) {
  // 跳过发送,回滚 transcript
  await pruneHeartbeatTranscript(transcriptState);
  emitHeartbeatEvent({ status: "skipped", reason: "duplicate" });
}

7.6 Channel Health Monitor——通道自愈

问题:消息通道(Telegram、Slack 等)可能出现「半死」状态——连接看起来存在,但实际上不再收到消息(典型的 WebSocket 静默断开)。

解决方案src/gateway/channel-health-monitor.ts 实现了定期健康检查 + 自动重启。

核心参数

const DEFAULT_CHECK_INTERVAL_MS = 5 * 60_000;       // 每 5 分钟检查一次
const DEFAULT_MONITOR_STARTUP_GRACE_MS = 60_000;     // 启动后 60s 宽限期
const DEFAULT_COOLDOWN_CYCLES = 2;                   // 重启后冷却 2 个周期
const DEFAULT_MAX_RESTARTS_PER_HOUR = 10;            // 每小时最多重启 10 次

检测逻辑

// channel-health-policy.ts
export function evaluateChannelHealth(params): ChannelHealthPolicy {
  // 检查通道最后收到事件的时间
  // 如果超过 staleEventThresholdMs(默认 10 分钟),标记为 stale
  // 结合连接状态综合判断是否需要重启
}

重启保护

  • 冷却期:重启后需要等待 cooldownCycles 个检查周期才能再次重启,避免频繁抖动

  • 限流:每小时最多 maxRestartsPerHour 次,防止因配置错误导致无限重启循环

  • 启动宽限期:Gateway 刚启动时,Channel 可能还在连接中,不触发健康检查


7.7 Broadcast 事件分发——Scope 级权限控制

Gateway 的 WebSocket 客户端(CLI、Control UI、Mobile Node)通过广播接收实时事件。但不是所有客户端都应该看到所有事件:

// server-broadcast.ts
const EVENT_SCOPE_GUARDS: Record<string, string[]> = {
  "exec.approval.requested": [APPROVALS_SCOPE],  // 执行审批事件
  "exec.approval.resolved": [APPROVALS_SCOPE],
  "device.pair.requested": [PAIRING_SCOPE],       // 设备配对事件
  "device.pair.resolved": [PAIRING_SCOPE],
  "node.pair.requested": [PAIRING_SCOPE],
  "node.pair.resolved": [PAIRING_SCOPE],
};

function hasEventScope(client: GatewayWsClient, event: string): boolean {
  const required = EVENT_SCOPE_GUARDS[event];
  if (!required) return true;  // 没有限制的事件,所有人可见
  const scopes = client.connect.scopes ?? [];
  if (scopes.includes(ADMIN_SCOPE)) return true;  // admin 全权
  return required.some(scope => scopes.includes(scope));
}

还有背压保护:通过 MAX_BUFFERED_BYTES 限制单个客户端的发送缓冲,慢客户端的事件会被丢弃(dropIfSlow),不拖慢整个 Gateway。


八、Agent 运行时详解(源码级解析)

8.1 全局视角:消息从入站到 Agent 执行的完整链路

用户消息(Telegram/Discord/...)
  │
  ▼
Channel 适配层(标准化消息、提取元数据)
  │
  ▼
agentCommand()                    ← src/commands/agent.ts(核心入口,~1200 行)
  │
  ├─ Session 解析 ← resolveSessionAgentId / loadSessionEntry
  ├─ 模型选择 ← resolveDefaultModelForAgent / Auth Profile 轮换
  ├─ 工具集构建 ← createOpenClawCodingTools()     ← 本章重点
  ├─ Skills 快照 ← buildWorkspaceSkillSnapshot()   ← 本章重点
  ├─ System Prompt 组装 ← buildSystemPrompt()
  │
  ▼
runEmbeddedPiAgent()              ← src/agents/pi-embedded-runner/run.ts
  │
  ├─ 入队 Lane(enqueueCommandInLane)
  ├─ Auth Profile 解析(API Key / OAuth)
  ├─ 重试循环(failover、context overflow compaction、rate limit)
  │
  ▼
Pi Agent SDK(@mariozechner/pi-coding-agent)
  │
  ├─ LLM 调用(Anthropic / OpenAI / Google / ...)
  ├─ 流式响应 ← pi-embedded-subscribe 事件处理
  │     ├─ tool_use 事件 → 工具调度
  │     ├─ text 事件 → 实时广播给 WS 客户端
  │     └─ error 事件 → failover 判断
  │
  ▼
工具调用循环(本章重点)
  │
  ├─ before_tool_call hook → 循环检测 / 插件拦截
  ├─ 工具执行 → 返回结果
  ├─ after_tool_call hook → 插件通知
  └─ 结果注入 → 继续 LLM 推理 → 直到无工具调用

8.2 工具集构建:createOpenClawCodingTools()

这是整个工具体系的组装工厂src/agents/pi-tools.ts,约 620 行),输入是一堆上下文参数,输出是一个 AnyAgentTool[] 数组——Agent 能用的所有工具。

工具的三大来源

┌─────────────────────────────────────────────────┐
│            createOpenClawCodingTools()            │
│                                                  │
│  ① 基础编码工具(Pi SDK 内置)                     │
│     read / write / edit / exec / process          │
│                                                  │
│  ② OpenClaw 平台工具(自研)                       │
│     browser / canvas / nodes / cron / message     │
│     gateway / tts / web_search / web_fetch        │
│     sessions_* / subagents / memory_* / image     │
│                                                  │
│  ③ 插件工具(Plugin SDK 第三方)                    │
│     由 resolvePluginTools() 从已注册插件中加载       │
│                                                  │
└─────────────────────────────────────────────────┘

① 基础编码工具的沙箱适配

基础工具来自 @mariozechner/pi-coding-agentcodingTools,但 OpenClaw 不是直接用,而是逐个替换

const base = (codingTools as AnyAgentTool[]).flatMap((tool) => {
  if (tool.name === readTool.name) {
    if (sandboxRoot) {
      // 沙箱模式:文件读写通过 Docker 容器内的 fs bridge
      return [createSandboxedReadTool({ root: sandboxRoot, bridge: sandboxFsBridge })];
    }
    // 非沙箱模式:创建带 workspace root 感知的 read tool
    const freshReadTool = createReadTool(workspaceRoot);
    return [createOpenClawReadTool(freshReadTool, { modelContextWindowTokens, imageSanitization })];
  }
  if (tool.name === "bash" || tool.name === "exec") {
    return [];  // 不用原生 bash,用 OpenClaw 自己的 exec tool
  }
  if (tool.name === "write") {
    if (sandboxRoot) return [];  // 沙箱模式下用 SandboxedWriteTool
    return [createHostWorkspaceWriteTool(workspaceRoot, { workspaceOnly })];
  }
  // ... edit 同理
});

设计意图:Pi SDK 的工具是通用的,OpenClaw 需要加上沙箱隔离、workspace 限制、图片尺寸清洗、context window 感知等额外逻辑。

② OpenClaw 平台工具

createOpenClawTools()src/agents/openclaw-tools.ts)创建 OpenClaw 特有的工具:

const tools: AnyAgentTool[] = [
  createBrowserTool({ ... }),        // 浏览器自动化(Playwright)
  createCanvasTool({ ... }),         // Canvas 渲染
  createNodesTool({ ... }),          // 移动设备控制
  createCronTool({ ... }),           // 定时任务管理
  createMessageTool({ ... }),        // 消息发送(跨通道)
  createTtsTool({ ... }),            // 文字转语音
  createGatewayTool({ ... }),        // Gateway 自管理
  createAgentsListTool({ ... }),     // 多 Agent 列表
  createSessionsListTool({ ... }),   // Session 列表
  createSessionsHistoryTool({ ... }),// Session 历史
  createSessionsSendTool({ ... }),   // 跨 Session 发送
  createSessionsSpawnTool({ ... }),  // 子代理 spawn
  createSubagentsTool({ ... }),      // 子代理管理
  createSessionStatusTool({ ... }),  // Session 状态
  createWebSearchTool({ ... }),      // Web 搜索(Brave / Tavily)
  createWebFetchTool({ ... }),       // Web 抓取(Firecrawl / 内置)
  createImageTool({ ... }),          // 图片生成
  createPdfTool({ ... }),            // PDF 解析
];

每个工具都是一个对象,包含 namedescriptionparameters(JSON Schema)和 execute 函数。

③ 插件工具

const pluginTools = resolvePluginTools({
  context: { config, workspaceDir, agentDir, sessionKey, ... },
  existingToolNames: new Set(tools.map(t => t.name)),  // 防止名称冲突
  toolAllowlist: pluginToolAllowlist,
});
return [...tools, ...pluginTools];

插件工具通过 Plugin SDK 注册,运行时由 resolvePluginToolssrc/plugins/tools.ts)实例化。关键保护:

  • 名称冲突检测:插件工具名不能和核心工具重名,否则整个插件的工具被跳过

  • Allowlist 过滤:可选工具(optional: true)需要显式出现在 allowlist 中才会加载

  • WeakMap 元数据标记:每个插件工具通过 pluginToolMeta WeakMap 打上 { pluginId, optional } 标记,后续策略管线用这个区分核心工具和插件工具


8.3 工具策略管线:9 层过滤

工具集构建完毕后,要经过9 层策略管线过滤,决定哪些工具最终对 Agent 可见:

工具全集(~30+ 个)
  │
  ├─ ① applyMessageProviderToolPolicy   → 按消息来源过滤(如 voice 场景禁用 tts)
  ├─ ② applyModelProviderToolPolicy     → 按模型提供商过滤(如 xAI 有原生 web_search,禁用 OpenClaw 的)
  ├─ ③ applyOwnerOnlyToolPolicy         → Owner-only 工具检查(非 owner 用户不可用的工具)
  ├─ ④ applyToolPolicyPipeline          → 7 步策略管线(核心过滤)
  │     ├─ tools.profile (coding/minimal/full)
  │     ├─ tools.byProvider.profile
  │     ├─ tools.allow(全局白名单)
  │     ├─ tools.byProvider.allow
  │     ├─ agents.<id>.tools.allow(Agent 级白名单)
  │     ├─ agents.<id>.tools.byProvider.allow
  │     └─ group tools.allow(群聊级白名单)
  ├─ ⑤ sandbox.tools(沙箱工具策略)
  ├─ ⑥ subagentPolicy(子代理工具策略)
  ├─ ⑦ normalizeToolParameters          → JSON Schema 标准化(OpenAI 不接受 union schema)
  ├─ ⑧ wrapToolWithBeforeToolCallHook   → 注入 before_tool_call 钩子
  └─ ⑨ wrapToolWithAbortSignal          → 注入中止信号
  │
  ▼
最终工具列表 → 传给 Pi Agent SDK

策略管线核心:applyToolPolicyPipeline

// src/agents/tool-policy-pipeline.ts
export function applyToolPolicyPipeline(params) {
  // 区分核心工具和插件工具
  const coreToolNames = new Set(
    params.tools
      .filter(tool => !params.toolMeta(tool))  // 没有 pluginMeta 的是核心工具
      .map(tool => normalizeToolName(tool.name))
  );
  const pluginGroups = buildPluginToolGroups({ tools, toolMeta });

  let filtered = params.tools;
  for (const step of params.steps) {
    if (!step.policy) continue;
    let policy = step.policy;

    // 安全措施:如果 allowlist 只包含插件工具名(不含核心工具),
    // 说明用户可能只想启用某个插件,而不是禁用所有核心工具。
    // 此时剥离 allowlist,避免误杀核心工具。
    if (step.stripPluginOnlyAllowlist) {
      const resolved = stripPluginOnlyAllowlist(policy, pluginGroups, coreToolNames);
      if (resolved.unknownAllowlist.length > 0) {
        warn(`tools: ${step.label} allowlist contains unknown entries...`);
      }
      policy = resolved.policy ?? policy;
    }

    filtered = filterToolsByPolicy(filtered, policy);
  }
  return filtered;
}

设计哲学:层层过滤,最严格的策略赢。每层都可以独立配置,互不干扰。特别处理了「只写了插件名的 allowlist 不应杀死核心工具」这种常见误配。


8.4 工具调用的运行时保护

Before Tool Call Hook

每次 LLM 决定调用工具前,wrapToolWithBeforeToolCallHook 注入的包装器会先执行:

// src/agents/pi-tools.before-tool-call.ts
export async function runBeforeToolCallHook(args) {
  // 1. 插件 Hook:让外部插件有机会拦截或修改参数
  const hookRunner = getGlobalHookRunner();
  if (hookRunner) {
    const hookResult = await hookRunner.runHook("before_tool_call", {
      toolName, params, toolCallId, agentId, sessionKey,
    });
    if (hookResult.blocked) {
      return { blocked: true, reason: hookResult.reason };
    }
    if (hookResult.adjustedParams) {
      // 插件可以修改工具参数(存入 Map,工具执行时取出)
      adjustedParamsByToolCallId.set(key, hookResult.adjustedParams);
      return { blocked: false, params: hookResult.adjustedParams };
    }
  }

  // 2. 循环检测:记录工具调用,检查是否陷入循环
  // (详见 8.5 节)
}

After Tool Call Hook

工具执行完成后,同样触发插件通知:

// pi-embedded-subscribe.handlers.tools.ts
// 工具完成后:
await hookRunner.runHook("after_tool_call", {
  toolName, args, result, durationMs, toolCallId, agentId, sessionKey,
});
// 同时记录到循环检测器
await recordLoopOutcome({ toolName, toolParams, result, error });

8.5 工具循环检测:三级探测器

Agent 可能陷入无意义的工具调用循环(比如反复 exec 同一条命令、在两个工具间来回跳),src/agents/tool-loop-detection.ts 实现了三级探测:

export type LoopDetectorKind =
  | "generic_repeat"           // 通用重复:同一工具+同一参数 hash 连续 N 次
  | "known_poll_no_progress"   // 已知轮询无进展:process poll 连续返回相同结果
  | "ping_pong";               // 乒乓循环:两个工具交替调用(如 read ↔ write)

三级阈值

WARNING_THRESHOLD = 10;                  // 10 次 → 注入警告到 Agent 上下文
CRITICAL_THRESHOLD = 20;                 // 20 次 → 强制返回错误
GLOBAL_CIRCUIT_BREAKER_THRESHOLD = 30;   // 30 次 → 全局熔断,终止整个 turn

检测原理

// 每次工具调用后,记录 { toolName, paramsHash, resultHash }
// 滑动窗口(默认 30 条)内统计连续重复次数

// generic_repeat:对参数做 SHA-256 hash,连续相同 hash 计数
const paramsHash = createHash("sha256").update(stableStringify(params)).digest("hex").slice(0,16);

// ping_pong:检测 A→B→A→B 交替模式
// 倒序扫描历史,如果最近 N 次在两个工具间交替,判定为乒乓

// known_poll_no_progress:针对 process(action=poll) 等已知轮询模式,
// 比较连续结果的 hash,如果输出不变则计数

设计考量

  • 阈值可配置(tools.loopDetection.*),支持全局和 per-agent 覆盖

  • Warning 级别只是在 tool result 中追加提示文本,让 Agent 自我修正

  • Critical 级别返回 error 结果,强制打断

  • Circuit Breaker 直接终止 Agent turn


8.6 Skills 系统:从发现到注入的完整链路

Skills 是 Agent 能力的声明式扩展——不是运行时加载的代码,而是注入到 System Prompt 中的「使用说明」,让 Agent 知道自己能做什么、怎么做。

Skills 发现:6 个来源,按优先级合并

// src/agents/skills/workspace.ts → loadSkillEntries()

// 优先级从低到高(同名 skill 后者覆盖前者):
const extraSkills = ...;           // 1. 额外目录(config skills.load.extraDirs)
const bundledSkills = ...;         // 2. 内置 skills(OpenClaw 自带,如 github、weather)
const managedSkills = ...;         // 3. 托管 skills(~/.openclaw/skills/,npm 安装的)
const personalAgentsSkills = ...;  // 4. 个人 agent skills(~/.agents/skills/)
const projectAgentsSkills = ...;   // 5. 项目 agent skills(workspace/.agents/skills/)
const workspaceSkills = ...;       // 6. 工作区 skills(workspace/skills/,最高优先级)

// 用 Map 按 name 去重,后者覆盖前者
const merged = new Map<string, Skill>();
for (const skill of extraSkills) merged.set(skill.name, skill);
for (const skill of bundledSkills) merged.set(skill.name, skill);
// ... 依次到 workspaceSkills

安全措施

  • 路径逃逸检测:每个 skill 的 baseDirfilePath 都必须在其 root 目录内(isPathInside + realpath 检查),防止 symlink 攻击

  • 文件大小限制:单个 SKILL.md 最大 256KB(maxSkillFileBytes

  • 数量限制:每个 source 最多 200 个 skill(maxSkillsLoadedPerSource),prompt 中最多 150 个(maxSkillsInPrompt),总字符上限 30,000(maxSkillsPromptChars

Skill 目录结构

skills/
├── github/
│   └── SKILL.md       # 包含 frontmatter + 使用指令
├── weather/
│   └── SKILL.md
├── search/
│   ├── SKILL.md
│   └── scripts/
│       └── search.sh  # 可选的辅助脚本

每个 SKILL.md 的 frontmatter 会被解析为结构化元数据:

type ParsedSkillFrontmatter = {
  name?: string;
  description?: string;
  requires?: { env?: string[] };  // 需要的环境变量
  primaryEnv?: string;            // 主要依赖的环境变量
  disableModelInvocation?: boolean; // 禁止 Agent 自动调用(仅通过 /command 触发)
  // ...
};

Skills 注入 System Prompt

// src/agents/system-prompt.ts → buildSkillsSection()
function buildSkillsSection(params: { skillsPrompt?: string; readToolName: string }) {
  return [
    "## Skills (mandatory)",
    "Before replying: scan <available_skills> <description> entries.",
    `- If exactly one skill clearly applies: read its SKILL.md at <location> with \`${readToolName}\`, then follow it.`,
    "- If multiple could apply: choose the most specific one, then read/follow it.",
    "- If none clearly apply: do not read any SKILL.md.",
    "Constraints: never read more than one skill up front; only read after selecting.",
    trimmed,  // ← 这里是 formatSkillsForPrompt() 的输出(XML 格式的 skill 列表)
  ];
}

注入格式(出现在 system prompt 中):

<available_skills>
  <skill>
    <name>github</name>
    <description>GitHub operations via `gh` CLI...</description>
    <location>~/.openclaw/skills/github/SKILL.md</location>
  </skill>
  <skill>
    <name>weather</name>
    <description>Get current weather...</description>
    <location>~/.openclaw/skills/weather/SKILL.md</location>
  </skill>
</available_skills>

Agent 看到的是 skill 的名字、描述和 SKILL.md 路径。当用户请求匹配某个 skill 时,Agent 会用 read 工具读取 SKILL.md 的完整内容,然后按其中的指令执行。

Token 优化compactSkillPaths()/Users/alice/.openclaw/... 替换为 ~/...,每个 path 节省 5-6 个 token,总计可省 400-600 token。

Prompt 大小控制:二分搜索

如果 skills 太多导致 prompt 超限,用二分搜索找到最大可容纳数量:

function applySkillsPromptLimits(params) {
  if (!fits(skillsForPrompt)) {
    // 二分搜索最大前缀
    let lo = 0, hi = skillsForPrompt.length;
    while (lo < hi) {
      const mid = Math.ceil((lo + hi) / 2);
      if (fits(skillsForPrompt.slice(0, mid))) lo = mid;
      else hi = mid - 1;
    }
    skillsForPrompt = skillsForPrompt.slice(0, lo);
  }
}

8.7 Agent 执行引擎:runEmbeddedPiAgent

src/agents/pi-embedded-runner/run.ts(约 1600 行)是 Agent 的实际执行引擎。

重试与 Failover 循环

while (iterations < maxRetryIterations) {
  │
  ├─ 解析 Auth Profile(API Key / OAuth)
  ├─ 调用 runEmbeddedAttempt()(单次 LLM + 工具循环)
  │
  ├─ 成功 → 返回结果
  │
  ├─ 认证失败 → markAuthProfileFailure() → 切换下一个 profile → 重试
  ├─ Rate Limit → 指数退避等待 → 重试
  ├─ Context Overflow → compaction(压缩上下文)→ 重试
  ├─ Billing 错误 → 切换模型/profile → 重试
  ├─ Timeout → 切换 profile → 重试
  │
  └─ 所有 profile 耗尽 → FailoverError
}

关键设计

  • Auth Profile 轮换:每个 Agent 可配置多个 API Key(Auth Profile),失败后标记 cooldown 并自动切换下一个

  • Usage 累加器:多次重试间的 token 用量累加,但 cache 字段只取最后一次(避免 N 次 cacheRead 导致 context size 虚高)

  • 最大迭代数动态计算baseRetryIterations + profileCount × 8,更多 profile 允许更多重试次数

事件订阅:pi-embedded-subscribe

Agent 执行过程中,Pi SDK 产生事件流,OpenClaw 通过订阅机制处理:

// src/agents/pi-embedded-subscribe.handlers.ts
// 事件类型:
// - assistant(文本输出) → 实时广播到 WS 客户端
// - tool_use(工具调用开始) → before_tool_call hook → 执行工具
// - tool_result(工具结果) → after_tool_call hook → 记录循环检测
// - error → failover 判断
// - usage → 累加到 usage accumulator

工具调用事件的处理(pi-embedded-subscribe.handlers.tools.ts)特别精细:

  • 记录每个工具调用的开始时间(toolStartData Map)

  • 工具完成后计算耗时、提取结果中的媒体 URL

  • 消息类工具(message)特殊处理:提取发送内容和目标

  • exec 工具特殊处理:追踪 pty/elevated 标记

  • Cron 工具的 add 动作特殊处理:标记为 mutating action


8.8 System Prompt 的分层组装

src/agents/system-prompt.ts(约 730 行)负责组装最终的 system prompt,结构如下:

┌─────────────────────────────────────────────┐
│              System Prompt                   │
│                                              │
│  1. 身份行("You are a personal assistant")    │
│                                              │
│  2. ## Tooling(工具可用性说明)                 │
│     - 工具列表及使用约束                        │
│     - TOOLS.md 用户指南                        │
│                                              │
│  3. ## Tool Call Style(工具调用风格)           │
│                                              │
│  4. ## Safety(安全约束)                       │
│                                              │
│  5. ## Skills (mandatory)                    │
│     - <available_skills> XML 列表              │
│                                              │
│  6. ## Memory Recall(记忆检索指令)             │
│                                              │
│  7. ## Self-Update(自更新规则)                 │
│                                              │
│  8. ## Model Aliases(模型别名映射)             │
│                                              │
│  9. ## Workspace(工作目录路径)                 │
│                                              │
│  10. ## Documentation(文档路径)               │
│                                              │
│  11. ## Authorized Senders(授权发送者)         │
│                                              │
│  12. ## Current Date & Time                  │
│                                              │
│  13. ## Workspace Files(注入的项目文件)         │
│      AGENTS.md / SOUL.md / USER.md / ...      │
│                                              │
│  14. ## Silent Replies(NO_REPLY 规则)         │
│                                              │
│  15. ## Heartbeats(心跳响应规则)               │
│                                              │
│  16. ## Runtime(运行环境信息)                  │
│      agent / host / os / model / channel      │
│                                              │
│  17. ## Messaging(消息路由规则)                │
│                                              │
│  18. ## Group Chat Context(群聊上下文,可选)    │
│                                              │
│  19. ## Reactions(表情回复规则,可选)            │
│                                              │
│  20. ## Reply Tags(回复标签规则,可选)           │
└─────────────────────────────────────────────┘

Prompt 模式(PromptMode)控制包含哪些章节:

  • "full":所有章节(主 Agent)

  • "minimal":只含 Tooling + Workspace + Runtime(子代理)

  • "none":只有身份行


8.9 MCP 与外部工具集成

OpenClaw 目前的工具体系不是通过 MCP(Model Context Protocol)协议动态发现,而是采用编译时注册 + 运行时策略过滤的模式。但 MCP 的理念在架构中有所体现:

与 MCP 的关系

  1. Plugin SDK 是 OpenClaw 版的 MCP Server:第三方通过 Plugin SDK 注册工具,运行时由 resolvePluginTools() 加载——这本质上就是 MCP 的「Server 向 Client 暴露工具」的模式,只是协议不同。

  2. Skills 是 MCP 的「Prompt 扩展」:MCP 定义了 Resources 和 Prompts,OpenClaw 的 Skills 扮演了类似角色——声明式地扩展 Agent 的能力描述。

  3. mcporter Skill:OpenClaw 内置了 mcporter skill(在 skills/mcporter/SKILL.md),可以通过它调用真正的 MCP Server。Agent 读取 mcporter 的 SKILL.md 后,可以用 exec 工具调用 mcporter CLI 来与任意 MCP Server 交互。

工具发现的实际路径

启动时:
  loadGatewayPlugins()
    → loadOpenClawPlugins()(jiti 动态加载 JS/TS 插件文件)
      → 每个插件 export tools: ToolFactory[]
        → 运行时 resolvePluginTools() 实例化

Agent 执行时:
  createOpenClawCodingTools()
    → 核心工具(硬编码注册)
    → OpenClaw 平台工具(硬编码注册)
    → resolvePluginTools()(插件工具动态加载)
    → 9 层策略过滤
    → 最终工具列表

8.10 工具分类速查表

工具名

来源

类别

源码位置

read

Pi SDK(重写)

文件

pi-tools.read.ts

write

Pi SDK(重写)

文件

pi-tools.read.ts

edit

Pi SDK(重写)

文件

pi-tools.read.ts

exec

OpenClaw

运行时

bash-tools.exec.ts

process

OpenClaw

运行时

bash-tools.process.ts

apply_patch

OpenClaw

文件

apply-patch.ts

browser

OpenClaw

UI

tools/browser-tool.ts

canvas

OpenClaw

UI

tools/canvas-tool.ts

nodes

OpenClaw

设备

tools/nodes-tool.ts

cron

OpenClaw

自动化

tools/cron-tool.ts

message

OpenClaw

消息

tools/message-tool.ts

tts

OpenClaw

媒体

tools/tts-tool.ts

gateway

OpenClaw

管理

tools/gateway-tool.ts

web_search

OpenClaw

Web

tools/web-search.ts

web_fetch

OpenClaw

Web

tools/web-fetch.ts

memory_search

OpenClaw

记忆

tools/memory-tool.ts

memory_get

OpenClaw

记忆

tools/memory-tool.ts

sessions_spawn

OpenClaw

会话

tools/sessions-spawn-tool.ts

sessions_send

OpenClaw

会话

tools/sessions-send-tool.ts

subagents

OpenClaw

Agent

tools/subagents-tool.ts

image

OpenClaw

媒体

tools/image-tool.ts

pdf

OpenClaw

媒体

tools/pdf-tool.ts

(插件工具)

Plugin SDK

扩展

plugins/tools.ts


附录:源码目录速查

src/
├── entry.ts              # 进程入口
├── index.ts              # 库入口 + CLI
├── gateway/              # 🔴 Gateway 核心(~120 文件)
│   ├── server.impl.ts    #   启动函数 startGatewayServer
│   ├── server-methods.ts #   方法注册与路由
│   ├── server-http.ts    #   HTTP 请求处理
│   ├── server-ws-runtime.ts # WebSocket 运行时
│   ├── server-chat.ts    #   聊天流处理
│   ├── server-channels.ts#   Channel 生命周期管理
│   ├── server-cron.ts    #   Cron 服务集成
│   ├── server-plugins.ts #   插件加载与 dispatch
│   ├── boot.ts           #   BOOT.md 启动任务
│   ├── auth.ts           #   认证核心逻辑
│   ├── call.ts           #   Gateway RPC 调用客户端
│   └── protocol/         #   协议 schema 定义
├── agents/               # 🟠 Agent 子系统(~300 文件)
│   ├── agent-scope.ts    #   Agent 作用域解析
│   ├── cli-runner.ts     #   CLI Agent 运行器
│   ├── bash-tools.ts     #   Bash/Exec 工具实现
│   ├── model-selection.ts#   模型选择逻辑
│   └── pi-embedded*.ts   #   Pi Agent 内嵌运行
├── commands/             # 🟡 命令层
│   ├── agent.ts          #   核心对话命令(最重要)
│   └── agents.ts         #   多 Agent 管理
├── channels/             # 🟢 通道插件
│   └── plugins/          #   统一插件接口
├── config/               # 配置管理
├── cron/                 # 定时任务
├── sessions/             # 会话管理
├── providers/            # LLM 提供商
├── plugins/              # 插件系统
├── acp/                  # ACP 协议
├── infra/                # 基础设施
├── hooks/                # Hook 触发
├── secrets/              # 密钥管理
├── routing/              # 路由层
├── browser/              # 浏览器控制
├── telegram/             # Telegram 适配
├── discord/              # Discord 适配
├── slack/                # Slack 适配
├── whatsapp/             # WhatsApp 适配
├── signal/               # Signal 适配
├── imessage/             # iMessage 适配
├── line/                 # LINE 适配
└── ...

文档持续更新中。有任何想深入的模块,随时告诉我。


九、ACP 协议深度解析——Agent 间通信的桥梁

9.1 什么是 ACP

ACP(Agent Client Protocol)是一个 IDE/客户端 ↔ Agent 的通信协议,由 Anthropic 提出(@agentclientprotocol/sdk),目前被 Zed 编辑器等工具采用。核心思想是:

让任意 IDE/客户端通过标准化的 stdio + NDJSON 流与 AI Agent 交互,不关心 Agent 背后是什么模型或框架。

OpenClaw 实现了一个 ACP Bridgeopenclaw acp 命令),把 ACP 协议翻译为 Gateway 内部的 WebSocket 调用,从而让 Zed 等 IDE 可以驱动 OpenClaw 的 Agent。

9.2 架构全景:ACP 在 OpenClaw 中的位置

┌─────────────┐          stdio/NDJSON         ┌──────────────────┐
│  IDE 客户端   │ ◄═══════════════════════════► │  openclaw acp     │
│ (Zed/VS Code)│     ACP Protocol              │  (ACP Bridge)     │
└─────────────┘                                └────────┬─────────┘
                                                        │ WebSocket
                                                        │ (Gateway Protocol)
                                                        ▼
                                               ┌──────────────────┐
                                               │  OpenClaw Gateway  │
                                               │                    │
                                               │  ┌──────────────┐ │
                                               │  │ Agent 子系统   │ │
                                               │  │ (Pi Agent)    │ │
                                               │  └──────┬───────┘ │
                                               │         │         │
                                               │         ▼         │
                                               │  ┌──────────────┐ │
                                               │  │ LLM Provider  │ │
                                               │  └──────────────┘ │
                                               └──────────────────┘

另一条路径——ACP 作为子代理运行时:

┌──────────────┐    sessions_spawn     ┌─────────────────────┐
│ OpenClaw Agent│ ──(runtime="acp")──► │ ACP Control Plane    │
│ (主 Agent)    │                       │ (manager.core.ts)    │
└──────────────┘                       └──────────┬──────────┘
                                                   │
                                          ensureSession / runTurn
                                                   │
                                                   ▼
                                       ┌─────────────────────┐
                                       │ ACP Runtime Backend  │
                                       │ (Codex / Claude Code │
                                       │  / 其他 ACP Agent)    │
                                       └─────────────────────┘

两种使用模式

  1. ACP Bridge 模式:IDE → openclaw acp → Gateway(IDE 是客户端,OpenClaw 是 Agent)

  2. ACP Dispatch 模式:OpenClaw Agent → ACP Control Plane → 外部 Agent(OpenClaw 是客户端,外部 Agent 是服务端)


9.3 ACP Bridge 模式:源码解析

入口:serveAcpGateway()src/acp/server.ts

export async function serveAcpGateway(opts: AcpServerOptions = {}): Promise<void> {
  // 1. 连接到 Gateway(WebSocket)
  const gateway = new GatewayClient({
    url: connection.url,
    token: creds.token,
    onEvent: (evt) => { agent?.handleGatewayEvent(evt); },
    onHelloOk: () => { agent?.handleGatewayReconnect(); },
  });
  gateway.start();
  await gatewayReady;

  // 2. 在 stdio 上建立 ACP 连接(NDJSON 流)
  const input = Writable.toWeb(process.stdout);
  const output = Readable.toWeb(process.stdin);
  const stream = ndJsonStream(input, output);

  // 3. 创建 ACP Agent 实例,开始处理请求
  new AgentSideConnection((conn) => {
    agent = new AcpGatewayAgent(conn, gateway, opts);
    agent.start();
    return agent;
  }, stream);
}

本质:一个双向桥接器。左侧是 ACP 的 stdio NDJSON 流,右侧是 Gateway 的 WebSocket 连接。

翻译器:AcpGatewayAgentsrc/acp/translator.ts,约 1100 行)

这是 ACP Bridge 的核心,实现了 Agent 接口,负责 ACP ↔ Gateway 消息的双向翻译。

ACP 方法到 Gateway 的映射

ACP 方法

Gateway 方法

说明

initialize

—(返回能力声明)

声明支持 image、loadSession、listSessions

newSession

Session 创建

生成 acp:<uuid> session key

loadSession

Session 加载 + transcript 回放

重建 IDE 的对话历史

listSessions

sessions.list

返回 Gateway 所有 session

prompt

chat.send

核心:将用户输入发给 Gateway

cancel

chat.abort

中止当前生成

setSessionMode

sessions.patch

调整 thinking level

setSessionConfigOption

sessions.patch

调整 verbosity / reasoning 等

Prompt 翻译的细节

async prompt(params: PromptRequest): Promise<PromptResponse> {
  // 1. 提取文本(带 2MB 大小限制,防 DoS)
  const userText = extractTextFromPrompt(params.prompt, MAX_PROMPT_BYTES);
  // 2. 提取附件(图片等)
  const attachments = extractAttachmentsFromPrompt(params.prompt);
  // 3. 前缀工作目录(让 Agent 知道 IDE 在哪个目录下工作)
  const message = prefixCwd ? `[Working directory: ${displayCwd}]\n\n${userText}` : userText;
  // 4. 可选的来源追踪(provenance)
  const systemInputProvenance = provenanceMode !== "off"
    ? buildSystemInputProvenance(params.sessionId)  // { kind: "external_user", sourceChannel: "acp" }
    : undefined;

  // 5. 发送到 Gateway
  return new Promise((resolve, reject) => {
    this.pendingPrompts.set(sessionId, { sessionId, sessionKey, resolve, reject, ... });
    this.gateway.request("chat.send", {
      sessionKey, message, attachments, idempotencyKey: runId,
      thinking, deliver, timeoutMs, systemInputProvenance,
    });
  });
}

Gateway 事件的回译

Gateway 产生的流式事件被翻译回 ACP 的 sessionUpdate 格式:

// 文本增量 → agent_message_chunk
async handleDeltaEvent(sessionId, messageData) {
  const fullText = content?.find(c => c.type === "text")?.text ?? "";
  const newText = fullText.slice(sentSoFar);
  await this.connection.sessionUpdate({
    sessionId,
    update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: newText } },
  });
}

// 工具调用 → tool_call / tool_call_update
async handleAgentEvent(evt) {
  if (phase === "start") {
    await this.connection.sessionUpdate({
      sessionId,
      update: { sessionUpdate: "tool_call", toolCallId, title, status: "in_progress", rawInput, kind, locations },
    });
  }
  if (phase === "result") {
    await this.connection.sessionUpdate({
      sessionId,
      update: { sessionUpdate: "tool_call_update", toolCallId, status: isError ? "failed" : "completed", rawOutput, content },
    });
  }
}

9.4 ACP Dispatch 模式:OpenClaw 作为客户端

当 OpenClaw Agent 执行 sessions_spawn(runtime="acp") 时,它变成了 ACP 的客户端,委托外部 Agent(如 Codex、Claude Code)执行任务。

控制面:AcpSessionManagersrc/acp/control-plane/manager.core.ts

export class AcpSessionManager {
  private readonly actorQueue = new SessionActorQueue();   // 串行化同一 session 的操作
  private readonly runtimeCache = new RuntimeCache();       // 运行时句柄缓存(带 TTL 驱逐)
  private readonly activeTurnBySession = new Map();         // 活跃的 turn 追踪

  // 核心操作
  resolveSession(params)          // 查找 session 状态
  initializeSession(params)       // 创建/恢复 ACP session
  runTurn(params)                 // 执行一轮对话
  cancelTurn(params)              // 取消活跃 turn
  closeSession(params)            // 关闭 session
}

关键设计

  • ActorQueue:同一 session 的操作严格串行(SessionActorQueue),避免并发写冲突

  • RuntimeCache:ACP 运行时句柄(进程连接)被缓存复用,空闲超时后自动驱逐

  • Identity Reconciliation:启动时检查所有 ACP session 的 identity 是否与后端一致,处理悬挂的旧 session

运行时后端:AcpRuntime 接口

// src/acp/runtime/types.ts
export interface AcpRuntime {
  ensureSession(input: AcpRuntimeEnsureInput): Promise<AcpRuntimeHandle>;
  runTurn(input: AcpRuntimeTurnInput): AsyncIterable<AcpRuntimeEvent>;
  cancel(input: { handle: AcpRuntimeHandle; reason?: string }): Promise<void>;
  close(input: { handle: AcpRuntimeHandle; reason: string }): Promise<void>;
  // 可选
  getCapabilities?(input): Promise<AcpRuntimeCapabilities>;
  getStatus?(input): Promise<AcpRuntimeStatus>;
  setMode?(input): Promise<void>;
  setConfigOption?(input): Promise<void>;
  doctor?(): Promise<AcpRuntimeDoctorReport>;
}

这是一个适配器接口。不同的外部 Agent 通过实现这个接口接入 OpenClaw:

// src/acp/runtime/registry.ts — 全局后端注册表
export function registerAcpRuntimeBackend(backend: AcpRuntimeBackend): void {
  ACP_BACKENDS_BY_ID.set(id, backend);
}

export function requireAcpRuntimeBackend(id?: string): AcpRuntimeBackend {
  // 如果指定了 id,精确匹配;否则返回第一个健康的后端
}

事件流模型

runTurn 返回 AsyncIterable<AcpRuntimeEvent>,事件类型:

type AcpRuntimeEvent =
  | { type: "text_delta"; text: string; stream?: "output" | "thought" }
  | { type: "status"; text: string; used?: number; size?: number }
  | { type: "tool_call"; text: string; toolCallId?: string; status?: string }
  | { type: "done"; stopReason?: string }
  | { type: "error"; message: string; code?: string; retryable?: boolean };

策略控制

// src/acp/policy.ts
isAcpEnabledByPolicy(cfg)         // acp.enabled !== false
isAcpDispatchEnabledByPolicy(cfg)  // acp.dispatch.enabled !== false
isAcpAgentAllowedByPolicy(cfg, agentId)  // acp.allowedAgents 白名单

三层开关:全局 ACP 开关 → Dispatch 开关 → Agent 级白名单。


9.5 Session Key 体系在 ACP 中的应用

ACP Bridge 模式:
  默认 key:  acp:<uuid>(每次 newSession 生成隔离 key)
  自定义 key:agent:creator:main(复用主会话)
  按 label:  通过 sessionLabel 元数据查找已有 session

ACP Dispatch 模式:
  key 格式:  agent:<agentId>:acp:<sessionId>
  session 元数据存储在 session store 的 `acp` 字段中:
    { backend, runtimeSessionName, agentSessionId, acpxRecordId, ... }

9.6 ACP vs A2A:两个协议的本质区别

维度

ACP(Agent Client Protocol)

A2A(Agent-to-Agent Protocol)

发起者

Anthropic(Claude Code 生态)

Google(Google Cloud 生态)

定位

IDE/客户端 ↔ Agent(人驱动 Agent

Agent ↔ Agent(Agent 驱动 Agent

通信模式

stdio + NDJSON(进程级)

JSON-RPC 2.0 over HTTP(S)(网络级)

发现机制

无(客户端直接配置 Agent 路径)

Agent Card(JSON 描述能力、认证、端点)

Session 模型

有状态 session(initialize → prompt → cancel)

Task 生命周期(send → getStatus → cancel)

流式支持

NDJSON 流(agent_message_chunk / tool_call)

SSE(Server-Sent Events)+ Push Notifications

透明度

Agent 内部工具/状态对客户端可见(tool streaming)

不透明(Agent 隐藏内部 memory/tools/logic)

协作模式

1:1(一个客户端对一个 Agent)

M:N(多 Agent 相互发现、相互委托)

MCP 关系

互补(Agent 内部用 MCP 调工具,对外用 ACP 暴露能力)

互补(MCP = Agent 调工具,A2A = Agent 调 Agent)

成熟度

已被 Zed / Claude Code 采用

Linux Foundation 开源,有 Python/Go/JS/Java/.NET SDK

典型场景

IDE 中的 AI 编码助手

企业跨系统多 Agent 协作(医疗、金融)

一句话总结

  • ACP 是「人 → Agent」的遥控器协议

  • A2A 是「Agent → Agent」的对话协议


9.7 OpenClaw 支持 A2A 吗?

目前不直接支持 A2A 协议。 从源码中没有发现任何 a2a 相关的 import 或实现。

但 OpenClaw 的架构为 A2A 接入留了空间:

已有的 Agent 间通信能力

OpenClaw 内部的 Agent 间通信走的是自研的 Gateway 协议,不是 ACP 也不是 A2A:

Agent A (sessions_spawn)
  → Gateway: "sessions.spawn" (创建子代理 session)
    → Agent B (子代理在独立 session 中运行)
      → 完成后通过 "subagent.completion" 事件通知
    → Agent A 收到通知,继续处理

Agent A (sessions_send)
  → Gateway: "sessions.send" (向另一个 session 发消息)
    → Agent B (收到消息,执行,返回结果)

这本质上是 A2A 的子集——Agent 间可以通信,但缺少 A2A 的发现机制(Agent Card)和跨系统互操作

如果要支持 A2A

从架构上看,最自然的接入方式是:

方式 1:OpenClaw 作为 A2A Server
  外部 Agent → HTTP → A2A Adapter → Gateway chat.send → Agent 执行

方式 2:OpenClaw 作为 A2A Client
  Agent → A2A Client Tool → HTTP → 外部 A2A Agent
  (类似现在的 ACP Dispatch,但走 A2A 协议)

方式 3:Plugin 实现
  通过 Plugin SDK 注册一个 A2A Gateway Plugin
  暴露 /a2a 端点,翻译 A2A Task ↔ Gateway Session

ACP Runtime 的适配器模式(AcpRuntime 接口 + registerAcpRuntimeBackend)已经证明了 OpenClaw 可以灵活对接外部协议,A2A 的接入只需要一个新的 Runtime Backend 实现。


9.8 三个协议的关系图

                     ┌─────────────────┐
                     │    人 / IDE      │
                     └────────┬────────┘
                              │ ACP(stdio/NDJSON)
                              ▼
                     ┌─────────────────┐
                     │   OpenClaw Agent  │
                     │                   │
                     │  ┌─────────────┐ │
                     │  │ 工具调用     │ │ ← MCP(Agent 调工具)
                     │  │ web_search  │ │
                     │  │ exec / read │ │
                     │  └─────────────┘ │
                     │                   │
                     │  ┌─────────────┐ │
                     │  │ 子代理委托   │ │ ← ACP Dispatch(Agent 调 Agent,进程级)
                     │  │ Codex       │ │
                     │  │ Claude Code │ │
                     │  └─────────────┘ │
                     │                   │
                     │  ┌─────────────┐ │
                     │  │ sessions_*  │ │ ← Gateway 内部协议(Agent 间通信)
                     │  │ subagents   │ │
                     │  └─────────────┘ │
                     └────────┬────────┘
                              │
                  (A2A 尚未实现,但架构预留)
                              │
                              ▼
                     ┌─────────────────┐
                     │  外部 Agent       │ ← A2A(Agent 调 Agent,网络级)
                     │ (跨组织/跨框架) │
                     └─────────────────┘

三层协议分工

  • MCP:Agent 内部的「手」——调用工具、访问资源

  • ACP:Agent 对外的「耳朵和嘴」——接收人的指令、返回结果

  • A2A:Agent 之间的「电话」——跨系统发现、协商、协作


十、Memory 系统深度解析——语义记忆引擎

10.1 全局架构

Memory 系统是 OpenClaw 的长期记忆引擎,让 Agent 能够跨会话检索历史信息。核心是一个混合搜索系统:向量语义搜索 + BM25 全文搜索 + 时间衰减 + MMR 多样性重排。

┌──────────────────────────────────────────────────────────────┐
│                     Memory 系统全景                            │
│                                                               │
│  数据源                    索引层                  查询层       │
│  ┌──────────┐        ┌──────────────┐       ┌──────────────┐ │
│  │MEMORY.md │──┐     │              │       │ memory_search│ │
│  │memory/   │  │     │   SQLite DB  │       │   (工具)     │ │
│  │*.md      │  ├────►│              │◄──────│              │ │
│  └──────────┘  │     │ ┌──────────┐ │       └──────────────┘ │
│  ┌──────────┐  │     │ │chunks_vec│ │  向量搜索               │
│  │ Session  │  │     │ │(sqlite-  │ │  ──────────►           │
│  │Transcripts│─┘     │ │ vec)     │ │                        │
│  │ *.jsonl  │        │ ├──────────┤ │  FTS 搜索              │
│  └──────────┘        │ │chunks_fts│ │  ──────────►           │
│                      │ │(FTS5)    │ │                        │
│  ┌──────────┐        │ ├──────────┤ │       ┌──────────────┐ │
│  │ Embedding│        │ │embedding │ │       │  混合排序     │ │
│  │ Provider │        │ │_cache    │ │       │  + 时间衰减   │ │
│  │ (6 种)   │        │ └──────────┘ │       │  + MMR 去重   │ │
│  └──────────┘        └──────────────┘       └──────────────┘ │
└──────────────────────────────────────────────────────────────┘

10.2 数据源:两类记忆

export type MemorySource = "memory" | "sessions";

① memory 源:工作区中的 Markdown 文件

  • MEMORY.md——核心记忆(长期、常青)

  • memory/YYYY-MM-DD.md——每日记忆(带日期,可衰减)

  • memory/*.md——主题记忆(如 memory/projects.md

  • 支持配置额外路径(skills.load.extraDirs

② sessions 源:会话历史的 JSONL 转录文件

  • 存储在 Agent 的 sessions 目录下

  • 每行是一条消息(JSON 格式,含 role/content)

  • 只提取 user 和 assistant 的文本内容,tool 调用被跳过

// session-files.ts — 从 JSONL 提取可搜索文本
export function extractSessionText(content: unknown): string | null {
  if (typeof content === "string") return normalizeSessionText(content);
  if (Array.isArray(content)) {
    // 只取 type=text 的 block
    return parts.filter(b => b.type === "text").map(b => b.text).join(" ");
  }
}

10.3 索引层:SQLite + sqlite-vec + FTS5

整个索引存储在一个 SQLite 数据库中(node:sqlite 同步 API),包含三张表:

表名

类型

用途

chunks

普通表

存储文本块的元数据(path、startLine、endLine、hash、source)

chunks_vec

sqlite-vec 虚拟表

存储 embedding 向量,支持近似最近邻搜索

chunks_fts

FTS5 虚拟表

全文搜索索引,支持 BM25 排序

embedding_cache

普通表

embedding 结果缓存(避免重复调 API)

sqlite-vec 加载

// sqlite-vec.ts — 动态加载向量搜索扩展
export async function loadSqliteVecExtension(params) {
  // 尝试加载 sqlite-vec native 扩展
  // 支持自定义 extensionPath 或自动发现
  // 失败时记录 loadError,不阻塞启动
  db.exec("PRAGMA busy_timeout = 5000");  // 并发保护
}

设计选择:用 SQLite 而非 Chroma/Pinecone 等专用向量数据库,因为:

  • 零依赖部署(SQLite 内置在 Node.js 中)

  • 单文件数据库,随工作区迁移

  • sqlite-vec 提供足够的向量搜索性能(千级文档场景)

10.4 Embedding 提供商:6 种选择 + 自动降级

// embeddings.ts
export type EmbeddingProviderId =
  | "openai"    // text-embedding-3-small/large
  | "local"     // node-llama-cpp(本地 GGUF 模型)
  | "gemini"    // Gemini embedding-2
  | "voyage"    // Voyage AI
  | "mistral"   // Mistral embed
  | "ollama";   // Ollama 本地服务

export type EmbeddingProviderRequest = EmbeddingProviderId | "auto";

auto 模式的选择策略

1. 尝试 openai(有 API Key?)
2. 尝试 gemini(有 API Key?)
3. 尝试 voyage(有 API Key?)
4. 尝试 mistral(有 API Key?)
5. 尝试 local(本地模型文件存在?)
6. 全部失败 → provider = null,降级到 FTS-only 模式

本地模型默认使用 embeddinggemma-300m-qat-Q8_0.gguf(300M 参数,quantized),通过 node-llama-cpp 运行。

降级保护:如果 embedding 提供商不可用,系统自动降级到纯 FTS 搜索,不会完全丧失搜索能力。

10.5 同步机制:文件监听 + 增量更新

MemoryManagerSyncOps(约 1400 行)管理文件→索引的同步:

触发时机

// 三种触发方式:
1. 文件监听(chokidar)     → memory/ 目录变更时标记 dirty
2. Session 事件订阅          → transcript 更新时标记 sessionsDirty
3. 定时轮询                  → 配置的 intervalMs 周期同步
4. 搜索时检查(sync.onSearch) → 搜索前如果 dirty 则先同步
5. 会话开始时(sync.onSessionStart)→ warmSession()

增量同步逻辑

对每个文件:
  1. 计算内容 hash
  2. 与 DB 中存储的 hash 比较
  3. 不同 → 删除旧 chunks → 重新切分 → 重新 embedding → 写入 DB
  4. 相同 → 跳过

Session transcript 增量更新:不是每次全量重建,而是跟踪每个 session 文件的 lastSize,只处理新增的字节:

protected sessionDeltas = new Map<string, {
  lastSize: number;      // 上次同步时的文件大小
  pendingBytes: number;  // 新增的字节数
  pendingMessages: number; // 新增的消息数
}>();

并发与锁

// 同一时间只有一个 sync 在运行
if (this.syncing) {
  // 如果有新的 session 文件变更,入队等待
  return this.enqueueTargetedSessionSync(params.sessionFiles);
}
this.syncing = this.runSyncWithReadonlyRecovery(params).finally(() => {
  this.syncing = null;
});

还有 readonly 恢复机制:如果 SQLite 数据库意外变成 readonly(比如文件系统问题),会尝试重新打开数据库:

private isReadonlyDbError(err: unknown): boolean {
  return /attempt to write a readonly database|SQLITE_READONLY/i.test(message);
}
// 检测到 readonly → 关闭 DB → 重新 openDatabase() → 从旧 DB 迁移 embedding cache

10.6 搜索管线:混合搜索的完整流程

用户查询 "上周讨论的网关方案"
          │
          ▼
    ┌─────────────────────────────┐
    │  1. 查询预处理               │
    │     extractKeywords()        │
    │     "上周" "讨论" "网关" "方案" │
    │     (去除停用词)             │
    └─────────┬───────────────────┘
              │
     ┌────────┼────────┐
     ▼                  ▼
┌──────────┐     ┌──────────┐
│ 向量搜索  │     │ FTS 搜索  │
│           │     │           │
│ query →   │     │ query →   │
│ embedding │     │ BM25 rank │
│ → cosine  │     │ → score   │
│ similarity│     │           │
└─────┬────┘     └─────┬────┘
      │                 │
      └────────┬────────┘
               ▼
    ┌─────────────────────────────┐
    │  2. 混合排序                  │
    │  score = vectorWeight × v    │
    │        + textWeight × t      │
    │  (默认 0.7 × vector + 0.3 × text)│
    └─────────┬───────────────────┘
              ▼
    ┌─────────────────────────────┐
    │  3. 时间衰减(可选)          │
    │  score × exp(-λ × ageDays)  │
    │  半衰期默认 30 天             │
    │  MEMORY.md 等常青文件不衰减   │
    └─────────┬───────────────────┘
              ▼
    ┌─────────────────────────────┐
    │  4. MMR 多样性重排(可选)     │
    │  MMR = λ×relevance          │
    │      - (1-λ)×maxSimilarity  │
    │  基于 Jaccard 相似度去冗余    │
    └─────────┬───────────────────┘
              ▼
    ┌─────────────────────────────┐
    │  5. minScore 过滤 + topK 截断│
    └─────────────────────────────┘
              │
              ▼
         搜索结果(path + startLine + endLine + score + snippet)

FTS-only 降级模式

当没有 embedding 提供商时,搜索走纯 FTS5:

if (!this.provider) {
  // 提取关键词(中英文停用词过滤)
  const keywords = extractKeywords(cleaned);
  // 对每个关键词独立搜索,合并去重,取最高分
  const resultSets = await Promise.all(
    searchTerms.map(term => this.searchKeyword(term, candidates))
  );
  // 合并、去重、排序
}

extractKeywords 支持中文分词(通过 Unicode 正则 [\p{L}\p{N}_]+),并移除中英文停用词。

10.7 时间衰减:记忆会随时间褪色

// temporal-decay.ts
export function calculateTemporalDecayMultiplier(params) {
  const lambda = Math.LN2 / halfLifeDays;  // 半衰期转衰减系数
  return Math.exp(-lambda * ageInDays);     // 指数衰减
}

时间来源的优先级

  1. 文件名中的日期(memory/2026-03-13.md → 2026-03-13)

  2. 文件的 mtime(修改时间)

不衰减的文件

  • MEMORY.md(核心记忆,常青)

  • memory/ 下不带日期的文件(如 memory/projects.md,主题知识)

实际效果:30 天半衰期意味着 30 天前的记忆分数降为 50%,60 天前降为 25%。这让 Agent 自然地优先回忆近期事件。

10.8 MMR 多样性重排:避免信息冗余

当搜索结果中多个 chunk 内容高度相似时,MMR(Maximal Marginal Relevance)算法会降低重复结果的排名:

// mmr.ts — Carbonell & Goldstein (1998)
MMR_score = λ × relevance - (1-λ) × max_similarity_to_selected

// λ = 0.7(默认):70% 权重给相关性,30% 给多样性
// 相似度计算:Jaccard 相似度(tokenize 后的集合交集/并集)

算法流程

  1. 选出分数最高的结果加入 selected

  2. 对剩余每个候选,计算 MMR 分数(兼顾与 query 的相关性和与已选结果的差异性)

  3. 选 MMR 分数最高的加入 selected

  4. 重复直到选满

10.9 Memory Flush:自动保存濒临压缩的记忆

当 session 接近 context window 上限、即将触发自动压缩(compaction)时,系统会先执行一次 Memory Flush——让 Agent 把重要信息写入 memory/YYYY-MM-DD.md

// auto-reply/reply/memory-flush.ts
export const DEFAULT_MEMORY_FLUSH_PROMPT = [
  "Pre-compaction memory flush.",
  "Store durable memories to disk.",
  "Target: memory/YYYY-MM-DD.md (append only).",
  "Treat MEMORY.md, SOUL.md, TOOLS.md as read-only.",
  "If nothing to store, reply with NO_REPLY.",
].join(" ");

关键约束

  • 只允许 readwrite 两个工具(MEMORY_FLUSH_ALLOWED_TOOL_NAMES

  • write 被包装为 append-onlywrapToolMemoryFlushAppendOnlyWrite),不能覆盖已有内容

  • 核心文件(MEMORY.md、SOUL.md、TOOLS.md、AGENTS.md)标记为只读

触发条件

// 两个阈值(满足任一):
DEFAULT_MEMORY_FLUSH_SOFT_TOKENS = 4000;         // session token 超过 4000
DEFAULT_MEMORY_FLUSH_FORCE_TRANSCRIPT_BYTES = 2MB; // transcript 文件超过 2MB

10.10 Agent 侧的记忆工具

Agent 通过两个工具与 Memory 系统交互:

// tools/memory-tool.ts
{
  name: "memory_search",
  description: "Mandatory recall step: semantically search MEMORY.md + memory/*.md...",
  parameters: { query: string, maxResults?: number, minScore?: number },
  execute: async (toolCallId, params) => {
    const manager = await getMemorySearchManager({ cfg, agentId });
    const results = await manager.search(query, { maxResults, minScore, sessionKey });
    // 返回 { results: [{ path, snippet, score, startLine, endLine, citation? }], ... }
  }
}

返回结果包含 citation(如 MEMORY.md#42),方便 Agent 在回复中引用来源。

memory_get

{
  name: "memory_get",
  description: "Safe snippet read from MEMORY.md or memory/*.md...",
  parameters: { path: string, from?: number, lines?: number },
  execute: async (toolCallId, params) => {
    const result = await manager.readFile({ relPath: path, from, lines });
    // 返回指定行范围的原文
  }
}

二步检索模式:Agent 先 memory_search 找到相关片段和行号,再用 memory_get 读取精确上下文。这样避免把整个文件塞进 prompt。

10.11 后端选择:内置 vs QMD

// search-manager.ts — 两种后端
if (resolved.backend === "qmd") {
  // QMD(外部后端,通过 HTTP 通信)
  const primary = await QmdMemoryManager.create({ cfg, agentId, resolved });
  // 带降级:QMD 失败时自动回退到内置后端
  return new FallbackMemoryManager({ primary, fallbackFactory: () => MemoryIndexManager.get(...) });
} else {
  // 内置后端(SQLite + sqlite-vec)
  return MemoryIndexManager.get({ cfg, agentId });
}

FallbackMemoryManager 实现了自动降级:QMD 后端抛错时,透明切换到内置的 SQLite 后端,保证搜索不中断。


10.12 Memory 系统速查表

组件

源码位置

职责

MemoryIndexManager

memory/manager.ts

核心管理器:索引、搜索、同步

MemoryManagerSyncOps

memory/manager-sync-ops.ts

文件同步:chokidar 监听、增量更新、DB 操作

MemoryManagerEmbeddingOps

memory/manager-embedding-ops.ts

Embedding 相关:批量处理、缓存、超时

mergeHybridResults

memory/hybrid.ts

混合排序:向量+FTS 加权合并

mmrRerank

memory/mmr.ts

MMR 多样性重排

applyTemporalDecay

memory/temporal-decay.ts

时间衰减

extractKeywords

memory/query-expansion.ts

查询关键词提取(中英文)

createEmbeddingProvider

memory/embeddings.ts

6 种 embedding 提供商适配

memory_search / memory_get

agents/tools/memory-tool.ts

Agent 工具接口

resolveMemoryFlushPromptForRun

auto-reply/reply/memory-flush.ts

压缩前记忆保存

FallbackMemoryManager

memory/search-manager.ts

QMD→内置的降级包装

十一、Session Router 深度解析——路由调度的核心引擎

11.1 路由问题的本质

OpenClaw 是一个多通道、多账户、多 Agent 的网关。一条消息从 Telegram/Discord/WhatsApp 等任意通道进来,系统需要回答三个问题:

  1. 谁来处理?(Agent 选择)—— 这条消息应该交给哪个 Agent?

  2. 哪个会话?(Session 路由)—— 消息属于哪个对话上下文?

  3. 怎么回去?(回复路由)—— Agent 的回复怎么发回原通道?

这三个问题的答案由 Session Router 一次性决定,输出一个 ResolvedRoute 对象:

type ResolvedRoute = {
  agentId: string;           // 处理此消息的 Agent
  channel: string;           // 通道标识(telegram/discord/...)
  accountId: string;         // 通道账户标识
  sessionKey: string;        // 完整的 session key(路由键)
  mainSessionKey: string;    // Agent 的主 session key
  matchedBy: string;         // 匹配方式(调试用)
};

11.2 路由调度全景图

消息到达(Telegram / Discord / Slack / WhatsApp / ...)
  │
  ▼
Channel 适配层(标准化 InboundContext)
  │  提取:channel, accountId, peer{kind,id}, guildId, teamId, memberRoleIds
  │
  ▼
┌─────────────────────────────────────────────────────────────────┐
│                    resolveAgentRoute()                           │
│                  src/routing/resolve-route.ts                    │
│                                                                 │
│  输入:                                                          │
│    cfg        ← 完整配置                                         │
│    channel    ← "telegram" / "discord" / ...                    │
│    accountId  ← 通道账户 ID                                      │
│    peer       ← { kind: "direct"|"group"|"channel", id: "..." }│
│    guildId    ← Discord guild ID(可选)                          │
│    teamId     ← Slack team ID(可选)                             │
│    memberRoleIds ← Discord 角色 ID 列表(可选)                    │
│    parentPeer ← 父级 peer(线程继承用)                            │
│                                                                 │
│  ┌─────────────────────────────────────────────────────┐       │
│  │           7 层级联匹配(Tiered Matching)              │       │
│  │                                                      │       │
│  │  Tier 1: binding.peer        ← 精确 peer 匹配        │       │
│  │  Tier 2: binding.peer.parent ← 父 peer 匹配(线程)   │       │
│  │  Tier 3: binding.guild+roles ← Guild + 角色匹配      │       │
│  │  Tier 4: binding.guild       ← Guild 匹配            │       │
│  │  Tier 5: binding.team        ← Team 匹配             │       │
│  │  Tier 6: binding.account     ← 账户匹配              │       │
│  │  Tier 7: binding.channel     ← 通道级匹配            │       │
│  │  Fallback: default agent                             │       │
│  └─────────────────────────────────────────────────────┘       │
│                          │                                      │
│                          ▼                                      │
│  ┌─────────────────────────────────────────────────────┐       │
│  │         buildAgentSessionKey()                        │       │
│  │     根据 agentId + peer + dmScope 生成 sessionKey     │       │
│  └─────────────────────────────────────────────────────┘       │
│                                                                 │
│  输出:ResolvedRoute { agentId, sessionKey, mainSessionKey }    │
└─────────────────────────────────────────────────────────────────┘
  │
  ▼
agentCommand()  ← 使用 sessionKey 查找/创建会话,执行 Agent
  │
  ▼
回复路由  ← 通过 lastRoute 记录的 channel/accountId 回发到原通道

11.3 Bindings:路由规则的声明式配置

Bindings 是用户在 openclaw.json 中声明的路由规则,定义了「什么条件的消息 → 交给哪个 Agent」。

配置格式

{
  bindings: [
    // 精确 peer 匹配:这个 Telegram 群 → 交给 worker agent
    {
      agentId: "worker",
      match: {
        channel: "telegram",
        peer: { kind: "group", id: "-1001234567890" }
      }
    },
    // 账户级匹配:alerts bot 的所有消息 → 交给 alerts agent
    {
      agentId: "alerts",
      match: { channel: "telegram", accountId: "alerts" }
    },
    // 通道级兜底:所有 Telegram 消息 → 交给 creator agent
    {
      agentId: "creator",
      match: { channel: "telegram", accountId: "*" }
    }
  ]
}

Binding Match 标准化

每条 binding 的 match 字段会被标准化为内部结构:

type NormalizedBindingMatch = {
  accountPattern: string;              // 账户匹配模式("*" = 任意,"" = 默认账户)
  peer: {
    state: "none" | "valid" | "invalid";  // peer 约束状态
    kind?: "direct" | "group" | "channel";
    id?: string;
  };
  guildId: string | null;             // Discord guild ID
  teamId: string | null;              // Slack team ID
  roles: string[] | null;            // Discord 角色列表
};

关键细节

  • accountId 为空或未设置 → 只匹配默认账户

  • accountId: "*" → 匹配该通道所有账户

  • peer.kind 支持 "group""channel" 互通(peerKindMatches 函数视两者等价)

11.4 七层级联匹配算法:最具体者胜出

resolveAgentRoute() 是路由的核心函数,实现了一个7 层级联匹配算法。每一层代表一种匹配精度,从最具体到最宽泛,第一个匹配到的层级直接返回结果,后续层级不再评估。

const tiers = [
  // Tier 1: 精确 peer 匹配(最高优先级)
  {
    matchedBy: "binding.peer",
    enabled: Boolean(peer),              // 有 peer 信息时才启用
    scopePeer: peer,
    candidates: collectPeerIndexedBindings(bindingsIndex, peer),
    predicate: (c) => c.match.peer.state === "valid"
  },

  // Tier 2: 父级 peer 匹配(线程继承)
  {
    matchedBy: "binding.peer.parent",
    enabled: Boolean(parentPeer?.id),    // 有父 peer 时才启用
    scopePeer: parentPeer,
    candidates: collectPeerIndexedBindings(bindingsIndex, parentPeer),
    predicate: (c) => c.match.peer.state === "valid"
  },

  // Tier 3: Guild + 角色匹配(Discord 专属)
  {
    matchedBy: "binding.guild+roles",
    enabled: Boolean(guildId && memberRoleIds.length > 0),
    candidates: bindingsIndex.byGuildWithRoles.get(guildId) ?? [],
    predicate: (c) => hasGuildConstraint(c.match) && hasRolesConstraint(c.match)
  },

  // Tier 4: 纯 Guild 匹配(Discord)
  {
    matchedBy: "binding.guild",
    enabled: Boolean(guildId),
    candidates: bindingsIndex.byGuild.get(guildId) ?? [],
    predicate: (c) => hasGuildConstraint(c.match) && !hasRolesConstraint(c.match)
  },

  // Tier 5: Team 匹配(Slack)
  {
    matchedBy: "binding.team",
    enabled: Boolean(teamId),
    candidates: bindingsIndex.byTeam.get(teamId) ?? [],
    predicate: (c) => hasTeamConstraint(c.match)
  },

  // Tier 6: 账户匹配(具体 accountId)
  {
    matchedBy: "binding.account",
    enabled: true,
    candidates: bindingsIndex.byAccount,
    predicate: (c) => c.match.accountPattern !== "*"
  },

  // Tier 7: 通道级兜底(accountId: "*")
  {
    matchedBy: "binding.channel",
    enabled: true,
    candidates: bindingsIndex.byChannel,
    predicate: (c) => c.match.accountPattern === "*"
  }
];

// 遍历每一层,第一个匹配的直接返回
for (const tier of tiers) {
  if (!tier.enabled) continue;
  const matched = tier.candidates.find(candidate =>
    tier.predicate(candidate) &&
    matchesBindingScope(candidate.match, { ...baseScope, peer: tier.scopePeer })
  );
  if (matched) return choose(matched.binding.agentId, tier.matchedBy);
}

// 所有层都没匹配 → 使用默认 Agent
return choose(resolveDefaultAgentId(cfg), "default");

匹配示例

以我们自己的配置为例:

4 个 Agent:墨白(creator) / 虾米(worker) / 二狗(aigw) / 铁面(commander)
4 个 Telegram Bot,每个绑定一个 accountId

消息来源                           │ 匹配层级              │ 路由到
───────────────────────────────────┼──────────────────────┼────────
@zhoujun_bot 私聊                  │ Tier 6: account      │ 墨白
@zhoujun88_bot 私聊                │ Tier 6: account      │ 虾米
军师联盟群 @aigw88_bot              │ Tier 1: peer(group)  │ 二狗
某群 @commander_bot                │ Tier 6: account      │ 铁面
未匹配的消息                        │ Fallback: default    │ 墨白

11.5 Binding 索引:O(1) 查找的秘密

如果每条消息都遍历所有 binding 做线性匹配,在配置大量规则时会很慢。resolve-route.ts 实现了一个预计算索引来加速查找。

索引结构

type EvaluatedBindingsIndex = {
  byPeer: Map<string, EvaluatedBinding[]>;           // peer key → bindings
  byGuildWithRoles: Map<string, EvaluatedBinding[]>;  // guildId → bindings
  byGuild: Map<string, EvaluatedBinding[]>;           // guildId → bindings
  byTeam: Map<string, EvaluatedBinding[]>;            // teamId → bindings
  byAccount: EvaluatedBinding[];                      // 账户级 bindings
  byChannel: EvaluatedBinding[];                      // 通道级 bindings
};

索引构建

function buildEvaluatedBindingsIndex(bindings) {
  for (const binding of bindings) {
    // 有 peer 约束 → 放入 byPeer(O(1) 按 peer key 查找)
    if (binding.match.peer.state === "valid") {
      for (const key of peerLookupKeys(kind, id)) {
        pushToIndexMap(byPeer, key, binding);
      }
      continue;
    }
    // 有 guild + roles → 放入 byGuildWithRoles
    if (binding.match.guildId && binding.match.roles) {
      pushToIndexMap(byGuildWithRoles, binding.match.guildId, binding);
      continue;
    }
    // ... 以此类推
    // 最后:byAccount 或 byChannel
  }
}

Peer Key 的双向查找peerLookupKeys("group", "123") 返回 ["group:123", "channel:123"],确保 groupchannel 类型的 peer 可以互相匹配。

两级缓存

Level 1: evaluatedBindingsCacheByCfg(WeakMap<Config, Map>)
  └─ Key: "telegram\tdefault"(channel + accountId)
  └─ Value: 预筛选后的 binding 列表 + 索引

Level 2: resolvedRouteCacheByCfg(WeakMap<Config, Map>)
  └─ Key: "telegram\tdefault\tgroup:123\t-\t-\t-\t-\tmain"
  └─ Value: 完整的 ResolvedRoute 对象

缓存失效策略

  • 使用 WeakMap 以 config 对象为 key,config 变更(新对象)自动失效

  • 内部维护 bindingsRef / agentsRef / sessionRef 引用比较,任一变化则清空

  • 硬性大小限制:Level 1 缓存 2000 条,Level 2 缓存 4000 条,超限时全量清空后重建

性能效果:对于同一 peer 的重复消息(最常见场景),命中 Level 2 缓存后直接返回,零计算开销。

11.6 Session Key 生成:从路由到会话标识

路由确定 agentId 后,下一步是生成 sessionKey——这是会话的唯一标识,决定了消息归入哪个对话上下文。

Session Key 的生成逻辑

function buildAgentPeerSessionKey(params) {
  const peerKind = params.peerKind ?? "direct";

  if (peerKind === "direct") {
    // DM 消息:根据 dmScope 决定隔离策略
    const dmScope = params.dmScope ?? "main";

    switch (dmScope) {
      case "main":
        // 所有 DM 共享一个主会话
        return `agent:${agentId}:main`;

      case "per-peer":
        // 按发送者隔离(跨通道)
        return `agent:${agentId}:direct:${peerId}`;

      case "per-channel-peer":
        // 按通道+发送者隔离
        return `agent:${agentId}:${channel}:direct:${peerId}`;

      case "per-account-channel-peer":
        // 按账户+通道+发送者隔离(最细粒度)
        return `agent:${agentId}:${channel}:${accountId}:direct:${peerId}`;
    }
  }

  // 群聊/频道消息:始终隔离
  return `agent:${agentId}:${channel}:${peerKind}:${peerId}`;
  // 例:agent:creator:telegram:group:-1001234567890
}

dmScope 四种模式对比

dmScope

Session Key 格式

适用场景

main(默认)

agent:creator:main

单用户,跨设备/通道连续对话

per-peer

agent:creator:direct:5534067368

多用户,同人跨通道共享

per-channel-peer

agent:creator:telegram:direct:5534067368

多用户,严格按通道隔离

per-account-channel-peer

agent:creator:telegram:default:direct:5534067368

多账户多用户,最细隔离

安全提醒main 模式下所有 DM 共享同一会话,这意味着:

  • 用户 A 的对话内容对用户 B 可见(通过上下文)

  • 适合单人使用,多人场景必须切换到 per-channel-peer 以上

Identity Links:跨通道身份合并

当 dmScope 不是 main 时,同一个人从不同通道(Telegram / Discord)发消息会产生不同的 sessionKey。identityLinks 解决这个问题:

{
  session: {
    dmScope: "per-peer",
    identityLinks: {
      "zhoujun": ["telegram:5534067368", "discord:123456789"]
    }
  }
}
function resolveLinkedPeerId(params) {
  // 把 "telegram:5534067368" 查找到 canonical name "zhoujun"
  for (const [canonical, ids] of Object.entries(identityLinks)) {
    for (const id of ids) {
      if (candidates.has(normalizeToken(id))) return canonical;
    }
  }
  return null;  // 未找到映射,使用原始 peerId
}

效果:无论从 Telegram 还是 Discord 发消息,都映射到 agent:creator:direct:zhoujun,共享同一会话。

11.7 特殊 Session Key 类型

除了标准的 peer-based session key,系统还有多种特殊 key:

// Session Key 分类识别函数

isSubagentSessionKey(key)
  // "agent:creator:subagent:8d298b97-..." → true
  // 子代理会话,由 sessions_spawn 创建

isCronSessionKey(key)
  // "agent:creator:cron:job-123" → true
  // Cron 任务会话

isCronRunSessionKey(key)
  // "agent:creator:cron:job-123:run:abc" → true
  // Cron 单次执行的隔离会话

isAcpSessionKey(key)
  // "agent:creator:acp:session-456" → true
  // ACP 协议会话

getSubagentDepth(key)
  // "agent:creator:subagent:a:subagent:b" → 2
  // 子代理嵌套深度

resolveThreadParentSessionKey(key)
  // "agent:creator:telegram:group:123:thread:456"
  //   → "agent:creator:telegram:group:123"
  // 线程的父级 session key

线程 Session Key

function resolveThreadSessionKeys(params) {
  const threadId = params.threadId;
  if (!threadId) return { sessionKey: params.baseSessionKey };

  return {
    sessionKey: `${params.baseSessionKey}:thread:${threadId}`,
    parentSessionKey: params.parentSessionKey
  };
}
// Discord: agent:creator:discord:channel:123:thread:456
// Telegram forum: agent:creator:telegram:group:-100123:topic:42

11.8 Agent Scope 解析:从 Session Key 到 Agent 配置

确定 agentId 后,系统需要解析该 Agent 的完整配置(workspace、model、tools 等)。这由 agent-scope.ts 负责。

Agent 配置解析链

sessionKey: "agent:creator:telegram:5534067368"
  │
  ├─ parseAgentSessionKey()
  │    → { agentId: "creator", rest: "telegram:5534067368" }
  │
  ├─ resolveAgentEntry(cfg, "creator")
  │    → 从 agents.list 中找到 { id: "creator", workspace: "~/.openclaw/workspace-creator", ... }
  │
  ├─ resolveAgentConfig(cfg, "creator")
  │    → { name, workspace, agentDir, model, skills, memorySearch,
  │        humanDelay, heartbeat, identity, groupChat, subagents, sandbox, tools }
  │
  ├─ resolveAgentWorkspaceDir(cfg, "creator")
  │    → "/Users/zhoujun24/.openclaw/workspace-creator"
  │    优先级:agent.workspace > agents.defaults.workspace > 默认路径
  │
  ├─ resolveAgentDir(cfg, "creator")
  │    → "/Users/zhoujun24/.openclaw/agents/creator/agent"
  │    Auth Profile、模型注册等 per-agent 状态存储在这里
  │
  └─ resolveAgentEffectiveModelPrimary(cfg, "creator")
       → "oneapi/Claude Opus 4.6"
       优先级:agent.model > agents.defaults.model > 全局默认

Default Agent 选择

function resolveDefaultAgentId(cfg) {
  const agents = listAgentEntries(cfg);
  if (agents.length === 0) return "main";  // 无 agent 配置 → 默认 "main"

  // 找标记了 default: true 的 agent
  const defaults = agents.filter(agent => agent?.default);
  if (defaults.length > 1) {
    warn("Multiple agents marked default=true; using the first.");
  }

  // 有 default 标记 → 用第一个 default
  // 无 default 标记 → 用列表第一个
  const chosen = (defaults[0] ?? agents[0])?.id;
  return normalizeAgentId(chosen || "main");
}

Agent ID 标准化

function normalizeAgentId(value) {
  const trimmed = (value ?? "").trim();
  if (!trimmed) return "main";

  // 合法字符:a-z 0-9 _ -,最长 64 字符
  if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) {
    return trimmed.toLowerCase();
  }

  // 非法字符替换为 -,去除首尾 -
  return trimmed.toLowerCase()
    .replace(/[^a-z0-9_-]+/g, "-")
    .replace(/^-+/, "")
    .replace(/-+$/, "")
    .slice(0, 64) || "main";
}

11.9 从路由到执行的完整链路

把前面的模块串起来,一条消息从到达到 Agent 执行的完整路径:

① Telegram Bot API → 收到消息
     │
② Channel 适配层(src/telegram/)
     │  标准化为 InboundContext:
     │  {
     │    Body: "帮我分析一下这段代码",
     │    ChatType: "direct",
     │    Provider: "telegram",
     │    AccountId: "default",
     │    From: "5534067368",
     │    ChatId: "5534067368",
     │    ...
     │  }
     │
③ resolveAgentRoute()(src/routing/resolve-route.ts)
     │  输入:channel="telegram", accountId="default", peer={kind:"direct", id:"5534067368"}
     │  七层匹配 → Tier 6: binding.account → agentId="creator"
     │  生成 sessionKey → "agent:creator:main"(dmScope=main)
     │  输出:{ agentId: "creator", sessionKey: "agent:creator:main", ... }
     │
④ resolveAgentConfig()(src/agents/agent-scope.ts)
     │  解析 workspace、model、tools、skills 等配置
     │  workspace → /Users/zhoujun24/.openclaw/workspace-creator
     │  model → oneapi/Claude Opus 4.6
     │
⑤ enqueueCommandInLane(CommandLane.Main, ...)
     │  进入 Main Lane 队列等待执行(严格串行)
     │
⑥ agentCommand()(src/commands/agent.ts)
     │  Session 管理:加载/创建 session entry
     │  读取 workspace 文件(SOUL.md, AGENTS.md, USER.md, ...)
     │  构建 System Prompt
     │  构建工具集(createOpenClawCodingTools → 9 层策略过滤)
     │  构建 Skills 快照
     │
⑦ runEmbeddedPiAgent()(src/agents/pi-embedded-runner/run.ts)
     │  Auth Profile 解析 → API Key
     │  LLM 调用(Anthropic Claude Opus 4.6)
     │  工具调用循环(read/write/exec/web_search/...)
     │  生成最终回复
     │
⑧ 回复路由
     │  通过 session 记录的 lastRoute 信息
     │  → Channel 适配层 → Telegram Bot API → 用户收到回复

11.10 回复路由:消息怎么回去

Agent 生成回复后,系统需要把回复发回正确的通道。这通过 lastRoute 机制实现:

入站时:
  updateLastRoute(sessionKey, {
    channel: "telegram",
    accountId: "default",
    chatId: "5534067368",
    messageId: "465",
    ...
  })

回复时:
  getLastRoute(sessionKey)
  → { channel: "telegram", accountId: "default", chatId: "5534067368" }
  → 通过 Telegram Channel 适配层发送回复

关键设计

  • 回复路由是状态驱动的——基于最后一次入站消息更新的 lastRoute

  • Agent 不选择回复到哪个通道,这是确定性的

  • 多通道共享同一 session 时(dmScope=main),lastRoute 跟随最后一条消息的通道

DM 主会话的 Route Pinning

dmScope=main 且只有一个 allowFrom 用户时,系统会推断出「主人」身份。非主人发来的 DM 不更新 lastRoute,防止回复被发到错误的通道/用户:

配置:allowFrom: ["5534067368"]  ← 唯一用户 = pinned owner

用户 A (5534067368) 发消息 → 更新 lastRoute(是 owner)
用户 B (9999999999) 发消息 → 不更新 lastRoute(不是 owner)
Agent 回复 → 发到用户 A 的通道(lastRoute 未被 B 覆盖)

11.11 路由调度的性能模型

                     热路径性能分析
  ─────────────────────────────────────────────────
  步骤                        │ 耗时    │ 缓存命中
  ─────────────────────────────────────────────────
  Tier 匹配(命中 L2 缓存)   │ ~0.01ms │ 是
  Tier 匹配(命中 L1 缓存)   │ ~0.1ms  │ 部分
  Tier 匹配(冷启动/无缓存)   │ ~1ms    │ 否
  Session Key 生成             │ ~0.01ms │ -
  Agent Config 解析            │ ~0.05ms │ -
  Lane 入队                    │ ~0.01ms │ -
  ─────────────────────────────────────────────────
  总计(热路径)               │ <0.1ms  │
  ─────────────────────────────────────────────────

路由调度本身的开销可以忽略不计。真正的延迟在 LLM 调用(秒级)和工具执行。

11.12 路由调试

当路由行为不符合预期时,可以通过设置 OPENCLAW_VERBOSE=1 环境变量开启调试日志:

[routing] resolveAgentRoute: channel=telegram accountId=default
          peer=direct:5534067368 guildId=none teamId=none bindings=3
[routing] binding: agentId=creator accountPattern=default peer=none
          guildId=none teamId=none roles=0
[routing] binding: agentId=worker accountPattern=worker peer=none
          guildId=none teamId=none roles=0
[routing] match: matchedBy=binding.account agentId=creator

关键排查要点:

  1. 检查 bindings 数量是否符合预期

  2. 确认 peer 的 kind 和 id 是否正确(group/channel 可互通)

  3. 确认 accountId 是否标准化正确

  4. 注意 binding 中 accountId 为空和 "*" 的区别


十二、Agent 子系统路由调度——从 Session 到 Agent 执行

12.1 Agent 子系统在整体架构中的位置

Session Router 解决了「消息交给谁」的问题,Agent 子系统则解决「怎么执行」的问题。两者的边界清晰:

Session Router(路由层)           Agent 子系统(执行层)
──────────────────────            ──────────────────────
resolveAgentRoute()               agentCommand()
  → agentId                         → Session 管理
  → sessionKey                      → Prompt 构建
  → mainSessionKey                  → Model 选择
                                    → Tool 集构建
                                    → LLM 调用
                                    → Tool 调用循环
                                    → 回复投递

12.2 Lane 并发控制与路由的关系

路由确定 agentId 和 sessionKey 后,消息进入 Lane 并发控制(详见第七章 7.1 节)。不同来源的消息进入不同 Lane:

用户消息(Telegram/Discord/...) → CommandLane.Main    (默认并发 1)
Cron 定时任务                     → CommandLane.Cron    (可配并发)
子代理调用(sessions_spawn)      → CommandLane.Subagent (可配并发)
嵌套调用                          → CommandLane.Nested   (内部使用)

Main Lane 并发 1 的设计意义:

  • 保证单 Agent 的主会话严格串行——不会出现两条消息同时修改上下文

  • 用户发了两条消息,第一条在执行时,第二条在队列中等待

  • 心跳检测会检查 Main Lane 是否有活跃任务,有则跳过(心跳让位于用户消息)

12.3 Model 选择与 Failover 路由

Agent 执行时的模型选择也是一种「路由」——消息被路由到哪个 LLM Provider:

模型选择优先级(高 → 低):
  1. Session 级别 override(/model 命令临时切换)
  2. Agent 级别配置(agents.list[].model)
  3. 全局默认(agents.defaults.model)
  4. 系统默认(anthropic/claude-sonnet-4-5)
function resolveAgentEffectiveModelPrimary(cfg, agentId) {
  // Agent 自己的 model 配置
  const agentModel = resolveAgentExplicitModelPrimary(cfg, agentId);
  if (agentModel) return agentModel;

  // 全局默认
  return resolveModelPrimary(cfg.agents?.defaults?.model);
}

Failover 路由:当主模型调用失败时,系统自动切换到 fallback 模型:

模型 Failover 链:
  oneapi/Claude Opus 4.6  ← primary
    │ (认证失败 / Rate Limit / Timeout)
    ▼
  anthropic/claude-sonnet-4-5  ← fallback[0]
    │ (仍然失败)
    ▼
  openai/gpt-4o  ← fallback[1]
    │ (所有 fallback 耗尽)
    ▼
  FailoverError(最终失败)

12.4 子代理路由:sessions_spawn 的调度机制

当 Agent 调用 sessions_spawn 创建子代理时,会触发一个新的路由链路:

主 Agent(agent:creator:main)
  │
  │  sessions_spawn({ task: "...", label: "start-my-day" })
  │
  ▼
Gateway 处理:
  1. 生成子代理 session key:agent:creator:subagent:<uuid>
  2. 入队 CommandLane.Subagent(不占 Main Lane 并发位)
  3. 创建子代理的执行上下文:
     - 继承父 Agent 的 workspace 和 agentDir
     - 使用 "minimal" prompt 模式(只含 Tooling + Workspace + Runtime)
     - 可以指定不同的 model
  4. 执行完成后,通过 subagent.completion 事件通知父会话
  │
  ▼
子代理(agent:creator:subagent:8d298b97-...)
  独立 session + 独立工具集 + 独立 LLM 调用

子代理的嵌套深度限制

function getSubagentDepth(sessionKey) {
  return sessionKey.split(":subagent:").length - 1;
}
// "agent:creator:subagent:a:subagent:b" → depth=2
// 系统限制最大嵌套深度,防止无限递归

12.5 路由系统的设计哲学总结

OpenClaw 的路由调度体现了几个核心设计原则:

  1. 确定性:路由结果完全由配置和消息元数据决定,无随机性,无 AI 参与

  2. 最具体者胜出:七层级联匹配从最精确到最宽泛,保证精确配置不被通用规则覆盖

  3. 缓存优先:两级缓存 + WeakMap 自动失效,热路径性能极高

  4. 串行安全:Main Lane 并发 1 保证会话上下文一致性

  5. 优雅降级:模型 failover、channel health monitor、secrets 降级——每一层都有兜底

  6. 可观测性:verbose 日志记录完整匹配过程,routeCache key 可追踪

以下章节将在后续版本中展开:

  • Channel 适配层:各平台消息格式转换、限速、重试策略

  • Node 系统:移动设备配对、远程控制、Camera/Screen/Location

  • Browser Control:Playwright 集成、Profile 管理、快照机制

  • Provider 适配:各 LLM 提供商的认证、请求格式、流式处理差异

  • 安全模型:认证链路、RBAC、Scope、Rate Limiting、CSP

  • Control UI:Web 管理界面架构

  • 测试体系:单元测试、E2E 测试、Live 测试的组织方式

0

评论区