一、项目概览
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.mjs→dist/entry.js包管理:pnpm
测试:Vitest(多套配置:gateway / channels / extensions / e2e / live)
核心设计理念
Gateway 即中枢:所有通道消息最终汇聚到 Gateway 进程,通过 WebSocket 协议与客户端(CLI、Control UI、Mobile Node)交互
Agent 是执行者:Gateway 不直接调 LLM,而是委托给 Agent 子系统(支持内嵌 Pi Agent、ACP 协议代理等)
Channel 即插件:每个消息平台是一个 Channel Plugin,遵循统一接口,支持热加载和多账户
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(loadConfig、runExec、waitForever 等),也能直接作为 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 关键模块职责
四、消息处理流程
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 适配层回发到 Telegram4.2 agentCommand 核心逻辑
src/commands/agent.ts 是最核心的命令,负责:
Session 解析:根据 sessionKey 确定 agentId、加载 session 历史
模型选择:
resolveDefaultModelForAgent→ 支持 per-session override、failoverPrompt 构建:组装 system prompt(身份、工具、技能、约束)
Agent 执行:
内嵌模式:
runEmbeddedPiAgent()(@mariozechner/pi-coding-agent)ACP 模式:通过 ACP Control Plane 委托外部代理
回复投递:
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 协议 sessionSession 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 防暴力破解
核心方法分类:
5.2 HTTP 端点
除 WebSocket 外,Gateway 还提供 HTTP 服务:
Control UI:Web 管理界面(React SPA,通过 CSP 保护)
OpenAI 兼容 API:
POST /v1/chat/completionsOpenResponses API:
POST /v1/responsesHooks:
POST /hook/:id— 外部系统触发 AgentSlack 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()立即 rejectGatewayDrainingError,实现优雅退出。全局单例:通过
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 时执行的具体动作
};
具体规则举例:
变更检测:深度 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);
}
精妙之处:通过 snapshotMainSessionMapping → restoreMainSessionMapping 的快照/恢复机制,确保 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-agent 的 codingTools,但 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 解析
];
每个工具都是一个对象,包含 name、description、parameters(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 注册,运行时由 resolvePluginTools(src/plugins/tools.ts)实例化。关键保护:
名称冲突检测:插件工具名不能和核心工具重名,否则整个插件的工具被跳过
Allowlist 过滤:可选工具(
optional: true)需要显式出现在 allowlist 中才会加载WeakMap 元数据标记:每个插件工具通过
pluginToolMetaWeakMap 打上{ 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 的
baseDir和filePath都必须在其 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)特别精细:
记录每个工具调用的开始时间(
toolStartDataMap)工具完成后计算耗时、提取结果中的媒体 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 的关系
Plugin SDK 是 OpenClaw 版的 MCP Server:第三方通过 Plugin SDK 注册工具,运行时由
resolvePluginTools()加载——这本质上就是 MCP 的「Server 向 Client 暴露工具」的模式,只是协议不同。Skills 是 MCP 的「Prompt 扩展」:MCP 定义了 Resources 和 Prompts,OpenClaw 的 Skills 扮演了类似角色——声明式地扩展 Agent 的能力描述。
mcporterSkill:OpenClaw 内置了mcporterskill(在skills/mcporter/SKILL.md),可以通过它调用真正的 MCP Server。Agent 读取 mcporter 的 SKILL.md 后,可以用exec工具调用mcporterCLI 来与任意 MCP Server 交互。
工具发现的实际路径
启动时:
loadGatewayPlugins()
→ loadOpenClawPlugins()(jiti 动态加载 JS/TS 插件文件)
→ 每个插件 export tools: ToolFactory[]
→ 运行时 resolvePluginTools() 实例化
Agent 执行时:
createOpenClawCodingTools()
→ 核心工具(硬编码注册)
→ OpenClaw 平台工具(硬编码注册)
→ resolvePluginTools()(插件工具动态加载)
→ 9 层策略过滤
→ 最终工具列表
8.10 工具分类速查表
附录:源码目录速查
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 Bridge(openclaw 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) │
└─────────────────────┘
两种使用模式:
ACP Bridge 模式:IDE →
openclaw acp→ Gateway(IDE 是客户端,OpenClaw 是 Agent)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 连接。
翻译器:AcpGatewayAgent(src/acp/translator.ts,约 1100 行)
这是 ACP Bridge 的核心,实现了 Agent 接口,负责 ACP ↔ Gateway 消息的双向翻译。
ACP 方法到 Gateway 的映射:
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)执行任务。
控制面:AcpSessionManager(src/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」的遥控器协议
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),包含三张表:
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); // 指数衰减
}
时间来源的优先级:
文件名中的日期(
memory/2026-03-13.md→ 2026-03-13)文件的 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 后的集合交集/并集)
算法流程:
选出分数最高的结果加入 selected
对剩余每个候选,计算 MMR 分数(兼顾与 query 的相关性和与已选结果的差异性)
选 MMR 分数最高的加入 selected
重复直到选满
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(" ");
关键约束:
只允许
read和write两个工具(MEMORY_FLUSH_ALLOWED_TOOL_NAMES)write 被包装为 append-only(
wrapToolMemoryFlushAppendOnlyWrite),不能覆盖已有内容核心文件(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 系统交互:
memory_search
// 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 系统速查表
十一、Session Router 深度解析——路由调度的核心引擎
11.1 路由问题的本质
OpenClaw 是一个多通道、多账户、多 Agent 的网关。一条消息从 Telegram/Discord/WhatsApp 等任意通道进来,系统需要回答三个问题:
谁来处理?(Agent 选择)—— 这条消息应该交给哪个 Agent?
哪个会话?(Session 路由)—— 消息属于哪个对话上下文?
怎么回去?(回复路由)—— 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"],确保 group 和 channel 类型的 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 四种模式对比
安全提醒: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
关键排查要点:
检查
bindings数量是否符合预期确认
peer的 kind 和 id 是否正确(group/channel 可互通)确认
accountId是否标准化正确注意 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 的路由调度体现了几个核心设计原则:
确定性:路由结果完全由配置和消息元数据决定,无随机性,无 AI 参与
最具体者胜出:七层级联匹配从最精确到最宽泛,保证精确配置不被通用规则覆盖
缓存优先:两级缓存 + WeakMap 自动失效,热路径性能极高
串行安全:Main Lane 并发 1 保证会话上下文一致性
优雅降级:模型 failover、channel health monitor、secrets 降级——每一层都有兜底
可观测性:verbose 日志记录完整匹配过程,routeCache key 可追踪
以下章节将在后续版本中展开:
Channel 适配层:各平台消息格式转换、限速、重试策略
Node 系统:移动设备配对、远程控制、Camera/Screen/Location
Browser Control:Playwright 集成、Profile 管理、快照机制
Provider 适配:各 LLM 提供商的认证、请求格式、流式处理差异
安全模型:认证链路、RBAC、Scope、Rate Limiting、CSP
Control UI:Web 管理界面架构
测试体系:单元测试、E2E 测试、Live 测试的组织方式
评论区