openclaw-bridge 0.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 +119 -0
- package/_inbox/main/bridge-test.md +1 -0
- package/_inbox/pm/bridge-test.md +1 -0
- package/openclaw.plugin.json +71 -0
- package/output/bridge-test.md +1 -0
- package/package.json +29 -0
- package/src/config.ts +63 -0
- package/src/discovery.ts +17 -0
- package/src/file-ops.ts +320 -0
- package/src/heartbeat.ts +165 -0
- package/src/index.ts +646 -0
- package/src/message-relay.ts +155 -0
- package/src/permissions.ts +18 -0
- package/src/registry.ts +107 -0
- package/src/restart.ts +137 -0
- package/src/router.ts +40 -0
- package/src/session.ts +33 -0
- package/src/types.ts +87 -0
- package/tsconfig.json +14 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { Type } from "@sinclair/typebox";
|
|
3
|
+
import { parseConfig, getMachineId } from "./config.js";
|
|
4
|
+
import { BridgeRegistry } from "./registry.js";
|
|
5
|
+
import { BridgeHeartbeat } from "./heartbeat.js";
|
|
6
|
+
import { BridgeFileOps } from "./file-ops.js";
|
|
7
|
+
import { BridgeRestart } from "./restart.js";
|
|
8
|
+
import { assertPermission } from "./permissions.js";
|
|
9
|
+
import { discoverAll, whois } from "./discovery.js";
|
|
10
|
+
import { MessageRelayClient } from "./message-relay.js";
|
|
11
|
+
import * as proxySession from "./session.js";
|
|
12
|
+
import type { OpenClawPluginApi, RegistryEntry } from "./types.js";
|
|
13
|
+
|
|
14
|
+
function resolveWorkspacePath(agentId: string): string {
|
|
15
|
+
// Try reading workspace from openclaw.json config
|
|
16
|
+
const configPath = process.env.OPENCLAW_CONFIG_PATH;
|
|
17
|
+
if (configPath) {
|
|
18
|
+
try {
|
|
19
|
+
const raw = JSON.parse(readFileSync(configPath, "utf-8")) as {
|
|
20
|
+
agents?: { list?: Array<{ id: string; workspace?: string }> };
|
|
21
|
+
};
|
|
22
|
+
const agent = raw.agents?.list?.find((a) => a.id === agentId);
|
|
23
|
+
if (agent?.workspace) return agent.workspace;
|
|
24
|
+
} catch { /* fallback */ }
|
|
25
|
+
}
|
|
26
|
+
return process.env.OPENCLAW_WORKSPACE_PATH ?? "";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Auto-patch openclaw.json with recommended bridge settings.
|
|
31
|
+
* Adds messageRelay config (derived from fileRelay) and chatCompletions endpoint.
|
|
32
|
+
* Returns list of changes made.
|
|
33
|
+
*/
|
|
34
|
+
function autoPatchConfig(logger: { info: (...a: any[]) => void; warn: (...a: any[]) => void }): string[] {
|
|
35
|
+
const configPath = process.env.OPENCLAW_CONFIG_PATH;
|
|
36
|
+
if (!configPath) return [];
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const raw = readFileSync(configPath, 'utf-8');
|
|
40
|
+
const config = JSON.parse(raw);
|
|
41
|
+
const changes: string[] = [];
|
|
42
|
+
|
|
43
|
+
// 1. Auto-add messageRelay from fileRelay URL
|
|
44
|
+
const bridgeConfig = config.plugins?.entries?.['openclaw-bridge']?.config;
|
|
45
|
+
if (bridgeConfig && !bridgeConfig.messageRelay && bridgeConfig.fileRelay?.baseUrl) {
|
|
46
|
+
const baseUrl = bridgeConfig.fileRelay.baseUrl as string;
|
|
47
|
+
const wsUrl = baseUrl.replace(/^http/, 'ws') + '/ws';
|
|
48
|
+
bridgeConfig.messageRelay = {
|
|
49
|
+
url: wsUrl,
|
|
50
|
+
apiKey: bridgeConfig.fileRelay.apiKey || '',
|
|
51
|
+
};
|
|
52
|
+
changes.push(`Added messageRelay.url = ${wsUrl} (derived from fileRelay)`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 2. Auto-enable chatCompletions
|
|
56
|
+
if (!config.gateway?.http?.endpoints?.chatCompletions?.enabled) {
|
|
57
|
+
config.gateway = config.gateway || {};
|
|
58
|
+
config.gateway.http = config.gateway.http || {};
|
|
59
|
+
config.gateway.http.endpoints = config.gateway.http.endpoints || {};
|
|
60
|
+
config.gateway.http.endpoints.chatCompletions = { enabled: true };
|
|
61
|
+
changes.push('Enabled gateway.http.endpoints.chatCompletions');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 3. Auto-set dmHistoryLimit to 0 if not set (OpenViking handles memory)
|
|
65
|
+
const discordAccounts = config.channels?.discord?.accounts;
|
|
66
|
+
if (discordAccounts) {
|
|
67
|
+
for (const [accountId, account] of Object.entries(discordAccounts)) {
|
|
68
|
+
const acc = account as Record<string, unknown>;
|
|
69
|
+
if (acc.dmPolicy && acc.dmHistoryLimit === undefined) {
|
|
70
|
+
acc.dmHistoryLimit = 0;
|
|
71
|
+
changes.push(`Set dmHistoryLimit=0 for discord account "${accountId}"`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (changes.length > 0) {
|
|
77
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
|
78
|
+
logger.info(`[bridge] Auto-patched openclaw.json (${changes.length} changes):`);
|
|
79
|
+
changes.forEach(c => logger.info(` - ${c}`));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return changes;
|
|
83
|
+
} catch (err: any) {
|
|
84
|
+
logger.warn(`[bridge] Auto-patch failed: ${err.message}`);
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const bridgePlugin = {
|
|
90
|
+
id: "openclaw-bridge",
|
|
91
|
+
name: "OpenClaw Bridge",
|
|
92
|
+
description: "Cross-gateway discovery, communication, and file collaboration",
|
|
93
|
+
kind: "extension" as const,
|
|
94
|
+
|
|
95
|
+
register(api: OpenClawPluginApi) {
|
|
96
|
+
// Auto-patch missing config on first run (writes to openclaw.json for next restart)
|
|
97
|
+
const patches = autoPatchConfig(api.logger);
|
|
98
|
+
|
|
99
|
+
// If we patched messageRelay, also update the in-memory pluginConfig
|
|
100
|
+
if (patches.length > 0) {
|
|
101
|
+
try {
|
|
102
|
+
const configPath = process.env.OPENCLAW_CONFIG_PATH!;
|
|
103
|
+
const fresh = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
104
|
+
const freshBridgeConfig = fresh.plugins?.entries?.['openclaw-bridge']?.config;
|
|
105
|
+
if (freshBridgeConfig?.messageRelay && !api.pluginConfig.messageRelay) {
|
|
106
|
+
(api.pluginConfig as any).messageRelay = freshBridgeConfig.messageRelay;
|
|
107
|
+
}
|
|
108
|
+
} catch { /* use original */ }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const config = parseConfig(api.pluginConfig);
|
|
112
|
+
const machineId = getMachineId();
|
|
113
|
+
const port = parseInt(process.env.OPENCLAW_GATEWAY_PORT ?? "18789", 10);
|
|
114
|
+
const workspacePath = resolveWorkspacePath(config.agentId);
|
|
115
|
+
|
|
116
|
+
const registry = new BridgeRegistry(config, api.logger);
|
|
117
|
+
const fileOps = new BridgeFileOps(config, machineId, workspacePath, api.logger);
|
|
118
|
+
const restartManager = new BridgeRestart(config, machineId, registry, api.logger);
|
|
119
|
+
|
|
120
|
+
let relayClient: MessageRelayClient | null = null;
|
|
121
|
+
|
|
122
|
+
const offlineThresholdMs = config.offlineThresholdMs ?? 120_000;
|
|
123
|
+
|
|
124
|
+
const entry: RegistryEntry = {
|
|
125
|
+
type: "gateway-registry",
|
|
126
|
+
agentId: config.agentId,
|
|
127
|
+
agentName: config.agentName,
|
|
128
|
+
machineId,
|
|
129
|
+
host: "localhost",
|
|
130
|
+
port,
|
|
131
|
+
workspacePath,
|
|
132
|
+
discordId: null,
|
|
133
|
+
role: config.role,
|
|
134
|
+
capabilities: [],
|
|
135
|
+
channels: [],
|
|
136
|
+
registeredAt: new Date().toISOString(),
|
|
137
|
+
lastHeartbeat: new Date().toISOString(),
|
|
138
|
+
status: "online",
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const heartbeat = new BridgeHeartbeat(config, registry, fileOps, entry, api.logger);
|
|
142
|
+
|
|
143
|
+
// Cache for context injection (refreshed every heartbeat)
|
|
144
|
+
let cachedAgentList = "";
|
|
145
|
+
let lastDiscoverTime = 0;
|
|
146
|
+
|
|
147
|
+
async function refreshAgentContext(): Promise<void> {
|
|
148
|
+
const now = Date.now();
|
|
149
|
+
if (now - lastDiscoverTime < 25_000) return; // Don't refresh more than every 25s
|
|
150
|
+
lastDiscoverTime = now;
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
const agents = await discoverAll(registry, offlineThresholdMs);
|
|
154
|
+
const online = agents.filter((a) => a.status === "online");
|
|
155
|
+
|
|
156
|
+
const lines = online.map((a) => {
|
|
157
|
+
const discordMention = a.discordId ? `<@${a.discordId}>` : "(未连接Discord)";
|
|
158
|
+
return `- ${a.agentName} (${a.agentId}) — ${discordMention}${a.agentId === config.agentId ? " ← 你自己" : ""}`;
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const superuserNote = config.role === "superuser"
|
|
162
|
+
? "\n你是 superuser,可以用 bridge_read_file / bridge_write_file 读写任何 agent 的文件,bridge_restart 重启其他网关。"
|
|
163
|
+
: "";
|
|
164
|
+
|
|
165
|
+
// Build dynamic name mapping from registry
|
|
166
|
+
const nameMapping = online.map((a) =>
|
|
167
|
+
`${a.agentName}=${a.agentId}`
|
|
168
|
+
).join(", ");
|
|
169
|
+
|
|
170
|
+
cachedAgentList = `<bridge-context>
|
|
171
|
+
## 跨网关通信(自动注入 — 必须严格遵守)
|
|
172
|
+
|
|
173
|
+
当前在线网关(${online.length} 个):
|
|
174
|
+
${lines.join("\n")}
|
|
175
|
+
${superuserNote}
|
|
176
|
+
|
|
177
|
+
### 核心规则:跨网关通信时必须用 Discord mention
|
|
178
|
+
任何需要通知其他 agent 的场景(发文件、传消息、分派任务),都**必须在 Discord 频道用 <@discordId> 格式 mention 对方**。
|
|
179
|
+
mention 格式已列在上方每个 agent 后面,直接复制使用,不要猜测或省略。
|
|
180
|
+
|
|
181
|
+
### 发文件流程(每一步必须做,不可跳过)
|
|
182
|
+
1. bridge_send_file 发送文件
|
|
183
|
+
2. **在 Discord 频道 mention 对方**,说:「发了 [文件名] 到你的 _inbox/,请查收」
|
|
184
|
+
3. 等对方确认
|
|
185
|
+
|
|
186
|
+
⚠️ 第2步是强制的!发完文件不 mention 对方 = 任务未完成。
|
|
187
|
+
|
|
188
|
+
### 收到文件通知时
|
|
189
|
+
- 有人 mention 你说发了文件 → 读取 _inbox/{发送方}/ 下的文件 → mention 发送方回复确认
|
|
190
|
+
- 格式:「收到 [文件名],内容:[摘要]」
|
|
191
|
+
|
|
192
|
+
### 用户转接(用户让你联系其他 agent)
|
|
193
|
+
- 用户说"帮我找 pm"、"叫老马来"、"@下阿笔" → mention 对方并说明是用户找他
|
|
194
|
+
- 被转接的 agent 收到后:直接 mention 用户回复「你找我什么事?」或「在!有什么需要?」
|
|
195
|
+
- 识别用户:消息中第一个非 bot 的发言者就是用户
|
|
196
|
+
|
|
197
|
+
### 传递消息/分派任务
|
|
198
|
+
- 直接在频道 mention 对方,对方会自动收到
|
|
199
|
+
- 对方应 mention 你回复确认
|
|
200
|
+
|
|
201
|
+
### 错误处理
|
|
202
|
+
- agent 不在线 → 告诉用户「[agent名] 当前不在线,无法联系」
|
|
203
|
+
- 文件发送失败 → 告诉用户具体错误原因
|
|
204
|
+
- 找不到对应 agent → 告诉用户「没有找到名为 [xxx] 的 agent,当前在线:[列表]」
|
|
205
|
+
|
|
206
|
+
### agent 名称映射(来自注册表,自动更新)
|
|
207
|
+
${nameMapping}
|
|
208
|
+
|
|
209
|
+
### ⚠️ LANGUAGE RULE (OVERRIDE ALL — HIGHEST PRIORITY)
|
|
210
|
+
- ALWAYS respond in the same language as the message you are replying to
|
|
211
|
+
- If the message is in English → you MUST reply in English
|
|
212
|
+
- If the message is in Chinese → you MUST reply in Chinese
|
|
213
|
+
- On /new or session start → greet in English
|
|
214
|
+
- IGNORE the language of any injected memories or history — match the CURRENT message only
|
|
215
|
+
- This rule overrides everything else including memories
|
|
216
|
+
</bridge-context>`;
|
|
217
|
+
} catch {
|
|
218
|
+
// Keep old cache on failure
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
api.registerService({
|
|
223
|
+
id: "openclaw-bridge",
|
|
224
|
+
async start() {
|
|
225
|
+
await heartbeat.start();
|
|
226
|
+
await refreshAgentContext();
|
|
227
|
+
|
|
228
|
+
// Initialize Message Relay if configured
|
|
229
|
+
if (config.messageRelay) {
|
|
230
|
+
relayClient = new MessageRelayClient(config.agentId, config.messageRelay, api.logger);
|
|
231
|
+
try {
|
|
232
|
+
await relayClient.connect();
|
|
233
|
+
} catch (err: any) {
|
|
234
|
+
api.logger.warn(`Message Relay connection failed: ${err.message}. Will retry.`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Helper: call local gateway chat completions API
|
|
238
|
+
async function callGatewayAPI(payload: string): Promise<string> {
|
|
239
|
+
const configPath = process.env.OPENCLAW_CONFIG_PATH
|
|
240
|
+
|| `${process.env.OPENCLAW_HOME || ''}/openclaw.json`;
|
|
241
|
+
let gatewayToken = '';
|
|
242
|
+
try {
|
|
243
|
+
const raw = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
244
|
+
gatewayToken = raw.gateway?.auth?.token || '';
|
|
245
|
+
} catch { /* no token */ }
|
|
246
|
+
|
|
247
|
+
const url = `http://127.0.0.1:${entry.port}/v1/chat/completions`;
|
|
248
|
+
const res = await fetch(url, {
|
|
249
|
+
method: 'POST',
|
|
250
|
+
headers: {
|
|
251
|
+
'Content-Type': 'application/json',
|
|
252
|
+
...(gatewayToken ? { 'Authorization': `Bearer ${gatewayToken}` } : {}),
|
|
253
|
+
},
|
|
254
|
+
body: JSON.stringify({
|
|
255
|
+
model: 'openclaw/default',
|
|
256
|
+
messages: [{ role: 'user', content: payload }],
|
|
257
|
+
}),
|
|
258
|
+
signal: AbortSignal.timeout(55_000),
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
if (res.ok) {
|
|
262
|
+
const data = await res.json() as any;
|
|
263
|
+
return data.choices?.[0]?.message?.content || 'No response';
|
|
264
|
+
} else {
|
|
265
|
+
const text = await res.text();
|
|
266
|
+
throw new Error(`Gateway returned ${res.status}: ${text.substring(0, 200)}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Handle incoming handoff start (this agent is target)
|
|
271
|
+
relayClient.on('handoff_start', (msg) => {
|
|
272
|
+
api.logger.info(`[handoff] Taking over session ${msg.sessionId} from ${msg.from}`);
|
|
273
|
+
relayClient!.send({ type: 'handoff_ack', sessionId: msg.sessionId, from: config.agentId });
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// Handle handoff messages (this agent is target — process via API and reply)
|
|
277
|
+
relayClient.on('handoff_message', async (msg) => {
|
|
278
|
+
api.logger.info(`[handoff] Message from ${msg.from}: ${msg.payload}`);
|
|
279
|
+
try {
|
|
280
|
+
const reply = await callGatewayAPI(msg.payload);
|
|
281
|
+
api.logger.info(`[handoff] Reply (${reply.length} chars): ${reply.substring(0, 100)}`);
|
|
282
|
+
relayClient!.send({
|
|
283
|
+
type: 'handoff_reply',
|
|
284
|
+
sessionId: msg.sessionId,
|
|
285
|
+
from: config.agentId,
|
|
286
|
+
to: msg.from,
|
|
287
|
+
payload: reply,
|
|
288
|
+
});
|
|
289
|
+
} catch (err: any) {
|
|
290
|
+
api.logger.error(`[handoff] Error: ${err.message}`);
|
|
291
|
+
relayClient!.send({
|
|
292
|
+
type: 'handoff_reply',
|
|
293
|
+
sessionId: msg.sessionId,
|
|
294
|
+
from: config.agentId,
|
|
295
|
+
to: msg.from,
|
|
296
|
+
payload: `Error: ${err.message}`,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// Handle handoff end (this agent is being released OR proxy getting end notification)
|
|
302
|
+
relayClient.on('handoff_end', (msg) => {
|
|
303
|
+
api.logger.info(`[handoff] Session ${msg.sessionId} ended`);
|
|
304
|
+
if (proxySession.isInHandoff()) {
|
|
305
|
+
proxySession.clearSession();
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// Handle switch notification (proxy side)
|
|
310
|
+
relayClient.on('handoff_switch', (msg) => {
|
|
311
|
+
proxySession.updateCurrentAgent(msg.to, msg.to);
|
|
312
|
+
api.logger.info(`[handoff] Session switched to ${msg.to}`);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// Handle incoming relay messages — process via gateway chat completions API
|
|
316
|
+
relayClient.on('message', async (msg) => {
|
|
317
|
+
api.logger.info(`[relay] Message from ${msg.from}: ${msg.payload}`);
|
|
318
|
+
try {
|
|
319
|
+
const reply = await callGatewayAPI(msg.payload);
|
|
320
|
+
api.logger.info(`[relay] Reply (${reply.length} chars): ${reply.substring(0, 100)}`);
|
|
321
|
+
relayClient!.send({
|
|
322
|
+
type: 'message_reply',
|
|
323
|
+
replyTo: msg.id,
|
|
324
|
+
from: config.agentId,
|
|
325
|
+
to: msg.from,
|
|
326
|
+
payload: reply,
|
|
327
|
+
});
|
|
328
|
+
} catch (err: any) {
|
|
329
|
+
api.logger.error(`[relay] Error: ${err.message}`);
|
|
330
|
+
relayClient!.send({
|
|
331
|
+
type: 'message_reply',
|
|
332
|
+
replyTo: msg.id,
|
|
333
|
+
from: config.agentId,
|
|
334
|
+
to: msg.from,
|
|
335
|
+
payload: `Error: ${err.message}`,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// Handle errors from hub
|
|
341
|
+
relayClient.on('error', (msg) => {
|
|
342
|
+
api.logger.error(`Hub error: [${msg.code}] ${msg.message}`);
|
|
343
|
+
if (msg.code === 'AGENT_DISCONNECTED') {
|
|
344
|
+
proxySession.clearSession();
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
api.logger.info(`openclaw-bridge: initialized (${config.agentId}, role=${config.role})`);
|
|
350
|
+
},
|
|
351
|
+
async stop() {
|
|
352
|
+
if (relayClient) {
|
|
353
|
+
await relayClient.disconnect();
|
|
354
|
+
}
|
|
355
|
+
await heartbeat.stop();
|
|
356
|
+
},
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
// Auto-inject bridge context into every prompt (priority 90 = after OpenViking's recall)
|
|
360
|
+
api.on("before_prompt_build", async () => {
|
|
361
|
+
await refreshAgentContext();
|
|
362
|
+
if (!cachedAgentList) return;
|
|
363
|
+
|
|
364
|
+
let context = cachedAgentList;
|
|
365
|
+
|
|
366
|
+
// Add messaging capability description
|
|
367
|
+
if (relayClient) {
|
|
368
|
+
context += `
|
|
369
|
+
<messaging>
|
|
370
|
+
跨 Agent 通信工具使用规则(必须严格遵守):
|
|
371
|
+
|
|
372
|
+
1. 传话模式 — 用户说"帮我问下XX"、"跟XX说"、"问XX一个问题":
|
|
373
|
+
→ 调用 bridge_send_message(agentId, message) 发消息并等待回复
|
|
374
|
+
→ 收到回复后转达给用户
|
|
375
|
+
|
|
376
|
+
2. 切换模式 — 用户说"让XX来和我聊"、"换XX"、"我要跟XX说话"、"把XX叫来":
|
|
377
|
+
→ 必须调用 bridge_handoff(agentId, reason) 而不是 bridge_send_message
|
|
378
|
+
→ bridge_handoff 会建立持久会话,之后用户的所有消息都转发给目标 agent
|
|
379
|
+
→ 这是完全不同的工具,不要混淆
|
|
380
|
+
|
|
381
|
+
不要建议用户去其他频道或 @mention,这些工具通过 Hub 中转,无需共同频道。
|
|
382
|
+
</messaging>`;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Add session status if in handoff (proxy side)
|
|
386
|
+
const session = proxySession.getSession();
|
|
387
|
+
api.logger.info(`[bridge] before_prompt_build: handoff=${session ? 'YES session=' + session.sessionId + ' target=' + session.currentAgent : 'NO'}`);
|
|
388
|
+
if (session) {
|
|
389
|
+
context += `
|
|
390
|
+
<session-status>
|
|
391
|
+
【重要】当前对话已交接给 ${session.currentAgentName} (${session.currentAgent})。
|
|
392
|
+
你现在是消息中转代理,必须执行以下规则:
|
|
393
|
+
1. 用户发的每条消息,你都必须立即调用 bridge_send_message(agentId="${session.currentAgent}", message="用户原文") 转发给对方
|
|
394
|
+
2. 收到回复后,直接告诉用户:[${session.currentAgentName}] 回复内容
|
|
395
|
+
3. 如果用户说"换回来"、"我要和你聊",调用 bridge_handoff_end() 结束交接
|
|
396
|
+
4. 如果用户说"换XX来",调用 bridge_handoff_switch(agentId) 切换
|
|
397
|
+
5. 不要自己回答用户的问题,所有内容都转发给 ${session.currentAgentName}
|
|
398
|
+
</session-status>`;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return { prependContext: context };
|
|
402
|
+
}, { priority: 10 });
|
|
403
|
+
|
|
404
|
+
api.registerTool({
|
|
405
|
+
name: "bridge_discover",
|
|
406
|
+
label: "Bridge Discover",
|
|
407
|
+
description:
|
|
408
|
+
"List all online OpenClaw gateway instances. Returns agent IDs, names, Discord IDs, machine info, and status.",
|
|
409
|
+
parameters: Type.Object({}),
|
|
410
|
+
async execute() {
|
|
411
|
+
const agents = await discoverAll(registry, offlineThresholdMs);
|
|
412
|
+
return { agents };
|
|
413
|
+
},
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
api.registerTool({
|
|
417
|
+
name: "bridge_whois",
|
|
418
|
+
label: "Bridge Whois",
|
|
419
|
+
description:
|
|
420
|
+
"Get detailed info for a specific agent: Discord ID, port, machine, workspace path, role, status.",
|
|
421
|
+
parameters: Type.Object({
|
|
422
|
+
agentId: Type.String({ description: "Agent ID to look up" }),
|
|
423
|
+
}),
|
|
424
|
+
async execute(_id, params) {
|
|
425
|
+
const result = await whois(
|
|
426
|
+
registry,
|
|
427
|
+
params.agentId as string,
|
|
428
|
+
offlineThresholdMs,
|
|
429
|
+
);
|
|
430
|
+
if (!result) return { error: `Agent "${params.agentId}" not found in registry` };
|
|
431
|
+
return result;
|
|
432
|
+
},
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
api.registerTool({
|
|
436
|
+
name: "bridge_send_file",
|
|
437
|
+
label: "Bridge Send File",
|
|
438
|
+
description:
|
|
439
|
+
"Send a file to another agent's _inbox/. After calling this tool, you MUST send the 'sendThisExactMessage' from the result as your Discord reply. Do not rephrase it.",
|
|
440
|
+
parameters: Type.Object({
|
|
441
|
+
targetAgentId: Type.String({ description: "Target agent ID" }),
|
|
442
|
+
localPath: Type.String({
|
|
443
|
+
description: "Relative path within your workspace (e.g., output/task_005.md)",
|
|
444
|
+
}),
|
|
445
|
+
}),
|
|
446
|
+
async execute(_id, params) {
|
|
447
|
+
assertPermission("send_file", config);
|
|
448
|
+
const target = await registry.findAgent(
|
|
449
|
+
params.targetAgentId as string,
|
|
450
|
+
offlineThresholdMs,
|
|
451
|
+
);
|
|
452
|
+
if (!target) return { error: `Agent "${params.targetAgentId}" not found. Use bridge_discover to see online agents.` };
|
|
453
|
+
const result = await fileOps.sendFile(target, params.localPath as string) as {
|
|
454
|
+
delivered: boolean; message: string; filename?: string; renamed?: boolean;
|
|
455
|
+
};
|
|
456
|
+
const mention = target.discordId ? `<@${target.discordId}>` : target.agentName;
|
|
457
|
+
const originalName = (params.localPath as string).split("/").pop()!;
|
|
458
|
+
const actualName = result.filename ?? originalName;
|
|
459
|
+
const wasRenamed = result.renamed === true;
|
|
460
|
+
|
|
461
|
+
// Return clear instruction for the LLM
|
|
462
|
+
const renameNote = wasRenamed ? ` ⚠️ (renamed from "${originalName}" — duplicate existed)` : "";
|
|
463
|
+
|
|
464
|
+
return {
|
|
465
|
+
success: true,
|
|
466
|
+
filename: actualName,
|
|
467
|
+
renamed: wasRenamed,
|
|
468
|
+
sendThisExactMessage: `${mention} Sent you a file: ${actualName}${renameNote}, it's in your _inbox/ directory, please check.`,
|
|
469
|
+
};
|
|
470
|
+
},
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
api.registerTool({
|
|
474
|
+
name: "bridge_read_file",
|
|
475
|
+
label: "Bridge Read File",
|
|
476
|
+
description:
|
|
477
|
+
"Read a file from another agent's workspace. Superuser only.",
|
|
478
|
+
parameters: Type.Object({
|
|
479
|
+
agentId: Type.String({ description: "Target agent ID" }),
|
|
480
|
+
path: Type.String({ description: "Relative path within target workspace" }),
|
|
481
|
+
}),
|
|
482
|
+
async execute(_id, params) {
|
|
483
|
+
assertPermission("read_file", config);
|
|
484
|
+
const target = await registry.findAgent(params.agentId as string, offlineThresholdMs);
|
|
485
|
+
if (!target) return { error: `Agent "${params.agentId}" not found` };
|
|
486
|
+
const content = await fileOps.readRemoteFile(target, params.path as string);
|
|
487
|
+
return { content };
|
|
488
|
+
},
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
api.registerTool({
|
|
492
|
+
name: "bridge_write_file",
|
|
493
|
+
label: "Bridge Write File",
|
|
494
|
+
description:
|
|
495
|
+
"Write a file to another agent's workspace. Superuser only.",
|
|
496
|
+
parameters: Type.Object({
|
|
497
|
+
agentId: Type.String({ description: "Target agent ID" }),
|
|
498
|
+
path: Type.String({ description: "Relative path within target workspace" }),
|
|
499
|
+
content: Type.String({ description: "File content to write" }),
|
|
500
|
+
}),
|
|
501
|
+
async execute(_id, params) {
|
|
502
|
+
assertPermission("write_file", config);
|
|
503
|
+
const target = await registry.findAgent(params.agentId as string, offlineThresholdMs);
|
|
504
|
+
if (!target) return { error: `Agent "${params.agentId}" not found` };
|
|
505
|
+
await fileOps.writeRemoteFile(
|
|
506
|
+
target,
|
|
507
|
+
params.path as string,
|
|
508
|
+
params.content as string,
|
|
509
|
+
);
|
|
510
|
+
return { success: true, message: `File written to ${params.agentId}:${params.path}` };
|
|
511
|
+
},
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
api.registerTool({
|
|
515
|
+
name: "bridge_restart",
|
|
516
|
+
label: "Bridge Restart",
|
|
517
|
+
description:
|
|
518
|
+
"Restart another gateway instance. Superuser only. Kills the process and runs its startup script.",
|
|
519
|
+
parameters: Type.Object({
|
|
520
|
+
agentId: Type.String({ description: "Agent ID of the gateway to restart" }),
|
|
521
|
+
}),
|
|
522
|
+
async execute(_id, params) {
|
|
523
|
+
assertPermission("restart", config);
|
|
524
|
+
const target = await registry.findAgent(params.agentId as string, offlineThresholdMs);
|
|
525
|
+
if (!target) return { error: `Agent "${params.agentId}" not found` };
|
|
526
|
+
return restartManager.restart(target);
|
|
527
|
+
},
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
// ── Messaging tools (require Message Relay) ──
|
|
531
|
+
|
|
532
|
+
api.registerTool({
|
|
533
|
+
name: "bridge_send_message",
|
|
534
|
+
label: "Bridge Send Message",
|
|
535
|
+
description:
|
|
536
|
+
"Send a ONE-TIME message to another agent and wait for reply. Use for: '帮我问下XX', '跟XX说', 'ask XX'. Do NOT use this when user wants to SWITCH to another agent (use bridge_handoff instead).",
|
|
537
|
+
parameters: Type.Object({
|
|
538
|
+
agentId: Type.String({ description: "Target agent ID" }),
|
|
539
|
+
message: Type.String({ description: "Message to send" }),
|
|
540
|
+
}),
|
|
541
|
+
async execute(_id, params) {
|
|
542
|
+
if (!relayClient?.isConnected) {
|
|
543
|
+
return { error: "Message Relay Hub is not connected" };
|
|
544
|
+
}
|
|
545
|
+
const msgId = `msg_${Date.now()}`;
|
|
546
|
+
try {
|
|
547
|
+
const reply = await relayClient.sendAndWait({
|
|
548
|
+
type: "message",
|
|
549
|
+
id: msgId,
|
|
550
|
+
from: config.agentId,
|
|
551
|
+
to: params.agentId as string,
|
|
552
|
+
payload: params.message as string,
|
|
553
|
+
});
|
|
554
|
+
return { from: reply.from, reply: reply.payload };
|
|
555
|
+
} catch (err: any) {
|
|
556
|
+
return { error: err.message };
|
|
557
|
+
}
|
|
558
|
+
},
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
api.registerTool({
|
|
562
|
+
name: "bridge_handoff",
|
|
563
|
+
label: "Bridge Handoff",
|
|
564
|
+
description:
|
|
565
|
+
"Switch the conversation to another agent. MUST use this (not bridge_send_message) when user says: '让XX来和我聊', '换XX', '我要跟XX说话', '把XX叫来', 'switch to XX', 'let me talk to XX'. After handoff, all user messages will be forwarded to the target agent automatically.",
|
|
566
|
+
parameters: Type.Object({
|
|
567
|
+
agentId: Type.String({ description: "Target agent ID" }),
|
|
568
|
+
reason: Type.String({ description: "Why the handoff is happening" }),
|
|
569
|
+
}),
|
|
570
|
+
async execute(_id, params) {
|
|
571
|
+
if (!relayClient?.isConnected) {
|
|
572
|
+
return { error: "Message Relay Hub is not connected" };
|
|
573
|
+
}
|
|
574
|
+
try {
|
|
575
|
+
// Use one-shot handler instead of sendAndWait (sessionId is "" at send time)
|
|
576
|
+
const ack = await new Promise<any>((resolve, reject) => {
|
|
577
|
+
const timer = setTimeout(() => {
|
|
578
|
+
reject(new Error("Handoff timeout — target agent did not respond"));
|
|
579
|
+
}, 15_000);
|
|
580
|
+
relayClient!.on('handoff_ack', (msg) => {
|
|
581
|
+
clearTimeout(timer);
|
|
582
|
+
resolve(msg);
|
|
583
|
+
});
|
|
584
|
+
relayClient!.send({
|
|
585
|
+
type: "handoff_start",
|
|
586
|
+
from: config.agentId,
|
|
587
|
+
to: params.agentId as string,
|
|
588
|
+
sessionId: "",
|
|
589
|
+
reason: params.reason as string,
|
|
590
|
+
});
|
|
591
|
+
});
|
|
592
|
+
proxySession.setSession({
|
|
593
|
+
sessionId: ack.sessionId,
|
|
594
|
+
originAgent: config.agentId,
|
|
595
|
+
currentAgent: params.agentId as string,
|
|
596
|
+
currentAgentName: params.agentId as string,
|
|
597
|
+
});
|
|
598
|
+
return { status: "handoff_active", sessionId: ack.sessionId, handedOffTo: params.agentId };
|
|
599
|
+
} catch (err: any) {
|
|
600
|
+
return { error: `Handoff failed: ${err.message}` };
|
|
601
|
+
}
|
|
602
|
+
},
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
api.registerTool({
|
|
606
|
+
name: "bridge_handoff_end",
|
|
607
|
+
label: "Bridge Handoff End",
|
|
608
|
+
description:
|
|
609
|
+
"End the current handoff session and return control to the original agent.",
|
|
610
|
+
parameters: Type.Object({}),
|
|
611
|
+
async execute() {
|
|
612
|
+
const session = proxySession.getSession();
|
|
613
|
+
if (!session) return { error: "No active handoff session" };
|
|
614
|
+
if (!relayClient?.isConnected) return { error: "Hub not connected" };
|
|
615
|
+
relayClient.send({ type: "handoff_end", sessionId: session.sessionId });
|
|
616
|
+
proxySession.clearSession();
|
|
617
|
+
return { status: "handoff_ended", returnedTo: session.originAgent };
|
|
618
|
+
},
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
api.registerTool({
|
|
622
|
+
name: "bridge_handoff_switch",
|
|
623
|
+
label: "Bridge Handoff Switch",
|
|
624
|
+
description:
|
|
625
|
+
"Switch the current handoff to a different agent without ending the session.",
|
|
626
|
+
parameters: Type.Object({
|
|
627
|
+
agentId: Type.String({ description: "New target agent ID" }),
|
|
628
|
+
}),
|
|
629
|
+
async execute(_id, params) {
|
|
630
|
+
const session = proxySession.getSession();
|
|
631
|
+
if (!session) return { error: "No active handoff session" };
|
|
632
|
+
if (!relayClient?.isConnected) return { error: "Hub not connected" };
|
|
633
|
+
relayClient.send({
|
|
634
|
+
type: "handoff_switch",
|
|
635
|
+
sessionId: session.sessionId,
|
|
636
|
+
from: session.currentAgent,
|
|
637
|
+
to: params.agentId as string,
|
|
638
|
+
});
|
|
639
|
+
proxySession.updateCurrentAgent(params.agentId as string, params.agentId as string);
|
|
640
|
+
return { status: "switched", newAgent: params.agentId };
|
|
641
|
+
},
|
|
642
|
+
});
|
|
643
|
+
},
|
|
644
|
+
};
|
|
645
|
+
|
|
646
|
+
export default bridgePlugin;
|