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 +84 -166
- package/index.ts +217 -33
- package/package.json +1 -1
- package/src/agentbridge-client.ts +5 -2
- package/src/tools.ts +30 -8
- package/src/ws-inject.mjs +98 -0
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
|
|
19
|
-
openclaw gateway
|
|
22
|
+
openclaw gateway --force
|
|
20
23
|
```
|
|
21
24
|
|
|
22
25
|
## 配置
|
|
23
26
|
|
|
24
|
-
在
|
|
27
|
+
在 `~/.openclaw/openclaw.json` 的 `plugins.entries` 中添加:
|
|
25
28
|
|
|
26
29
|
```json
|
|
27
30
|
{
|
|
28
|
-
"
|
|
31
|
+
"@agentbridge/openclaw-skill": {
|
|
29
32
|
"enabled": true,
|
|
30
|
-
"
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
"
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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`
|
|
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
|
-
|
|
85
|
-
- `delivery: "poll"`(默认)— 每 5 秒自动轮询
|
|
86
|
-
- `delivery: "sse"` — 通过 SSE 长连接实时接收,零延迟,自动重连
|
|
61
|
+
## 工具
|
|
87
62
|
|
|
88
|
-
3
|
|
63
|
+
插件注册 3 个工具:
|
|
89
64
|
|
|
90
|
-
|
|
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
|
-
|
|
|
113
|
-
|
|
|
114
|
-
|
|
|
115
|
-
|
|
|
75
|
+
| `receiver` | 是 | 目标:agentId / `?skill=X` / `?tag=X` / `?intent=X` / `?rule` |
|
|
76
|
+
| `message` | 是 | 消息内容 |
|
|
77
|
+
| `type` | 否 | CloudEvents 类型,如 `alert.escalate`(用于规则路由) |
|
|
78
|
+
| `source` | 否 | 消息来源标识,如 `alert-screener`(用于规则路由) |
|
|
116
79
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
```
|
|
120
|
-
你: "查看 AgentBridge 上有哪些可用的 Agent"
|
|
80
|
+
## 自动回复模式
|
|
121
81
|
|
|
122
|
-
|
|
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
|
-
|
|
133
|
-
```
|
|
90
|
+
## 防循环
|
|
134
91
|
|
|
135
|
-
|
|
92
|
+
`maxAutoHops`(默认 2)控制消息自动处理的最大跳数:
|
|
136
93
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
| SSE | `"delivery": "sse"` | 实时 | 长连接推送,自动重连,附带低频 fallback poll 兜底 |
|
|
94
|
+
- 外部事件 → A(hop=0 ✅ 自动处理)
|
|
95
|
+
- A → B(hop=1 ✅ 自动处理)
|
|
96
|
+
- B → A(hop=2 ≥ 阈值 ❌ 缓冲,不自动处理)
|
|
141
97
|
|
|
142
|
-
|
|
98
|
+
多 Agent 链路(A→B→C)设 `maxAutoHops: 3`。
|
|
143
99
|
|
|
144
100
|
## 配置项
|
|
145
101
|
|
|
146
|
-
| 配置项 |
|
|
147
|
-
|
|
148
|
-
| `consoleEndpoint` |
|
|
149
|
-
| `workerEndpoint` |
|
|
150
|
-
| `bridgeId` |
|
|
151
|
-
| `agentId` |
|
|
152
|
-
| `agentName` |
|
|
153
|
-
| `apiKey`
|
|
154
|
-
| `
|
|
155
|
-
| `
|
|
156
|
-
| `
|
|
157
|
-
| `
|
|
158
|
-
| `
|
|
159
|
-
| `
|
|
160
|
-
| `
|
|
161
|
-
| `
|
|
162
|
-
| `
|
|
163
|
-
| `
|
|
164
|
-
| `gatewayPort` |
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
@@ -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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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 ${
|
|
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);
|