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/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
- try {
204
- // Execute via openclaw agent subprocess
205
- const message = payload.context
206
- ? `${payload.task}\n\nContext:\n${payload.context}`
207
- : payload.task;
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.id, "--message", message],
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
- // Stream stdout chunks back to the caller
215
- const fullOutput = await this.streamStdout(proc.stdout, id, from);
216
- const exitCode = await proc.exited;
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: agent.id,
233
- result: fullOutput.trim(),
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: agent.id,
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 peers = runtime.peerManager.router.getAllPeers();
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
- if (config.agents.length > 0) {
176
- const localAgent = config.agents[0]!;
177
- lines.push(`Your role: ${localAgent.description}`);
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
- lines.push("", "Remote nodes in the cluster:");
181
- for (const peer of peers) {
182
- const status = peer.connection?.isOpen ? "connected" : "via relay";
183
- const tags = peer.tags.length ? ` [${peer.tags.join(", ")}]` : "";
184
- lines.push(` - ${peer.nodeId} (${status})${tags}`);
185
- for (const agent of peer.agents) {
186
- lines.push(` agent "${agent.id}": ${agent.description}`);
187
- }
188
- if (peer.models.length > 0) {
189
- lines.push(` models: ${peer.models.map((m) => m.id).join(", ")}`);
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
- lines.push(
194
- "",
195
- "When a task involves resources on a remote node (files, commands, services), " +
196
- "use cluster tools to operate there directly:",
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
  }