clawmatrix 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 +15 -0
- package/llms.txt +191 -0
- package/openclaw.plugin.json +76 -0
- package/package.json +43 -0
- package/src/auth.ts +38 -0
- package/src/cli.ts +84 -0
- package/src/cluster-service.ts +160 -0
- package/src/config.ts +50 -0
- package/src/connection.ts +261 -0
- package/src/handoff.ts +201 -0
- package/src/index.ts +119 -0
- package/src/model-proxy.ts +441 -0
- package/src/peer-manager.ts +333 -0
- package/src/router.ts +275 -0
- package/src/tool-proxy.ts +324 -0
- package/src/tools/cluster-exec.ts +66 -0
- package/src/tools/cluster-handoff.ts +82 -0
- package/src/tools/cluster-ls.ts +51 -0
- package/src/tools/cluster-peers.ts +55 -0
- package/src/tools/cluster-read.ts +48 -0
- package/src/tools/cluster-send.ts +79 -0
- package/src/tools/cluster-write.ts +61 -0
- package/src/types.ts +197 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { readdir, stat } from "node:fs/promises";
|
|
2
|
+
import { join, resolve, normalize } from "node:path";
|
|
3
|
+
import type { PeerManager } from "./peer-manager.ts";
|
|
4
|
+
import type { ClawMatrixConfig, ToolProxyConfig } from "./config.ts";
|
|
5
|
+
import type { ToolProxyRequest, ToolProxyResponse } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
const TOOL_TIMEOUT = 30_000;
|
|
8
|
+
|
|
9
|
+
interface PendingToolReq {
|
|
10
|
+
resolve: (result: Record<string, unknown>) => void;
|
|
11
|
+
reject: (error: Error) => void;
|
|
12
|
+
timer: ReturnType<typeof setTimeout>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class ToolProxy {
|
|
16
|
+
private config: ClawMatrixConfig;
|
|
17
|
+
private peerManager: PeerManager;
|
|
18
|
+
private pending = new Map<string, PendingToolReq>();
|
|
19
|
+
|
|
20
|
+
constructor(config: ClawMatrixConfig, peerManager: PeerManager) {
|
|
21
|
+
this.config = config;
|
|
22
|
+
this.peerManager = peerManager;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ── Outbound: request remote execution ─────────────────────────
|
|
26
|
+
async exec(
|
|
27
|
+
node: string,
|
|
28
|
+
command: string,
|
|
29
|
+
cwd?: string,
|
|
30
|
+
timeout?: number,
|
|
31
|
+
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
|
32
|
+
const result = await this.sendToolReq(node, "exec", {
|
|
33
|
+
command,
|
|
34
|
+
cwd,
|
|
35
|
+
timeout: timeout ?? TOOL_TIMEOUT,
|
|
36
|
+
});
|
|
37
|
+
return result as { exitCode: number; stdout: string; stderr: string };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async read(node: string, path: string): Promise<{ content: string }> {
|
|
41
|
+
const result = await this.sendToolReq(node, "read", { path });
|
|
42
|
+
return result as { content: string };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async write(
|
|
46
|
+
node: string,
|
|
47
|
+
path: string,
|
|
48
|
+
content: string,
|
|
49
|
+
): Promise<{ success: boolean }> {
|
|
50
|
+
const result = await this.sendToolReq(node, "write", { path, content });
|
|
51
|
+
return result as { success: boolean };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async ls(
|
|
55
|
+
node: string,
|
|
56
|
+
path: string,
|
|
57
|
+
): Promise<{ entries: { name: string; type: "file" | "dir"; size: number }[] }> {
|
|
58
|
+
const result = await this.sendToolReq(node, "ls", { path });
|
|
59
|
+
return result as { entries: { name: string; type: "file" | "dir"; size: number }[] };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private async sendToolReq(
|
|
63
|
+
node: string,
|
|
64
|
+
tool: ToolProxyRequest["payload"]["tool"],
|
|
65
|
+
params: Record<string, unknown>,
|
|
66
|
+
): Promise<Record<string, unknown>> {
|
|
67
|
+
const route = this.peerManager.router.resolveNode(node);
|
|
68
|
+
if (!route) throw new Error(`Node "${node}" not reachable`);
|
|
69
|
+
|
|
70
|
+
const id = crypto.randomUUID();
|
|
71
|
+
const frame: ToolProxyRequest = {
|
|
72
|
+
type: "tool_req",
|
|
73
|
+
id,
|
|
74
|
+
from: this.config.nodeId,
|
|
75
|
+
to: route.nodeId,
|
|
76
|
+
timestamp: Date.now(),
|
|
77
|
+
payload: { tool, params },
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
return new Promise((resolve, reject) => {
|
|
81
|
+
const timeoutMs = (params.timeout as number | undefined) ?? TOOL_TIMEOUT;
|
|
82
|
+
const timer = setTimeout(() => {
|
|
83
|
+
this.pending.delete(id);
|
|
84
|
+
this.peerManager.router.markFailed(id);
|
|
85
|
+
reject(new Error(`Tool "${tool}" timed out on node "${node}"`));
|
|
86
|
+
}, timeoutMs);
|
|
87
|
+
|
|
88
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
89
|
+
|
|
90
|
+
const sent = this.peerManager.sendTo(route.nodeId, frame);
|
|
91
|
+
if (!sent) {
|
|
92
|
+
this.pending.delete(id);
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
reject(new Error(`Cannot reach node "${route.nodeId}"`));
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Incoming response ──────────────────────────────────────────
|
|
100
|
+
handleResponse(frame: ToolProxyResponse) {
|
|
101
|
+
if (this.peerManager.router.isFailed(frame.id)) return;
|
|
102
|
+
const pending = this.pending.get(frame.id);
|
|
103
|
+
if (!pending) return;
|
|
104
|
+
|
|
105
|
+
clearTimeout(pending.timer);
|
|
106
|
+
this.pending.delete(frame.id);
|
|
107
|
+
|
|
108
|
+
if (frame.payload.success && frame.payload.result) {
|
|
109
|
+
pending.resolve(frame.payload.result);
|
|
110
|
+
} else {
|
|
111
|
+
pending.reject(new Error(frame.payload.error ?? "Remote tool execution failed"));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── Incoming request: execute locally ──────────────────────────
|
|
116
|
+
async handleRequest(frame: ToolProxyRequest): Promise<void> {
|
|
117
|
+
const { id, from, payload } = frame;
|
|
118
|
+
const toolProxyConfig = this.config.toolProxy;
|
|
119
|
+
|
|
120
|
+
// Check if tool proxy is enabled
|
|
121
|
+
if (!toolProxyConfig?.enabled) {
|
|
122
|
+
this.sendResponse(id, from, {
|
|
123
|
+
success: false,
|
|
124
|
+
error: "Tool proxy not enabled on this node",
|
|
125
|
+
});
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Check tool allowlist/denylist
|
|
130
|
+
if (!this.isToolAllowed(payload.tool, toolProxyConfig)) {
|
|
131
|
+
this.sendResponse(id, from, {
|
|
132
|
+
success: false,
|
|
133
|
+
error: `Tool "${payload.tool}" not allowed on this node`,
|
|
134
|
+
});
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
let result: Record<string, unknown>;
|
|
140
|
+
|
|
141
|
+
switch (payload.tool) {
|
|
142
|
+
case "exec":
|
|
143
|
+
result = await this.executeExec(payload.params, toolProxyConfig);
|
|
144
|
+
break;
|
|
145
|
+
case "read":
|
|
146
|
+
result = await this.executeRead(payload.params, toolProxyConfig);
|
|
147
|
+
break;
|
|
148
|
+
case "write":
|
|
149
|
+
result = await this.executeWrite(payload.params, toolProxyConfig);
|
|
150
|
+
break;
|
|
151
|
+
case "ls":
|
|
152
|
+
result = await this.executeLs(payload.params, toolProxyConfig);
|
|
153
|
+
break;
|
|
154
|
+
default:
|
|
155
|
+
throw new Error(`Unknown tool: ${payload.tool}`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
this.sendResponse(id, from, { success: true, result });
|
|
159
|
+
} catch (err) {
|
|
160
|
+
this.sendResponse(id, from, {
|
|
161
|
+
success: false,
|
|
162
|
+
error: err instanceof Error ? err.message : String(err),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private sendResponse(
|
|
168
|
+
id: string,
|
|
169
|
+
to: string,
|
|
170
|
+
payload: ToolProxyResponse["payload"],
|
|
171
|
+
) {
|
|
172
|
+
this.peerManager.sendTo(to, {
|
|
173
|
+
type: "tool_res",
|
|
174
|
+
id,
|
|
175
|
+
from: this.config.nodeId,
|
|
176
|
+
to,
|
|
177
|
+
timestamp: Date.now(),
|
|
178
|
+
payload,
|
|
179
|
+
} as ToolProxyResponse);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── Local execution ────────────────────────────────────────────
|
|
183
|
+
private async executeExec(
|
|
184
|
+
params: Record<string, unknown>,
|
|
185
|
+
tpConfig: ToolProxyConfig,
|
|
186
|
+
): Promise<Record<string, unknown>> {
|
|
187
|
+
const command = params.command as string;
|
|
188
|
+
if (!command) throw new Error("Missing command");
|
|
189
|
+
|
|
190
|
+
// Check exec allowlist
|
|
191
|
+
if (tpConfig.execAllowlist.length > 0) {
|
|
192
|
+
const base = command.split(/\s+/)[0]!;
|
|
193
|
+
if (!tpConfig.execAllowlist.includes(base)) {
|
|
194
|
+
throw new Error(`Command "${base}" not in exec allowlist`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const cwd = (params.cwd as string) ?? process.cwd();
|
|
199
|
+
const timeout = (params.timeout as number) ?? TOOL_TIMEOUT;
|
|
200
|
+
|
|
201
|
+
const proc = Bun.spawn(["sh", "-c", command], {
|
|
202
|
+
cwd,
|
|
203
|
+
stdout: "pipe",
|
|
204
|
+
stderr: "pipe",
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const timeoutId = setTimeout(() => proc.kill(), timeout);
|
|
208
|
+
|
|
209
|
+
const [stdout, stderr] = await Promise.all([
|
|
210
|
+
new Response(proc.stdout).text(),
|
|
211
|
+
new Response(proc.stderr).text(),
|
|
212
|
+
]);
|
|
213
|
+
const exitCode = await proc.exited;
|
|
214
|
+
clearTimeout(timeoutId);
|
|
215
|
+
|
|
216
|
+
const maxBytes = tpConfig.maxOutputBytes;
|
|
217
|
+
return {
|
|
218
|
+
exitCode,
|
|
219
|
+
stdout: stdout.slice(0, maxBytes),
|
|
220
|
+
stderr: stderr.slice(0, maxBytes),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private async executeRead(
|
|
225
|
+
params: Record<string, unknown>,
|
|
226
|
+
tpConfig: ToolProxyConfig,
|
|
227
|
+
): Promise<Record<string, unknown>> {
|
|
228
|
+
const path = params.path as string;
|
|
229
|
+
if (!path) throw new Error("Missing path");
|
|
230
|
+
this.validatePath(path, tpConfig);
|
|
231
|
+
|
|
232
|
+
const file = Bun.file(path);
|
|
233
|
+
const content = await file.text();
|
|
234
|
+
return {
|
|
235
|
+
content: content.slice(0, tpConfig.maxOutputBytes),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private async executeWrite(
|
|
240
|
+
params: Record<string, unknown>,
|
|
241
|
+
tpConfig: ToolProxyConfig,
|
|
242
|
+
): Promise<Record<string, unknown>> {
|
|
243
|
+
const path = params.path as string;
|
|
244
|
+
const content = params.content as string;
|
|
245
|
+
if (!path || content === undefined) throw new Error("Missing path or content");
|
|
246
|
+
this.validatePath(path, tpConfig);
|
|
247
|
+
|
|
248
|
+
await Bun.write(path, content);
|
|
249
|
+
return { success: true };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private async executeLs(
|
|
253
|
+
params: Record<string, unknown>,
|
|
254
|
+
tpConfig: ToolProxyConfig,
|
|
255
|
+
): Promise<Record<string, unknown>> {
|
|
256
|
+
const path = params.path as string;
|
|
257
|
+
if (!path) throw new Error("Missing path");
|
|
258
|
+
this.validatePath(path, tpConfig);
|
|
259
|
+
|
|
260
|
+
const dirEntries = await readdir(path, { withFileTypes: true });
|
|
261
|
+
const entries: { name: string; type: "file" | "dir"; size: number }[] = [];
|
|
262
|
+
|
|
263
|
+
for (const entry of dirEntries) {
|
|
264
|
+
const fullPath = join(path, entry.name);
|
|
265
|
+
try {
|
|
266
|
+
const st = await stat(fullPath);
|
|
267
|
+
entries.push({
|
|
268
|
+
name: entry.name,
|
|
269
|
+
type: entry.isDirectory() ? "dir" : "file",
|
|
270
|
+
size: Number(st.size),
|
|
271
|
+
});
|
|
272
|
+
} catch {
|
|
273
|
+
entries.push({
|
|
274
|
+
name: entry.name,
|
|
275
|
+
type: entry.isDirectory() ? "dir" : "file",
|
|
276
|
+
size: 0,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return { entries };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ── Security ───────────────────────────────────────────────────
|
|
285
|
+
private isToolAllowed(
|
|
286
|
+
tool: string,
|
|
287
|
+
tpConfig: ToolProxyConfig,
|
|
288
|
+
): boolean {
|
|
289
|
+
if (tpConfig.deny.includes(tool as "exec" | "read" | "write" | "ls")) return false;
|
|
290
|
+
if (tpConfig.allow.length > 0) {
|
|
291
|
+
return tpConfig.allow.includes(tool as "exec" | "read" | "write" | "ls");
|
|
292
|
+
}
|
|
293
|
+
return true;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private validatePath(path: string, tpConfig: ToolProxyConfig) {
|
|
297
|
+
const normalized = normalize(resolve(path));
|
|
298
|
+
|
|
299
|
+
// Check deny paths first
|
|
300
|
+
for (const denied of tpConfig.denyPaths) {
|
|
301
|
+
if (normalized.startsWith(normalize(resolve(denied)))) {
|
|
302
|
+
throw new Error(`Path "${path}" is in denied path prefix`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Check allow paths
|
|
307
|
+
if (tpConfig.allowPaths.length > 0) {
|
|
308
|
+
const allowed = tpConfig.allowPaths.some((p) =>
|
|
309
|
+
normalized.startsWith(normalize(resolve(p))),
|
|
310
|
+
);
|
|
311
|
+
if (!allowed) {
|
|
312
|
+
throw new Error(`Path "${path}" is not in allowed path prefixes`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
destroy() {
|
|
318
|
+
for (const [, pending] of this.pending) {
|
|
319
|
+
clearTimeout(pending.timer);
|
|
320
|
+
pending.reject(new Error("Shutting down"));
|
|
321
|
+
}
|
|
322
|
+
this.pending.clear();
|
|
323
|
+
}
|
|
324
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { AnyAgentTool } from "openclaw/plugin-sdk";
|
|
2
|
+
import { getClusterRuntime } from "../cluster-service.ts";
|
|
3
|
+
|
|
4
|
+
export function createClusterExecTool(): AnyAgentTool {
|
|
5
|
+
return {
|
|
6
|
+
name: "cluster_exec",
|
|
7
|
+
label: "Cluster Exec",
|
|
8
|
+
description:
|
|
9
|
+
"Execute a shell command on a remote cluster node. " +
|
|
10
|
+
"Use nodeId or 'tags:<tag>' to specify the target.",
|
|
11
|
+
parameters: {
|
|
12
|
+
type: "object",
|
|
13
|
+
properties: {
|
|
14
|
+
node: {
|
|
15
|
+
type: "string",
|
|
16
|
+
description: 'Target nodeId or "tags:<tag>"',
|
|
17
|
+
},
|
|
18
|
+
command: {
|
|
19
|
+
type: "string",
|
|
20
|
+
description: "Shell command to execute",
|
|
21
|
+
},
|
|
22
|
+
cwd: {
|
|
23
|
+
type: "string",
|
|
24
|
+
description: "Working directory (optional)",
|
|
25
|
+
},
|
|
26
|
+
timeout: {
|
|
27
|
+
type: "number",
|
|
28
|
+
description: "Timeout in milliseconds (default 30000)",
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
required: ["node", "command"],
|
|
32
|
+
},
|
|
33
|
+
async execute(_toolCallId, params) {
|
|
34
|
+
const { node, command, cwd, timeout } = params as {
|
|
35
|
+
node: string;
|
|
36
|
+
command: string;
|
|
37
|
+
cwd?: string;
|
|
38
|
+
timeout?: number;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const runtime = getClusterRuntime();
|
|
43
|
+
const result = await runtime.toolProxy.exec(node, command, cwd, timeout);
|
|
44
|
+
return {
|
|
45
|
+
content: [
|
|
46
|
+
{
|
|
47
|
+
type: "text" as const,
|
|
48
|
+
text: JSON.stringify(result, null, 2),
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
details: result,
|
|
52
|
+
};
|
|
53
|
+
} catch (err) {
|
|
54
|
+
return {
|
|
55
|
+
content: [
|
|
56
|
+
{
|
|
57
|
+
type: "text" as const,
|
|
58
|
+
text: `Exec error: ${err instanceof Error ? err.message : String(err)}`,
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
details: { error: true },
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { AnyAgentTool } from "openclaw/plugin-sdk";
|
|
2
|
+
import { getClusterRuntime } from "../cluster-service.ts";
|
|
3
|
+
|
|
4
|
+
export function createClusterHandoffTool(): AnyAgentTool {
|
|
5
|
+
return {
|
|
6
|
+
name: "cluster_handoff",
|
|
7
|
+
label: "Cluster Handoff",
|
|
8
|
+
description:
|
|
9
|
+
"Hand off a task to another agent in the cluster and wait for the result. " +
|
|
10
|
+
"Use agent ID (e.g. 'coder') or tag query (e.g. 'tags:coding') as target.",
|
|
11
|
+
parameters: {
|
|
12
|
+
type: "object",
|
|
13
|
+
properties: {
|
|
14
|
+
target: {
|
|
15
|
+
type: "string",
|
|
16
|
+
description: 'Agent ID or "tags:<tag>" expression',
|
|
17
|
+
},
|
|
18
|
+
task: {
|
|
19
|
+
type: "string",
|
|
20
|
+
description: "Task description for the remote agent",
|
|
21
|
+
},
|
|
22
|
+
context: {
|
|
23
|
+
type: "string",
|
|
24
|
+
description: "Additional context for the task",
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
required: ["target", "task"],
|
|
28
|
+
},
|
|
29
|
+
async execute(_toolCallId, params) {
|
|
30
|
+
const { target, task, context } = params as {
|
|
31
|
+
target: string;
|
|
32
|
+
task: string;
|
|
33
|
+
context?: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const runtime = getClusterRuntime();
|
|
38
|
+
const result = await runtime.handoffManager.handoff(target, task, context);
|
|
39
|
+
|
|
40
|
+
if (!result.success) {
|
|
41
|
+
return {
|
|
42
|
+
content: [
|
|
43
|
+
{
|
|
44
|
+
type: "text" as const,
|
|
45
|
+
text: `Handoff failed: ${result.error}`,
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
details: result,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
content: [
|
|
54
|
+
{
|
|
55
|
+
type: "text" as const,
|
|
56
|
+
text: JSON.stringify(
|
|
57
|
+
{
|
|
58
|
+
nodeId: result.nodeId,
|
|
59
|
+
agent: result.agent,
|
|
60
|
+
result: result.result,
|
|
61
|
+
},
|
|
62
|
+
null,
|
|
63
|
+
2,
|
|
64
|
+
),
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
details: result,
|
|
68
|
+
};
|
|
69
|
+
} catch (err) {
|
|
70
|
+
return {
|
|
71
|
+
content: [
|
|
72
|
+
{
|
|
73
|
+
type: "text" as const,
|
|
74
|
+
text: `Handoff error: ${err instanceof Error ? err.message : String(err)}`,
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
details: { error: true },
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { AnyAgentTool } from "openclaw/plugin-sdk";
|
|
2
|
+
import { getClusterRuntime } from "../cluster-service.ts";
|
|
3
|
+
|
|
4
|
+
export function createClusterLsTool(): AnyAgentTool {
|
|
5
|
+
return {
|
|
6
|
+
name: "cluster_ls",
|
|
7
|
+
label: "Cluster Ls",
|
|
8
|
+
description: "List directory contents on a remote cluster node.",
|
|
9
|
+
parameters: {
|
|
10
|
+
type: "object",
|
|
11
|
+
properties: {
|
|
12
|
+
node: {
|
|
13
|
+
type: "string",
|
|
14
|
+
description: 'Target nodeId or "tags:<tag>"',
|
|
15
|
+
},
|
|
16
|
+
path: {
|
|
17
|
+
type: "string",
|
|
18
|
+
description: "Absolute directory path to list",
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
required: ["node", "path"],
|
|
22
|
+
},
|
|
23
|
+
async execute(_toolCallId, params) {
|
|
24
|
+
const { node, path } = params as { node: string; path: string };
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const runtime = getClusterRuntime();
|
|
28
|
+
const result = await runtime.toolProxy.ls(node, path);
|
|
29
|
+
return {
|
|
30
|
+
content: [
|
|
31
|
+
{
|
|
32
|
+
type: "text" as const,
|
|
33
|
+
text: JSON.stringify(result.entries, null, 2),
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
details: result,
|
|
37
|
+
};
|
|
38
|
+
} catch (err) {
|
|
39
|
+
return {
|
|
40
|
+
content: [
|
|
41
|
+
{
|
|
42
|
+
type: "text" as const,
|
|
43
|
+
text: `Ls error: ${err instanceof Error ? err.message : String(err)}`,
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
details: { error: true },
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { AnyAgentTool } from "openclaw/plugin-sdk";
|
|
2
|
+
import { getClusterRuntime } from "../cluster-service.ts";
|
|
3
|
+
|
|
4
|
+
export function createClusterPeersTool(): AnyAgentTool {
|
|
5
|
+
return {
|
|
6
|
+
name: "cluster_peers",
|
|
7
|
+
label: "Cluster Peers",
|
|
8
|
+
description:
|
|
9
|
+
"List all reachable peers in the cluster, their agents, available models, and connection status.",
|
|
10
|
+
parameters: {
|
|
11
|
+
type: "object",
|
|
12
|
+
properties: {},
|
|
13
|
+
},
|
|
14
|
+
async execute() {
|
|
15
|
+
try {
|
|
16
|
+
const runtime = getClusterRuntime();
|
|
17
|
+
const peers = runtime.peerManager.router.getAllPeers().map((entry) => ({
|
|
18
|
+
nodeId: entry.nodeId,
|
|
19
|
+
agents: entry.agents.map((a) => ({
|
|
20
|
+
id: a.id,
|
|
21
|
+
description: a.description,
|
|
22
|
+
tags: a.tags,
|
|
23
|
+
})),
|
|
24
|
+
models: entry.models.map((m) => ({
|
|
25
|
+
id: m.id,
|
|
26
|
+
provider: m.provider,
|
|
27
|
+
})),
|
|
28
|
+
tags: entry.tags,
|
|
29
|
+
status: entry.connection?.isOpen ? "connected" : "unreachable",
|
|
30
|
+
latencyMs: entry.latencyMs,
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
content: [
|
|
35
|
+
{
|
|
36
|
+
type: "text" as const,
|
|
37
|
+
text: JSON.stringify(peers, null, 2),
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
details: peers,
|
|
41
|
+
};
|
|
42
|
+
} catch (err) {
|
|
43
|
+
return {
|
|
44
|
+
content: [
|
|
45
|
+
{
|
|
46
|
+
type: "text" as const,
|
|
47
|
+
text: `Error: ${err instanceof Error ? err.message : String(err)}`,
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
details: { error: true },
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { AnyAgentTool } from "openclaw/plugin-sdk";
|
|
2
|
+
import { getClusterRuntime } from "../cluster-service.ts";
|
|
3
|
+
|
|
4
|
+
export function createClusterReadTool(): AnyAgentTool {
|
|
5
|
+
return {
|
|
6
|
+
name: "cluster_read",
|
|
7
|
+
label: "Cluster Read",
|
|
8
|
+
description: "Read a file from a remote cluster node.",
|
|
9
|
+
parameters: {
|
|
10
|
+
type: "object",
|
|
11
|
+
properties: {
|
|
12
|
+
node: {
|
|
13
|
+
type: "string",
|
|
14
|
+
description: 'Target nodeId or "tags:<tag>"',
|
|
15
|
+
},
|
|
16
|
+
path: {
|
|
17
|
+
type: "string",
|
|
18
|
+
description: "Absolute file path to read",
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
required: ["node", "path"],
|
|
22
|
+
},
|
|
23
|
+
async execute(_toolCallId, params) {
|
|
24
|
+
const { node, path } = params as { node: string; path: string };
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const runtime = getClusterRuntime();
|
|
28
|
+
const result = await runtime.toolProxy.read(node, path);
|
|
29
|
+
return {
|
|
30
|
+
content: [
|
|
31
|
+
{ type: "text" as const, text: result.content },
|
|
32
|
+
],
|
|
33
|
+
details: result,
|
|
34
|
+
};
|
|
35
|
+
} catch (err) {
|
|
36
|
+
return {
|
|
37
|
+
content: [
|
|
38
|
+
{
|
|
39
|
+
type: "text" as const,
|
|
40
|
+
text: `Read error: ${err instanceof Error ? err.message : String(err)}`,
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
details: { error: true },
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { AnyAgentTool } from "openclaw/plugin-sdk";
|
|
2
|
+
import { getClusterRuntime } from "../cluster-service.ts";
|
|
3
|
+
|
|
4
|
+
export function createClusterSendTool(): AnyAgentTool {
|
|
5
|
+
return {
|
|
6
|
+
name: "cluster_send",
|
|
7
|
+
label: "Cluster Send",
|
|
8
|
+
description:
|
|
9
|
+
"Send a one-way message to another agent in the cluster. " +
|
|
10
|
+
"The message is injected into the target agent's session as an inbound message. " +
|
|
11
|
+
"Does not wait for a response.",
|
|
12
|
+
parameters: {
|
|
13
|
+
type: "object",
|
|
14
|
+
properties: {
|
|
15
|
+
target: {
|
|
16
|
+
type: "string",
|
|
17
|
+
description: 'Agent ID or "tags:<tag>" expression',
|
|
18
|
+
},
|
|
19
|
+
message: {
|
|
20
|
+
type: "string",
|
|
21
|
+
description: "Message content to send",
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
required: ["target", "message"],
|
|
25
|
+
},
|
|
26
|
+
async execute(_toolCallId, params) {
|
|
27
|
+
const { target, message } = params as {
|
|
28
|
+
target: string;
|
|
29
|
+
message: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const runtime = getClusterRuntime();
|
|
34
|
+
const route = runtime.peerManager.router.resolveAgent(target);
|
|
35
|
+
if (!route) {
|
|
36
|
+
return {
|
|
37
|
+
content: [
|
|
38
|
+
{
|
|
39
|
+
type: "text" as const,
|
|
40
|
+
text: `No reachable agent for target "${target}"`,
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
details: { error: true },
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const sent = runtime.peerManager.sendTo(route.nodeId, {
|
|
48
|
+
type: "send",
|
|
49
|
+
from: runtime.config.nodeId,
|
|
50
|
+
to: route.nodeId,
|
|
51
|
+
timestamp: Date.now(),
|
|
52
|
+
payload: { target, message },
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
content: [
|
|
57
|
+
{
|
|
58
|
+
type: "text" as const,
|
|
59
|
+
text: sent
|
|
60
|
+
? `Message sent to ${target} on node ${route.nodeId}`
|
|
61
|
+
: `Failed to deliver message to ${target}`,
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
details: { sent, nodeId: route.nodeId },
|
|
65
|
+
};
|
|
66
|
+
} catch (err) {
|
|
67
|
+
return {
|
|
68
|
+
content: [
|
|
69
|
+
{
|
|
70
|
+
type: "text" as const,
|
|
71
|
+
text: `Send error: ${err instanceof Error ? err.message : String(err)}`,
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
details: { error: true },
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|