clawmatrix 0.1.8 → 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/package.json +6 -2
- package/src/cli.ts +50 -25
- package/src/cluster-service.ts +8 -1
- package/src/config.ts +16 -0
- package/src/connection.ts +1 -0
- package/src/debug.ts +5 -0
- package/src/index.ts +30 -13
- package/src/local-tools.ts +176 -0
- package/src/model-proxy.ts +179 -69
- package/src/peer-manager.ts +32 -5
- package/src/router.ts +18 -3
- package/src/tool-proxy.ts +4 -1
- package/src/types.ts +16 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clawmatrix",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
4
4
|
"description": "Decentralized mesh cluster plugin for OpenClaw — inter-gateway communication, model proxy, task handoff, and tool proxy.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,7 +28,11 @@
|
|
|
28
28
|
},
|
|
29
29
|
"scripts": {
|
|
30
30
|
"test": "bun test",
|
|
31
|
-
"prepublishOnly": "bun test"
|
|
31
|
+
"prepublishOnly": "bun test",
|
|
32
|
+
"release": "bun scripts/release.ts",
|
|
33
|
+
"release:minor": "bun scripts/release.ts minor",
|
|
34
|
+
"release:major": "bun scripts/release.ts major",
|
|
35
|
+
"release:dry": "bun scripts/release.ts --dry-run"
|
|
32
36
|
},
|
|
33
37
|
"dependencies": {
|
|
34
38
|
"ws": "^8.19.0",
|
package/src/cli.ts
CHANGED
|
@@ -62,21 +62,32 @@ export const registerClusterCli = ({ program }: { program: Command }) => {
|
|
|
62
62
|
return;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
// Style helpers
|
|
66
|
+
const bold = (s: string) => `\x1b[1m${s}\x1b[22m`;
|
|
67
|
+
const dim = (s: string) => `\x1b[2m${s}\x1b[22m`;
|
|
68
|
+
const green = (s: string) => `\x1b[32m${s}\x1b[39m`;
|
|
69
|
+
const red = (s: string) => `\x1b[31m${s}\x1b[39m`;
|
|
70
|
+
const cyan = (s: string) => `\x1b[36m${s}\x1b[39m`;
|
|
71
|
+
const yellow = (s: string) => `\x1b[33m${s}\x1b[39m`;
|
|
72
|
+
const bar = dim("│");
|
|
73
|
+
const lbl = (text: string) => dim(text.padEnd(13));
|
|
74
|
+
|
|
65
75
|
const agents = data.agents as Array<{ id: string }>;
|
|
66
76
|
const models = data.models as Array<{ id: string }>;
|
|
67
77
|
const tags = data.tags as string[];
|
|
68
78
|
|
|
69
|
-
|
|
70
|
-
console.log(
|
|
71
|
-
console.log("");
|
|
72
|
-
console.log(`
|
|
79
|
+
// Local node
|
|
80
|
+
console.log();
|
|
81
|
+
console.log(` ${cyan("◆")} ${bold("ClawMatrix Cluster")}`);
|
|
82
|
+
console.log(` ${bar}`);
|
|
83
|
+
console.log(` ${bar} ${lbl("Node")}${bold(String(data.nodeId))}`);
|
|
73
84
|
if (tags.length > 0) {
|
|
74
|
-
console.log(` Tags
|
|
85
|
+
console.log(` ${bar} ${lbl("Tags")}${tags.join(dim(", "))}`);
|
|
75
86
|
}
|
|
76
|
-
console.log(` Listen
|
|
77
|
-
console.log(` Model
|
|
78
|
-
console.log(` Agents
|
|
79
|
-
console.log(` Models
|
|
87
|
+
console.log(` ${bar} ${lbl("Listen")}${data.listen !== false ? `:${data.listen}` : dim("disabled")}`);
|
|
88
|
+
console.log(` ${bar} ${lbl("Model Proxy")}:${data.proxyPort}`);
|
|
89
|
+
console.log(` ${bar} ${lbl("Agents")}${agents.map((a) => a.id).join(dim(", ")) || dim("–")}`);
|
|
90
|
+
console.log(` ${bar} ${lbl("Models")}${models.map((m) => m.id).join(dim(", ")) || dim("–")}`);
|
|
80
91
|
|
|
81
92
|
const peers = data.peers as Array<{
|
|
82
93
|
nodeId: string;
|
|
@@ -87,34 +98,48 @@ export const registerClusterCli = ({ program }: { program: Command }) => {
|
|
|
87
98
|
latencyMs: number;
|
|
88
99
|
}>;
|
|
89
100
|
|
|
90
|
-
const connected = peers.filter((p) => p.connected).length;
|
|
91
|
-
console.log("");
|
|
92
101
|
if (!peers || peers.length === 0) {
|
|
93
|
-
console.log(
|
|
94
|
-
console.log("");
|
|
102
|
+
console.log(` ${bar}`);
|
|
103
|
+
console.log(` ${dim("◇")} ${dim("No peers discovered")}`);
|
|
104
|
+
console.log();
|
|
95
105
|
return;
|
|
96
106
|
}
|
|
97
107
|
|
|
98
|
-
|
|
99
|
-
|
|
108
|
+
const connected = peers.filter((p) => p.connected).length;
|
|
109
|
+
const countStr = `${connected}/${peers.length} connected`;
|
|
110
|
+
const countColor = connected === peers.length ? green : connected > 0 ? yellow : red;
|
|
111
|
+
|
|
112
|
+
console.log(` ${bar}`);
|
|
113
|
+
console.log(` ${cyan("◆")} ${bold("Peers")} ${countColor(countStr)}`);
|
|
114
|
+
console.log(` ${bar}`);
|
|
115
|
+
|
|
116
|
+
for (let i = 0; i < peers.length; i++) {
|
|
117
|
+
const peer = peers[i];
|
|
118
|
+
const dot = peer.connected ? green("●") : red("○");
|
|
119
|
+
const latency = peer.connected && peer.latencyMs > 0 ? dim(` ${peer.latencyMs}ms`) : "";
|
|
120
|
+
const status = peer.connected ? "" : red(" disconnected");
|
|
121
|
+
console.log(` ${bar} ${dot} ${bold(peer.nodeId)}${status}${latency}`);
|
|
100
122
|
|
|
101
|
-
for (const peer of peers) {
|
|
102
|
-
const status = peer.connected ? "connected" : "unreachable";
|
|
103
|
-
const latency = peer.latencyMs > 0 ? `, ${peer.latencyMs}ms` : "";
|
|
104
|
-
console.log(` ${peer.nodeId} (${status}${latency})`);
|
|
105
123
|
if (peer.tags.length > 0) {
|
|
106
|
-
console.log(` Tags
|
|
124
|
+
console.log(` ${bar} ${lbl("Tags")}${peer.tags.join(dim(", "))}`);
|
|
107
125
|
}
|
|
108
|
-
const peerAgents = peer.agents.map((a) => a.id).join(", ");
|
|
126
|
+
const peerAgents = peer.agents.map((a) => a.id).join(dim(", "));
|
|
109
127
|
if (peerAgents) {
|
|
110
|
-
console.log(` Agents
|
|
128
|
+
console.log(` ${bar} ${lbl("Agents")}${peerAgents}`);
|
|
111
129
|
}
|
|
112
|
-
const peerModels = peer.models.map((m) => m.id).join(", ");
|
|
130
|
+
const peerModels = peer.models.map((m) => m.id).join(dim(", "));
|
|
113
131
|
if (peerModels) {
|
|
114
|
-
console.log(` Models
|
|
132
|
+
console.log(` ${bar} ${lbl("Models")}${peerModels}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (i < peers.length - 1) {
|
|
136
|
+
console.log(` ${bar}`);
|
|
115
137
|
}
|
|
116
138
|
}
|
|
117
|
-
|
|
139
|
+
|
|
140
|
+
console.log(` ${bar}`);
|
|
141
|
+
console.log(` ${dim("◇")}`);
|
|
142
|
+
console.log();
|
|
118
143
|
});
|
|
119
144
|
|
|
120
145
|
cmd
|
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";
|
|
@@ -88,6 +89,9 @@ export class ClusterRuntime {
|
|
|
88
89
|
}
|
|
89
90
|
|
|
90
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
|
+
}
|
|
91
95
|
switch (frame.type) {
|
|
92
96
|
case "handoff_req":
|
|
93
97
|
this.handoffManager.handleRequest(frame as HandoffRequest).catch((err) => {
|
|
@@ -143,10 +147,13 @@ export class ClusterRuntime {
|
|
|
143
147
|
}
|
|
144
148
|
|
|
145
149
|
// Fire-and-forget: inject message via openclaw agent CLI
|
|
146
|
-
spawnProcess(["openclaw", "agent", "--agent", agent.id, "--message", message], {
|
|
150
|
+
const proc = spawnProcess(["openclaw", "agent", "--agent", agent.id, "--message", message], {
|
|
147
151
|
stdout: "ignore",
|
|
148
152
|
stderr: "ignore",
|
|
149
153
|
});
|
|
154
|
+
proc.exited.catch((err) => {
|
|
155
|
+
this.logger.error(`[clawmatrix] Failed to inject message to agent "${agent.id}": ${err}`);
|
|
156
|
+
});
|
|
150
157
|
}
|
|
151
158
|
}
|
|
152
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({
|
package/src/connection.ts
CHANGED
package/src/debug.ts
ADDED
package/src/index.ts
CHANGED
|
@@ -45,20 +45,35 @@ const plugin = {
|
|
|
45
45
|
const baseUrl = `http://127.0.0.1:${config.proxyPort}/v1`;
|
|
46
46
|
const modelsByNode = groupModelsByNode(config);
|
|
47
47
|
|
|
48
|
-
// Patch openclaw config so auth resolution can find a dummy API key
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
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
|
+
};
|
|
52
62
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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>);
|
|
60
71
|
}
|
|
72
|
+
} catch {
|
|
73
|
+
// Best-effort; api.config patch is the fallback
|
|
74
|
+
}
|
|
61
75
|
|
|
76
|
+
for (const [nodeId, models] of Object.entries(modelsByNode)) {
|
|
62
77
|
api.registerProvider({
|
|
63
78
|
id: nodeId,
|
|
64
79
|
label: `ClawMatrix: ${nodeId}`,
|
|
@@ -186,8 +201,8 @@ const plugin = {
|
|
|
186
201
|
},
|
|
187
202
|
};
|
|
188
203
|
|
|
189
|
-
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 }) {
|
|
190
|
-
|
|
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> = {
|
|
191
206
|
id: m.id,
|
|
192
207
|
name: m.description ?? m.id,
|
|
193
208
|
api: m.api ?? ("openai-completions" as const),
|
|
@@ -197,6 +212,8 @@ function formatModel(m: { id: string; description?: string; api?: string; reason
|
|
|
197
212
|
contextWindow: m.contextWindow ?? 128_000,
|
|
198
213
|
maxTokens: m.maxTokens ?? 4096,
|
|
199
214
|
};
|
|
215
|
+
if (m.compat) result.compat = m.compat;
|
|
216
|
+
return result;
|
|
200
217
|
}
|
|
201
218
|
|
|
202
219
|
function groupModelsByNode(config: ReturnType<typeof parseConfig>): Record<string, ReturnType<typeof formatModel>[]> {
|
|
@@ -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
|
+
}
|
package/src/model-proxy.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
ModelResponse,
|
|
8
8
|
ModelStreamChunk,
|
|
9
9
|
} from "./types.ts";
|
|
10
|
+
import { debug } from "./debug.ts";
|
|
10
11
|
|
|
11
12
|
const MODEL_TIMEOUT = 120_000; // 2 minutes
|
|
12
13
|
|
|
@@ -35,21 +36,37 @@ export class ModelProxy {
|
|
|
35
36
|
/** Start the local HTTP proxy server for OpenAI-compatible requests. */
|
|
36
37
|
start() {
|
|
37
38
|
this.httpServer = createServer(async (req, res) => {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
39
|
+
try {
|
|
40
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
41
|
+
|
|
42
|
+
const p = url.pathname.replace(/^\/v1/, "");
|
|
43
|
+
debug("proxy", `${req.method} ${url.pathname} → ${p}`);
|
|
44
|
+
|
|
45
|
+
if (p === "/chat/completions" && req.method === "POST") {
|
|
46
|
+
const body = await this.readBody(req);
|
|
47
|
+
const response = await this.handleChatCompletion(body);
|
|
48
|
+
debug("proxy", `response status=${response.status}`);
|
|
49
|
+
this.sendResponse(res, response);
|
|
50
|
+
} else if (p === "/models" && req.method === "GET") {
|
|
51
|
+
const response = this.handleListModels();
|
|
52
|
+
this.sendResponse(res, response);
|
|
53
|
+
} else {
|
|
54
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
55
|
+
res.end(JSON.stringify({ error: { message: `No handler for ${req.method} ${url.pathname}` } }));
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
if (!res.headersSent) {
|
|
59
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
60
|
+
}
|
|
61
|
+
res.end(JSON.stringify({ error: { message: "Internal proxy error" } }));
|
|
50
62
|
}
|
|
51
63
|
});
|
|
52
64
|
|
|
65
|
+
this.httpServer.on("error", (err) => {
|
|
66
|
+
// Log but don't crash — port conflict or other server error
|
|
67
|
+
console.error(`[clawmatrix] Model proxy server error: ${err.message}`);
|
|
68
|
+
});
|
|
69
|
+
|
|
53
70
|
this.httpServer.listen(this.config.proxyPort, "127.0.0.1");
|
|
54
71
|
}
|
|
55
72
|
|
|
@@ -84,12 +101,16 @@ export class ModelProxy {
|
|
|
84
101
|
const pump = (): void => {
|
|
85
102
|
reader.read().then(({ done, value }) => {
|
|
86
103
|
if (done) {
|
|
104
|
+
reader.releaseLock();
|
|
87
105
|
res.end();
|
|
88
106
|
return;
|
|
89
107
|
}
|
|
90
108
|
res.write(value);
|
|
91
109
|
pump();
|
|
92
|
-
}).catch(() =>
|
|
110
|
+
}).catch(() => {
|
|
111
|
+
reader.releaseLock();
|
|
112
|
+
res.end();
|
|
113
|
+
});
|
|
93
114
|
};
|
|
94
115
|
pump();
|
|
95
116
|
}
|
|
@@ -115,17 +136,32 @@ export class ModelProxy {
|
|
|
115
136
|
};
|
|
116
137
|
}
|
|
117
138
|
|
|
118
|
-
const
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
139
|
+
const rawModelId = body.model;
|
|
140
|
+
// Parse "nodeId/model" format: first segment is nodeId, rest is model ID.
|
|
141
|
+
// OpenClaw sends "providerId/modelId" where providerId = nodeId, so this
|
|
142
|
+
// naturally handles both OpenClaw calls and direct curl calls.
|
|
143
|
+
// If no "/" present, treat entire string as model ID and auto-resolve.
|
|
144
|
+
let nodeId: string | undefined;
|
|
145
|
+
let modelId: string;
|
|
146
|
+
const slashIdx = rawModelId.indexOf("/");
|
|
147
|
+
if (slashIdx > 0) {
|
|
148
|
+
nodeId = rawModelId.slice(0, slashIdx);
|
|
149
|
+
modelId = rawModelId.slice(slashIdx + 1);
|
|
150
|
+
} else {
|
|
151
|
+
modelId = rawModelId;
|
|
152
|
+
}
|
|
153
|
+
debug("proxy", `model raw="${rawModelId}" nodeId=${nodeId ?? "auto"} modelId="${modelId}" stream=${body.stream ?? false}`);
|
|
154
|
+
const proxyModel = this.config.proxyModels.find((m) => m.id === modelId && (!nodeId || m.nodeId === nodeId))
|
|
155
|
+
?? this.config.proxyModels.find((m) => m.id === modelId);
|
|
156
|
+
const route = nodeId
|
|
157
|
+
? this.peerManager.router.getRoute(nodeId)
|
|
123
158
|
: this.peerManager.router.resolveModel(modelId);
|
|
159
|
+
debug("proxy", `proxyModel=${proxyModel?.id ?? "none"} route=${route?.nodeId ?? "none"} reachable=${route ? this.peerManager.canReach(route.nodeId) : false}`);
|
|
124
160
|
if (!route) {
|
|
125
161
|
return {
|
|
126
162
|
status: 404,
|
|
127
163
|
headers: { "Content-Type": "application/json" },
|
|
128
|
-
body: JSON.stringify({ error: { message: `Model "${modelId}" not found in cluster` } }),
|
|
164
|
+
body: JSON.stringify({ error: { message: `Model "${modelId}" not found in cluster (proxyModels: [${this.config.proxyModels.map(m => m.id).join(", ")}])` } }),
|
|
129
165
|
};
|
|
130
166
|
}
|
|
131
167
|
|
|
@@ -158,6 +194,15 @@ export class ModelProxy {
|
|
|
158
194
|
},
|
|
159
195
|
};
|
|
160
196
|
|
|
197
|
+
// Pre-check reachability before starting a stream (avoids silent empty response)
|
|
198
|
+
if (!this.peerManager.canReach(route.nodeId)) {
|
|
199
|
+
return {
|
|
200
|
+
status: 502,
|
|
201
|
+
headers: { "Content-Type": "application/json" },
|
|
202
|
+
body: JSON.stringify({ error: { message: `Cannot reach model node "${route.nodeId}"` } }),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
161
206
|
if (stream) {
|
|
162
207
|
return this.handleStreamRequest(requestId, route.nodeId, frame);
|
|
163
208
|
} else {
|
|
@@ -178,8 +223,13 @@ export class ModelProxy {
|
|
|
178
223
|
this.pending.delete(requestId);
|
|
179
224
|
this.peerManager.router.markFailed(requestId);
|
|
180
225
|
try {
|
|
226
|
+
const errorChunk = {
|
|
227
|
+
id: `chatcmpl-${requestId}`,
|
|
228
|
+
object: "chat.completion.chunk",
|
|
229
|
+
choices: [{ index: 0, delta: { content: "\n\n[ClawMatrix] Error: model request timed out" }, finish_reason: "stop" }],
|
|
230
|
+
};
|
|
181
231
|
controller.enqueue(
|
|
182
|
-
encoder.encode(`data: ${JSON.stringify(
|
|
232
|
+
encoder.encode(`data: ${JSON.stringify(errorChunk)}\n\n`),
|
|
183
233
|
);
|
|
184
234
|
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
|
185
235
|
controller.close();
|
|
@@ -201,10 +251,13 @@ export class ModelProxy {
|
|
|
201
251
|
if (!sent) {
|
|
202
252
|
this.pending.delete(requestId);
|
|
203
253
|
clearTimeout(timer);
|
|
254
|
+
const errChunk = {
|
|
255
|
+
id: `chatcmpl-${requestId}`,
|
|
256
|
+
object: "chat.completion.chunk",
|
|
257
|
+
choices: [{ index: 0, delta: { content: `[ClawMatrix] Cannot reach model node "${targetNodeId}"` }, finish_reason: "stop" }],
|
|
258
|
+
};
|
|
204
259
|
controller.enqueue(
|
|
205
|
-
encoder.encode(
|
|
206
|
-
`data: ${JSON.stringify({ error: "Cannot reach model node" })}\n\n`,
|
|
207
|
-
),
|
|
260
|
+
encoder.encode(`data: ${JSON.stringify(errChunk)}\n\n`),
|
|
208
261
|
);
|
|
209
262
|
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
|
210
263
|
controller.close();
|
|
@@ -317,7 +370,31 @@ export class ModelProxy {
|
|
|
317
370
|
handleModelResponse(frame: ModelResponse) {
|
|
318
371
|
if (this.peerManager.router.isFailed(frame.id)) return;
|
|
319
372
|
const pending = this.pending.get(frame.id);
|
|
320
|
-
if (!pending
|
|
373
|
+
if (!pending) return;
|
|
374
|
+
|
|
375
|
+
// For stream requests, handle error responses (the remote node couldn't
|
|
376
|
+
// process the request and sent model_res instead of model_stream).
|
|
377
|
+
if (pending.stream) {
|
|
378
|
+
if (!frame.payload.success && pending.controller && pending.encoder) {
|
|
379
|
+
clearTimeout(pending.timer);
|
|
380
|
+
this.pending.delete(frame.id);
|
|
381
|
+
try {
|
|
382
|
+
const errChunk = {
|
|
383
|
+
id: `chatcmpl-${frame.id}`,
|
|
384
|
+
object: "chat.completion.chunk",
|
|
385
|
+
choices: [{ index: 0, delta: { content: `[ClawMatrix] Remote error: ${frame.payload.error}` }, finish_reason: "stop" }],
|
|
386
|
+
};
|
|
387
|
+
pending.controller.enqueue(
|
|
388
|
+
pending.encoder.encode(`data: ${JSON.stringify(errChunk)}\n\n`),
|
|
389
|
+
);
|
|
390
|
+
pending.controller.enqueue(pending.encoder.encode("data: [DONE]\n\n"));
|
|
391
|
+
pending.controller.close();
|
|
392
|
+
} catch {
|
|
393
|
+
// controller may already be closed
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
321
398
|
|
|
322
399
|
clearTimeout(pending.timer);
|
|
323
400
|
this.pending.delete(frame.id);
|
|
@@ -325,27 +402,28 @@ export class ModelProxy {
|
|
|
325
402
|
}
|
|
326
403
|
|
|
327
404
|
handleModelStream(frame: ModelStreamChunk) {
|
|
405
|
+
debug("stream", `id=${frame.id} done=${frame.payload.done} delta=${JSON.stringify(frame.payload.delta?.slice?.(0, 50) ?? frame.payload.delta)} failed=${this.peerManager.router.isFailed(frame.id)} hasPending=${this.pending.has(frame.id)}`);
|
|
328
406
|
if (this.peerManager.router.isFailed(frame.id)) return;
|
|
329
407
|
const pending = this.pending.get(frame.id);
|
|
330
408
|
if (!pending?.stream || !pending.controller || !pending.encoder) return;
|
|
331
409
|
|
|
332
410
|
try {
|
|
333
411
|
if (frame.payload.done) {
|
|
412
|
+
const finalChunk: Record<string, unknown> = {
|
|
413
|
+
id: `chatcmpl-${frame.id}`,
|
|
414
|
+
object: "chat.completion.chunk",
|
|
415
|
+
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
|
416
|
+
};
|
|
334
417
|
if (frame.payload.usage) {
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
usage: {
|
|
340
|
-
prompt_tokens: frame.payload.usage.inputTokens,
|
|
341
|
-
completion_tokens: frame.payload.usage.outputTokens,
|
|
342
|
-
total_tokens: frame.payload.usage.inputTokens + frame.payload.usage.outputTokens,
|
|
343
|
-
},
|
|
418
|
+
finalChunk.usage = {
|
|
419
|
+
prompt_tokens: frame.payload.usage.inputTokens,
|
|
420
|
+
completion_tokens: frame.payload.usage.outputTokens,
|
|
421
|
+
total_tokens: frame.payload.usage.inputTokens + frame.payload.usage.outputTokens,
|
|
344
422
|
};
|
|
345
|
-
pending.controller.enqueue(
|
|
346
|
-
pending.encoder.encode(`data: ${JSON.stringify(usageChunk)}\n\n`),
|
|
347
|
-
);
|
|
348
423
|
}
|
|
424
|
+
pending.controller.enqueue(
|
|
425
|
+
pending.encoder.encode(`data: ${JSON.stringify(finalChunk)}\n\n`),
|
|
426
|
+
);
|
|
349
427
|
pending.controller.enqueue(pending.encoder.encode("data: [DONE]\n\n"));
|
|
350
428
|
pending.controller.close();
|
|
351
429
|
clearTimeout(pending.timer);
|
|
@@ -375,6 +453,7 @@ export class ModelProxy {
|
|
|
375
453
|
/** Handle model_req locally: forward to OpenClaw's configured model provider. */
|
|
376
454
|
async handleModelRequest(frame: ModelRequest): Promise<void> {
|
|
377
455
|
const { id, from, payload } = frame;
|
|
456
|
+
debug("model_req", `handling model="${payload.model}" from=${from} stream=${payload.stream}`);
|
|
378
457
|
|
|
379
458
|
const model = this.config.models.find((m) => m.id === payload.model);
|
|
380
459
|
if (!model) {
|
|
@@ -403,57 +482,88 @@ export class ModelProxy {
|
|
|
403
482
|
temperature: payload.temperature,
|
|
404
483
|
max_tokens: payload.maxTokens,
|
|
405
484
|
stream: payload.stream,
|
|
485
|
+
...(payload.stream ? { stream_options: { include_usage: true } } : {}),
|
|
406
486
|
}),
|
|
407
487
|
});
|
|
408
488
|
|
|
489
|
+
if (!response.ok) {
|
|
490
|
+
const errBody = await response.text();
|
|
491
|
+
throw new Error(`Upstream ${response.status}: ${errBody.slice(0, 200)}`);
|
|
492
|
+
}
|
|
493
|
+
|
|
409
494
|
if (payload.stream) {
|
|
410
495
|
const reader = response.body?.getReader();
|
|
411
496
|
if (!reader) throw new Error("No response body");
|
|
412
497
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
if (done) break;
|
|
419
|
-
|
|
420
|
-
buffer += decoder.decode(value, { stream: true });
|
|
421
|
-
const lines = buffer.split("\n");
|
|
422
|
-
buffer = lines.pop()!;
|
|
423
|
-
|
|
424
|
-
for (const line of lines) {
|
|
425
|
-
if (!line.startsWith("data: ")) continue;
|
|
426
|
-
const data = line.slice(6).trim();
|
|
427
|
-
if (data === "[DONE]") {
|
|
428
|
-
this.peerManager.sendTo(from, {
|
|
429
|
-
type: "model_stream",
|
|
430
|
-
id,
|
|
431
|
-
from: this.config.nodeId,
|
|
432
|
-
to: from,
|
|
433
|
-
timestamp: Date.now(),
|
|
434
|
-
payload: { delta: "", done: true },
|
|
435
|
-
} satisfies ModelStreamChunk);
|
|
436
|
-
break;
|
|
437
|
-
}
|
|
498
|
+
try {
|
|
499
|
+
const decoder = new TextDecoder();
|
|
500
|
+
let buffer = "";
|
|
501
|
+
let lastUsage: { inputTokens: number; outputTokens: number } | undefined;
|
|
502
|
+
let streamDone = false;
|
|
438
503
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
504
|
+
while (!streamDone) {
|
|
505
|
+
const { done, value } = await reader.read();
|
|
506
|
+
if (done) break;
|
|
507
|
+
|
|
508
|
+
buffer += decoder.decode(value, { stream: true });
|
|
509
|
+
const lines = buffer.split("\n");
|
|
510
|
+
buffer = lines.pop()!;
|
|
511
|
+
|
|
512
|
+
for (const line of lines) {
|
|
513
|
+
if (!line.startsWith("data: ")) continue;
|
|
514
|
+
const data = line.slice(6).trim();
|
|
515
|
+
if (data === "[DONE]") {
|
|
444
516
|
this.peerManager.sendTo(from, {
|
|
445
517
|
type: "model_stream",
|
|
446
518
|
id,
|
|
447
519
|
from: this.config.nodeId,
|
|
448
520
|
to: from,
|
|
449
521
|
timestamp: Date.now(),
|
|
450
|
-
payload: { delta, done:
|
|
522
|
+
payload: { delta: "", done: true, usage: lastUsage },
|
|
451
523
|
} satisfies ModelStreamChunk);
|
|
524
|
+
streamDone = true;
|
|
525
|
+
break;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
try {
|
|
529
|
+
const parsed = JSON.parse(data);
|
|
530
|
+
if (parsed.usage) {
|
|
531
|
+
lastUsage = {
|
|
532
|
+
inputTokens: parsed.usage.prompt_tokens,
|
|
533
|
+
outputTokens: parsed.usage.completion_tokens,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
const d = parsed.choices?.[0]?.delta;
|
|
537
|
+
const delta = d?.content || d?.reasoning_content || "";
|
|
538
|
+
if (delta) {
|
|
539
|
+
this.peerManager.sendTo(from, {
|
|
540
|
+
type: "model_stream",
|
|
541
|
+
id,
|
|
542
|
+
from: this.config.nodeId,
|
|
543
|
+
to: from,
|
|
544
|
+
timestamp: Date.now(),
|
|
545
|
+
payload: { delta, done: false },
|
|
546
|
+
} satisfies ModelStreamChunk);
|
|
547
|
+
}
|
|
548
|
+
} catch {
|
|
549
|
+
// skip malformed chunks
|
|
452
550
|
}
|
|
453
|
-
} catch {
|
|
454
|
-
// skip malformed chunks
|
|
455
551
|
}
|
|
456
552
|
}
|
|
553
|
+
// If the upstream closed without sending [DONE], send a completion
|
|
554
|
+
// frame so the requesting side doesn't hang until MODEL_TIMEOUT.
|
|
555
|
+
if (!streamDone) {
|
|
556
|
+
this.peerManager.sendTo(from, {
|
|
557
|
+
type: "model_stream",
|
|
558
|
+
id,
|
|
559
|
+
from: this.config.nodeId,
|
|
560
|
+
to: from,
|
|
561
|
+
timestamp: Date.now(),
|
|
562
|
+
payload: { delta: "", done: true, usage: lastUsage },
|
|
563
|
+
} satisfies ModelStreamChunk);
|
|
564
|
+
}
|
|
565
|
+
} finally {
|
|
566
|
+
reader.releaseLock();
|
|
457
567
|
}
|
|
458
568
|
} else {
|
|
459
569
|
const result = (await response.json()) as {
|
package/src/peer-manager.ts
CHANGED
|
@@ -33,6 +33,7 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
33
33
|
private stopped = false;
|
|
34
34
|
/** Map from ws WebSocket to Connection for inbound connections. */
|
|
35
35
|
private inboundConnections = new Map<WsWebSocket, Connection>();
|
|
36
|
+
private gossipDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
36
37
|
|
|
37
38
|
constructor(config: ClawMatrixConfig) {
|
|
38
39
|
super();
|
|
@@ -62,6 +63,10 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
62
63
|
|
|
63
64
|
async stop() {
|
|
64
65
|
this.stopped = true;
|
|
66
|
+
if (this.gossipDebounceTimer) {
|
|
67
|
+
clearTimeout(this.gossipDebounceTimer);
|
|
68
|
+
this.gossipDebounceTimer = null;
|
|
69
|
+
}
|
|
65
70
|
for (const timer of this.reconnectTimers.values()) {
|
|
66
71
|
clearTimeout(timer);
|
|
67
72
|
}
|
|
@@ -86,6 +91,8 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
86
91
|
this.httpServer.close();
|
|
87
92
|
this.httpServer = null;
|
|
88
93
|
}
|
|
94
|
+
|
|
95
|
+
this.router.destroy();
|
|
89
96
|
}
|
|
90
97
|
|
|
91
98
|
// ── Inbound WS server (node:http + ws) ──────────────────────────
|
|
@@ -119,6 +126,10 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
119
126
|
});
|
|
120
127
|
});
|
|
121
128
|
|
|
129
|
+
this.httpServer.on("error", (err) => {
|
|
130
|
+
console.error(`[clawmatrix] WS server error on port ${port}: ${err.message}`);
|
|
131
|
+
});
|
|
132
|
+
|
|
122
133
|
this.httpServer.listen(port, hostname);
|
|
123
134
|
}
|
|
124
135
|
|
|
@@ -319,13 +330,17 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
319
330
|
}
|
|
320
331
|
}
|
|
321
332
|
|
|
322
|
-
// If we learned new info, re-sync with other peers
|
|
333
|
+
// If we learned new info, re-sync with other peers (debounced to avoid storms)
|
|
323
334
|
if (changed) {
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
335
|
+
if (this.gossipDebounceTimer) clearTimeout(this.gossipDebounceTimer);
|
|
336
|
+
this.gossipDebounceTimer = setTimeout(() => {
|
|
337
|
+
this.gossipDebounceTimer = null;
|
|
338
|
+
for (const conn of this.router.getDirectConnections()) {
|
|
339
|
+
if (conn !== from && conn.isOpen) {
|
|
340
|
+
this.sendPeerSync(conn);
|
|
341
|
+
}
|
|
327
342
|
}
|
|
328
|
-
}
|
|
343
|
+
}, 100);
|
|
329
344
|
}
|
|
330
345
|
}
|
|
331
346
|
|
|
@@ -334,6 +349,18 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
334
349
|
return this.router.sendTo(nodeId, frame);
|
|
335
350
|
}
|
|
336
351
|
|
|
352
|
+
/** Check if a node is reachable (direct or via relay) without sending. */
|
|
353
|
+
canReach(nodeId: string): boolean {
|
|
354
|
+
const route = this.router.getRoute(nodeId);
|
|
355
|
+
if (!route) return false;
|
|
356
|
+
if (route.connection?.isOpen) return true;
|
|
357
|
+
if (route.reachableVia) {
|
|
358
|
+
const relay = this.router.getRoute(route.reachableVia);
|
|
359
|
+
return !!relay?.connection?.isOpen;
|
|
360
|
+
}
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
|
|
337
364
|
broadcast(frame: ClusterFrame | AnyClusterFrame) {
|
|
338
365
|
this.router.broadcast(frame);
|
|
339
366
|
}
|
package/src/router.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { Connection } from "./connection.ts";
|
|
|
4
4
|
const DEFAULT_TTL = 3;
|
|
5
5
|
const SEEN_FRAME_TTL = 60_000; // 60s dedup window
|
|
6
6
|
const MAX_SEEN_FRAMES = 10_000;
|
|
7
|
+
const PRUNE_INTERVAL = 30_000; // periodic cleanup every 30s
|
|
7
8
|
|
|
8
9
|
export interface RouteEntry {
|
|
9
10
|
nodeId: string;
|
|
@@ -24,6 +25,7 @@ export class Router {
|
|
|
24
25
|
private routes = new Map<string, RouteEntry>();
|
|
25
26
|
private connections = new Map<string, Connection>(); // nodeId → direct connection
|
|
26
27
|
private seenFrames = new Map<string, number>(); // frameId → timestamp
|
|
28
|
+
private pruneTimer: ReturnType<typeof setInterval> | null = null;
|
|
27
29
|
|
|
28
30
|
constructor(
|
|
29
31
|
nodeId: string,
|
|
@@ -33,6 +35,17 @@ export class Router {
|
|
|
33
35
|
this.localAgents = localCapabilities?.agents ?? [];
|
|
34
36
|
this.localModels = localCapabilities?.models ?? [];
|
|
35
37
|
this.localTags = localCapabilities?.tags ?? [];
|
|
38
|
+
|
|
39
|
+
this.pruneTimer = setInterval(() => this.pruneSeenFrames(true), PRUNE_INTERVAL);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Stop periodic cleanup. Call on shutdown. */
|
|
43
|
+
destroy() {
|
|
44
|
+
if (this.pruneTimer) {
|
|
45
|
+
clearInterval(this.pruneTimer);
|
|
46
|
+
this.pruneTimer = null;
|
|
47
|
+
}
|
|
48
|
+
this.seenFrames.clear();
|
|
36
49
|
}
|
|
37
50
|
|
|
38
51
|
// ── Route table management ─────────────────────────────────────
|
|
@@ -211,7 +224,9 @@ export class Router {
|
|
|
211
224
|
tryRelay(frame: ClusterFrame): boolean {
|
|
212
225
|
if (!frame.to || frame.to === this.nodeId) return false;
|
|
213
226
|
|
|
214
|
-
const
|
|
227
|
+
const rawTtl = frame.ttl ?? DEFAULT_TTL;
|
|
228
|
+
if (typeof rawTtl !== "number" || !Number.isFinite(rawTtl) || rawTtl < 1) return false;
|
|
229
|
+
const ttl = rawTtl - 1;
|
|
215
230
|
if (ttl <= 0) return false;
|
|
216
231
|
|
|
217
232
|
const relayed = this.sendTo(frame.to, { ...frame, ttl });
|
|
@@ -238,8 +253,8 @@ export class Router {
|
|
|
238
253
|
return this.seenFrames.has(`failed:${requestId}`);
|
|
239
254
|
}
|
|
240
255
|
|
|
241
|
-
private pruneSeenFrames() {
|
|
242
|
-
if (this.seenFrames.size <= MAX_SEEN_FRAMES) return;
|
|
256
|
+
private pruneSeenFrames(force = false) {
|
|
257
|
+
if (!force && this.seenFrames.size <= MAX_SEEN_FRAMES) return;
|
|
243
258
|
const now = Date.now();
|
|
244
259
|
for (const [id, ts] of this.seenFrames) {
|
|
245
260
|
if (now - ts > SEEN_FRAME_TTL) {
|
package/src/tool-proxy.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { PeerManager } from "./peer-manager.ts";
|
|
2
2
|
import type { ClawMatrixConfig, ToolProxyConfig } from "./config.ts";
|
|
3
3
|
import type { ToolProxyRequest, ToolProxyResponse } from "./types.ts";
|
|
4
|
+
import { isLocalTool, executeLocally } from "./local-tools.ts";
|
|
4
5
|
|
|
5
6
|
const TOOL_TIMEOUT = 30_000;
|
|
6
7
|
|
|
@@ -113,7 +114,9 @@ export class ToolProxy {
|
|
|
113
114
|
}
|
|
114
115
|
|
|
115
116
|
try {
|
|
116
|
-
const result =
|
|
117
|
+
const result = isLocalTool(payload.tool)
|
|
118
|
+
? await executeLocally(payload.tool, payload.params)
|
|
119
|
+
: await this.executeViaGateway(payload.tool, payload.params);
|
|
117
120
|
this.sendResponse(id, from, { success: true, result });
|
|
118
121
|
} catch (err) {
|
|
119
122
|
this.sendResponse(id, from, {
|
package/src/types.ts
CHANGED
|
@@ -170,10 +170,26 @@ export interface AgentInfo {
|
|
|
170
170
|
tags: string[];
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
+
export interface ModelCompatInfo {
|
|
174
|
+
supportsTools?: boolean;
|
|
175
|
+
supportsStore?: boolean;
|
|
176
|
+
supportsDeveloperRole?: boolean;
|
|
177
|
+
supportsReasoningEffort?: boolean;
|
|
178
|
+
supportsUsageInStreaming?: boolean;
|
|
179
|
+
supportsStrictMode?: boolean;
|
|
180
|
+
maxTokensField?: "max_completion_tokens" | "max_tokens";
|
|
181
|
+
thinkingFormat?: "openai" | "zai" | "qwen";
|
|
182
|
+
requiresToolResultName?: boolean;
|
|
183
|
+
requiresAssistantAfterToolResult?: boolean;
|
|
184
|
+
requiresThinkingAsText?: boolean;
|
|
185
|
+
requiresMistralToolIds?: boolean;
|
|
186
|
+
}
|
|
187
|
+
|
|
173
188
|
export interface ModelInfo {
|
|
174
189
|
id: string;
|
|
175
190
|
provider: string;
|
|
176
191
|
description?: string;
|
|
192
|
+
compat?: ModelCompatInfo;
|
|
177
193
|
}
|
|
178
194
|
|
|
179
195
|
export interface PeerInfo {
|