agentbridge-openclaw-skill 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,206 +1,124 @@
1
1
  # @agentbridge/openclaw-skill
2
2
 
3
- OpenClaw 插件,将你的 Agent 接入 AgentBridge,实现跨 Agent 通信、智能路由和企业级治理。
3
+ OpenClaw 插件,将你的 Agent 接入 AgentBridge,实现跨 Agent 通信、智能路由、事件规则路由和多 Agent 协作。
4
+
5
+ ## v1.1.0 新特性
6
+
7
+ - 消息直接注入 main chat(WebSocket chat.send + v2 设备签名),用户能实时看到 Agent 处理过程
8
+ - `agentbridge_send` 支持 `type` 和 `source` 参数,用于事件规则路由链路
9
+ - Hop count 防循环机制(`maxAutoHops`),支持多 Agent 协作不死循环
10
+ - 消息缓冲 + `agentbridge_poll` 优先返回缓冲消息
11
+ - 配置驱动的 smart-ack 模式(可选)
4
12
 
5
13
  ## 安装
6
14
 
7
15
  ```bash
8
- # 方式一:从本地路径安装
9
- openclaw plugins install /path/to/agentbridge-openclaw-skill
10
-
11
- # 方式二:从 npm 安装(发布后)
12
- openclaw plugins install @agentbridge/openclaw-skill
16
+ openclaw plugins install agentbridge-openclaw-skill
13
17
  ```
14
18
 
15
19
  安装后重启 Gateway:
16
20
 
17
21
  ```bash
18
- openclaw gateway stop
19
- openclaw gateway
22
+ openclaw gateway --force
20
23
  ```
21
24
 
22
25
  ## 配置
23
26
 
24
- OpenClaw 的 `~/.openclaw/openclaw.json` 中,`plugins.entries` 下添加:
27
+ 在 `~/.openclaw/openclaw.json` 的 `plugins.entries` 中添加:
25
28
 
26
29
  ```json
27
30
  {
28
- "hooks": {
31
+ "@agentbridge/openclaw-skill": {
29
32
  "enabled": true,
30
- "token": "your-hooks-token",
31
- "path": "/hooks"
32
- },
33
- "plugins": {
34
- "entries": {
35
- "@agentbridge/openclaw-skill": {
36
- "enabled": true,
37
- "config": {
38
- "consoleEndpoint": "http://localhost:18080",
39
- "workerEndpoint": "http://localhost:18081",
40
- "bridgeId": "prod-bridge",
41
- "agentId": "my-openclaw-agent",
42
- "apiKey": "your-api-key",
43
- "apiSecret": "your-api-secret",
44
- "delivery": "poll",
45
- "tags": ["openclaw", "assistant"],
46
- "skills": [
47
- {"name": "web_search", "description": "Search the web for information"},
48
- {"name": "code_review", "description": "Review code and suggest improvements"}
49
- ],
50
- "autoRegister": true,
51
- "autoReply": true,
52
- "gatewayPort": 18789,
53
- "gatewayToken": "your-gateway-token",
54
- "hooksToken": "your-hooks-token"
55
- }
56
- }
33
+ "config": {
34
+ "consoleEndpoint": "http://localhost:18080",
35
+ "workerEndpoint": "http://localhost:18081",
36
+ "bridgeId": "prod-bridge",
37
+ "agentId": "my-agent",
38
+ "agentName": "My OpenClaw Agent",
39
+ "apiKey": "your-api-key",
40
+ "apiSecret": "your-api-secret",
41
+ "delivery": "poll",
42
+ "pollIntervalMs": 3000,
43
+ "tags": ["openclaw"],
44
+ "skills": [
45
+ {"name": "web_search", "description": "搜索网页信息"}
46
+ ],
47
+ "autoRegister": true,
48
+ "autoReply": true,
49
+ "autoReplyMode": "llm",
50
+ "gatewayToken": "your-gateway-token",
51
+ "gatewayPort": 18789,
52
+ "hooksToken": "your-hooks-token",
53
+ "maxAutoHops": 2
57
54
  }
58
55
  }
59
56
  }
60
57
  ```
61
58
 
62
- 注意:`hooks.token` 必须与 `gateway.auth.token` 不同,否则 OpenClaw 会拒绝启动。
63
-
64
- ## 验证安装
65
-
66
- ```bash
67
- # 检查插件是否加载
68
- openclaw plugins list | grep agentbridge
69
-
70
- # 检查 Gateway 日志
71
- openclaw health
72
- # 应看到: AgentBridge: registered 3 tools (send, poll, discover)
73
- ```
74
-
75
- ## 插件做了什么
76
-
77
- 启动时,插件会:
78
-
79
- 1. **注册 3 个工具**给你的 OpenClaw Agent:
80
- - `agentbridge_send` — 向其他 Agent 发送消息(支持智能路由)
81
- - `agentbridge_poll` — 手动检查消息(通常不需要,后台监听会自动处理)
82
- - `agentbridge_discover` — 发现可用的 Agent 及其技能
59
+ 注意:`hooks.token` 必须与 `gateway.auth.token` 不同。
83
60
 
