clawmatrix 0.1.14 → 0.1.16
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/BOOTSTRAP.md +55 -8
- package/package.json +4 -2
- package/src/auth.ts +42 -12
- package/src/cluster-service.ts +35 -7
- package/src/compat.ts +3 -0
- package/src/config.ts +57 -6
- package/src/connection.ts +34 -8
- package/src/device-info.ts +48 -0
- package/src/handoff.ts +330 -21
- package/src/http-utils.ts +35 -0
- package/src/index.ts +47 -19
- package/src/model-proxy.ts +546 -242
- package/src/peer-manager.ts +65 -6
- package/src/router.ts +89 -47
- package/src/tool-proxy.ts +22 -7
- package/src/tools/cluster-events.ts +119 -0
- package/src/tools/cluster-exec.ts +4 -0
- package/src/tools/cluster-handoff-reply.ts +77 -0
- package/src/tools/cluster-handoff.ts +12 -0
- package/src/tools/cluster-peers.ts +17 -1
- package/src/tools/cluster-send.ts +1 -3
- package/src/tools/cluster-tool.ts +2 -5
- package/src/types.ts +117 -0
- package/src/web-ui.ts +694 -342
- package/src/web.ts +726 -50
package/src/peer-manager.ts
CHANGED
|
@@ -5,9 +5,11 @@ import type { ClawMatrixConfig, PeerConfig } from "./config.ts";
|
|
|
5
5
|
import { Connection } from "./connection.ts";
|
|
6
6
|
import type { WsTransport } from "./connection.ts";
|
|
7
7
|
import { Router } from "./router.ts";
|
|
8
|
+
import { collectDeviceInfo } from "./device-info.ts";
|
|
8
9
|
import type {
|
|
9
10
|
AnyClusterFrame,
|
|
10
11
|
ClusterFrame,
|
|
12
|
+
DeviceInfo,
|
|
11
13
|
NodeCapabilities,
|
|
12
14
|
PeerInfo,
|
|
13
15
|
PeerSync,
|
|
@@ -24,6 +26,8 @@ export interface PeerManagerEvents {
|
|
|
24
26
|
|
|
25
27
|
export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
26
28
|
readonly router: Router;
|
|
29
|
+
readonly localDeviceInfo: DeviceInfo;
|
|
30
|
+
satelliteContexts: import("./types.ts").SatelliteContext[] = [];
|
|
27
31
|
private config: ClawMatrixConfig;
|
|
28
32
|
private localCapabilities: NodeCapabilities;
|
|
29
33
|
private httpServer: Server | null = null;
|
|
@@ -35,19 +39,28 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
35
39
|
private inboundConnections = new Map<WsWebSocket, Connection>();
|
|
36
40
|
private gossipDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
37
41
|
|
|
38
|
-
constructor(config: ClawMatrixConfig) {
|
|
42
|
+
constructor(config: ClawMatrixConfig, openclawVersion?: string) {
|
|
39
43
|
super();
|
|
40
44
|
this.config = config;
|
|
45
|
+
this.localDeviceInfo = collectDeviceInfo(openclawVersion);
|
|
41
46
|
this.localCapabilities = {
|
|
42
47
|
nodeId: config.nodeId,
|
|
43
48
|
agents: config.agents,
|
|
44
49
|
models: config.models,
|
|
45
50
|
tags: config.tags,
|
|
51
|
+
deviceInfo: this.localDeviceInfo,
|
|
52
|
+
toolProxy: config.toolProxy ? {
|
|
53
|
+
enabled: config.toolProxy.enabled,
|
|
54
|
+
allow: config.toolProxy.allow,
|
|
55
|
+
deny: config.toolProxy.deny,
|
|
56
|
+
} : undefined,
|
|
46
57
|
};
|
|
47
58
|
this.router = new Router(config.nodeId, {
|
|
48
59
|
agents: config.agents,
|
|
49
60
|
models: config.models,
|
|
50
61
|
tags: config.tags,
|
|
62
|
+
deviceInfo: this.localDeviceInfo,
|
|
63
|
+
toolProxy: this.localCapabilities.toolProxy,
|
|
51
64
|
});
|
|
52
65
|
}
|
|
53
66
|
|
|
@@ -230,6 +243,7 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
230
243
|
this.router.addDirectPeer(nodeId, conn, caps);
|
|
231
244
|
|
|
232
245
|
conn.on("message", (frame) => this.onFrame(frame, conn));
|
|
246
|
+
conn.on("latency", (ms) => this.router.updateLatency(nodeId, ms));
|
|
233
247
|
conn.on("close", () => this.onPeerDisconnected(conn));
|
|
234
248
|
|
|
235
249
|
this.sendPeerSync(conn);
|
|
@@ -244,6 +258,8 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
244
258
|
agents: caps.agents,
|
|
245
259
|
models: caps.models,
|
|
246
260
|
tags: caps.tags,
|
|
261
|
+
deviceInfo: caps.deviceInfo,
|
|
262
|
+
toolProxy: caps.toolProxy,
|
|
247
263
|
},
|
|
248
264
|
} as AnyClusterFrame);
|
|
249
265
|
|
|
@@ -263,6 +279,13 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
263
279
|
|
|
264
280
|
this.router.removePeer(nodeId);
|
|
265
281
|
|
|
282
|
+
// Remove satellite contexts that were only reachable via this peer
|
|
283
|
+
this.satelliteContexts = this.satelliteContexts.filter(s => {
|
|
284
|
+
// Keep satellites that are not associated with the disconnected peer
|
|
285
|
+
// (satellite nodeIds typically differ from mesh peer nodeIds)
|
|
286
|
+
return s.nodeId !== nodeId;
|
|
287
|
+
});
|
|
288
|
+
|
|
266
289
|
this.router.broadcast({
|
|
267
290
|
type: "peer_leave",
|
|
268
291
|
from: this.config.nodeId,
|
|
@@ -278,9 +301,22 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
278
301
|
// Validate from field: must be the direct peer or a known node (relayed)
|
|
279
302
|
if (frame.from && frame.from !== from.remoteNodeId && !this.router.getRoute(frame.from)) return;
|
|
280
303
|
|
|
281
|
-
// Skip dedup for streaming frame types
|
|
282
|
-
|
|
283
|
-
|
|
304
|
+
// Skip dedup for streaming and response frame types.
|
|
305
|
+
// Stream frames share one id across many chunks.
|
|
306
|
+
// Response frames (model_res, tool_res, handoff_res, etc.) share the same id
|
|
307
|
+
// as their request — without this exemption, a relay node that forwarded
|
|
308
|
+
// the request would mark the id as seen and then drop the returning response.
|
|
309
|
+
// Skip dedup for streaming chunks (same id across many chunks) and response
|
|
310
|
+
// frames (share id with their request — relay would otherwise drop the reply).
|
|
311
|
+
// handoff_input, handoff_cancel, and handoff_status reuse the original handoff_req id,
|
|
312
|
+
// so relay nodes that already forwarded the request would drop them as duplicates.
|
|
313
|
+
const skipDedup = frame.type === "model_stream" || frame.type === "handoff_stream"
|
|
314
|
+
|| frame.type === "model_res" || frame.type === "tool_res"
|
|
315
|
+
|| frame.type === "handoff_res" || frame.type === "handoff_status_res"
|
|
316
|
+
|| frame.type === "handoff_input_required"
|
|
317
|
+
|| frame.type === "handoff_input" || frame.type === "handoff_cancel"
|
|
318
|
+
|| frame.type === "handoff_status";
|
|
319
|
+
if (frame.id && !skipDedup && this.router.isDuplicate(frame.id)) return;
|
|
284
320
|
|
|
285
321
|
if (frame.type === "peer_sync") {
|
|
286
322
|
this.handlePeerSync(frame as PeerSync, from);
|
|
@@ -314,23 +350,46 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
314
350
|
|
|
315
351
|
private sendPeerSync(conn: Connection) {
|
|
316
352
|
const peers = this.router.buildPeerSyncPayload();
|
|
353
|
+
const payload: Record<string, unknown> = { peers };
|
|
354
|
+
if (this.satelliteContexts.length > 0) {
|
|
355
|
+
payload.satellites = this.satelliteContexts;
|
|
356
|
+
}
|
|
317
357
|
conn.send({
|
|
318
358
|
type: "peer_sync",
|
|
319
359
|
from: this.config.nodeId,
|
|
320
360
|
timestamp: Date.now(),
|
|
321
|
-
payload
|
|
361
|
+
payload,
|
|
322
362
|
} as AnyClusterFrame);
|
|
323
363
|
}
|
|
324
364
|
|
|
325
365
|
private handlePeerSync(frame: PeerSync, from: Connection) {
|
|
366
|
+
// Merge satellite contexts from relay node (keep newest per nodeId)
|
|
367
|
+
if (frame.payload.satellites) {
|
|
368
|
+
const byNodeId = new Map<string, import("./types.ts").SatelliteContext>();
|
|
369
|
+
for (const s of this.satelliteContexts) byNodeId.set(s.nodeId, s);
|
|
370
|
+
for (const s of frame.payload.satellites) {
|
|
371
|
+
const existing = byNodeId.get(s.nodeId);
|
|
372
|
+
if (!existing || s.ts > existing.ts) {
|
|
373
|
+
byNodeId.set(s.nodeId, s);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
this.satelliteContexts = [...byNodeId.values()];
|
|
377
|
+
}
|
|
378
|
+
|
|
326
379
|
let changed = false;
|
|
327
380
|
for (const peer of frame.payload.peers) {
|
|
328
381
|
if (peer.nodeId === this.config.nodeId) continue;
|
|
329
382
|
if (peer.nodeId === from.remoteNodeId) {
|
|
330
383
|
const prev = this.router.getRoute(peer.nodeId);
|
|
331
384
|
const hadAgents = prev?.agents.length ?? 0;
|
|
385
|
+
const hadDirectPeers = prev?.directPeers.length ?? 0;
|
|
386
|
+
const hadToolProxy = JSON.stringify(prev?.toolProxy);
|
|
387
|
+
const hadDeviceInfo = prev?.deviceInfo?.hostname;
|
|
332
388
|
this.router.updatePeerCapabilities(peer.nodeId, peer);
|
|
333
|
-
if (peer.agents.length !== hadAgents || peer.models.length !== (prev?.models.length ?? 0)
|
|
389
|
+
if (peer.agents.length !== hadAgents || peer.models.length !== (prev?.models.length ?? 0)
|
|
390
|
+
|| (peer.directPeers?.length ?? 0) !== hadDirectPeers
|
|
391
|
+
|| JSON.stringify(peer.toolProxy) !== hadToolProxy
|
|
392
|
+
|| peer.deviceInfo?.hostname !== hadDeviceInfo) {
|
|
334
393
|
changed = true;
|
|
335
394
|
}
|
|
336
395
|
} else {
|
package/src/router.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
import type { ClusterFrame, AnyClusterFrame, PeerInfo, AgentInfo, ModelInfo } from "./types.ts";
|
|
1
|
+
import type { ClusterFrame, AnyClusterFrame, PeerInfo, AgentInfo, ModelInfo, DeviceInfo, ToolProxyInfo } from "./types.ts";
|
|
2
2
|
import type { Connection } from "./connection.ts";
|
|
3
3
|
|
|
4
4
|
const DEFAULT_TTL = 3;
|
|
5
|
-
const SEEN_FRAME_TTL = 60_000; // 60s dedup window
|
|
6
5
|
const MAX_SEEN_FRAMES = 10_000;
|
|
7
|
-
const
|
|
6
|
+
const ROTATE_INTERVAL = 60_000; // rotate dedup maps every 60s
|
|
8
7
|
|
|
9
8
|
export interface RouteEntry {
|
|
10
9
|
nodeId: string;
|
|
@@ -15,6 +14,9 @@ export interface RouteEntry {
|
|
|
15
14
|
reachableVia: string | null; // relay nodeId, null = direct
|
|
16
15
|
lastSeen: number;
|
|
17
16
|
latencyMs: number;
|
|
17
|
+
directPeers: string[]; // nodeIds this node has direct connections to
|
|
18
|
+
deviceInfo?: DeviceInfo;
|
|
19
|
+
toolProxy?: ToolProxyInfo;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
export class Router {
|
|
@@ -22,37 +24,47 @@ export class Router {
|
|
|
22
24
|
private localAgents: AgentInfo[];
|
|
23
25
|
private localModels: ModelInfo[];
|
|
24
26
|
private localTags: string[];
|
|
27
|
+
private localDeviceInfo?: DeviceInfo;
|
|
28
|
+
private localToolProxy?: ToolProxyInfo;
|
|
25
29
|
private routes = new Map<string, RouteEntry>();
|
|
26
30
|
private connections = new Map<string, Connection>(); // nodeId → direct connection
|
|
27
|
-
|
|
28
|
-
private
|
|
31
|
+
/** Double-map dedup: current window + previous window. Rotated periodically. */
|
|
32
|
+
private seenCurrent = new Map<string, true>();
|
|
33
|
+
private seenPrevious = new Map<string, true>();
|
|
34
|
+
private rotateTimer: ReturnType<typeof setInterval> | null = null;
|
|
35
|
+
/** Failed request IDs with expiry timestamps. Separate from dedup to support longer TTLs. */
|
|
36
|
+
private failedRequests = new Map<string, number>(); // requestId → expiresAt
|
|
29
37
|
|
|
30
38
|
constructor(
|
|
31
39
|
nodeId: string,
|
|
32
|
-
localCapabilities?: { agents: AgentInfo[]; models: ModelInfo[]; tags: string[] },
|
|
40
|
+
localCapabilities?: { agents: AgentInfo[]; models: ModelInfo[]; tags: string[]; deviceInfo?: DeviceInfo; toolProxy?: ToolProxyInfo },
|
|
33
41
|
) {
|
|
34
42
|
this.nodeId = nodeId;
|
|
35
43
|
this.localAgents = localCapabilities?.agents ?? [];
|
|
36
44
|
this.localModels = localCapabilities?.models ?? [];
|
|
37
45
|
this.localTags = localCapabilities?.tags ?? [];
|
|
46
|
+
this.localDeviceInfo = localCapabilities?.deviceInfo;
|
|
47
|
+
this.localToolProxy = localCapabilities?.toolProxy;
|
|
38
48
|
|
|
39
|
-
this.
|
|
49
|
+
this.rotateTimer = setInterval(() => this.rotateSeenFrames(), ROTATE_INTERVAL);
|
|
40
50
|
}
|
|
41
51
|
|
|
42
52
|
/** Stop periodic cleanup. Call on shutdown. */
|
|
43
53
|
destroy() {
|
|
44
|
-
if (this.
|
|
45
|
-
clearInterval(this.
|
|
46
|
-
this.
|
|
54
|
+
if (this.rotateTimer) {
|
|
55
|
+
clearInterval(this.rotateTimer);
|
|
56
|
+
this.rotateTimer = null;
|
|
47
57
|
}
|
|
48
|
-
this.
|
|
58
|
+
this.seenCurrent.clear();
|
|
59
|
+
this.seenPrevious.clear();
|
|
60
|
+
this.failedRequests.clear();
|
|
49
61
|
}
|
|
50
62
|
|
|
51
63
|
// ── Route table management ─────────────────────────────────────
|
|
52
64
|
addDirectPeer(
|
|
53
65
|
nodeId: string,
|
|
54
66
|
connection: Connection,
|
|
55
|
-
capabilities: { agents: AgentInfo[]; models: ModelInfo[]; tags: string[] },
|
|
67
|
+
capabilities: { agents: AgentInfo[]; models: ModelInfo[]; tags: string[]; deviceInfo?: DeviceInfo; toolProxy?: ToolProxyInfo },
|
|
56
68
|
) {
|
|
57
69
|
this.connections.set(nodeId, connection);
|
|
58
70
|
this.routes.set(nodeId, {
|
|
@@ -64,6 +76,9 @@ export class Router {
|
|
|
64
76
|
reachableVia: null,
|
|
65
77
|
lastSeen: Date.now(),
|
|
66
78
|
latencyMs: 0,
|
|
79
|
+
directPeers: [],
|
|
80
|
+
deviceInfo: capabilities.deviceInfo,
|
|
81
|
+
toolProxy: capabilities.toolProxy,
|
|
67
82
|
});
|
|
68
83
|
}
|
|
69
84
|
|
|
@@ -83,6 +98,9 @@ export class Router {
|
|
|
83
98
|
reachableVia: viaNodeId,
|
|
84
99
|
lastSeen: Date.now(),
|
|
85
100
|
latencyMs: 0,
|
|
101
|
+
directPeers: peer.directPeers ?? [],
|
|
102
|
+
deviceInfo: peer.deviceInfo,
|
|
103
|
+
toolProxy: peer.toolProxy,
|
|
86
104
|
});
|
|
87
105
|
}
|
|
88
106
|
|
|
@@ -99,17 +117,31 @@ export class Router {
|
|
|
99
117
|
|
|
100
118
|
updatePeerCapabilities(
|
|
101
119
|
nodeId: string,
|
|
102
|
-
capabilities: { agents: AgentInfo[]; models: ModelInfo[]; tags: string[] },
|
|
120
|
+
capabilities: { agents: AgentInfo[]; models: ModelInfo[]; tags: string[]; directPeers?: string[]; deviceInfo?: DeviceInfo; toolProxy?: ToolProxyInfo },
|
|
103
121
|
) {
|
|
104
122
|
const entry = this.routes.get(nodeId);
|
|
105
123
|
if (entry) {
|
|
106
124
|
entry.agents = capabilities.agents;
|
|
107
125
|
entry.models = capabilities.models;
|
|
108
126
|
entry.tags = capabilities.tags;
|
|
127
|
+
if (capabilities.directPeers) {
|
|
128
|
+
entry.directPeers = capabilities.directPeers;
|
|
129
|
+
}
|
|
130
|
+
if (capabilities.deviceInfo) {
|
|
131
|
+
entry.deviceInfo = capabilities.deviceInfo;
|
|
132
|
+
}
|
|
133
|
+
entry.toolProxy = capabilities.toolProxy;
|
|
109
134
|
entry.lastSeen = Date.now();
|
|
110
135
|
}
|
|
111
136
|
}
|
|
112
137
|
|
|
138
|
+
updateLatency(nodeId: string, latencyMs: number) {
|
|
139
|
+
const entry = this.routes.get(nodeId);
|
|
140
|
+
if (entry) {
|
|
141
|
+
entry.latencyMs = latencyMs;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
113
145
|
// ── Routing ────────────────────────────────────────────────────
|
|
114
146
|
getRoute(nodeId: string): RouteEntry | undefined {
|
|
115
147
|
return this.routes.get(nodeId);
|
|
@@ -152,24 +184,6 @@ export class Router {
|
|
|
152
184
|
return candidates[0];
|
|
153
185
|
}
|
|
154
186
|
|
|
155
|
-
/** Find node that has a specific model. */
|
|
156
|
-
resolveModel(modelId: string): RouteEntry | undefined {
|
|
157
|
-
let candidates: RouteEntry[] = [];
|
|
158
|
-
for (const entry of this.routes.values()) {
|
|
159
|
-
if (entry.models.some((m) => m.id === modelId)) {
|
|
160
|
-
candidates.push(entry);
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
if (candidates.length === 0) return undefined;
|
|
164
|
-
|
|
165
|
-
candidates.sort((a, b) => {
|
|
166
|
-
const aDirect = a.connection ? 0 : 1;
|
|
167
|
-
const bDirect = b.connection ? 0 : 1;
|
|
168
|
-
if (aDirect !== bDirect) return aDirect - bDirect;
|
|
169
|
-
return a.latencyMs - b.latencyMs;
|
|
170
|
-
});
|
|
171
|
-
return candidates[0];
|
|
172
|
-
}
|
|
173
187
|
|
|
174
188
|
/** Resolve a node target (nodeId or "tags:<tag>"). */
|
|
175
189
|
resolveNode(target: string): RouteEntry | undefined {
|
|
@@ -233,33 +247,54 @@ export class Router {
|
|
|
233
247
|
return relayed;
|
|
234
248
|
}
|
|
235
249
|
|
|
236
|
-
// ── Deduplication
|
|
237
|
-
/**
|
|
250
|
+
// ── Deduplication (double-map rotation) ────────────────────────
|
|
251
|
+
/**
|
|
252
|
+
* Returns true if the frame has been seen before (duplicate).
|
|
253
|
+
* Uses a double-map strategy: entries live in `seenCurrent` and are
|
|
254
|
+
* promoted from `seenPrevious` on access. Every ROTATE_INTERVAL the
|
|
255
|
+
* previous map is discarded and current becomes previous — O(1) cleanup.
|
|
256
|
+
*/
|
|
238
257
|
isDuplicate(frameId: string): boolean {
|
|
239
258
|
if (!frameId) return false;
|
|
240
|
-
if (this.
|
|
259
|
+
if (this.seenCurrent.has(frameId)) return true;
|
|
260
|
+
if (this.seenPrevious.has(frameId)) {
|
|
261
|
+
// Promote to current so it survives the next rotation
|
|
262
|
+
this.seenCurrent.set(frameId, true);
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
241
265
|
|
|
242
|
-
this.
|
|
243
|
-
|
|
266
|
+
this.seenCurrent.set(frameId, true);
|
|
267
|
+
// Safety valve: if current map grows too large, force a rotation
|
|
268
|
+
if (this.seenCurrent.size > MAX_SEEN_FRAMES) {
|
|
269
|
+
this.rotateSeenFrames();
|
|
270
|
+
}
|
|
244
271
|
return false;
|
|
245
272
|
}
|
|
246
273
|
|
|
247
|
-
/** Mark a request ID as failed so late responses are ignored.
|
|
248
|
-
|
|
249
|
-
|
|
274
|
+
/** Mark a request ID as failed so late responses are ignored.
|
|
275
|
+
* TTL defaults to 15 minutes — long enough for handoff timeouts. */
|
|
276
|
+
markFailed(requestId: string, ttlMs = 900_000) {
|
|
277
|
+
this.failedRequests.set(requestId, Date.now() + ttlMs);
|
|
250
278
|
}
|
|
251
279
|
|
|
252
280
|
isFailed(requestId: string): boolean {
|
|
253
|
-
|
|
281
|
+
const expiresAt = this.failedRequests.get(requestId);
|
|
282
|
+
if (expiresAt === undefined) return false;
|
|
283
|
+
if (Date.now() > expiresAt) {
|
|
284
|
+
this.failedRequests.delete(requestId);
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
return true;
|
|
254
288
|
}
|
|
255
289
|
|
|
256
|
-
private
|
|
257
|
-
|
|
290
|
+
private rotateSeenFrames() {
|
|
291
|
+
this.seenPrevious = this.seenCurrent;
|
|
292
|
+
this.seenCurrent = new Map();
|
|
293
|
+
|
|
294
|
+
// Prune expired failed requests
|
|
258
295
|
const now = Date.now();
|
|
259
|
-
for (const [id,
|
|
260
|
-
if (now
|
|
261
|
-
this.seenFrames.delete(id);
|
|
262
|
-
}
|
|
296
|
+
for (const [id, expiresAt] of this.failedRequests) {
|
|
297
|
+
if (now > expiresAt) this.failedRequests.delete(id);
|
|
263
298
|
}
|
|
264
299
|
}
|
|
265
300
|
|
|
@@ -275,12 +310,16 @@ export class Router {
|
|
|
275
310
|
/** Build PeerInfo list for peer_sync. */
|
|
276
311
|
buildPeerSyncPayload(): PeerInfo[] {
|
|
277
312
|
const peers: PeerInfo[] = [];
|
|
278
|
-
// Include ourselves
|
|
313
|
+
// Include ourselves with our direct peer list
|
|
314
|
+
const myDirectPeers = [...this.connections.keys()];
|
|
279
315
|
peers.push({
|
|
280
316
|
nodeId: this.nodeId,
|
|
281
317
|
agents: this.localAgents,
|
|
282
318
|
models: this.localModels,
|
|
283
319
|
tags: this.localTags,
|
|
320
|
+
directPeers: myDirectPeers,
|
|
321
|
+
deviceInfo: this.localDeviceInfo,
|
|
322
|
+
toolProxy: this.localToolProxy,
|
|
284
323
|
});
|
|
285
324
|
for (const entry of this.routes.values()) {
|
|
286
325
|
peers.push({
|
|
@@ -289,6 +328,9 @@ export class Router {
|
|
|
289
328
|
models: entry.models,
|
|
290
329
|
tags: entry.tags,
|
|
291
330
|
reachableVia: entry.reachableVia ?? undefined,
|
|
331
|
+
directPeers: entry.directPeers.length > 0 ? entry.directPeers : undefined,
|
|
332
|
+
deviceInfo: entry.deviceInfo,
|
|
333
|
+
toolProxy: entry.toolProxy,
|
|
292
334
|
});
|
|
293
335
|
}
|
|
294
336
|
return peers;
|
package/src/tool-proxy.ts
CHANGED
|
@@ -16,11 +16,18 @@ export interface GatewayInfo {
|
|
|
16
16
|
authHeader?: string;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/** Interface for satellite tool routing (implemented by WebHandler). */
|
|
20
|
+
export interface SatelliteToolHandler {
|
|
21
|
+
isSatelliteNode(nodeId: string): boolean;
|
|
22
|
+
queueToolForSatellite(nodeId: string, id: string, tool: string, params: Record<string, unknown>, timeout?: number): Promise<Record<string, unknown>>;
|
|
23
|
+
}
|
|
24
|
+
|
|
19
25
|
export class ToolProxy {
|
|
20
26
|
private config: ClawMatrixConfig;
|
|
21
27
|
private peerManager: PeerManager;
|
|
22
28
|
private pending = new Map<string, PendingToolReq>();
|
|
23
29
|
private gatewayInfo: GatewayInfo;
|
|
30
|
+
private satelliteHandler: SatelliteToolHandler | null = null;
|
|
24
31
|
|
|
25
32
|
constructor(config: ClawMatrixConfig, peerManager: PeerManager, gatewayInfo: GatewayInfo) {
|
|
26
33
|
this.config = config;
|
|
@@ -28,6 +35,11 @@ export class ToolProxy {
|
|
|
28
35
|
this.gatewayInfo = gatewayInfo;
|
|
29
36
|
}
|
|
30
37
|
|
|
38
|
+
/** Set the satellite tool handler (called by ClusterRuntime after WebHandler is created). */
|
|
39
|
+
setSatelliteHandler(handler: SatelliteToolHandler) {
|
|
40
|
+
this.satelliteHandler = handler;
|
|
41
|
+
}
|
|
42
|
+
|
|
31
43
|
// ── Outbound: request remote execution ─────────────────────────
|
|
32
44
|
async invoke(
|
|
33
45
|
node: string,
|
|
@@ -45,7 +57,15 @@ export class ToolProxy {
|
|
|
45
57
|
timeout?: number,
|
|
46
58
|
): Promise<Record<string, unknown>> {
|
|
47
59
|
const route = this.peerManager.router.resolveNode(node);
|
|
48
|
-
|
|
60
|
+
|
|
61
|
+
// If no WS route, try satellite fallback
|
|
62
|
+
if (!route) {
|
|
63
|
+
if (this.satelliteHandler?.isSatelliteNode(node)) {
|
|
64
|
+
const id = crypto.randomUUID();
|
|
65
|
+
return this.satelliteHandler.queueToolForSatellite(node, id, tool, params, timeout);
|
|
66
|
+
}
|
|
67
|
+
throw new Error(`Node "${node}" not reachable`);
|
|
68
|
+
}
|
|
49
69
|
|
|
50
70
|
const id = crypto.randomUUID();
|
|
51
71
|
const frame: ToolProxyRequest = {
|
|
@@ -114,17 +134,12 @@ export class ToolProxy {
|
|
|
114
134
|
}
|
|
115
135
|
|
|
116
136
|
try {
|
|
117
|
-
console.log(`Received tool request for: ${payload.tool}`);
|
|
118
|
-
console.log(`Is local tool? ${isLocalTool(payload.tool)}`);
|
|
119
|
-
|
|
120
137
|
const result = isLocalTool(payload.tool)
|
|
121
138
|
? await executeLocally(payload.tool, payload.params)
|
|
122
139
|
: await this.executeViaGateway(payload.tool, payload.params);
|
|
123
|
-
|
|
124
|
-
console.log(`Tool execution result: ${JSON.stringify(result)}`);
|
|
140
|
+
|
|
125
141
|
this.sendResponse(id, from, { success: true, result });
|
|
126
142
|
} catch (err) {
|
|
127
|
-
console.error(`Tool execution error: ${err}`);
|
|
128
143
|
this.sendResponse(id, from, {
|
|
129
144
|
success: false,
|
|
130
145
|
error: err instanceof Error ? err.message : String(err),
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { AnyAgentTool } from "openclaw/plugin-sdk";
|
|
2
|
+
import { getClusterRuntime } from "../cluster-service.ts";
|
|
3
|
+
|
|
4
|
+
export function createClusterEventsTool(): AnyAgentTool {
|
|
5
|
+
return {
|
|
6
|
+
name: "cluster_events",
|
|
7
|
+
label: "Cluster Events",
|
|
8
|
+
description:
|
|
9
|
+
"Query and consume events from external sources (messages, calls, location changes).",
|
|
10
|
+
parameters: {
|
|
11
|
+
type: "object",
|
|
12
|
+
properties: {
|
|
13
|
+
action: {
|
|
14
|
+
type: "string",
|
|
15
|
+
enum: ["query", "consume"],
|
|
16
|
+
description: '"query" to list events, "consume" to mark events as processed',
|
|
17
|
+
},
|
|
18
|
+
type: {
|
|
19
|
+
type: "string",
|
|
20
|
+
description: 'Filter by event type (e.g. "message_received", "call_missed"). Only for action=query.',
|
|
21
|
+
},
|
|
22
|
+
source: {
|
|
23
|
+
type: "string",
|
|
24
|
+
description: 'Filter by source (e.g. "shortcuts", "iphone"). Only for action=query.',
|
|
25
|
+
},
|
|
26
|
+
unconsumed: {
|
|
27
|
+
type: "boolean",
|
|
28
|
+
description: "Only return unconsumed events. Default true. Only for action=query.",
|
|
29
|
+
},
|
|
30
|
+
since: {
|
|
31
|
+
type: "number",
|
|
32
|
+
description: "Only return events after this unix timestamp (ms). Only for action=query.",
|
|
33
|
+
},
|
|
34
|
+
limit: {
|
|
35
|
+
type: "number",
|
|
36
|
+
description: "Max events to return. Default 20. Only for action=query.",
|
|
37
|
+
},
|
|
38
|
+
ids: {
|
|
39
|
+
type: "array",
|
|
40
|
+
items: { type: "string" },
|
|
41
|
+
description: "Event IDs to mark as consumed. Required for action=consume.",
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
required: ["action"],
|
|
45
|
+
},
|
|
46
|
+
async execute(_toolCallId, params) {
|
|
47
|
+
const { action, type, source, unconsumed, since, limit, ids } = params as {
|
|
48
|
+
action: "query" | "consume";
|
|
49
|
+
type?: string;
|
|
50
|
+
source?: string;
|
|
51
|
+
unconsumed?: boolean;
|
|
52
|
+
since?: number;
|
|
53
|
+
limit?: number;
|
|
54
|
+
ids?: string[];
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const runtime = getClusterRuntime();
|
|
59
|
+
const webHandler = runtime.webHandler;
|
|
60
|
+
if (!webHandler) {
|
|
61
|
+
return {
|
|
62
|
+
content: [{ type: "text" as const, text: "Event ingestion requires web dashboard to be enabled (web.enabled = true)" }],
|
|
63
|
+
details: { error: true },
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (action === "consume") {
|
|
68
|
+
if (!ids || ids.length === 0) {
|
|
69
|
+
return {
|
|
70
|
+
content: [{ type: "text" as const, text: "Error: ids array required for consume action" }],
|
|
71
|
+
details: { error: true },
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
const consumed = webHandler.consumeEvents(ids);
|
|
75
|
+
return {
|
|
76
|
+
content: [{ type: "text" as const, text: `Consumed ${consumed} event(s)` }],
|
|
77
|
+
details: { consumed },
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// action === "query"
|
|
82
|
+
const events = webHandler.queryEvents({
|
|
83
|
+
type,
|
|
84
|
+
source,
|
|
85
|
+
since,
|
|
86
|
+
unconsumed: unconsumed !== false, // default true
|
|
87
|
+
limit: limit ?? 20,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
if (events.length === 0) {
|
|
91
|
+
return {
|
|
92
|
+
content: [{ type: "text" as const, text: "No matching events found." }],
|
|
93
|
+
details: { events: [] },
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const summary = events.map(e => ({
|
|
98
|
+
id: e.id,
|
|
99
|
+
type: e.type,
|
|
100
|
+
source: e.source,
|
|
101
|
+
ts: e.ts,
|
|
102
|
+
time: new Date(e.ts).toISOString(),
|
|
103
|
+
consumed: e.consumed,
|
|
104
|
+
data: e.data,
|
|
105
|
+
}));
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
content: [{ type: "text" as const, text: JSON.stringify(summary, null, 2) }],
|
|
109
|
+
details: { events: summary },
|
|
110
|
+
};
|
|
111
|
+
} catch (err) {
|
|
112
|
+
return {
|
|
113
|
+
content: [{ type: "text" as const, text: `Error: ${err instanceof Error ? err.message : String(err)}` }],
|
|
114
|
+
details: { error: true },
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -40,10 +40,14 @@ export function createClusterExecTool(): AnyAgentTool {
|
|
|
40
40
|
|
|
41
41
|
try {
|
|
42
42
|
const runtime = getClusterRuntime();
|
|
43
|
+
// Network timeout must cover the exec timeout plus overhead
|
|
44
|
+
const execTimeoutSec = timeout ?? 1800;
|
|
45
|
+
const networkTimeout = (execTimeoutSec + 30) * 1000;
|
|
43
46
|
const result = await runtime.toolProxy.invoke(
|
|
44
47
|
node,
|
|
45
48
|
"exec",
|
|
46
49
|
{ command, workdir, timeout },
|
|
50
|
+
networkTimeout,
|
|
47
51
|
);
|
|
48
52
|
return {
|
|
49
53
|
content: [
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { AnyAgentTool } from "openclaw/plugin-sdk";
|
|
2
|
+
import { getClusterRuntime } from "../cluster-service.ts";
|
|
3
|
+
|
|
4
|
+
export function createClusterHandoffReplyTool(): AnyAgentTool {
|
|
5
|
+
return {
|
|
6
|
+
name: "cluster_handoff_reply",
|
|
7
|
+
label: "Cluster Handoff Reply",
|
|
8
|
+
description:
|
|
9
|
+
"Reply to a remote agent that requested more input during a handoff. " +
|
|
10
|
+
"Use the handoff_id returned by cluster_handoff.",
|
|
11
|
+
parameters: {
|
|
12
|
+
type: "object",
|
|
13
|
+
properties: {
|
|
14
|
+
handoff_id: {
|
|
15
|
+
type: "string",
|
|
16
|
+
description: "The handoff ID from cluster_handoff's response",
|
|
17
|
+
},
|
|
18
|
+
message: {
|
|
19
|
+
type: "string",
|
|
20
|
+
description: "Your reply to the remote agent's question",
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
required: ["handoff_id", "message"],
|
|
24
|
+
},
|
|
25
|
+
async execute(_toolCallId, params) {
|
|
26
|
+
const { handoff_id, message } = params as { handoff_id: string; message: string };
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const runtime = getClusterRuntime();
|
|
30
|
+
const result = await runtime.handoffManager.sendHandoffInput(handoff_id, message);
|
|
31
|
+
|
|
32
|
+
if (result.inputRequired) {
|
|
33
|
+
return {
|
|
34
|
+
content: [
|
|
35
|
+
{
|
|
36
|
+
type: "text" as const,
|
|
37
|
+
text: `Remote agent needs more information.\n\nHandoff ID: ${result.handoffId}\nQuestion: ${result.result}\n\nUse cluster_handoff_reply again to respond.`,
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
details: result,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!result.success) {
|
|
45
|
+
return {
|
|
46
|
+
content: [{ type: "text" as const, text: `Handoff failed: ${result.error}` }],
|
|
47
|
+
details: result,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
content: [
|
|
53
|
+
{
|
|
54
|
+
type: "text" as const,
|
|
55
|
+
text: JSON.stringify(
|
|
56
|
+
{ nodeId: result.nodeId, agent: result.agent, result: result.result },
|
|
57
|
+
null,
|
|
58
|
+
2,
|
|
59
|
+
),
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
details: result,
|
|
63
|
+
};
|
|
64
|
+
} catch (err) {
|
|
65
|
+
return {
|
|
66
|
+
content: [
|
|
67
|
+
{
|
|
68
|
+
type: "text" as const,
|
|
69
|
+
text: `Handoff reply error: ${err instanceof Error ? err.message : String(err)}`,
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
details: { error: true },
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|