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/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,20 +136,46 @@ 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
|
|
|
168
|
+
// Inject model identity so the LLM knows what it is
|
|
169
|
+
const messages = body.messages;
|
|
170
|
+
if (proxyModel?.description) {
|
|
171
|
+
const first = messages[0] as { role?: string; content?: string } | undefined;
|
|
172
|
+
if (first?.role === "system" && typeof first.content === "string") {
|
|
173
|
+
first.content = `[Model: ${proxyModel.description}]\n${first.content}`;
|
|
174
|
+
} else {
|
|
175
|
+
messages.unshift({ role: "system", content: `[Model: ${proxyModel.description}]` });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
132
179
|
const stream = body.stream ?? false;
|
|
133
180
|
const requestId = crypto.randomUUID();
|
|
134
181
|
|
|
@@ -140,13 +187,22 @@ export class ModelProxy {
|
|
|
140
187
|
timestamp: Date.now(),
|
|
141
188
|
payload: {
|
|
142
189
|
model: modelId,
|
|
143
|
-
messages
|
|
190
|
+
messages,
|
|
144
191
|
temperature: body.temperature,
|
|
145
192
|
maxTokens: body.max_tokens,
|
|
146
193
|
stream,
|
|
147
194
|
},
|
|
148
195
|
};
|
|
149
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
|
+
|
|
150
206
|
if (stream) {
|
|
151
207
|
return this.handleStreamRequest(requestId, route.nodeId, frame);
|
|
152
208
|
} else {
|
|
@@ -167,8 +223,13 @@ export class ModelProxy {
|
|
|
167
223
|
this.pending.delete(requestId);
|
|
168
224
|
this.peerManager.router.markFailed(requestId);
|
|
169
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
|
+
};
|
|
170
231
|
controller.enqueue(
|
|
171
|
-
encoder.encode(`data: ${JSON.stringify(
|
|
232
|
+
encoder.encode(`data: ${JSON.stringify(errorChunk)}\n\n`),
|
|
172
233
|
);
|
|
173
234
|
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
|
174
235
|
controller.close();
|
|
@@ -190,10 +251,13 @@ export class ModelProxy {
|
|
|
190
251
|
if (!sent) {
|
|
191
252
|
this.pending.delete(requestId);
|
|
192
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
|
+
};
|
|
193
259
|
controller.enqueue(
|
|
194
|
-
encoder.encode(
|
|
195
|
-
`data: ${JSON.stringify({ error: "Cannot reach model node" })}\n\n`,
|
|
196
|
-
),
|
|
260
|
+
encoder.encode(`data: ${JSON.stringify(errChunk)}\n\n`),
|
|
197
261
|
);
|
|
198
262
|
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
|
199
263
|
controller.close();
|
|
@@ -306,7 +370,31 @@ export class ModelProxy {
|
|
|
306
370
|
handleModelResponse(frame: ModelResponse) {
|
|
307
371
|
if (this.peerManager.router.isFailed(frame.id)) return;
|
|
308
372
|
const pending = this.pending.get(frame.id);
|
|
309
|
-
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
|
+
}
|
|
310
398
|
|
|
311
399
|
clearTimeout(pending.timer);
|
|
312
400
|
this.pending.delete(frame.id);
|
|
@@ -314,27 +402,28 @@ export class ModelProxy {
|
|
|
314
402
|
}
|
|
315
403
|
|
|
316
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)}`);
|
|
317
406
|
if (this.peerManager.router.isFailed(frame.id)) return;
|
|
318
407
|
const pending = this.pending.get(frame.id);
|
|
319
408
|
if (!pending?.stream || !pending.controller || !pending.encoder) return;
|
|
320
409
|
|
|
321
410
|
try {
|
|
322
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
|
+
};
|
|
323
417
|
if (frame.payload.usage) {
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
usage: {
|
|
329
|
-
prompt_tokens: frame.payload.usage.inputTokens,
|
|
330
|
-
completion_tokens: frame.payload.usage.outputTokens,
|
|
331
|
-
total_tokens: frame.payload.usage.inputTokens + frame.payload.usage.outputTokens,
|
|
332
|
-
},
|
|
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,
|
|
333
422
|
};
|
|
334
|
-
pending.controller.enqueue(
|
|
335
|
-
pending.encoder.encode(`data: ${JSON.stringify(usageChunk)}\n\n`),
|
|
336
|
-
);
|
|
337
423
|
}
|
|
424
|
+
pending.controller.enqueue(
|
|
425
|
+
pending.encoder.encode(`data: ${JSON.stringify(finalChunk)}\n\n`),
|
|
426
|
+
);
|
|
338
427
|
pending.controller.enqueue(pending.encoder.encode("data: [DONE]\n\n"));
|
|
339
428
|
pending.controller.close();
|
|
340
429
|
clearTimeout(pending.timer);
|
|
@@ -364,6 +453,7 @@ export class ModelProxy {
|
|
|
364
453
|
/** Handle model_req locally: forward to OpenClaw's configured model provider. */
|
|
365
454
|
async handleModelRequest(frame: ModelRequest): Promise<void> {
|
|
366
455
|
const { id, from, payload } = frame;
|
|
456
|
+
debug("model_req", `handling model="${payload.model}" from=${from} stream=${payload.stream}`);
|
|
367
457
|
|
|
368
458
|
const model = this.config.models.find((m) => m.id === payload.model);
|
|
369
459
|
if (!model) {
|
|
@@ -392,63 +482,96 @@ export class ModelProxy {
|
|
|
392
482
|
temperature: payload.temperature,
|
|
393
483
|
max_tokens: payload.maxTokens,
|
|
394
484
|
stream: payload.stream,
|
|
485
|
+
...(payload.stream ? { stream_options: { include_usage: true } } : {}),
|
|
395
486
|
}),
|
|
396
487
|
});
|
|
397
488
|
|
|
489
|
+
if (!response.ok) {
|
|
490
|
+
const errBody = await response.text();
|
|
491
|
+
throw new Error(`Upstream ${response.status}: ${errBody.slice(0, 200)}`);
|
|
492
|
+
}
|
|
493
|
+
|
|
398
494
|
if (payload.stream) {
|
|
399
495
|
const reader = response.body?.getReader();
|
|
400
496
|
if (!reader) throw new Error("No response body");
|
|
401
497
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
const data = line.slice(6).trim();
|
|
416
|
-
if (data === "[DONE]") {
|
|
417
|
-
this.peerManager.sendTo(from, {
|
|
418
|
-
type: "model_stream",
|
|
419
|
-
id,
|
|
420
|
-
from: this.config.nodeId,
|
|
421
|
-
to: from,
|
|
422
|
-
timestamp: Date.now(),
|
|
423
|
-
payload: { delta: "", done: true },
|
|
424
|
-
} satisfies ModelStreamChunk);
|
|
425
|
-
break;
|
|
426
|
-
}
|
|
498
|
+
try {
|
|
499
|
+
const decoder = new TextDecoder();
|
|
500
|
+
let buffer = "";
|
|
501
|
+
let lastUsage: { inputTokens: number; outputTokens: number } | undefined;
|
|
502
|
+
let streamDone = false;
|
|
503
|
+
|
|
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()!;
|
|
427
511
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
const
|
|
431
|
-
if (
|
|
512
|
+
for (const line of lines) {
|
|
513
|
+
if (!line.startsWith("data: ")) continue;
|
|
514
|
+
const data = line.slice(6).trim();
|
|
515
|
+
if (data === "[DONE]") {
|
|
432
516
|
this.peerManager.sendTo(from, {
|
|
433
517
|
type: "model_stream",
|
|
434
518
|
id,
|
|
435
519
|
from: this.config.nodeId,
|
|
436
520
|
to: from,
|
|
437
521
|
timestamp: Date.now(),
|
|
438
|
-
payload: { delta, done:
|
|
522
|
+
payload: { delta: "", done: true, usage: lastUsage },
|
|
439
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
|
|
440
550
|
}
|
|
441
|
-
} catch {
|
|
442
|
-
// skip malformed chunks
|
|
443
551
|
}
|
|
444
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();
|
|
445
567
|
}
|
|
446
568
|
} else {
|
|
447
569
|
const result = (await response.json()) as {
|
|
448
|
-
choices?: { message?: { content?: string } }[];
|
|
570
|
+
choices?: { message?: { content?: string; reasoning_content?: string } }[];
|
|
449
571
|
usage?: { prompt_tokens: number; completion_tokens: number };
|
|
450
572
|
};
|
|
451
|
-
const
|
|
573
|
+
const msg = result.choices?.[0]?.message;
|
|
574
|
+
const content = msg?.content || msg?.reasoning_content || "";
|
|
452
575
|
const usage = result.usage;
|
|
453
576
|
|
|
454
577
|
this.peerManager.sendTo(from, {
|
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 ─────────────────────────────────────
|
|
@@ -120,6 +133,12 @@ export class Router {
|
|
|
120
133
|
}
|
|
121
134
|
}
|
|
122
135
|
|
|
136
|
+
// Fallback: if no agent ID or tag matched, try matching by nodeId
|
|
137
|
+
if (candidates.length === 0 && !isTagQuery) {
|
|
138
|
+
const byNode = this.routes.get(target);
|
|
139
|
+
if (byNode) candidates.push(byNode);
|
|
140
|
+
}
|
|
141
|
+
|
|
123
142
|
if (candidates.length === 0) return undefined;
|
|
124
143
|
|
|
125
144
|
// Sort: direct connections first, then by latency
|
|
@@ -205,7 +224,9 @@ export class Router {
|
|
|
205
224
|
tryRelay(frame: ClusterFrame): boolean {
|
|
206
225
|
if (!frame.to || frame.to === this.nodeId) return false;
|
|
207
226
|
|
|
208
|
-
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;
|
|
209
230
|
if (ttl <= 0) return false;
|
|
210
231
|
|
|
211
232
|
const relayed = this.sendTo(frame.to, { ...frame, ttl });
|
|
@@ -232,8 +253,8 @@ export class Router {
|
|
|
232
253
|
return this.seenFrames.has(`failed:${requestId}`);
|
|
233
254
|
}
|
|
234
255
|
|
|
235
|
-
private pruneSeenFrames() {
|
|
236
|
-
if (this.seenFrames.size <= MAX_SEEN_FRAMES) return;
|
|
256
|
+
private pruneSeenFrames(force = false) {
|
|
257
|
+
if (!force && this.seenFrames.size <= MAX_SEEN_FRAMES) return;
|
|
237
258
|
const now = Date.now();
|
|
238
259
|
for (const [id, ts] of this.seenFrames) {
|
|
239
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
|
@@ -113,6 +113,15 @@ export interface HandoffRequest extends ClusterFrame {
|
|
|
113
113
|
};
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
export interface HandoffStreamChunk extends ClusterFrame {
|
|
117
|
+
type: "handoff_stream";
|
|
118
|
+
id: string;
|
|
119
|
+
payload: {
|
|
120
|
+
delta: string;
|
|
121
|
+
done: boolean;
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
116
125
|
export interface HandoffResponse extends ClusterFrame {
|
|
117
126
|
type: "handoff_res";
|
|
118
127
|
id: string;
|
|
@@ -161,10 +170,26 @@ export interface AgentInfo {
|
|
|
161
170
|
tags: string[];
|
|
162
171
|
}
|
|
163
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
|
+
|
|
164
188
|
export interface ModelInfo {
|
|
165
189
|
id: string;
|
|
166
190
|
provider: string;
|
|
167
191
|
description?: string;
|
|
192
|
+
compat?: ModelCompatInfo;
|
|
168
193
|
}
|
|
169
194
|
|
|
170
195
|
export interface PeerInfo {
|
|
@@ -197,6 +222,7 @@ export type AnyClusterFrame =
|
|
|
197
222
|
| ModelResponse
|
|
198
223
|
| ModelStreamChunk
|
|
199
224
|
| HandoffRequest
|
|
225
|
+
| HandoffStreamChunk
|
|
200
226
|
| HandoffResponse
|
|
201
227
|
| SendMessage
|
|
202
228
|
| ToolProxyRequest
|