84
- 2. **启动后台消息监听**:
85
- - `delivery: "poll"`(默认)— 每 5 秒自动轮询
86
- - `delivery: "sse"` — 通过 SSE 长连接实时接收,零延迟,自动重连
61
+ ## 工具
87
62
 
88
- 3. **自动回复**(需启用 hooks):收到消息后通过 Gateway `/hooks/agent` 端点注入给 LLM,Agent 可自动调用 `agentbridge_send` 回复发送方
63
+ 插件注册 3 个工具:
89
64
 
90
- 4. **自动注册到 AgentBridge**,把 OpenClaw 上的其他工具作为技能注册
65
+ | 工具 | 说明 |
66
+ |------|------|
67
+ | `agentbridge_send` | 发送消息,支持智能路由和事件规则路由 |
68
+ | `agentbridge_poll` | 接收消息(后台自动 poll + 手动调用) |
69
+ | `agentbridge_discover` | 发现可用 Agent 及技能 |
91
70
 
92
- ## 使用方式
71
+ ### agentbridge_send 参数
93
72
 
94
- ### 发送消息
95
-
96
- 在 OpenClaw 对话中直接说:
97
-
98
- ```
99
- 你: "让 DBA Agent 帮我分析 rds-prod-01 的慢查询"
100
-
101
- Agent 自动调用 agentbridge_send:
102
- receiver: "?skill=slow_query_analysis"
103
- message: "分析 rds-prod-01 的慢查询"
104
- ```
105
-
106
- ### 智能路由
107
-
108
- `agentbridge_send` 的 `receiver` 参数支持:
109
-
110
- | 格式 | 示例 | 行为 |
73
+ | 参数 | 必填 | 说明 |
111
74
  |------|------|------|
112
- | Agent ID | `dba-agent` | 直接发送 |
113
- | 技能查询 | `?skill=slow_query_analysis` | 路由到具备该技能的 Agent(round-robin) |
114
- | 标签查询 | `?tag=ops` | 路由到带该标签的 Agent |
115
- | 意图查询 | `?intent=分析数据库性能` | 通过 Orchestrator Agent + LLM 智能路由 |
75
+ | `receiver` | | 目标:agentId / `?skill=X` / `?tag=X` / `?intent=X` / `?rule` |
76
+ | `message` | | 消息内容 |
77
+ | `type` | | CloudEvents 类型,如 `alert.escalate`(用于规则路由) |
78
+ | `source` | | 消息来源标识,如 `alert-screener`(用于规则路由) |
116
79
 
117
- ### 发现 Agent
118
-
119
- ```
120
- 你: "查看 AgentBridge 上有哪些可用的 Agent"
80
+ ## 自动回复模式
121
81
 
122
- Agent 调用 agentbridge_discover,返回所有 Agent 及其技能列表
123
- ```
124
-
125
- ### 接收消息
126
-
127
- 消息会自动出现在 Gateway 日志中(后台监听)。也可以手动检查:
82
+ | 模式 | 配置 | 行为 |
83
+ |------|------|------|
84
+ | `llm` | `"autoReplyMode": "llm"` | 通过 WebSocket 注入 main chat,LLM 处理后回复 |
85
+ | `ack` | `"autoReplyMode": "ack"` | 直接发送确认回复,不走 LLM |
86
+ | `smart-ack` | `"autoReplyMode": "smart-ack"` | 配置驱动的规则判定,不走 LLM |
128
87
 
129
- ```
130
- 你: "检查一下有没有新消息"
88
+ `llm` 模式通过 Gateway WebSocket 协议的 `chat.send` 方法注入消息到 main agent 对话,使用 v2 设备签名认证,无需 `dangerouslyDisableDeviceAuth`。
131
89
 
132
- Agent 调用 agentbridge_poll
133
- ```
90
+ ## 防循环
134
91
 
135
- ## 投递模式
92
+ `maxAutoHops`(默认 2)控制消息自动处理的最大跳数:
136
93
 
137
- | 模式 | 配置 | 延迟 | 说明 |
138
- |------|------|------|------|
139
- | Poll(默认) | `"delivery": "poll"` | ~5 秒 | 定时轮询,简单可靠 |
140
- | SSE | `"delivery": "sse"` | 实时 | 长连接推送,自动重连,附带低频 fallback poll 兜底 |
94
+ - 外部事件 A(hop=0 自动处理)
95
+ - A → B(hop=1 ✅ 自动处理)
96
+ - B A(hop=2 阈值 缓冲,不自动处理)
141
97
 
142
- SSE 模式推荐用于需要实时响应的场景。
98
+ Agent 链路(A→B→C)设 `maxAutoHops: 3`。
143
99
 
144
100
  ## 配置项
145
101
 
