multiclaws 0.4.1 → 0.4.2
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/index.js +15 -1
- package/dist/service/a2a-adapter.d.ts +1 -0
- package/dist/service/a2a-adapter.js +53 -9
- package/dist/service/agent-registry.d.ts +9 -0
- package/dist/service/agent-registry.js +41 -0
- package/dist/service/multiclaws-service.d.ts +7 -1
- package/dist/service/multiclaws-service.js +166 -47
- package/dist/service/session-store.js +7 -3
- package/package.json +1 -1
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 = {
|
|
@@ -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");
|
|
@@ -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
|
/**
|
|
@@ -10,6 +10,34 @@ function extractTextFromMessage(message) {
|
|
|
10
10
|
.map((p) => p.text)
|
|
11
11
|
.join("\n");
|
|
12
12
|
}
|
|
13
|
+
function buildTaskWithHistory(context) {
|
|
14
|
+
const currentText = extractTextFromMessage(context.userMessage);
|
|
15
|
+
const history = context.task?.history ?? [];
|
|
16
|
+
if (history.length <= 1) {
|
|
17
|
+
// First message — no prior context
|
|
18
|
+
return currentText;
|
|
19
|
+
}
|
|
20
|
+
// Build context from previous exchanges (exclude the last message, that's currentText)
|
|
21
|
+
const prior = history
|
|
22
|
+
.slice(0, -1)
|
|
23
|
+
.slice(-8) // keep last 8 messages max to avoid huge prompts
|
|
24
|
+
.map((m) => {
|
|
25
|
+
const text = extractTextFromMessage(m);
|
|
26
|
+
const role = m.role === "agent" ? "[agent]" : "[user]";
|
|
27
|
+
return `[${role}]: ${text}`;
|
|
28
|
+
})
|
|
29
|
+
.filter((line) => line.length > 10)
|
|
30
|
+
.join("\n");
|
|
31
|
+
if (!prior)
|
|
32
|
+
return currentText;
|
|
33
|
+
return [
|
|
34
|
+
"[conversation history]",
|
|
35
|
+
prior,
|
|
36
|
+
"",
|
|
37
|
+
"[latest message]",
|
|
38
|
+
currentText,
|
|
39
|
+
].join("\n");
|
|
40
|
+
}
|
|
13
41
|
function sleep(ms) {
|
|
14
42
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
15
43
|
}
|
|
@@ -42,13 +70,15 @@ class OpenClawAgentExecutor {
|
|
|
42
70
|
gatewayConfig;
|
|
43
71
|
taskTracker;
|
|
44
72
|
logger;
|
|
73
|
+
// Map A2A task IDs → internal tracker IDs so cancelTask can find the right record
|
|
74
|
+
a2aToTracker = new Map();
|
|
45
75
|
constructor(options) {
|
|
46
76
|
this.gatewayConfig = options.gatewayConfig;
|
|
47
77
|
this.taskTracker = options.taskTracker;
|
|
48
78
|
this.logger = options.logger;
|
|
49
79
|
}
|
|
50
80
|
async execute(context, eventBus) {
|
|
51
|
-
const taskText =
|
|
81
|
+
const taskText = buildTaskWithHistory(context);
|
|
52
82
|
const taskId = context.taskId;
|
|
53
83
|
if (!taskText.trim()) {
|
|
54
84
|
this.publishMessage(eventBus, "Error: empty task received.");
|
|
@@ -56,14 +86,16 @@ class OpenClawAgentExecutor {
|
|
|
56
86
|
return;
|
|
57
87
|
}
|
|
58
88
|
const fromAgent = context.userMessage.metadata?.agentUrl ?? "unknown";
|
|
59
|
-
this.taskTracker.create({
|
|
89
|
+
const tracked = this.taskTracker.create({
|
|
60
90
|
fromPeerId: fromAgent,
|
|
61
91
|
toPeerId: "local",
|
|
62
92
|
task: taskText,
|
|
63
93
|
});
|
|
94
|
+
const trackedId = tracked.taskId;
|
|
95
|
+
this.a2aToTracker.set(taskId, trackedId);
|
|
64
96
|
if (!this.gatewayConfig) {
|
|
65
97
|
this.logger.error("[a2a-adapter] gateway config not available, cannot execute task");
|
|
66
|
-
this.taskTracker.update(
|
|
98
|
+
this.taskTracker.update(trackedId, { status: "failed", error: "gateway config not available" });
|
|
67
99
|
this.publishMessage(eventBus, "Error: gateway config not available, cannot execute task.");
|
|
68
100
|
eventBus.finished();
|
|
69
101
|
return;
|
|
@@ -90,16 +122,19 @@ class OpenClawAgentExecutor {
|
|
|
90
122
|
this.logger.info(`[a2a-adapter] task ${taskId} spawned as ${childSessionKey}, waiting for result...`);
|
|
91
123
|
const output = await this.waitForCompletion(childSessionKey, 180_000);
|
|
92
124
|
// 3. Return result
|
|
93
|
-
this.taskTracker.update(
|
|
125
|
+
this.taskTracker.update(trackedId, { status: "completed", result: output });
|
|
94
126
|
this.logger.info(`[a2a-adapter] task ${taskId} completed`);
|
|
95
127
|
this.publishMessage(eventBus, output || "Task completed with no output.");
|
|
96
128
|
}
|
|
97
129
|
catch (err) {
|
|
98
130
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
99
131
|
this.logger.error(`[a2a-adapter] task execution failed: ${errorMsg}`);
|
|
100
|
-
this.taskTracker.update(
|
|
132
|
+
this.taskTracker.update(trackedId, { status: "failed", error: errorMsg });
|
|
101
133
|
this.publishMessage(eventBus, `Error: ${errorMsg}`);
|
|
102
134
|
}
|
|
135
|
+
finally {
|
|
136
|
+
this.a2aToTracker.delete(taskId);
|
|
137
|
+
}
|
|
103
138
|
eventBus.finished();
|
|
104
139
|
}
|
|
105
140
|
/**
|
|
@@ -155,14 +190,21 @@ class OpenClawAgentExecutor {
|
|
|
155
190
|
const messages = (details.messages ?? []);
|
|
156
191
|
if (messages.length === 0)
|
|
157
192
|
return null;
|
|
158
|
-
// If no explicit flag,
|
|
193
|
+
// If no explicit flag, use conservative heuristic: only consider
|
|
194
|
+
// complete if the last message is an assistant message with text
|
|
195
|
+
// and NO tool calls (tool calls indicate ongoing work)
|
|
159
196
|
if (details.isComplete === undefined) {
|
|
160
197
|
const lastMsg = messages[messages.length - 1];
|
|
161
|
-
if (lastMsg
|
|
198
|
+
if (!lastMsg || lastMsg.role !== "assistant")
|
|
199
|
+
return null;
|
|
200
|
+
if (Array.isArray(lastMsg.content)) {
|
|
162
201
|
const content = lastMsg.content;
|
|
163
202
|
const hasToolCalls = content.some((c) => c?.type === "toolCall" || c?.type === "tool_use");
|
|
203
|
+
// If there are ANY tool calls, assume still running
|
|
204
|
+
if (hasToolCalls)
|
|
205
|
+
return null;
|
|
164
206
|
const hasText = content.some((c) => c?.type === "text" && typeof c.text === "string" && c.text.trim());
|
|
165
|
-
if (
|
|
207
|
+
if (!hasText)
|
|
166
208
|
return null;
|
|
167
209
|
}
|
|
168
210
|
}
|
|
@@ -188,7 +230,9 @@ class OpenClawAgentExecutor {
|
|
|
188
230
|
return null;
|
|
189
231
|
}
|
|
190
232
|
async cancelTask(taskId, eventBus) {
|
|
191
|
-
this.
|
|
233
|
+
const trackedId = this.a2aToTracker.get(taskId) ?? taskId;
|
|
234
|
+
this.taskTracker.update(trackedId, { status: "failed", error: "canceled" });
|
|
235
|
+
this.a2aToTracker.delete(taskId);
|
|
192
236
|
this.publishMessage(eventBus, "Task was canceled.");
|
|
193
237
|
eventBus.finished();
|
|
194
238
|
}
|
|
@@ -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
|
}
|
|
@@ -73,6 +73,47 @@ class AgentRegistry {
|
|
|
73
73
|
const normalizedUrl = url.replace(/\/+$/, "");
|
|
74
74
|
return store.agents.find((a) => a.url === normalizedUrl) ?? null;
|
|
75
75
|
}
|
|
76
|
+
async addTeamSource(url, teamId) {
|
|
77
|
+
await (0, json_store_1.withJsonLock)(this.filePath, emptyStore(), async () => {
|
|
78
|
+
const store = await this.readStore();
|
|
79
|
+
const normalizedUrl = url.replace(/\/+$/, "");
|
|
80
|
+
const agent = store.agents.find((a) => a.url === normalizedUrl);
|
|
81
|
+
if (agent) {
|
|
82
|
+
const teams = new Set(agent.teamIds ?? []);
|
|
83
|
+
teams.add(teamId);
|
|
84
|
+
agent.teamIds = [...teams];
|
|
85
|
+
await (0, json_store_1.writeJsonAtomically)(this.filePath, store);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Remove a team source from an agent. Returns true if the agent
|
|
91
|
+
* was fully removed (no remaining sources), false otherwise.
|
|
92
|
+
* Manually-added agents (no teamIds) are never removed by this method.
|
|
93
|
+
*/
|
|
94
|
+
async removeTeamSource(url, teamId) {
|
|
95
|
+
return await (0, json_store_1.withJsonLock)(this.filePath, emptyStore(), async () => {
|
|
96
|
+
const store = await this.readStore();
|
|
97
|
+
const normalizedUrl = url.replace(/\/+$/, "");
|
|
98
|
+
const agent = store.agents.find((a) => a.url === normalizedUrl);
|
|
99
|
+
if (!agent)
|
|
100
|
+
return false;
|
|
101
|
+
// Agent was manually added (no team tracking) — never auto-remove
|
|
102
|
+
if (!agent.teamIds || agent.teamIds.length === 0)
|
|
103
|
+
return false;
|
|
104
|
+
const teams = new Set(agent.teamIds);
|
|
105
|
+
teams.delete(teamId);
|
|
106
|
+
if (teams.size === 0) {
|
|
107
|
+
// No team sources remain — remove entirely
|
|
108
|
+
store.agents = store.agents.filter((a) => a.url !== normalizedUrl);
|
|
109
|
+
await (0, json_store_1.writeJsonAtomically)(this.filePath, store);
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
agent.teamIds = [...teams];
|
|
113
|
+
await (0, json_store_1.writeJsonAtomically)(this.filePath, store);
|
|
114
|
+
return false;
|
|
115
|
+
});
|
|
116
|
+
}
|
|
76
117
|
async updateDescription(url, description) {
|
|
77
118
|
await (0, json_store_1.withJsonLock)(this.filePath, emptyStore(), async () => {
|
|
78
119
|
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;
|
|
@@ -153,6 +157,18 @@ class MulticlawsService extends node_events_1.EventEmitter {
|
|
|
153
157
|
if (!this.started)
|
|
154
158
|
return;
|
|
155
159
|
this.started = false;
|
|
160
|
+
// Abort all in-flight sessions so they don't hang
|
|
161
|
+
for (const [, abort] of this.sessionAborts) {
|
|
162
|
+
abort.abort();
|
|
163
|
+
}
|
|
164
|
+
// Wait for session locks to drain (with a cap)
|
|
165
|
+
if (this.sessionLocks.size > 0) {
|
|
166
|
+
const pending = [...this.sessionLocks.values()];
|
|
167
|
+
await Promise.race([
|
|
168
|
+
Promise.allSettled(pending),
|
|
169
|
+
new Promise((r) => setTimeout(r, 5_000)),
|
|
170
|
+
]);
|
|
171
|
+
}
|
|
156
172
|
this.taskTracker.destroy();
|
|
157
173
|
this.httpRateLimiter.destroy();
|
|
158
174
|
await new Promise((resolve) => {
|
|
@@ -165,6 +181,7 @@ class MulticlawsService extends node_events_1.EventEmitter {
|
|
|
165
181
|
this.httpServer = null;
|
|
166
182
|
}
|
|
167
183
|
updateGatewayConfig(config) {
|
|
184
|
+
this.options.gatewayConfig = config;
|
|
168
185
|
this.agentExecutor?.updateGatewayConfig(config);
|
|
169
186
|
}
|
|
170
187
|
/* ---------------------------------------------------------------- */
|
|
@@ -178,20 +195,23 @@ class MulticlawsService extends node_events_1.EventEmitter {
|
|
|
178
195
|
try {
|
|
179
196
|
const client = await this.clientFactory.createFromUrl(normalizedUrl);
|
|
180
197
|
const card = await client.getAgentCard();
|
|
181
|
-
|
|
198
|
+
const record = await this.agentRegistry.add({
|
|
182
199
|
url: normalizedUrl,
|
|
183
200
|
name: card.name ?? normalizedUrl,
|
|
184
201
|
description: card.description ?? "",
|
|
185
202
|
skills: card.skills?.map((s) => s.name ?? s.id) ?? [],
|
|
186
203
|
apiKey: params.apiKey,
|
|
187
204
|
});
|
|
205
|
+
return { ...record, reachable: true };
|
|
188
206
|
}
|
|
189
207
|
catch {
|
|
190
|
-
|
|
208
|
+
this.log("warn", `agent at ${normalizedUrl} is not reachable, adding with limited info`);
|
|
209
|
+
const record = await this.agentRegistry.add({
|
|
191
210
|
url: normalizedUrl,
|
|
192
211
|
name: normalizedUrl,
|
|
193
212
|
apiKey: params.apiKey,
|
|
194
213
|
});
|
|
214
|
+
return { ...record, reachable: false };
|
|
195
215
|
}
|
|
196
216
|
}
|
|
197
217
|
async removeAgent(url) {
|
|
@@ -205,27 +225,29 @@ class MulticlawsService extends node_events_1.EventEmitter {
|
|
|
205
225
|
/* ---------------------------------------------------------------- */
|
|
206
226
|
async startSession(params) {
|
|
207
227
|
const agentRecord = await this.agentRegistry.get(params.agentUrl);
|
|
228
|
+
// Fix #3: throw instead of returning empty sessionId
|
|
208
229
|
if (!agentRecord) {
|
|
209
|
-
|
|
230
|
+
throw new Error(`unknown agent: ${params.agentUrl}`);
|
|
210
231
|
}
|
|
211
|
-
|
|
232
|
+
// Fix #4: don't pre-generate contextId; let server assign it.
|
|
233
|
+
// Use a local placeholder that gets replaced after first response.
|
|
212
234
|
const session = this.sessionStore.create({
|
|
213
235
|
agentUrl: params.agentUrl,
|
|
214
236
|
agentName: agentRecord.name,
|
|
215
|
-
contextId,
|
|
237
|
+
contextId: "", // will be filled in from server response
|
|
216
238
|
});
|
|
217
239
|
this.sessionStore.appendMessage(session.sessionId, {
|
|
218
240
|
role: "user",
|
|
219
241
|
content: params.message,
|
|
220
242
|
timestampMs: Date.now(),
|
|
221
243
|
});
|
|
222
|
-
void this.runSession({
|
|
244
|
+
void this.acquireSessionLock(session.sessionId, () => this.runSession({
|
|
223
245
|
sessionId: session.sessionId,
|
|
224
246
|
agentRecord,
|
|
225
247
|
message: params.message,
|
|
226
|
-
contextId,
|
|
248
|
+
contextId: undefined, // first message: no contextId
|
|
227
249
|
taskId: undefined,
|
|
228
|
-
});
|
|
250
|
+
}));
|
|
229
251
|
return { sessionId: session.sessionId, status: "running" };
|
|
230
252
|
}
|
|
231
253
|
async sendSessionMessage(params) {
|
|
@@ -247,13 +269,14 @@ class MulticlawsService extends node_events_1.EventEmitter {
|
|
|
247
269
|
this.sessionStore.update(params.sessionId, { status: "failed", error: "agent no longer registered" });
|
|
248
270
|
return { sessionId: params.sessionId, status: "failed", error: "agent no longer registered" };
|
|
249
271
|
}
|
|
250
|
-
|
|
272
|
+
// Fix #5: acquire lock to prevent concurrent runSession on same session
|
|
273
|
+
void this.acquireSessionLock(params.sessionId, () => this.runSession({
|
|
251
274
|
sessionId: params.sessionId,
|
|
252
275
|
agentRecord,
|
|
253
276
|
message: params.message,
|
|
254
|
-
contextId: session.contextId,
|
|
277
|
+
contextId: session.contextId || undefined,
|
|
255
278
|
taskId: session.currentTaskId,
|
|
256
|
-
});
|
|
279
|
+
}));
|
|
257
280
|
return { sessionId: params.sessionId, status: "ok" };
|
|
258
281
|
}
|
|
259
282
|
getSession(sessionId) {
|
|
@@ -297,49 +320,115 @@ class MulticlawsService extends node_events_1.EventEmitter {
|
|
|
297
320
|
if (!session)
|
|
298
321
|
return false;
|
|
299
322
|
this.sessionStore.update(sessionId, { status: "canceled" });
|
|
323
|
+
// Signal the in-flight runSession to abort
|
|
324
|
+
const abort = this.sessionAborts.get(sessionId);
|
|
325
|
+
if (abort)
|
|
326
|
+
abort.abort();
|
|
300
327
|
return true;
|
|
301
328
|
}
|
|
329
|
+
// Fix #5: serialise concurrent calls on the same session
|
|
330
|
+
async acquireSessionLock(sessionId, fn) {
|
|
331
|
+
const prev = this.sessionLocks.get(sessionId) ?? Promise.resolve();
|
|
332
|
+
let release;
|
|
333
|
+
const next = new Promise((r) => { release = r; });
|
|
334
|
+
this.sessionLocks.set(sessionId, next);
|
|
335
|
+
try {
|
|
336
|
+
await prev;
|
|
337
|
+
await fn();
|
|
338
|
+
}
|
|
339
|
+
finally {
|
|
340
|
+
release();
|
|
341
|
+
if (this.sessionLocks.get(sessionId) === next) {
|
|
342
|
+
this.sessionLocks.delete(sessionId);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
302
346
|
async runSession(params) {
|
|
303
|
-
const timeout = params.timeoutMs ?? 5 * 60 * 1000;
|
|
304
|
-
const
|
|
305
|
-
const
|
|
347
|
+
const timeout = params.timeoutMs ?? 5 * 60 * 1000;
|
|
348
|
+
const deadline = Date.now() + timeout;
|
|
349
|
+
const abortController = new AbortController();
|
|
350
|
+
this.sessionAborts.set(params.sessionId, abortController);
|
|
351
|
+
const timer = setTimeout(() => abortController.abort(), timeout);
|
|
306
352
|
try {
|
|
307
353
|
const client = await this.createA2AClient(params.agentRecord);
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
354
|
+
const withAbort = (p) => Promise.race([
|
|
355
|
+
p,
|
|
356
|
+
new Promise((_, reject) => {
|
|
357
|
+
if (abortController.signal.aborted) {
|
|
358
|
+
reject(new Error("session canceled"));
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
abortController.signal.addEventListener("abort", () => reject(new Error(this.sessionStore.get(params.sessionId)?.status === "canceled"
|
|
362
|
+
? "session canceled"
|
|
363
|
+
: "session timeout")));
|
|
318
364
|
}),
|
|
319
|
-
new Promise((_, reject) => timeoutController.signal.addEventListener("abort", () => reject(new Error("session timeout")))),
|
|
320
365
|
]);
|
|
366
|
+
let result = await withAbort(client.sendMessage({
|
|
367
|
+
message: {
|
|
368
|
+
kind: "message",
|
|
369
|
+
role: "user",
|
|
370
|
+
parts: [{ kind: "text", text: params.message }],
|
|
371
|
+
messageId: (0, node_crypto_1.randomUUID)(),
|
|
372
|
+
// Fix #4: only pass contextId if we have a server-assigned one
|
|
373
|
+
...(params.contextId ? { contextId: params.contextId } : {}),
|
|
374
|
+
...(params.taskId ? { taskId: params.taskId } : {}),
|
|
375
|
+
},
|
|
376
|
+
}));
|
|
377
|
+
// Fix #1: poll until terminal state if server returns working/submitted
|
|
378
|
+
const POLL_DELAYS = [1000, 2000, 3000, 5000];
|
|
379
|
+
let pollAttempt = 0;
|
|
380
|
+
while (true) {
|
|
381
|
+
const state = this.extractResultState(result);
|
|
382
|
+
const remoteTaskId = "id" in result ? result.id : undefined;
|
|
383
|
+
if (state !== "working" && state !== "submitted")
|
|
384
|
+
break;
|
|
385
|
+
if (!remoteTaskId)
|
|
386
|
+
break; // can't poll without task id
|
|
387
|
+
if (Date.now() >= deadline)
|
|
388
|
+
throw new Error("session timeout");
|
|
389
|
+
const delay = POLL_DELAYS[Math.min(pollAttempt, POLL_DELAYS.length - 1)];
|
|
390
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
391
|
+
pollAttempt++;
|
|
392
|
+
result = await withAbort(client.getTask({ id: remoteTaskId, historyLength: 10 }));
|
|
393
|
+
}
|
|
394
|
+
// Check if session was canceled while we were running
|
|
395
|
+
const current = this.sessionStore.get(params.sessionId);
|
|
396
|
+
if (current?.status === "canceled")
|
|
397
|
+
return;
|
|
321
398
|
await this.handleSessionResult(params.sessionId, result);
|
|
322
399
|
}
|
|
323
400
|
catch (err) {
|
|
401
|
+
// Don't overwrite a user-initiated cancel
|
|
402
|
+
const current = this.sessionStore.get(params.sessionId);
|
|
403
|
+
if (current?.status === "canceled")
|
|
404
|
+
return;
|
|
324
405
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
325
406
|
this.sessionStore.update(params.sessionId, { status: "failed", error: errorMsg });
|
|
326
407
|
await this.notifySessionUpdate(params.sessionId, "failed");
|
|
327
408
|
}
|
|
328
409
|
finally {
|
|
329
410
|
clearTimeout(timer);
|
|
411
|
+
this.sessionAborts.delete(params.sessionId);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
extractResultState(result) {
|
|
415
|
+
if ("status" in result && result.status) {
|
|
416
|
+
return result.status?.state ?? "unknown";
|
|
330
417
|
}
|
|
418
|
+
return "completed"; // plain Message = completed
|
|
331
419
|
}
|
|
332
420
|
async handleSessionResult(sessionId, result) {
|
|
333
|
-
// Extract content
|
|
334
421
|
let content = "";
|
|
335
422
|
let state = "completed";
|
|
336
423
|
let remoteTaskId;
|
|
424
|
+
let serverContextId;
|
|
337
425
|
if ("status" in result && result.status) {
|
|
338
426
|
const task = result;
|
|
339
427
|
state = task.status?.state ?? "completed";
|
|
340
428
|
remoteTaskId = task.id;
|
|
429
|
+
// Fix #4: capture server-assigned contextId
|
|
430
|
+
serverContextId = task.contextId;
|
|
341
431
|
content = this.extractArtifactText(task);
|
|
342
|
-
// Also try to get text from task messages if artifacts empty
|
|
343
432
|
if (!content && task.history?.length) {
|
|
344
433
|
const lastAgentMsg = [...task.history].reverse().find((m) => m.role === "agent");
|
|
345
434
|
if (lastAgentMsg) {
|
|
@@ -353,23 +442,24 @@ class MulticlawsService extends node_events_1.EventEmitter {
|
|
|
353
442
|
else {
|
|
354
443
|
const msg = result;
|
|
355
444
|
remoteTaskId = msg.taskId;
|
|
445
|
+
serverContextId = msg.contextId;
|
|
356
446
|
content = msg.parts
|
|
357
447
|
?.filter((p) => p.kind === "text")
|
|
358
448
|
.map((p) => p.text)
|
|
359
449
|
.join("\n") ?? "";
|
|
360
450
|
}
|
|
361
|
-
//
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
}
|
|
370
|
-
// Update session state
|
|
451
|
+
// Fix #6: always record agent message, use placeholder when content is empty
|
|
452
|
+
this.sessionStore.appendMessage(sessionId, {
|
|
453
|
+
role: "agent",
|
|
454
|
+
content: content || "(no text output)",
|
|
455
|
+
timestampMs: Date.now(),
|
|
456
|
+
taskId: remoteTaskId,
|
|
457
|
+
});
|
|
458
|
+
// Fix #4: update contextId with server-assigned value
|
|
459
|
+
const contextUpdate = serverContextId ? { contextId: serverContextId } : {};
|
|
371
460
|
if (state === "input-required" || state === "auth-required") {
|
|
372
461
|
this.sessionStore.update(sessionId, {
|
|
462
|
+
...contextUpdate,
|
|
373
463
|
status: "input-required",
|
|
374
464
|
currentTaskId: remoteTaskId,
|
|
375
465
|
});
|
|
@@ -377,36 +467,54 @@ class MulticlawsService extends node_events_1.EventEmitter {
|
|
|
377
467
|
}
|
|
378
468
|
else if (state === "failed" || state === "rejected") {
|
|
379
469
|
this.sessionStore.update(sessionId, {
|
|
470
|
+
...contextUpdate,
|
|
380
471
|
status: "failed",
|
|
381
472
|
currentTaskId: remoteTaskId,
|
|
382
473
|
error: content || "remote task failed",
|
|
383
474
|
});
|
|
384
475
|
await this.notifySessionUpdate(sessionId, "failed");
|
|
385
476
|
}
|
|
386
|
-
else if (state === "completed"
|
|
477
|
+
else if (state === "completed") {
|
|
387
478
|
this.sessionStore.update(sessionId, {
|
|
479
|
+
...contextUpdate,
|
|
388
480
|
status: "completed",
|
|
389
481
|
currentTaskId: remoteTaskId,
|
|
390
482
|
});
|
|
391
483
|
await this.notifySessionUpdate(sessionId, "completed");
|
|
392
484
|
}
|
|
485
|
+
else if (state === "canceled") {
|
|
486
|
+
// Fix #2: canceled remote task → local status "canceled", not "completed"
|
|
487
|
+
this.sessionStore.update(sessionId, {
|
|
488
|
+
...contextUpdate,
|
|
489
|
+
status: "canceled",
|
|
490
|
+
currentTaskId: remoteTaskId,
|
|
491
|
+
error: "remote task was canceled",
|
|
492
|
+
});
|
|
493
|
+
await this.notifySessionUpdate(sessionId, "failed");
|
|
494
|
+
}
|
|
393
495
|
else {
|
|
394
|
-
// working / submitted / unknown
|
|
395
|
-
this.sessionStore.update(sessionId, { currentTaskId: remoteTaskId });
|
|
496
|
+
// working / submitted / unknown: runSession's polling loop handles these
|
|
497
|
+
this.sessionStore.update(sessionId, { ...contextUpdate, currentTaskId: remoteTaskId });
|
|
396
498
|
}
|
|
397
499
|
}
|
|
398
500
|
async notifySessionUpdate(sessionId, event) {
|
|
399
|
-
if (!this.options.gatewayConfig)
|
|
501
|
+
if (!this.options.gatewayConfig) {
|
|
502
|
+
this.log("warn", `session ${sessionId} ${event} but gateway config unavailable — user won't be notified. Check gateway.auth.token in config.`);
|
|
400
503
|
return;
|
|
504
|
+
}
|
|
401
505
|
const session = this.sessionStore.get(sessionId);
|
|
402
506
|
if (!session)
|
|
403
507
|
return;
|
|
404
508
|
const lastAgentMsg = [...session.messages].reverse().find((m) => m.role === "agent");
|
|
405
|
-
const
|
|
509
|
+
const rawContent = lastAgentMsg?.content ?? "";
|
|
510
|
+
// Don't show the placeholder text in user-facing notifications
|
|
511
|
+
const content = rawContent === "(no text output)" ? "" : rawContent;
|
|
406
512
|
const agentName = session.agentName;
|
|
407
513
|
let message;
|
|
408
514
|
if (event === "completed") {
|
|
409
|
-
message =
|
|
515
|
+
message = content
|
|
516
|
+
? [`✅ **${agentName} 任务完成** (session: \`${sessionId}\`)`, "", content].join("\n")
|
|
517
|
+
: `✅ **${agentName} 任务完成** (session: \`${sessionId}\`) — 任务已执行但无文本输出,可能产生了 artifacts。`;
|
|
410
518
|
}
|
|
411
519
|
else if (event === "input-required") {
|
|
412
520
|
message = [
|
|
@@ -586,7 +694,7 @@ class MulticlawsService extends node_events_1.EventEmitter {
|
|
|
586
694
|
}
|
|
587
695
|
}));
|
|
588
696
|
for (const m of others) {
|
|
589
|
-
await this.agentRegistry.
|
|
697
|
+
await this.agentRegistry.removeTeamSource(m.url, team.teamId);
|
|
590
698
|
}
|
|
591
699
|
await this.teamStore.deleteTeam(team.teamId);
|
|
592
700
|
this.log("info", `left team ${team.teamId}`);
|
|
@@ -662,10 +770,14 @@ class MulticlawsService extends node_events_1.EventEmitter {
|
|
|
662
770
|
name: member.name,
|
|
663
771
|
description: member.description,
|
|
664
772
|
});
|
|
773
|
+
await this.agentRegistry.addTeamSource(normalizedUrl, team.teamId);
|
|
665
774
|
// Broadcast to other members if new
|
|
666
775
|
if (!alreadyKnown) {
|
|
667
776
|
const selfNormalized = this.selfUrl.replace(/\/+$/, "");
|
|
668
|
-
|
|
777
|
+
// Re-read team after addMember to get the latest member list,
|
|
778
|
+
// avoiding missed broadcasts when multiple members join concurrently
|
|
779
|
+
const freshTeam = await this.teamStore.getTeam(team.teamId);
|
|
780
|
+
const others = (freshTeam?.members ?? team.members).filter((m) => m.url.replace(/\/+$/, "") !== normalizedUrl &&
|
|
669
781
|
m.url.replace(/\/+$/, "") !== selfNormalized);
|
|
670
782
|
for (const other of others) {
|
|
671
783
|
void this.fetchWithRetry(`${other.url}/team/${team.teamId}/announce`, {
|
|
@@ -785,14 +897,20 @@ class MulticlawsService extends node_events_1.EventEmitter {
|
|
|
785
897
|
const client = await this.clientFactory.createFromUrl(m.url);
|
|
786
898
|
const card = await client.getAgentCard();
|
|
787
899
|
if (card.description) {
|
|
788
|
-
|
|
900
|
+
// Use addMember (which uses withJsonLock) instead of saveTeam
|
|
901
|
+
// to avoid overwriting concurrent member additions
|
|
902
|
+
await this.teamStore.addMember(team.teamId, {
|
|
903
|
+
url: m.url,
|
|
904
|
+
name: m.name,
|
|
905
|
+
description: card.description,
|
|
906
|
+
joinedAtMs: m.joinedAtMs,
|
|
907
|
+
});
|
|
789
908
|
}
|
|
790
909
|
}
|
|
791
910
|
catch {
|
|
792
911
|
this.log("warn", `failed to fetch Agent Card from ${m.url}`);
|
|
793
912
|
}
|
|
794
913
|
}));
|
|
795
|
-
await this.teamStore.saveTeam(team);
|
|
796
914
|
}
|
|
797
915
|
async syncTeamToRegistry(team) {
|
|
798
916
|
const selfNormalized = this.selfUrl.replace(/\/+$/, "");
|
|
@@ -804,6 +922,7 @@ class MulticlawsService extends node_events_1.EventEmitter {
|
|
|
804
922
|
name: member.name,
|
|
805
923
|
description: member.description,
|
|
806
924
|
});
|
|
925
|
+
await this.agentRegistry.addTeamSource(member.url, team.teamId);
|
|
807
926
|
}
|
|
808
927
|
}
|
|
809
928
|
async createA2AClient(agent) {
|
|
@@ -10,6 +10,7 @@ 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
|
}
|
|
@@ -66,9 +67,12 @@ class SessionStore {
|
|
|
66
67
|
const session = this.get(sessionId);
|
|
67
68
|
if (!session)
|
|
68
69
|
return null;
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
let messages = [...session.messages, msg];
|
|
71
|
+
// Truncate old messages, keeping the most recent ones
|
|
72
|
+
if (messages.length > MAX_MESSAGES_PER_SESSION) {
|
|
73
|
+
messages = messages.slice(-MAX_MESSAGES_PER_SESSION);
|
|
74
|
+
}
|
|
75
|
+
return this.update(sessionId, { messages });
|
|
72
76
|
}
|
|
73
77
|
loadSync() {
|
|
74
78
|
node_fs_1.default.mkdirSync(node_path_1.default.dirname(this.filePath), { recursive: true });
|