@alfe.ai/openclaw-chat 0.0.19 → 0.0.20

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/plugin.d.cts CHANGED
@@ -225,6 +225,13 @@ interface PluginApi {
225
225
  config?: Record<string, unknown>;
226
226
  runtime?: PluginRuntime;
227
227
  registerChannel(channel: ReturnType<typeof createAlfeChannelPlugin>): void;
228
+ registerTool?(tool: {
229
+ name: string;
230
+ description: string;
231
+ label: string;
232
+ parameters: Record<string, unknown>;
233
+ execute: (toolCallId: string, params: Record<string, unknown>) => Promise<unknown>;
234
+ }): void;
228
235
  registerGatewayMethod?(name: string, handler: (...args: unknown[]) => Promise<unknown>): void;
229
236
  on(event: string, handler: (...args: unknown[]) => void | Promise<void>, options?: {
230
237
  priority?: number;
package/dist/plugin.d.ts CHANGED
@@ -225,6 +225,13 @@ interface PluginApi {
225
225
  config?: Record<string, unknown>;
226
226
  runtime?: PluginRuntime;
227
227
  registerChannel(channel: ReturnType<typeof createAlfeChannelPlugin>): void;
228
+ registerTool?(tool: {
229
+ name: string;
230
+ description: string;
231
+ label: string;
232
+ parameters: Record<string, unknown>;
233
+ execute: (toolCallId: string, params: Record<string, unknown>) => Promise<unknown>;
234
+ }): void;
228
235
  registerGatewayMethod?(name: string, handler: (...args: unknown[]) => Promise<unknown>): void;
229
236
  on(event: string, handler: (...args: unknown[]) => void | Promise<void>, options?: {
230
237
  priority?: number;
package/dist/plugin2.cjs CHANGED
@@ -151,7 +151,7 @@ function isAlfeSessionKey(key) {
151
151
  }
152
152
  /**
153
153
  * Extract the channel mode from a standardized session key or conversationId.
154
- * Returns the mode segment (e.g. 'sms', 'whatsapp', 'chat') or fallback.
154
+ * Returns the mode segment (e.g. 'sms', 'whatsapp', 'chat', 'a2a') or fallback.
155
155
  */
156
156
  function extractChannelMode(conversationId, fallback = "chat") {
157
157
  return /^alfe:(\w+):/.exec(conversationId)?.[1] ?? fallback;
@@ -181,7 +181,6 @@ function sessionPath(sessionId) {
181
181
  }
182
182
  async function cleanupOldSessions() {
183
183
  if (Date.now() - lastCleanupAt < CLEANUP_INTERVAL_MS) return;
184
- lastCleanupAt = Date.now();
185
184
  try {
186
185
  const jsonFiles = (await (0, node_fs_promises.readdir)(SESSIONS_DIR)).filter((f) => f.endsWith(".json"));
187
186
  if (jsonFiles.length <= MAX_SESSIONS) {
@@ -211,6 +210,7 @@ async function cleanupOldSessions() {
211
210
  remaining--;
212
211
  } catch {}
213
212
  }
213
+ lastCleanupAt = Date.now();
214
214
  } catch {}
215
215
  }
216
216
  async function getSession(sessionId) {
@@ -242,17 +242,24 @@ async function createSession(sessionId, agentId, channel, tenantId, userId) {
242
242
  cleanupOldSessions();
243
243
  return session;
244
244
  }
245
+ const writeLocks = /* @__PURE__ */ new Map();
245
246
  async function addMessage(sessionId, role, content, senderId, senderName) {
246
- const session = await getSession(sessionId);
247
- if (!session) return;
248
- session.messages.push({
249
- role,
250
- content,
251
- timestamp: Date.now(),
252
- ...senderId ? { senderId } : {},
253
- ...senderName ? { senderName } : {}
247
+ const next = (writeLocks.get(sessionId) ?? Promise.resolve()).then(async () => {
248
+ const session = await getSession(sessionId);
249
+ if (!session) return;
250
+ session.messages.push({
251
+ role,
252
+ content,
253
+ timestamp: Date.now(),
254
+ ...senderId ? { senderId } : {},
255
+ ...senderName ? { senderName } : {}
256
+ });
257
+ await saveSession(session);
258
+ }).finally(() => {
259
+ if (writeLocks.get(sessionId) === next) writeLocks.delete(sessionId);
254
260
  });
255
- await saveSession(session);
261
+ writeLocks.set(sessionId, next);
262
+ await next;
256
263
  }
257
264
  async function listSessions(filters, limit = 50) {
258
265
  await ensureDir();
@@ -289,6 +296,112 @@ async function listSessions(filters, limit = 50) {
289
296
  return summaries.slice(0, limit);
290
297
  }
291
298
  //#endregion
299
+ //#region src/a2a-tools.ts
300
+ function ok(result) {
301
+ return { content: [{
302
+ type: "text",
303
+ text: JSON.stringify(result)
304
+ }] };
305
+ }
306
+ function errResult(message) {
307
+ return { content: [{
308
+ type: "text",
309
+ text: JSON.stringify({ error: message })
310
+ }] };
311
+ }
312
+ function defineTool(def) {
313
+ return {
314
+ name: def.name,
315
+ description: def.description,
316
+ label: def.name,
317
+ parameters: def.parameters,
318
+ execute: async (_toolCallId, params) => {
319
+ try {
320
+ return ok(await def.handler(params));
321
+ } catch (e) {
322
+ return errResult(e.message);
323
+ }
324
+ }
325
+ };
326
+ }
327
+ function buildA2ATools(chatClient, log) {
328
+ return [
329
+ defineTool({
330
+ name: "list_agents",
331
+ description: "List other agents in your organization that you can communicate with.",
332
+ parameters: {
333
+ type: "object",
334
+ properties: {},
335
+ required: []
336
+ },
337
+ handler: async () => {
338
+ log.info("list_agents tool called");
339
+ return await chatClient.sendRequest("a2a.list-agents", {});
340
+ }
341
+ }),
342
+ defineTool({
343
+ name: "message_agent",
344
+ description: "Send a message to another agent. Starts a new conversation thread, or continues an existing one. The conversation will bounce back and forth automatically until one agent calls end_conversation() or says [RESOLVED].",
345
+ parameters: {
346
+ type: "object",
347
+ properties: {
348
+ agent_id: {
349
+ type: "string",
350
+ description: "Target agent ID (use list_agents to find available agents)"
351
+ },
352
+ message: {
353
+ type: "string",
354
+ description: "Message to send to the agent"
355
+ },
356
+ thread_id: {
357
+ type: "string",
358
+ description: "Continue an existing conversation thread (omit to start a new one)"
359
+ },
360
+ max_depth: {
361
+ type: "number",
362
+ description: "Maximum number of back-and-forth exchanges (default: 10)"
363
+ }
364
+ },
365
+ required: ["agent_id", "message"]
366
+ },
367
+ handler: async (params) => {
368
+ const agentId = params.agent_id;
369
+ const message = params.message;
370
+ const threadId = params.thread_id;
371
+ const maxDepth = params.max_depth ?? 10;
372
+ if (!agentId || !message) throw new Error("agent_id and message are required");
373
+ log.info(`message_agent tool: sending to ${agentId}`);
374
+ return await chatClient.sendRequest("a2a.send", {
375
+ targetAgentId: agentId,
376
+ message,
377
+ threadId,
378
+ maxDepth
379
+ });
380
+ }
381
+ }),
382
+ defineTool({
383
+ name: "end_conversation",
384
+ description: "End the current agent-to-agent conversation. Call this when the discussion is resolved and no further exchanges are needed.",
385
+ parameters: {
386
+ type: "object",
387
+ properties: { summary: {
388
+ type: "string",
389
+ description: "Brief summary of what was discussed or resolved"
390
+ } },
391
+ required: []
392
+ },
393
+ handler: (params) => {
394
+ const summary = params.summary;
395
+ log.info(`end_conversation tool called${summary ? `: ${summary}` : ""}`);
396
+ return Promise.resolve({
397
+ status: "conversation_ended",
398
+ summary
399
+ });
400
+ }
401
+ })
402
+ ];
403
+ }
404
+ //#endregion
292
405
  //#region src/plugin.ts
293
406
  /**
294
407
  * @alfe.ai/openclaw-chat — OpenClaw chat channel plugin.
@@ -325,20 +438,30 @@ let pluginRuntime = null;
325
438
  let chatClient = null;
326
439
  let connectingPromise = null;
327
440
  let metricsClient = null;
441
+ const MAX_FILE_SIZE = 50 * 1024 * 1024;
442
+ const DOWNLOAD_TIMEOUT_MS = 3e4;
328
443
  async function downloadAttachments(attachments, log) {
329
444
  const attachDir = (0, node_path.join)((0, node_os.homedir)(), ".alfe", "attachments");
330
445
  await (0, node_fs_promises.mkdir)(attachDir, { recursive: true });
331
446
  const results = [];
332
447
  for (const att of attachments) {
333
- const filename = att.filename ?? att.id;
448
+ const filename = (att.filename ?? att.id).replace(/[/\\]/g, "_").replace(/\.\./g, "_").replace(/\0/g, "");
334
449
  const localPath = (0, node_path.join)(attachDir, `${att.id}_${filename}`);
450
+ const controller = new AbortController();
451
+ const timeout = setTimeout(() => {
452
+ controller.abort();
453
+ }, DOWNLOAD_TIMEOUT_MS);
335
454
  try {
336
- const res = await fetch(att.url);
455
+ const res = await fetch(att.url, { signal: controller.signal });
337
456
  if (!res.ok) {
338
457
  log.warn(`Failed to download attachment ${att.id}: ${String(res.status)}`);
339
458
  continue;
340
459
  }
341
460
  const buffer = Buffer.from(await res.arrayBuffer());
461
+ if (buffer.length > MAX_FILE_SIZE) {
462
+ log.warn(`Attachment ${att.id} exceeds max size (${String(buffer.length)} bytes) — skipping`);
463
+ continue;
464
+ }
342
465
  await (0, node_fs_promises.writeFile)(localPath, buffer);
343
466
  results.push({
344
467
  localPath,
@@ -348,6 +471,8 @@ async function downloadAttachments(attachments, log) {
348
471
  log.info(`Downloaded attachment: ${localPath} (${String(buffer.length)} bytes)`);
349
472
  } catch (err) {
350
473
  log.error(`Failed to download attachment ${att.id}: ${err instanceof Error ? err.message : String(err)}`);
474
+ } finally {
475
+ clearTimeout(timeout);
351
476
  }
352
477
  }
353
478
  return results;
@@ -362,7 +487,8 @@ async function handleAgentRequest(request, log) {
362
487
  chatClient?.sendResponse(request.id, false, { message: "OpenClaw SDK not available — cannot dispatch" });
363
488
  return;
364
489
  }
365
- const { message, sessionKey: legacySessionKey, userId, conversationId, conversationType, tenantId, clientType, displayName, attachments: rawAttachments } = request.params;
490
+ const { message, sessionKey: legacySessionKey, userId, conversationId, conversationType, tenantId, clientType, displayName, attachments: rawAttachments, a2a } = request.params;
491
+ const isA2A = !!a2a;
366
492
  if (!message && !rawAttachments?.length) {
367
493
  chatClient?.sendResponse(request.id, false, { message: "Missing message" });
368
494
  return;
@@ -389,11 +515,13 @@ async function handleAgentRequest(request, log) {
389
515
  try {
390
516
  const downloadedFiles = rawAttachments?.length ? await downloadAttachments(rawAttachments, log) : [];
391
517
  const bodyForAgent = downloadedFiles.length ? `${message || ""}\n\n[Attached files:\n${downloadedFiles.map((f) => `- ${f.filename}: ${f.localPath}`).join("\n")}]` : void 0;
392
- const channelMode = extractChannelMode(conversationId ?? "", clientType ?? "chat");
393
- const channelLabel = channelMode === "sms" ? "SMS" : channelMode === "whatsapp" ? "WhatsApp" : "Alfe";
518
+ const channelMode = isA2A ? "a2a" : extractChannelMode(conversationId ?? "", clientType ?? "chat");
519
+ const channelLabel = channelMode === "sms" ? "SMS" : channelMode === "whatsapp" ? "WhatsApp" : channelMode === "a2a" ? "Agent" : "Alfe";
394
520
  const shortConvId = conversationId?.slice(-8) ?? "";
395
521
  const userLabel = displayName ?? userId ?? senderId;
396
522
  const conversationLabel = conversationType === "group" ? shortConvId ? `[${channelLabel}] Group (${shortConvId})` : `[${channelLabel}] Group` : shortConvId ? `[${channelLabel}] ${userLabel} (${shortConvId})` : `[${channelLabel}] ${userLabel}`;
523
+ let a2aResponseBuffer = "";
524
+ let a2aResolved = false;
397
525
  resolvedOpenClawKey = (await dispatchInbound({
398
526
  cfg,
399
527
  runtime: { channel: runtime.channel },
@@ -420,12 +548,29 @@ async function handleAgentRequest(request, log) {
420
548
  ...clientType ? { ClientType: clientType } : {},
421
549
  ...conversationId ? { ConversationId: conversationId } : {},
422
550
  ...displayName ? { SenderName: displayName } : {},
423
- ChannelMode: channelMode
551
+ ChannelMode: channelMode,
552
+ ...isA2A ? {
553
+ CallerType: "agent",
554
+ CallerAgentId: a2a.sourceAgentId,
555
+ CallerAgentName: a2a.sourceAgentName,
556
+ InteractionDepth: String(a2a.depth),
557
+ A2ASystemPrompt: [
558
+ `This is an agent-to-agent conversation with ${a2a.sourceAgentName}.`,
559
+ "Rules:",
560
+ "- Only respond if you have new information, a question, or an action to coordinate.",
561
+ "- If the conversation is complete, call the end_conversation() tool OR end your message with [RESOLVED].",
562
+ "- Do NOT respond just to acknowledge — that creates infinite loops."
563
+ ].join("\n")
564
+ } : {}
424
565
  },
425
566
  deliver: async (payload) => {
426
567
  const responseText = payload.text ?? "";
427
568
  const mediaUrls = [...payload.mediaUrl ? [payload.mediaUrl] : [], ...payload.mediaUrls ?? []];
428
569
  await addMessage(sessionId, "assistant", responseText);
570
+ if (isA2A) {
571
+ a2aResponseBuffer += responseText;
572
+ if (responseText.includes("\"status\":\"conversation_ended\"") || responseText.includes("\"status\": \"conversation_ended\"")) a2aResolved = true;
573
+ }
429
574
  chatClient?.notify("agent-message", {
430
575
  conversationId: conversationId ?? legacySessionKey,
431
576
  text: responseText,
@@ -437,7 +582,7 @@ async function handleAgentRequest(request, log) {
437
582
  channel: "alfe",
438
583
  role: "assistant"
439
584
  }).catch((err) => {
440
- log.debug(`Metrics report failed: ${err instanceof Error ? err.message : String(err)}`);
585
+ log.warn(`Metrics report failed: ${err instanceof Error ? err.message : String(err)}`);
441
586
  });
442
587
  },
443
588
  onRecordError: (err) => {
@@ -447,6 +592,12 @@ async function handleAgentRequest(request, log) {
447
592
  log.error(`Dispatch error (${info.kind}): ${err instanceof Error ? err.message : String(err)}`);
448
593
  }
449
594
  })).route.sessionKey;
595
+ if (isA2A && a2aResponseBuffer) chatClient?.notify("a2a-complete", {
596
+ conversationId: conversationId ?? legacySessionKey,
597
+ fullText: a2aResponseBuffer,
598
+ a2a,
599
+ resolved: a2aResolved
600
+ });
450
601
  chatClient?.sendResponse(request.id, true, { sessionKey: resolvedOpenClawKey });
451
602
  log.info(`Agent dispatch complete: sessionKey=${resolvedOpenClawKey}`);
452
603
  } catch (err) {
@@ -516,10 +667,15 @@ const plugin = {
516
667
  activate(api) {
517
668
  const log = api.logger;
518
669
  const alreadyActivated = globalThis.__alfeChatPluginActivated === true;
519
- globalThis.__alfeChatPluginActivated = true;
670
+ const isGatewayMode = !!api.runtime;
520
671
  const alfeChannel = createAlfeChannelPlugin();
521
672
  api.registerChannel(alfeChannel);
522
673
  log.info(`Registered channel: ${alfeChannel.id}`);
674
+ if (!isGatewayMode) {
675
+ log.debug("Management command context — skipping persistent resource init");
676
+ return;
677
+ }
678
+ globalThis.__alfeChatPluginActivated = true;
523
679
  if (!alreadyActivated) {
524
680
  log.info("Chat plugin registering...");
525
681
  resolveOpenClawSdk(log);
@@ -547,10 +703,17 @@ const plugin = {
547
703
  wsUrl: chatWsUrl,
548
704
  apiKey,
549
705
  onRequest: (request) => {
550
- if (request.method === "agent") handleAgentRequest(request, log);
551
- else if (request.method === "sessions.list") handleSessionsList(request, log);
552
- else if (request.method === "sessions.get") handleSessionsGet(request, log);
553
- else chatClient?.sendResponse(request.id, false, { message: `Unknown method: ${request.method}` });
706
+ const handle = async () => {
707
+ if (request.method === "agent") await handleAgentRequest(request, log);
708
+ else if (request.method === "sessions.list") await handleSessionsList(request, log);
709
+ else if (request.method === "sessions.get") await handleSessionsGet(request, log);
710
+ else chatClient?.sendResponse(request.id, false, { message: `Unknown method: ${request.method}` });
711
+ };
712
+ handle().catch((err) => {
713
+ const errMsg = err instanceof Error ? err.message : String(err);
714
+ log.error(`Request handler crashed: ${errMsg}`);
715
+ chatClient?.sendResponse(request.id, false, { message: "Internal error" });
716
+ });
554
717
  },
555
718
  onConnectionChange: (connected) => {
556
719
  log.info(`Chat service connection: ${connected ? "connected" : "disconnected"}`);
@@ -599,20 +762,30 @@ const plugin = {
599
762
  });
600
763
  log.info("Registered gateway RPC methods: sessions.list, sessions.get");
601
764
  }
765
+ if (!alreadyActivated && typeof api.registerTool === "function") connectingPromise?.then(() => {
766
+ if (chatClient && typeof api.registerTool === "function") {
767
+ const a2aTools = buildA2ATools(chatClient, log);
768
+ for (const tool of a2aTools) api.registerTool(tool);
769
+ log.info(`Registered ${String(a2aTools.length)} agent-to-agent tools`);
770
+ }
771
+ }).catch(() => {});
602
772
  if (!alreadyActivated) {
603
773
  api.on("session_start", async (...eventArgs) => {
774
+ if (!pluginRuntime) return;
604
775
  const key = eventArgs[0].sessionKey;
605
776
  if (!key || isAlfeSessionKey(key)) return;
606
777
  log.info(`Chat session starting: ${key}`);
607
778
  await createSession(key, "", "alfe");
608
779
  }, { priority: 50 });
609
780
  api.on("message_received", async (...eventArgs) => {
781
+ if (!pluginRuntime) return;
610
782
  const event = eventArgs[0];
611
783
  const ctx = eventArgs[1];
612
784
  if (!ctx.conversationId || ctx.channelId === "alfe") return;
613
785
  await addMessage(ctx.conversationId, "user", event.content, event.from);
614
786
  });
615
787
  api.on("session_end", (...eventArgs) => {
788
+ if (!pluginRuntime) return;
616
789
  const key = eventArgs[0].sessionKey;
617
790
  if (!key || !isAlfeSessionKey(key)) return;
618
791
  log.info(`Chat session ending: ${key}`);
package/dist/plugin2.js CHANGED
@@ -151,7 +151,7 @@ function isAlfeSessionKey(key) {
151
151
  }
152
152
  /**
153
153
  * Extract the channel mode from a standardized session key or conversationId.
154
- * Returns the mode segment (e.g. 'sms', 'whatsapp', 'chat') or fallback.
154
+ * Returns the mode segment (e.g. 'sms', 'whatsapp', 'chat', 'a2a') or fallback.
155
155
  */
156
156
  function extractChannelMode(conversationId, fallback = "chat") {
157
157
  return /^alfe:(\w+):/.exec(conversationId)?.[1] ?? fallback;
@@ -181,7 +181,6 @@ function sessionPath(sessionId) {
181
181
  }
182
182
  async function cleanupOldSessions() {
183
183
  if (Date.now() - lastCleanupAt < CLEANUP_INTERVAL_MS) return;
184
- lastCleanupAt = Date.now();
185
184
  try {
186
185
  const jsonFiles = (await readdir(SESSIONS_DIR)).filter((f) => f.endsWith(".json"));
187
186
  if (jsonFiles.length <= MAX_SESSIONS) {
@@ -211,6 +210,7 @@ async function cleanupOldSessions() {
211
210
  remaining--;
212
211
  } catch {}
213
212
  }
213
+ lastCleanupAt = Date.now();
214
214
  } catch {}
215
215
  }
216
216
  async function getSession(sessionId) {
@@ -242,17 +242,24 @@ async function createSession(sessionId, agentId, channel, tenantId, userId) {
242
242
  cleanupOldSessions();
243
243
  return session;
244
244
  }
245
+ const writeLocks = /* @__PURE__ */ new Map();
245
246
  async function addMessage(sessionId, role, content, senderId, senderName) {
246
- const session = await getSession(sessionId);
247
- if (!session) return;
248
- session.messages.push({
249
- role,
250
- content,
251
- timestamp: Date.now(),
252
- ...senderId ? { senderId } : {},
253
- ...senderName ? { senderName } : {}
247
+ const next = (writeLocks.get(sessionId) ?? Promise.resolve()).then(async () => {
248
+ const session = await getSession(sessionId);
249
+ if (!session) return;
250
+ session.messages.push({
251
+ role,
252
+ content,
253
+ timestamp: Date.now(),
254
+ ...senderId ? { senderId } : {},
255
+ ...senderName ? { senderName } : {}
256
+ });
257
+ await saveSession(session);
258
+ }).finally(() => {
259
+ if (writeLocks.get(sessionId) === next) writeLocks.delete(sessionId);
254
260
  });
255
- await saveSession(session);
261
+ writeLocks.set(sessionId, next);
262
+ await next;
256
263
  }
257
264
  async function listSessions(filters, limit = 50) {
258
265
  await ensureDir();
@@ -289,6 +296,112 @@ async function listSessions(filters, limit = 50) {
289
296
  return summaries.slice(0, limit);
290
297
  }
291
298
  //#endregion
299
+ //#region src/a2a-tools.ts
300
+ function ok(result) {
301
+ return { content: [{
302
+ type: "text",
303
+ text: JSON.stringify(result)
304
+ }] };
305
+ }
306
+ function errResult(message) {
307
+ return { content: [{
308
+ type: "text",
309
+ text: JSON.stringify({ error: message })
310
+ }] };
311
+ }
312
+ function defineTool(def) {
313
+ return {
314
+ name: def.name,
315
+ description: def.description,
316
+ label: def.name,
317
+ parameters: def.parameters,
318
+ execute: async (_toolCallId, params) => {
319
+ try {
320
+ return ok(await def.handler(params));
321
+ } catch (e) {
322
+ return errResult(e.message);
323
+ }
324
+ }
325
+ };
326
+ }
327
+ function buildA2ATools(chatClient, log) {
328
+ return [
329
+ defineTool({
330
+ name: "list_agents",
331
+ description: "List other agents in your organization that you can communicate with.",
332
+ parameters: {
333
+ type: "object",
334
+ properties: {},
335
+ required: []
336
+ },
337
+ handler: async () => {
338
+ log.info("list_agents tool called");
339
+ return await chatClient.sendRequest("a2a.list-agents", {});
340
+ }
341
+ }),
342
+ defineTool({
343
+ name: "message_agent",
344
+ description: "Send a message to another agent. Starts a new conversation thread, or continues an existing one. The conversation will bounce back and forth automatically until one agent calls end_conversation() or says [RESOLVED].",
345
+ parameters: {
346
+ type: "object",
347
+ properties: {
348
+ agent_id: {
349
+ type: "string",
350
+ description: "Target agent ID (use list_agents to find available agents)"
351
+ },
352
+ message: {
353
+ type: "string",
354
+ description: "Message to send to the agent"
355
+ },
356
+ thread_id: {
357
+ type: "string",
358
+ description: "Continue an existing conversation thread (omit to start a new one)"
359
+ },
360
+ max_depth: {
361
+ type: "number",
362
+ description: "Maximum number of back-and-forth exchanges (default: 10)"
363
+ }
364
+ },
365
+ required: ["agent_id", "message"]
366
+ },
367
+ handler: async (params) => {
368
+ const agentId = params.agent_id;
369
+ const message = params.message;
370
+ const threadId = params.thread_id;
371
+ const maxDepth = params.max_depth ?? 10;
372
+ if (!agentId || !message) throw new Error("agent_id and message are required");
373
+ log.info(`message_agent tool: sending to ${agentId}`);
374
+ return await chatClient.sendRequest("a2a.send", {
375
+ targetAgentId: agentId,
376
+ message,
377
+ threadId,
378
+ maxDepth
379
+ });
380
+ }
381
+ }),
382
+ defineTool({
383
+ name: "end_conversation",
384
+ description: "End the current agent-to-agent conversation. Call this when the discussion is resolved and no further exchanges are needed.",
385
+ parameters: {
386
+ type: "object",
387
+ properties: { summary: {
388
+ type: "string",
389
+ description: "Brief summary of what was discussed or resolved"
390
+ } },
391
+ required: []
392
+ },
393
+ handler: (params) => {
394
+ const summary = params.summary;
395
+ log.info(`end_conversation tool called${summary ? `: ${summary}` : ""}`);
396
+ return Promise.resolve({
397
+ status: "conversation_ended",
398
+ summary
399
+ });
400
+ }
401
+ })
402
+ ];
403
+ }
404
+ //#endregion
292
405
  //#region src/plugin.ts
293
406
  /**
294
407
  * @alfe.ai/openclaw-chat — OpenClaw chat channel plugin.
@@ -325,20 +438,30 @@ let pluginRuntime = null;
325
438
  let chatClient = null;
326
439
  let connectingPromise = null;
327
440
  let metricsClient = null;
441
+ const MAX_FILE_SIZE = 50 * 1024 * 1024;
442
+ const DOWNLOAD_TIMEOUT_MS = 3e4;
328
443
  async function downloadAttachments(attachments, log) {
329
444
  const attachDir = join(homedir(), ".alfe", "attachments");
330
445
  await mkdir(attachDir, { recursive: true });
331
446
  const results = [];
332
447
  for (const att of attachments) {
333
- const filename = att.filename ?? att.id;
448
+ const filename = (att.filename ?? att.id).replace(/[/\\]/g, "_").replace(/\.\./g, "_").replace(/\0/g, "");
334
449
  const localPath = join(attachDir, `${att.id}_${filename}`);
450
+ const controller = new AbortController();
451
+ const timeout = setTimeout(() => {
452
+ controller.abort();
453
+ }, DOWNLOAD_TIMEOUT_MS);
335
454
  try {
336
- const res = await fetch(att.url);
455
+ const res = await fetch(att.url, { signal: controller.signal });
337
456
  if (!res.ok) {
338
457
  log.warn(`Failed to download attachment ${att.id}: ${String(res.status)}`);
339
458
  continue;
340
459
  }
341
460
  const buffer = Buffer.from(await res.arrayBuffer());
461
+ if (buffer.length > MAX_FILE_SIZE) {
462
+ log.warn(`Attachment ${att.id} exceeds max size (${String(buffer.length)} bytes) — skipping`);
463
+ continue;
464
+ }
342
465
  await writeFile(localPath, buffer);
343
466
  results.push({
344
467
  localPath,
@@ -348,6 +471,8 @@ async function downloadAttachments(attachments, log) {
348
471
  log.info(`Downloaded attachment: ${localPath} (${String(buffer.length)} bytes)`);
349
472
  } catch (err) {
350
473
  log.error(`Failed to download attachment ${att.id}: ${err instanceof Error ? err.message : String(err)}`);
474
+ } finally {
475
+ clearTimeout(timeout);
351
476
  }
352
477
  }
353
478
  return results;
@@ -362,7 +487,8 @@ async function handleAgentRequest(request, log) {
362
487
  chatClient?.sendResponse(request.id, false, { message: "OpenClaw SDK not available — cannot dispatch" });
363
488
  return;
364
489
  }
365
- const { message, sessionKey: legacySessionKey, userId, conversationId, conversationType, tenantId, clientType, displayName, attachments: rawAttachments } = request.params;
490
+ const { message, sessionKey: legacySessionKey, userId, conversationId, conversationType, tenantId, clientType, displayName, attachments: rawAttachments, a2a } = request.params;
491
+ const isA2A = !!a2a;
366
492
  if (!message && !rawAttachments?.length) {
367
493
  chatClient?.sendResponse(request.id, false, { message: "Missing message" });
368
494
  return;
@@ -389,11 +515,13 @@ async function handleAgentRequest(request, log) {
389
515
  try {
390
516
  const downloadedFiles = rawAttachments?.length ? await downloadAttachments(rawAttachments, log) : [];
391
517
  const bodyForAgent = downloadedFiles.length ? `${message || ""}\n\n[Attached files:\n${downloadedFiles.map((f) => `- ${f.filename}: ${f.localPath}`).join("\n")}]` : void 0;
392
- const channelMode = extractChannelMode(conversationId ?? "", clientType ?? "chat");
393
- const channelLabel = channelMode === "sms" ? "SMS" : channelMode === "whatsapp" ? "WhatsApp" : "Alfe";
518
+ const channelMode = isA2A ? "a2a" : extractChannelMode(conversationId ?? "", clientType ?? "chat");
519
+ const channelLabel = channelMode === "sms" ? "SMS" : channelMode === "whatsapp" ? "WhatsApp" : channelMode === "a2a" ? "Agent" : "Alfe";
394
520
  const shortConvId = conversationId?.slice(-8) ?? "";
395
521
  const userLabel = displayName ?? userId ?? senderId;
396
522
  const conversationLabel = conversationType === "group" ? shortConvId ? `[${channelLabel}] Group (${shortConvId})` : `[${channelLabel}] Group` : shortConvId ? `[${channelLabel}] ${userLabel} (${shortConvId})` : `[${channelLabel}] ${userLabel}`;
523
+ let a2aResponseBuffer = "";
524
+ let a2aResolved = false;
397
525
  resolvedOpenClawKey = (await dispatchInbound({
398
526
  cfg,
399
527
  runtime: { channel: runtime.channel },
@@ -420,12 +548,29 @@ async function handleAgentRequest(request, log) {
420
548
  ...clientType ? { ClientType: clientType } : {},
421
549
  ...conversationId ? { ConversationId: conversationId } : {},
422
550
  ...displayName ? { SenderName: displayName } : {},
423
- ChannelMode: channelMode
551
+ ChannelMode: channelMode,
552
+ ...isA2A ? {
553
+ CallerType: "agent",
554
+ CallerAgentId: a2a.sourceAgentId,
555
+ CallerAgentName: a2a.sourceAgentName,
556
+ InteractionDepth: String(a2a.depth),
557
+ A2ASystemPrompt: [
558
+ `This is an agent-to-agent conversation with ${a2a.sourceAgentName}.`,
559
+ "Rules:",
560
+ "- Only respond if you have new information, a question, or an action to coordinate.",
561
+ "- If the conversation is complete, call the end_conversation() tool OR end your message with [RESOLVED].",
562
+ "- Do NOT respond just to acknowledge — that creates infinite loops."
563
+ ].join("\n")
564
+ } : {}
424
565
  },
425
566
  deliver: async (payload) => {
426
567
  const responseText = payload.text ?? "";
427
568
  const mediaUrls = [...payload.mediaUrl ? [payload.mediaUrl] : [], ...payload.mediaUrls ?? []];
428
569
  await addMessage(sessionId, "assistant", responseText);
570
+ if (isA2A) {
571
+ a2aResponseBuffer += responseText;
572
+ if (responseText.includes("\"status\":\"conversation_ended\"") || responseText.includes("\"status\": \"conversation_ended\"")) a2aResolved = true;
573
+ }
429
574
  chatClient?.notify("agent-message", {
430
575
  conversationId: conversationId ?? legacySessionKey,
431
576
  text: responseText,
@@ -437,7 +582,7 @@ async function handleAgentRequest(request, log) {
437
582
  channel: "alfe",
438
583
  role: "assistant"
439
584
  }).catch((err) => {
440
- log.debug(`Metrics report failed: ${err instanceof Error ? err.message : String(err)}`);
585
+ log.warn(`Metrics report failed: ${err instanceof Error ? err.message : String(err)}`);
441
586
  });
442
587
  },
443
588
  onRecordError: (err) => {
@@ -447,6 +592,12 @@ async function handleAgentRequest(request, log) {
447
592
  log.error(`Dispatch error (${info.kind}): ${err instanceof Error ? err.message : String(err)}`);
448
593
  }
449
594
  })).route.sessionKey;
595
+ if (isA2A && a2aResponseBuffer) chatClient?.notify("a2a-complete", {
596
+ conversationId: conversationId ?? legacySessionKey,
597
+ fullText: a2aResponseBuffer,
598
+ a2a,
599
+ resolved: a2aResolved
600
+ });
450
601
  chatClient?.sendResponse(request.id, true, { sessionKey: resolvedOpenClawKey });
451
602
  log.info(`Agent dispatch complete: sessionKey=${resolvedOpenClawKey}`);
452
603
  } catch (err) {
@@ -516,10 +667,15 @@ const plugin = {
516
667
  activate(api) {
517
668
  const log = api.logger;
518
669
  const alreadyActivated = globalThis.__alfeChatPluginActivated === true;
519
- globalThis.__alfeChatPluginActivated = true;
670
+ const isGatewayMode = !!api.runtime;
520
671
  const alfeChannel = createAlfeChannelPlugin();
521
672
  api.registerChannel(alfeChannel);
522
673
  log.info(`Registered channel: ${alfeChannel.id}`);
674
+ if (!isGatewayMode) {
675
+ log.debug("Management command context — skipping persistent resource init");
676
+ return;
677
+ }
678
+ globalThis.__alfeChatPluginActivated = true;
523
679
  if (!alreadyActivated) {
524
680
  log.info("Chat plugin registering...");
525
681
  resolveOpenClawSdk(log);
@@ -547,10 +703,17 @@ const plugin = {
547
703
  wsUrl: chatWsUrl,
548
704
  apiKey,
549
705
  onRequest: (request) => {
550
- if (request.method === "agent") handleAgentRequest(request, log);
551
- else if (request.method === "sessions.list") handleSessionsList(request, log);
552
- else if (request.method === "sessions.get") handleSessionsGet(request, log);
553
- else chatClient?.sendResponse(request.id, false, { message: `Unknown method: ${request.method}` });
706
+ const handle = async () => {
707
+ if (request.method === "agent") await handleAgentRequest(request, log);
708
+ else if (request.method === "sessions.list") await handleSessionsList(request, log);
709
+ else if (request.method === "sessions.get") await handleSessionsGet(request, log);
710
+ else chatClient?.sendResponse(request.id, false, { message: `Unknown method: ${request.method}` });
711
+ };
712
+ handle().catch((err) => {
713
+ const errMsg = err instanceof Error ? err.message : String(err);
714
+ log.error(`Request handler crashed: ${errMsg}`);
715
+ chatClient?.sendResponse(request.id, false, { message: "Internal error" });
716
+ });
554
717
  },
555
718
  onConnectionChange: (connected) => {
556
719
  log.info(`Chat service connection: ${connected ? "connected" : "disconnected"}`);
@@ -599,20 +762,30 @@ const plugin = {
599
762
  });
600
763
  log.info("Registered gateway RPC methods: sessions.list, sessions.get");
601
764
  }
765
+ if (!alreadyActivated && typeof api.registerTool === "function") connectingPromise?.then(() => {
766
+ if (chatClient && typeof api.registerTool === "function") {
767
+ const a2aTools = buildA2ATools(chatClient, log);
768
+ for (const tool of a2aTools) api.registerTool(tool);
769
+ log.info(`Registered ${String(a2aTools.length)} agent-to-agent tools`);
770
+ }
771
+ }).catch(() => {});
602
772
  if (!alreadyActivated) {
603
773
  api.on("session_start", async (...eventArgs) => {
774
+ if (!pluginRuntime) return;
604
775
  const key = eventArgs[0].sessionKey;
605
776
  if (!key || isAlfeSessionKey(key)) return;
606
777
  log.info(`Chat session starting: ${key}`);
607
778
  await createSession(key, "", "alfe");
608
779
  }, { priority: 50 });
609
780
  api.on("message_received", async (...eventArgs) => {
781
+ if (!pluginRuntime) return;
610
782
  const event = eventArgs[0];
611
783
  const ctx = eventArgs[1];
612
784
  if (!ctx.conversationId || ctx.channelId === "alfe") return;
613
785
  await addMessage(ctx.conversationId, "user", event.content, event.from);
614
786
  });
615
787
  api.on("session_end", (...eventArgs) => {
788
+ if (!pluginRuntime) return;
616
789
  const key = eventArgs[0].sessionKey;
617
790
  if (!key || !isAlfeSessionKey(key)) return;
618
791
  log.info(`Chat session ending: ${key}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/openclaw-chat",
3
- "version": "0.0.19",
3
+ "version": "0.0.20",
4
4
  "description": "OpenClaw chat plugin for Alfe — web widget and mobile app channels",
5
5
  "type": "module",
6
6
  "main": "./dist/plugin.js",
@@ -28,7 +28,7 @@
28
28
  ],
29
29
  "dependencies": {
30
30
  "@alfe.ai/agent-api-client": "^0.0.7",
31
- "@alfe.ai/chat": "^0.0.6",
31
+ "@alfe.ai/chat": "^0.0.7",
32
32
  "@alfe.ai/config": "^0.0.7"
33
33
  },
34
34
  "peerDependencies": {