146
- | 配置项 | 必填 | 默认值 | 说明 |
147
- |--------|------|--------|------|
148
- | `consoleEndpoint` | 否 | `http://localhost:18080` | AgentBridge Console 地址 |
149
- | `workerEndpoint` | 否 | `http://localhost:18081` | AgentBridge Worker Gateway 地址 |
150
- | `bridgeId` | 否 | `prod-bridge` | 要加入的 Bridge ID |
151
- | `agentId` | 是 | — | Agent AgentBridge 中的唯一标识 |
152
- | `agentName` | 否 | 自动生成 | Agent 显示名称 |
153
- | `apiKey` | | — | Console API Key |
154
- | `apiSecret` | | | Console API Secret |
155
- | `delivery` | 否 | `poll` | 客户端监听模式:`poll`(定时轮询)/ `sse`(实时推送 + fallback poll)。注意:Agent 在 AgentBridge 服务端始终注册为 poll 投递,避免 SSE 连接冲突 |
156
- | `tags` | 否 | `[]` | Agent 标签 |
157
- | `skills` | 否 | `[]` | Agent 技能列表,格式 `[{"name":"x","description":"y"}]`。OpenClaw Plugin SDK 不提供查询已注册工具的 API,需手动声明 |
158
- | `autoRegister` | 否 | `true` | 是否自动注册到 AgentBridge |
159
- | `pollIntervalMs` | 否 | `5000` | Poll 模式轮询间隔(毫秒),设为 0 关闭后台监听 |
160
- | `sseFallbackPollMs` | 否 | `30000` | SSE 模式下 fallback poll 间隔(毫秒),兜底 SSE 断连期间的消息丢失,设为 0 关闭 |
161
- | `autoReply` | 否 | `true` | 收到消息后是否自动注入 Gateway 触发 LLM 回复 |
162
- | `maxAutoReplyDepth` | | `3` | 同一发送方在滑动窗口内最多自动回复次数(防乒乓循环) |
163
- | `replyWindowMs` | | `60000` | 自动回复滑动窗口时长(毫秒) |
164
- | `gatewayPort` | 否 | `18789` | 本地 Gateway 端口(用于 autoReply 注入) |
165
- | `gatewayToken` | 否 | — | Gateway auth token |
166
- | `hooksToken` | 否 | — | Gateway hooks token(需在 openclaw.json 中启用 hooks) |
167
-
168
- ## 技能注册
169
-
170
- 在配置中声明要暴露给 AgentBridge 的 skills:
171
-
172
- ```json
173
- "skills": [
174
- {"name": "web_search", "description": "Search the web for information"},
175
- {"name": "code_review", "description": "Review code and suggest improvements"}
176
- ]
177
- ```
178
-
179
- 只有声明的 skills 会注册到 AgentBridge,其他 Agent 可以通过 `?skill=web_search` 路由到你的 OpenClaw Agent。
180
-
181
- 未声明的工具(如 `exec`、`write` 等)不会暴露,确保安全性。AgentBridge 自身的工具(`agentbridge_send/poll/discover`)也不需要声明。
182
-
183
- ## 完整示例
184
-
185
- ```bash
186
- # 1. 确保 AgentBridge 在运行
187
- docker-compose up -d
188
-
189
- # 2. 安装插件
190
- openclaw plugins install /path/to/agentbridge-openclaw-skill
191
-
192
- # 3. 配置(编辑 ~/.openclaw/openclaw.json 添加上面的 plugins 配置)
193
-
194
- # 4. 重启 Gateway
195
- openclaw gateway stop && openclaw gateway
196
-
197
- # 5. 验证
198
- openclaw health
199
- # → AgentBridge: registered 3 tools (send, poll, discover)
200
- # → AgentBridge: poll listener started (every 5s)
201
-
202
- # 6. 开始对话
203
- openclaw tui
204
- # > 查看 AgentBridge 上有哪些 Agent
205
- # > 让 DBA Agent 分析 rds-prod-01 的慢查询
206
- ```
102
+ | 配置项 | 默认值 | 说明 |
103
+ |--------|--------|------|
104
+ | `consoleEndpoint` | `http://localhost:18080` | Console 地址 |
105
+ | `workerEndpoint` | `http://localhost:18081` | Worker Gateway 地址 |
106
+ | `bridgeId` | `prod-bridge` | Bridge ID |
107
+ | `agentId` | — | Agent 唯一标识(必填) |
108
+ | `agentName` | 自动生成 | 显示名称 |
109
+ | `apiKey` / `apiSecret` | — | Console API 凭证 |
110
+ | `delivery` | `poll` | 监听模式:`poll` / `sse` |
111
+ | `pollIntervalMs` | `5000` | Poll 间隔(毫秒) |
112
+ | `autoReply` | `true` | 是否自动处理收到的消息 |
113
+ | `autoReplyMode` | `ack` | 自动回复模式:`llm` / `ack` / `smart-ack` |
114
+ | `maxAutoHops` | `2` | 防循环最大跳数 |
115
+ | `maxAutoReplyDepth` | `3` | 同一发送方最大回复次数 |
116
+ | `tags` | `[]` | Agent 标签 |
117
+ | `skills` | `[]` | 暴露的技能列表 |
118
+ | `gatewayToken` | | Gateway auth token |
119
+ | `hooksToken` | | Hooks token(需与 gateway token 不同) |
120
+ | `gatewayPort` | `18789` | Gateway 端口 |
121
+
122
+ ## K8s 部署
123
+
124
+ 参考 `deploy/openclaw-agentbridge-configmap.yaml`,通过 ConfigMap + Secret 注入配置,支持另一个产品通过 K8s API 动态创建 OpenClaw 实例并自动接入 AgentBridge。
package/index.ts CHANGED
@@ -12,6 +12,23 @@
12
12
  import { AgentBridgeClient } from "./src/agentbridge-client.js";
