multiclaws 0.4.41 → 0.4.43
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/README.md +2 -0
- package/dist/gateway/handlers.d.ts +4 -4
- package/dist/gateway/handlers.js +239 -239
- package/dist/index.d.ts +8 -8
- package/dist/index.js +710 -710
- package/dist/infra/frp.d.ts +55 -55
- package/dist/infra/frp.js +398 -398
- package/dist/infra/gateway-client.d.ts +27 -27
- package/dist/infra/gateway-client.js +136 -136
- package/dist/infra/json-store.d.ts +4 -4
- package/dist/infra/json-store.js +57 -57
- package/dist/infra/logger.d.ts +14 -14
- package/dist/infra/logger.js +25 -25
- package/dist/infra/rate-limiter.d.ts +19 -19
- package/dist/infra/rate-limiter.js +69 -69
- package/dist/infra/tailscale.d.ts +19 -19
- package/dist/infra/tailscale.js +120 -120
- package/dist/infra/telemetry.d.ts +3 -3
- package/dist/infra/telemetry.js +17 -17
- package/dist/infra/version.d.ts +1 -1
- package/dist/infra/version.js +19 -19
- package/dist/service/a2a-adapter.d.ts +80 -80
- package/dist/service/a2a-adapter.js +505 -505
- package/dist/service/agent-profile.d.ts +17 -17
- package/dist/service/agent-profile.js +58 -58
- package/dist/service/agent-registry.d.ts +29 -29
- package/dist/service/agent-registry.js +131 -131
- package/dist/service/multiclaws-service.d.ts +150 -150
- package/dist/service/multiclaws-service.js +1137 -1137
- package/dist/service/session-store.d.ts +46 -46
- package/dist/service/session-store.js +143 -143
- package/dist/task/tracker.d.ts +46 -46
- package/dist/task/tracker.js +191 -191
- package/dist/team/team-store.d.ts +42 -42
- package/dist/team/team-store.js +195 -195
- package/dist/types/openclaw.d.ts +109 -109
- package/dist/types/openclaw.js +2 -2
- package/package.json +1 -1
- package/skills/meeting-scheduler/SKILL.md +112 -105
|
@@ -1,36 +1,36 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
-
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
-
};
|
|
5
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.MulticlawsService = void 0;
|
|
7
|
-
const node_events_1 = require("node:events");
|
|
8
|
-
const node_os_1 = __importDefault(require("node:os"));
|
|
9
|
-
const node_http_1 = __importDefault(require("node:http"));
|
|
10
|
-
const node_path_1 = __importDefault(require("node:path"));
|
|
11
|
-
const promises_1 = __importDefault(require("node:fs/promises"));
|
|
12
|
-
const frp_1 = require("../infra/frp");
|
|
13
|
-
const json_store_1 = require("../infra/json-store");
|
|
14
|
-
const express_1 = __importDefault(require("express"));
|
|
15
|
-
const server_1 = require("@a2a-js/sdk/server");
|
|
16
|
-
const express_2 = require("@a2a-js/sdk/server/express");
|
|
17
|
-
const client_1 = require("@a2a-js/sdk/client");
|
|
18
|
-
const version_1 = require("../infra/version");
|
|
19
|
-
const a2a_adapter_1 = require("./a2a-adapter");
|
|
20
|
-
const agent_registry_1 = require("./agent-registry");
|
|
21
|
-
const agent_profile_1 = require("./agent-profile");
|
|
22
|
-
const team_store_1 = require("../team/team-store");
|
|
23
|
-
const tracker_1 = require("../task/tracker");
|
|
24
|
-
const zod_1 = require("zod");
|
|
25
|
-
const gateway_client_1 = require("../infra/gateway-client");
|
|
26
|
-
const rate_limiter_1 = require("../infra/rate-limiter");
|
|
27
|
-
/* ------------------------------------------------------------------ */
|
|
28
|
-
/* Delegation prompt builder */
|
|
29
|
-
/* ------------------------------------------------------------------ */
|
|
30
|
-
function buildDelegationPrompt(agent, task) {
|
|
31
|
-
const bioSnippet = agent.description
|
|
32
|
-
? `\n**智能体能力**: ${agent.description.slice(0, 500)}`
|
|
33
|
-
: "";
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.MulticlawsService = void 0;
|
|
7
|
+
const node_events_1 = require("node:events");
|
|
8
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
9
|
+
const node_http_1 = __importDefault(require("node:http"));
|
|
10
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
11
|
+
const promises_1 = __importDefault(require("node:fs/promises"));
|
|
12
|
+
const frp_1 = require("../infra/frp");
|
|
13
|
+
const json_store_1 = require("../infra/json-store");
|
|
14
|
+
const express_1 = __importDefault(require("express"));
|
|
15
|
+
const server_1 = require("@a2a-js/sdk/server");
|
|
16
|
+
const express_2 = require("@a2a-js/sdk/server/express");
|
|
17
|
+
const client_1 = require("@a2a-js/sdk/client");
|
|
18
|
+
const version_1 = require("../infra/version");
|
|
19
|
+
const a2a_adapter_1 = require("./a2a-adapter");
|
|
20
|
+
const agent_registry_1 = require("./agent-registry");
|
|
21
|
+
const agent_profile_1 = require("./agent-profile");
|
|
22
|
+
const team_store_1 = require("../team/team-store");
|
|
23
|
+
const tracker_1 = require("../task/tracker");
|
|
24
|
+
const zod_1 = require("zod");
|
|
25
|
+
const gateway_client_1 = require("../infra/gateway-client");
|
|
26
|
+
const rate_limiter_1 = require("../infra/rate-limiter");
|
|
27
|
+
/* ------------------------------------------------------------------ */
|
|
28
|
+
/* Delegation prompt builder */
|
|
29
|
+
/* ------------------------------------------------------------------ */
|
|
30
|
+
function buildDelegationPrompt(agent, task) {
|
|
31
|
+
const bioSnippet = agent.description
|
|
32
|
+
? `\n**智能体能力**: ${agent.description.slice(0, 500)}`
|
|
33
|
+
: "";
|
|
34
34
|
return `## 委派任务
|
|
35
35
|
向远端智能体发送任务并汇报结果。
|
|
36
36
|
|
|
@@ -47,1107 +47,1107 @@ function buildDelegationPrompt(agent, task) {
|
|
|
47
47
|
- 使用 multiclaws_delegate_send(不是 multiclaws_delegate)发送任务
|
|
48
48
|
- 使用 multiclaws_notify(不是 message)将结果推送给用户
|
|
49
49
|
- 最多 5 轮沟通
|
|
50
|
-
- 遇到错误时在 multiclaws_notify 中说明失败原因`;
|
|
51
|
-
}
|
|
52
|
-
/* ------------------------------------------------------------------ */
|
|
53
|
-
/* Service */
|
|
54
|
-
/* ------------------------------------------------------------------ */
|
|
55
|
-
class MulticlawsService extends node_events_1.EventEmitter {
|
|
56
|
-
options;
|
|
57
|
-
started = false;
|
|
58
|
-
httpServer = null;
|
|
59
|
-
agentRegistry;
|
|
60
|
-
teamStore;
|
|
61
|
-
profileStore;
|
|
62
|
-
taskTracker;
|
|
63
|
-
agentExecutor = null;
|
|
64
|
-
a2aRequestHandler = null;
|
|
65
|
-
agentCard = null;
|
|
66
|
-
clientFactory = new client_1.ClientFactory();
|
|
67
|
-
httpRateLimiter = new rate_limiter_1.RateLimiter({ windowMs: 60_000, maxRequests: 60 });
|
|
68
|
-
frpTunnel = null;
|
|
69
|
-
selfUrl;
|
|
70
|
-
profileDescription = "OpenClaw agent";
|
|
71
|
-
gatewayConfig;
|
|
72
|
-
resolvedCwd;
|
|
73
|
-
notificationTargets = new Map();
|
|
74
|
-
constructor(options) {
|
|
75
|
-
super();
|
|
76
|
-
this.options = options;
|
|
77
|
-
const multiclawsStateDir = node_path_1.default.join(options.stateDir, "multiclaws");
|
|
78
|
-
this.agentRegistry = new agent_registry_1.AgentRegistry(node_path_1.default.join(multiclawsStateDir, "agents.json"), options.logger);
|
|
79
|
-
this.teamStore = new team_store_1.TeamStore(node_path_1.default.join(multiclawsStateDir, "teams.json"), options.logger);
|
|
80
|
-
this.profileStore = new agent_profile_1.ProfileStore(node_path_1.default.join(multiclawsStateDir, "profile.json"), options.logger);
|
|
81
|
-
this.taskTracker = new tracker_1.TaskTracker({
|
|
82
|
-
filePath: node_path_1.default.join(multiclawsStateDir, "tasks.json"),
|
|
83
|
-
logger: options.logger,
|
|
84
|
-
});
|
|
85
|
-
// selfUrl resolved later in start() after FRP tunnel setup
|
|
86
|
-
this.selfUrl = options.selfUrl ?? "";
|
|
87
|
-
this.gatewayConfig = options.gatewayConfig ?? null;
|
|
88
|
-
this.resolvedCwd = options.cwd || node_os_1.default.homedir();
|
|
89
|
-
}
|
|
90
|
-
async start() {
|
|
91
|
-
if (this.started)
|
|
92
|
-
return;
|
|
93
|
-
this.log("debug", `start(port=${this.options.port ?? 3100}, selfUrl=${this.options.selfUrl ?? "auto"})`);
|
|
94
|
-
try {
|
|
95
|
-
// Resolve selfUrl: explicit config > FRP tunnel
|
|
96
|
-
if (!this.options.selfUrl) {
|
|
97
|
-
const port = this.options.port ?? 3100;
|
|
98
|
-
if (!this.options.tunnel || this.options.tunnel.type !== "frp") {
|
|
99
|
-
throw new Error("multiclaws requires either 'selfUrl' or 'tunnel' configuration. " +
|
|
100
|
-
"Please configure tunnel in plugin settings.");
|
|
101
|
-
}
|
|
102
|
-
this.frpTunnel = new frp_1.FrpTunnelManager({
|
|
103
|
-
config: this.options.tunnel,
|
|
104
|
-
localPort: port,
|
|
105
|
-
stateDir: node_path_1.default.join(this.options.stateDir, "multiclaws"),
|
|
106
|
-
logger: this.options.logger,
|
|
107
|
-
});
|
|
108
|
-
const publicUrl = await this.frpTunnel.start();
|
|
109
|
-
this.selfUrl = publicUrl;
|
|
110
|
-
this.log("info", `FRP tunnel ready: ${publicUrl}`);
|
|
111
|
-
}
|
|
112
|
-
// Load profile for AgentCard description
|
|
113
|
-
const profile = await this.profileStore.load();
|
|
114
|
-
if (!profile.ownerName?.trim()) {
|
|
115
|
-
await this.setPendingProfileReview();
|
|
116
|
-
}
|
|
117
|
-
this.profileDescription = (0, agent_profile_1.renderProfileDescription)(profile);
|
|
118
|
-
const logger = this.options.logger ?? { info: () => { }, warn: () => { }, error: () => { } };
|
|
119
|
-
this.agentExecutor = new a2a_adapter_1.OpenClawAgentExecutor({
|
|
120
|
-
gatewayConfig: this.options.gatewayConfig ?? null,
|
|
121
|
-
taskTracker: this.taskTracker,
|
|
122
|
-
cwd: this.resolvedCwd,
|
|
123
|
-
getNotificationTargets: () => this.notificationTargets,
|
|
124
|
-
registerDiscoveredTarget: (sessionKey) => {
|
|
125
|
-
this.addNotificationTarget(`web:${sessionKey}`, { type: "web", sessionKey });
|
|
126
|
-
},
|
|
127
|
-
logger,
|
|
128
|
-
});
|
|
129
|
-
this.agentCard = {
|
|
130
|
-
name: profile.ownerName?.trim() ? (0, agent_profile_1.formatAgentCardName)(profile.ownerName.trim()) : "OpenClaw Agent",
|
|
131
|
-
description: this.profileDescription,
|
|
132
|
-
url: this.selfUrl,
|
|
133
|
-
version: version_1.PLUGIN_VERSION,
|
|
134
|
-
protocolVersion: "0.2.2",
|
|
135
|
-
defaultInputModes: ["text/plain"],
|
|
136
|
-
defaultOutputModes: ["text/plain"],
|
|
137
|
-
capabilities: { streaming: false, pushNotifications: false },
|
|
138
|
-
skills: [
|
|
139
|
-
{
|
|
140
|
-
id: "general",
|
|
141
|
-
name: "General Task",
|
|
142
|
-
description: "Execute any delegated task via OpenClaw",
|
|
143
|
-
tags: ["task", "delegation", "general"],
|
|
144
|
-
},
|
|
145
|
-
],
|
|
146
|
-
};
|
|
147
|
-
const taskStore = new server_1.InMemoryTaskStore();
|
|
148
|
-
this.a2aRequestHandler = new server_1.DefaultRequestHandler(this.agentCard, taskStore, this.agentExecutor);
|
|
149
|
-
const app = (0, express_1.default)();
|
|
150
|
-
app.use(express_1.default.json({ limit: "1mb" }));
|
|
151
|
-
// Rate limiting
|
|
152
|
-
app.use((req, res, next) => {
|
|
153
|
-
const clientIp = req.ip ?? req.socket.remoteAddress ?? "unknown";
|
|
154
|
-
if (!this.httpRateLimiter.allow(clientIp)) {
|
|
155
|
-
res.status(429).json({ error: "rate limited" });
|
|
156
|
-
return;
|
|
157
|
-
}
|
|
158
|
-
next();
|
|
159
|
-
});
|
|
160
|
-
// Team + profile REST endpoints
|
|
161
|
-
this.mountTeamRoutes(app);
|
|
162
|
-
// A2A endpoints
|
|
163
|
-
app.use("/.well-known/agent-card.json", (0, express_2.agentCardHandler)({
|
|
164
|
-
agentCardProvider: this.a2aRequestHandler,
|
|
165
|
-
}));
|
|
166
|
-
app.use("/", (0, express_2.jsonRpcHandler)({
|
|
167
|
-
requestHandler: this.a2aRequestHandler,
|
|
168
|
-
userBuilder: express_2.UserBuilder.noAuthentication,
|
|
169
|
-
}));
|
|
170
|
-
const listenPort = this.options.port ?? 3100;
|
|
171
|
-
this.httpServer = node_http_1.default.createServer(app);
|
|
172
|
-
await new Promise((resolve) => this.httpServer.listen(listenPort, "0.0.0.0", resolve));
|
|
173
|
-
this.started = true;
|
|
174
|
-
this.log("info", `multiclaws A2A service listening on :${listenPort}`);
|
|
175
|
-
}
|
|
176
|
-
catch (err) {
|
|
177
|
-
this.log("error", `start failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
178
|
-
throw err;
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
async stop() {
|
|
182
|
-
if (!this.started)
|
|
183
|
-
return;
|
|
184
|
-
this.log("debug", "stopping");
|
|
185
|
-
try {
|
|
186
|
-
this.started = false;
|
|
187
|
-
this.taskTracker.destroy();
|
|
188
|
-
this.httpRateLimiter.destroy();
|
|
189
|
-
if (this.frpTunnel) {
|
|
190
|
-
await this.frpTunnel.stop();
|
|
191
|
-
this.frpTunnel = null;
|
|
192
|
-
}
|
|
193
|
-
await new Promise((resolve) => {
|
|
194
|
-
if (!this.httpServer) {
|
|
195
|
-
resolve();
|
|
196
|
-
return;
|
|
197
|
-
}
|
|
198
|
-
this.httpServer.close(() => resolve());
|
|
199
|
-
});
|
|
200
|
-
this.httpServer = null;
|
|
201
|
-
this.log("debug", "stopped");
|
|
202
|
-
}
|
|
203
|
-
catch (err) {
|
|
204
|
-
this.log("error", `stop failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
205
|
-
throw err;
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
updateGatewayConfig(config) {
|
|
209
|
-
this.agentExecutor?.updateGatewayConfig(config);
|
|
210
|
-
}
|
|
211
|
-
/* ---------------------------------------------------------------- */
|
|
212
|
-
/* Agent management */
|
|
213
|
-
/* ---------------------------------------------------------------- */
|
|
214
|
-
async listAgents() {
|
|
215
|
-
return await this.agentRegistry.list();
|
|
216
|
-
}
|
|
217
|
-
async addAgent(params) {
|
|
218
|
-
const normalizedUrl = params.url.replace(/\/+$/, "");
|
|
219
|
-
this.log("debug", `addAgent(url=${normalizedUrl})`);
|
|
220
|
-
try {
|
|
221
|
-
const client = await this.clientFactory.createFromUrl(normalizedUrl);
|
|
222
|
-
const card = await client.getAgentCard();
|
|
223
|
-
const result = await this.agentRegistry.add({
|
|
224
|
-
url: normalizedUrl,
|
|
225
|
-
name: card.name ?? normalizedUrl,
|
|
226
|
-
description: card.description ?? "",
|
|
227
|
-
skills: card.skills?.map((s) => s.name ?? s.id) ?? [],
|
|
228
|
-
apiKey: params.apiKey,
|
|
229
|
-
});
|
|
230
|
-
this.log("debug", `addAgent completed, name=${result.name}`);
|
|
231
|
-
return result;
|
|
232
|
-
}
|
|
233
|
-
catch {
|
|
234
|
-
this.log("debug", `addAgent: card fetch failed for ${normalizedUrl}, adding with URL as name`);
|
|
235
|
-
return await this.agentRegistry.add({
|
|
236
|
-
url: normalizedUrl,
|
|
237
|
-
name: normalizedUrl,
|
|
238
|
-
apiKey: params.apiKey,
|
|
239
|
-
});
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
async removeAgent(url) {
|
|
243
|
-
this.log("debug", `removeAgent(url=${url})`);
|
|
244
|
-
try {
|
|
245
|
-
const result = await this.agentRegistry.remove(url);
|
|
246
|
-
this.log("debug", `removeAgent completed, result=${result}`);
|
|
247
|
-
return result;
|
|
248
|
-
}
|
|
249
|
-
catch (err) {
|
|
250
|
-
this.log("error", `removeAgent failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
251
|
-
throw err;
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
/* ---------------------------------------------------------------- */
|
|
255
|
-
/* Task delegation */
|
|
256
|
-
/* ---------------------------------------------------------------- */
|
|
257
|
-
async delegateTask(params) {
|
|
258
|
-
this.log("info", `[delegate] ▶ delegateTask(agentUrl=${params.agentUrl}, taskLen=${params.task.length})`);
|
|
259
|
-
this.log("info", `[delegate] task preview: "${params.task.slice(0, 120)}"`);
|
|
260
|
-
// Step 1: Check profile
|
|
261
|
-
this.log("info", `[delegate] [step:profile-check] verifying profile completeness`);
|
|
262
|
-
try {
|
|
263
|
-
await this.requireCompleteProfile();
|
|
264
|
-
}
|
|
265
|
-
catch (err) {
|
|
266
|
-
this.log("error", `[delegate] [step:profile-check] ✗ profile incomplete: ${err instanceof Error ? err.message : String(err)}`);
|
|
267
|
-
return { status: "failed", error: err instanceof Error ? err.message : String(err) };
|
|
268
|
-
}
|
|
269
|
-
// Step 2: Look up agent
|
|
270
|
-
this.log("info", `[delegate] [step:agent-lookup] looking up agent: ${params.agentUrl}`);
|
|
271
|
-
const agentRecord = await this.agentRegistry.get(params.agentUrl);
|
|
272
|
-
if (!agentRecord) {
|
|
273
|
-
this.log("warn", `[delegate] [step:agent-lookup] ✗ unknown agent: ${params.agentUrl} → aborting`);
|
|
274
|
-
return { status: "failed", error: `unknown agent: ${params.agentUrl}` };
|
|
275
|
-
}
|
|
276
|
-
this.log("info", `[delegate] [step:agent-lookup] ✓ found: ${agentRecord.name} (${agentRecord.url})`);
|
|
277
|
-
// Step 3: Track task
|
|
278
|
-
const track = this.taskTracker.create({
|
|
279
|
-
fromPeerId: "local",
|
|
280
|
-
toPeerId: params.agentUrl,
|
|
281
|
-
task: params.task,
|
|
282
|
-
});
|
|
283
|
-
this.taskTracker.update(track.taskId, { status: "running" });
|
|
284
|
-
this.log("info", `[delegate] [step:track] taskId=${track.taskId}, status=running`);
|
|
285
|
-
try {
|
|
286
|
-
// Step 4: Create A2A client
|
|
287
|
-
this.log("info", `[delegate] ${track.taskId} [step:create-client] creating A2A client for ${agentRecord.url}`);
|
|
288
|
-
const client = await this.createA2AClient(agentRecord);
|
|
289
|
-
this.log("info", `[delegate] ${track.taskId} [step:create-client] ✓ client created → starting fire-and-forget send`);
|
|
290
|
-
// Step 5: Fire-and-forget execution
|
|
291
|
-
void (async () => {
|
|
292
|
-
try {
|
|
293
|
-
this.log("info", `[delegate] ${track.taskId} [step:background-send] sending A2A message to ${agentRecord.name}...`);
|
|
294
|
-
const result = await client.sendMessage({
|
|
295
|
-
message: {
|
|
296
|
-
kind: "message",
|
|
297
|
-
role: "user",
|
|
298
|
-
parts: [{ kind: "text", text: params.task }],
|
|
299
|
-
messageId: track.taskId,
|
|
300
|
-
},
|
|
301
|
-
});
|
|
302
|
-
this.log("info", `[delegate] ${track.taskId} [step:background-send] ✓ A2A response received → processing result`);
|
|
303
|
-
this.processTaskResult(track.taskId, result);
|
|
304
|
-
}
|
|
305
|
-
catch (err) {
|
|
306
|
-
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
307
|
-
this.taskTracker.update(track.taskId, { status: "failed", error: errorMsg });
|
|
308
|
-
this.log("error", `[delegate] ${track.taskId} [step:background-send] ✗ caught error: ${errorMsg} → task marked failed`);
|
|
309
|
-
}
|
|
310
|
-
})();
|
|
311
|
-
this.log("info", `[delegate] ${track.taskId} [step:return] returned immediately (fire-and-forget), background send in progress`);
|
|
312
|
-
return { taskId: track.taskId, status: "running" };
|
|
313
|
-
}
|
|
314
|
-
catch (err) {
|
|
315
|
-
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
316
|
-
this.taskTracker.update(track.taskId, { status: "failed", error: errorMsg });
|
|
317
|
-
this.log("error", `[delegate] ${track.taskId} [step:catch] ✗ caught error during client creation: ${errorMsg} → task marked failed`);
|
|
318
|
-
return { taskId: track.taskId, status: "failed", error: errorMsg };
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
/**
|
|
322
|
-
* Synchronous delegation: sends A2A task and waits for the result.
|
|
323
|
-
* Used by sub-agents internally via the multiclaws_delegate_send tool.
|
|
324
|
-
*/
|
|
325
|
-
async delegateTaskSync(params) {
|
|
326
|
-
this.log("info", `[delegate-sync] ▶ delegateTaskSync(agentUrl=${params.agentUrl}, taskLen=${params.task.length})`);
|
|
327
|
-
this.log("info", `[delegate-sync] task preview: "${params.task.slice(0, 120)}"`);
|
|
328
|
-
// Step 1: Check profile
|
|
329
|
-
this.log("info", `[delegate-sync] [step:profile-check] verifying profile completeness`);
|
|
330
|
-
try {
|
|
331
|
-
await this.requireCompleteProfile();
|
|
332
|
-
}
|
|
333
|
-
catch (err) {
|
|
334
|
-
this.log("error", `[delegate-sync] [step:profile-check] ✗ profile incomplete: ${err instanceof Error ? err.message : String(err)}`);
|
|
335
|
-
return { status: "failed", error: err instanceof Error ? err.message : String(err) };
|
|
336
|
-
}
|
|
337
|
-
// Step 2: Look up agent
|
|
338
|
-
this.log("info", `[delegate-sync] [step:agent-lookup] looking up agent: ${params.agentUrl}`);
|
|
339
|
-
const agentRecord = await this.agentRegistry.get(params.agentUrl);
|
|
340
|
-
if (!agentRecord) {
|
|
341
|
-
this.log("warn", `[delegate-sync] [step:agent-lookup] ✗ unknown agent: ${params.agentUrl} → aborting`);
|
|
342
|
-
return { status: "failed", error: `unknown agent: ${params.agentUrl}` };
|
|
343
|
-
}
|
|
344
|
-
this.log("info", `[delegate-sync] [step:agent-lookup] ✓ found: ${agentRecord.name} (${agentRecord.url})`);
|
|
345
|
-
// Step 3: Track task
|
|
346
|
-
const track = this.taskTracker.create({
|
|
347
|
-
fromPeerId: "local",
|
|
348
|
-
toPeerId: params.agentUrl,
|
|
349
|
-
task: params.task,
|
|
350
|
-
});
|
|
351
|
-
this.taskTracker.update(track.taskId, { status: "running" });
|
|
352
|
-
this.log("info", `[delegate-sync] [step:track] taskId=${track.taskId}, status=running`);
|
|
353
|
-
try {
|
|
354
|
-
// Step 4: Create A2A client
|
|
355
|
-
this.log("info", `[delegate-sync] ${track.taskId} [step:create-client] creating A2A client for ${agentRecord.url}`);
|
|
356
|
-
const client = await this.createA2AClient(agentRecord);
|
|
357
|
-
// Step 5: Send A2A message (synchronous — blocks until response)
|
|
358
|
-
this.log("info", `[delegate-sync] ${track.taskId} [step:send] sending A2A message (sync, metadata: selfUrl=${this.selfUrl}, selfName=${this.agentCard?.name ?? "unknown"})...`);
|
|
359
|
-
const result = await client.sendMessage({
|
|
360
|
-
message: {
|
|
361
|
-
kind: "message",
|
|
362
|
-
role: "user",
|
|
363
|
-
parts: [{ kind: "text", text: params.task }],
|
|
364
|
-
messageId: track.taskId,
|
|
365
|
-
metadata: {
|
|
366
|
-
agentUrl: this.selfUrl,
|
|
367
|
-
agentName: this.agentCard?.name ?? "unknown",
|
|
368
|
-
},
|
|
369
|
-
},
|
|
370
|
-
});
|
|
371
|
-
this.log("info", `[delegate-sync] ${track.taskId} [step:send] ✓ A2A response received → processing result`);
|
|
372
|
-
// Step 6: Process result
|
|
373
|
-
const taskResult = this.processTaskResult(track.taskId, result);
|
|
374
|
-
this.log("info", `[delegate-sync] ${track.taskId} [step:completed] ✓ status=${taskResult.status}, outputLen=${taskResult.output?.length ?? 0}, preview="${(taskResult.output ?? "").slice(0, 120)}"`);
|
|
375
|
-
return taskResult;
|
|
376
|
-
}
|
|
377
|
-
catch (err) {
|
|
378
|
-
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
379
|
-
this.taskTracker.update(track.taskId, { status: "failed", error: errorMsg });
|
|
380
|
-
this.log("error", `[delegate-sync] ${track.taskId} [step:catch] ✗ caught error: ${errorMsg} → task marked failed`);
|
|
381
|
-
return { taskId: track.taskId, status: "failed", error: errorMsg };
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
/**
|
|
385
|
-
* Spawn a sub-agent to handle delegation asynchronously.
|
|
386
|
-
* The sub-agent uses multiclaws_delegate_send internally and
|
|
387
|
-
* reports results back to the user via the message tool.
|
|
388
|
-
*/
|
|
389
|
-
async spawnDelegation(params) {
|
|
390
|
-
this.log("info", `[spawn-delegate] ▶ spawnDelegation(agentUrl=${params.agentUrl}, taskLen=${params.task.length})`);
|
|
391
|
-
this.log("info", `[spawn-delegate] task preview: "${params.task.slice(0, 120)}"`);
|
|
392
|
-
// Step 1: Check profile
|
|
393
|
-
this.log("info", `[spawn-delegate] [step:profile-check] verifying profile completeness`);
|
|
394
|
-
try {
|
|
395
|
-
await this.requireCompleteProfile();
|
|
396
|
-
}
|
|
397
|
-
catch (err) {
|
|
398
|
-
this.log("error", `[spawn-delegate] [step:profile-check] ✗ profile incomplete: ${err instanceof Error ? err.message : String(err)}`);
|
|
399
|
-
throw err;
|
|
400
|
-
}
|
|
401
|
-
// Step 2: Look up agent
|
|
402
|
-
this.log("info", `[spawn-delegate] [step:agent-lookup] looking up agent: ${params.agentUrl}`);
|
|
403
|
-
const agent = await this.agentRegistry.get(params.agentUrl);
|
|
404
|
-
if (!agent) {
|
|
405
|
-
this.log("warn", `[spawn-delegate] [step:agent-lookup] ✗ unknown agent: ${params.agentUrl} → aborting`);
|
|
406
|
-
throw new Error(`unknown agent: ${params.agentUrl}`);
|
|
407
|
-
}
|
|
408
|
-
this.log("info", `[spawn-delegate] [step:agent-lookup] ✓ found: ${agent.name} (${agent.url})`);
|
|
409
|
-
// Step 3: Check gateway config
|
|
410
|
-
if (!this.gatewayConfig) {
|
|
411
|
-
this.log("error", `[spawn-delegate] [step:gateway-check] ✗ gateway config not available → aborting`);
|
|
412
|
-
throw new Error("gateway config not available — cannot spawn sub-agent");
|
|
413
|
-
}
|
|
414
|
-
// Step 4: Spawn sub-agent
|
|
415
|
-
const prompt = buildDelegationPrompt(agent, params.task);
|
|
416
|
-
const sessionKey = `delegate-${Date.now()}`;
|
|
417
|
-
this.log("info", `[spawn-delegate] [step:spawn] calling sessions_spawn (cwd=${this.resolvedCwd}, sessionKey=${sessionKey}, promptLen=${prompt.length})`);
|
|
418
|
-
try {
|
|
419
|
-
const spawnResult = await (0, gateway_client_1.invokeGatewayTool)({
|
|
420
|
-
gateway: this.gatewayConfig,
|
|
421
|
-
tool: "sessions_spawn",
|
|
422
|
-
args: { task: prompt, mode: "run", cwd: this.resolvedCwd },
|
|
423
|
-
sessionKey,
|
|
424
|
-
timeoutMs: 15_000,
|
|
425
|
-
});
|
|
426
|
-
this.log("info", `[spawn-delegate] [step:spawn] ✓ sub-agent spawned for ${agent.name} — result=${JSON.stringify(spawnResult).slice(0, 200)}`);
|
|
427
|
-
return { message: `已启动子 agent 向 ${agent.name} 委派任务` };
|
|
428
|
-
}
|
|
429
|
-
catch (err) {
|
|
430
|
-
this.log("error", `[spawn-delegate] [step:spawn] ✗ sessions_spawn failed: ${err instanceof Error ? err.message : String(err)} → aborting`);
|
|
431
|
-
throw err;
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
getTaskStatus(taskId) {
|
|
435
|
-
return this.taskTracker.get(taskId);
|
|
436
|
-
}
|
|
437
|
-
/* ---------------------------------------------------------------- */
|
|
438
|
-
/* Profile */
|
|
439
|
-
/* ---------------------------------------------------------------- */
|
|
440
|
-
async getProfile() {
|
|
441
|
-
return await this.profileStore.load();
|
|
442
|
-
}
|
|
443
|
-
/**
|
|
444
|
-
* Throws if the profile is incomplete (ownerName or bio missing).
|
|
445
|
-
* Call this before any action that exposes the user's identity to other agents.
|
|
446
|
-
*/
|
|
447
|
-
async requireCompleteProfile() {
|
|
448
|
-
const profile = await this.profileStore.load();
|
|
449
|
-
if (!profile.ownerName?.trim()) {
|
|
450
|
-
throw new Error("档案未完成设置。请先调用 multiclaws_profile_set(ownerName=\"你的名字\") 设置用户名后再继续。");
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
async setProfile(patch) {
|
|
454
|
-
this.log("debug", `setProfile(keys=${Object.keys(patch).join(",")})`);
|
|
455
|
-
try {
|
|
456
|
-
const profile = await this.profileStore.update(patch);
|
|
457
|
-
this.updateProfileDescription(profile);
|
|
458
|
-
await this.broadcastProfileToTeams();
|
|
459
|
-
this.log("debug", "setProfile completed");
|
|
460
|
-
return profile;
|
|
461
|
-
}
|
|
462
|
-
catch (err) {
|
|
463
|
-
this.log("error", `setProfile failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
464
|
-
throw err;
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
updateProfileDescription(profile) {
|
|
468
|
-
this.profileDescription = (0, agent_profile_1.renderProfileDescription)(profile);
|
|
469
|
-
if (this.agentCard) {
|
|
470
|
-
this.agentCard.description = this.profileDescription;
|
|
471
|
-
if (profile.ownerName?.trim()) {
|
|
472
|
-
this.agentCard.name = (0, agent_profile_1.formatAgentCardName)(profile.ownerName.trim());
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
/* ---------------------------------------------------------------- */
|
|
477
|
-
/* Pending profile review (install / first-run) */
|
|
478
|
-
/* ---------------------------------------------------------------- */
|
|
479
|
-
getPendingReviewPath() {
|
|
480
|
-
return node_path_1.default.join(this.options.stateDir, "multiclaws", "pending-profile-review.json");
|
|
481
|
-
}
|
|
482
|
-
async getPendingProfileReview() {
|
|
483
|
-
const p = this.getPendingReviewPath();
|
|
484
|
-
const data = await (0, json_store_1.readJsonWithFallback)(p, {});
|
|
485
|
-
if (data.pending !== true) {
|
|
486
|
-
return { pending: false };
|
|
487
|
-
}
|
|
488
|
-
const profile = await this.profileStore.load();
|
|
489
|
-
return {
|
|
490
|
-
pending: true,
|
|
491
|
-
profile,
|
|
492
|
-
message: "这是您当前的 MultiClaws 档案,是否需要修改名字、角色、数据源或能力?",
|
|
493
|
-
};
|
|
494
|
-
}
|
|
495
|
-
async setPendingProfileReview() {
|
|
496
|
-
this.log("debug", "setPendingProfileReview");
|
|
497
|
-
try {
|
|
498
|
-
const p = this.getPendingReviewPath();
|
|
499
|
-
await (0, json_store_1.writeJsonAtomically)(p, { pending: true });
|
|
500
|
-
}
|
|
501
|
-
catch (err) {
|
|
502
|
-
this.log("error", `setPendingProfileReview failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
503
|
-
throw err;
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
async clearPendingProfileReview() {
|
|
507
|
-
this.log("debug", "clearPendingProfileReview");
|
|
508
|
-
const p = this.getPendingReviewPath();
|
|
509
|
-
try {
|
|
510
|
-
await promises_1.default.unlink(p);
|
|
511
|
-
}
|
|
512
|
-
catch {
|
|
513
|
-
// ignore if missing
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
/* ---------------------------------------------------------------- */
|
|
517
|
-
/* Team management */
|
|
518
|
-
/* ---------------------------------------------------------------- */
|
|
519
|
-
async createTeam(name) {
|
|
520
|
-
this.log("debug", `createTeam(name=${name})`);
|
|
521
|
-
try {
|
|
522
|
-
await this.requireCompleteProfile();
|
|
523
|
-
const team = await this.teamStore.createTeam({
|
|
524
|
-
teamName: name,
|
|
525
|
-
selfUrl: this.selfUrl,
|
|
526
|
-
selfName: this.getFormattedName(),
|
|
527
|
-
selfDescription: this.profileDescription,
|
|
528
|
-
});
|
|
529
|
-
this.log("info", `team created: ${team.teamId} (${team.teamName})`);
|
|
530
|
-
return team;
|
|
531
|
-
}
|
|
532
|
-
catch (err) {
|
|
533
|
-
this.log("error", `createTeam failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
534
|
-
throw err;
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
async createInvite(teamId) {
|
|
538
|
-
this.log("debug", `createInvite(teamId=${teamId ?? "first"})`);
|
|
539
|
-
try {
|
|
540
|
-
const team = teamId
|
|
541
|
-
? await this.teamStore.getTeam(teamId)
|
|
542
|
-
: await this.teamStore.getFirstTeam();
|
|
543
|
-
if (!team)
|
|
544
|
-
throw new Error(teamId ? `team not found: ${teamId}` : "no team exists");
|
|
545
|
-
const code = (0, team_store_1.encodeInvite)(team.teamId, this.selfUrl);
|
|
546
|
-
this.log("debug", "createInvite completed");
|
|
547
|
-
return code;
|
|
548
|
-
}
|
|
549
|
-
catch (err) {
|
|
550
|
-
this.log("error", `createInvite failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
551
|
-
throw err;
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
async joinTeam(inviteCode) {
|
|
555
|
-
this.log("info", "joinTeam starting");
|
|
556
|
-
try {
|
|
557
|
-
await this.requireCompleteProfile();
|
|
558
|
-
const invite = (0, team_store_1.decodeInvite)(inviteCode);
|
|
559
|
-
const seedUrl = invite.u.replace(/\/+$/, "");
|
|
560
|
-
this.log("debug", `joinTeam: seedUrl=${seedUrl}, teamId=${invite.t}`);
|
|
561
|
-
// 1. Fetch member list from seed
|
|
562
|
-
let membersRes;
|
|
563
|
-
try {
|
|
564
|
-
membersRes = await fetch(`${seedUrl}/team/${invite.t}/members`);
|
|
565
|
-
}
|
|
566
|
-
catch (err) {
|
|
567
|
-
throw new Error(`Unable to reach team seed node at ${seedUrl}: ${err instanceof Error ? err.message : String(err)}`);
|
|
568
|
-
}
|
|
569
|
-
if (!membersRes.ok) {
|
|
570
|
-
throw new Error(`failed to fetch team members from ${seedUrl}: HTTP ${membersRes.status}`);
|
|
571
|
-
}
|
|
572
|
-
const { team: remoteTeam } = (await membersRes.json());
|
|
573
|
-
// 2. Announce self to seed (seed broadcasts to others)
|
|
574
|
-
const selfMember = {
|
|
575
|
-
url: this.selfUrl,
|
|
576
|
-
name: this.getFormattedName(),
|
|
577
|
-
description: this.profileDescription,
|
|
578
|
-
joinedAtMs: Date.now(),
|
|
579
|
-
};
|
|
580
|
-
let announceRes;
|
|
581
|
-
try {
|
|
582
|
-
announceRes = await fetch(`${seedUrl}/team/${invite.t}/announce`, {
|
|
583
|
-
method: "POST",
|
|
584
|
-
headers: { "Content-Type": "application/json" },
|
|
585
|
-
body: JSON.stringify(selfMember),
|
|
586
|
-
});
|
|
587
|
-
}
|
|
588
|
-
catch (err) {
|
|
589
|
-
throw new Error(`Failed to announce self to seed ${seedUrl}: ${err instanceof Error ? err.message : String(err)}`);
|
|
590
|
-
}
|
|
591
|
-
if (!announceRes.ok) {
|
|
592
|
-
throw new Error(`failed to announce to seed ${seedUrl}: HTTP ${announceRes.status}`);
|
|
593
|
-
}
|
|
594
|
-
// 3. Store team locally
|
|
595
|
-
const allMembers = [...remoteTeam.members];
|
|
596
|
-
const selfNormalized = this.selfUrl.replace(/\/+$/, "");
|
|
597
|
-
if (!allMembers.some((m) => m.url.replace(/\/+$/, "") === selfNormalized)) {
|
|
598
|
-
allMembers.push(selfMember);
|
|
599
|
-
}
|
|
600
|
-
const team = {
|
|
601
|
-
teamId: invite.t,
|
|
602
|
-
teamName: remoteTeam.teamName,
|
|
603
|
-
selfUrl: this.selfUrl,
|
|
604
|
-
members: allMembers,
|
|
605
|
-
createdAtMs: Date.now(),
|
|
606
|
-
};
|
|
607
|
-
await this.teamStore.saveTeam(team);
|
|
608
|
-
// 4. Fetch Agent Cards for members without descriptions, then sync to registry
|
|
609
|
-
await this.fetchMemberDescriptions(team);
|
|
610
|
-
await this.syncTeamToRegistry(team);
|
|
611
|
-
this.log("info", `joined team ${team.teamId} (${team.teamName}) with ${allMembers.length} members`);
|
|
612
|
-
return team;
|
|
613
|
-
}
|
|
614
|
-
catch (err) {
|
|
615
|
-
this.log("error", `joinTeam failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
616
|
-
throw err;
|
|
617
|
-
}
|
|
618
|
-
}
|
|
619
|
-
async leaveTeam(teamId) {
|
|
620
|
-
this.log("info", `leaveTeam(teamId=${teamId ?? "first"})`);
|
|
621
|
-
try {
|
|
622
|
-
const team = teamId
|
|
623
|
-
? await this.teamStore.getTeam(teamId)
|
|
624
|
-
: await this.teamStore.getFirstTeam();
|
|
625
|
-
if (!team)
|
|
626
|
-
throw new Error(teamId ? `team not found: ${teamId}` : "no team exists");
|
|
627
|
-
const selfNormalized = this.selfUrl.replace(/\/+$/, "");
|
|
628
|
-
const selfMember = {
|
|
629
|
-
url: this.selfUrl,
|
|
630
|
-
name: this.getFormattedName(),
|
|
631
|
-
joinedAtMs: 0,
|
|
632
|
-
};
|
|
633
|
-
const others = team.members.filter((m) => m.url.replace(/\/+$/, "") !== selfNormalized);
|
|
634
|
-
await Promise.allSettled(others.map(async (m) => {
|
|
635
|
-
try {
|
|
636
|
-
await fetch(`${m.url}/team/${team.teamId}/leave`, {
|
|
637
|
-
method: "POST",
|
|
638
|
-
headers: { "Content-Type": "application/json" },
|
|
639
|
-
body: JSON.stringify(selfMember),
|
|
640
|
-
});
|
|
641
|
-
}
|
|
642
|
-
catch {
|
|
643
|
-
this.log("warn", `failed to notify ${m.url} about leaving`);
|
|
644
|
-
}
|
|
645
|
-
}));
|
|
646
|
-
for (const m of others) {
|
|
647
|
-
await this.agentRegistry.remove(m.url);
|
|
648
|
-
}
|
|
649
|
-
await this.teamStore.deleteTeam(team.teamId);
|
|
650
|
-
this.log("info", `left team ${team.teamId}`);
|
|
651
|
-
}
|
|
652
|
-
catch (err) {
|
|
653
|
-
this.log("error", `leaveTeam failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
654
|
-
throw err;
|
|
655
|
-
}
|
|
656
|
-
}
|
|
657
|
-
async listTeamMembers(teamId) {
|
|
658
|
-
if (teamId) {
|
|
659
|
-
const team = await this.teamStore.getTeam(teamId);
|
|
660
|
-
if (!team)
|
|
661
|
-
return null;
|
|
662
|
-
return { team, members: team.members };
|
|
663
|
-
}
|
|
664
|
-
const all = await this.teamStore.listTeams();
|
|
665
|
-
if (all.length === 0)
|
|
666
|
-
return null;
|
|
667
|
-
if (all.length === 1)
|
|
668
|
-
return { team: all[0], members: all[0].members };
|
|
669
|
-
return { teams: all.map((team) => ({ team, members: team.members })) };
|
|
670
|
-
}
|
|
671
|
-
/* ---------------------------------------------------------------- */
|
|
672
|
-
/* Team REST routes */
|
|
673
|
-
/* ---------------------------------------------------------------- */
|
|
674
|
-
mountTeamRoutes(app) {
|
|
675
|
-
const announceBodySchema = zod_1.z.object({
|
|
676
|
-
url: zod_1.z.string().trim().min(1),
|
|
677
|
-
name: zod_1.z.string().trim().min(1),
|
|
678
|
-
description: zod_1.z.string().trim().optional(),
|
|
679
|
-
joinedAtMs: zod_1.z.number().optional(),
|
|
680
|
-
});
|
|
681
|
-
const leaveBodySchema = zod_1.z.object({
|
|
682
|
-
url: zod_1.z.string().trim().min(1),
|
|
683
|
-
});
|
|
684
|
-
const profileUpdateBodySchema = zod_1.z.object({
|
|
685
|
-
url: zod_1.z.string().trim().min(1),
|
|
686
|
-
name: zod_1.z.string().trim().optional(),
|
|
687
|
-
description: zod_1.z.string().optional(),
|
|
688
|
-
});
|
|
689
|
-
app.get("/team/:id/members", async (req, res) => {
|
|
690
|
-
try {
|
|
691
|
-
const team = await this.teamStore.getTeam(req.params.id);
|
|
692
|
-
if (!team) {
|
|
693
|
-
res.status(404).json({ error: "team not found" });
|
|
694
|
-
return;
|
|
695
|
-
}
|
|
696
|
-
res.json({ team: { teamName: team.teamName, members: team.members } });
|
|
697
|
-
}
|
|
698
|
-
catch (err) {
|
|
699
|
-
this.log("error", `GET /team/${req.params.id}/members failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
700
|
-
res.status(500).json({ error: String(err) });
|
|
701
|
-
}
|
|
702
|
-
});
|
|
703
|
-
app.post("/team/:id/announce", async (req, res) => {
|
|
704
|
-
this.log("debug", `POST /team/${req.params.id}/announce from ${req.body?.url}`);
|
|
705
|
-
try {
|
|
706
|
-
const team = await this.teamStore.getTeam(req.params.id);
|
|
707
|
-
if (!team) {
|
|
708
|
-
this.log("debug", `announce: team ${req.params.id} not found`);
|
|
709
|
-
res.status(404).json({ error: "team not found" });
|
|
710
|
-
return;
|
|
711
|
-
}
|
|
712
|
-
const parsed = announceBodySchema.safeParse(req.body);
|
|
713
|
-
if (!parsed.success) {
|
|
714
|
-
this.log("debug", `announce: invalid body: ${parsed.error.message}`);
|
|
715
|
-
res.status(400).json({ error: parsed.error.message });
|
|
716
|
-
return;
|
|
717
|
-
}
|
|
718
|
-
const member = parsed.data;
|
|
719
|
-
this.log("debug", `announce: member=${member.name}, url=${member.url}`);
|
|
720
|
-
const normalizedUrl = member.url.replace(/\/+$/, "");
|
|
721
|
-
const alreadyKnown = team.members.some((m) => m.url.replace(/\/+$/, "") === normalizedUrl);
|
|
722
|
-
this.log("debug", `announce: alreadyKnown=${alreadyKnown}`);
|
|
723
|
-
await this.teamStore.addMember(team.teamId, {
|
|
724
|
-
url: normalizedUrl,
|
|
725
|
-
name: member.name,
|
|
726
|
-
description: member.description,
|
|
727
|
-
joinedAtMs: member.joinedAtMs ?? Date.now(),
|
|
728
|
-
});
|
|
729
|
-
this.log("debug", `announce: addMember completed`);
|
|
730
|
-
await this.agentRegistry.add({
|
|
731
|
-
url: normalizedUrl,
|
|
732
|
-
name: member.name,
|
|
733
|
-
description: member.description,
|
|
734
|
-
});
|
|
735
|
-
this.log("debug", `announce: agentRegistry.add completed`);
|
|
736
|
-
// Broadcast to other members if new
|
|
737
|
-
if (!alreadyKnown) {
|
|
738
|
-
const selfNormalized = this.selfUrl.replace(/\/+$/, "");
|
|
739
|
-
const others = team.members.filter((m) => m.url.replace(/\/+$/, "") !== normalizedUrl &&
|
|
740
|
-
m.url.replace(/\/+$/, "") !== selfNormalized);
|
|
741
|
-
this.log("debug", `announce: broadcasting to ${others.length} other members`);
|
|
742
|
-
for (const other of others) {
|
|
743
|
-
void this.fetchWithRetry(`${other.url}/team/${team.teamId}/announce`, {
|
|
744
|
-
method: "POST",
|
|
745
|
-
headers: { "Content-Type": "application/json" },
|
|
746
|
-
body: JSON.stringify({
|
|
747
|
-
url: normalizedUrl,
|
|
748
|
-
name: member.name,
|
|
749
|
-
description: member.description,
|
|
750
|
-
joinedAtMs: member.joinedAtMs ?? Date.now(),
|
|
751
|
-
}),
|
|
752
|
-
}).catch((err) => {
|
|
753
|
-
this.log("warn", `broadcast to ${other.url} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
754
|
-
});
|
|
755
|
-
}
|
|
756
|
-
// Notify local user that a new member joined
|
|
757
|
-
this.log("debug", `announce: notifying local user about ${member.name}`);
|
|
758
|
-
void this.notifyUser(`📢 **${member.name}** 已加入团队「${team.teamName}」`);
|
|
759
|
-
}
|
|
760
|
-
this.log("debug", `announce: completed for ${member.name}`);
|
|
761
|
-
res.json({ ok: true });
|
|
762
|
-
}
|
|
763
|
-
catch (err) {
|
|
764
|
-
this.log("error", `POST /team/${req.params.id}/announce failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
765
|
-
res.status(500).json({ error: String(err) });
|
|
766
|
-
}
|
|
767
|
-
});
|
|
768
|
-
app.post("/team/:id/leave", async (req, res) => {
|
|
769
|
-
this.log("debug", `POST /team/${req.params.id}/leave from ${req.body?.url}`);
|
|
770
|
-
try {
|
|
771
|
-
const team = await this.teamStore.getTeam(req.params.id);
|
|
772
|
-
if (!team) {
|
|
773
|
-
res.status(404).json({ error: "team not found" });
|
|
774
|
-
return;
|
|
775
|
-
}
|
|
776
|
-
const parsed = leaveBodySchema.safeParse(req.body);
|
|
777
|
-
if (!parsed.success) {
|
|
778
|
-
res.status(400).json({ error: parsed.error.message });
|
|
779
|
-
return;
|
|
780
|
-
}
|
|
781
|
-
const normalizedUrl = parsed.data.url.replace(/\/+$/, "");
|
|
782
|
-
await this.teamStore.removeMember(team.teamId, normalizedUrl);
|
|
783
|
-
await this.agentRegistry.remove(normalizedUrl);
|
|
784
|
-
res.json({ ok: true });
|
|
785
|
-
}
|
|
786
|
-
catch (err) {
|
|
787
|
-
this.log("error", `POST /team/${req.params.id}/leave failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
788
|
-
res.status(500).json({ error: String(err) });
|
|
789
|
-
}
|
|
790
|
-
});
|
|
791
|
-
// Profile update broadcast receiver
|
|
792
|
-
app.post("/team/:id/profile-update", async (req, res) => {
|
|
793
|
-
this.log("debug", `POST /team/${req.params.id}/profile-update from ${req.body?.url}`);
|
|
794
|
-
try {
|
|
795
|
-
const team = await this.teamStore.getTeam(req.params.id);
|
|
796
|
-
if (!team) {
|
|
797
|
-
res.status(404).json({ error: "team not found" });
|
|
798
|
-
return;
|
|
799
|
-
}
|
|
800
|
-
const parsed = profileUpdateBodySchema.safeParse(req.body);
|
|
801
|
-
if (!parsed.success) {
|
|
802
|
-
res.status(400).json({ error: parsed.error.message });
|
|
803
|
-
return;
|
|
804
|
-
}
|
|
805
|
-
const { url, name, description } = parsed.data;
|
|
806
|
-
const normalizedUrl = url.replace(/\/+$/, "");
|
|
807
|
-
// Update team member description
|
|
808
|
-
const existing = team.members.find((m) => m.url.replace(/\/+$/, "") === normalizedUrl);
|
|
809
|
-
if (existing) {
|
|
810
|
-
if (name)
|
|
811
|
-
existing.name = name;
|
|
812
|
-
if (description !== undefined)
|
|
813
|
-
existing.description = description;
|
|
814
|
-
await this.teamStore.saveTeam(team);
|
|
815
|
-
}
|
|
816
|
-
// Update agent registry description
|
|
817
|
-
if (description !== undefined) {
|
|
818
|
-
await this.agentRegistry.updateDescription(normalizedUrl, description);
|
|
819
|
-
}
|
|
820
|
-
res.json({ ok: true });
|
|
821
|
-
}
|
|
822
|
-
catch (err) {
|
|
823
|
-
this.log("error", `POST /team/${req.params.id}/profile-update failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
824
|
-
res.status(500).json({ error: String(err) });
|
|
825
|
-
}
|
|
826
|
-
});
|
|
827
|
-
}
|
|
828
|
-
/* ---------------------------------------------------------------- */
|
|
829
|
-
/* Private helpers */
|
|
830
|
-
/* ---------------------------------------------------------------- */
|
|
831
|
-
async broadcastProfileToTeams() {
|
|
832
|
-
this.log("debug", "broadcastProfileToTeams");
|
|
833
|
-
try {
|
|
834
|
-
const teams = await this.teamStore.listTeams();
|
|
835
|
-
const selfNormalized = this.selfUrl.replace(/\/+$/, "");
|
|
836
|
-
const agentName = this.getFormattedName();
|
|
837
|
-
for (const team of teams) {
|
|
838
|
-
// Update self in team store
|
|
839
|
-
await this.teamStore.addMember(team.teamId, {
|
|
840
|
-
url: this.selfUrl,
|
|
841
|
-
name: agentName,
|
|
842
|
-
description: this.profileDescription,
|
|
843
|
-
joinedAtMs: Date.now(),
|
|
844
|
-
});
|
|
845
|
-
// Broadcast to other members
|
|
846
|
-
const others = team.members.filter((m) => m.url.replace(/\/+$/, "") !== selfNormalized);
|
|
847
|
-
for (const member of others) {
|
|
848
|
-
void this.fetchWithRetry(`${member.url}/team/${team.teamId}/profile-update`, {
|
|
849
|
-
method: "POST",
|
|
850
|
-
headers: { "Content-Type": "application/json" },
|
|
851
|
-
body: JSON.stringify({
|
|
852
|
-
url: this.selfUrl,
|
|
853
|
-
name: agentName,
|
|
854
|
-
description: this.profileDescription,
|
|
855
|
-
}),
|
|
856
|
-
}).catch(() => {
|
|
857
|
-
this.log("warn", `profile broadcast to ${member.url} failed`);
|
|
858
|
-
});
|
|
859
|
-
}
|
|
860
|
-
}
|
|
861
|
-
this.log("debug", "broadcastProfileToTeams completed");
|
|
862
|
-
}
|
|
863
|
-
catch (err) {
|
|
864
|
-
this.log("error", `broadcastProfileToTeams failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
865
|
-
throw err;
|
|
866
|
-
}
|
|
867
|
-
}
|
|
868
|
-
async fetchMemberDescriptions(team) {
|
|
869
|
-
const selfNormalized = this.selfUrl.replace(/\/+$/, "");
|
|
870
|
-
const membersToFetch = team.members.filter((m) => m.url.replace(/\/+$/, "") !== selfNormalized && !m.description);
|
|
871
|
-
this.log("debug", `fetchMemberDescriptions(teamId=${team.teamId}, count=${membersToFetch.length})`);
|
|
872
|
-
try {
|
|
873
|
-
await Promise.allSettled(membersToFetch.map(async (m) => {
|
|
874
|
-
try {
|
|
875
|
-
const client = await this.clientFactory.createFromUrl(m.url);
|
|
876
|
-
const card = await client.getAgentCard();
|
|
877
|
-
if (card.description) {
|
|
878
|
-
m.description = card.description;
|
|
879
|
-
}
|
|
880
|
-
}
|
|
881
|
-
catch {
|
|
882
|
-
this.log("warn", `failed to fetch Agent Card from ${m.url}`);
|
|
883
|
-
}
|
|
884
|
-
}));
|
|
885
|
-
await this.teamStore.saveTeam(team);
|
|
886
|
-
this.log("debug", "fetchMemberDescriptions completed");
|
|
887
|
-
}
|
|
888
|
-
catch (err) {
|
|
889
|
-
this.log("error", `fetchMemberDescriptions failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
890
|
-
throw err;
|
|
891
|
-
}
|
|
892
|
-
}
|
|
893
|
-
async syncTeamToRegistry(team) {
|
|
894
|
-
this.log("debug", `syncTeamToRegistry(teamId=${team.teamId})`);
|
|
895
|
-
try {
|
|
896
|
-
const selfNormalized = this.selfUrl.replace(/\/+$/, "");
|
|
897
|
-
for (const member of team.members) {
|
|
898
|
-
if (member.url.replace(/\/+$/, "") === selfNormalized)
|
|
899
|
-
continue;
|
|
900
|
-
await this.agentRegistry.add({
|
|
901
|
-
url: member.url,
|
|
902
|
-
name: member.name,
|
|
903
|
-
description: member.description,
|
|
904
|
-
});
|
|
905
|
-
}
|
|
906
|
-
this.log("debug", "syncTeamToRegistry completed");
|
|
907
|
-
}
|
|
908
|
-
catch (err) {
|
|
909
|
-
this.log("error", `syncTeamToRegistry failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
910
|
-
throw err;
|
|
911
|
-
}
|
|
912
|
-
}
|
|
913
|
-
async createA2AClient(agent) {
|
|
914
|
-
this.log("info", `[a2a-client] creating client for ${agent.name} (${agent.url})`);
|
|
915
|
-
try {
|
|
916
|
-
const client = await this.clientFactory.createFromUrl(agent.url);
|
|
917
|
-
this.log("info", `[a2a-client] ✓ client created for ${agent.name} (${agent.url})`);
|
|
918
|
-
return client;
|
|
919
|
-
}
|
|
920
|
-
catch (err) {
|
|
921
|
-
this.log("error", `[a2a-client] ✗ failed to create client for ${agent.name} (${agent.url}): ${err instanceof Error ? err.message : String(err)}`);
|
|
922
|
-
throw err;
|
|
923
|
-
}
|
|
924
|
-
}
|
|
925
|
-
/**
|
|
926
|
-
* Send a message using A2A streaming to minimize latency.
|
|
927
|
-
* Instead of a single blocking HTTP call, consume the SSE stream and
|
|
928
|
-
* return the final Task or Message as soon as B signals completion.
|
|
929
|
-
*/
|
|
930
|
-
processTaskResult(trackId, result) {
|
|
931
|
-
this.log("info", `[process-result] processing result for ${trackId}, resultType=${("status" in result && result.status) ? "Task" : "Message"}`);
|
|
932
|
-
try {
|
|
933
|
-
if ("status" in result && result.status) {
|
|
934
|
-
const task = result;
|
|
935
|
-
const state = task.status?.state ?? "unknown";
|
|
936
|
-
const output = this.extractArtifactText(task);
|
|
937
|
-
this.log("info", `[process-result] ${trackId} Task response — state=${state}, outputLen=${output.length}, preview=${output.slice(0, 120)}`);
|
|
938
|
-
if (state === "completed") {
|
|
939
|
-
this.taskTracker.update(trackId, { status: "completed", result: output });
|
|
940
|
-
this.log("info", `[process-result] ✓ ${trackId} marked completed`);
|
|
941
|
-
}
|
|
942
|
-
else if (state === "failed") {
|
|
943
|
-
this.taskTracker.update(trackId, { status: "failed", error: output || "remote task failed" });
|
|
944
|
-
this.log("warn", `[process-result] ✗ ${trackId} marked failed — error=${output || "remote task failed"}`);
|
|
945
|
-
}
|
|
946
|
-
else {
|
|
947
|
-
// For any other state (unknown, working, etc.), mark as failed to avoid
|
|
948
|
-
// tasks stuck in "running" forever until TTL prune.
|
|
949
|
-
this.taskTracker.update(trackId, { status: "failed", error: `unexpected remote state: ${state}` });
|
|
950
|
-
this.log("warn", `[process-result] ✗ ${trackId} unexpected state=${state}, marked failed`);
|
|
951
|
-
}
|
|
952
|
-
return { taskId: task.id, output, status: state };
|
|
953
|
-
}
|
|
954
|
-
const msg = result;
|
|
955
|
-
const text = msg.parts
|
|
956
|
-
?.filter((p) => p.kind === "text")
|
|
957
|
-
.map((p) => p.text)
|
|
958
|
-
.join("\n") ?? "";
|
|
959
|
-
this.taskTracker.update(trackId, { status: "completed", result: text });
|
|
960
|
-
this.log("info", `[process-result] ✓ ${trackId} Message response — completed, textLen=${text.length}, preview=${text.slice(0, 120)}`);
|
|
961
|
-
return { taskId: trackId, output: text, status: "completed" };
|
|
962
|
-
}
|
|
963
|
-
catch (err) {
|
|
964
|
-
this.log("error", `[process-result] ✗ ${trackId} processing failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
965
|
-
throw err;
|
|
966
|
-
}
|
|
967
|
-
}
|
|
968
|
-
extractArtifactText(task) {
|
|
969
|
-
if (!task.artifacts?.length)
|
|
970
|
-
return "";
|
|
971
|
-
return task.artifacts
|
|
972
|
-
.flatMap((a) => a.parts ?? [])
|
|
973
|
-
.filter((p) => p.kind === "text")
|
|
974
|
-
.map((p) => p.text)
|
|
975
|
-
.join("\n");
|
|
976
|
-
}
|
|
977
|
-
/** Fetch with up to 2 retries and exponential backoff. */
|
|
978
|
-
async fetchWithRetry(url, init, retries = 2) {
|
|
979
|
-
let lastError = null;
|
|
980
|
-
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
981
|
-
try {
|
|
982
|
-
const res = await fetch(url, init);
|
|
983
|
-
if (res.ok || attempt === retries)
|
|
984
|
-
return res;
|
|
985
|
-
lastError = new Error(`HTTP ${res.status}`);
|
|
986
|
-
}
|
|
987
|
-
catch (err) {
|
|
988
|
-
lastError = err instanceof Error ? err : new Error(String(err));
|
|
989
|
-
if (attempt === retries)
|
|
990
|
-
break;
|
|
991
|
-
}
|
|
992
|
-
await new Promise((r) => setTimeout(r, 200 * 2 ** attempt));
|
|
993
|
-
}
|
|
994
|
-
throw lastError;
|
|
995
|
-
}
|
|
996
|
-
/**
|
|
997
|
-
* Called by the `multiclaws_task_respond` tool when the local human
|
|
998
|
-
* approves or rejects a pending risky incoming task.
|
|
999
|
-
*/
|
|
1000
|
-
respondToTask(taskId, approved) {
|
|
1001
|
-
this.log("info", `respondToTask(taskId=${taskId}, approved=${approved})`);
|
|
1002
|
-
if (!this.agentExecutor) {
|
|
1003
|
-
this.log("warn", `respondToTask: no agentExecutor available for taskId=${taskId}`);
|
|
1004
|
-
return false;
|
|
1005
|
-
}
|
|
1006
|
-
const resolved = this.agentExecutor.resolveApproval(taskId, approved);
|
|
1007
|
-
this.log("info", `respondToTask: ${resolved ? "✓" : "✗"} taskId=${taskId} ${resolved ? "resolved" : "no pending approval found"}`);
|
|
1008
|
-
return resolved;
|
|
1009
|
-
}
|
|
1010
|
-
/** Resolve a pending A2A callback from sub-agent. */
|
|
1011
|
-
resolveA2ACallback(taskId, result) {
|
|
1012
|
-
this.log("info", `[a2a-callback] resolveA2ACallback(taskId=${taskId}, resultLen=${result.length})`);
|
|
1013
|
-
if (!this.agentExecutor) {
|
|
1014
|
-
this.log("warn", `[a2a-callback] ✗ no agentExecutor available for taskId=${taskId}`);
|
|
1015
|
-
return false;
|
|
1016
|
-
}
|
|
1017
|
-
const resolved = this.agentExecutor.resolveCallback(taskId, result);
|
|
1018
|
-
this.log("info", `[a2a-callback] ${resolved ? "✓" : "✗"} taskId=${taskId} ${resolved ? "resolved" : "no pending callback found"}`);
|
|
1019
|
-
return resolved;
|
|
1020
|
-
}
|
|
1021
|
-
addNotificationTarget(key, target) {
|
|
1022
|
-
if (!this.notificationTargets.has(key)) {
|
|
1023
|
-
this.notificationTargets.set(key, target);
|
|
1024
|
-
this.log("debug", `notification target registered: ${key} (total: ${this.notificationTargets.size})`);
|
|
1025
|
-
}
|
|
1026
|
-
}
|
|
1027
|
-
/** Consistent name for this agent: AgentCard.name or fallback. */
|
|
1028
|
-
getFormattedName() {
|
|
1029
|
-
return this.agentCard?.name ?? "OpenClaw Agent";
|
|
1030
|
-
}
|
|
1031
|
-
/** Discover the most recently active non-internal session via sessions_list. */
|
|
1032
|
-
async discoverActiveSession() {
|
|
1033
|
-
if (!this.gatewayConfig) {
|
|
1034
|
-
this.log("warn", `discoverActiveSession: skipped — no gateway config`);
|
|
1035
|
-
return null;
|
|
1036
|
-
}
|
|
1037
|
-
try {
|
|
1038
|
-
this.log("info", `discoverActiveSession: calling sessions_list (limit=10, activeMinutes=120)`);
|
|
1039
|
-
const raw = await (0, gateway_client_1.invokeGatewayTool)({
|
|
1040
|
-
gateway: this.gatewayConfig,
|
|
1041
|
-
tool: "sessions_list",
|
|
1042
|
-
args: { limit: 10, activeMinutes: 120 },
|
|
1043
|
-
timeoutMs: 5_000,
|
|
1044
|
-
});
|
|
1045
|
-
this.log("info", `discoverActiveSession: raw result = ${JSON.stringify(raw).slice(0, 500)}`);
|
|
1046
|
-
// Unwrap gateway tool standard response: { content: [{ type: "text", text: "..." }] }
|
|
1047
|
-
let parsed = raw;
|
|
1048
|
-
if (raw?.content?.[0]?.type === "text") {
|
|
1049
|
-
try {
|
|
1050
|
-
parsed = JSON.parse(raw.content[0].text);
|
|
1051
|
-
this.log("info", `discoverActiveSession: unwrapped gateway response successfully`);
|
|
1052
|
-
}
|
|
1053
|
-
catch (parseErr) {
|
|
1054
|
-
this.log("warn", `discoverActiveSession: failed to parse content[0].text as JSON — ${parseErr instanceof Error ? parseErr.message : String(parseErr)}, using raw object`);
|
|
1055
|
-
}
|
|
1056
|
-
}
|
|
1057
|
-
const sessions = parsed?.sessions ?? [];
|
|
1058
|
-
this.log("info", `discoverActiveSession: found ${sessions.length} sessions from gateway`);
|
|
1059
|
-
const INTERNAL_PREFIXES = ["delegate-", "a2a-"];
|
|
1060
|
-
// sessions_list returns "key" not "sessionKey"
|
|
1061
|
-
const session = sessions.find((s) => {
|
|
1062
|
-
const k = (s.key ?? s.sessionKey);
|
|
1063
|
-
return k && !INTERNAL_PREFIXES.some((p) => k.startsWith(p));
|
|
1064
|
-
});
|
|
1065
|
-
const matchedKey = (session?.key ?? session?.sessionKey);
|
|
1066
|
-
if (matchedKey) {
|
|
1067
|
-
this.log("info", `discoverActiveSession: ✓ matched session ${matchedKey}`);
|
|
1068
|
-
}
|
|
1069
|
-
else {
|
|
1070
|
-
this.log("warn", `discoverActiveSession: ✗ all ${sessions.length} sessions filtered or empty`);
|
|
1071
|
-
sessions.forEach((s, i) => this.log("info", `discoverActiveSession: session[${i}]: key=${(s.key ?? s.sessionKey) ?? "(no key)"}`));
|
|
1072
|
-
}
|
|
1073
|
-
return matchedKey ?? null;
|
|
1074
|
-
}
|
|
1075
|
-
catch (err) {
|
|
1076
|
-
this.log("error", `discoverActiveSession: ✗ caught error — ${err instanceof Error ? err.message : String(err)}, returning null`);
|
|
1077
|
-
return null;
|
|
1078
|
-
}
|
|
1079
|
-
}
|
|
1080
|
-
async notifyUser(message) {
|
|
1081
|
-
this.log("info", `notifyUser: targets=${this.notificationTargets.size}, msgLen=${message.length}, preview="${message.slice(0, 80)}"`);
|
|
1082
|
-
if (!this.gatewayConfig) {
|
|
1083
|
-
this.log("warn", "notifyUser: skipped — no gatewayConfig, message lost");
|
|
1084
|
-
return;
|
|
1085
|
-
}
|
|
1086
|
-
// Fallback: no registered targets yet (e.g. right after gateway restart)
|
|
1087
|
-
if (this.notificationTargets.size === 0) {
|
|
1088
|
-
this.log("info", "notifyUser: no registered targets → falling back to discoverActiveSession()");
|
|
1089
|
-
const sessionKey = await this.discoverActiveSession();
|
|
1090
|
-
if (sessionKey) {
|
|
1091
|
-
this.log("info", `notifyUser: fallback discovered session ${sessionKey} → calling sessions_send`);
|
|
1092
|
-
try {
|
|
1093
|
-
await (0, gateway_client_1.invokeGatewayTool)({
|
|
1094
|
-
gateway: this.gatewayConfig,
|
|
1095
|
-
tool: "sessions_send",
|
|
1096
|
-
args: { sessionKey, message },
|
|
1097
|
-
timeoutMs: 5_000,
|
|
1098
|
-
});
|
|
1099
|
-
this.log("info", `notifyUser: ✓ fallback sessions_send to ${sessionKey} succeeded`);
|
|
1100
|
-
this.addNotificationTarget(`web:${sessionKey}`, { type: "web", sessionKey });
|
|
1101
|
-
this.log("info", `notifyUser: registered ${sessionKey} as notification target for future use`);
|
|
1102
|
-
}
|
|
1103
|
-
catch (err) {
|
|
1104
|
-
this.log("error", `notifyUser: ✗ fallback sessions_send to ${sessionKey} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1105
|
-
}
|
|
1106
|
-
}
|
|
1107
|
-
else {
|
|
1108
|
-
this.log("warn", "notifyUser: ✗ discoverActiveSession returned null — no active session found, message lost");
|
|
1109
|
-
}
|
|
1110
|
-
return;
|
|
1111
|
-
}
|
|
1112
|
-
const entries = [...this.notificationTargets.entries()];
|
|
1113
|
-
this.log("info", `notifyUser: sending to ${entries.length} registered target(s): [${entries.map(([k]) => k).join(", ")}]`);
|
|
1114
|
-
const results = await Promise.allSettled(entries.map(async ([key, target]) => {
|
|
1115
|
-
this.log("info", `notifyUser: → ${key} (type=${target.type})`);
|
|
1116
|
-
try {
|
|
1117
|
-
await (target.type === "channel"
|
|
1118
|
-
? (0, gateway_client_1.invokeGatewayTool)({
|
|
1119
|
-
gateway: this.gatewayConfig,
|
|
1120
|
-
tool: "message",
|
|
1121
|
-
args: { action: "send", target: target.conversationId, message },
|
|
1122
|
-
timeoutMs: 5_000,
|
|
1123
|
-
})
|
|
1124
|
-
: (0, gateway_client_1.invokeGatewayTool)({
|
|
1125
|
-
gateway: this.gatewayConfig,
|
|
1126
|
-
tool: "sessions_send",
|
|
1127
|
-
args: { sessionKey: target.sessionKey, message },
|
|
1128
|
-
timeoutMs: 5_000,
|
|
1129
|
-
}));
|
|
1130
|
-
this.log("info", `notifyUser: ✓ ${key} (${target.type}) succeeded`);
|
|
1131
|
-
}
|
|
1132
|
-
catch (err) {
|
|
1133
|
-
this.log("error", `notifyUser: ✗ ${key} (${target.type}) failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1134
|
-
throw err;
|
|
1135
|
-
}
|
|
1136
|
-
}));
|
|
1137
|
-
const okCount = results.filter((r) => r.status === "fulfilled").length;
|
|
1138
|
-
const failCount = results.filter((r) => r.status === "rejected").length;
|
|
1139
|
-
if (failCount === 0) {
|
|
1140
|
-
this.log("info", `notifyUser: ✓ all ${okCount} targets succeeded`);
|
|
1141
|
-
}
|
|
1142
|
-
else if (failCount === entries.length) {
|
|
1143
|
-
this.log("error", `notifyUser: ✗ ALL ${failCount} targets failed`);
|
|
1144
|
-
}
|
|
1145
|
-
else {
|
|
1146
|
-
this.log("warn", `notifyUser: ${okCount} ok, ${failCount} FAILED out of ${entries.length} targets`);
|
|
1147
|
-
}
|
|
1148
|
-
}
|
|
1149
|
-
log(level, message) {
|
|
1150
|
-
this.options.logger?.[level]?.(`[multiclaws] ${message}`);
|
|
1151
|
-
}
|
|
1152
|
-
}
|
|
1153
|
-
exports.MulticlawsService = MulticlawsService;
|
|
50
|
+
- 遇到错误时在 multiclaws_notify 中说明失败原因`;
|
|
51
|
+
}
|
|
52
|
+
/* ------------------------------------------------------------------ */
|
|
53
|
+
/* Service */
|
|
54
|
+
/* ------------------------------------------------------------------ */
|
|
55
|
+
class MulticlawsService extends node_events_1.EventEmitter {
|
|
56
|
+
options;
|
|
57
|
+
started = false;
|
|
58
|
+
httpServer = null;
|
|
59
|
+
agentRegistry;
|
|
60
|
+
teamStore;
|
|
61
|
+
profileStore;
|
|
62
|
+
taskTracker;
|
|
63
|
+
agentExecutor = null;
|
|
64
|
+
a2aRequestHandler = null;
|
|
65
|
+
agentCard = null;
|
|
66
|
+
clientFactory = new client_1.ClientFactory();
|
|
67
|
+
httpRateLimiter = new rate_limiter_1.RateLimiter({ windowMs: 60_000, maxRequests: 60 });
|
|
68
|
+
frpTunnel = null;
|
|
69
|
+
selfUrl;
|
|
70
|
+
profileDescription = "OpenClaw agent";
|
|
71
|
+
gatewayConfig;
|
|
72
|
+
resolvedCwd;
|
|
73
|
+
notificationTargets = new Map();
|
|
74
|
+
constructor(options) {
|
|
75
|
+
super();
|
|
76
|
+
this.options = options;
|
|
77
|
+
const multiclawsStateDir = node_path_1.default.join(options.stateDir, "multiclaws");
|
|
78
|
+
this.agentRegistry = new agent_registry_1.AgentRegistry(node_path_1.default.join(multiclawsStateDir, "agents.json"), options.logger);
|
|
79
|
+
this.teamStore = new team_store_1.TeamStore(node_path_1.default.join(multiclawsStateDir, "teams.json"), options.logger);
|
|
80
|
+
this.profileStore = new agent_profile_1.ProfileStore(node_path_1.default.join(multiclawsStateDir, "profile.json"), options.logger);
|
|
81
|
+
this.taskTracker = new tracker_1.TaskTracker({
|
|
82
|
+
filePath: node_path_1.default.join(multiclawsStateDir, "tasks.json"),
|
|
83
|
+
logger: options.logger,
|
|
84
|
+
});
|
|
85
|
+
// selfUrl resolved later in start() after FRP tunnel setup
|
|
86
|
+
this.selfUrl = options.selfUrl ?? "";
|
|
87
|
+
this.gatewayConfig = options.gatewayConfig ?? null;
|
|
88
|
+
this.resolvedCwd = options.cwd || node_os_1.default.homedir();
|
|
89
|
+
}
|
|
90
|
+
async start() {
|
|
91
|
+
if (this.started)
|
|
92
|
+
return;
|
|
93
|
+
this.log("debug", `start(port=${this.options.port ?? 3100}, selfUrl=${this.options.selfUrl ?? "auto"})`);
|
|
94
|
+
try {
|
|
95
|
+
// Resolve selfUrl: explicit config > FRP tunnel
|
|
96
|
+
if (!this.options.selfUrl) {
|
|
97
|
+
const port = this.options.port ?? 3100;
|
|
98
|
+
if (!this.options.tunnel || this.options.tunnel.type !== "frp") {
|
|
99
|
+
throw new Error("multiclaws requires either 'selfUrl' or 'tunnel' configuration. " +
|
|
100
|
+
"Please configure tunnel in plugin settings.");
|
|
101
|
+
}
|
|
102
|
+
this.frpTunnel = new frp_1.FrpTunnelManager({
|
|
103
|
+
config: this.options.tunnel,
|
|
104
|
+
localPort: port,
|
|
105
|
+
stateDir: node_path_1.default.join(this.options.stateDir, "multiclaws"),
|
|
106
|
+
logger: this.options.logger,
|
|
107
|
+
});
|
|
108
|
+
const publicUrl = await this.frpTunnel.start();
|
|
109
|
+
this.selfUrl = publicUrl;
|
|
110
|
+
this.log("info", `FRP tunnel ready: ${publicUrl}`);
|
|
111
|
+
}
|
|
112
|
+
// Load profile for AgentCard description
|
|
113
|
+
const profile = await this.profileStore.load();
|
|
114
|
+
if (!profile.ownerName?.trim()) {
|
|
115
|
+
await this.setPendingProfileReview();
|
|
116
|
+
}
|
|
117
|
+
this.profileDescription = (0, agent_profile_1.renderProfileDescription)(profile);
|
|
118
|
+
const logger = this.options.logger ?? { info: () => { }, warn: () => { }, error: () => { } };
|
|
119
|
+
this.agentExecutor = new a2a_adapter_1.OpenClawAgentExecutor({
|
|
120
|
+
gatewayConfig: this.options.gatewayConfig ?? null,
|
|
121
|
+
taskTracker: this.taskTracker,
|
|
122
|
+
cwd: this.resolvedCwd,
|
|
123
|
+
getNotificationTargets: () => this.notificationTargets,
|
|
124
|
+
registerDiscoveredTarget: (sessionKey) => {
|
|
125
|
+
this.addNotificationTarget(`web:${sessionKey}`, { type: "web", sessionKey });
|
|
126
|
+
},
|
|
127
|
+
logger,
|
|
128
|
+
});
|
|
129
|
+
this.agentCard = {
|
|
130
|
+
name: profile.ownerName?.trim() ? (0, agent_profile_1.formatAgentCardName)(profile.ownerName.trim()) : "OpenClaw Agent",
|
|
131
|
+
description: this.profileDescription,
|
|
132
|
+
url: this.selfUrl,
|
|
133
|
+
version: version_1.PLUGIN_VERSION,
|
|
134
|
+
protocolVersion: "0.2.2",
|
|
135
|
+
defaultInputModes: ["text/plain"],
|
|
136
|
+
defaultOutputModes: ["text/plain"],
|
|
137
|
+
capabilities: { streaming: false, pushNotifications: false },
|
|
138
|
+
skills: [
|
|
139
|
+
{
|
|
140
|
+
id: "general",
|
|
141
|
+
name: "General Task",
|
|
142
|
+
description: "Execute any delegated task via OpenClaw",
|
|
143
|
+
tags: ["task", "delegation", "general"],
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
};
|
|
147
|
+
const taskStore = new server_1.InMemoryTaskStore();
|
|
148
|
+
this.a2aRequestHandler = new server_1.DefaultRequestHandler(this.agentCard, taskStore, this.agentExecutor);
|
|
149
|
+
const app = (0, express_1.default)();
|
|
150
|
+
app.use(express_1.default.json({ limit: "1mb" }));
|
|
151
|
+
// Rate limiting
|
|
152
|
+
app.use((req, res, next) => {
|
|
153
|
+
const clientIp = req.ip ?? req.socket.remoteAddress ?? "unknown";
|
|
154
|
+
if (!this.httpRateLimiter.allow(clientIp)) {
|
|
155
|
+
res.status(429).json({ error: "rate limited" });
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
next();
|
|
159
|
+
});
|
|
160
|
+
// Team + profile REST endpoints
|
|
161
|
+
this.mountTeamRoutes(app);
|
|
162
|
+
// A2A endpoints
|
|
163
|
+
app.use("/.well-known/agent-card.json", (0, express_2.agentCardHandler)({
|
|
164
|
+
agentCardProvider: this.a2aRequestHandler,
|
|
165
|
+
}));
|
|
166
|
+
app.use("/", (0, express_2.jsonRpcHandler)({
|
|
167
|
+
requestHandler: this.a2aRequestHandler,
|
|
168
|
+
userBuilder: express_2.UserBuilder.noAuthentication,
|
|
169
|
+
}));
|
|
170
|
+
const listenPort = this.options.port ?? 3100;
|
|
171
|
+
this.httpServer = node_http_1.default.createServer(app);
|
|
172
|
+
await new Promise((resolve) => this.httpServer.listen(listenPort, "0.0.0.0", resolve));
|
|
173
|
+
this.started = true;
|
|
174
|
+
this.log("info", `multiclaws A2A service listening on :${listenPort}`);
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
this.log("error", `start failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
178
|
+
throw err;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
async stop() {
|
|
182
|
+
if (!this.started)
|
|
183
|
+
return;
|
|
184
|
+
this.log("debug", "stopping");
|
|
185
|
+
try {
|
|
186
|
+
this.started = false;
|
|
187
|
+
this.taskTracker.destroy();
|
|
188
|
+
this.httpRateLimiter.destroy();
|
|
189
|
+
if (this.frpTunnel) {
|
|
190
|
+
await this.frpTunnel.stop();
|
|
191
|
+
this.frpTunnel = null;
|
|
192
|
+
}
|
|
193
|
+
await new Promise((resolve) => {
|
|
194
|
+
if (!this.httpServer) {
|
|
195
|
+
resolve();
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
this.httpServer.close(() => resolve());
|
|
199
|
+
});
|
|
200
|
+
this.httpServer = null;
|
|
201
|
+
this.log("debug", "stopped");
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
this.log("error", `stop failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
205
|
+
throw err;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
updateGatewayConfig(config) {
|
|
209
|
+
this.agentExecutor?.updateGatewayConfig(config);
|
|
210
|
+
}
|
|
211
|
+
/* ---------------------------------------------------------------- */
|
|
212
|
+
/* Agent management */
|
|
213
|
+
/* ---------------------------------------------------------------- */
|
|
214
|
+
async listAgents() {
|
|
215
|
+
return await this.agentRegistry.list();
|
|
216
|
+
}
|
|
217
|
+
async addAgent(params) {
|
|
218
|
+
const normalizedUrl = params.url.replace(/\/+$/, "");
|
|
219
|
+
this.log("debug", `addAgent(url=${normalizedUrl})`);
|
|
220
|
+
try {
|
|
221
|
+
const client = await this.clientFactory.createFromUrl(normalizedUrl);
|
|
222
|
+
const card = await client.getAgentCard();
|
|
223
|
+
const result = await this.agentRegistry.add({
|
|
224
|
+
url: normalizedUrl,
|
|
225
|
+
name: card.name ?? normalizedUrl,
|
|
226
|
+
description: card.description ?? "",
|
|
227
|
+
skills: card.skills?.map((s) => s.name ?? s.id) ?? [],
|
|
228
|
+
apiKey: params.apiKey,
|
|
229
|
+
});
|
|
230
|
+
this.log("debug", `addAgent completed, name=${result.name}`);
|
|
231
|
+
return result;
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
this.log("debug", `addAgent: card fetch failed for ${normalizedUrl}, adding with URL as name`);
|
|
235
|
+
return await this.agentRegistry.add({
|
|
236
|
+
url: normalizedUrl,
|
|
237
|
+
name: normalizedUrl,
|
|
238
|
+
apiKey: params.apiKey,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
async removeAgent(url) {
|
|
243
|
+
this.log("debug", `removeAgent(url=${url})`);
|
|
244
|
+
try {
|
|
245
|
+
const result = await this.agentRegistry.remove(url);
|
|
246
|
+
this.log("debug", `removeAgent completed, result=${result}`);
|
|
247
|
+
return result;
|
|
248
|
+
}
|
|
249
|
+
catch (err) {
|
|
250
|
+
this.log("error", `removeAgent failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
251
|
+
throw err;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
/* ---------------------------------------------------------------- */
|
|
255
|
+
/* Task delegation */
|
|
256
|
+
/* ---------------------------------------------------------------- */
|
|
257
|
+
async delegateTask(params) {
|
|
258
|
+
this.log("info", `[delegate] ▶ delegateTask(agentUrl=${params.agentUrl}, taskLen=${params.task.length})`);
|
|
259
|
+
this.log("info", `[delegate] task preview: "${params.task.slice(0, 120)}"`);
|
|
260
|
+
// Step 1: Check profile
|
|
261
|
+
this.log("info", `[delegate] [step:profile-check] verifying profile completeness`);
|
|
262
|
+
try {
|
|
263
|
+
await this.requireCompleteProfile();
|
|
264
|
+
}
|
|
265
|
+
catch (err) {
|
|
266
|
+
this.log("error", `[delegate] [step:profile-check] ✗ profile incomplete: ${err instanceof Error ? err.message : String(err)}`);
|
|
267
|
+
return { status: "failed", error: err instanceof Error ? err.message : String(err) };
|
|
268
|
+
}
|
|
269
|
+
// Step 2: Look up agent
|
|
270
|
+
this.log("info", `[delegate] [step:agent-lookup] looking up agent: ${params.agentUrl}`);
|
|
271
|
+
const agentRecord = await this.agentRegistry.get(params.agentUrl);
|
|
272
|
+
if (!agentRecord) {
|
|
273
|
+
this.log("warn", `[delegate] [step:agent-lookup] ✗ unknown agent: ${params.agentUrl} → aborting`);
|
|
274
|
+
return { status: "failed", error: `unknown agent: ${params.agentUrl}` };
|
|
275
|
+
}
|
|
276
|
+
this.log("info", `[delegate] [step:agent-lookup] ✓ found: ${agentRecord.name} (${agentRecord.url})`);
|
|
277
|
+
// Step 3: Track task
|
|
278
|
+
const track = this.taskTracker.create({
|
|
279
|
+
fromPeerId: "local",
|
|
280
|
+
toPeerId: params.agentUrl,
|
|
281
|
+
task: params.task,
|
|
282
|
+
});
|
|
283
|
+
this.taskTracker.update(track.taskId, { status: "running" });
|
|
284
|
+
this.log("info", `[delegate] [step:track] taskId=${track.taskId}, status=running`);
|
|
285
|
+
try {
|
|
286
|
+
// Step 4: Create A2A client
|
|
287
|
+
this.log("info", `[delegate] ${track.taskId} [step:create-client] creating A2A client for ${agentRecord.url}`);
|
|
288
|
+
const client = await this.createA2AClient(agentRecord);
|
|
289
|
+
this.log("info", `[delegate] ${track.taskId} [step:create-client] ✓ client created → starting fire-and-forget send`);
|
|
290
|
+
// Step 5: Fire-and-forget execution
|
|
291
|
+
void (async () => {
|
|
292
|
+
try {
|
|
293
|
+
this.log("info", `[delegate] ${track.taskId} [step:background-send] sending A2A message to ${agentRecord.name}...`);
|
|
294
|
+
const result = await client.sendMessage({
|
|
295
|
+
message: {
|
|
296
|
+
kind: "message",
|
|
297
|
+
role: "user",
|
|
298
|
+
parts: [{ kind: "text", text: params.task }],
|
|
299
|
+
messageId: track.taskId,
|
|
300
|
+
},
|
|
301
|
+
});
|
|
302
|
+
this.log("info", `[delegate] ${track.taskId} [step:background-send] ✓ A2A response received → processing result`);
|
|
303
|
+
this.processTaskResult(track.taskId, result);
|
|
304
|
+
}
|
|
305
|
+
catch (err) {
|
|
306
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
307
|
+
this.taskTracker.update(track.taskId, { status: "failed", error: errorMsg });
|
|
308
|
+
this.log("error", `[delegate] ${track.taskId} [step:background-send] ✗ caught error: ${errorMsg} → task marked failed`);
|
|
309
|
+
}
|
|
310
|
+
})();
|
|
311
|
+
this.log("info", `[delegate] ${track.taskId} [step:return] returned immediately (fire-and-forget), background send in progress`);
|
|
312
|
+
return { taskId: track.taskId, status: "running" };
|
|
313
|
+
}
|
|
314
|
+
catch (err) {
|
|
315
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
316
|
+
this.taskTracker.update(track.taskId, { status: "failed", error: errorMsg });
|
|
317
|
+
this.log("error", `[delegate] ${track.taskId} [step:catch] ✗ caught error during client creation: ${errorMsg} → task marked failed`);
|
|
318
|
+
return { taskId: track.taskId, status: "failed", error: errorMsg };
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Synchronous delegation: sends A2A task and waits for the result.
|
|
323
|
+
* Used by sub-agents internally via the multiclaws_delegate_send tool.
|
|
324
|
+
*/
|
|
325
|
+
async delegateTaskSync(params) {
|
|
326
|
+
this.log("info", `[delegate-sync] ▶ delegateTaskSync(agentUrl=${params.agentUrl}, taskLen=${params.task.length})`);
|
|
327
|
+
this.log("info", `[delegate-sync] task preview: "${params.task.slice(0, 120)}"`);
|
|
328
|
+
// Step 1: Check profile
|
|
329
|
+
this.log("info", `[delegate-sync] [step:profile-check] verifying profile completeness`);
|
|
330
|
+
try {
|
|
331
|
+
await this.requireCompleteProfile();
|
|
332
|
+
}
|
|
333
|
+
catch (err) {
|
|
334
|
+
this.log("error", `[delegate-sync] [step:profile-check] ✗ profile incomplete: ${err instanceof Error ? err.message : String(err)}`);
|
|
335
|
+
return { status: "failed", error: err instanceof Error ? err.message : String(err) };
|
|
336
|
+
}
|
|
337
|
+
// Step 2: Look up agent
|
|
338
|
+
this.log("info", `[delegate-sync] [step:agent-lookup] looking up agent: ${params.agentUrl}`);
|
|
339
|
+
const agentRecord = await this.agentRegistry.get(params.agentUrl);
|
|
340
|
+
if (!agentRecord) {
|
|
341
|
+
this.log("warn", `[delegate-sync] [step:agent-lookup] ✗ unknown agent: ${params.agentUrl} → aborting`);
|
|
342
|
+
return { status: "failed", error: `unknown agent: ${params.agentUrl}` };
|
|
343
|
+
}
|
|
344
|
+
this.log("info", `[delegate-sync] [step:agent-lookup] ✓ found: ${agentRecord.name} (${agentRecord.url})`);
|
|
345
|
+
// Step 3: Track task
|
|
346
|
+
const track = this.taskTracker.create({
|
|
347
|
+
fromPeerId: "local",
|
|
348
|
+
toPeerId: params.agentUrl,
|
|
349
|
+
task: params.task,
|
|
350
|
+
});
|
|
351
|
+
this.taskTracker.update(track.taskId, { status: "running" });
|
|
352
|
+
this.log("info", `[delegate-sync] [step:track] taskId=${track.taskId}, status=running`);
|
|
353
|
+
try {
|
|
354
|
+
// Step 4: Create A2A client
|
|
355
|
+
this.log("info", `[delegate-sync] ${track.taskId} [step:create-client] creating A2A client for ${agentRecord.url}`);
|
|
356
|
+
const client = await this.createA2AClient(agentRecord);
|
|
357
|
+
// Step 5: Send A2A message (synchronous — blocks until response)
|
|
358
|
+
this.log("info", `[delegate-sync] ${track.taskId} [step:send] sending A2A message (sync, metadata: selfUrl=${this.selfUrl}, selfName=${this.agentCard?.name ?? "unknown"})...`);
|
|
359
|
+
const result = await client.sendMessage({
|
|
360
|
+
message: {
|
|
361
|
+
kind: "message",
|
|
362
|
+
role: "user",
|
|
363
|
+
parts: [{ kind: "text", text: params.task }],
|
|
364
|
+
messageId: track.taskId,
|
|
365
|
+
metadata: {
|
|
366
|
+
agentUrl: this.selfUrl,
|
|
367
|
+
agentName: this.agentCard?.name ?? "unknown",
|
|
368
|
+
},
|
|
369
|
+
},
|
|
370
|
+
});
|
|
371
|
+
this.log("info", `[delegate-sync] ${track.taskId} [step:send] ✓ A2A response received → processing result`);
|
|
372
|
+
// Step 6: Process result
|
|
373
|
+
const taskResult = this.processTaskResult(track.taskId, result);
|
|
374
|
+
this.log("info", `[delegate-sync] ${track.taskId} [step:completed] ✓ status=${taskResult.status}, outputLen=${taskResult.output?.length ?? 0}, preview="${(taskResult.output ?? "").slice(0, 120)}"`);
|
|
375
|
+
return taskResult;
|
|
376
|
+
}
|
|
377
|
+
catch (err) {
|
|
378
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
379
|
+
this.taskTracker.update(track.taskId, { status: "failed", error: errorMsg });
|
|
380
|
+
this.log("error", `[delegate-sync] ${track.taskId} [step:catch] ✗ caught error: ${errorMsg} → task marked failed`);
|
|
381
|
+
return { taskId: track.taskId, status: "failed", error: errorMsg };
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Spawn a sub-agent to handle delegation asynchronously.
|
|
386
|
+
* The sub-agent uses multiclaws_delegate_send internally and
|
|
387
|
+
* reports results back to the user via the message tool.
|
|
388
|
+
*/
|
|
389
|
+
async spawnDelegation(params) {
|
|
390
|
+
this.log("info", `[spawn-delegate] ▶ spawnDelegation(agentUrl=${params.agentUrl}, taskLen=${params.task.length})`);
|
|
391
|
+
this.log("info", `[spawn-delegate] task preview: "${params.task.slice(0, 120)}"`);
|
|
392
|
+
// Step 1: Check profile
|
|
393
|
+
this.log("info", `[spawn-delegate] [step:profile-check] verifying profile completeness`);
|
|
394
|
+
try {
|
|
395
|
+
await this.requireCompleteProfile();
|
|
396
|
+
}
|
|
397
|
+
catch (err) {
|
|
398
|
+
this.log("error", `[spawn-delegate] [step:profile-check] ✗ profile incomplete: ${err instanceof Error ? err.message : String(err)}`);
|
|
399
|
+
throw err;
|
|
400
|
+
}
|
|
401
|
+
// Step 2: Look up agent
|
|
402
|
+
this.log("info", `[spawn-delegate] [step:agent-lookup] looking up agent: ${params.agentUrl}`);
|
|
403
|
+
const agent = await this.agentRegistry.get(params.agentUrl);
|
|
404
|
+
if (!agent) {
|
|
405
|
+
this.log("warn", `[spawn-delegate] [step:agent-lookup] ✗ unknown agent: ${params.agentUrl} → aborting`);
|
|
406
|
+
throw new Error(`unknown agent: ${params.agentUrl}`);
|
|
407
|
+
}
|
|
408
|
+
this.log("info", `[spawn-delegate] [step:agent-lookup] ✓ found: ${agent.name} (${agent.url})`);
|
|
409
|
+
// Step 3: Check gateway config
|
|
410
|
+
if (!this.gatewayConfig) {
|
|
411
|
+
this.log("error", `[spawn-delegate] [step:gateway-check] ✗ gateway config not available → aborting`);
|
|
412
|
+
throw new Error("gateway config not available — cannot spawn sub-agent");
|
|
413
|
+
}
|
|
414
|
+
// Step 4: Spawn sub-agent
|
|
415
|
+
const prompt = buildDelegationPrompt(agent, params.task);
|
|
416
|
+
const sessionKey = `delegate-${Date.now()}`;
|
|
417
|
+
this.log("info", `[spawn-delegate] [step:spawn] calling sessions_spawn (cwd=${this.resolvedCwd}, sessionKey=${sessionKey}, promptLen=${prompt.length})`);
|
|
418
|
+
try {
|
|
419
|
+
const spawnResult = await (0, gateway_client_1.invokeGatewayTool)({
|
|
420
|
+
gateway: this.gatewayConfig,
|
|
421
|
+
tool: "sessions_spawn",
|
|
422
|
+
args: { task: prompt, mode: "run", cwd: this.resolvedCwd },
|
|
423
|
+
sessionKey,
|
|
424
|
+
timeoutMs: 15_000,
|
|
425
|
+
});
|
|
426
|
+
this.log("info", `[spawn-delegate] [step:spawn] ✓ sub-agent spawned for ${agent.name} — result=${JSON.stringify(spawnResult).slice(0, 200)}`);
|
|
427
|
+
return { message: `已启动子 agent 向 ${agent.name} 委派任务` };
|
|
428
|
+
}
|
|
429
|
+
catch (err) {
|
|
430
|
+
this.log("error", `[spawn-delegate] [step:spawn] ✗ sessions_spawn failed: ${err instanceof Error ? err.message : String(err)} → aborting`);
|
|
431
|
+
throw err;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
getTaskStatus(taskId) {
|
|
435
|
+
return this.taskTracker.get(taskId);
|
|
436
|
+
}
|
|
437
|
+
/* ---------------------------------------------------------------- */
|
|
438
|
+
/* Profile */
|
|
439
|
+
/* ---------------------------------------------------------------- */
|
|
440
|
+
async getProfile() {
|
|
441
|
+
return await this.profileStore.load();
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Throws if the profile is incomplete (ownerName or bio missing).
|
|
445
|
+
* Call this before any action that exposes the user's identity to other agents.
|
|
446
|
+
*/
|
|
447
|
+
async requireCompleteProfile() {
|
|
448
|
+
const profile = await this.profileStore.load();
|
|
449
|
+
if (!profile.ownerName?.trim()) {
|
|
450
|
+
throw new Error("档案未完成设置。请先调用 multiclaws_profile_set(ownerName=\"你的名字\") 设置用户名后再继续。");
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
async setProfile(patch) {
|
|
454
|
+
this.log("debug", `setProfile(keys=${Object.keys(patch).join(",")})`);
|
|
455
|
+
try {
|
|
456
|
+
const profile = await this.profileStore.update(patch);
|
|
457
|
+
this.updateProfileDescription(profile);
|
|
458
|
+
await this.broadcastProfileToTeams();
|
|
459
|
+
this.log("debug", "setProfile completed");
|
|
460
|
+
return profile;
|
|
461
|
+
}
|
|
462
|
+
catch (err) {
|
|
463
|
+
this.log("error", `setProfile failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
464
|
+
throw err;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
updateProfileDescription(profile) {
|
|
468
|
+
this.profileDescription = (0, agent_profile_1.renderProfileDescription)(profile);
|
|
469
|
+
if (this.agentCard) {
|
|
470
|
+
this.agentCard.description = this.profileDescription;
|
|
471
|
+
if (profile.ownerName?.trim()) {
|
|
472
|
+
this.agentCard.name = (0, agent_profile_1.formatAgentCardName)(profile.ownerName.trim());
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
/* ---------------------------------------------------------------- */
|
|
477
|
+
/* Pending profile review (install / first-run) */
|
|
478
|
+
/* ---------------------------------------------------------------- */
|
|
479
|
+
getPendingReviewPath() {
|
|
480
|
+
return node_path_1.default.join(this.options.stateDir, "multiclaws", "pending-profile-review.json");
|
|
481
|
+
}
|
|
482
|
+
async getPendingProfileReview() {
|
|
483
|
+
const p = this.getPendingReviewPath();
|
|
484
|
+
const data = await (0, json_store_1.readJsonWithFallback)(p, {});
|
|
485
|
+
if (data.pending !== true) {
|
|
486
|
+
return { pending: false };
|
|
487
|
+
}
|
|
488
|
+
const profile = await this.profileStore.load();
|
|
489
|
+
return {
|
|
490
|
+
pending: true,
|
|
491
|
+
profile,
|
|
492
|
+
message: "这是您当前的 MultiClaws 档案,是否需要修改名字、角色、数据源或能力?",
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
async setPendingProfileReview() {
|
|
496
|
+
this.log("debug", "setPendingProfileReview");
|
|
497
|
+
try {
|
|
498
|
+
const p = this.getPendingReviewPath();
|
|
499
|
+
await (0, json_store_1.writeJsonAtomically)(p, { pending: true });
|
|
500
|
+
}
|
|
501
|
+
catch (err) {
|
|
502
|
+
this.log("error", `setPendingProfileReview failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
503
|
+
throw err;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
async clearPendingProfileReview() {
|
|
507
|
+
this.log("debug", "clearPendingProfileReview");
|
|
508
|
+
const p = this.getPendingReviewPath();
|
|
509
|
+
try {
|
|
510
|
+
await promises_1.default.unlink(p);
|
|
511
|
+
}
|
|
512
|
+
catch {
|
|
513
|
+
// ignore if missing
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
/* ---------------------------------------------------------------- */
|
|
517
|
+
/* Team management */
|
|
518
|
+
/* ---------------------------------------------------------------- */
|
|
519
|
+
async createTeam(name) {
|
|
520
|
+
this.log("debug", `createTeam(name=${name})`);
|
|
521
|
+
try {
|
|
522
|
+
await this.requireCompleteProfile();
|
|
523
|
+
const team = await this.teamStore.createTeam({
|
|
524
|
+
teamName: name,
|
|
525
|
+
selfUrl: this.selfUrl,
|
|
526
|
+
selfName: this.getFormattedName(),
|
|
527
|
+
selfDescription: this.profileDescription,
|
|
528
|
+
});
|
|
529
|
+
this.log("info", `team created: ${team.teamId} (${team.teamName})`);
|
|
530
|
+
return team;
|
|
531
|
+
}
|
|
532
|
+
catch (err) {
|
|
533
|
+
this.log("error", `createTeam failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
534
|
+
throw err;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
async createInvite(teamId) {
|
|
538
|
+
this.log("debug", `createInvite(teamId=${teamId ?? "first"})`);
|
|
539
|
+
try {
|
|
540
|
+
const team = teamId
|
|
541
|
+
? await this.teamStore.getTeam(teamId)
|
|
542
|
+
: await this.teamStore.getFirstTeam();
|
|
543
|
+
if (!team)
|
|
544
|
+
throw new Error(teamId ? `team not found: ${teamId}` : "no team exists");
|
|
545
|
+
const code = (0, team_store_1.encodeInvite)(team.teamId, this.selfUrl);
|
|
546
|
+
this.log("debug", "createInvite completed");
|
|
547
|
+
return code;
|
|
548
|
+
}
|
|
549
|
+
catch (err) {
|
|
550
|
+
this.log("error", `createInvite failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
551
|
+
throw err;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
async joinTeam(inviteCode) {
|
|
555
|
+
this.log("info", "joinTeam starting");
|
|
556
|
+
try {
|
|
557
|
+
await this.requireCompleteProfile();
|
|
558
|
+
const invite = (0, team_store_1.decodeInvite)(inviteCode);
|
|
559
|
+
const seedUrl = invite.u.replace(/\/+$/, "");
|
|
560
|
+
this.log("debug", `joinTeam: seedUrl=${seedUrl}, teamId=${invite.t}`);
|
|
561
|
+
// 1. Fetch member list from seed
|
|
562
|
+
let membersRes;
|
|
563
|
+
try {
|
|
564
|
+
membersRes = await fetch(`${seedUrl}/team/${invite.t}/members`);
|
|
565
|
+
}
|
|
566
|
+
catch (err) {
|
|
567
|
+
throw new Error(`Unable to reach team seed node at ${seedUrl}: ${err instanceof Error ? err.message : String(err)}`);
|
|
568
|
+
}
|
|
569
|
+
if (!membersRes.ok) {
|
|
570
|
+
throw new Error(`failed to fetch team members from ${seedUrl}: HTTP ${membersRes.status}`);
|
|
571
|
+
}
|
|
572
|
+
const { team: remoteTeam } = (await membersRes.json());
|
|
573
|
+
// 2. Announce self to seed (seed broadcasts to others)
|
|
574
|
+
const selfMember = {
|
|
575
|
+
url: this.selfUrl,
|
|
576
|
+
name: this.getFormattedName(),
|
|
577
|
+
description: this.profileDescription,
|
|
578
|
+
joinedAtMs: Date.now(),
|
|
579
|
+
};
|
|
580
|
+
let announceRes;
|
|
581
|
+
try {
|
|
582
|
+
announceRes = await fetch(`${seedUrl}/team/${invite.t}/announce`, {
|
|
583
|
+
method: "POST",
|
|
584
|
+
headers: { "Content-Type": "application/json" },
|
|
585
|
+
body: JSON.stringify(selfMember),
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
catch (err) {
|
|
589
|
+
throw new Error(`Failed to announce self to seed ${seedUrl}: ${err instanceof Error ? err.message : String(err)}`);
|
|
590
|
+
}
|
|
591
|
+
if (!announceRes.ok) {
|
|
592
|
+
throw new Error(`failed to announce to seed ${seedUrl}: HTTP ${announceRes.status}`);
|
|
593
|
+
}
|
|
594
|
+
// 3. Store team locally
|
|
595
|
+
const allMembers = [...remoteTeam.members];
|
|
596
|
+
const selfNormalized = this.selfUrl.replace(/\/+$/, "");
|
|
597
|
+
if (!allMembers.some((m) => m.url.replace(/\/+$/, "") === selfNormalized)) {
|
|
598
|
+
allMembers.push(selfMember);
|
|
599
|
+
}
|
|
600
|
+
const team = {
|
|
601
|
+
teamId: invite.t,
|
|
602
|
+
teamName: remoteTeam.teamName,
|
|
603
|
+
selfUrl: this.selfUrl,
|
|
604
|
+
members: allMembers,
|
|
605
|
+
createdAtMs: Date.now(),
|
|
606
|
+
};
|
|
607
|
+
await this.teamStore.saveTeam(team);
|
|
608
|
+
// 4. Fetch Agent Cards for members without descriptions, then sync to registry
|
|
609
|
+
await this.fetchMemberDescriptions(team);
|
|
610
|
+
await this.syncTeamToRegistry(team);
|
|
611
|
+
this.log("info", `joined team ${team.teamId} (${team.teamName}) with ${allMembers.length} members`);
|
|
612
|
+
return team;
|
|
613
|
+
}
|
|
614
|
+
catch (err) {
|
|
615
|
+
this.log("error", `joinTeam failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
616
|
+
throw err;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
async leaveTeam(teamId) {
|
|
620
|
+
this.log("info", `leaveTeam(teamId=${teamId ?? "first"})`);
|
|
621
|
+
try {
|
|
622
|
+
const team = teamId
|
|
623
|
+
? await this.teamStore.getTeam(teamId)
|
|
624
|
+
: await this.teamStore.getFirstTeam();
|
|
625
|
+
if (!team)
|
|
626
|
+
throw new Error(teamId ? `team not found: ${teamId}` : "no team exists");
|
|
627
|
+
const selfNormalized = this.selfUrl.replace(/\/+$/, "");
|
|
628
|
+
const selfMember = {
|
|
629
|
+
url: this.selfUrl,
|
|
630
|
+
name: this.getFormattedName(),
|
|
631
|
+
joinedAtMs: 0,
|
|
632
|
+
};
|
|
633
|
+
const others = team.members.filter((m) => m.url.replace(/\/+$/, "") !== selfNormalized);
|
|
634
|
+
await Promise.allSettled(others.map(async (m) => {
|
|
635
|
+
try {
|
|
636
|
+
await fetch(`${m.url}/team/${team.teamId}/leave`, {
|
|
637
|
+
method: "POST",
|
|
638
|
+
headers: { "Content-Type": "application/json" },
|
|
639
|
+
body: JSON.stringify(selfMember),
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
catch {
|
|
643
|
+
this.log("warn", `failed to notify ${m.url} about leaving`);
|
|
644
|
+
}
|
|
645
|
+
}));
|
|
646
|
+
for (const m of others) {
|
|
647
|
+
await this.agentRegistry.remove(m.url);
|
|
648
|
+
}
|
|
649
|
+
await this.teamStore.deleteTeam(team.teamId);
|
|
650
|
+
this.log("info", `left team ${team.teamId}`);
|
|
651
|
+
}
|
|
652
|
+
catch (err) {
|
|
653
|
+
this.log("error", `leaveTeam failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
654
|
+
throw err;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
async listTeamMembers(teamId) {
|
|
658
|
+
if (teamId) {
|
|
659
|
+
const team = await this.teamStore.getTeam(teamId);
|
|
660
|
+
if (!team)
|
|
661
|
+
return null;
|
|
662
|
+
return { team, members: team.members };
|
|
663
|
+
}
|
|
664
|
+
const all = await this.teamStore.listTeams();
|
|
665
|
+
if (all.length === 0)
|
|
666
|
+
return null;
|
|
667
|
+
if (all.length === 1)
|
|
668
|
+
return { team: all[0], members: all[0].members };
|
|
669
|
+
return { teams: all.map((team) => ({ team, members: team.members })) };
|
|
670
|
+
}
|
|
671
|
+
/* ---------------------------------------------------------------- */
|
|
672
|
+
/* Team REST routes */
|
|
673
|
+
/* ---------------------------------------------------------------- */
|
|
674
|
+
mountTeamRoutes(app) {
|
|
675
|
+
const announceBodySchema = zod_1.z.object({
|
|
676
|
+
url: zod_1.z.string().trim().min(1),
|
|
677
|
+
name: zod_1.z.string().trim().min(1),
|
|
678
|
+
description: zod_1.z.string().trim().optional(),
|
|
679
|
+
joinedAtMs: zod_1.z.number().optional(),
|
|
680
|
+
});
|
|
681
|
+
const leaveBodySchema = zod_1.z.object({
|
|
682
|
+
url: zod_1.z.string().trim().min(1),
|
|
683
|
+
});
|
|
684
|
+
const profileUpdateBodySchema = zod_1.z.object({
|
|
685
|
+
url: zod_1.z.string().trim().min(1),
|
|
686
|
+
name: zod_1.z.string().trim().optional(),
|
|
687
|
+
description: zod_1.z.string().optional(),
|
|
688
|
+
});
|
|
689
|
+
app.get("/team/:id/members", async (req, res) => {
|
|
690
|
+
try {
|
|
691
|
+
const team = await this.teamStore.getTeam(req.params.id);
|
|
692
|
+
if (!team) {
|
|
693
|
+
res.status(404).json({ error: "team not found" });
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
res.json({ team: { teamName: team.teamName, members: team.members } });
|
|
697
|
+
}
|
|
698
|
+
catch (err) {
|
|
699
|
+
this.log("error", `GET /team/${req.params.id}/members failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
700
|
+
res.status(500).json({ error: String(err) });
|
|
701
|
+
}
|
|
702
|
+
});
|
|
703
|
+
app.post("/team/:id/announce", async (req, res) => {
|
|
704
|
+
this.log("debug", `POST /team/${req.params.id}/announce from ${req.body?.url}`);
|
|
705
|
+
try {
|
|
706
|
+
const team = await this.teamStore.getTeam(req.params.id);
|
|
707
|
+
if (!team) {
|
|
708
|
+
this.log("debug", `announce: team ${req.params.id} not found`);
|
|
709
|
+
res.status(404).json({ error: "team not found" });
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
const parsed = announceBodySchema.safeParse(req.body);
|
|
713
|
+
if (!parsed.success) {
|
|
714
|
+
this.log("debug", `announce: invalid body: ${parsed.error.message}`);
|
|
715
|
+
res.status(400).json({ error: parsed.error.message });
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
const member = parsed.data;
|
|
719
|
+
this.log("debug", `announce: member=${member.name}, url=${member.url}`);
|
|
720
|
+
const normalizedUrl = member.url.replace(/\/+$/, "");
|
|
721
|
+
const alreadyKnown = team.members.some((m) => m.url.replace(/\/+$/, "") === normalizedUrl);
|
|
722
|
+
this.log("debug", `announce: alreadyKnown=${alreadyKnown}`);
|
|
723
|
+
await this.teamStore.addMember(team.teamId, {
|
|
724
|
+
url: normalizedUrl,
|
|
725
|
+
name: member.name,
|
|
726
|
+
description: member.description,
|
|
727
|
+
joinedAtMs: member.joinedAtMs ?? Date.now(),
|
|
728
|
+
});
|
|
729
|
+
this.log("debug", `announce: addMember completed`);
|
|
730
|
+
await this.agentRegistry.add({
|
|
731
|
+
url: normalizedUrl,
|
|
732
|
+
name: member.name,
|
|
733
|
+
description: member.description,
|
|
734
|
+
});
|
|
735
|
+
this.log("debug", `announce: agentRegistry.add completed`);
|
|
736
|
+
// Broadcast to other members if new
|
|
737
|
+
if (!alreadyKnown) {
|
|
738
|
+
const selfNormalized = this.selfUrl.replace(/\/+$/, "");
|
|
739
|
+
const others = team.members.filter((m) => m.url.replace(/\/+$/, "") !== normalizedUrl &&
|
|
740
|
+
m.url.replace(/\/+$/, "") !== selfNormalized);
|
|
741
|
+
this.log("debug", `announce: broadcasting to ${others.length} other members`);
|
|
742
|
+
for (const other of others) {
|
|
743
|
+
void this.fetchWithRetry(`${other.url}/team/${team.teamId}/announce`, {
|
|
744
|
+
method: "POST",
|
|
745
|
+
headers: { "Content-Type": "application/json" },
|
|
746
|
+
body: JSON.stringify({
|
|
747
|
+
url: normalizedUrl,
|
|
748
|
+
name: member.name,
|
|
749
|
+
description: member.description,
|
|
750
|
+
joinedAtMs: member.joinedAtMs ?? Date.now(),
|
|
751
|
+
}),
|
|
752
|
+
}).catch((err) => {
|
|
753
|
+
this.log("warn", `broadcast to ${other.url} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
// Notify local user that a new member joined
|
|
757
|
+
this.log("debug", `announce: notifying local user about ${member.name}`);
|
|
758
|
+
void this.notifyUser(`📢 **${member.name}** 已加入团队「${team.teamName}」`);
|
|
759
|
+
}
|
|
760
|
+
this.log("debug", `announce: completed for ${member.name}`);
|
|
761
|
+
res.json({ ok: true });
|
|
762
|
+
}
|
|
763
|
+
catch (err) {
|
|
764
|
+
this.log("error", `POST /team/${req.params.id}/announce failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
765
|
+
res.status(500).json({ error: String(err) });
|
|
766
|
+
}
|
|
767
|
+
});
|
|
768
|
+
app.post("/team/:id/leave", async (req, res) => {
|
|
769
|
+
this.log("debug", `POST /team/${req.params.id}/leave from ${req.body?.url}`);
|
|
770
|
+
try {
|
|
771
|
+
const team = await this.teamStore.getTeam(req.params.id);
|
|
772
|
+
if (!team) {
|
|
773
|
+
res.status(404).json({ error: "team not found" });
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
const parsed = leaveBodySchema.safeParse(req.body);
|
|
777
|
+
if (!parsed.success) {
|
|
778
|
+
res.status(400).json({ error: parsed.error.message });
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
const normalizedUrl = parsed.data.url.replace(/\/+$/, "");
|
|
782
|
+
await this.teamStore.removeMember(team.teamId, normalizedUrl);
|
|
783
|
+
await this.agentRegistry.remove(normalizedUrl);
|
|
784
|
+
res.json({ ok: true });
|
|
785
|
+
}
|
|
786
|
+
catch (err) {
|
|
787
|
+
this.log("error", `POST /team/${req.params.id}/leave failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
788
|
+
res.status(500).json({ error: String(err) });
|
|
789
|
+
}
|
|
790
|
+
});
|
|
791
|
+
// Profile update broadcast receiver
|
|
792
|
+
app.post("/team/:id/profile-update", async (req, res) => {
|
|
793
|
+
this.log("debug", `POST /team/${req.params.id}/profile-update from ${req.body?.url}`);
|
|
794
|
+
try {
|
|
795
|
+
const team = await this.teamStore.getTeam(req.params.id);
|
|
796
|
+
if (!team) {
|
|
797
|
+
res.status(404).json({ error: "team not found" });
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
const parsed = profileUpdateBodySchema.safeParse(req.body);
|
|
801
|
+
if (!parsed.success) {
|
|
802
|
+
res.status(400).json({ error: parsed.error.message });
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
const { url, name, description } = parsed.data;
|
|
806
|
+
const normalizedUrl = url.replace(/\/+$/, "");
|
|
807
|
+
// Update team member description
|
|
808
|
+
const existing = team.members.find((m) => m.url.replace(/\/+$/, "") === normalizedUrl);
|
|
809
|
+
if (existing) {
|
|
810
|
+
if (name)
|
|
811
|
+
existing.name = name;
|
|
812
|
+
if (description !== undefined)
|
|
813
|
+
existing.description = description;
|
|
814
|
+
await this.teamStore.saveTeam(team);
|
|
815
|
+
}
|
|
816
|
+
// Update agent registry description
|
|
817
|
+
if (description !== undefined) {
|
|
818
|
+
await this.agentRegistry.updateDescription(normalizedUrl, description);
|
|
819
|
+
}
|
|
820
|
+
res.json({ ok: true });
|
|
821
|
+
}
|
|
822
|
+
catch (err) {
|
|
823
|
+
this.log("error", `POST /team/${req.params.id}/profile-update failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
824
|
+
res.status(500).json({ error: String(err) });
|
|
825
|
+
}
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
/* ---------------------------------------------------------------- */
|
|
829
|
+
/* Private helpers */
|
|
830
|
+
/* ---------------------------------------------------------------- */
|
|
831
|
+
async broadcastProfileToTeams() {
|
|
832
|
+
this.log("debug", "broadcastProfileToTeams");
|
|
833
|
+
try {
|
|
834
|
+
const teams = await this.teamStore.listTeams();
|
|
835
|
+
const selfNormalized = this.selfUrl.replace(/\/+$/, "");
|
|
836
|
+
const agentName = this.getFormattedName();
|
|
837
|
+
for (const team of teams) {
|
|
838
|
+
// Update self in team store
|
|
839
|
+
await this.teamStore.addMember(team.teamId, {
|
|
840
|
+
url: this.selfUrl,
|
|
841
|
+
name: agentName,
|
|
842
|
+
description: this.profileDescription,
|
|
843
|
+
joinedAtMs: Date.now(),
|
|
844
|
+
});
|
|
845
|
+
// Broadcast to other members
|
|
846
|
+
const others = team.members.filter((m) => m.url.replace(/\/+$/, "") !== selfNormalized);
|
|
847
|
+
for (const member of others) {
|
|
848
|
+
void this.fetchWithRetry(`${member.url}/team/${team.teamId}/profile-update`, {
|
|
849
|
+
method: "POST",
|
|
850
|
+
headers: { "Content-Type": "application/json" },
|
|
851
|
+
body: JSON.stringify({
|
|
852
|
+
url: this.selfUrl,
|
|
853
|
+
name: agentName,
|
|
854
|
+
description: this.profileDescription,
|
|
855
|
+
}),
|
|
856
|
+
}).catch(() => {
|
|
857
|
+
this.log("warn", `profile broadcast to ${member.url} failed`);
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
this.log("debug", "broadcastProfileToTeams completed");
|
|
862
|
+
}
|
|
863
|
+
catch (err) {
|
|
864
|
+
this.log("error", `broadcastProfileToTeams failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
865
|
+
throw err;
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
async fetchMemberDescriptions(team) {
|
|
869
|
+
const selfNormalized = this.selfUrl.replace(/\/+$/, "");
|
|
870
|
+
const membersToFetch = team.members.filter((m) => m.url.replace(/\/+$/, "") !== selfNormalized && !m.description);
|
|
871
|
+
this.log("debug", `fetchMemberDescriptions(teamId=${team.teamId}, count=${membersToFetch.length})`);
|
|
872
|
+
try {
|
|
873
|
+
await Promise.allSettled(membersToFetch.map(async (m) => {
|
|
874
|
+
try {
|
|
875
|
+
const client = await this.clientFactory.createFromUrl(m.url);
|
|
876
|
+
const card = await client.getAgentCard();
|
|
877
|
+
if (card.description) {
|
|
878
|
+
m.description = card.description;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
catch {
|
|
882
|
+
this.log("warn", `failed to fetch Agent Card from ${m.url}`);
|
|
883
|
+
}
|
|
884
|
+
}));
|
|
885
|
+
await this.teamStore.saveTeam(team);
|
|
886
|
+
this.log("debug", "fetchMemberDescriptions completed");
|
|
887
|
+
}
|
|
888
|
+
catch (err) {
|
|
889
|
+
this.log("error", `fetchMemberDescriptions failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
890
|
+
throw err;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
async syncTeamToRegistry(team) {
|
|
894
|
+
this.log("debug", `syncTeamToRegistry(teamId=${team.teamId})`);
|
|
895
|
+
try {
|
|
896
|
+
const selfNormalized = this.selfUrl.replace(/\/+$/, "");
|
|
897
|
+
for (const member of team.members) {
|
|
898
|
+
if (member.url.replace(/\/+$/, "") === selfNormalized)
|
|
899
|
+
continue;
|
|
900
|
+
await this.agentRegistry.add({
|
|
901
|
+
url: member.url,
|
|
902
|
+
name: member.name,
|
|
903
|
+
description: member.description,
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
this.log("debug", "syncTeamToRegistry completed");
|
|
907
|
+
}
|
|
908
|
+
catch (err) {
|
|
909
|
+
this.log("error", `syncTeamToRegistry failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
910
|
+
throw err;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
async createA2AClient(agent) {
|
|
914
|
+
this.log("info", `[a2a-client] creating client for ${agent.name} (${agent.url})`);
|
|
915
|
+
try {
|
|
916
|
+
const client = await this.clientFactory.createFromUrl(agent.url);
|
|
917
|
+
this.log("info", `[a2a-client] ✓ client created for ${agent.name} (${agent.url})`);
|
|
918
|
+
return client;
|
|
919
|
+
}
|
|
920
|
+
catch (err) {
|
|
921
|
+
this.log("error", `[a2a-client] ✗ failed to create client for ${agent.name} (${agent.url}): ${err instanceof Error ? err.message : String(err)}`);
|
|
922
|
+
throw err;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
/**
|
|
926
|
+
* Send a message using A2A streaming to minimize latency.
|
|
927
|
+
* Instead of a single blocking HTTP call, consume the SSE stream and
|
|
928
|
+
* return the final Task or Message as soon as B signals completion.
|
|
929
|
+
*/
|
|
930
|
+
processTaskResult(trackId, result) {
|
|
931
|
+
this.log("info", `[process-result] processing result for ${trackId}, resultType=${("status" in result && result.status) ? "Task" : "Message"}`);
|
|
932
|
+
try {
|
|
933
|
+
if ("status" in result && result.status) {
|
|
934
|
+
const task = result;
|
|
935
|
+
const state = task.status?.state ?? "unknown";
|
|
936
|
+
const output = this.extractArtifactText(task);
|
|
937
|
+
this.log("info", `[process-result] ${trackId} Task response — state=${state}, outputLen=${output.length}, preview=${output.slice(0, 120)}`);
|
|
938
|
+
if (state === "completed") {
|
|
939
|
+
this.taskTracker.update(trackId, { status: "completed", result: output });
|
|
940
|
+
this.log("info", `[process-result] ✓ ${trackId} marked completed`);
|
|
941
|
+
}
|
|
942
|
+
else if (state === "failed") {
|
|
943
|
+
this.taskTracker.update(trackId, { status: "failed", error: output || "remote task failed" });
|
|
944
|
+
this.log("warn", `[process-result] ✗ ${trackId} marked failed — error=${output || "remote task failed"}`);
|
|
945
|
+
}
|
|
946
|
+
else {
|
|
947
|
+
// For any other state (unknown, working, etc.), mark as failed to avoid
|
|
948
|
+
// tasks stuck in "running" forever until TTL prune.
|
|
949
|
+
this.taskTracker.update(trackId, { status: "failed", error: `unexpected remote state: ${state}` });
|
|
950
|
+
this.log("warn", `[process-result] ✗ ${trackId} unexpected state=${state}, marked failed`);
|
|
951
|
+
}
|
|
952
|
+
return { taskId: task.id, output, status: state };
|
|
953
|
+
}
|
|
954
|
+
const msg = result;
|
|
955
|
+
const text = msg.parts
|
|
956
|
+
?.filter((p) => p.kind === "text")
|
|
957
|
+
.map((p) => p.text)
|
|
958
|
+
.join("\n") ?? "";
|
|
959
|
+
this.taskTracker.update(trackId, { status: "completed", result: text });
|
|
960
|
+
this.log("info", `[process-result] ✓ ${trackId} Message response — completed, textLen=${text.length}, preview=${text.slice(0, 120)}`);
|
|
961
|
+
return { taskId: trackId, output: text, status: "completed" };
|
|
962
|
+
}
|
|
963
|
+
catch (err) {
|
|
964
|
+
this.log("error", `[process-result] ✗ ${trackId} processing failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
965
|
+
throw err;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
extractArtifactText(task) {
|
|
969
|
+
if (!task.artifacts?.length)
|
|
970
|
+
return "";
|
|
971
|
+
return task.artifacts
|
|
972
|
+
.flatMap((a) => a.parts ?? [])
|
|
973
|
+
.filter((p) => p.kind === "text")
|
|
974
|
+
.map((p) => p.text)
|
|
975
|
+
.join("\n");
|
|
976
|
+
}
|
|
977
|
+
/** Fetch with up to 2 retries and exponential backoff. */
|
|
978
|
+
async fetchWithRetry(url, init, retries = 2) {
|
|
979
|
+
let lastError = null;
|
|
980
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
981
|
+
try {
|
|
982
|
+
const res = await fetch(url, init);
|
|
983
|
+
if (res.ok || attempt === retries)
|
|
984
|
+
return res;
|
|
985
|
+
lastError = new Error(`HTTP ${res.status}`);
|
|
986
|
+
}
|
|
987
|
+
catch (err) {
|
|
988
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
989
|
+
if (attempt === retries)
|
|
990
|
+
break;
|
|
991
|
+
}
|
|
992
|
+
await new Promise((r) => setTimeout(r, 200 * 2 ** attempt));
|
|
993
|
+
}
|
|
994
|
+
throw lastError;
|
|
995
|
+
}
|
|
996
|
+
/**
|
|
997
|
+
* Called by the `multiclaws_task_respond` tool when the local human
|
|
998
|
+
* approves or rejects a pending risky incoming task.
|
|
999
|
+
*/
|
|
1000
|
+
respondToTask(taskId, approved) {
|
|
1001
|
+
this.log("info", `respondToTask(taskId=${taskId}, approved=${approved})`);
|
|
1002
|
+
if (!this.agentExecutor) {
|
|
1003
|
+
this.log("warn", `respondToTask: no agentExecutor available for taskId=${taskId}`);
|
|
1004
|
+
return false;
|
|
1005
|
+
}
|
|
1006
|
+
const resolved = this.agentExecutor.resolveApproval(taskId, approved);
|
|
1007
|
+
this.log("info", `respondToTask: ${resolved ? "✓" : "✗"} taskId=${taskId} ${resolved ? "resolved" : "no pending approval found"}`);
|
|
1008
|
+
return resolved;
|
|
1009
|
+
}
|
|
1010
|
+
/** Resolve a pending A2A callback from sub-agent. */
|
|
1011
|
+
resolveA2ACallback(taskId, result) {
|
|
1012
|
+
this.log("info", `[a2a-callback] resolveA2ACallback(taskId=${taskId}, resultLen=${result.length})`);
|
|
1013
|
+
if (!this.agentExecutor) {
|
|
1014
|
+
this.log("warn", `[a2a-callback] ✗ no agentExecutor available for taskId=${taskId}`);
|
|
1015
|
+
return false;
|
|
1016
|
+
}
|
|
1017
|
+
const resolved = this.agentExecutor.resolveCallback(taskId, result);
|
|
1018
|
+
this.log("info", `[a2a-callback] ${resolved ? "✓" : "✗"} taskId=${taskId} ${resolved ? "resolved" : "no pending callback found"}`);
|
|
1019
|
+
return resolved;
|
|
1020
|
+
}
|
|
1021
|
+
addNotificationTarget(key, target) {
|
|
1022
|
+
if (!this.notificationTargets.has(key)) {
|
|
1023
|
+
this.notificationTargets.set(key, target);
|
|
1024
|
+
this.log("debug", `notification target registered: ${key} (total: ${this.notificationTargets.size})`);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
/** Consistent name for this agent: AgentCard.name or fallback. */
|
|
1028
|
+
getFormattedName() {
|
|
1029
|
+
return this.agentCard?.name ?? "OpenClaw Agent";
|
|
1030
|
+
}
|
|
1031
|
+
/** Discover the most recently active non-internal session via sessions_list. */
|
|
1032
|
+
async discoverActiveSession() {
|
|
1033
|
+
if (!this.gatewayConfig) {
|
|
1034
|
+
this.log("warn", `discoverActiveSession: skipped — no gateway config`);
|
|
1035
|
+
return null;
|
|
1036
|
+
}
|
|
1037
|
+
try {
|
|
1038
|
+
this.log("info", `discoverActiveSession: calling sessions_list (limit=10, activeMinutes=120)`);
|
|
1039
|
+
const raw = await (0, gateway_client_1.invokeGatewayTool)({
|
|
1040
|
+
gateway: this.gatewayConfig,
|
|
1041
|
+
tool: "sessions_list",
|
|
1042
|
+
args: { limit: 10, activeMinutes: 120 },
|
|
1043
|
+
timeoutMs: 5_000,
|
|
1044
|
+
});
|
|
1045
|
+
this.log("info", `discoverActiveSession: raw result = ${JSON.stringify(raw).slice(0, 500)}`);
|
|
1046
|
+
// Unwrap gateway tool standard response: { content: [{ type: "text", text: "..." }] }
|
|
1047
|
+
let parsed = raw;
|
|
1048
|
+
if (raw?.content?.[0]?.type === "text") {
|
|
1049
|
+
try {
|
|
1050
|
+
parsed = JSON.parse(raw.content[0].text);
|
|
1051
|
+
this.log("info", `discoverActiveSession: unwrapped gateway response successfully`);
|
|
1052
|
+
}
|
|
1053
|
+
catch (parseErr) {
|
|
1054
|
+
this.log("warn", `discoverActiveSession: failed to parse content[0].text as JSON — ${parseErr instanceof Error ? parseErr.message : String(parseErr)}, using raw object`);
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
const sessions = parsed?.sessions ?? [];
|
|
1058
|
+
this.log("info", `discoverActiveSession: found ${sessions.length} sessions from gateway`);
|
|
1059
|
+
const INTERNAL_PREFIXES = ["delegate-", "a2a-"];
|
|
1060
|
+
// sessions_list returns "key" not "sessionKey"
|
|
1061
|
+
const session = sessions.find((s) => {
|
|
1062
|
+
const k = (s.key ?? s.sessionKey);
|
|
1063
|
+
return k && !INTERNAL_PREFIXES.some((p) => k.startsWith(p));
|
|
1064
|
+
});
|
|
1065
|
+
const matchedKey = (session?.key ?? session?.sessionKey);
|
|
1066
|
+
if (matchedKey) {
|
|
1067
|
+
this.log("info", `discoverActiveSession: ✓ matched session ${matchedKey}`);
|
|
1068
|
+
}
|
|
1069
|
+
else {
|
|
1070
|
+
this.log("warn", `discoverActiveSession: ✗ all ${sessions.length} sessions filtered or empty`);
|
|
1071
|
+
sessions.forEach((s, i) => this.log("info", `discoverActiveSession: session[${i}]: key=${(s.key ?? s.sessionKey) ?? "(no key)"}`));
|
|
1072
|
+
}
|
|
1073
|
+
return matchedKey ?? null;
|
|
1074
|
+
}
|
|
1075
|
+
catch (err) {
|
|
1076
|
+
this.log("error", `discoverActiveSession: ✗ caught error — ${err instanceof Error ? err.message : String(err)}, returning null`);
|
|
1077
|
+
return null;
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
async notifyUser(message) {
|
|
1081
|
+
this.log("info", `notifyUser: targets=${this.notificationTargets.size}, msgLen=${message.length}, preview="${message.slice(0, 80)}"`);
|
|
1082
|
+
if (!this.gatewayConfig) {
|
|
1083
|
+
this.log("warn", "notifyUser: skipped — no gatewayConfig, message lost");
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
// Fallback: no registered targets yet (e.g. right after gateway restart)
|
|
1087
|
+
if (this.notificationTargets.size === 0) {
|
|
1088
|
+
this.log("info", "notifyUser: no registered targets → falling back to discoverActiveSession()");
|
|
1089
|
+
const sessionKey = await this.discoverActiveSession();
|
|
1090
|
+
if (sessionKey) {
|
|
1091
|
+
this.log("info", `notifyUser: fallback discovered session ${sessionKey} → calling sessions_send`);
|
|
1092
|
+
try {
|
|
1093
|
+
await (0, gateway_client_1.invokeGatewayTool)({
|
|
1094
|
+
gateway: this.gatewayConfig,
|
|
1095
|
+
tool: "sessions_send",
|
|
1096
|
+
args: { sessionKey, message },
|
|
1097
|
+
timeoutMs: 5_000,
|
|
1098
|
+
});
|
|
1099
|
+
this.log("info", `notifyUser: ✓ fallback sessions_send to ${sessionKey} succeeded`);
|
|
1100
|
+
this.addNotificationTarget(`web:${sessionKey}`, { type: "web", sessionKey });
|
|
1101
|
+
this.log("info", `notifyUser: registered ${sessionKey} as notification target for future use`);
|
|
1102
|
+
}
|
|
1103
|
+
catch (err) {
|
|
1104
|
+
this.log("error", `notifyUser: ✗ fallback sessions_send to ${sessionKey} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
else {
|
|
1108
|
+
this.log("warn", "notifyUser: ✗ discoverActiveSession returned null — no active session found, message lost");
|
|
1109
|
+
}
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
const entries = [...this.notificationTargets.entries()];
|
|
1113
|
+
this.log("info", `notifyUser: sending to ${entries.length} registered target(s): [${entries.map(([k]) => k).join(", ")}]`);
|
|
1114
|
+
const results = await Promise.allSettled(entries.map(async ([key, target]) => {
|
|
1115
|
+
this.log("info", `notifyUser: → ${key} (type=${target.type})`);
|
|
1116
|
+
try {
|
|
1117
|
+
await (target.type === "channel"
|
|
1118
|
+
? (0, gateway_client_1.invokeGatewayTool)({
|
|
1119
|
+
gateway: this.gatewayConfig,
|
|
1120
|
+
tool: "message",
|
|
1121
|
+
args: { action: "send", target: target.conversationId, message },
|
|
1122
|
+
timeoutMs: 5_000,
|
|
1123
|
+
})
|
|
1124
|
+
: (0, gateway_client_1.invokeGatewayTool)({
|
|
1125
|
+
gateway: this.gatewayConfig,
|
|
1126
|
+
tool: "sessions_send",
|
|
1127
|
+
args: { sessionKey: target.sessionKey, message },
|
|
1128
|
+
timeoutMs: 5_000,
|
|
1129
|
+
}));
|
|
1130
|
+
this.log("info", `notifyUser: ✓ ${key} (${target.type}) succeeded`);
|
|
1131
|
+
}
|
|
1132
|
+
catch (err) {
|
|
1133
|
+
this.log("error", `notifyUser: ✗ ${key} (${target.type}) failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1134
|
+
throw err;
|
|
1135
|
+
}
|
|
1136
|
+
}));
|
|
1137
|
+
const okCount = results.filter((r) => r.status === "fulfilled").length;
|
|
1138
|
+
const failCount = results.filter((r) => r.status === "rejected").length;
|
|
1139
|
+
if (failCount === 0) {
|
|
1140
|
+
this.log("info", `notifyUser: ✓ all ${okCount} targets succeeded`);
|
|
1141
|
+
}
|
|
1142
|
+
else if (failCount === entries.length) {
|
|
1143
|
+
this.log("error", `notifyUser: ✗ ALL ${failCount} targets failed`);
|
|
1144
|
+
}
|
|
1145
|
+
else {
|
|
1146
|
+
this.log("warn", `notifyUser: ${okCount} ok, ${failCount} FAILED out of ${entries.length} targets`);
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
log(level, message) {
|
|
1150
|
+
this.options.logger?.[level]?.(`[multiclaws] ${message}`);
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
exports.MulticlawsService = MulticlawsService;
|