multiclaws 0.4.1 → 0.4.3
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/dist/gateway/handlers.js +32 -12
- package/dist/index.js +17 -3
- package/dist/infra/gateway-client.js +10 -0
- package/dist/infra/tailscale.d.ts +1 -1
- package/dist/infra/tailscale.js +14 -5
- package/dist/service/a2a-adapter.d.ts +1 -0
- package/dist/service/a2a-adapter.js +56 -10
- package/dist/service/agent-registry.d.ts +9 -0
- package/dist/service/agent-registry.js +45 -1
- package/dist/service/multiclaws-service.d.ts +7 -1
- package/dist/service/multiclaws-service.js +182 -54
- package/dist/service/session-store.js +22 -6
- package/package.json +1 -1
package/dist/gateway/handlers.js
CHANGED
|
@@ -40,9 +40,14 @@ function createGatewayHandlers(getService) {
|
|
|
40
40
|
const handlers = {
|
|
41
41
|
/* ── Agent handlers ─────────────────────────────────────────── */
|
|
42
42
|
"multiclaws.agent.list": async ({ respond }) => {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
try {
|
|
44
|
+
const service = getService();
|
|
45
|
+
const agents = await service.listAgents();
|
|
46
|
+
respond(true, { agents });
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
safeHandle(respond, "agent_list_failed", error);
|
|
50
|
+
}
|
|
46
51
|
},
|
|
47
52
|
"multiclaws.agent.add": async ({ params, respond }) => {
|
|
48
53
|
try {
|
|
@@ -187,19 +192,34 @@ function createGatewayHandlers(getService) {
|
|
|
187
192
|
},
|
|
188
193
|
/* ── Profile handlers ───────────────────────────────────────── */
|
|
189
194
|
"multiclaws.profile.show": async ({ respond }) => {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
195
|
+
try {
|
|
196
|
+
const service = getService();
|
|
197
|
+
const profile = await service.getProfile();
|
|
198
|
+
respond(true, profile);
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
safeHandle(respond, "profile_show_failed", error);
|
|
202
|
+
}
|
|
193
203
|
},
|
|
194
204
|
"multiclaws.profile.pending_review": async ({ respond }) => {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
205
|
+
try {
|
|
206
|
+
const service = getService();
|
|
207
|
+
const result = await service.getPendingProfileReview();
|
|
208
|
+
respond(true, result);
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
safeHandle(respond, "profile_pending_review_failed", error);
|
|
212
|
+
}
|
|
198
213
|
},
|
|
199
214
|
"multiclaws.profile.clear_pending_review": async ({ respond }) => {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
215
|
+
try {
|
|
216
|
+
const service = getService();
|
|
217
|
+
await service.clearPendingProfileReview();
|
|
218
|
+
respond(true, { cleared: true });
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
safeHandle(respond, "profile_clear_pending_review_failed", error);
|
|
222
|
+
}
|
|
203
223
|
},
|
|
204
224
|
"multiclaws.profile.set": async ({ params, respond }) => {
|
|
205
225
|
try {
|
package/dist/index.js
CHANGED
|
@@ -64,7 +64,10 @@ function createTools(getService) {
|
|
|
64
64
|
throw new Error("url is required");
|
|
65
65
|
const apiKey = typeof args.apiKey === "string" ? args.apiKey.trim() : undefined;
|
|
66
66
|
const agent = await service.addAgent({ url, apiKey });
|
|
67
|
-
|
|
67
|
+
const status = agent.reachable
|
|
68
|
+
? `Agent added: ${agent.name} (${agent.url})`
|
|
69
|
+
: `⚠️ Agent added but NOT reachable: ${agent.url} — agent card could not be fetched. Verify the URL and ensure the agent is running.`;
|
|
70
|
+
return textResult(status, agent);
|
|
68
71
|
},
|
|
69
72
|
};
|
|
70
73
|
const multiclawsRemoveAgent = {
|
|
@@ -366,7 +369,7 @@ function createTools(getService) {
|
|
|
366
369
|
const plugin = {
|
|
367
370
|
id: "multiclaws",
|
|
368
371
|
name: "MultiClaws",
|
|
369
|
-
version: "0.
|
|
372
|
+
version: "0.4.2",
|
|
370
373
|
register(api) {
|
|
371
374
|
const config = readConfig(api);
|
|
372
375
|
(0, telemetry_1.initializeTelemetry)({ enableConsoleExporter: config.telemetry?.consoleExporter });
|
|
@@ -379,7 +382,7 @@ const plugin = {
|
|
|
379
382
|
if (gw) {
|
|
380
383
|
const tools = (gw.tools ?? {});
|
|
381
384
|
const allow = Array.isArray(tools.allow) ? tools.allow : [];
|
|
382
|
-
const required = ["sessions_spawn", "sessions_history"];
|
|
385
|
+
const required = ["sessions_spawn", "sessions_history", "message"];
|
|
383
386
|
const missing = required.filter((t) => !allow.includes(t));
|
|
384
387
|
if (missing.length > 0) {
|
|
385
388
|
tools.allow = [...allow, ...missing];
|
|
@@ -437,6 +440,17 @@ const plugin = {
|
|
|
437
440
|
});
|
|
438
441
|
api.on("gateway_start", () => {
|
|
439
442
|
structured.logger.info("[multiclaws] gateway_start observed");
|
|
443
|
+
// Re-read gateway config in case token became available after initial registration
|
|
444
|
+
if (service && !gatewayConfig) {
|
|
445
|
+
const gw = api.config?.gateway;
|
|
446
|
+
const port = typeof gw?.port === "number" ? gw.port : 18789;
|
|
447
|
+
const token = typeof gw?.auth?.token === "string" ? gw.auth.token : null;
|
|
448
|
+
if (token) {
|
|
449
|
+
const newConfig = { port, token };
|
|
450
|
+
service.updateGatewayConfig(newConfig);
|
|
451
|
+
structured.logger.info("[multiclaws] gateway config updated from gateway_start event");
|
|
452
|
+
}
|
|
453
|
+
}
|
|
440
454
|
});
|
|
441
455
|
api.on("gateway_stop", () => {
|
|
442
456
|
structured.logger.info("[multiclaws] gateway_stop observed");
|
|
@@ -40,6 +40,7 @@ exports.invokeGatewayTool = invokeGatewayTool;
|
|
|
40
40
|
const opossum_1 = __importDefault(require("opossum"));
|
|
41
41
|
class NonRetryableError extends Error {
|
|
42
42
|
}
|
|
43
|
+
const MAX_BREAKERS = 50;
|
|
43
44
|
const breakerCache = new Map();
|
|
44
45
|
let pRetryModulePromise = null;
|
|
45
46
|
async function loadPRetry() {
|
|
@@ -53,6 +54,15 @@ function getBreaker(key, timeoutMs) {
|
|
|
53
54
|
if (existing) {
|
|
54
55
|
return existing;
|
|
55
56
|
}
|
|
57
|
+
// Evict oldest entries when cache is full
|
|
58
|
+
if (breakerCache.size >= MAX_BREAKERS) {
|
|
59
|
+
const oldest = breakerCache.keys().next().value;
|
|
60
|
+
if (oldest !== undefined) {
|
|
61
|
+
const old = breakerCache.get(oldest);
|
|
62
|
+
old?.shutdown();
|
|
63
|
+
breakerCache.delete(oldest);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
56
66
|
const breaker = new opossum_1.default((operation) => operation(), {
|
|
57
67
|
timeout: false, // timeout handled by AbortController in the operation
|
|
58
68
|
errorThresholdPercentage: 50,
|
|
@@ -10,7 +10,7 @@ export type TailscaleStatus = {
|
|
|
10
10
|
status: "unavailable";
|
|
11
11
|
reason: string;
|
|
12
12
|
};
|
|
13
|
-
/** Check network interfaces for a Tailscale IP (100.
|
|
13
|
+
/** Check network interfaces for a Tailscale IP (100.64.0.0/10) — exported for fast-path checks */
|
|
14
14
|
export declare function getTailscaleIpFromInterfaces(): string | null;
|
|
15
15
|
/**
|
|
16
16
|
* Detect Tailscale status — does NOT install or modify system state.
|
package/dist/infra/tailscale.js
CHANGED
|
@@ -7,6 +7,7 @@ exports.getTailscaleIpFromInterfaces = getTailscaleIpFromInterfaces;
|
|
|
7
7
|
exports.detectTailscale = detectTailscale;
|
|
8
8
|
const node_child_process_1 = require("node:child_process");
|
|
9
9
|
const node_os_1 = __importDefault(require("node:os"));
|
|
10
|
+
const isWindows = process.platform === "win32";
|
|
10
11
|
function run(cmd, timeoutMs = 5_000) {
|
|
11
12
|
return (0, node_child_process_1.execSync)(cmd, { timeout: timeoutMs, stdio: ["ignore", "pipe", "pipe"] })
|
|
12
13
|
.toString()
|
|
@@ -14,21 +15,30 @@ function run(cmd, timeoutMs = 5_000) {
|
|
|
14
15
|
}
|
|
15
16
|
function commandExists(cmd) {
|
|
16
17
|
try {
|
|
17
|
-
run(`which ${cmd}`);
|
|
18
|
+
run(isWindows ? `where ${cmd}` : `which ${cmd}`);
|
|
18
19
|
return true;
|
|
19
20
|
}
|
|
20
21
|
catch {
|
|
21
22
|
return false;
|
|
22
23
|
}
|
|
23
24
|
}
|
|
24
|
-
/** Check
|
|
25
|
+
/** Check whether an IPv4 address falls within the Tailscale CGNAT range (100.64.0.0/10). */
|
|
26
|
+
function isTailscaleCGNAT(ip) {
|
|
27
|
+
const parts = ip.split(".");
|
|
28
|
+
if (parts.length !== 4)
|
|
29
|
+
return false;
|
|
30
|
+
const first = parseInt(parts[0], 10);
|
|
31
|
+
const second = parseInt(parts[1], 10);
|
|
32
|
+
return first === 100 && second >= 64 && second <= 127;
|
|
33
|
+
}
|
|
34
|
+
/** Check network interfaces for a Tailscale IP (100.64.0.0/10) — exported for fast-path checks */
|
|
25
35
|
function getTailscaleIpFromInterfaces() {
|
|
26
36
|
const interfaces = node_os_1.default.networkInterfaces();
|
|
27
37
|
for (const addrs of Object.values(interfaces)) {
|
|
28
38
|
if (!addrs)
|
|
29
39
|
continue;
|
|
30
40
|
for (const addr of addrs) {
|
|
31
|
-
if (addr.family === "IPv4" && addr.address
|
|
41
|
+
if (addr.family === "IPv4" && isTailscaleCGNAT(addr.address)) {
|
|
32
42
|
return addr.address;
|
|
33
43
|
}
|
|
34
44
|
}
|
|
@@ -61,8 +71,7 @@ async function getAuthUrl() {
|
|
|
61
71
|
return new Promise((resolve) => {
|
|
62
72
|
try {
|
|
63
73
|
// tailscale up prints the auth URL to stderr
|
|
64
|
-
const
|
|
65
|
-
const proc = spawn("tailscale", ["up"], { stdio: ["ignore", "pipe", "pipe"] });
|
|
74
|
+
const proc = (0, node_child_process_1.spawn)("tailscale", ["up"], { stdio: ["ignore", "pipe", "pipe"] });
|
|
66
75
|
let output = "";
|
|
67
76
|
let resolved = false;
|
|
68
77
|
const tryResolve = (text) => {
|
|
@@ -24,6 +24,7 @@ export declare class OpenClawAgentExecutor implements AgentExecutor {
|
|
|
24
24
|
private gatewayConfig;
|
|
25
25
|
private readonly taskTracker;
|
|
26
26
|
private readonly logger;
|
|
27
|
+
private readonly a2aToTracker;
|
|
27
28
|
constructor(options: A2AAdapterOptions);
|
|
28
29
|
execute(context: RequestContext, eventBus: ExecutionEventBus): Promise<void>;
|
|
29
30
|
/**
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.OpenClawAgentExecutor = void 0;
|
|
4
|
+
const node_crypto_1 = require("node:crypto");
|
|
4
5
|
const gateway_client_1 = require("../infra/gateway-client");
|
|
5
6
|
function extractTextFromMessage(message) {
|
|
6
7
|
if (!message.parts)
|
|
@@ -10,6 +11,34 @@ function extractTextFromMessage(message) {
|
|
|
10
11
|
.map((p) => p.text)
|
|
11
12
|
.join("\n");
|
|
12
13
|
}
|
|
14
|
+
function buildTaskWithHistory(context) {
|
|
15
|
+
const currentText = extractTextFromMessage(context.userMessage);
|
|
16
|
+
const history = context.task?.history ?? [];
|
|
17
|
+
if (history.length <= 1) {
|
|
18
|
+
// First message — no prior context
|
|
19
|
+
return currentText;
|
|
20
|
+
}
|
|
21
|
+
// Build context from previous exchanges (exclude the last message, that's currentText)
|
|
22
|
+
const prior = history
|
|
23
|
+
.slice(0, -1)
|
|
24
|
+
.slice(-8) // keep last 8 messages max to avoid huge prompts
|
|
25
|
+
.map((m) => {
|
|
26
|
+
const text = extractTextFromMessage(m);
|
|
27
|
+
const role = m.role === "agent" ? "agent" : "user";
|
|
28
|
+
return `[${role}]: ${text}`;
|
|
29
|
+
})
|
|
30
|
+
.filter((line) => line.length > 10)
|
|
31
|
+
.join("\n");
|
|
32
|
+
if (!prior)
|
|
33
|
+
return currentText;
|
|
34
|
+
return [
|
|
35
|
+
"[conversation history]",
|
|
36
|
+
prior,
|
|
37
|
+
"",
|
|
38
|
+
"[latest message]",
|
|
39
|
+
currentText,
|
|
40
|
+
].join("\n");
|
|
41
|
+
}
|
|
13
42
|
function sleep(ms) {
|
|
14
43
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
15
44
|
}
|
|
@@ -42,13 +71,15 @@ class OpenClawAgentExecutor {
|
|
|
42
71
|
gatewayConfig;
|
|
43
72
|
taskTracker;
|
|
44
73
|
logger;
|
|
74
|
+
// Map A2A task IDs → internal tracker IDs so cancelTask can find the right record
|
|
75
|
+
a2aToTracker = new Map();
|
|
45
76
|
constructor(options) {
|
|
46
77
|
this.gatewayConfig = options.gatewayConfig;
|
|
47
78
|
this.taskTracker = options.taskTracker;
|
|
48
79
|
this.logger = options.logger;
|
|
49
80
|
}
|
|
50
81
|
async execute(context, eventBus) {
|
|
51
|
-
const taskText =
|
|
82
|
+
const taskText = buildTaskWithHistory(context);
|
|
52
83
|
const taskId = context.taskId;
|
|
53
84
|
if (!taskText.trim()) {
|
|
54
85
|
this.publishMessage(eventBus, "Error: empty task received.");
|
|
@@ -56,14 +87,17 @@ class OpenClawAgentExecutor {
|
|
|
56
87
|
return;
|
|
57
88
|
}
|
|
58
89
|
const fromAgent = context.userMessage.metadata?.agentUrl ?? "unknown";
|
|
59
|
-
this.taskTracker.create({
|
|
90
|
+
const tracked = this.taskTracker.create({
|
|
60
91
|
fromPeerId: fromAgent,
|
|
61
92
|
toPeerId: "local",
|
|
62
93
|
task: taskText,
|
|
63
94
|
});
|
|
95
|
+
const trackedId = tracked.taskId;
|
|
96
|
+
this.a2aToTracker.set(taskId, trackedId);
|
|
64
97
|
if (!this.gatewayConfig) {
|
|
65
98
|
this.logger.error("[a2a-adapter] gateway config not available, cannot execute task");
|
|
66
|
-
this.taskTracker.update(
|
|
99
|
+
this.taskTracker.update(trackedId, { status: "failed", error: "gateway config not available" });
|
|
100
|
+
this.a2aToTracker.delete(taskId);
|
|
67
101
|
this.publishMessage(eventBus, "Error: gateway config not available, cannot execute task.");
|
|
68
102
|
eventBus.finished();
|
|
69
103
|
return;
|
|
@@ -90,16 +124,19 @@ class OpenClawAgentExecutor {
|
|
|
90
124
|
this.logger.info(`[a2a-adapter] task ${taskId} spawned as ${childSessionKey}, waiting for result...`);
|
|
91
125
|
const output = await this.waitForCompletion(childSessionKey, 180_000);
|
|
92
126
|
// 3. Return result
|
|
93
|
-
this.taskTracker.update(
|
|
127
|
+
this.taskTracker.update(trackedId, { status: "completed", result: output });
|
|
94
128
|
this.logger.info(`[a2a-adapter] task ${taskId} completed`);
|
|
95
129
|
this.publishMessage(eventBus, output || "Task completed with no output.");
|
|
96
130
|
}
|
|
97
131
|
catch (err) {
|
|
98
132
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
99
133
|
this.logger.error(`[a2a-adapter] task execution failed: ${errorMsg}`);
|
|
100
|
-
this.taskTracker.update(
|
|
134
|
+
this.taskTracker.update(trackedId, { status: "failed", error: errorMsg });
|
|
101
135
|
this.publishMessage(eventBus, `Error: ${errorMsg}`);
|
|
102
136
|
}
|
|
137
|
+
finally {
|
|
138
|
+
this.a2aToTracker.delete(taskId);
|
|
139
|
+
}
|
|
103
140
|
eventBus.finished();
|
|
104
141
|
}
|
|
105
142
|
/**
|
|
@@ -155,14 +192,21 @@ class OpenClawAgentExecutor {
|
|
|
155
192
|
const messages = (details.messages ?? []);
|
|
156
193
|
if (messages.length === 0)
|
|
157
194
|
return null;
|
|
158
|
-
// If no explicit flag,
|
|
195
|
+
// If no explicit flag, use conservative heuristic: only consider
|
|
196
|
+
// complete if the last message is an assistant message with text
|
|
197
|
+
// and NO tool calls (tool calls indicate ongoing work)
|
|
159
198
|
if (details.isComplete === undefined) {
|
|
160
199
|
const lastMsg = messages[messages.length - 1];
|
|
161
|
-
if (lastMsg
|
|
200
|
+
if (!lastMsg || lastMsg.role !== "assistant")
|
|
201
|
+
return null;
|
|
202
|
+
if (Array.isArray(lastMsg.content)) {
|
|
162
203
|
const content = lastMsg.content;
|
|
163
204
|
const hasToolCalls = content.some((c) => c?.type === "toolCall" || c?.type === "tool_use");
|
|
205
|
+
// If there are ANY tool calls, assume still running
|
|
206
|
+
if (hasToolCalls)
|
|
207
|
+
return null;
|
|
164
208
|
const hasText = content.some((c) => c?.type === "text" && typeof c.text === "string" && c.text.trim());
|
|
165
|
-
if (
|
|
209
|
+
if (!hasText)
|
|
166
210
|
return null;
|
|
167
211
|
}
|
|
168
212
|
}
|
|
@@ -188,7 +232,9 @@ class OpenClawAgentExecutor {
|
|
|
188
232
|
return null;
|
|
189
233
|
}
|
|
190
234
|
async cancelTask(taskId, eventBus) {
|
|
191
|
-
this.
|
|
235
|
+
const trackedId = this.a2aToTracker.get(taskId) ?? taskId;
|
|
236
|
+
this.taskTracker.update(trackedId, { status: "failed", error: "canceled" });
|
|
237
|
+
this.a2aToTracker.delete(taskId);
|
|
192
238
|
this.publishMessage(eventBus, "Task was canceled.");
|
|
193
239
|
eventBus.finished();
|
|
194
240
|
}
|
|
@@ -199,7 +245,7 @@ class OpenClawAgentExecutor {
|
|
|
199
245
|
const message = {
|
|
200
246
|
kind: "message",
|
|
201
247
|
role: "agent",
|
|
202
|
-
messageId:
|
|
248
|
+
messageId: (0, node_crypto_1.randomUUID)(),
|
|
203
249
|
parts: [{ kind: "text", text }],
|
|
204
250
|
};
|
|
205
251
|
eventBus.publish(message);
|
|
@@ -6,6 +6,8 @@ export type AgentRecord = {
|
|
|
6
6
|
apiKey?: string;
|
|
7
7
|
addedAtMs: number;
|
|
8
8
|
lastSeenAtMs: number;
|
|
9
|
+
/** Which teams synced this agent. Empty or undefined = manually added. */
|
|
10
|
+
teamIds?: string[];
|
|
9
11
|
};
|
|
10
12
|
export declare class AgentRegistry {
|
|
11
13
|
private readonly filePath;
|
|
@@ -21,6 +23,13 @@ export declare class AgentRegistry {
|
|
|
21
23
|
remove(url: string): Promise<boolean>;
|
|
22
24
|
list(): Promise<AgentRecord[]>;
|
|
23
25
|
get(url: string): Promise<AgentRecord | null>;
|
|
26
|
+
addTeamSource(url: string, teamId: string): Promise<void>;
|
|
27
|
+
/**
|
|
28
|
+
* Remove a team source from an agent. Returns true if the agent
|
|
29
|
+
* was fully removed (no remaining sources), false otherwise.
|
|
30
|
+
* Manually-added agents (no teamIds) are never removed by this method.
|
|
31
|
+
*/
|
|
32
|
+
removeTeamSource(url: string, teamId: string): Promise<boolean>;
|
|
24
33
|
updateDescription(url: string, description: string): Promise<void>;
|
|
25
34
|
updateLastSeen(url: string): Promise<void>;
|
|
26
35
|
}
|
|
@@ -32,14 +32,17 @@ class AgentRegistry {
|
|
|
32
32
|
const normalizedUrl = params.url.replace(/\/+$/, "");
|
|
33
33
|
const existing = store.agents.findIndex((a) => a.url === normalizedUrl);
|
|
34
34
|
const now = Date.now();
|
|
35
|
+
const prev = existing >= 0 ? store.agents[existing] : null;
|
|
35
36
|
const record = {
|
|
36
37
|
url: normalizedUrl,
|
|
37
38
|
name: params.name,
|
|
38
39
|
description: params.description ?? "",
|
|
39
40
|
skills: params.skills ?? [],
|
|
40
41
|
apiKey: params.apiKey,
|
|
41
|
-
addedAtMs:
|
|
42
|
+
addedAtMs: prev?.addedAtMs ?? now,
|
|
42
43
|
lastSeenAtMs: now,
|
|
44
|
+
// Preserve existing teamIds so that team associations are not lost on upsert
|
|
45
|
+
...(prev?.teamIds?.length ? { teamIds: prev.teamIds } : {}),
|
|
43
46
|
};
|
|
44
47
|
if (existing >= 0) {
|
|
45
48
|
store.agents[existing] = record;
|
|
@@ -73,6 +76,47 @@ class AgentRegistry {
|
|
|
73
76
|
const normalizedUrl = url.replace(/\/+$/, "");
|
|
74
77
|
return store.agents.find((a) => a.url === normalizedUrl) ?? null;
|
|
75
78
|
}
|
|
79
|
+
async addTeamSource(url, teamId) {
|
|
80
|
+
await (0, json_store_1.withJsonLock)(this.filePath, emptyStore(), async () => {
|
|
81
|
+
const store = await this.readStore();
|
|
82
|
+
const normalizedUrl = url.replace(/\/+$/, "");
|
|
83
|
+
const agent = store.agents.find((a) => a.url === normalizedUrl);
|
|
84
|
+
if (agent) {
|
|
85
|
+
const teams = new Set(agent.teamIds ?? []);
|
|
86
|
+
teams.add(teamId);
|
|
87
|
+
agent.teamIds = [...teams];
|
|
88
|
+
await (0, json_store_1.writeJsonAtomically)(this.filePath, store);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Remove a team source from an agent. Returns true if the agent
|
|
94
|
+
* was fully removed (no remaining sources), false otherwise.
|
|
95
|
+
* Manually-added agents (no teamIds) are never removed by this method.
|
|
96
|
+
*/
|
|
97
|
+
async removeTeamSource(url, teamId) {
|
|
98
|
+
return await (0, json_store_1.withJsonLock)(this.filePath, emptyStore(), async () => {
|
|
99
|
+
const store = await this.readStore();
|
|
100
|
+
const normalizedUrl = url.replace(/\/+$/, "");
|
|
101
|
+
const agent = store.agents.find((a) => a.url === normalizedUrl);
|
|
102
|
+
if (!agent)
|
|
103
|
+
return false;
|
|
104
|
+
// Agent was manually added (no team tracking) — never auto-remove
|
|
105
|
+
if (!agent.teamIds || agent.teamIds.length === 0)
|
|
106
|
+
return false;
|
|
107
|
+
const teams = new Set(agent.teamIds);
|
|
108
|
+
teams.delete(teamId);
|
|
109
|
+
if (teams.size === 0) {
|
|
110
|
+
// No team sources remain — remove entirely
|
|
111
|
+
store.agents = store.agents.filter((a) => a.url !== normalizedUrl);
|
|
112
|
+
await (0, json_store_1.writeJsonAtomically)(this.filePath, store);
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
agent.teamIds = [...teams];
|
|
116
|
+
await (0, json_store_1.writeJsonAtomically)(this.filePath, store);
|
|
117
|
+
return false;
|
|
118
|
+
});
|
|
119
|
+
}
|
|
76
120
|
async updateDescription(url, description) {
|
|
77
121
|
await (0, json_store_1.withJsonLock)(this.filePath, emptyStore(), async () => {
|
|
78
122
|
const store = await this.readStore();
|
|
@@ -36,6 +36,8 @@ export declare class MulticlawsService extends EventEmitter {
|
|
|
36
36
|
private readonly profileStore;
|
|
37
37
|
private readonly taskTracker;
|
|
38
38
|
private readonly sessionStore;
|
|
39
|
+
private readonly sessionLocks;
|
|
40
|
+
private readonly sessionAborts;
|
|
39
41
|
private agentExecutor;
|
|
40
42
|
private a2aRequestHandler;
|
|
41
43
|
private agentCard;
|
|
@@ -51,7 +53,9 @@ export declare class MulticlawsService extends EventEmitter {
|
|
|
51
53
|
addAgent(params: {
|
|
52
54
|
url: string;
|
|
53
55
|
apiKey?: string;
|
|
54
|
-
}): Promise<AgentRecord
|
|
56
|
+
}): Promise<AgentRecord & {
|
|
57
|
+
reachable: boolean;
|
|
58
|
+
}>;
|
|
55
59
|
removeAgent(url: string): Promise<boolean>;
|
|
56
60
|
startSession(params: {
|
|
57
61
|
agentUrl: string;
|
|
@@ -77,7 +81,9 @@ export declare class MulticlawsService extends EventEmitter {
|
|
|
77
81
|
timedOut: boolean;
|
|
78
82
|
}>;
|
|
79
83
|
endSession(sessionId: string): boolean;
|
|
84
|
+
private acquireSessionLock;
|
|
80
85
|
private runSession;
|
|
86
|
+
private extractResultState;
|
|
81
87
|
private handleSessionResult;
|
|
82
88
|
private notifySessionUpdate;
|
|
83
89
|
getProfile(): Promise<AgentProfile>;
|
|
@@ -37,6 +37,10 @@ class MulticlawsService extends node_events_1.EventEmitter {
|
|
|
37
37
|
profileStore;
|
|
38
38
|
taskTracker;
|
|
39
39
|
sessionStore;
|
|
40
|
+
// Fix #5: per-session lock to prevent concurrent runSession calls
|
|
41
|
+
sessionLocks = new Map();
|
|
42
|
+
// Per-session AbortController so endSession can cancel in-flight runSession
|
|
43
|
+
sessionAborts = new Map();
|
|
40
44
|
agentExecutor = null;
|
|
41
45
|
a2aRequestHandler = null;
|
|
42
46
|
agentCard = null;
|
|
@@ -106,7 +110,7 @@ class MulticlawsService extends node_events_1.EventEmitter {
|
|
|
106
110
|
name: this.options.displayName ?? (profile.ownerName || "OpenClaw Agent"),
|
|
107
111
|
description: this.profileDescription,
|
|
108
112
|
url: this.selfUrl,
|
|
109
|
-
version: "0.
|
|
113
|
+
version: "0.4.2",
|
|
110
114
|
protocolVersion: "0.2.2",
|
|
111
115
|
defaultInputModes: ["text/plain"],
|
|
112
116
|
defaultOutputModes: ["text/plain"],
|
|
@@ -145,7 +149,13 @@ class MulticlawsService extends node_events_1.EventEmitter {
|
|
|
145
149
|
}));
|
|
146
150
|
const listenPort = this.options.port ?? 3100;
|
|
147
151
|
this.httpServer = node_http_1.default.createServer(app);
|
|
148
|
-
await new Promise((resolve) =>
|
|
152
|
+
await new Promise((resolve, reject) => {
|
|
153
|
+
this.httpServer.once("error", reject);
|
|
154
|
+
this.httpServer.listen(listenPort, "0.0.0.0", () => {
|
|
155
|
+
this.httpServer.removeListener("error", reject);
|
|
156
|
+
resolve();
|
|
157
|
+
});
|
|
158
|
+
});
|
|
149
159
|
this.started = true;
|
|
150
160
|
this.log("info", `multiclaws A2A service listening on :${listenPort}`);
|
|
151
161
|
}
|
|
@@ -153,6 +163,18 @@ class MulticlawsService extends node_events_1.EventEmitter {
|
|
|
153
163
|
if (!this.started)
|
|
154
164
|
return;
|
|
155
165
|
this.started = false;
|
|
166
|
+
// Abort all in-flight sessions so they don't hang
|
|
167
|
+
for (const [, abort] of this.sessionAborts) {
|
|
168
|
+
abort.abort();
|
|
169
|
+
}
|
|
170
|
+
// Wait for session locks to drain (with a cap)
|
|
171
|
+
if (this.sessionLocks.size > 0) {
|
|
172
|
+
const pending = [...this.sessionLocks.values()];
|
|
173
|
+
await Promise.race([
|
|
174
|
+
Promise.allSettled(pending),
|
|
175
|
+
new Promise((r) => setTimeout(r, 5_000)),
|
|
176
|
+
]);
|
|
177
|
+
}
|
|
156
178
|
this.taskTracker.destroy();
|
|
157
179
|
this.httpRateLimiter.destroy();
|
|
158
180
|
await new Promise((resolve) => {
|
|
@@ -165,6 +187,7 @@ class MulticlawsService extends node_events_1.EventEmitter {
|
|
|
165
187
|
this.httpServer = null;
|
|
166
188
|
}
|
|
167
189
|
updateGatewayConfig(config) {
|
|
190
|
+
this.options.gatewayConfig = config;
|
|
168
191
|
this.agentExecutor?.updateGatewayConfig(config);
|
|
169
192
|
}
|
|
170
193
|
/* ---------------------------------------------------------------- */
|
|
@@ -178,20 +201,23 @@ class MulticlawsService extends node_events_1.EventEmitter {
|
|
|
178
201
|
try {
|
|
179
202
|
const client = await this.clientFactory.createFromUrl(normalizedUrl);
|
|
180
203
|
const card = await client.getAgentCard();
|
|
181
|
-
|
|
204
|
+
const record = await this.agentRegistry.add({
|
|
182
205
|
url: normalizedUrl,
|
|
183
206
|
name: card.name ?? normalizedUrl,
|
|
184
207
|
description: card.description ?? "",
|
|
185
208
|
skills: card.skills?.map((s) => s.name ?? s.id) ?? [],
|
|
186
209
|
apiKey: params.apiKey,
|
|
187
210
|
});
|
|
211
|
+
return { ...record, reachable: true };
|
|
188
212
|
}
|
|
189
213
|
catch {
|
|
190
|
-
|
|
214
|
+
this.log("warn", `agent at ${normalizedUrl} is not reachable, adding with limited info`);
|
|
215
|
+
const record = await this.agentRegistry.add({
|
|
191
216
|
url: normalizedUrl,
|
|
192
217
|
name: normalizedUrl,
|
|
193
218
|
apiKey: params.apiKey,
|
|
194
219
|
});
|
|
220
|
+
return { ...record, reachable: false };
|
|
195
221
|
}
|
|
196
222
|
}
|
|
197
223
|
async removeAgent(url) {
|
|
@@ -205,27 +231,29 @@ class MulticlawsService extends node_events_1.EventEmitter {
|
|
|
205
231
|
/* ---------------------------------------------------------------- */
|
|
206
232
|
async startSession(params) {
|
|
207
233
|
const agentRecord = await this.agentRegistry.get(params.agentUrl);
|
|
234
|
+
// Fix #3: throw instead of returning empty sessionId
|
|
208
235
|
if (!agentRecord) {
|
|
209
|
-
|
|
236
|
+
throw new Error(`unknown agent: ${params.agentUrl}`);
|
|
210
237
|
}
|
|
211
|
-
|
|
238
|
+
// Fix #4: don't pre-generate contextId; let server assign it.
|
|
239
|
+
// Use a local placeholder that gets replaced after first response.
|
|
212
240
|
const session = this.sessionStore.create({
|
|
213
241
|
agentUrl: params.agentUrl,
|
|
214
242
|
agentName: agentRecord.name,
|
|
215
|
-
contextId,
|
|
243
|
+
contextId: "", // will be filled in from server response
|
|
216
244
|
});
|
|
217
245
|
this.sessionStore.appendMessage(session.sessionId, {
|
|
218
246
|
role: "user",
|
|
219
247
|
content: params.message,
|
|
220
248
|
timestampMs: Date.now(),
|
|
221
249
|
});
|
|
222
|
-
void this.runSession({
|
|
250
|
+
void this.acquireSessionLock(session.sessionId, () => this.runSession({
|
|
223
251
|
sessionId: session.sessionId,
|
|
224
252
|
agentRecord,
|
|
225
253
|
message: params.message,
|
|
226
|
-
contextId,
|
|
254
|
+
contextId: undefined, // first message: no contextId
|
|
227
255
|
taskId: undefined,
|
|
228
|
-
});
|
|
256
|
+
}));
|
|
229
257
|
return { sessionId: session.sessionId, status: "running" };
|
|
230
258
|
}
|
|
231
259
|
async sendSessionMessage(params) {
|
|
@@ -247,13 +275,14 @@ class MulticlawsService extends node_events_1.EventEmitter {
|
|
|
247
275
|
this.sessionStore.update(params.sessionId, { status: "failed", error: "agent no longer registered" });
|
|
248
276
|
return { sessionId: params.sessionId, status: "failed", error: "agent no longer registered" };
|
|
249
277
|
}
|
|
250
|
-
|
|
278
|
+
// Fix #5: acquire lock to prevent concurrent runSession on same session
|
|
279
|
+
void this.acquireSessionLock(params.sessionId, () => this.runSession({
|
|
251
280
|
sessionId: params.sessionId,
|
|
252
281
|
agentRecord,
|
|
253
282
|
message: params.message,
|
|
254
|
-
contextId: session.contextId,
|
|
283
|
+
contextId: session.contextId || undefined,
|
|
255
284
|
taskId: session.currentTaskId,
|
|
256
|
-
});
|
|
285
|
+
}));
|
|
257
286
|
return { sessionId: params.sessionId, status: "ok" };
|
|
258
287
|
}
|
|
259
288
|
getSession(sessionId) {
|
|
@@ -297,49 +326,117 @@ class MulticlawsService extends node_events_1.EventEmitter {
|
|
|
297
326
|
if (!session)
|
|
298
327
|
return false;
|
|
299
328
|
this.sessionStore.update(sessionId, { status: "canceled" });
|
|
329
|
+
// Signal the in-flight runSession to abort
|
|
330
|
+
const abort = this.sessionAborts.get(sessionId);
|
|
331
|
+
if (abort)
|
|
332
|
+
abort.abort();
|
|
300
333
|
return true;
|
|
301
334
|
}
|
|
335
|
+
// Fix #5: serialise concurrent calls on the same session
|
|
336
|
+
async acquireSessionLock(sessionId, fn) {
|
|
337
|
+
const prev = this.sessionLocks.get(sessionId) ?? Promise.resolve();
|
|
338
|
+
let release;
|
|
339
|
+
const next = new Promise((r) => { release = r; });
|
|
340
|
+
this.sessionLocks.set(sessionId, next);
|
|
341
|
+
try {
|
|
342
|
+
await prev;
|
|
343
|
+
await fn();
|
|
344
|
+
}
|
|
345
|
+
finally {
|
|
346
|
+
release();
|
|
347
|
+
if (this.sessionLocks.get(sessionId) === next) {
|
|
348
|
+
this.sessionLocks.delete(sessionId);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
302
352
|
async runSession(params) {
|
|
303
|
-
const timeout = params.timeoutMs ?? 5 * 60 * 1000;
|
|
304
|
-
const
|
|
305
|
-
const
|
|
353
|
+
const timeout = params.timeoutMs ?? 5 * 60 * 1000;
|
|
354
|
+
const deadline = Date.now() + timeout;
|
|
355
|
+
const abortController = new AbortController();
|
|
356
|
+
this.sessionAborts.set(params.sessionId, abortController);
|
|
357
|
+
const timer = setTimeout(() => abortController.abort(), timeout);
|
|
306
358
|
try {
|
|
307
359
|
const client = await this.createA2AClient(params.agentRecord);
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
360
|
+
const withAbort = (p) => {
|
|
361
|
+
if (abortController.signal.aborted) {
|
|
362
|
+
return Promise.reject(new Error("session canceled"));
|
|
363
|
+
}
|
|
364
|
+
return new Promise((resolve, reject) => {
|
|
365
|
+
const onAbort = () => {
|
|
366
|
+
reject(new Error(this.sessionStore.get(params.sessionId)?.status === "canceled"
|
|
367
|
+
? "session canceled"
|
|
368
|
+
: "session timeout"));
|
|
369
|
+
};
|
|
370
|
+
abortController.signal.addEventListener("abort", onAbort, { once: true });
|
|
371
|
+
p.then((val) => { abortController.signal.removeEventListener("abort", onAbort); resolve(val); }, (err) => { abortController.signal.removeEventListener("abort", onAbort); reject(err); });
|
|
372
|
+
});
|
|
373
|
+
};
|
|
374
|
+
let result = await withAbort(client.sendMessage({
|
|
375
|
+
message: {
|
|
376
|
+
kind: "message",
|
|
377
|
+
role: "user",
|
|
378
|
+
parts: [{ kind: "text", text: params.message }],
|
|
379
|
+
messageId: (0, node_crypto_1.randomUUID)(),
|
|
380
|
+
// Fix #4: only pass contextId if we have a server-assigned one
|
|
381
|
+
...(params.contextId ? { contextId: params.contextId } : {}),
|
|
382
|
+
...(params.taskId ? { taskId: params.taskId } : {}),
|
|
383
|
+
},
|
|
384
|
+
}));
|
|
385
|
+
// Fix #1: poll until terminal state if server returns working/submitted
|
|
386
|
+
const POLL_DELAYS = [1000, 2000, 3000, 5000];
|
|
387
|
+
let pollAttempt = 0;
|
|
388
|
+
while (true) {
|
|
389
|
+
const state = this.extractResultState(result);
|
|
390
|
+
const remoteTaskId = "id" in result ? result.id : undefined;
|
|
391
|
+
if (state !== "working" && state !== "submitted")
|
|
392
|
+
break;
|
|
393
|
+
if (!remoteTaskId)
|
|
394
|
+
break; // can't poll without task id
|
|
395
|
+
if (Date.now() >= deadline)
|
|
396
|
+
throw new Error("session timeout");
|
|
397
|
+
const delay = POLL_DELAYS[Math.min(pollAttempt, POLL_DELAYS.length - 1)];
|
|
398
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
399
|
+
pollAttempt++;
|
|
400
|
+
result = await withAbort(client.getTask({ id: remoteTaskId, historyLength: 10 }));
|
|
401
|
+
}
|
|
402
|
+
// Check if session was canceled while we were running
|
|
403
|
+
const current = this.sessionStore.get(params.sessionId);
|
|
404
|
+
if (current?.status === "canceled")
|
|
405
|
+
return;
|
|
321
406
|
await this.handleSessionResult(params.sessionId, result);
|
|
322
407
|
}
|
|
323
408
|
catch (err) {
|
|
409
|
+
// Don't overwrite a user-initiated cancel
|
|
410
|
+
const current = this.sessionStore.get(params.sessionId);
|
|
411
|
+
if (current?.status === "canceled")
|
|
412
|
+
return;
|
|
324
413
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
325
414
|
this.sessionStore.update(params.sessionId, { status: "failed", error: errorMsg });
|
|
326
415
|
await this.notifySessionUpdate(params.sessionId, "failed");
|
|
327
416
|
}
|
|
328
417
|
finally {
|
|
329
418
|
clearTimeout(timer);
|
|
419
|
+
this.sessionAborts.delete(params.sessionId);
|
|
330
420
|
}
|
|
331
421
|
}
|
|
422
|
+
extractResultState(result) {
|
|
423
|
+
if ("status" in result && result.status) {
|
|
424
|
+
return result.status?.state ?? "unknown";
|
|
425
|
+
}
|
|
426
|
+
return "completed"; // plain Message = completed
|
|
427
|
+
}
|
|
332
428
|
async handleSessionResult(sessionId, result) {
|
|
333
|
-
// Extract content
|
|
334
429
|
let content = "";
|
|
335
430
|
let state = "completed";
|
|
336
431
|
let remoteTaskId;
|
|
432
|
+
let serverContextId;
|
|
337
433
|
if ("status" in result && result.status) {
|
|
338
434
|
const task = result;
|
|
339
435
|
state = task.status?.state ?? "completed";
|
|
340
436
|
remoteTaskId = task.id;
|
|
437
|
+
// Fix #4: capture server-assigned contextId
|
|
438
|
+
serverContextId = task.contextId;
|
|
341
439
|
content = this.extractArtifactText(task);
|
|
342
|
-
// Also try to get text from task messages if artifacts empty
|
|
343
440
|
if (!content && task.history?.length) {
|
|
344
441
|
const lastAgentMsg = [...task.history].reverse().find((m) => m.role === "agent");
|
|
345
442
|
if (lastAgentMsg) {
|
|
@@ -353,23 +450,24 @@ class MulticlawsService extends node_events_1.EventEmitter {
|
|
|
353
450
|
else {
|
|
354
451
|
const msg = result;
|
|
355
452
|
remoteTaskId = msg.taskId;
|
|
453
|
+
serverContextId = msg.contextId;
|
|
356
454
|
content = msg.parts
|
|
357
455
|
?.filter((p) => p.kind === "text")
|
|
358
456
|
.map((p) => p.text)
|
|
359
457
|
.join("\n") ?? "";
|
|
360
458
|
}
|
|
361
|
-
//
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
}
|
|
370
|
-
// Update session state
|
|
459
|
+
// Fix #6: always record agent message, use placeholder when content is empty
|
|
460
|
+
this.sessionStore.appendMessage(sessionId, {
|
|
461
|
+
role: "agent",
|
|
462
|
+
content: content || "(no text output)",
|
|
463
|
+
timestampMs: Date.now(),
|
|
464
|
+
taskId: remoteTaskId,
|
|
465
|
+
});
|
|
466
|
+
// Fix #4: update contextId with server-assigned value
|
|
467
|
+
const contextUpdate = serverContextId ? { contextId: serverContextId } : {};
|
|
371
468
|
if (state === "input-required" || state === "auth-required") {
|
|
372
469
|
this.sessionStore.update(sessionId, {
|
|
470
|
+
...contextUpdate,
|
|
373
471
|
status: "input-required",
|
|
374
472
|
currentTaskId: remoteTaskId,
|
|
375
473
|
});
|
|
@@ -377,36 +475,54 @@ class MulticlawsService extends node_events_1.EventEmitter {
|
|
|
377
475
|
}
|
|
378
476
|
else if (state === "failed" || state === "rejected") {
|
|
379
477
|
this.sessionStore.update(sessionId, {
|
|
478
|
+
...contextUpdate,
|
|
380
479
|
status: "failed",
|
|
381
480
|
currentTaskId: remoteTaskId,
|
|
382
481
|
error: content || "remote task failed",
|
|
383
482
|
});
|
|
384
483
|
await this.notifySessionUpdate(sessionId, "failed");
|
|
385
484
|
}
|
|
386
|
-
else if (state === "completed"
|
|
485
|
+
else if (state === "completed") {
|
|
387
486
|
this.sessionStore.update(sessionId, {
|
|
487
|
+
...contextUpdate,
|
|
388
488
|
status: "completed",
|
|
389
489
|
currentTaskId: remoteTaskId,
|
|
390
490
|
});
|
|
391
491
|
await this.notifySessionUpdate(sessionId, "completed");
|
|
392
492
|
}
|
|
493
|
+
else if (state === "canceled") {
|
|
494
|
+
// Fix #2: canceled remote task → local status "canceled", not "completed"
|
|
495
|
+
this.sessionStore.update(sessionId, {
|
|
496
|
+
...contextUpdate,
|
|
497
|
+
status: "canceled",
|
|
498
|
+
currentTaskId: remoteTaskId,
|
|
499
|
+
error: "remote task was canceled",
|
|
500
|
+
});
|
|
501
|
+
await this.notifySessionUpdate(sessionId, "failed");
|
|
502
|
+
}
|
|
393
503
|
else {
|
|
394
|
-
// working / submitted / unknown
|
|
395
|
-
this.sessionStore.update(sessionId, { currentTaskId: remoteTaskId });
|
|
504
|
+
// working / submitted / unknown: runSession's polling loop handles these
|
|
505
|
+
this.sessionStore.update(sessionId, { ...contextUpdate, currentTaskId: remoteTaskId });
|
|
396
506
|
}
|
|
397
507
|
}
|
|
398
508
|
async notifySessionUpdate(sessionId, event) {
|
|
399
|
-
if (!this.options.gatewayConfig)
|
|
509
|
+
if (!this.options.gatewayConfig) {
|
|
510
|
+
this.log("warn", `session ${sessionId} ${event} but gateway config unavailable — user won't be notified. Check gateway.auth.token in config.`);
|
|
400
511
|
return;
|
|
512
|
+
}
|
|
401
513
|
const session = this.sessionStore.get(sessionId);
|
|
402
514
|
if (!session)
|
|
403
515
|
return;
|
|
404
516
|
const lastAgentMsg = [...session.messages].reverse().find((m) => m.role === "agent");
|
|
405
|
-
const
|
|
517
|
+
const rawContent = lastAgentMsg?.content ?? "";
|
|
518
|
+
// Don't show the placeholder text in user-facing notifications
|
|
519
|
+
const content = rawContent === "(no text output)" ? "" : rawContent;
|
|
406
520
|
const agentName = session.agentName;
|
|
407
521
|
let message;
|
|
408
522
|
if (event === "completed") {
|
|
409
|
-
message =
|
|
523
|
+
message = content
|
|
524
|
+
? [`✅ **${agentName} 任务完成** (session: \`${sessionId}\`)`, "", content].join("\n")
|
|
525
|
+
: `✅ **${agentName} 任务完成** (session: \`${sessionId}\`) — 任务已执行但无文本输出,可能产生了 artifacts。`;
|
|
410
526
|
}
|
|
411
527
|
else if (event === "input-required") {
|
|
412
528
|
message = [
|
|
@@ -586,7 +702,7 @@ class MulticlawsService extends node_events_1.EventEmitter {
|
|
|
586
702
|
}
|
|
587
703
|
}));
|
|
588
704
|
for (const m of others) {
|
|
589
|
-
await this.agentRegistry.
|
|
705
|
+
await this.agentRegistry.removeTeamSource(m.url, team.teamId);
|
|
590
706
|
}
|
|
591
707
|
await this.teamStore.deleteTeam(team.teamId);
|
|
592
708
|
this.log("info", `left team ${team.teamId}`);
|
|
@@ -662,10 +778,14 @@ class MulticlawsService extends node_events_1.EventEmitter {
|
|
|
662
778
|
name: member.name,
|
|
663
779
|
description: member.description,
|
|
664
780
|
});
|
|
781
|
+
await this.agentRegistry.addTeamSource(normalizedUrl, team.teamId);
|
|
665
782
|
// Broadcast to other members if new
|
|
666
783
|
if (!alreadyKnown) {
|
|
667
784
|
const selfNormalized = this.selfUrl.replace(/\/+$/, "");
|
|
668
|
-
|
|
785
|
+
// Re-read team after addMember to get the latest member list,
|
|
786
|
+
// avoiding missed broadcasts when multiple members join concurrently
|
|
787
|
+
const freshTeam = await this.teamStore.getTeam(team.teamId);
|
|
788
|
+
const others = (freshTeam?.members ?? team.members).filter((m) => m.url.replace(/\/+$/, "") !== normalizedUrl &&
|
|
669
789
|
m.url.replace(/\/+$/, "") !== selfNormalized);
|
|
670
790
|
for (const other of others) {
|
|
671
791
|
void this.fetchWithRetry(`${other.url}/team/${team.teamId}/announce`, {
|
|
@@ -702,7 +822,7 @@ class MulticlawsService extends node_events_1.EventEmitter {
|
|
|
702
822
|
}
|
|
703
823
|
const normalizedUrl = parsed.data.url.replace(/\/+$/, "");
|
|
704
824
|
await this.teamStore.removeMember(team.teamId, normalizedUrl);
|
|
705
|
-
await this.agentRegistry.
|
|
825
|
+
await this.agentRegistry.removeTeamSource(normalizedUrl, team.teamId);
|
|
706
826
|
res.json({ ok: true });
|
|
707
827
|
}
|
|
708
828
|
catch (err) {
|
|
@@ -752,12 +872,13 @@ class MulticlawsService extends node_events_1.EventEmitter {
|
|
|
752
872
|
const selfNormalized = this.selfUrl.replace(/\/+$/, "");
|
|
753
873
|
const displayName = this.options.displayName ?? node_os_1.default.hostname();
|
|
754
874
|
for (const team of teams) {
|
|
755
|
-
// Update self in team store
|
|
875
|
+
// Update self in team store, preserving original joinedAtMs
|
|
876
|
+
const selfMember = team.members.find((m) => m.url.replace(/\/+$/, "") === selfNormalized);
|
|
756
877
|
await this.teamStore.addMember(team.teamId, {
|
|
757
878
|
url: this.selfUrl,
|
|
758
879
|
name: displayName,
|
|
759
880
|
description: this.profileDescription,
|
|
760
|
-
joinedAtMs: Date.now(),
|
|
881
|
+
joinedAtMs: selfMember?.joinedAtMs ?? Date.now(),
|
|
761
882
|
});
|
|
762
883
|
// Broadcast to other members
|
|
763
884
|
const others = team.members.filter((m) => m.url.replace(/\/+$/, "") !== selfNormalized);
|
|
@@ -785,14 +906,20 @@ class MulticlawsService extends node_events_1.EventEmitter {
|
|
|
785
906
|
const client = await this.clientFactory.createFromUrl(m.url);
|
|
786
907
|
const card = await client.getAgentCard();
|
|
787
908
|
if (card.description) {
|
|
788
|
-
|
|
909
|
+
// Use addMember (which uses withJsonLock) instead of saveTeam
|
|
910
|
+
// to avoid overwriting concurrent member additions
|
|
911
|
+
await this.teamStore.addMember(team.teamId, {
|
|
912
|
+
url: m.url,
|
|
913
|
+
name: m.name,
|
|
914
|
+
description: card.description,
|
|
915
|
+
joinedAtMs: m.joinedAtMs,
|
|
916
|
+
});
|
|
789
917
|
}
|
|
790
918
|
}
|
|
791
919
|
catch {
|
|
792
920
|
this.log("warn", `failed to fetch Agent Card from ${m.url}`);
|
|
793
921
|
}
|
|
794
922
|
}));
|
|
795
|
-
await this.teamStore.saveTeam(team);
|
|
796
923
|
}
|
|
797
924
|
async syncTeamToRegistry(team) {
|
|
798
925
|
const selfNormalized = this.selfUrl.replace(/\/+$/, "");
|
|
@@ -804,6 +931,7 @@ class MulticlawsService extends node_events_1.EventEmitter {
|
|
|
804
931
|
name: member.name,
|
|
805
932
|
description: member.description,
|
|
806
933
|
});
|
|
934
|
+
await this.agentRegistry.addTeamSource(member.url, team.teamId);
|
|
807
935
|
}
|
|
808
936
|
}
|
|
809
937
|
async createA2AClient(agent) {
|
|
@@ -10,9 +10,24 @@ const promises_1 = __importDefault(require("node:fs/promises"));
|
|
|
10
10
|
const node_path_1 = __importDefault(require("node:path"));
|
|
11
11
|
const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
12
12
|
const MAX_SESSIONS = 1_000;
|
|
13
|
+
const MAX_MESSAGES_PER_SESSION = 200;
|
|
13
14
|
function emptyStore() {
|
|
14
15
|
return { version: 1, sessions: [] };
|
|
15
16
|
}
|
|
17
|
+
function normalizeStore(raw) {
|
|
18
|
+
if (raw.version !== 1 || !Array.isArray(raw.sessions)) {
|
|
19
|
+
return emptyStore();
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
version: 1,
|
|
23
|
+
sessions: raw.sessions.filter((s) => s &&
|
|
24
|
+
typeof s.sessionId === "string" &&
|
|
25
|
+
typeof s.agentUrl === "string" &&
|
|
26
|
+
typeof s.status === "string" &&
|
|
27
|
+
typeof s.createdAtMs === "number" &&
|
|
28
|
+
Array.isArray(s.messages)),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
16
31
|
class SessionStore {
|
|
17
32
|
filePath;
|
|
18
33
|
ttlMs;
|
|
@@ -66,17 +81,18 @@ class SessionStore {
|
|
|
66
81
|
const session = this.get(sessionId);
|
|
67
82
|
if (!session)
|
|
68
83
|
return null;
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
84
|
+
let messages = [...session.messages, msg];
|
|
85
|
+
// Truncate old messages, keeping the most recent ones
|
|
86
|
+
if (messages.length > MAX_MESSAGES_PER_SESSION) {
|
|
87
|
+
messages = messages.slice(-MAX_MESSAGES_PER_SESSION);
|
|
88
|
+
}
|
|
89
|
+
return this.update(sessionId, { messages });
|
|
72
90
|
}
|
|
73
91
|
loadSync() {
|
|
74
92
|
node_fs_1.default.mkdirSync(node_path_1.default.dirname(this.filePath), { recursive: true });
|
|
75
93
|
try {
|
|
76
94
|
const raw = JSON.parse(node_fs_1.default.readFileSync(this.filePath, "utf8"));
|
|
77
|
-
|
|
78
|
-
return emptyStore();
|
|
79
|
-
return raw;
|
|
95
|
+
return normalizeStore(raw);
|
|
80
96
|
}
|
|
81
97
|
catch {
|
|
82
98
|
const store = emptyStore();
|