13
13
  import { createSendTool, createPollTool, createDiscoverTool } from "./src/tools.js";
14
14
 
15
+ /**
16
+ * Simple pattern matcher for smart-ack rules.
17
+ * - "value" → exact match
18
+ * - "a|b|c" → OR match (any of)
19
+ * - "!value" → NOT match
20
+ * - "!a|b" → NOT any of
21
+ * - "*" → match anything
22
+ */
23
+ function matchSmartAckPattern(actual: string, pattern: string): boolean {
24
+ if (pattern === "*") return true;
25
+ const negate = pattern.startsWith("!");
26
+ const p = negate ? pattern.slice(1) : pattern;
27
+ const values = p.split("|").map(v => v.trim());
28
+ const hit = values.some(v => actual === v);
29
+ return negate ? !hit : hit;
30
+ }
31
+
15
32
  export default function register(api: any) {
16
33
  const pluginCfg = api.pluginConfig || {};
17
34
  const agentId = pluginCfg.agentId;
@@ -33,9 +50,12 @@ export default function register(api: any) {
33
50
  delivery: pluginCfg.delivery || "poll",
34
51
  });
35
52
 
53
+ // Shared message buffer: background poll fills it, agentbridge_poll drains it
54
+ const messageBuffer: any[] = [];
55
+
36
56
  // Register tools
37
57
  api.registerTool(createSendTool(client));
38
- api.registerTool(createPollTool(client));
58
+ api.registerTool(createPollTool(client, messageBuffer));
39
59
 
40
60
  const headers: Record<string, string> = { "Content-Type": "application/json" };
41
61
  if (pluginCfg.apiKey) headers["X-API-Key"] = pluginCfg.apiKey;
