multiclaws 0.4.30 → 0.4.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -200,6 +200,40 @@ function createTools(getService, logger) {
200
200
  }
201
201
  },
202
202
  };
203
+ const multiclawsA2ACallback = {
204
+ name: "multiclaws_a2a_callback",
205
+ description: "Report the result of an incoming A2A delegated task. " +
206
+ "Called by sub-agents spawned to handle remote tasks. " +
207
+ "Do NOT call this directly.",
208
+ parameters: {
209
+ type: "object",
210
+ additionalProperties: false,
211
+ properties: {
212
+ taskId: { type: "string" },
213
+ result: { type: "string" },
214
+ },
215
+ required: ["taskId", "result"],
216
+ },
217
+ execute: async (_toolCallId, args) => {
218
+ const taskId = typeof args.taskId === "string" ? args.taskId.trim() : "";
219
+ const result = typeof args.result === "string" ? args.result : "";
220
+ log("info", `tool:multiclaws_a2a_callback(taskId=${taskId})`);
221
+ try {
222
+ const service = requireService(getService());
223
+ if (!taskId || !result)
224
+ throw new Error("taskId and result are required");
225
+ const resolved = service.resolveA2ACallback(taskId, result);
226
+ if (!resolved) {
227
+ return textResult(`No pending callback for task ${taskId}. It may have timed out.`);
228
+ }
229
+ return textResult(`Task ${taskId} result reported successfully.`);
230
+ }
231
+ catch (err) {
232
+ log("error", `tool:multiclaws_a2a_callback failed: ${err instanceof Error ? err.message : String(err)}`);
233
+ throw err;
234
+ }
235
+ },
236
+ };
203
237
  const multiclawsTaskStatus = {
204
238
  name: "multiclaws_task_status",
205
239
  description: "Check the status of a delegated task.",
@@ -436,6 +470,7 @@ function createTools(getService, logger) {
436
470
  multiclawsRemoveAgent,
437
471
  multiclawsDelegate,
438
472
  multiclawsDelegateSend,
473
+ multiclawsA2ACallback,
439
474
  multiclawsTaskStatus,
440
475
  multiclawsTeamCreate,
441
476
  multiclawsTeamJoin,
@@ -458,16 +493,34 @@ const plugin = {
458
493
  let service = null;
459
494
  // Ensure required tools are in gateway.tools.allow at registration time
460
495
  // so the gateway starts with them already present (no restart needed).
496
+ //
497
+ // Two categories:
498
+ // 1. Adapter-internal tools: sessions_spawn, sessions_history, message
499
+ // — needed by a2a-adapter itself to spawn/poll/notify.
500
+ // 2. A2A execution tools: exec, read, write, glob, grep
501
+ // — needed by spawned sub-agents to actually perform delegated tasks.
502
+ // Without these the sub-agent session hits "permission denied" because
503
+ // gateway.tools.allow restricts which tools the session can invoke.
504
+ //
505
+ // Users can override the execution tool list via plugin config:
506
+ // plugins.multiclaws.a2aAllowedTools: ["exec", "read", "write", ...]
461
507
  if (api.config) {
462
508
  const gw = api.config.gateway;
463
509
  if (gw) {
464
510
  const tools = (gw.tools ?? {});
465
511
  const allow = Array.isArray(tools.allow) ? tools.allow : [];
466
- const required = ["sessions_spawn", "sessions_history", "message"];
512
+ const adapterRequired = ["sessions_spawn", "sessions_history", "message"];
513
+ const defaultA2AExecutionTools = ["exec", "read", "write", "edit", "process"];
514
+ const pluginConf = api.pluginConfig ?? {};
515
+ const a2aExecTools = Array.isArray(pluginConf.a2aAllowedTools)
516
+ ? pluginConf.a2aAllowedTools
517
+ : defaultA2AExecutionTools;
518
+ const required = [...new Set([...adapterRequired, ...a2aExecTools])];
467
519
  const missing = required.filter((t) => !allow.includes(t));
468
520
  if (missing.length > 0) {
469
521
  tools.allow = [...allow, ...missing];
470
522
  gw.tools = tools;
523
+ structured.logger.info(`auto-added gateway tools: ${missing.join(", ")}`);
471
524
  }
472
525
  }
473
526
  }
@@ -19,7 +19,7 @@ export type A2AAdapterOptions = {
19
19
  * this executor:
20
20
  * 1. Records the task via TaskTracker
21
21
  * 2. Calls OpenClaw's `sessions_spawn` (run mode) to start execution
22
- * 3. Polls `sessions_history` until the subagent completes
22
+ * 3. Waits for the sub-agent to call back via `multiclaws_a2a_callback`
23
23
  * 4. Returns the final result as a Message
24
24
  */
25
25
  export declare class OpenClawAgentExecutor implements AgentExecutor {
@@ -28,25 +28,21 @@ export declare class OpenClawAgentExecutor implements AgentExecutor {
28
28
  private readonly getChannelIds;
29
29
  private readonly logger;
30
30
  private readonly cwd;
31
+ private readonly pendingCallbacks;
31
32
  constructor(options: A2AAdapterOptions);
32
33
  execute(context: RequestContext, eventBus: ExecutionEventBus): Promise<void>;
33
34
  /**
34
- * Poll sessions_history until the subagent session completes.
35
- * Collects ALL assistant text messages and returns them joined.
35
+ * Called by the `multiclaws_a2a_callback` tool when a sub-agent reports its result.
36
+ * Returns true if a pending callback was found and resolved.
36
37
  */
37
- private waitForCompletion;
38
- /**
39
- * Extract all assistant text from session history once the session is complete.
40
- * Returns null if the session is still running.
41
- * Returns all assistant text messages joined (not just the last one).
42
- *
43
- * Gateway /tools/invoke returns: { content: [...], details: { messages: [...], isComplete?: boolean } }
44
- */
45
- private extractCompletedResult;
46
- /** Extract text content from a single history message. */
47
- private extractTextFromHistoryMessage;
38
+ resolveCallback(taskId: string, result: string): boolean;
48
39
  cancelTask(taskId: string, eventBus: ExecutionEventBus): Promise<void>;
49
40
  updateGatewayConfig(config: GatewayConfig): void;
41
+ /**
42
+ * Create a pending callback that resolves when the sub-agent reports back,
43
+ * or rejects on timeout.
44
+ */
45
+ private createCallback;
50
46
  /** Send a notification to all known channels. Individual failures are silently ignored. */
51
47
  private notifyUser;
52
48
  private publishMessage;
@@ -10,24 +10,6 @@ function extractTextFromMessage(message) {
10
10
  .map((p) => p.text)
11
11
  .join("\n");
12
12
  }
13
- function sleep(ms) {
14
- return new Promise((resolve) => setTimeout(resolve, ms));
15
- }
16
- /**
17
- * Extract the details object from a gateway /tools/invoke result.
18
- * The result shape is: { content: [...], details: { ...actual data... } }
19
- */
20
- function extractDetails(result) {
21
- if (!result || typeof result !== "object")
22
- return null;
23
- const r = result;
24
- // Direct details from /tools/invoke
25
- if (r.details && typeof r.details === "object") {
26
- return r.details;
27
- }
28
- // Fallback: result itself might be the details
29
- return r;
30
- }
31
13
  /**
32
14
  * Bridges the A2A protocol to OpenClaw's sessions_spawn gateway tool.
33
15
  *
@@ -35,7 +17,7 @@ function extractDetails(result) {
35
17
  * this executor:
36
18
  * 1. Records the task via TaskTracker
37
19
  * 2. Calls OpenClaw's `sessions_spawn` (run mode) to start execution
38
- * 3. Polls `sessions_history` until the subagent completes
20
+ * 3. Waits for the sub-agent to call back via `multiclaws_a2a_callback`
39
21
  * 4. Returns the final result as a Message
40
22
  */
41
23
  class OpenClawAgentExecutor {
@@ -44,6 +26,7 @@ class OpenClawAgentExecutor {
44
26
  getChannelIds;
45
27
  logger;
46
28
  cwd;
29
+ pendingCallbacks = new Map();
47
30
  constructor(options) {
48
31
  this.gatewayConfig = options.gatewayConfig;
49
32
  this.taskTracker = options.taskTracker;
@@ -76,34 +59,27 @@ class OpenClawAgentExecutor {
76
59
  void this.notifyUser(`📨 收到来自 **${fromAgent}** 的委派任务:${taskText.slice(0, 200)}`);
77
60
  try {
78
61
  this.logger.info(`[a2a-adapter] executing task ${taskId}: ${taskText.slice(0, 100)}`);
79
- // 1. Spawn the subagent
80
- // Use a dedicated session key to avoid inheriting the main session's
81
- // thinking blocks, which would cause "thinking blocks cannot be modified"
82
- // errors from the Claude API.
83
- const spawnResult = await (0, gateway_client_1.invokeGatewayTool)({
62
+ // Create a promise that resolves when sub-agent calls multiclaws_a2a_callback
63
+ const resultPromise = this.createCallback(taskId, 180_000);
64
+ // Spawn the subagent with instructions to call back when done
65
+ const prompt = buildA2ASubagentPrompt(taskId, taskText);
66
+ await (0, gateway_client_1.invokeGatewayTool)({
84
67
  gateway: this.gatewayConfig,
85
68
  tool: "sessions_spawn",
86
69
  args: {
87
- task: taskText,
70
+ task: prompt,
88
71
  mode: "run",
89
72
  cwd: this.cwd,
90
73
  },
91
74
  sessionKey: `a2a-${taskId}`,
92
75
  timeoutMs: 15_000,
93
76
  });
94
- // Extract details from gateway response: { content: [...], details: { childSessionKey, ... } }
95
- const details = extractDetails(spawnResult);
96
- const childSessionKey = details?.childSessionKey;
97
- if (!childSessionKey) {
98
- throw new Error("sessions_spawn did not return a childSessionKey");
99
- }
100
- // 2. Poll for completion
101
- const gatewaySessionKey = `a2a-${taskId}`;
102
- this.logger.info(`[a2a-adapter] task ${taskId} spawned as ${childSessionKey}, waiting for result...`);
103
- const output = await this.waitForCompletion(childSessionKey, 180_000, gatewaySessionKey);
104
- // 3. Return result
77
+ this.logger.info(`[a2a-adapter] task ${taskId} spawned, waiting for callback...`);
78
+ // Wait for the sub-agent to call back
79
+ const output = await resultPromise;
80
+ // Return result
105
81
  this.taskTracker.update(taskId, { status: "completed", result: output });
106
- this.logger.info(`[a2a-adapter] task ${taskId} completed`);
82
+ this.logger.info(`[a2a-adapter] task ${taskId} completed, resultLen=${output.length}`);
107
83
  this.publishMessage(eventBus, output || "Task completed with no output.");
108
84
  }
109
85
  catch (err) {
@@ -115,146 +91,27 @@ class OpenClawAgentExecutor {
115
91
  eventBus.finished();
116
92
  }
117
93
  /**
118
- * Poll sessions_history until the subagent session completes.
119
- * Collects ALL assistant text messages and returns them joined.
94
+ * Called by the `multiclaws_a2a_callback` tool when a sub-agent reports its result.
95
+ * Returns true if a pending callback was found and resolved.
120
96
  */
121
- async waitForCompletion(sessionKey, timeoutMs, gatewaySessionKey) {
122
- this.logger.info(`[a2a-adapter] waitForCompletion(sessionKey=${sessionKey}, timeoutMs=${timeoutMs})`);
123
- const gateway = this.gatewayConfig;
124
- const startTime = Date.now();
125
- let attempt = 0;
126
- const pollDelays = [100, 200, 300, 500];
127
- while (Date.now() - startTime < timeoutMs) {
128
- const delay = pollDelays[Math.min(attempt, pollDelays.length - 1)];
129
- await sleep(delay);
130
- attempt++;
131
- try {
132
- const histResult = await (0, gateway_client_1.invokeGatewayTool)({
133
- gateway,
134
- tool: "sessions_history",
135
- args: {
136
- sessionKey,
137
- limit: 50,
138
- includeTools: false,
139
- },
140
- sessionKey: gatewaySessionKey,
141
- timeoutMs: 8_000,
142
- });
143
- const result = this.extractCompletedResult(histResult);
144
- if (result !== null) {
145
- this.logger.info(`[a2a-adapter] poll attempt ${attempt}: session ${sessionKey} completed, resultLen=${result.length}`);
146
- return result;
147
- }
148
- // Log details on first attempt, then every 10 attempts for diagnosis
149
- if (attempt === 1 || attempt % 10 === 0) {
150
- const details = extractDetails(histResult);
151
- const messages = (details?.messages ?? []);
152
- const lastMsg = messages[messages.length - 1];
153
- this.logger.info(`[a2a-adapter] poll attempt ${attempt}: session ${sessionKey} still running. ` +
154
- `isComplete=${details?.isComplete}, status=${details?.status}, ` +
155
- `msgCount=${messages.length}, lastRole=${lastMsg?.role}, ` +
156
- `lastContentTypes=${JSON.stringify(Array.isArray(lastMsg?.content)
157
- ? lastMsg.content.map((c) => c?.type)
158
- : typeof lastMsg?.content)}`);
159
- }
160
- else {
161
- this.logger.info(`[a2a-adapter] poll attempt ${attempt}: session ${sessionKey} still running...`);
162
- }
163
- }
164
- catch (err) {
165
- this.logger.warn(`[a2a-adapter] poll attempt ${attempt} error: ${err instanceof Error ? err.message : err}`);
166
- }
167
- }
168
- const elapsed = Math.round((Date.now() - startTime) / 1000);
169
- this.logger.error(`[a2a-adapter] waitForCompletion timed out: session=${sessionKey}, elapsed=${elapsed}s, attempts=${attempt}`);
170
- throw new Error(`task timed out after ${elapsed}s (${attempt} attempts) waiting for subagent`);
171
- }
172
- /**
173
- * Extract all assistant text from session history once the session is complete.
174
- * Returns null if the session is still running.
175
- * Returns all assistant text messages joined (not just the last one).
176
- *
177
- * Gateway /tools/invoke returns: { content: [...], details: { messages: [...], isComplete?: boolean } }
178
- */
179
- extractCompletedResult(histResult) {
180
- const details = extractDetails(histResult);
181
- if (!details)
182
- return null;
183
- // Check for session-level error/status from gateway
184
- const sessionError = details.error;
185
- const sessionStatus = details.status;
186
- // Immediately fail on terminal session statuses — do NOT keep polling
187
- const terminalStatuses = ["forbidden", "failed", "error", "not_found", "unauthorized"];
188
- if (sessionStatus && terminalStatuses.includes(sessionStatus)) {
189
- this.logger.warn(`[a2a-adapter] extractCompletedResult: terminal status="${sessionStatus}", error="${sessionError ?? "none"}"`);
190
- return `Error: session status "${sessionStatus}"${sessionError ? `: ${sessionError}` : ""}`;
191
- }
192
- const messages = (details.messages ?? []);
193
- if (messages.length === 0 && !details.isComplete)
194
- return null;
195
- // If session is not explicitly complete, use heuristic: check if the session is still executing
196
- if (details.isComplete !== true) {
197
- if (messages.length === 0)
198
- return null;
199
- const lastMsg = messages[messages.length - 1];
200
- if (lastMsg && Array.isArray(lastMsg.content)) {
201
- const content = lastMsg.content;
202
- const hasToolCalls = content.some((c) => c?.type === "toolCall" || c?.type === "tool_use");
203
- // If the last message only has tool calls (no text), still running
204
- const hasText = content.some((c) => c?.type === "text" && typeof c.text === "string" && c.text.trim());
205
- if (hasToolCalls && !hasText)
206
- return null;
207
- }
208
- // If the last message is a user message, the agent hasn't responded yet
209
- if (lastMsg?.role === "user")
210
- return null;
211
- }
212
- // Session is complete — collect ALL assistant text messages in order
213
- const allTexts = [];
214
- for (const msg of messages) {
215
- if (msg.role !== "assistant")
216
- continue;
217
- const text = this.extractTextFromHistoryMessage(msg);
218
- if (text)
219
- allTexts.push(text);
220
- }
221
- // If we have assistant text, return it (even if there's also an error)
222
- if (allTexts.length > 0) {
223
- // Append error info if present so the delegating agent sees both
224
- if (sessionError) {
225
- allTexts.push(`[session error: ${sessionError}]`);
226
- }
227
- return allTexts.join("\n\n");
228
- }
229
- // No assistant text — check if the session reported an error
230
- if (sessionError) {
231
- return `Error: ${sessionError}`;
232
- }
233
- if (sessionStatus === "failed" || sessionStatus === "error") {
234
- return `Error: session ended with status "${sessionStatus}"`;
235
- }
236
- // Session truly completed with no output at all
237
- return "(task completed with no text output)";
238
- }
239
- /** Extract text content from a single history message. */
240
- extractTextFromHistoryMessage(msg) {
241
- const content = msg.content;
242
- if (typeof content === "string" && content.trim()) {
243
- return content;
244
- }
245
- if (Array.isArray(content)) {
246
- const parts = content;
247
- const textParts = parts
248
- .filter((c) => c?.type === "text" && typeof c.text === "string" && c.text.trim())
249
- .map((c) => c.text);
250
- if (textParts.length > 0) {
251
- return textParts.join("\n");
252
- }
253
- }
254
- return null;
97
+ resolveCallback(taskId, result) {
98
+ const pending = this.pendingCallbacks.get(taskId);
99
+ if (!pending)
100
+ return false;
101
+ clearTimeout(pending.timer);
102
+ this.pendingCallbacks.delete(taskId);
103
+ pending.resolve(result);
104
+ return true;
255
105
  }
256
106
  async cancelTask(taskId, eventBus) {
257
107
  this.logger.info(`[a2a-adapter] cancelTask(taskId=${taskId})`);
108
+ // Reject pending callback if any
109
+ const pending = this.pendingCallbacks.get(taskId);
110
+ if (pending) {
111
+ clearTimeout(pending.timer);
112
+ this.pendingCallbacks.delete(taskId);
113
+ pending.reject(new Error("canceled"));
114
+ }
258
115
  this.taskTracker.update(taskId, { status: "failed", error: "canceled" });
259
116
  this.publishMessage(eventBus, "Task was canceled.");
260
117
  eventBus.finished();
@@ -262,6 +119,19 @@ class OpenClawAgentExecutor {
262
119
  updateGatewayConfig(config) {
263
120
  this.gatewayConfig = config;
264
121
  }
122
+ /**
123
+ * Create a pending callback that resolves when the sub-agent reports back,
124
+ * or rejects on timeout.
125
+ */
126
+ createCallback(taskId, timeoutMs) {
127
+ return new Promise((resolve, reject) => {
128
+ const timer = setTimeout(() => {
129
+ this.pendingCallbacks.delete(taskId);
130
+ reject(new Error(`task timed out after ${timeoutMs / 1000}s waiting for sub-agent callback`));
131
+ }, timeoutMs);
132
+ this.pendingCallbacks.set(taskId, { resolve, reject, timer });
133
+ });
134
+ }
265
135
  /** Send a notification to all known channels. Individual failures are silently ignored. */
266
136
  async notifyUser(message) {
267
137
  const channels = this.getChannelIds();
@@ -285,3 +155,28 @@ class OpenClawAgentExecutor {
285
155
  }
286
156
  }
287
157
  exports.OpenClawAgentExecutor = OpenClawAgentExecutor;
158
+ /**
159
+ * Build the prompt for the sub-agent that handles an incoming A2A task.
160
+ * The sub-agent must call `multiclaws_a2a_callback` to report its result.
161
+ */
162
+ function buildA2ASubagentPrompt(taskId, taskText) {
163
+ return `你收到了一个来自远端智能体的委派任务。请完成任务并汇报结果。
164
+
165
+ ## 任务内容
166
+
167
+ ${taskText}
168
+
169
+ ## 完成后必做
170
+
171
+ 完成任务后,你**必须**调用 \`multiclaws_a2a_callback\` 工具汇报结果:
172
+
173
+ \`\`\`
174
+ multiclaws_a2a_callback(taskId="${taskId}", result="你的完整回复内容")
175
+ \`\`\`
176
+
177
+ **重要**:
178
+ - 无论任务成功还是失败,都必须调用 \`multiclaws_a2a_callback\`
179
+ - result 参数填写你的完整回复文本
180
+ - 如果任务失败,在 result 中说明失败原因
181
+ - 这是唯一的结果回传方式,不调用则结果会丢失`;
182
+ }
@@ -127,6 +127,7 @@ export declare class MulticlawsService extends EventEmitter {
127
127
  /** Fetch with up to 2 retries and exponential backoff. */
128
128
  private fetchWithRetry;
129
129
  /** Register a channel ID for notifications. */
130
+ resolveA2ACallback(taskId: string, result: string): boolean;
130
131
  addChannelId(channelId: string): void;
131
132
  /** Send a notification to all known channels. Individual failures are silently ignored. */
132
133
  private notifyUser;
@@ -920,6 +920,11 @@ class MulticlawsService extends node_events_1.EventEmitter {
920
920
  throw lastError;
921
921
  }
922
922
  /** Register a channel ID for notifications. */
923
+ resolveA2ACallback(taskId, result) {
924
+ if (!this.agentExecutor)
925
+ return false;
926
+ return this.agentExecutor.resolveCallback(taskId, result);
927
+ }
923
928
  addChannelId(channelId) {
924
929
  if (!this.knownChannelIds.has(channelId)) {
925
930
  this.knownChannelIds.add(channelId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "multiclaws",
3
- "version": "0.4.30",
3
+ "version": "0.4.31",
4
4
  "description": "MultiClaws plugin for OpenClaw collaboration via A2A protocol",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -15,6 +15,12 @@
15
15
  "openclaw.plugin.json",
16
16
  "README.md"
17
17
  ],
18
+ "scripts": {
19
+ "build": "tsc -p tsconfig.json",
20
+ "test": "vitest run",
21
+ "test:watch": "vitest",
22
+ "clean": "rm -rf dist"
23
+ },
18
24
  "keywords": [
19
25
  "openclaw",
20
26
  "plugin",
@@ -46,11 +52,5 @@
46
52
  "@types/proper-lockfile": "^4.1.4",
47
53
  "typescript": "^5.9.2",
48
54
  "vitest": "^3.2.4"
49
- },
50
- "scripts": {
51
- "build": "tsc -p tsconfig.json",
52
- "test": "vitest run",
53
- "test:watch": "vitest",
54
- "clean": "rm -rf dist"
55
55
  }
56
- }
56
+ }