重读OpenClaw:Plugin SDK深度解析
openclaw Plugin SDK 深度解析:从发现到调用的完整链路
这是「重读 OpenClaw」系列的第三篇,读完这篇,你将彻底理解 openclaw 的插件系统是如何工作的——插件怎么被发现、怎么加载、怎么注册、怎么被 Agent 调用。每一步的输入输出、每个关键对象的结构和作用、闭包绑定机制、handlers 传输容器模式、Factory 延迟实例化、按需激活的两层决策架构,全部讲清楚。
一、Plugin 在 openclaw 中是什么地位?
先说一个可能颠覆认知的事实:openclaw 几乎所有功能都是插件。
它不是”框架 + 可选插件”的架构,而是”框架只是个骨架,器官全是插件”的架构。以下这些全是 plugin:
|
|
|
|---|---|
| Model Provider |
|
| Channel(聊天平台) |
|
| Web Search |
|
| Speech/TTS |
|
| Memory |
|
| Image/Video |
|
| Tool |
|
extensions/ 目录下有 100+ 个插件,每个都是一个独立的目录,包含 openclaw.plugin.json + TypeScript 源码。
所以 Plugin 不是 openclaw 的”附加功能”,而是它的第一公民。理解 Plugin 机制,就是理解 openclaw 本身。
二、一个 Plugin 包里有什么?
2.1 物理结构
每个插件是 extensions/<name>/ 下的一个目录,最少包含两个文件:
extensions/brave/
├── openclaw.plugin.json ← 静态 manifest(必须)
└── src/
└── index.ts ← 入口文件,导出 register()
2.2 openclaw.plugin.json —— 插件的”静态档案”
以 Brave Search 插件为例:
{
"id": "brave",
"name": "Brave",
"description": "Brave Search provider plugin",
"activation": {
"onStartup":false
},
"setup": {
"providers": [{
"id": "brave",
"authMethods": ["api-key"],
"envVars": ["BRAVE_API_KEY"]
}]
},
"contracts": {
"webSearchProviders": ["brave"]
},
"configSchema": {
"type": "object",
"properties": {
"webSearch": {
"type": "object",
"properties": {
"apiKey": { "type": ["string", "object"] },
"mode": { "type": "string", "enum": ["web", "llm-context"] }
}
}
}
}
}
这个 manifest 文件的完整字段类型是 PluginManifest,包含:
|
|
|
|---|---|
id |
|
activation |
onStartup 是否随启动激活,onProviders/onChannels/onCommands 按需激活 |
contracts |
tools、webSearchProviders、speechProviders 等 |
setup |
|
configSchema |
|
channels
providers |
|
2.3 入口文件 —— 必须导出 register 函数
框架对插件的要求极简——只需要导出一个 register 函数:
// types.ts 中的类型定义
export type OpenClawPluginModule =
| OpenClawPluginDefinition // { register?: (api) => void }
| ((api: OpenClawPluginApi) => void); // 直接就是一个函数!
也就是说下面这种写法完全合法:
// 最简插件 —— 导出一个函数即可
export default function register(api: OpenClawPluginApi) {
api.registerTool({ name: "hello", /*...*/ });
}
2.4 插件的完整生命周期
发现 (Discovery) → 加载 (Load) → 注册 (Register) → 激活 (Activate) → 退役 (Retire)
|
|
|
|
|---|---|---|
| Discovery | discoverOpenClawPlugins() |
openclaw.plugin.json |
| Load | loadPluginModule() |
register 函数 |
| Register | register(api) |
api.registerTool() 等方法,写入 registry |
| Activate | activatePluginRegistry() |
|
| Retire | cleanupPreviousPluginHostRegistry() |
|
下面我们按这个顺序,一步一步深入。
三、第一步:发现(Discovery)—— 怎么找到插件?
3.1 入口函数
loadOpenClawPlugins() 是插件的总入口(src/plugins/loader.ts),它做的第一件事就是调用 discoverOpenClawPlugins() 扫描文件系统。
3.2 扫描 5 个来源
|
|
|
|
|---|---|---|
| bundled | <install>/extensions/ |
|
| workspace | <cwd>/.openclaw/plugins/ |
|
| global | ~/.openclaw/plugins/ |
|
| package | node_modules/
openclaw-plugin-* |
|
| bundle |
|
|
每个目录递归扫描,找到 openclaw.plugin.json,记录为一个 PluginCandidate:
type PluginCandidate = {
idHint: string; // 插件 id
source: string; // 入口文件路径
rootDir: string; // 插件根目录
origin: PluginOrigin; // "bundled" | "workspace" | "global" | "package" | "bundle"
packageName?: string; // npm 包名
bundledManifest?: PluginManifest; // manifest JSON 内容
};
3.3 输入 → 输出
输入: workspaceDir(工作目录), env(环境变量)
↓
discoverOpenClawPlugins()
↓
输出: PluginCandidate[](候选插件列表)
四、第二步:加载(Load)—— 怎么把 TypeScript 变成可执行代码?
4.1 jiti 即时编译
openclaw 的插件不需要预编译。loadPluginModule() 使用 jiti(JIT Instant TypeScript)即时编译:
// loader.ts
mod = loadPluginModule("extensions/brave/src/index.ts");
// jiti 即时编译 TS → 执行顶层代码 → 返回模块导出
jiti 还负责解析 import ... from "openclaw/plugin-sdk" 这种导入——它被 alias 到真实的实现文件,不需要 node_modules 里真有这个包。
4.2 提取 register 函数
插件模块导出后,resolvePluginModuleExport() 从模块中提取 register 函数,支持三种导出格式:
-
• export default function register(api) {} -
• export function register(api) {} -
• 整个模块就是一个函数
五、核心概念:PluginRecord —— 插件的”身份证”
在加载每个插件之前,框架会为它创建一张”身份证”——PluginRecord。
5.1 它是什么
PluginRecord(src/plugins/registry-types.ts)是一个包含 50+ 字段 的对象,记录了插件的一切:
type PluginRecord = {
// ===== 身份信息 =====
id: "brave"; // 唯一标识
name: "Brave"; // 人类可读名
version?: "1.0.0";
description?: "Brave Search provider plugin";
packageName?: string; // npm 包名
// ===== 来源信息 =====
source: "extensions/brave/src/index.ts"; // 入口文件路径
rootDir: "extensions/brave"; // 插件根目录
origin: "bundled"; // 来源类型
// ===== 状态信息 =====
status: "loaded" | "disabled" | "error";
enabled: true;
error?: string; // 失败原因
failedAt?: Date; // 失败时间
failurePhase?: "validation" | "load" | "register";
// ===== 能力清单 =====
toolNames: ["web_search"]; // 注册了哪些 tool
hookNames: ["before_agent_start"]; // 注册了哪些 hook
channelIds: ["discord"]; // 注册了哪些 channel
providerIds: ["brave"]; // 注册了哪些 provider
webSearchProviderIds: ["brave"];
// ... 20+ 个能力 ID 数组
// ===== 合同(manifest 里声明的)=====
contracts: { webSearchProviders: ["brave"], tools: ["web_search"] };
};
5.2 它从哪里来
在 loader 的加载循环中,对每个候选插件调用 createPluginRecord():
// loader.ts
const record = createPluginRecord({
id: pluginId, // "brave"
name: manifestRecord.name ?? pluginId, // "Brave"
source: candidate.source, // 入口文件路径
rootDir: candidate.rootDir, // 插件根目录
origin: candidate.origin, // "bundled"
enabled: enableState.enabled, // true
contracts: manifestRecord.contracts, // { webSearchProviders: ["brave"] }
});
5.3 它在整个流程中的角色
PluginCandidate (文件系统扫描的原始信息)
↓ createPluginRecord()
PluginRecord (规范化后的"身份证")
↓ 存入 registry.plugins[]
↓ 传给 createApi(record) → 绑定到所有 register* 方法
↓ 随 registry 一起被激活
一句话:record 是插件的”户口本”,记录了它是谁、从哪来、处于什么状态、拥有哪些能力。它在 createPluginRecord() 时出生,在 createApi() 时被绑定到所有注册方法上,伴随插件的整个生命周期。
六、核心概念:Registry —— 插件的”管理者”
6.1 它是什么
PluginRegistry(src/plugins/registry-types.ts)是一个包含 ~40 个数组 的纯数据容器:
type PluginRegistry = {
plugins: PluginRecord[]; // 所有插件的档案
tools: PluginToolRegistration[]; // agent 工具注册
hooks: PluginHookRegistration[]; // 生命周期钩子
channels: PluginChannelRegistration[]; // 聊天平台
providers: PluginProviderRegistration[]; // 模型提供商
webSearchProviders: [...];
webFetchProviders: [...];
speechProviders: [...];
httpRoutes: PluginHttpRouteRegistration[]; // HTTP 路由
commands: PluginCommandRegistration[]; // CLI 命令
// ... 还有 30+ 个数组
diagnostics: PluginDiagnostic[]; // 错误/警告
};
6.2 它从哪里来
createEmptyPluginRegistry()(src/plugins/registry-empty.ts)创建全空的 registry:
function createEmptyPluginRegistry(): PluginRegistry {
return {
plugins: [],
tools: [], // ← 最终 tool 会进这里
channels: [],
hooks: [],
// ... 全部 40 个空数组
diagnostics: [],
};
}
6.3 同一时间只有一个活跃的 registry
Registry 通过全局单例管理(src/plugins/runtime.ts):
const state: RegistryState = {
activeRegistry: PluginRegistry | null, // 唯一的"主"registry
activeVersion: 0,
httpRoute: { registry, pinned, version }, // HTTP 路由可以 pin 到特定 registry
channel: { registry, pinned, version }, // Channel 也可以 pin
};
-
• 同一时间只有一个 activeRegistry -
• HTTP 路由和 Channel 可以 pin 到不同的 registry -
• 切换 registry 时,旧的被清理(retire),新的接管
七、第三步:注册(Register)—— createPluginRegistry() 内部发生了什么?
这是整个插件系统最核心的部分。我们从 createPluginRegistry() 被调用开始,一步步追踪。
7.1 第一步:创建空壳 + 闭包函数
// loader.ts:1898
const { registry, createApi, ... } = createPluginRegistry({
logger, runtime,
coreGatewayHandlers,
activateGlobalSideEffects: shouldActivate,
});
进入 createPluginRegistry()(src/plugins/registry.ts):
function createPluginRegistry(registryParams) {
// 1. 创建空壳 —— 40 个空数组
const registry = createEmptyPluginRegistry();
// 2. 在闭包内定义 ~30 个 register* 函数
// 每个都能访问 registry!
const registerTool = (record, tool, opts) => {
// 校验 contracts.tools 声明
// 构建 factory
// registry.tools.push({ pluginId, factory, names, ... })
};
const registerChannel = (record, channel) => { ... };
const registerHook = (record, events, handler, ...) => { ... };
// ... 还有 20+ 个
// 3. 定义 createApi —— 这是关键!
// 把 record 绑定到 register* 函数上
const createApi = (record, params) => {
return buildPluginApi({
handlers: {
registerTool: (tool, opts) => registerTool(record, tool, opts),
// ↑ 闭包捕获了 record,插件不需要知道自己是谁
registerChannel: (ch) => registerChannel(record, ch),
// ... 全部 50+ 个方法
}
});
};
// 4. 返回
return { registry, createApi, ... };
}
此时内存状态:
registry = {
tools: [], channels: [], hooks: [], ... ← 全部空数组
}
闭包内(外部不可访问):
registerTool = (record, tool, opts) => { registry.tools.push(...) }
registerChannel = (record, ch) => { ... }
createApi = (record, params) => { ... } // 绑定函数
7.2 第二步:jiti 加载插件模块
// loader.ts:2358
mod = loadPluginModule("extensions/brave/src/index.ts");
// jiti 即时编译 TS → 返回 { default: function register(api) { ... } }
7.3 第三步:createApi(record) —— 绑定身份证到 API
// loader.ts:2718
const api = createApi(record, {
config: cfg, // 全局配置
pluginConfig: validatedConfig.value, // 插件自己的配置段
registrationMode: "full",
});
这一步产生 OpenClawPluginApi 对象:
api = {
id: "brave",
name: "Brave",
config: { ...全局配置... },
pluginConfig: { webSearch: { apiKey: "BSA..." } },
runtime: { logger, llm, tasks, state, ... },
logger: { info, warn, error },
// 以下每个方法都已绑定 brave 的 record:
registerTool: (tool, opts) => registerTool(braveRecord, tool, opts),
registerChannel: (ch) => registerChannel(braveRecord, ch),
registerWebSearchProvider: (p) => registerWebSearchProvider(braveRecord, p),
// ... 50+ 个方法
}
关键设计:插件代码里写 api.registerTool(myTool) 时,完全不需要传自己的 pluginId——record 已经被闭包捕获了,registerTool 内部自动知道 record.id === "brave"。
7.3.1 为什么用 handlers 包装?—— 内部传输容器模式
你可能注意到了一个细节:createApi 里先把所有 register 方法包在 handlers 对象里,但最终 api 对象上并没有 handlers 属性。这是为什么?
看 buildPluginApi() 的实现(src/plugins/api-builder.ts):
export function buildPluginApi(params: BuildPluginApiParams): OpenClawPluginApi {
const handlers = params.handlers ?? {}; // ← 取出 handlers
// 每个方法从 handlers 里取出,没有就用 noop
const api = {
id: params.id,
config: params.config,
runtime: params.runtime,
registerTool: handlers.registerTool ?? noopRegisterTool,
registerChannel: handlers.registerChannel ?? noopRegisterChannel,
registerProvider: handlers.registerProvider ?? noopRegisterProvider,
// ... 50+ 个方法,逐个解包摊平
};
return attachPluginApiFacades(api); // 再加 facade 层
}
handlers 在这里被”拆开”了——每个方法被逐个取出,用 ?? 提供了 noop 默认值,然后直接赋到 api 对象的顶层。最终的 api 上是平的 50+ 个方法,没有 handlers 属性。
为什么要多这一层包装?三个原因:
原因一:避免 50+ 个函数参数。buildPluginApi 需要接收 50+ 个 register 方法。如果不用 handlers 对象包装,函数签名会变成:
// ❌ 不可维护
function buildPluginApi(
id, name, config, runtime, logger,
registerTool, // 第 7 个
registerChannel, // 第 8 个
registerProvider, // 第 9 个
// ... 50+ 个参数
) { ... }
用 handlers: Partial<Pick<OpenClawPluginApi, "registerTool" | "registerChannel" | ...>>,一个对象装所有方法,类型安全、可选、易扩展。新增一个 register 方法只需加一行类型定义。
原因二:不同 registrationMode 传不同的 handler 集合。createApi 根据 registrationMode 决定传哪些 handler。例如 cli-metadata 模式只需要 registerCli,其他 49 个不需要:
handlers: {
...(registrationCapabilities.capabilityHandlers ? {
// full 模式:传全部 50+ 个
registerTool: (tool, opts) => registerTool(record, tool, opts),
registerChannel: (ch) => registerChannel(record, ch),
// ... 全部
} : {}),
// setup-runtime 模式:只传 channel 相关的几个
...(registrationCapabilities.setupRuntimeHandlers ? {
registerChannel: (ch) => registerChannel(record, ch),
} : {}),
}
原因三:优雅降级。 没有在 handlers 里传的方法,buildPluginApi 自动用 ?? 降级为 noop(空函数)。插件如果误调了一个不适用的 register 方法,不会报错,只是静默丢弃。这在 discovery 和 tool-discovery 等受限模式下特别重要——那些模式下很多能力确实不应该注册。
一句话:handlers 是 createApi 和 buildPluginApi 之间的内部传输容器,作用是让调用方只传需要的 handler,其余自动 noop。buildPluginApi 收到后逐个解包摊平到 api 对象顶层,handlers 用完即弃,不出现在最终产物上。
7.4 第四步:mod.register(api) —— 插件代码执行
// loader.ts:2740
runPluginRegisterSync(register, api);
// 等价于: register(api)
插件代码执行,调用 api 上的各种 register 方法:
function register(api) {
// api 已经绑定到 braveRecord
api.registerWebSearchProvider({
id: "brave",
search: async (params) => { /* HTTP 请求 */ }
});
// → registerWebSearchProvider(braveRecord, { id:"brave", ... })
// → registry.webSearchProviders.push({ pluginId:"brave", ... })
api.registerTool((ctx) => ({
name: "web_search",
description: "Search the web",
parameters: Type.Object({ query: Type.String() }),
execute: async (params) => { /* 调 brave search */ }
}));
// → registerTool(braveRecord, factory, undefined)
}
7.5 第五步:registry.tools.push() —— 写入 registry
registerTool 内部(src/plugins/registry.ts):
const registerTool = (record, tool, opts) => {
// 1. 校验:manifest 的 contracts.tools 必须声明了 tool 名
const declaredNames = normalizePluginToolContractNames(record.contracts);
// brave 的 manifest: "contracts": { "tools": ["web_search"] }
// 2. 统一成 factory 格式
const factory = typeof tool === "function"
? tool // 已经是 factory
: (_ctx) => tool; // 包装成 factory
// 3. 写入 registry.tools[]
registry.tools.push({
pluginId: "brave",
pluginName: "Brave",
factory, // ← 工具工厂函数
names: ["web_search"], // ← Agent 用这个名字调用
declaredNames: ["web_search"],
optional: false,
source: "extensions/brave/src/index.ts",
});
// 4. 更新 record 的能力清单
record.toolNames.push("web_search");
};
此时 registry.tools[] 内存状态:
registry.tools = [
{
pluginId: "brave",
pluginName: "Brave",
factory: (ctx) => ({ name:"web_search", execute: [Function], ... }),
names: ["web_search"],
source: "extensions/brave/src/index.ts",
},
// ... 其他插件注册的 tool
]
7.6 第六步:全部插件加载完毕
// loader.ts:2851
if (shouldActivate) {
activatePluginRegistry(registry, cacheKey, runtimeSubagentMode, options.workspaceDir);
}
return registry;
7.7 整个注册过程的输入→输出串讲
Step 1: PluginLoadOptions
→ loadOpenClawPlugins()
→ discoverOpenClawPlugins() → PluginCandidate[]
→ loadPluginManifestRegistry() → PluginManifestRecord[]
→ createPluginRegistry() → { registry (空壳), createApi }
Step 2: for each candidate:
→ createPluginRecord(candidate, manifest) → PluginRecord
Step 3: createApi(record, {config, pluginConfig})
→ 闭包绑定 record 到所有 register* 方法
→ OpenClawPluginApi
Step 4: mod.register(api)
→ 插件代码调 api.registerTool(), api.registerChannel() 等
→ 每个调用进入对应的 register* 闭包
Step 5: registerTool(record, tool)
→ registry.tools.push({ pluginId, factory, names, ... })
Step 6: activatePluginRegistry(registry)
→ globalThis[...].activeRegistry = registry
→ 所有 tool/channel/hook 全局可见
八、核心概念:Factory —— 为什么 tool 不能直接注册?
8.1 问题:启动时注册 vs 运行时上下文
假设没有 factory,插件只能这样注册:
// ❌ 静态注册 —— 在启动时就创建好了 tool 对象
api.registerTool({
name: "web_search",
execute: async (params) => {
// 问题:这里拿不到 sessionKey、拿不到 agentId、拿不到最新的 config
// 因为这个对象在启动时创建,但 session 是后来才有的
}
});
8.2 解决方案:Factory 模式
Factory 是一个函数,注册时只存函数,调用时才生产 tool:
// ✅ factory 模式
api.registerTool((ctx) => ({
name: "web_search",
execute: async (params) => {
// ctx.config ← 最新全局配置
// ctx.sessionKey ← 当前会话
// ctx.agentId ← 当前 agent
// ctx.workspaceDir ← 工作目录
// ctx.sandboxed ← 是否沙箱
// ctx.fsPolicy ← 文件系统权限
}
}));
8.3 Factory 的类型定义
// src/plugins/tool-types.ts
type OpenClawPluginToolFactory = (
ctx: OpenClawPluginToolContext,
) => AnyAgentTool | AnyAgentTool[] | null | undefined;
输入:OpenClawPluginToolContext(运行时上下文,包含 20+ 个字段)
输出:真正的 AnyAgentTool(或数组,或 null 表示当前上下文不注册)
8.4 OpenClawPluginToolContext —— Factory 收到的上下文
type OpenClawPluginToolContext = {
config?: OpenClawConfig; // 全局配置(可能过期)
runtimeConfig?: OpenClawConfig; // 当前运行时配置快照
getRuntimeConfig?: () => OpenClawConfig | undefined; // 获取最新配置
// 会话信息
sessionKey?: string; // 会话标识
sessionId?: string; // 会话 UUID(/new 时会刷新)
// Agent 信息
agentId?: string; // 当前 agent ID
agentDir?: string; // agent 目录
workspaceDir?: string; // 工作空间目录
// 安全上下文
fsPolicy?: ToolFsPolicy; // 文件系统权限策略
sandboxed?: boolean; // 是否在沙箱中
// 模型信息
activeModel?: { // 当前使用的模型
provider?: string;
modelId?: string;
};
// 消息上下文
messageChannel?: string; // 来自哪个 channel
agentAccountId?: string;
requesterSenderId?: string; // 谁发的消息
deliveryContext?: DeliveryContext; // 投递路由
};
8.5 Factory 的四种返回形式
// 形式 1:返回单个 tool
api.registerTool((ctx) => ({ name: "t1", execute: ... }));
// 形式 2:根据上下文动态决定
api.registerTool((ctx) => {
if (ctx.sandboxed) return null; // 沙箱里不注册
return { name: "t1", execute: ... };
});
// 形式 3:返回多个 tool
api.registerTool((ctx) => [
{ name: "t1", execute: ... },
{ name: "t2", execute: ... },
]);
// 形式 4:兼容模式(直接传 tool 对象,自动包装成 factory)
api.registerTool({ name: "t1", execute: ... });
// 等价于 api.registerTool((_ctx) => ({ name: "t1", execute: ... }));
8.6 Factory 在何时被调用?
不是在 register() 时,而是在 Agent 实际需要 tool 时。调用链(src/plugins/tools.ts):
function resolvePluginToolFactory(entry: PluginToolRegistration, ctx: OpenClawPluginToolContext) {
return runWithPluginToolScope(entry, () =>
wrapPluginToolFactoryResult(entry, entry.factory(ctx)),
// ^^^^^^^^^^^^
// 这里!调 factory
);
}
Agent turn 开始
→ 构建 tool catalog
→ 遍历 registry.tools[]
→ 对每条: factory(ctx) ← ctx 携带当前 session/agent/config
→ 返回 AnyAgentTool (name, description, parameters, execute)
→ 加入 catalog
→ Agent 推理
→ 决定调 tool_call("web_search", {query:"hi"})
→ 在 catalog 中找到 → 调 execute({query:"hi"})
一句话:factory 是”tool 模板的工厂函数”。注册时只存模板,调用时注入运行时上下文,动态生产出适应当前 session/config/agent 的 tool 实例。这解决了”启动时注册 vs 运行时上下文”的时间差矛盾。
九、第四步:激活(Activate)—— registry 变成全局可用
9.1 激活做了什么
所有插件 register 完毕后,loader 调用 activatePluginRegistry():
// src/plugins/runtime.ts
function setActivePluginRegistry(registry, cacheKey, subagentMode, workspaceDir) {
const previousRegistry = state.activeRegistry;
state.activeRegistry = registry; // ← 写入全局单例
state.activeVersion += 1;
// 同步 HTTP 路由和 Channel 表面
syncTrackedSurface(state.httpRoute, registry, true);
syncTrackedSurface(state.channel, registry, true);
// 建立 agent 事件桥
syncPluginAgentEventBridge();
// 如果之前有活跃的 registry,清理它
if (previousRegistry && previousRegistry !== registry) {
cleanupRetiredPluginHostRegistry(previousRegistry);
}
}
9.2 按需激活 —— 不在启动时加载的插件,怎么被唤醒?
不是所有插件都在启动时激活。Brave 插件的 manifest 里写了 "onStartup": false,意味着它随 Gateway 启动时只被发现、不被加载——openclaw.plugin.json 的内容被读到内存,但 src/index.ts 从未被 jiti 执行,tool 和 provider 都不可用。
那么它什么时候被加载?答案是:当有人”点名”需要它时。
整个按需激活机制分两层:决策层(纯读 manifest 元数据,零代码加载)和执行层(真正触发 loadOpenClawPlugins)。
第一层:决策 —— resolveManifestActivationPlan()
这个函数(src/plugins/activation-planner.ts)接收一个 trigger(”谁需要被激活?”),扫描所有已知插件的 manifest 元数据,找出能响应这个 trigger 的插件。整个过程只读 manifest JSON 字段,不 import 任何 TS 代码。
Trigger 有 6 种:
type PluginActivationPlannerTrigger =
| { kind: "command"; command: string } // 用户敲了某命令
| { kind: "provider"; provider: string } // 需要某模型 provider
| { kind: "channel"; channel: string } // channel 收到消息
| { kind: "route"; route: string } // HTTP 路由被命中
| { kind: "agentHarness"; runtime: string } // agent harness 被引用
| { kind: "capability"; capability: "provider"|"channel"|"tool"|"hook" };
匹配逻辑——每种 trigger 检查 manifest 的不同字段:
|
|
|
|
|---|---|---|
{ kind: "provider", provider: "brave" } |
providers
activation.onProviders, setup.providers |
"providers": ["brave"] → 匹配 |
{ kind: "channel", channel: "discord" } |
channels
activation.onChannels |
"channels": ["discord"] → 匹配 |
{ kind: "command", command: "search" } |
activation.onCommands
commandAliases |
"onCommands": ["search"] → 匹配 |
{ kind: "route", route: "api" } |
activation.onRoutes |
"onRoutes": ["api"] → 匹配 |
{ kind: "capability", capability: "tool" } |
contracts.tools
activation.onCapabilities |
"contracts": {"tools": [...]} → 匹配 |
{ kind: "agentHarness", runtime: "..." } |
activation.onAgentHarnesses |
"onAgentHarnesses": [...] → 匹配 |
具体实现(src/plugins/activation-planner.ts):
function resolveManifestActivationPlan(params): PluginActivationPlan {
// 遍历所有已发现插件的 manifest 记录(纯 JSON 元数据,不加载 TS 代码)
const entries = registry.plugins.flatMap((plugin) => {
// 检查这个插件的 manifest 字段是否匹配 trigger
const reasons = listManifestActivationTriggerReasons(plugin, params.trigger);
if (reasons.length === 0) return []; // 不匹配,跳过
return [{ pluginId: plugin.id, origin: plugin.origin, reasons }];
});
return { trigger, pluginIds: [...], entries, diagnostics: [] };
}
第二层:执行 —— 用 onlyPluginIds 只加载目标插件
拿到插件 ID 列表后,调用方再次调 loadOpenClawPlugins(),但这次传入 onlyPluginIds 限定范围。框架只加载这一个(或几个)插件,走完整流程。
以 Provider 被引用为例(src/plugins/providers.runtime.ts):
// 用户配置了 models.providers.brave,或 agent 需要调 brave 模型
function resolveExplicitProviderOwnerPluginIds(params, snapshot) {
return params.providerRefs.flatMap((provider) => {
// 第一步:纯读 manifest,找出谁拥有这个 provider(零代码加载)
const plannedPluginIds = resolveManifestActivationPluginIds({
trigger: { kind: "provider", provider: "brave" },
manifestRecords: snapshot.manifestRegistry.plugins,
});
return plannedPluginIds; // ["brave"]
});
}
// 第二步:用 onlyPluginIds 只加载 brave 这一个插件
const loadOptions = buildPluginRuntimeLoadOptionsFromValues({...}, {
onlyPluginIds: ["brave"], // ← 只加载这一个!
activate: true,
});
// loadOpenClawPlugins(loadOptions)
// → 走完整加载流程,但 for 循环只处理 brave 这一个候选
以 Channel 收到消息为例(src/plugins/channel-presence-policy.ts):
// Discord 消息到达,需要激活 discord 插件来处理
const candidateIds = channelIds.flatMap((channelId) =>
resolveManifestActivationPluginIds({
trigger: { kind: "channel", channel: "discord" },
manifestRecords: records,
})
);
// → ["discord"] → loadOpenClawPlugins({ onlyPluginIds: ["discord"] })
完整时序:Brave 插件从”休眠”到”可用”
Gateway 启动
│
├─ loadOpenClawPlugins({ mode: "full" })
│ ├─ discoverOpenClawPlugins()
│ │ └─ 扫描到 brave 的 openclaw.plugin.json
│ │ 读取 manifest → PluginManifestRecord {
│ │ id: "brave",
│ │ activation: { onStartup: false }, ← 不随启动激活
│ │ contracts: { webSearchProviders: ["brave"] },
│ │ providers: ["brave"]
│ │ }
│ │
│ └─ for (candidate of candidates) {
│ if (brave) {
│ // onStartup=false → enableState.enabled=false
│ record.status = "disabled"
│ record.error = "not enabled at startup"
│ registry.plugins.push(record) ← 进了 registry 但是 disabled
│ continue ← 跳过!不加载 TS 代码!
│ }
│ }
│ → activatePluginRegistry(registry)
│ brave 在 registry.plugins[] 里,但 status="disabled"
│ 没有 brave 的 tool,没有 brave 的 provider
│
├─ 用户发消息 → Agent 决定用 web_search
│ │
│ └─ resolvePluginProviders({ providerRefs: ["brave"] })
│ │
│ ├─ resolveManifestActivationPluginIds({
│ │ trigger: { kind: "provider", provider: "brave" }
│ │ manifestRecords: [...所有 manifest 元数据...]
│ │ })
│ │ └─ 遍历 manifest 记录
│ │ brave.providers 里有 "brave"!→ reason = "manifest-provider-owner"
│ │ → 返回 ["brave"]
│ │
│ └─ loadOpenClawPlugins({
│ onlyPluginIds: ["brave"], ← 只加载 brave!
│ activate: true
│ })
│ ├─ discoverOpenClawPlugins() → 找到 brave
│ ├─ for (candidate of candidates) {
│ │ if (candidate 不是 brave) continue ← 只处理 brave
│ │ record = createPluginRecord(...) ← 创建身份证
│ │ mod = loadPluginModule("extensions/brave/src/index.ts")
│ │ api = createApi(record, {...})
│ │ mod.register(api)
│ │ → api.registerWebSearchProvider(...)
│ │ → api.registerTool(web_search_factory)
│ │ → registry.tools.push({ pluginId:"brave", ... })
│ │ }
│ └─ activatePluginRegistry(registry)
│ → brave 的 tool 和 provider 现在可用了!
│
└─ Agent: tool_call("web_search", {query:"hi"})
→ getActivePluginRegistry().tools[] 里有 web_search 了 ✅
→ factory(ctx) → execute() → 返回结果
一句话:不在启动时加载的插件 = manifest 已读入内存但 TS 代码未执行。当 trigger 发生时,resolveManifestActivationPlan() 纯读 manifest 字段找出需要哪些插件(零 I/O、零代码加载),然后 loadOpenClawPlugins({ onlyPluginIds: [...] }) 只加载这几个插件,走完整编译→注册→激活流程。
十、第五步:Agent 调用 Tool —— 完整链路
10.1 Agent 怎么发现 tool?
Agent 通过 Tool Search 系统(src/agents/tool-search.ts)发现和调用 tool。系统注入 4 个 meta-tool:
|
|
|
|---|---|
tool_search |
|
tool_describe |
|
tool_call |
|
tool_search_code |
|
10.2 完整调用链
1. Agent turn 开始
→ Tool Search 调 getActivePluginRegistry()
→ 遍历 registry.tools[]
→ 对每条: resolvePluginToolFactory(entry, ctx)
→ entry.factory(ctx) ← 注入当前运行时上下文
→ 返回 AnyAgentTool
→ 加入 tool catalog
2. Agent 推理
→ 决定: tool_call({ toolName: "web_search", input: { query: "hello" } })
3. Tool Search 处理 tool_call
→ 在 catalog 中找到匹配条目
→ 调 tool.execute({ query: "hello" })
→ execute 内部发 HTTP 请求到 Brave Search API
→ 返回搜索结果
4. 结果返回 Agent
→ Agent 基于搜索结果继续推理
10.3 Tool 的执行方式
Tool 的执行方式有多种:
|
|
|
|---|---|
| 本地代码 |
execute 就是 TypeScript 函数,jiti 已经编译过,直接调 |
| HTTP 调用 |
execute() 里发 HTTP 请求 |
| MCP 调用 |
CatalogSource = "mcp",MCP tool 走 MCP 协议 |
| CLI 调用 |
api.registerCliBackend() 注册,tool_call 时 fork 子进程 |
十一、槽位机制 —— 互斥能力如何管理?
有些能力同一时间只能有一个插件占据。openclaw 有两种”排他性槽位”:
// src/plugins/slots.ts
const SLOT_BY_KIND = {
memory: "memory", // 同一时间只有一个 memory 插件
"context-engine": "contextEngine", // 同一时间只有一个 context engine
};
冲突解决策略
|
|
|
|---|---|
| 同 ID 去重 |
|
| Hook 同名冲突 |
hook already registered |
| Gateway Method 冲突 |
gateway method already registered |
| HTTP Route 重叠 |
|
| 信任边界 |
|
十二、Bundle 机制 —— 跨生态兼容
Bundle 是 openclaw 加载外部生态插件的机制。它支持三种外部格式:
type PluginBundleFormat = "codex" | "claude" | "cursor";
工作原理
-
1. 读取外部 manifest(如 .claude-plugin/plugin.json) -
2. 探测目录结构(skills/、commands/、hooks/、.mcp.json 等) -
3. 生成标准化的 BundlePluginManifest -
4. openclaw 将外部插件的 skills/commands/MCP servers 映射到自己的对应能力上
能力映射
|
|
|
|
|
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
十三、总结:一张全景图
┌─ 发现 ───────────────────────────────────────────────────────┐
│ discoverOpenClawPlugins() │
│ 扫描 5 个来源 → PluginCandidate[] │
│ 加载 manifest → PluginManifestRecord[] │
└──────────────────────────────────────────────────────────────┘
↓
┌─ 创建 Registry ──────────────────────────────────────────────┐
│ createPluginRegistry() │
│ → createEmptyPluginRegistry() // { tools:[], channels:[], }│
│ → 定义 30 个 register* 闭包函数 │
│ → 定义 createApi = (record) => bind(record, register*们) │
│ → return { registry, createApi, ... } │
└──────────────────────────────────────────────────────────────┘
↓
┌─ 加载循环 ───────────────────────────────────────────────────┐
│ for each candidate: │
│ → createPluginRecord(candidate, manifest) → PluginRecord │
│ → loadPluginModule(source) → jiti 编译 TS → 拿到 register │
│ → api = createApi(record, {config, pluginConfig}) │
│ → 闭包绑定: api.registerTool = (t) => registerTool(rec,t)│
│ → mod.register(api) │
│ → 插件代码: api.registerTool(factory) │
│ → registerTool(record, factory) │
│ → registry.tools.push({ pluginId, factory, names, ... })│
│ → registry.plugins.push(record) │
└──────────────────────────────────────────────────────────────┘
↓
┌─ 激活 ───────────────────────────────────────────────────────┐
│ activatePluginRegistry(registry) │
│ → globalThis[...].activeRegistry = registry │
│ → 所有 tool/channel/hook 全局可见 │
└──────────────────────────────────────────────────────────────┘
↓
┌─ 运行时调用 ─────────────────────────────────────────────────┐
│ Agent: tool_call({ name: "web_search", args: {query:"hi"} }) │
│ → getActivePluginRegistry().tools[] │
│ → factory(ctx) → AnyAgentTool │
│ → tool.execute({ query: "hi" }) │
│ → 返回结果给 Agent │
└──────────────────────────────────────────────────────────────┘
关键设计原则
-
1. 数据与操作分离: registry是纯数据容器(struct),操作是闭包函数 -
2. 通过 createApi绑定:把抽象的registerTool(record, tool)变成具体的api.registerTool(tool),record被闭包捕获 -
3. handlers 传输容器: createApi和buildPluginApi之间用handlers对象传递方法,支持按 registrationMode 选择性传参,未传的方法自动降级为 noop -
4. 注册 ≠ 可用: registry.tools.push()只是写入数据,Agent 能看到必须等activatePluginRegistry() -
5. Factory 延迟实例化:注册时存工厂函数,调用时注入运行时上下文,解决启动时 vs 运行时的时间差 -
6. 两级按需激活:决策层( resolveManifestActivationPlan纯读 manifest) + 执行层(onlyPluginIds限定范围重新加载),零 I/O、零代码加载即可完成激活决策 -
7. 来源优先级去重:同 ID 插件按 bundled > workspace > global > package > bundle 优先级去重
本文基于 openclaw 源码分析,核心文件:
• src/plugins/discovery.ts— 插件发现• src/plugins/loader.ts— 插件加载主流程• src/plugins/registry.ts— Registry 创建与注册• src/plugins/registry-types.ts— 类型定义• src/plugins/types.ts— OpenClawPluginApi 类型• src/plugins/tool-types.ts— Factory 与 ToolContext 类型• src/plugins/api-builder.ts— handlers 解包与 api 对象组装• src/plugins/activation-planner.ts— 按需激活决策引擎• src/plugins/runtime.ts— 全局单例与激活• src/plugins/slots.ts— 槽位机制• src/plugins/bundle-manifest.ts— Bundle 格式转换• src/agents/tool-search.ts— Agent Tool 搜索与调用• src/plugin-sdk/— 对外暴露的 SDK 类型
夜雨聆风