@@ -68,7 +88,7 @@ export default function register(api: any) {
68
88
  async function injectToAgent(sender: string, content: string) {
69
89
  if (!autoReply) return;
70
90
 
71
- // --- Loop guard: limit auto-replies per sender within a sliding window ---
91
+ // --- Loop guard ---
72
92
  const now = Date.now();
73
93
  const tracker = replyTracker.get(sender);
74
94
  if (tracker) {
@@ -81,57 +101,165 @@ export default function register(api: any) {
81
101
  }
82
102
  tracker.count++;
83
103
  } else {
84
- // Window expired, reset
85
104
  replyTracker.set(sender, { count: 1, firstSeen: now });
86
105
  }
87
106
  } else {
88
107
  replyTracker.set(sender, { count: 1, firstSeen: now });
89
108
  }
90
109
 
110
+ const replyMode = pluginCfg.autoReplyMode || "ack"; // "ack" | "llm" | "smart-ack"
111
+
112
+ // Mode: smart-ack — config-driven auto-response without LLM
113
+ // Rules are declared in pluginCfg.smartAckRules as an array of:
114
+ // { match: { field: value, ... }, verdict, reason, replyType }
115
+ // Fields in "match" are checked against the message data (AND logic).
116
+ // Supports exact match and simple patterns: "P0|P1" (OR), "!test" (NOT).
117
+ // First matching rule wins. If no rule matches, falls back to defaultVerdict.
118
+ if (replyMode === "smart-ack") {
119
+ try {
120
+ let parsed: any = {};
121
+ try { parsed = typeof content === 'string' ? JSON.parse(content) : content; } catch { parsed = { content }; }
122
+
123
+ // Skip auto-reply / ack messages (prevent ping-pong)
124
+ const dataPayload = parsed?.data || parsed;
125
+ if (dataPayload?.autoReply || dataPayload?.content?.toString().startsWith("[Auto-reply")) {
126
+ api.logger.info(`AgentBridge: smart-ack skipping auto-reply from ${sender}`);
127
+ return;
128
+ }
129
+ if (!dataPayload?.alertName && !dataPayload?.severity && !dataPayload?.verdict) {
130
+ api.logger.info(`AgentBridge: smart-ack skipping non-alert message from ${sender}`);
131
+ return;
132
+ }
133
+
134
+ const alertData = dataPayload;
135
+ const rules: any[] = pluginCfg.smartAckRules || [];
136
+ const defaultVerdict = pluginCfg.smartAckDefault?.verdict || "archive";
137
+ const defaultReplyType = pluginCfg.smartAckDefault?.replyType || "alert.archive";
138
+ const defaultReason = pluginCfg.smartAckDefault?.reason || "默认归档处理";
139
+
140
+ let verdict = defaultVerdict;
141
+ let reason = defaultReason;
142
+ let replyType = defaultReplyType;
143
+ let matched = false;
144
+
145
+ for (const rule of rules) {
146
+ if (!rule.match) continue;
147
+ let allMatch = true;
148
+ for (const [field, pattern] of Object.entries(rule.match as Record<string, string>)) {
149
+ const actual = String(alertData?.[field] ?? "");
150
+ if (!matchSmartAckPattern(actual, pattern)) { allMatch = false; break; }
151
+ }
152
+ if (allMatch) {
153
+ verdict = rule.verdict || defaultVerdict;
154
+ // Support template strings in reason: ${field} replaced with alert data
155
+ reason = (rule.reason || defaultReason).replace(/\$\{(\w+)\}/g, (_: string, k: string) => alertData?.[k] ?? k);
156
+ replyType = rule.replyType || `alert.${verdict}`;
157
+ matched = true;
158
+ break;
159
+ }
160
+ }
161
+
162
+ if (!matched && rules.length > 0) {
163
+ reason = `${alertData?.severity || "unknown"} 级别告警,${defaultReason}`;
164
+ }
165
+
166
+ const verdictPayload = {
167
+ verdict, reason,
168
+ severity: alertData?.severity, alertName: alertData?.alertName,
169
+ service: alertData?.service, environment: alertData?.environment,
170
+ screenedBy: agentId, screenedAt: new Date().toISOString(),
171
+ };
172
+
173
+ await client.send("?rule", verdictPayload, undefined, replyType, agentId);
174
+ api.logger.info(`AgentBridge: smart-ack verdict=${verdict} for alert "${alertData?.alertName || "?"}" from ${sender} → ?rule (type=${replyType}, source=${agentId})`);
175
+ } catch (err) {
176
+ api.logger.warn(`AgentBridge: smart-ack failed: ${err}`);
177
+ }
178
+ return;
179
+ }
180
+
181
+ // Mode: ack — send a direct acknowledgment reply without LLM
182
+ if (replyMode === "ack") {
183
+ try {
184
+ const ackContent = typeof content === 'string' && content.length > 100
185
+ ? content.slice(0, 100) + '...'
186
+ : content;
187
+ await client.send(sender, {
188
+ content: `[Auto-reply from ${agentId}] Received your message. Processing: ${ackContent}`,
189
+ from: agentId,
190
+ autoReply: true,
191
+ });
192
+ api.logger.info(`AgentBridge: ack reply sent to ${sender}`);
193
+ } catch (err) {
194
+ api.logger.warn(`AgentBridge: ack reply failed: ${err}`);
195
+ }
196
+ return;
197
+ }
198
+
199
+ // Mode: llm — inject into main agent chat via Gateway WebSocket (chat.send)
200
+ // This puts the message directly into the main conversation context,
201
+ // so the user can see it and the agent processes it with full history.
202
+
203
+ const prompt = `[AgentBridge 收到新消息]\n来源: ${sender}\n内容:\n${content}\n\n` +
204
+ `请处理这条消息,然后必须调用 agentbridge_send 工具发送处理结果。调用时参数如下:\n` +
205
+ `- receiver: "?rule"\n` +
206
+ `- source: "${agentId}"\n` +
207
+ `- type 和 message: 根据你的分析结果设置`;
208
+
209
+ // Try WebSocket chat.send first (main chat injection, secure device auth)
210
+ const wsOk = await injectViaWs(prompt);
211
+ if (wsOk) {
212
+ api.logger.info(`AgentBridge: injected via WebSocket chat.send from ${sender} (main chat)`);
213
+ return;
214
+ }
215
+
216
+ // Fallback: /hooks/agent (creates nested conversation, not ideal)
91
217
  const baseUrl = `http://127.0.0.1:${gatewayPort}`;
92
- const prompt = `[AgentBridge message from ${sender}]\n${content}\n\n` +
93
- `Reply to ${sender} using agentbridge_send tool.`;
94
218
  const hdrs: Record<string, string> = { "Content-Type": "application/json" };
95
- // Use hooksToken for /hooks/agent, fall back to gatewayToken
96
219
  const hookAuth = hooksToken || gatewayToken;
97
220
  if (hookAuth) hdrs["Authorization"] = `Bearer ${hookAuth}`;
98
221
 
99
- // Try 1: /hooks/agent
100
222
  try {
101
223
  const resp = await fetch(`${baseUrl}/hooks/agent`, {
102
224
  method: "POST", headers: hdrs,
103
- body: JSON.stringify({
104
- message: prompt,
105
- wakeMode: "now",
106
- }),
225
+ body: JSON.stringify({ message: prompt, wakeMode: "now" }),
107
226
  });
108
227
  if (resp.ok) {
109
- api.logger.info(`AgentBridge: injected via /hooks/agent from ${sender}`);
228
+ api.logger.info(`AgentBridge: injected via /hooks/agent (fallback) from ${sender}`);
110
229
  return;
111
230
  }
112
231
  } catch { /* fall through */ }
113
232
 
114
- // Try 2: /v1/chat/completions (OpenAI-compatible)
115
- try {
116
- const chatHdrs: Record<string, string> = { "Content-Type": "application/json" };
117
- if (gatewayToken) chatHdrs["Authorization"] = `Bearer ${gatewayToken}`;
118
- const resp = await fetch(`${baseUrl}/v1/chat/completions`, {
119
- method: "POST", headers: chatHdrs,
120
- body: JSON.stringify({
121
- model: "openclaw",
122
- messages: [
123
- { role: "system", content: "You are an AI agent connected to AgentBridge. When you receive messages from other agents, process them and reply using the agentbridge_send tool." },
124
- { role: "user", content: prompt },
125
- ],
126
- }),
127
- });
128
- if (resp.ok) {
129
- api.logger.info(`AgentBridge: injected via /v1/chat/completions from ${sender}`);
130
- return;
131
- }
132
- } catch { /* fall through */ }
233
+ api.logger.warn(`AgentBridge: could not inject message from ${sender} (no working injection path)`);
234
+ }
235
+
236
+ // --- Main chat injection via child process (secure device auth) ---
237
+ // Uses a standalone .mjs script that runs outside the plugin sandbox,
238
+ // with full access to Node.js crypto for v2 device signature.
239
+ const { execFile } = require('child_process');
240
+ const path = require('path');
133
241
 
