clawmatrix 0.1.15 → 0.1.17
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 +17 -2
- package/package.json +1 -1
- package/src/auth.ts +42 -12
- package/src/cluster-service.ts +31 -4
- package/src/compat.ts +3 -0
- package/src/connection.ts +15 -6
- package/src/handoff.ts +311 -17
- package/src/http-utils.ts +35 -0
- package/src/index.ts +52 -41
- package/src/model-proxy.ts +19 -16
- package/src/peer-manager.ts +55 -5
- package/src/router.ts +62 -28
- package/src/tool-proxy.ts +22 -7
- package/src/tools/cluster-events.ts +119 -0
- package/src/tools/cluster-exec.ts +5 -1
- package/src/tools/cluster-handoff-reply.ts +73 -0
- package/src/tools/cluster-handoff.ts +13 -9
- package/src/tools/cluster-peers.ts +15 -6
- package/src/tools/cluster-read.ts +1 -1
- package/src/tools/cluster-send.ts +1 -3
- package/src/tools/cluster-tool.ts +3 -6
- package/src/tools/cluster-write.ts +1 -1
- package/src/types.ts +93 -0
- package/src/web-ui.ts +490 -345
- package/src/web.ts +675 -53
package/src/handoff.ts
CHANGED
|
@@ -5,16 +5,25 @@ import type {
|
|
|
5
5
|
HandoffRequest,
|
|
6
6
|
HandoffResponse,
|
|
7
7
|
HandoffStreamChunk,
|
|
8
|
+
HandoffCancel,
|
|
9
|
+
HandoffStatusQuery,
|
|
10
|
+
HandoffStatusResponse,
|
|
11
|
+
HandoffInputRequired,
|
|
12
|
+
HandoffInput,
|
|
13
|
+
HandoffStatus,
|
|
8
14
|
} from "./types.ts";
|
|
9
15
|
|
|
10
16
|
const DEFAULT_HANDOFF_TIMEOUT = 600_000; // 10 minutes (resets on each stream chunk)
|
|
11
17
|
const MAX_RETRIES = 2;
|
|
18
|
+
const INPUT_REQUIRED_TTL = 1_800_000; // 30 minutes — max time to wait for user reply
|
|
19
|
+
const STALE_CLEANUP_INTERVAL = 120_000; // check every 2 minutes
|
|
12
20
|
|
|
13
21
|
interface PendingHandoff {
|
|
14
22
|
resolve: (result: HandoffResponse["payload"]) => void;
|
|
15
23
|
reject: (error: Error) => void;
|
|
16
24
|
timer: ReturnType<typeof setTimeout>;
|
|
17
25
|
target: string;
|
|
26
|
+
targetNodeId: string;
|
|
18
27
|
retriesLeft: number;
|
|
19
28
|
task: string;
|
|
20
29
|
context?: string;
|
|
@@ -22,14 +31,65 @@ interface PendingHandoff {
|
|
|
22
31
|
onStream?: (delta: string) => void;
|
|
23
32
|
}
|
|
24
33
|
|
|
34
|
+
interface ActiveHandoff {
|
|
35
|
+
proc: { kill: () => void; exited: Promise<number> } | null;
|
|
36
|
+
status: HandoffStatus;
|
|
37
|
+
sessionId: string;
|
|
38
|
+
agent: string;
|
|
39
|
+
startedAt: number;
|
|
40
|
+
inputRequiredAt: number | null; // when status changed to input_required
|
|
41
|
+
from: string; // requesting nodeId
|
|
42
|
+
}
|
|
43
|
+
|
|
25
44
|
export class HandoffManager {
|
|
26
45
|
private config: ClawMatrixConfig;
|
|
27
46
|
private peerManager: PeerManager;
|
|
28
47
|
private pending = new Map<string, PendingHandoff>();
|
|
48
|
+
private active = new Map<string, ActiveHandoff>();
|
|
49
|
+
private inputRequiredTargets = new Map<string, string>(); // handoffId → targetNodeId
|
|
50
|
+
private staleCleanupTimer: ReturnType<typeof setInterval> | null = null;
|
|
29
51
|
|
|
30
52
|
constructor(config: ClawMatrixConfig, peerManager: PeerManager) {
|
|
31
53
|
this.config = config;
|
|
32
54
|
this.peerManager = peerManager;
|
|
55
|
+
|
|
56
|
+
// Periodically clean up stale input_required entries
|
|
57
|
+
this.staleCleanupTimer = setInterval(() => this.cleanupStale(), STALE_CLEANUP_INTERVAL);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Remove stale input_required and canceled entries that exceeded TTL. */
|
|
61
|
+
private cleanupStale() {
|
|
62
|
+
const now = Date.now();
|
|
63
|
+
for (const [id, entry] of this.active) {
|
|
64
|
+
if (entry.status === "input_required" && entry.inputRequiredAt && now - entry.inputRequiredAt > INPUT_REQUIRED_TTL) {
|
|
65
|
+
this.active.delete(id);
|
|
66
|
+
// Notify the requester that the handoff timed out
|
|
67
|
+
this.peerManager.sendTo(entry.from, {
|
|
68
|
+
type: "handoff_res",
|
|
69
|
+
id,
|
|
70
|
+
from: this.config.nodeId,
|
|
71
|
+
to: entry.from,
|
|
72
|
+
timestamp: now,
|
|
73
|
+
payload: {
|
|
74
|
+
success: false,
|
|
75
|
+
nodeId: this.config.nodeId,
|
|
76
|
+
agent: entry.agent,
|
|
77
|
+
error: "Handoff timed out waiting for input",
|
|
78
|
+
},
|
|
79
|
+
} satisfies HandoffResponse);
|
|
80
|
+
}
|
|
81
|
+
// Clean up canceled entries that were never cleaned by runAgentTurn
|
|
82
|
+
// (e.g. cancel arrived during input_required when no process was running)
|
|
83
|
+
if (entry.status === "canceled") {
|
|
84
|
+
this.active.delete(id);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// Clean stale inputRequiredTargets (requester side)
|
|
88
|
+
for (const [handoffId] of this.inputRequiredTargets) {
|
|
89
|
+
if (!this.pending.has(handoffId)) {
|
|
90
|
+
this.inputRequiredTargets.delete(handoffId);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
33
93
|
}
|
|
34
94
|
|
|
35
95
|
/** Send a handoff request and wait for the response. */
|
|
@@ -65,7 +125,7 @@ export class HandoffManager {
|
|
|
65
125
|
return new Promise<HandoffResponse["payload"]>((resolve, reject) => {
|
|
66
126
|
const timer = this.createTimeout(id, targetNodeId, target, task, context, retriesLeft, resolve, reject, onStream);
|
|
67
127
|
|
|
68
|
-
this.pending.set(id, { resolve, reject, timer, target, retriesLeft, task, context, accumulated: "", onStream });
|
|
128
|
+
this.pending.set(id, { resolve, reject, timer, target, targetNodeId, retriesLeft, task, context, accumulated: "", onStream });
|
|
69
129
|
|
|
70
130
|
const frame: HandoffRequest = {
|
|
71
131
|
type: "handoff_req",
|
|
@@ -200,26 +260,94 @@ export class HandoffManager {
|
|
|
200
260
|
return;
|
|
201
261
|
}
|
|
202
262
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
263
|
+
const sessionId = `handoff-${id}`;
|
|
264
|
+
const message = payload.context
|
|
265
|
+
? `${payload.task}\n\nContext:\n${payload.context}`
|
|
266
|
+
: payload.task;
|
|
267
|
+
|
|
268
|
+
const activeEntry: ActiveHandoff = {
|
|
269
|
+
proc: null,
|
|
270
|
+
status: "working",
|
|
271
|
+
sessionId,
|
|
272
|
+
agent: agent.id,
|
|
273
|
+
startedAt: Date.now(),
|
|
274
|
+
inputRequiredAt: null,
|
|
275
|
+
from,
|
|
276
|
+
};
|
|
277
|
+
this.active.set(id, activeEntry);
|
|
278
|
+
|
|
279
|
+
await this.runAgentTurn(id, agent.id, message, sessionId, from, activeEntry);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Handle incoming handoff_input (resume agent with user reply). */
|
|
283
|
+
async handleInput(frame: HandoffInput): Promise<void> {
|
|
284
|
+
const entry = this.active.get(frame.id);
|
|
285
|
+
if (!entry || entry.status !== "input_required") return;
|
|
286
|
+
|
|
287
|
+
// Verify the input comes from the original requester
|
|
288
|
+
if (frame.from !== entry.from) return;
|
|
289
|
+
|
|
290
|
+
entry.status = "working";
|
|
291
|
+
await this.runAgentTurn(frame.id, entry.agent, frame.payload.message, entry.sessionId, entry.from, entry);
|
|
292
|
+
}
|
|
208
293
|
|
|
294
|
+
/** Shared logic: spawn agent subprocess, stream output, handle input_required or completion. */
|
|
295
|
+
private async runAgentTurn(
|
|
296
|
+
id: string,
|
|
297
|
+
agent: string,
|
|
298
|
+
message: string,
|
|
299
|
+
sessionId: string,
|
|
300
|
+
from: string,
|
|
301
|
+
activeEntry: ActiveHandoff,
|
|
302
|
+
): Promise<void> {
|
|
303
|
+
try {
|
|
209
304
|
const proc = spawnProcess(
|
|
210
|
-
["openclaw", "agent", "--agent", agent
|
|
305
|
+
["openclaw", "agent", "--agent", agent, "--message", message, "--session-id", sessionId],
|
|
211
306
|
{ stdout: "pipe", stderr: "pipe" },
|
|
212
307
|
);
|
|
308
|
+
activeEntry.proc = proc;
|
|
309
|
+
|
|
310
|
+
// If cancel arrived between active.set() and proc assignment, kill immediately
|
|
311
|
+
if (activeEntry.status === "canceled") {
|
|
312
|
+
proc.kill();
|
|
313
|
+
this.active.delete(id);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
213
316
|
|
|
214
|
-
//
|
|
215
|
-
const
|
|
216
|
-
const
|
|
317
|
+
// Consume stderr concurrently to prevent pipe buffer deadlock
|
|
318
|
+
const stderrPromise = proc.stderr ? drainStream(proc.stderr) : Promise.resolve("");
|
|
319
|
+
const { output, inputRequired } = await this.streamStdout(proc.stdout, id, from);
|
|
320
|
+
const [exitCode, stderr] = await Promise.all([proc.exited, stderrPromise]);
|
|
321
|
+
|
|
322
|
+
if (activeEntry.status === "canceled") {
|
|
323
|
+
this.active.delete(id);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (inputRequired) {
|
|
328
|
+
const marker = "[INPUT_REQUIRED]";
|
|
329
|
+
const question = output.slice(output.indexOf(marker) + marker.length).trim();
|
|
330
|
+
activeEntry.status = "input_required";
|
|
331
|
+
activeEntry.inputRequiredAt = Date.now();
|
|
332
|
+
|
|
333
|
+
this.peerManager.sendTo(from, {
|
|
334
|
+
type: "handoff_input_required",
|
|
335
|
+
id,
|
|
336
|
+
from: this.config.nodeId,
|
|
337
|
+
to: from,
|
|
338
|
+
timestamp: Date.now(),
|
|
339
|
+
payload: { message: question },
|
|
340
|
+
} satisfies HandoffInputRequired);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
217
343
|
|
|
218
344
|
if (exitCode !== 0) {
|
|
219
|
-
const stderr = proc.stderr ? await new Response(proc.stderr).text() : "";
|
|
220
345
|
throw new Error(`Agent exited with code ${exitCode}: ${stderr}`);
|
|
221
346
|
}
|
|
222
347
|
|
|
348
|
+
activeEntry.status = "completed";
|
|
349
|
+
this.active.delete(id);
|
|
350
|
+
|
|
223
351
|
this.peerManager.sendTo(from, {
|
|
224
352
|
type: "handoff_res",
|
|
225
353
|
id,
|
|
@@ -229,11 +357,18 @@ export class HandoffManager {
|
|
|
229
357
|
payload: {
|
|
230
358
|
success: true,
|
|
231
359
|
nodeId: this.config.nodeId,
|
|
232
|
-
agent
|
|
233
|
-
result:
|
|
360
|
+
agent,
|
|
361
|
+
result: output.trim(),
|
|
234
362
|
},
|
|
235
363
|
} satisfies HandoffResponse);
|
|
236
364
|
} catch (err) {
|
|
365
|
+
if (activeEntry.status === "canceled") {
|
|
366
|
+
this.active.delete(id);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
activeEntry.status = "failed";
|
|
370
|
+
this.active.delete(id);
|
|
371
|
+
|
|
237
372
|
this.peerManager.sendTo(from, {
|
|
238
373
|
type: "handoff_res",
|
|
239
374
|
id,
|
|
@@ -243,7 +378,7 @@ export class HandoffManager {
|
|
|
243
378
|
payload: {
|
|
244
379
|
success: false,
|
|
245
380
|
nodeId: this.config.nodeId,
|
|
246
|
-
agent
|
|
381
|
+
agent,
|
|
247
382
|
error: err instanceof Error ? err.message : String(err),
|
|
248
383
|
},
|
|
249
384
|
} satisfies HandoffResponse);
|
|
@@ -255,8 +390,8 @@ export class HandoffManager {
|
|
|
255
390
|
stdout: ReadableStream | null,
|
|
256
391
|
handoffId: string,
|
|
257
392
|
to: string,
|
|
258
|
-
): Promise<string> {
|
|
259
|
-
if (!stdout) return "";
|
|
393
|
+
): Promise<{ output: string; inputRequired: boolean }> {
|
|
394
|
+
if (!stdout) return { output: "", inputRequired: false };
|
|
260
395
|
|
|
261
396
|
const reader = stdout.getReader();
|
|
262
397
|
const decoder = new TextDecoder();
|
|
@@ -283,6 +418,11 @@ export class HandoffManager {
|
|
|
283
418
|
reader.releaseLock();
|
|
284
419
|
}
|
|
285
420
|
|
|
421
|
+
const INPUT_MARKER = "[INPUT_REQUIRED]";
|
|
422
|
+
if (full.indexOf(INPUT_MARKER) !== -1) {
|
|
423
|
+
return { output: full, inputRequired: true };
|
|
424
|
+
}
|
|
425
|
+
|
|
286
426
|
// Send final done marker
|
|
287
427
|
this.peerManager.sendTo(to, {
|
|
288
428
|
type: "handoff_stream",
|
|
@@ -293,15 +433,169 @@ export class HandoffManager {
|
|
|
293
433
|
payload: { delta: "", done: true },
|
|
294
434
|
} satisfies HandoffStreamChunk);
|
|
295
435
|
|
|
296
|
-
return full;
|
|
436
|
+
return { output: full, inputRequired: false };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Handle incoming input_required from remote (requester side). */
|
|
440
|
+
handleInputRequired(frame: HandoffInputRequired) {
|
|
441
|
+
const pending = this.pending.get(frame.id);
|
|
442
|
+
if (!pending) return;
|
|
443
|
+
|
|
444
|
+
clearTimeout(pending.timer);
|
|
445
|
+
this.pending.delete(frame.id);
|
|
446
|
+
|
|
447
|
+
// Cache target for sendHandoffInput
|
|
448
|
+
this.inputRequiredTargets.set(frame.id, frame.from);
|
|
449
|
+
|
|
450
|
+
pending.resolve({
|
|
451
|
+
success: true,
|
|
452
|
+
nodeId: frame.from,
|
|
453
|
+
result: frame.payload.message,
|
|
454
|
+
inputRequired: true,
|
|
455
|
+
handoffId: frame.id,
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Send a reply to a remote agent that requested more input (requester side). */
|
|
460
|
+
sendHandoffInput(handoffId: string, message: string): Promise<HandoffResponse["payload"]> {
|
|
461
|
+
const targetNodeId = this.inputRequiredTargets.get(handoffId);
|
|
462
|
+
if (!targetNodeId) throw new Error(`No pending input_required for handoff ${handoffId}`);
|
|
463
|
+
|
|
464
|
+
this.inputRequiredTargets.delete(handoffId);
|
|
465
|
+
|
|
466
|
+
return new Promise<HandoffResponse["payload"]>((resolve, reject) => {
|
|
467
|
+
const timer = setTimeout(() => {
|
|
468
|
+
this.pending.delete(handoffId);
|
|
469
|
+
reject(new Error("Handoff reply timed out"));
|
|
470
|
+
}, this.config.handoffTimeout ?? DEFAULT_HANDOFF_TIMEOUT);
|
|
471
|
+
|
|
472
|
+
this.pending.set(handoffId, {
|
|
473
|
+
resolve, reject, timer,
|
|
474
|
+
target: "", targetNodeId,
|
|
475
|
+
retriesLeft: 0, task: message, accumulated: "",
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
this.peerManager.sendTo(targetNodeId, {
|
|
479
|
+
type: "handoff_input",
|
|
480
|
+
id: handoffId,
|
|
481
|
+
from: this.config.nodeId,
|
|
482
|
+
to: targetNodeId,
|
|
483
|
+
timestamp: Date.now(),
|
|
484
|
+
payload: { message },
|
|
485
|
+
} as HandoffInput);
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Handle incoming cancel request (receiver side — kill subprocess). */
|
|
490
|
+
handleCancel(frame: HandoffCancel) {
|
|
491
|
+
const entry = this.active.get(frame.id);
|
|
492
|
+
if (!entry || (entry.status !== "working" && entry.status !== "input_required")) return;
|
|
493
|
+
|
|
494
|
+
// Verify the cancel comes from the original requester
|
|
495
|
+
if (frame.from !== entry.from) return;
|
|
496
|
+
|
|
497
|
+
const wasInputRequired = entry.status === "input_required";
|
|
498
|
+
entry.status = "canceled";
|
|
499
|
+
entry.proc?.kill();
|
|
500
|
+
|
|
501
|
+
// If canceled during input_required, no runAgentTurn is running to clean up
|
|
502
|
+
if (wasInputRequired) {
|
|
503
|
+
this.active.delete(frame.id);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
this.peerManager.sendTo(entry.from, {
|
|
507
|
+
type: "handoff_res",
|
|
508
|
+
id: frame.id,
|
|
509
|
+
from: this.config.nodeId,
|
|
510
|
+
to: entry.from,
|
|
511
|
+
timestamp: Date.now(),
|
|
512
|
+
payload: {
|
|
513
|
+
success: false,
|
|
514
|
+
nodeId: this.config.nodeId,
|
|
515
|
+
agent: entry.agent,
|
|
516
|
+
error: "canceled",
|
|
517
|
+
},
|
|
518
|
+
} satisfies HandoffResponse);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** Handle incoming status query (receiver side — report current state). */
|
|
522
|
+
handleStatusQuery(frame: HandoffStatusQuery) {
|
|
523
|
+
const entry = this.active.get(frame.id);
|
|
524
|
+
if (!entry) return;
|
|
525
|
+
|
|
526
|
+
this.peerManager.sendTo(frame.from, {
|
|
527
|
+
type: "handoff_status_res",
|
|
528
|
+
id: frame.id,
|
|
529
|
+
from: this.config.nodeId,
|
|
530
|
+
to: frame.from,
|
|
531
|
+
timestamp: Date.now(),
|
|
532
|
+
payload: {
|
|
533
|
+
status: entry.status,
|
|
534
|
+
nodeId: this.config.nodeId,
|
|
535
|
+
agent: entry.agent,
|
|
536
|
+
elapsedMs: Date.now() - entry.startedAt,
|
|
537
|
+
},
|
|
538
|
+
} satisfies HandoffStatusResponse);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Cancel an outgoing handoff (requester side — send cancel to remote). */
|
|
542
|
+
cancelHandoff(handoffId: string): boolean {
|
|
543
|
+
const pending = this.pending.get(handoffId);
|
|
544
|
+
if (!pending) return false;
|
|
545
|
+
|
|
546
|
+
clearTimeout(pending.timer);
|
|
547
|
+
this.pending.delete(handoffId);
|
|
548
|
+
pending.reject(new Error("Handoff canceled by requester"));
|
|
549
|
+
|
|
550
|
+
// Send cancel frame to the remote node
|
|
551
|
+
this.peerManager.sendTo(pending.targetNodeId, {
|
|
552
|
+
type: "handoff_cancel",
|
|
553
|
+
id: handoffId,
|
|
554
|
+
from: this.config.nodeId,
|
|
555
|
+
to: pending.targetNodeId,
|
|
556
|
+
timestamp: Date.now(),
|
|
557
|
+
} as HandoffCancel);
|
|
558
|
+
|
|
559
|
+
return true;
|
|
297
560
|
}
|
|
298
561
|
|
|
299
562
|
/** Clean up on shutdown. */
|
|
300
563
|
destroy() {
|
|
564
|
+
if (this.staleCleanupTimer) {
|
|
565
|
+
clearInterval(this.staleCleanupTimer);
|
|
566
|
+
this.staleCleanupTimer = null;
|
|
567
|
+
}
|
|
568
|
+
|
|
301
569
|
for (const [, pending] of this.pending) {
|
|
302
570
|
clearTimeout(pending.timer);
|
|
303
571
|
pending.reject(new Error("Shutting down"));
|
|
304
572
|
}
|
|
305
573
|
this.pending.clear();
|
|
574
|
+
|
|
575
|
+
// Kill all active tasks (working and input_required)
|
|
576
|
+
for (const [, entry] of this.active) {
|
|
577
|
+
if (entry.status === "working" || entry.status === "input_required") {
|
|
578
|
+
entry.proc?.kill();
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
this.active.clear();
|
|
582
|
+
this.inputRequiredTargets.clear();
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** Drain a ReadableStream into a string (for stderr consumption). */
|
|
587
|
+
async function drainStream(stream: ReadableStream): Promise<string> {
|
|
588
|
+
const reader = stream.getReader();
|
|
589
|
+
const decoder = new TextDecoder();
|
|
590
|
+
let result = "";
|
|
591
|
+
try {
|
|
592
|
+
while (true) {
|
|
593
|
+
const { done, value } = await reader.read();
|
|
594
|
+
if (done) break;
|
|
595
|
+
result += decoder.decode(value, { stream: true });
|
|
596
|
+
}
|
|
597
|
+
} finally {
|
|
598
|
+
reader.releaseLock();
|
|
306
599
|
}
|
|
600
|
+
return result;
|
|
307
601
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { IncomingMessage } from "node:http";
|
|
2
|
+
|
|
3
|
+
const MAX_BODY_SIZE = 1024 * 1024; // 1 MB
|
|
4
|
+
|
|
5
|
+
/** Read HTTP request body with size limit (default 1 MB). */
|
|
6
|
+
export function readBody(req: IncomingMessage, maxBytes = MAX_BODY_SIZE): Promise<string> {
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
const chunks: Buffer[] = [];
|
|
9
|
+
let size = 0;
|
|
10
|
+
let settled = false;
|
|
11
|
+
req.on("data", (chunk: Buffer) => {
|
|
12
|
+
if (settled) return;
|
|
13
|
+
size += chunk.length;
|
|
14
|
+
if (size > maxBytes) {
|
|
15
|
+
settled = true;
|
|
16
|
+
req.destroy();
|
|
17
|
+
reject(new Error("Request body too large"));
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
chunks.push(chunk);
|
|
21
|
+
});
|
|
22
|
+
req.on("end", () => {
|
|
23
|
+
if (!settled) {
|
|
24
|
+
settled = true;
|
|
25
|
+
resolve(Buffer.concat(chunks).toString());
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
req.on("error", (err) => {
|
|
29
|
+
if (!settled) {
|
|
30
|
+
settled = true;
|
|
31
|
+
reject(err);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -2,12 +2,14 @@ import type { OpenClawPluginApi, GatewayRequestHandlerOptions } from "openclaw/p
|
|
|
2
2
|
import { ClawMatrixConfigSchema, parseConfig } from "./config.ts";
|
|
3
3
|
import { createClusterService, getClusterRuntime } from "./cluster-service.ts";
|
|
4
4
|
import { createClusterHandoffTool } from "./tools/cluster-handoff.ts";
|
|
5
|
+
import { createClusterHandoffReplyTool } from "./tools/cluster-handoff-reply.ts";
|
|
5
6
|
import { createClusterSendTool } from "./tools/cluster-send.ts";
|
|
6
7
|
import { createClusterPeersTool } from "./tools/cluster-peers.ts";
|
|
7
8
|
import { createClusterExecTool } from "./tools/cluster-exec.ts";
|
|
8
9
|
import { createClusterReadTool } from "./tools/cluster-read.ts";
|
|
9
10
|
import { createClusterWriteTool } from "./tools/cluster-write.ts";
|
|
10
11
|
import { createClusterToolTool } from "./tools/cluster-tool.ts";
|
|
12
|
+
import { createClusterEventsTool } from "./tools/cluster-events.ts";
|
|
11
13
|
import { registerClusterCli } from "./cli.ts";
|
|
12
14
|
|
|
13
15
|
const plugin = {
|
|
@@ -99,12 +101,14 @@ const plugin = {
|
|
|
99
101
|
|
|
100
102
|
// Agent tools
|
|
101
103
|
api.registerTool(createClusterHandoffTool(), { optional: true });
|
|
104
|
+
api.registerTool(createClusterHandoffReplyTool(), { optional: true });
|
|
102
105
|
api.registerTool(createClusterSendTool(), { optional: true });
|
|
103
106
|
api.registerTool(createClusterPeersTool(), { optional: true });
|
|
104
107
|
api.registerTool(createClusterExecTool(), { optional: true });
|
|
105
108
|
api.registerTool(createClusterReadTool(), { optional: true });
|
|
106
109
|
api.registerTool(createClusterWriteTool(), { optional: true });
|
|
107
110
|
api.registerTool(createClusterToolTool(), { optional: true });
|
|
111
|
+
api.registerTool(createClusterEventsTool(), { optional: true });
|
|
108
112
|
|
|
109
113
|
// Gateway methods (queried by CLI via `openclaw gateway call`)
|
|
110
114
|
api.registerGatewayMethod(
|
|
@@ -160,56 +164,63 @@ const plugin = {
|
|
|
160
164
|
// CLI subcommand
|
|
161
165
|
api.registerCli(registerClusterCli, { commands: ["clawmatrix"] });
|
|
162
166
|
|
|
163
|
-
// Inject cluster context into agent prompts
|
|
167
|
+
// Inject cluster context into agent prompts.
|
|
168
|
+
//
|
|
169
|
+
// Minimal-injection strategy:
|
|
170
|
+
// prependSystemContext (cached) = static identity + guidance + peer count.
|
|
171
|
+
// Content is stable across turns → prompt caching works.
|
|
172
|
+
// Peer count is included so the agent knows the cluster exists;
|
|
173
|
+
// detailed topology is on-demand via cluster_peers tool.
|
|
174
|
+
// prependContext (per-turn) = only pending event notifications.
|
|
175
|
+
// Events need proactive push so the agent can react without being asked.
|
|
176
|
+
// Everything else (peer details, satellites) is pull-based via tools.
|
|
177
|
+
|
|
178
|
+
let cachedPeerCount = -1;
|
|
179
|
+
let cachedSystemContext = "";
|
|
180
|
+
|
|
164
181
|
api.on("before_prompt_build", () => {
|
|
165
182
|
try {
|
|
166
183
|
const runtime = getClusterRuntime();
|
|
167
|
-
const
|
|
168
|
-
if (peers.length === 0) return;
|
|
169
|
-
|
|
170
|
-
const lines = [
|
|
171
|
-
`[ClawMatrix Cluster]`,
|
|
172
|
-
`You are on node "${config.nodeId}"${config.tags.length ? ` (tags: ${config.tags.join(", ")})` : ""}.`,
|
|
173
|
-
];
|
|
184
|
+
const peerCount = runtime.peerManager.router.getAllPeers().length;
|
|
174
185
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
186
|
+
// Rebuild system context only when peer count changes
|
|
187
|
+
if (peerCount !== cachedPeerCount) {
|
|
188
|
+
cachedPeerCount = peerCount;
|
|
189
|
+
const lines: string[] = [];
|
|
190
|
+
if (peerCount === 0) {
|
|
191
|
+
lines.push("[ClawMatrix] No peers online. Use cluster_peers to check cluster status.");
|
|
192
|
+
} else {
|
|
193
|
+
lines.push(
|
|
194
|
+
`[ClawMatrix Cluster] node="${config.nodeId}"${config.tags.length ? ` tags=${config.tags.join(",")}` : ""}`,
|
|
195
|
+
...(config.agents.length > 0 ? [`Role: ${config.agents[0]!.description}`] : []),
|
|
196
|
+
`${peerCount} remote peer(s) online. Use cluster_peers to see topology, agents, and models.`,
|
|
197
|
+
"Prefer cluster_exec/read/write for simple ops; cluster_handoff for complex multi-step tasks.",
|
|
198
|
+
"IMPORTANT: Always tell user which remote node you're targeting before calling cluster tools.",
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
cachedSystemContext = lines.join("\n");
|
|
178
202
|
}
|
|
179
203
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
for (const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
204
|
+
// Per-turn: only push pending events (agent must react proactively)
|
|
205
|
+
const pendingEvents = runtime.webHandler?.getUnconsumedEvents(5) ?? [];
|
|
206
|
+
let prependContext: string | undefined;
|
|
207
|
+
if (pendingEvents.length > 0) {
|
|
208
|
+
const evtLines = ["Pending events (use cluster_events to query details or consume):"];
|
|
209
|
+
for (const evt of pendingEvents) {
|
|
210
|
+
const age = Math.floor((Date.now() - evt.ts) / 1000);
|
|
211
|
+
const dataStr = Object.entries(evt.data)
|
|
212
|
+
.map(([k, v]) => `${k}:${typeof v === "string" ? v : JSON.stringify(v)}`)
|
|
213
|
+
.join(",");
|
|
214
|
+
const truncated = dataStr.length > 120 ? dataStr.slice(0, 120) + "…" : dataStr;
|
|
215
|
+
evtLines.push(` [${evt.type}] ${evt.source} (${age}s,id:${evt.id}): ${truncated}`);
|
|
190
216
|
}
|
|
217
|
+
prependContext = evtLines.join("\n");
|
|
191
218
|
}
|
|
192
219
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
" - cluster_exec / cluster_read / cluster_write — run commands, read/write files on a remote node",
|
|
198
|
-
" - cluster_tool — invoke any OpenClaw tool on a remote node",
|
|
199
|
-
" - cluster_handoff — delegate a complex task to a remote agent for autonomous execution",
|
|
200
|
-
" - cluster_send — send a one-way message to a remote agent",
|
|
201
|
-
" - cluster_peers — inspect cluster topology",
|
|
202
|
-
"Use the node's description and tags to decide which node to target. " +
|
|
203
|
-
"For simple operations, prefer cluster_exec/read/write; for complex multi-step tasks, prefer cluster_handoff.",
|
|
204
|
-
"",
|
|
205
|
-
"IMPORTANT: Before calling any cluster tool or using a remote model, you MUST explicitly tell the user " +
|
|
206
|
-
"which remote node you are targeting and what you are about to do. For example:",
|
|
207
|
-
' "I\'m going to run this command on remote node «coder» ..."',
|
|
208
|
-
' "I\'m delegating this task to agent «reviewer» on node «server-b» ..."',
|
|
209
|
-
"This ensures the user always knows when operations leave the local node.",
|
|
210
|
-
);
|
|
211
|
-
|
|
212
|
-
return { prependSystemContext: lines.join("\n") };
|
|
220
|
+
return {
|
|
221
|
+
prependSystemContext: cachedSystemContext,
|
|
222
|
+
...(prependContext ? { prependContext } : {}),
|
|
223
|
+
};
|
|
213
224
|
} catch {
|
|
214
225
|
return;
|
|
215
226
|
}
|