clawmatrix 0.1.6 → 0.1.11
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 +260 -0
- package/README.md +167 -6
- package/package.json +7 -3
- package/src/cli.ts +128 -54
- package/src/cluster-service.ts +12 -1
- package/src/config.ts +17 -0
- package/src/connection.ts +1 -0
- package/src/debug.ts +5 -0
- package/src/handoff.ts +112 -22
- package/src/index.ts +120 -25
- package/src/local-tools.ts +176 -0
- package/src/model-proxy.ts +194 -71
- package/src/peer-manager.ts +32 -5
- package/src/router.ts +24 -3
- package/src/tool-proxy.ts +4 -1
- package/src/types.ts +26 -0
- package/llms.txt +0 -187
package/src/cluster-service.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
} from "openclaw/plugin-sdk";
|
|
7
7
|
import type { ClawMatrixConfig } from "./config.ts";
|
|
8
8
|
import { spawnProcess } from "./compat.ts";
|
|
9
|
+
import { debug } from "./debug.ts";
|
|
9
10
|
import { PeerManager } from "./peer-manager.ts";
|
|
10
11
|
import { HandoffManager } from "./handoff.ts";
|
|
11
12
|
import { ModelProxy } from "./model-proxy.ts";
|
|
@@ -14,6 +15,7 @@ import type {
|
|
|
14
15
|
AnyClusterFrame,
|
|
15
16
|
HandoffRequest,
|
|
16
17
|
HandoffResponse,
|
|
18
|
+
HandoffStreamChunk,
|
|
17
19
|
ModelRequest,
|
|
18
20
|
ModelResponse,
|
|
19
21
|
ModelStreamChunk,
|
|
@@ -87,12 +89,18 @@ export class ClusterRuntime {
|
|
|
87
89
|
}
|
|
88
90
|
|
|
89
91
|
private dispatchFrame(frame: AnyClusterFrame) {
|
|
92
|
+
if (frame.type.startsWith("model_")) {
|
|
93
|
+
debug("dispatch", `${frame.type} id=${frame.id} from=${(frame as ClusterFrame).from}`);
|
|
94
|
+
}
|
|
90
95
|
switch (frame.type) {
|
|
91
96
|
case "handoff_req":
|
|
92
97
|
this.handoffManager.handleRequest(frame as HandoffRequest).catch((err) => {
|
|
93
98
|
this.logger.error(`[clawmatrix] Handoff request error: ${err}`);
|
|
94
99
|
});
|
|
95
100
|
break;
|
|
101
|
+
case "handoff_stream":
|
|
102
|
+
this.handoffManager.handleStream(frame as HandoffStreamChunk);
|
|
103
|
+
break;
|
|
96
104
|
case "handoff_res":
|
|
97
105
|
this.handoffManager.handleResponse(frame as HandoffResponse);
|
|
98
106
|
break;
|
|
@@ -139,10 +147,13 @@ export class ClusterRuntime {
|
|
|
139
147
|
}
|
|
140
148
|
|
|
141
149
|
// Fire-and-forget: inject message via openclaw agent CLI
|
|
142
|
-
spawnProcess(["openclaw", "agent", "--agent", agent.id, "--message", message], {
|
|
150
|
+
const proc = spawnProcess(["openclaw", "agent", "--agent", agent.id, "--message", message], {
|
|
143
151
|
stdout: "ignore",
|
|
144
152
|
stderr: "ignore",
|
|
145
153
|
});
|
|
154
|
+
proc.exited.catch((err) => {
|
|
155
|
+
this.logger.error(`[clawmatrix] Failed to inject message to agent "${agent.id}": ${err}`);
|
|
156
|
+
});
|
|
146
157
|
}
|
|
147
158
|
}
|
|
148
159
|
|
package/src/config.ts
CHANGED
|
@@ -6,6 +6,21 @@ const AgentInfoSchema = z.object({
|
|
|
6
6
|
tags: z.array(z.string()).default([]),
|
|
7
7
|
});
|
|
8
8
|
|
|
9
|
+
const ModelCompatSchema = z.object({
|
|
10
|
+
supportsTools: z.boolean().optional(),
|
|
11
|
+
supportsStore: z.boolean().optional(),
|
|
12
|
+
supportsDeveloperRole: z.boolean().optional(),
|
|
13
|
+
supportsReasoningEffort: z.boolean().optional(),
|
|
14
|
+
supportsUsageInStreaming: z.boolean().optional(),
|
|
15
|
+
supportsStrictMode: z.boolean().optional(),
|
|
16
|
+
maxTokensField: z.enum(["max_completion_tokens", "max_tokens"]).optional(),
|
|
17
|
+
thinkingFormat: z.enum(["openai", "zai", "qwen"]).optional(),
|
|
18
|
+
requiresToolResultName: z.boolean().optional(),
|
|
19
|
+
requiresAssistantAfterToolResult: z.boolean().optional(),
|
|
20
|
+
requiresThinkingAsText: z.boolean().optional(),
|
|
21
|
+
requiresMistralToolIds: z.boolean().optional(),
|
|
22
|
+
}).optional();
|
|
23
|
+
|
|
9
24
|
const ModelParamsSchema = {
|
|
10
25
|
api: z.enum([
|
|
11
26
|
"openai-completions", "openai-responses", "openai-codex-responses",
|
|
@@ -22,6 +37,7 @@ const ModelParamsSchema = {
|
|
|
22
37
|
cacheRead: z.number(),
|
|
23
38
|
cacheWrite: z.number(),
|
|
24
39
|
}).optional(),
|
|
40
|
+
compat: ModelCompatSchema,
|
|
25
41
|
};
|
|
26
42
|
|
|
27
43
|
const ModelInfoSchema = z.object({
|
|
@@ -63,6 +79,7 @@ export const ClawMatrixConfigSchema = z.object({
|
|
|
63
79
|
tags: z.array(z.string()).default([]),
|
|
64
80
|
proxyPort: z.number().default(19001),
|
|
65
81
|
toolProxy: ToolProxyConfigSchema.optional(),
|
|
82
|
+
handoffTimeout: z.number().default(600_000),
|
|
66
83
|
});
|
|
67
84
|
|
|
68
85
|
export type ClawMatrixConfig = z.infer<typeof ClawMatrixConfigSchema>;
|
package/src/connection.ts
CHANGED
package/src/debug.ts
ADDED
package/src/handoff.ts
CHANGED
|
@@ -4,10 +4,10 @@ import { spawnProcess } from "./compat.ts";
|
|
|
4
4
|
import type {
|
|
5
5
|
HandoffRequest,
|
|
6
6
|
HandoffResponse,
|
|
7
|
-
|
|
7
|
+
HandoffStreamChunk,
|
|
8
8
|
} from "./types.ts";
|
|
9
9
|
|
|
10
|
-
const
|
|
10
|
+
const DEFAULT_HANDOFF_TIMEOUT = 600_000; // 10 minutes (resets on each stream chunk)
|
|
11
11
|
const MAX_RETRIES = 2;
|
|
12
12
|
|
|
13
13
|
interface PendingHandoff {
|
|
@@ -18,6 +18,7 @@ interface PendingHandoff {
|
|
|
18
18
|
retriesLeft: number;
|
|
19
19
|
task: string;
|
|
20
20
|
context?: string;
|
|
21
|
+
accumulated: string;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
export class HandoffManager {
|
|
@@ -54,24 +55,9 @@ export class HandoffManager {
|
|
|
54
55
|
const id = crypto.randomUUID();
|
|
55
56
|
|
|
56
57
|
return new Promise<HandoffResponse["payload"]>((resolve, reject) => {
|
|
57
|
-
const timer =
|
|
58
|
-
this.pending.delete(id);
|
|
59
|
-
this.peerManager.router.markFailed(id);
|
|
60
|
-
|
|
61
|
-
// Retry with failover
|
|
62
|
-
if (retriesLeft > 0) {
|
|
63
|
-
const nextRoute = this.peerManager.router.resolveAgent(target);
|
|
64
|
-
if (nextRoute && nextRoute.nodeId !== targetNodeId) {
|
|
65
|
-
this.sendHandoff(nextRoute.nodeId, target, task, context, retriesLeft - 1)
|
|
66
|
-
.then(resolve)
|
|
67
|
-
.catch(reject);
|
|
68
|
-
return;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
reject(new Error(`Handoff to "${target}" timed out`));
|
|
72
|
-
}, HANDOFF_TIMEOUT);
|
|
58
|
+
const timer = this.createTimeout(id, targetNodeId, target, task, context, retriesLeft, resolve, reject);
|
|
73
59
|
|
|
74
|
-
this.pending.set(id, { resolve, reject, timer, target, retriesLeft, task, context });
|
|
60
|
+
this.pending.set(id, { resolve, reject, timer, target, retriesLeft, task, context, accumulated: "" });
|
|
75
61
|
|
|
76
62
|
const frame: HandoffRequest = {
|
|
77
63
|
type: "handoff_req",
|
|
@@ -102,6 +88,57 @@ export class HandoffManager {
|
|
|
102
88
|
});
|
|
103
89
|
}
|
|
104
90
|
|
|
91
|
+
private createTimeout(
|
|
92
|
+
id: string,
|
|
93
|
+
targetNodeId: string,
|
|
94
|
+
target: string,
|
|
95
|
+
task: string,
|
|
96
|
+
context: string | undefined,
|
|
97
|
+
retriesLeft: number,
|
|
98
|
+
resolve: (result: HandoffResponse["payload"]) => void,
|
|
99
|
+
reject: (error: Error) => void,
|
|
100
|
+
): ReturnType<typeof setTimeout> {
|
|
101
|
+
return setTimeout(() => {
|
|
102
|
+
this.pending.delete(id);
|
|
103
|
+
this.peerManager.router.markFailed(id);
|
|
104
|
+
|
|
105
|
+
// Retry with failover
|
|
106
|
+
if (retriesLeft > 0) {
|
|
107
|
+
const nextRoute = this.peerManager.router.resolveAgent(target);
|
|
108
|
+
if (nextRoute && nextRoute.nodeId !== targetNodeId) {
|
|
109
|
+
this.sendHandoff(nextRoute.nodeId, target, task, context, retriesLeft - 1)
|
|
110
|
+
.then(resolve)
|
|
111
|
+
.catch(reject);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
reject(new Error(`Handoff to "${target}" timed out`));
|
|
116
|
+
}, this.config.handoffTimeout ?? DEFAULT_HANDOFF_TIMEOUT);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Handle incoming stream chunk — reset timeout & accumulate. */
|
|
120
|
+
handleStream(frame: HandoffStreamChunk) {
|
|
121
|
+
if (this.peerManager.router.isFailed(frame.id)) return;
|
|
122
|
+
|
|
123
|
+
const pending = this.pending.get(frame.id);
|
|
124
|
+
if (!pending) return;
|
|
125
|
+
|
|
126
|
+
pending.accumulated += frame.payload.delta;
|
|
127
|
+
|
|
128
|
+
// Reset timeout — the remote agent is still working
|
|
129
|
+
clearTimeout(pending.timer);
|
|
130
|
+
pending.timer = this.createTimeout(
|
|
131
|
+
frame.id,
|
|
132
|
+
frame.from,
|
|
133
|
+
pending.target,
|
|
134
|
+
pending.task,
|
|
135
|
+
pending.context,
|
|
136
|
+
pending.retriesLeft,
|
|
137
|
+
pending.resolve,
|
|
138
|
+
pending.reject,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
105
142
|
/** Handle incoming handoff response. */
|
|
106
143
|
handleResponse(frame: HandoffResponse) {
|
|
107
144
|
if (this.peerManager.router.isFailed(frame.id)) return;
|
|
@@ -111,6 +148,12 @@ export class HandoffManager {
|
|
|
111
148
|
|
|
112
149
|
clearTimeout(pending.timer);
|
|
113
150
|
this.pending.delete(frame.id);
|
|
151
|
+
|
|
152
|
+
// If the response has no result but we accumulated stream data, use that
|
|
153
|
+
if (frame.payload.success && !frame.payload.result && pending.accumulated) {
|
|
154
|
+
frame.payload.result = pending.accumulated;
|
|
155
|
+
}
|
|
156
|
+
|
|
114
157
|
pending.resolve(frame.payload);
|
|
115
158
|
}
|
|
116
159
|
|
|
@@ -153,7 +196,8 @@ export class HandoffManager {
|
|
|
153
196
|
{ stdout: "pipe", stderr: "pipe" },
|
|
154
197
|
);
|
|
155
198
|
|
|
156
|
-
|
|
199
|
+
// Stream stdout chunks back to the caller
|
|
200
|
+
const fullOutput = await this.streamStdout(proc.stdout, id, from);
|
|
157
201
|
const exitCode = await proc.exited;
|
|
158
202
|
|
|
159
203
|
if (exitCode !== 0) {
|
|
@@ -171,7 +215,7 @@ export class HandoffManager {
|
|
|
171
215
|
success: true,
|
|
172
216
|
nodeId: this.config.nodeId,
|
|
173
217
|
agent: agent.id,
|
|
174
|
-
result:
|
|
218
|
+
result: fullOutput.trim(),
|
|
175
219
|
},
|
|
176
220
|
} satisfies HandoffResponse);
|
|
177
221
|
} catch (err) {
|
|
@@ -191,9 +235,55 @@ export class HandoffManager {
|
|
|
191
235
|
}
|
|
192
236
|
}
|
|
193
237
|
|
|
238
|
+
/** Read stdout incrementally, sending handoff_stream chunks to the caller. */
|
|
239
|
+
private async streamStdout(
|
|
240
|
+
stdout: ReadableStream | null,
|
|
241
|
+
handoffId: string,
|
|
242
|
+
to: string,
|
|
243
|
+
): Promise<string> {
|
|
244
|
+
if (!stdout) return "";
|
|
245
|
+
|
|
246
|
+
const reader = stdout.getReader();
|
|
247
|
+
const decoder = new TextDecoder();
|
|
248
|
+
let full = "";
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
while (true) {
|
|
252
|
+
const { done, value } = await reader.read();
|
|
253
|
+
if (done) break;
|
|
254
|
+
|
|
255
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
256
|
+
full += chunk;
|
|
257
|
+
|
|
258
|
+
this.peerManager.sendTo(to, {
|
|
259
|
+
type: "handoff_stream",
|
|
260
|
+
id: handoffId,
|
|
261
|
+
from: this.config.nodeId,
|
|
262
|
+
to,
|
|
263
|
+
timestamp: Date.now(),
|
|
264
|
+
payload: { delta: chunk, done: false },
|
|
265
|
+
} satisfies HandoffStreamChunk);
|
|
266
|
+
}
|
|
267
|
+
} finally {
|
|
268
|
+
reader.releaseLock();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Send final done marker
|
|
272
|
+
this.peerManager.sendTo(to, {
|
|
273
|
+
type: "handoff_stream",
|
|
274
|
+
id: handoffId,
|
|
275
|
+
from: this.config.nodeId,
|
|
276
|
+
to,
|
|
277
|
+
timestamp: Date.now(),
|
|
278
|
+
payload: { delta: "", done: true },
|
|
279
|
+
} satisfies HandoffStreamChunk);
|
|
280
|
+
|
|
281
|
+
return full;
|
|
282
|
+
}
|
|
283
|
+
|
|
194
284
|
/** Clean up on shutdown. */
|
|
195
285
|
destroy() {
|
|
196
|
-
for (const [
|
|
286
|
+
for (const [, pending] of this.pending) {
|
|
197
287
|
clearTimeout(pending.timer);
|
|
198
288
|
pending.reject(new Error("Shutting down"));
|
|
199
289
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
1
|
+
import type { OpenClawPluginApi, GatewayRequestHandlerOptions } from "openclaw/plugin-sdk";
|
|
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";
|
|
@@ -41,18 +41,51 @@ const plugin = {
|
|
|
41
41
|
// Background service: manages mesh connections, WS listener, heartbeat
|
|
42
42
|
api.registerService(createClusterService(config, api.config));
|
|
43
43
|
|
|
44
|
-
// Model
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
},
|
|
55
|
-
|
|
44
|
+
// Model providers: register per-node providers so models are accessed as nodeId/modelId
|
|
45
|
+
const baseUrl = `http://127.0.0.1:${config.proxyPort}/v1`;
|
|
46
|
+
const modelsByNode = groupModelsByNode(config);
|
|
47
|
+
|
|
48
|
+
// Patch openclaw config so auth resolution can find a dummy API key.
|
|
49
|
+
// resolveApiKeyForProvider checks cfg.models.providers, not the plugin registry.
|
|
50
|
+
// We must patch BOTH api.config (cfgAtStart) AND the runtimeConfigSnapshot
|
|
51
|
+
// (returned by loadConfig()) because activateSecretsRuntimeSnapshot clones the
|
|
52
|
+
// config before plugins load, so api.config and the snapshot are separate objects.
|
|
53
|
+
const patchProviders = (cfg: Record<string, unknown>) => {
|
|
54
|
+
const models = ((cfg).models ??= {}) as Record<string, unknown>;
|
|
55
|
+
const providers = (models.providers ??= {}) as Record<string, unknown>;
|
|
56
|
+
for (const [nodeId, nodeModels] of Object.entries(modelsByNode)) {
|
|
57
|
+
if (!providers[nodeId]) {
|
|
58
|
+
providers[nodeId] = { baseUrl, apiKey: "sk-clawmatrix-proxy", api: "openai-completions", models: nodeModels };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
patchProviders(api.config as Record<string, unknown>);
|
|
64
|
+
|
|
65
|
+
// Also patch the runtime config snapshot (loadConfig returns it by reference).
|
|
66
|
+
// api.runtime.config.loadConfig() returns runtimeConfigSnapshot directly.
|
|
67
|
+
try {
|
|
68
|
+
const snapshot = api.runtime.config.loadConfig();
|
|
69
|
+
if (snapshot && snapshot !== api.config) {
|
|
70
|
+
patchProviders(snapshot as Record<string, unknown>);
|
|
71
|
+
}
|
|
72
|
+
} catch {
|
|
73
|
+
// Best-effort; api.config patch is the fallback
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
for (const [nodeId, models] of Object.entries(modelsByNode)) {
|
|
77
|
+
api.registerProvider({
|
|
78
|
+
id: nodeId,
|
|
79
|
+
label: `ClawMatrix: ${nodeId}`,
|
|
80
|
+
docsPath: "/plugins/clawmatrix",
|
|
81
|
+
auth: [],
|
|
82
|
+
models: {
|
|
83
|
+
baseUrl,
|
|
84
|
+
apiKey: "sk-clawmatrix-proxy",
|
|
85
|
+
models,
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
}
|
|
56
89
|
|
|
57
90
|
// Agent tools
|
|
58
91
|
api.registerTool(createClusterHandoffTool(), { optional: true });
|
|
@@ -63,6 +96,57 @@ const plugin = {
|
|
|
63
96
|
api.registerTool(createClusterWriteTool(), { optional: true });
|
|
64
97
|
api.registerTool(createClusterToolTool(), { optional: true });
|
|
65
98
|
|
|
99
|
+
// Gateway methods (queried by CLI via `openclaw gateway call`)
|
|
100
|
+
api.registerGatewayMethod(
|
|
101
|
+
"clawmatrix.status",
|
|
102
|
+
({ respond }: GatewayRequestHandlerOptions) => {
|
|
103
|
+
try {
|
|
104
|
+
const runtime = getClusterRuntime();
|
|
105
|
+
const peers = runtime.peerManager.router.getAllPeers();
|
|
106
|
+
respond(true, {
|
|
107
|
+
nodeId: config.nodeId,
|
|
108
|
+
listen: config.listen ? config.listenPort : false,
|
|
109
|
+
proxyPort: config.proxyPort,
|
|
110
|
+
agents: config.agents.map((a) => ({ id: a.id, description: a.description })),
|
|
111
|
+
models: config.models.map((m) => ({ id: m.id })),
|
|
112
|
+
tags: config.tags,
|
|
113
|
+
peers: peers.map((p) => ({
|
|
114
|
+
nodeId: p.nodeId,
|
|
115
|
+
agents: p.agents,
|
|
116
|
+
models: p.models,
|
|
117
|
+
tags: p.tags,
|
|
118
|
+
connected: !!p.connection?.isOpen,
|
|
119
|
+
reachableVia: p.reachableVia,
|
|
120
|
+
latencyMs: p.latencyMs,
|
|
121
|
+
})),
|
|
122
|
+
});
|
|
123
|
+
} catch {
|
|
124
|
+
respond(false, { error: "ClawMatrix service not running" });
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
api.registerGatewayMethod(
|
|
130
|
+
"clawmatrix.peers",
|
|
131
|
+
({ respond }: GatewayRequestHandlerOptions) => {
|
|
132
|
+
try {
|
|
133
|
+
const runtime = getClusterRuntime();
|
|
134
|
+
const peers = runtime.peerManager.router.getAllPeers().map((p) => ({
|
|
135
|
+
nodeId: p.nodeId,
|
|
136
|
+
agents: p.agents,
|
|
137
|
+
models: p.models,
|
|
138
|
+
tags: p.tags,
|
|
139
|
+
connected: !!p.connection?.isOpen,
|
|
140
|
+
reachableVia: p.reachableVia,
|
|
141
|
+
latencyMs: p.latencyMs,
|
|
142
|
+
}));
|
|
143
|
+
respond(true, peers);
|
|
144
|
+
} catch {
|
|
145
|
+
respond(true, []);
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
);
|
|
149
|
+
|
|
66
150
|
// CLI subcommand
|
|
67
151
|
api.registerCli(registerClusterCli, { commands: ["clawmatrix"] });
|
|
68
152
|
|
|
@@ -117,17 +201,8 @@ const plugin = {
|
|
|
117
201
|
},
|
|
118
202
|
};
|
|
119
203
|
|
|
120
|
-
function
|
|
121
|
-
|
|
122
|
-
// proxyModels = remote models this node wants to consume from the cluster
|
|
123
|
-
// Both need to be registered with the OpenClaw provider so it routes requests to our local proxy.
|
|
124
|
-
const seen = new Set<string>();
|
|
125
|
-
const all = [...config.models, ...config.proxyModels].filter((m) => {
|
|
126
|
-
if (seen.has(m.id)) return false;
|
|
127
|
-
seen.add(m.id);
|
|
128
|
-
return true;
|
|
129
|
-
});
|
|
130
|
-
return all.map((m) => ({
|
|
204
|
+
function formatModel(m: { id: string; description?: string; api?: string; reasoning?: boolean; input?: string[]; cost?: { input: number; output: number; cacheRead: number; cacheWrite: number }; contextWindow?: number; maxTokens?: number; compat?: Record<string, unknown> }) {
|
|
205
|
+
const result: Record<string, unknown> = {
|
|
131
206
|
id: m.id,
|
|
132
207
|
name: m.description ?? m.id,
|
|
133
208
|
api: m.api ?? ("openai-completions" as const),
|
|
@@ -136,7 +211,27 @@ function getAllClusterModels(config: ReturnType<typeof parseConfig>) {
|
|
|
136
211
|
cost: m.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
137
212
|
contextWindow: m.contextWindow ?? 128_000,
|
|
138
213
|
maxTokens: m.maxTokens ?? 4096,
|
|
139
|
-
}
|
|
214
|
+
};
|
|
215
|
+
if (m.compat) result.compat = m.compat;
|
|
216
|
+
return result;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function groupModelsByNode(config: ReturnType<typeof parseConfig>): Record<string, ReturnType<typeof formatModel>[]> {
|
|
220
|
+
const result: Record<string, ReturnType<typeof formatModel>[]> = {};
|
|
221
|
+
|
|
222
|
+
// Local models served by this node
|
|
223
|
+
for (const m of config.models) {
|
|
224
|
+
const nodeId = config.nodeId;
|
|
225
|
+
(result[nodeId] ??= []).push(formatModel(m));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Remote models consumed from peers (proxyModels have nodeId)
|
|
229
|
+
for (const m of config.proxyModels) {
|
|
230
|
+
const nodeId = m.nodeId;
|
|
231
|
+
(result[nodeId] ??= []).push(formatModel(m));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return result;
|
|
140
235
|
}
|
|
141
236
|
|
|
142
237
|
export default plugin;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local tool execution for exec/read/write/edit.
|
|
3
|
+
*
|
|
4
|
+
* These tools cannot go through the Gateway HTTP `/tools/invoke` endpoint
|
|
5
|
+
* because it only exposes `createOpenClawTools()` (safe tools), not
|
|
6
|
+
* `createOpenClawCodingTools()` (which includes exec/read/write/edit).
|
|
7
|
+
*
|
|
8
|
+
* Instead, we execute them directly in the same Node.js process.
|
|
9
|
+
* - exec: simplified child_process.spawn (no sandbox/PTY/background)
|
|
10
|
+
* - read/write/edit: reuse @mariozechner/pi-coding-agent factories
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
14
|
+
import {
|
|
15
|
+
createReadTool,
|
|
16
|
+
createWriteTool,
|
|
17
|
+
createEditTool,
|
|
18
|
+
} from "@mariozechner/pi-coding-agent";
|
|
19
|
+
|
|
20
|
+
// ── Types ──────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
interface ExecParams {
|
|
23
|
+
command: string;
|
|
24
|
+
workdir?: string;
|
|
25
|
+
env?: Record<string, string>;
|
|
26
|
+
timeout?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type ToolResult = Record<string, unknown>;
|
|
30
|
+
|
|
31
|
+
// ── Constants ──────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
const LOCAL_TOOLS = new Set(["exec", "read", "write", "edit"]);
|
|
34
|
+
const DEFAULT_EXEC_TIMEOUT = 300; // seconds
|
|
35
|
+
const MAX_OUTPUT_BYTES = 512 * 1024; // 512KB
|
|
36
|
+
|
|
37
|
+
// ── Public API ─────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
export function isLocalTool(tool: string): boolean {
|
|
40
|
+
return LOCAL_TOOLS.has(tool);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function executeLocally(
|
|
44
|
+
tool: string,
|
|
45
|
+
params: Record<string, unknown>,
|
|
46
|
+
): Promise<ToolResult> {
|
|
47
|
+
switch (tool) {
|
|
48
|
+
case "exec":
|
|
49
|
+
return executeExec(params as unknown as ExecParams);
|
|
50
|
+
case "read":
|
|
51
|
+
return executePiTool("read", params);
|
|
52
|
+
case "write":
|
|
53
|
+
return executePiTool("write", params);
|
|
54
|
+
case "edit":
|
|
55
|
+
return executePiTool("edit", params);
|
|
56
|
+
default:
|
|
57
|
+
throw new Error(`Unknown local tool: ${tool}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── exec: simplified child_process.spawn ───────────────────────────
|
|
62
|
+
|
|
63
|
+
async function executeExec(params: ExecParams): Promise<ToolResult> {
|
|
64
|
+
const { command, workdir, env, timeout } = params;
|
|
65
|
+
if (!command) throw new Error("exec: command is required");
|
|
66
|
+
|
|
67
|
+
const shell = process.env.SHELL || "/bin/bash";
|
|
68
|
+
const timeoutMs = (timeout ?? DEFAULT_EXEC_TIMEOUT) * 1000;
|
|
69
|
+
|
|
70
|
+
return new Promise<ToolResult>((resolve, reject) => {
|
|
71
|
+
const child = spawn(shell, ["-c", command], {
|
|
72
|
+
cwd: workdir || process.cwd(),
|
|
73
|
+
env: env ? { ...process.env, ...env } : process.env,
|
|
74
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
75
|
+
detached: true,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
let stdout = "";
|
|
79
|
+
let stderr = "";
|
|
80
|
+
let killed = false;
|
|
81
|
+
|
|
82
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
83
|
+
stdout += chunk.toString();
|
|
84
|
+
if (stdout.length > MAX_OUTPUT_BYTES) {
|
|
85
|
+
stdout = stdout.slice(0, MAX_OUTPUT_BYTES);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
90
|
+
stderr += chunk.toString();
|
|
91
|
+
if (stderr.length > MAX_OUTPUT_BYTES) {
|
|
92
|
+
stderr = stderr.slice(0, MAX_OUTPUT_BYTES);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const timer = setTimeout(() => {
|
|
97
|
+
killed = true;
|
|
98
|
+
// Kill the process group
|
|
99
|
+
try {
|
|
100
|
+
process.kill(-child.pid!, "SIGKILL");
|
|
101
|
+
} catch {
|
|
102
|
+
child.kill("SIGKILL");
|
|
103
|
+
}
|
|
104
|
+
}, timeoutMs);
|
|
105
|
+
|
|
106
|
+
child.on("close", (code, signal) => {
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
const truncated =
|
|
109
|
+
stdout.length >= MAX_OUTPUT_BYTES || stderr.length >= MAX_OUTPUT_BYTES;
|
|
110
|
+
resolve({
|
|
111
|
+
exitCode: code ?? 1,
|
|
112
|
+
stdout,
|
|
113
|
+
stderr,
|
|
114
|
+
...(signal && { signal }),
|
|
115
|
+
...(killed && { timedOut: true }),
|
|
116
|
+
...(truncated && { truncated: true }),
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
child.on("error", (err) => {
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
reject(new Error(`exec: failed to spawn: ${err.message}`));
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── read/write/edit: reuse pi-coding-agent factories ───────────────
|
|
128
|
+
|
|
129
|
+
const piToolCache = new Map<string, { execute: Function }>();
|
|
130
|
+
|
|
131
|
+
function getPiTool(name: string): { execute: Function } {
|
|
132
|
+
let tool = piToolCache.get(name);
|
|
133
|
+
if (tool) return tool;
|
|
134
|
+
|
|
135
|
+
const cwd = process.cwd();
|
|
136
|
+
switch (name) {
|
|
137
|
+
case "read":
|
|
138
|
+
tool = createReadTool(cwd);
|
|
139
|
+
break;
|
|
140
|
+
case "write":
|
|
141
|
+
tool = createWriteTool(cwd);
|
|
142
|
+
break;
|
|
143
|
+
case "edit":
|
|
144
|
+
tool = createEditTool(cwd);
|
|
145
|
+
break;
|
|
146
|
+
default:
|
|
147
|
+
throw new Error(`Unknown pi tool: ${name}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
piToolCache.set(name, tool);
|
|
151
|
+
return tool;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function executePiTool(
|
|
155
|
+
name: string,
|
|
156
|
+
params: Record<string, unknown>,
|
|
157
|
+
): Promise<ToolResult> {
|
|
158
|
+
const tool = getPiTool(name);
|
|
159
|
+
const toolCallId = crypto.randomUUID();
|
|
160
|
+
|
|
161
|
+
const result = (await tool.execute(toolCallId, params)) as {
|
|
162
|
+
content: Array<{ type: string; text?: string }>;
|
|
163
|
+
details: unknown;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// Flatten content array to a single text result
|
|
167
|
+
const text = result.content
|
|
168
|
+
.filter((c) => c.type === "text" && c.text)
|
|
169
|
+
.map((c) => c.text)
|
|
170
|
+
.join("\n");
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
content: text,
|
|
174
|
+
details: result.details,
|
|
175
|
+
};
|
|
176
|
+
}
|