134
- api.logger.warn(`AgentBridge: could not inject message from ${sender} (no working Gateway API)`);
242
+ // Resolve the ws-inject.mjs script path relative to this plugin
243
+ const wsInjectScript = path.join(__dirname, 'src', 'ws-inject.mjs');
244
+
245
+ /**
246
+ * Inject a message into the main agent chat via WebSocket (child process).
247
+ * Returns true if successful, false if failed (caller should use fallback).
248
+ */
249
+ function injectViaWs(message: string): Promise<boolean> {
250
+ return new Promise((resolve) => {
251
+ execFile('node', [wsInjectScript, String(gatewayPort), gatewayToken, 'auto', message],
252
+ { timeout: 15000 },
253
+ (err: any, stdout: string, stderr: string) => {
254
+ if (!err && stdout.includes('OK:sent')) {
255
+ resolve(true);
256
+ } else {
257
+ api.logger.debug(`AgentBridge: ws-inject failed: ${stderr || stdout || err?.message}`);
258
+ resolve(false);
259
+ }
260
+ }
261
+ );
262
+ });
135
263
  }
136
264
 
137
265
  // Background message listener
@@ -161,12 +289,68 @@ export default function register(api: any) {
161
289
  return false;
162
290
  }
163
291
 
164
- function handleIncomingMessage(msg: any, channel: string) {
292
+ async function handleIncomingMessage(msg: any, channel: string) {
165
293
  if (dedup(msg)) return;
166
294
  const data = msg?.data || msg;
167
295
  const sender = msg?.extensions?.["ab-sender"] || (data as any)?.from || "unknown";
168
296
  const content = typeof data === "object" ? JSON.stringify(data) : String(data);
169
297
  api.logger.info(`AgentBridge [${channel}]: message from ${sender}: ${content.slice(0, 100)}`);
298
+
299
+ if (!autoReply) return;
300
+
301
+ // Anti-loop: check hop count in message data.
302
+ // Each agent processing increments _abHop. When it exceeds maxHops, don't auto-inject.
303
+ const maxHops = pluginCfg.maxAutoHops ?? 2; // default: allow 2 hops (source→A→B, but B won't auto-reply back)
304
+ let hopCount = 0;
305
+ try {
306
+ const parsed = typeof data === 'string' ? JSON.parse(data) : data;
307
+ hopCount = parsed?._abHop || parsed?.data?._abHop || 0;
308
+ } catch { /* not JSON, hop=0 */ }
309
+
310
+ if (hopCount >= maxHops) {
311
+ messageBuffer.push(msg);
312
+ api.logger.info(`AgentBridge: hop limit reached (${hopCount}>=${maxHops}) from ${sender}, buffered for manual poll`);
313
+ return;
314
+ }
315
+
316
+ // Skip system messages
317
+ const msgType = msg?.type || "";
318
+ if (msgType.includes("system.capabilities-sync")) return;
319
+
320
+ const replyMode = pluginCfg.autoReplyMode || "ack";
321
+
322
+ // For llm mode: inject directly into main chat via WebSocket (ws-inject)
323
+ if (replyMode === "llm") {
324
+ const preview = content.length > 80 ? content.slice(0, 80) + '...' : content;
325
+ const prompt = `[AgentBridge 收到新消息]\n来源: ${sender}\n内容:\n${content}\n\n` +
326
+ `请根据你的职责处理这条消息。如需通过 AgentBridge 发送处理结果,使用 agentbridge_send 工具,` +
327
+ `receiver 设为 "?rule"(规则路由),同时设置 type 和 source="${agentId}" 字段。`;
328
+
329
+ // Try ws-inject (main chat, secure device auth)
330
+ const wsOk = await injectViaWs(prompt);
331
+ if (wsOk) {
332
+ api.logger.info(`AgentBridge: injected via WebSocket chat.send from ${sender} (main chat)`);
333
+ return;
334
+ }
335
+
336
+ // Fallback: buffer + hooks notification
337
+ messageBuffer.push(msg);
338
+ api.logger.info(`AgentBridge: ws-inject failed, buffered message from ${sender} (buffer=${messageBuffer.length})`);
339
+ const baseUrl = `http://127.0.0.1:${gatewayPort}`;
340
+ const hdrs: Record<string, string> = { "Content-Type": "application/json" };
341
+ const hookAuth = hooksToken || gatewayToken;
342
+ if (hookAuth) hdrs["Authorization"] = `Bearer ${hookAuth}`;
343
+ fetch(`${baseUrl}/hooks/agent`, {
344
+ method: "POST", headers: hdrs,
345
+ body: JSON.stringify({
346
+ message: `[AgentBridge 新消息通知]\n来自: ${sender}\n预览: ${preview}\n\n请调用 agentbridge_poll 工具获取完整消息并处理。`,
347
+ wakeMode: "now",
348
+ }),
349
+ }).catch(() => {});
350
+ return;
351
+ }
352
+
353
+ // Other modes (ack, smart-ack): use injectToAgent as before
170
354
  injectToAgent(sender, content);
171
355
  }
172
356
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentbridge-openclaw-skill",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "OpenClaw plugin — connect to AgentBridge for cross-agent communication and intelligent routing.",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -96,18 +96,21 @@ export class AgentBridgeClient {
96
96
  }
97
97
 
98
98
  /** Send a message through the Worker Gateway. */
99
- async send(receiver: string, data: unknown, taskId?: string): Promise<{ messageId: string }> {
99
+ async send(receiver: string, data: unknown, taskId?: string, type?: string, source?: string): Promise<{ messageId: string }> {
100
100
  // Ensure we have a token
101
101
  if (!this.token) {
102
102
  await this.issueToken();
103
103
  }
104
104
 
105
105
  const body: Record<string, unknown> = {
106
- type: "com.agentbridge.task.dispatch",
106
+ type: type || "com.agentbridge.task.dispatch",
107
107
  receiver,
108
108
  data,
109
109
  taskId: taskId || `oc-${this.config.agentId}-${Date.now().toString(36)}`,
110
110
  };
111
+ if (source) {
112
+ body.source = source;
113
+ }
111
114
 
112
115
  const resp = await fetch(`${this.config.workerEndpoint}/api/v1/messages`, {
113
116
  method: "POST",
package/src/tools.ts CHANGED
@@ -19,19 +19,29 @@ export function createSendTool(client: AgentBridgeClient) {
19
19
  name: "agentbridge_send",
20
20
  description:
21
21
  "Send a message to another agent through AgentBridge. " +
22
- "Receiver can be an agent ID, ?skill=X, ?tag=X, or ?intent=text.",
22
+ "Receiver can be an agent ID, ?skill=X, ?tag=X, ?intent=text, or ?rule (for event rule routing).",
23
23
  parameters: {
24
24
  type: "object",
25
25
  properties: {
26
- receiver: { type: "string", description: "Target agent ID or query" },
26
+ receiver: { type: "string", description: "Target agent ID, query, or ?rule for event rule routing" },
27
27
  message: { type: "string", description: "Message content" },
28
28
  taskId: { type: "string", description: "Task ID for correlation" },
29
+ type: { type: "string", description: "CloudEvents type (e.g. alert.escalate). Defaults to task.dispatch" },
30
+ source: { type: "string", description: "CloudEvents source (e.g. alert-screener). Defaults to agentId" },
29
31
  },
30
32
  required: ["receiver", "message"],
31
33
  },
32
- async execute(_id: string, params: { receiver: string; message: string; taskId?: string }) {
34
+ async execute(_id: string, params: { receiver: string; message: string; taskId?: string; type?: string; source?: string }) {
33
35
  try {
34
- const result = await client.send(params.receiver, { content: params.message }, params.taskId);
36
+ // Inject hop count for anti-loop: each send increments _abHop
37
+ let msgData: any = { content: params.message, _abHop: 1 };
38
+ try {
39
+ const parsed = JSON.parse(params.message);
40
+ if (typeof parsed === 'object' && parsed !== null) {
41
+ msgData = { ...parsed, _abHop: (parsed._abHop || 0) + 1 };
42
+ }
43
+ } catch { /* not JSON, use default */ }
44
+ const result = await client.send(params.receiver, msgData, params.taskId, params.type, params.source);
35
45
  return textResult(`Message sent to ${params.receiver}. MessageId: ${result.messageId}`);
36
46
  } catch (err) {
37
47
  return textResult(`Send failed: ${err instanceof Error ? err.message : String(err)}`);
@@ -40,7 +50,7 @@ export function createSendTool(client: AgentBridgeClient) {
40
50
  };
41
51
  }
42
52
 
43
- export function createPollTool(client: AgentBridgeClient) {
53
+ export function createPollTool(client: AgentBridgeClient, messageBuffer?: any[]) {
44
54
  return {
45
55
  name: "agentbridge_poll",
46
56
  description: "Poll for incoming messages from other agents through AgentBridge.",
@@ -52,11 +62,23 @@ export function createPollTool(client: AgentBridgeClient) {
52
62
  },
53
63
  async execute(_id: string, params: { maxMessages?: number }) {
54
64
  try {
55
- const messages = await client.poll(params?.maxMessages || 5);
56
- if (messages.length === 0) {
65
+ // First drain any buffered messages (from background poll)
66
+ const buffered: any[] = [];
67
+ if (messageBuffer) {
68
+ while (messageBuffer.length > 0 && buffered.length < (params?.maxMessages || 5)) {
69
+ buffered.push(messageBuffer.shift());
70
+ }
71
+ }
72
+ // Then poll for more if needed
73
+ const remaining = (params?.maxMessages || 5) - buffered.length;
74
+ if (remaining > 0) {
75
+ const fresh = await client.poll(remaining);
76
+ buffered.push(...fresh);
77
+ }
78
+ if (buffered.length === 0) {
57
79
  return textResult("No messages waiting.");
58
80
  }
59
- return textResult(`Received ${messages.length} message(s):\n${JSON.stringify(messages, null, 2)}`);
81
+ return textResult(`Received ${buffered.length} message(s):\n${JSON.stringify(buffered, null, 2)}`);
60
82
  } catch (err) {
61
83
  return textResult(`Poll failed: ${err instanceof Error ? err.message : String(err)}`);
62
84
  }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Standalone WebSocket injector for OpenClaw main chat.
3
+ * Uses correct v2 device auth: raw ed25519 public key (32 bytes, base64url).
4
+ *
5
+ * Usage: node ws-inject.mjs <port> <token> <sessionKey> <message>
6
+ */
7
+ import crypto from 'node:crypto';
8
+
9
+ const [,, port, token, sessionKey, message] = process.argv;
10
+ if (!port || !token || !message) {
11
+ console.error('Usage: node ws-inject.mjs <port> <token> <sessionKey> <message>');
12
+ process.exit(1);
13
+ }
14
+
15
+ // base64url encode/decode (no padding, - and _ instead of + and /)
16
+ function b64url(buf) { return buf.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, ''); }
17
+
18
+ const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
19
+
20
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
21
+ const spkiDer = publicKey.export({ type: 'spki', format: 'der' });
22
+ // Extract raw 32-byte public key from SPKI DER
23
+ const rawPub = spkiDer.subarray(ED25519_SPKI_PREFIX.length);
24
+ // Device ID = sha256 of raw public key bytes (not SPKI DER)
25
+ const deviceId = crypto.createHash('sha256').update(rawPub).digest('hex');
26
+ // Public key sent to server: raw 32 bytes, base64url encoded
27
+ const pubKeyB64Url = b64url(rawPub);
28
+
29
+ const ws = new WebSocket(`ws://127.0.0.1:${port}`, {
30
+ headers: { 'Origin': `http://127.0.0.1:${port}` }
31
+ });
32
+
33
+ let done = false;
34
+
35
+ ws.onmessage = (e) => {
36
+ const msg = JSON.parse(e.data);
37
+
38
+ if (msg.type === 'event' && msg.event === 'connect.challenge') {
39
+ const nonce = msg.payload.nonce;
40
+ const signedAt = Date.now();
41
+ const scopes = 'operator.read,operator.write';
42
+ // v2 payload: v2|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce
43
+ const payload = `v2|${deviceId}|openclaw-control-ui|webchat|operator|${scopes}|${signedAt}|${token}|${nonce}`;
44
+ const sigRaw = crypto.sign(null, Buffer.from(payload), privateKey);
45
+ // Signature: base64url encoded
46
+ const sig = b64url(sigRaw);
47
+
48
+ ws.send(JSON.stringify({
49
+ type: 'req', id: '1', method: 'connect',
50
+ params: {
51
+ minProtocol: 3, maxProtocol: 3,
52
+ client: { id: 'openclaw-control-ui', version: '1.0.0', platform: 'macos', mode: 'webchat' },
53
+ role: 'operator', scopes: ['operator.read', 'operator.write'],
54
+ caps: [], commands: [], permissions: {},
55
+ auth: { token },
56
+ locale: 'zh-CN', userAgent: 'agentbridge-ws-inject/1.0.0',
57
+ device: { id: deviceId, publicKey: pubKeyB64Url, signature: sig, signedAt, nonce }
58
+ }
59
+ }));
60
+ }
61
+
62
+ if (msg.type === 'res' && msg.id === '1') {
63
+ if (msg.ok) {
64
+ if (!sessionKey || sessionKey === 'auto') {
65
+ ws.send(JSON.stringify({ type: 'req', id: 'sessions', method: 'sessions.list', params: {} }));
66
+ } else {
67
+ sendChat(sessionKey);
68
+ }
69
+ } else {
70
+ console.error('CONNECT_FAIL:' + (msg.error?.message || 'unknown'));
71
+ ws.close(); process.exit(1);
72
+ }
73
+ }
74
+
75
+ if (msg.type === 'res' && msg.id === 'sessions' && msg.ok) {
76
+ const sessions = msg.payload?.sessions || [];
77
+ const main = sessions.find(s => s.key?.includes(':main:') && !s.key?.includes(':hook:')) || sessions[0];
78
+ sendChat(main?.key || 'agent:main:main');
79
+ }
80
+
81
+ if (msg.type === 'res' && msg.id === 'chat' && !done) {
82
+ done = true;
83
+ console.log('OK:' + (msg.ok ? 'sent' : (msg.error?.message || 'fail')));
84
+ ws.close(); process.exit(msg.ok ? 0 : 1);
85
+ }
86
+ };
87
+
88
+ function sendChat(sk) {
89
+ const ik = `ab-${Date.now().toString(36)}-${Math.random().toString(36).slice(2,8)}`;
90
+ ws.send(JSON.stringify({
91
+ type: 'req', id: 'chat', method: 'chat.send',
92
+ params: { sessionKey: sk, message, idempotencyKey: ik }
93
+ }));
94
+ }
95
+
96
+ ws.onerror = () => { process.exit(1); };
97
+ ws.onclose = () => { if (!done) process.exit(1); };
98
+ setTimeout(() => { ws.close(); process.exit(1); }, 15000);