a2a-xmtp 1.0.1 → 1.2.0

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 CHANGED
@@ -4,102 +4,189 @@ Decentralized Agent-to-Agent E2EE messaging for [OpenClaw](https://openclaw.ai)
4
4
 
5
5
  ## Features
6
6
 
7
- - **E2EE Messaging** — End-to-end encrypted Agent-to-Agent communication
8
- - **Cross-Gateway** — Agents on different servers can communicate directly
9
- - **XMTP Network** — Decentralized, no VPN or API sharing needed
10
- - **3 Agent Tools** — `xmtp_send`, `xmtp_inbox`, `xmtp_agents`
7
+ - **E2EE Messaging** — End-to-end encrypted Agent-to-Agent communication over XMTP
8
+ - **Group Chat** — Create and manage multi-agent group conversations with natural turn-taking
9
+ - **Auto-Reply** — Incoming messages trigger LLM reasoning via subagent, replies sent automatically
10
+ - **Anti-Loop** — 4-layer protection (turn budget, cool-down, depth limit, consent) prevents infinite loops
11
+ - **Cross-Gateway** — Agents on different servers communicate directly, no VPN or API sharing needed
12
+ - **4 Agent Tools** — `xmtp_send`, `xmtp_inbox`, `xmtp_agents`, `xmtp_group`
11
13
 
12
14
  ## Install
13
15
 
14
16
  ```bash
15
17
  openclaw plugins install a2a-xmtp
16
18
  chown -R root:root ~/.openclaw/extensions/a2a-xmtp/
19
+ ```
17
20
 
18
- Configure
21
+ ## Configure
19
22
 
20
- Edit ~/.openclaw/openclaw.json:
23
+ Edit `~/.openclaw/openclaw.json`:
21
24
 
25
+ ```json
22
26
  {
23
- "tools": {
24
- "profile": "full"
25
- },
26
- "plugins": {
27
- "entries": {
28
- "a2a-xmtp": {
29
- "enabled": true,
30
- "config": {
31
- "xmtp": {
32
- "env": "dev",
33
- "dbPath": "/root/.openclaw/xmtp-data"
27
+ "tools": {
28
+ "profile": "full"
29
+ },
30
+ "plugins": {
31
+ "entries": {
32
+ "a2a-xmtp": {
33
+ "enabled": true,
34
+ "config": {
35
+ "xmtp": {
36
+ "env": "dev",
37
+ "dbPath": "/root/.openclaw/xmtp-data"
38
+ }
34
39
  }
35
40
  }
36
41
  }
37
42
  }
38
43
  }
39
- }
44
+ ```
40
45
 
41
46
  Then restart:
42
47
 
48
+ ```bash
43
49
  mkdir -p /root/.openclaw/xmtp-data
44
50
  openclaw gateway restart
51
+ ```
45
52
 
46
- Verify
53
+ ## Verify
47
54
 
55
+ ```bash
48
56
  # Check plugin loaded
49
57
  openclaw plugins list | grep a2a-xmtp
50
58
 
51
- # Check bridge status (replace TOKEN with your gateway auth token)
59
+ # Check bridge status
52
60
  TOKEN=$(python3 -c "import json; print(json.load(open('/root/.openclaw/openclaw.json'))['gateway']['auth']['token'])")
53
61
  curl -s -H "Authorization: Bearer $TOKEN" http://localhost:18789/a2a-xmtp/status
62
+ ```
54
63
 
55
64
  Expected output:
56
65
 
66
+ ```json
57
67
  {
58
- "plugin": "a2a-xmtp",
59
- "bridgeCount": 1,
60
- "agents": [{"agentId": "main", "xmtpAddress": "0x...", "connected": true}]
68
+ "plugin": "a2a-xmtp",
69
+ "bridgeCount": 1,
70
+ "agents": [{"agentId": "main", "xmtpAddress": "0x...", "connected": true}]
61
71
  }
72
+ ```
73
+
74
+ ## Tools
75
+
76
+ ### xmtp_send
77
+
78
+ Send an E2E encrypted message to another agent.
79
+
80
+ | Parameter | Required | Description |
81
+ |-----------|----------|-------------|
82
+ | `to` | When no `conversationId` | Target agent ID or `0x` address |
83
+ | `message` | Yes | Message content |
84
+ | `conversationId` | No | Reuse existing conversation (DM or group) |
85
+ | `contentType` | No | `text` (default) or `markdown` |
86
+
87
+ ### xmtp_inbox
88
+
89
+ Check your inbox for messages from other agents.
90
+
91
+ | Parameter | Required | Description |
92
+ |-----------|----------|-------------|
93
+ | `limit` | No | Max messages to return (default: 10) |
94
+ | `from` | No | Filter by sender agent ID or address |
95
+
96
+ Messages are tagged with `[group]` when from a group conversation.
97
+
98
+ ### xmtp_agents
99
+
100
+ Discover agents available for XMTP communication.
101
+
102
+ | Parameter | Required | Description |
103
+ |-----------|----------|-------------|
104
+ | `includeExternal` | No | Include ERC-8004 external registry (Phase 3) |
105
+
106
+ ### xmtp_group
107
+
108
+ Manage group conversations.
62
109
 
63
- Usage
110
+ | Parameter | Required | Description |
111
+ |-----------|----------|-------------|
112
+ | `action` | Yes | `create`, `list`, `members`, `add_member`, `remove_member` |
113
+ | `members` | For create/add/remove | Agent IDs or `0x` addresses |
114
+ | `conversationId` | For members/add/remove | Group conversation ID |
115
+ | `name` | No | Group name (for create) |
64
116
 
65
- Talk to your Agent via Telegram or any OpenClaw channel:
117
+ **Examples:**
66
118
 
67
- ┌─────────────────┬──────────────────────────────────────────┐
68
- │ Command │ Example │
69
- ├─────────────────┼──────────────────────────────────────────┤
70
- │ Discover agents │ "使用 xmtp_agents 列出可通信的 Agent" │
71
- ├─────────────────┼──────────────────────────────────────────┤
72
- │ Send message │ "使用 xmtp_send 给 0xAddress 发送 Hello" │
73
- ├─────────────────┼──────────────────────────────────────────┤
74
- │ Check inbox │ "使用 xmtp_inbox 查看消息" │
75
- └─────────────────┴──────────────────────────────────────────┘
119
+ ```
120
+ # Create a group with two agents
121
+ "Use xmtp_group to create a group named 'Research Team' with members agent-a and 0xBob..."
76
122
 
77
- Cross-Server Communication
123
+ # List all groups
124
+ "Use xmtp_group to list all groups"
78
125
 
79
- Two OpenClaw agents on different servers can message each other — just exchange XMTP addresses:
126
+ # Send message to group
127
+ "Use xmtp_send to send 'Hello everyone' to conversationId xxx"
128
+ ```
80
129
 
81
- 1. Get your agent's address from /a2a-xmtp/status
130
+ ## Auto-Reply
131
+
132
+ When your agent receives an XMTP message, the plugin automatically:
133
+
134
+ 1. Buffers the message in the inbox
135
+ 2. Triggers a subagent with the message context
136
+ 3. Sends the LLM's reply back via XMTP
137
+
138
+ This works for both DMs and group chats. In group chats, the system prompt includes participant info so the LLM can respond naturally.
139
+
140
+ ## Multi-Agent Group Chat
141
+
142
+ When multiple OpenClaw agents are in the same group, the plugin ensures natural turn-taking:
143
+
144
+ - **No parallel replies** — When a human sends a message, agents use random delay + dedup check so only one responds first
145
+ - **Sequential discussion** — After one agent replies, the other naturally follows, creating a back-and-forth discussion
146
+ - **Natural conversation flow** — The LLM is prompted to behave like a human in group chat, only speaking when it has something to add
147
+
148
+ ```
149
+ Admin: "Discuss the pros and cons of Rust vs Go"
150
+ Agent A: "Rust offers memory safety without GC..." (responds first)
151
+ Agent B: "I agree on safety, but Go's simplicity..." (follows up)
152
+ Agent A: "Good point. For our use case though..." (continues)
153
+ ```
154
+
155
+ ## Cross-Server Communication
156
+
157
+ Two OpenClaw agents on different servers can message each other:
158
+
159
+ 1. Get your agent's address from `/a2a-xmtp/status`
82
160
  2. Share it with the other party
83
- 3. Send messages using xmtp_send with the other agent's 0x address
161
+ 3. Send messages using `xmtp_send` with the other agent's `0x` address
84
162
 
85
163
  No VPN, no API keys, no shared infrastructure needed.
86
164
 
87
- Policy Configuration
88
-
89
- ┌──────────────────────┬──────────────────┬────────────────────────────┐
90
- │ Setting │ Default │ Description │
91
- ├──────────────────────┼──────────────────┼────────────────────────────┤
92
- │ policy.maxTurns │ 10 │ Max turns per conversation │
93
- ├──────────────────────┼──────────────────┼────────────────────────────┤
94
- policy.maxDepth 5 Max reply depth
95
- ├──────────────────────┼──────────────────┼────────────────────────────┤
96
- policy.minIntervalMs 5000 │ Cool-down between sends │
97
- ├──────────────────────┼──────────────────┼────────────────────────────┤
98
- │ policy.ttlMinutes │ 60 │ Conversation TTL │
99
- ├──────────────────────┼──────────────────┼────────────────────────────┤
100
- │ policy.consentMode │ auto-allow-local │ Consent for local agents │
101
- └──────────────────────┴──────────────────┴────────────────────────────┘
102
-
103
- License
165
+ ## Policy Configuration
166
+
167
+ Anti-loop protection with 4 guards:
168
+
169
+ | Setting | Default | Description |
170
+ |---------|---------|-------------|
171
+ | `policy.maxTurns` | 10 | Max turns per conversation |
172
+ | `policy.maxDepth` | 5 | Max reply depth |
173
+ | `policy.minIntervalMs` | 5000 | Cool-down between sends (ms) |
174
+ | `policy.ttlMinutes` | 60 | Conversation TTL |
175
+ | `policy.consentMode` | `auto-allow-local` | `auto-allow-local` or `explicit-only` |
176
+
177
+ ## Architecture
178
+
179
+ ```
180
+ Plugin Entry (index.ts)
181
+ ├── xmtp_send ─┐
182
+ ├── xmtp_inbox │── Tool Handlers (src/tools/)
183
+ ├── xmtp_agents │
184
+ ├── xmtp_group ─┘
185
+ ├── XmtpBridge (xmtp-bridge.ts) ── XMTP Agent SDK wrapper
186
+ ├── IdentityRegistry (identity-registry.ts) ── agentId <-> wallet mapping
187
+ └── PolicyEngine (policy-engine.ts) ── anti-loop guards
188
+ ```
189
+
190
+ ## License
104
191
 
105
192
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2a-xmtp",
3
- "version": "1.0.1",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -33,4 +33,4 @@
33
33
  "package.json",
34
34
  "README.md"
35
35
  ]
36
- }
36
+ }
package/src/index.ts CHANGED
@@ -11,7 +11,8 @@ import { XmtpBridge } from "./xmtp-bridge.js";
11
11
  import { handleXmtpSend } from "./tools/xmtp-send.js";
12
12
  import { handleXmtpInbox } from "./tools/xmtp-inbox.js";
13
13
  import { handleDiscoverAgents } from "./tools/xmtp-agents.js";
14
- import { DEFAULT_PLUGIN_CONFIG, type PluginConfig } from "./types.js";
14
+ import { handleXmtpGroup } from "./tools/xmtp-group.js";
15
+ import { DEFAULT_PLUGIN_CONFIG, formatA2AMessage, type PluginConfig, type A2AInjectPayload } from "./types.js";
15
16
 
16
17
  const AGENT_ID = "main"; // OpenClaw 默认 agent
17
18
 
@@ -29,25 +30,27 @@ export default definePluginEntry({
29
30
 
30
31
  api.registerTool({
31
32
  name: "xmtp_send",
33
+ label: "Send XMTP Message",
32
34
  description:
33
35
  "Send an E2EE message to another agent via XMTP. " +
34
36
  "Supports cross-gateway and cross-organization communication.",
35
37
  parameters: Type.Object({
36
- to: Type.String({ description: "Target agent ID or XMTP address (0x...)" }),
38
+ to: Type.Optional(Type.String({ description: "Target agent ID or XMTP address (0x...). Optional when conversationId is provided." })),
37
39
  message: Type.String({ description: "Message content to send" }),
38
- conversationId: Type.Optional(Type.String({ description: "Reuse existing conversation" })),
40
+ conversationId: Type.Optional(Type.String({ description: "Reuse existing conversation. When set, 'to' is optional." })),
39
41
  contentType: Type.Optional(
40
42
  Type.String({ description: "Message type: text or markdown", default: "text" }),
41
43
  ),
42
44
  }),
43
45
  async execute(_toolCallId, params) {
44
- const p = params as { to: string; message: string; conversationId?: string; contentType?: "text" | "markdown" };
46
+ const p = params as { to?: string; message: string; conversationId?: string; contentType?: "text" | "markdown" };
45
47
  return await handleXmtpSend(bridges, registry, policyEngine, p, AGENT_ID);
46
48
  },
47
49
  });
48
50
 
49
51
  api.registerTool({
50
52
  name: "xmtp_inbox",
53
+ label: "XMTP Inbox",
51
54
  description:
52
55
  "Check your XMTP inbox for messages from other agents. " +
53
56
  "Messages are E2E encrypted and only you can read them.",
@@ -63,6 +66,7 @@ export default definePluginEntry({
63
66
 
64
67
  api.registerTool({
65
68
  name: "xmtp_agents",
69
+ label: "Discover XMTP Agents",
66
70
  description:
67
71
  "Discover agents available for XMTP communication. " +
68
72
  "Lists registered agents and their connection status.",
@@ -77,6 +81,24 @@ export default definePluginEntry({
77
81
  },
78
82
  });
79
83
 
84
+ api.registerTool({
85
+ name: "xmtp_group",
86
+ label: "XMTP Group Management",
87
+ description:
88
+ "Manage XMTP group conversations. Actions: create (new group), list (all groups), " +
89
+ "members (view members), add_member, remove_member.",
90
+ parameters: Type.Object({
91
+ action: Type.String({ description: "Action: create | list | members | add_member | remove_member" }),
92
+ members: Type.Optional(Type.Array(Type.String(), { description: "Agent IDs or 0x addresses (for create/add_member/remove_member)" })),
93
+ conversationId: Type.Optional(Type.String({ description: "Group conversation ID (for members/add_member/remove_member)" })),
94
+ name: Type.Optional(Type.String({ description: "Group name (for create)" })),
95
+ }),
96
+ async execute(_toolCallId, params) {
97
+ const p = params as { action: string; members?: string[]; conversationId?: string; name?: string };
98
+ return await handleXmtpGroup(bridges, registry, p, AGENT_ID);
99
+ },
100
+ });
101
+
80
102
  // ── 2. 注册 HTTP 状态路由 ──
81
103
 
82
104
  api.registerHttpRoute({
@@ -141,9 +163,120 @@ export default definePluginEntry({
141
163
  config.xmtp.dbPath,
142
164
  );
143
165
 
144
- // 消息回调:收到 XMTP 消息时记录日志
145
- bridge.onMessage = (agentId, message) => {
146
- ctx.logger.info(`[a2a-xmtp] Incoming message for ${agentId}: ${message}`);
166
+ // 消息回调:收到 XMTP 消息时触发 subagent 进行 LLM 推理并自动回复
167
+ bridge.onMessage = async (agentId, payload: A2AInjectPayload) => {
168
+ const sessionKey = `xmtp:${payload.conversation.id}`;
169
+ const formattedMsg = formatA2AMessage(payload);
170
+ const senderLabel = payload.from.agentId || payload.from.xmtpAddress;
171
+
172
+ // ── 群聊防抢答:随机延迟 + 发送前检查 ──
173
+ // 多个 agent 收到同一条消息时,随机延迟打破对称性,
174
+ // 延迟结束后检查是否已有其他 agent 回复,有则跳过
175
+ if (payload.conversation.isGroup) {
176
+ const delay = 3000 + Math.random() * 5000; // 3-8 秒随机延迟
177
+ ctx.logger.info(
178
+ `[a2a-xmtp] Group message from ${senderLabel}, waiting ${Math.round(delay)}ms before responding...`,
179
+ );
180
+ await new Promise((r) => setTimeout(r, delay));
181
+
182
+ // 检查延迟期间是否已有其他人回复
183
+ const alreadyReplied = await bridge.hasNewGroupReplies(
184
+ payload.conversation.id,
185
+ payload.timestamp,
186
+ [bridge.address, payload.from.xmtpAddress], // 排除自己和原始发送者
187
+ );
188
+ if (alreadyReplied) {
189
+ ctx.logger.info(
190
+ `[a2a-xmtp] Skipping reply — another agent already responded in ${payload.conversation.id}`,
191
+ );
192
+ return;
193
+ }
194
+ }
195
+
196
+ const participantInfo = payload.conversation.isGroup
197
+ ? [
198
+ `这是群聊,参与者: ${payload.conversation.participants.join(", ")}。`,
199
+ `群内有多个 AI agent,请像人类群聊一样自然讨论。`,
200
+ `不需要每条消息都回复,如果话题不需要你的输入可以保持沉默(回复空文本)。`,
201
+ `回复应该是对话的自然延续,而不是重复别人的观点。`,
202
+ ].join("\n")
203
+ : `这是私聊。`;
204
+
205
+ const extraSystemPrompt = [
206
+ `你收到了一条 XMTP 消息,来自 ${senderLabel}。`,
207
+ participantInfo,
208
+ `请直接用文本回复,不要调用任何工具(不要调用 xmtp_send、xmtp_inbox 等)。`,
209
+ `系统会自动将你的文本回复发送给对方。`,
210
+ ].join("\n");
211
+
212
+ try {
213
+ const { runId } = await api.runtime.subagent.run({
214
+ sessionKey,
215
+ message: formattedMsg,
216
+ extraSystemPrompt,
217
+ deliver: false,
218
+ idempotencyKey: `xmtp:${payload.message.id}`,
219
+ });
220
+ const result = await api.runtime.subagent.waitForRun({ runId, timeoutMs: 60000 });
221
+ if (result.status === "error") {
222
+ ctx.logger.error(`[a2a-xmtp] Subagent error: ${result.error}`);
223
+ } else if (result.status === "timeout") {
224
+ ctx.logger.warn(`[a2a-xmtp] Subagent timeout for ${sessionKey}`);
225
+ }
226
+
227
+ // 取回 LLM 的文本回复,手动通过 XMTP 发送
228
+ if (result.status === "ok") {
229
+ const { messages } = await api.runtime.subagent.getSessionMessages({
230
+ sessionKey,
231
+ limit: 5,
232
+ });
233
+ // 找到最后一条 assistant 回复
234
+ const lastReply = [...messages].reverse().find(
235
+ (m: any) => m.role === "assistant" && m.content,
236
+ );
237
+ if (lastReply) {
238
+ const rawContent = (lastReply as any).content;
239
+ // content 可能是 string 或 content blocks 数组(含 thinking/text)
240
+ let replyText: string;
241
+ if (typeof rawContent === "string") {
242
+ replyText = rawContent;
243
+ } else if (Array.isArray(rawContent)) {
244
+ // 只提取 type=text 的部分,跳过 thinking
245
+ replyText = rawContent
246
+ .filter((block: any) => block.type === "text" && block.text)
247
+ .map((block: any) => block.text)
248
+ .join("\n");
249
+ } else {
250
+ replyText = String(rawContent);
251
+ }
252
+ if (!replyText.trim()) return; // 没有有效文本则不回复
253
+
254
+ // 群聊:发送前再次检查,防止 LLM 推理期间其他 agent 已经回复
255
+ if (payload.conversation.isGroup) {
256
+ const raceCheck = await bridge.hasNewGroupReplies(
257
+ payload.conversation.id,
258
+ payload.timestamp,
259
+ [bridge.address, payload.from.xmtpAddress],
260
+ );
261
+ if (raceCheck) {
262
+ ctx.logger.info(
263
+ `[a2a-xmtp] Skipping reply (post-LLM check) — another agent responded in ${payload.conversation.id}`,
264
+ );
265
+ return;
266
+ }
267
+ }
268
+
269
+ await bridge.sendMessage(payload.from.xmtpAddress, replyText, {
270
+ conversationId: payload.conversation.id,
271
+ });
272
+ ctx.logger.info(`[a2a-xmtp] Replied to ${senderLabel} in ${payload.conversation.id}`);
273
+ }
274
+ }
275
+ } catch (err) {
276
+ ctx.logger.error(
277
+ `[a2a-xmtp] Failed to trigger subagent: ${err instanceof Error ? err.message : String(err)}`,
278
+ );
279
+ }
147
280
  };
148
281
 
149
282
  await bridge.start();
@@ -0,0 +1,153 @@
1
+ // ============================================================
2
+ // Tool: xmtp_group — 群聊管理(创建、列表、成员查看/添加/移除)
3
+ // ============================================================
4
+
5
+ import type { XmtpBridge } from "../xmtp-bridge.js";
6
+ import type { IdentityRegistry } from "../identity-registry.js";
7
+
8
+ type GroupAction = "create" | "list" | "members" | "add_member" | "remove_member";
9
+
10
+ export async function handleXmtpGroup(
11
+ bridges: Map<string, XmtpBridge>,
12
+ registry: IdentityRegistry,
13
+ params: {
14
+ action: string;
15
+ members?: string[];
16
+ conversationId?: string;
17
+ name?: string;
18
+ },
19
+ agentId: string,
20
+ ) {
21
+ const bridge = bridges.get(agentId) ?? bridges.values().next().value;
22
+ if (!bridge) {
23
+ return { content: [{ type: "text" as const, text: "No XMTP bridge available. Plugin not initialized." }], details: null };
24
+ }
25
+
26
+ const action = params.action as GroupAction;
27
+
28
+ try {
29
+ switch (action) {
30
+ case "create": {
31
+ if (!params.members?.length) {
32
+ return { content: [{ type: "text" as const, text: "Parameter 'members' is required for create action. Provide agent IDs or 0x addresses." }], details: null };
33
+ }
34
+ // 解析成员地址
35
+ const addresses: string[] = [];
36
+ for (const member of params.members) {
37
+ if (member.startsWith("0x")) {
38
+ addresses.push(member);
39
+ } else {
40
+ const addr = registry.getAddress(member);
41
+ if (!addr) {
42
+ return { content: [{ type: "text" as const, text: `Agent "${member}" not found. Use xmtp_agents to discover available agents.` }], details: null };
43
+ }
44
+ addresses.push(addr);
45
+ }
46
+ }
47
+
48
+ const result = await bridge.createGroup(addresses, params.name);
49
+ return {
50
+ content: [{ type: "text" as const, text: `Group created${result.name ? ` "${result.name}"` : ""} with ${addresses.length} members.` }],
51
+ details: {
52
+ action: "create",
53
+ conversationId: result.conversationId,
54
+ name: result.name,
55
+ memberCount: addresses.length,
56
+ },
57
+ };
58
+ }
59
+
60
+ case "list": {
61
+ const groups = await bridge.listGroups();
62
+ if (groups.length === 0) {
63
+ return { content: [{ type: "text" as const, text: "No group conversations found." }], details: null };
64
+ }
65
+ const lines = groups.map((g) => {
66
+ const nameLabel = g.name ? ` "${g.name}"` : "";
67
+ return `- ${g.conversationId}${nameLabel} (${g.memberAddresses.length} members, created ${g.createdAt})`;
68
+ });
69
+ return {
70
+ content: [{ type: "text" as const, text: `Groups:\n${lines.join("\n")}` }],
71
+ details: { action: "list", count: groups.length, groups },
72
+ };
73
+ }
74
+
75
+ case "members": {
76
+ if (!params.conversationId) {
77
+ return { content: [{ type: "text" as const, text: "Parameter 'conversationId' is required for members action." }], details: null };
78
+ }
79
+ const members = await bridge.getGroupMembers(params.conversationId);
80
+ // 尝试解析 agentId
81
+ const resolved = members.map((addr) => {
82
+ const aid = registry.getAgentId(addr);
83
+ return aid ? `${aid} (${addr})` : addr;
84
+ });
85
+ return {
86
+ content: [{ type: "text" as const, text: `Members (${members.length}):\n${resolved.map((m) => `- ${m}`).join("\n")}` }],
87
+ details: { action: "members", conversationId: params.conversationId, count: members.length, members },
88
+ };
89
+ }
90
+
91
+ case "add_member": {
92
+ if (!params.conversationId) {
93
+ return { content: [{ type: "text" as const, text: "Parameter 'conversationId' is required for add_member action." }], details: null };
94
+ }
95
+ if (!params.members?.length) {
96
+ return { content: [{ type: "text" as const, text: "Parameter 'members' is required for add_member action." }], details: null };
97
+ }
98
+ const addresses: string[] = [];
99
+ for (const member of params.members) {
100
+ if (member.startsWith("0x")) {
101
+ addresses.push(member);
102
+ } else {
103
+ const addr = registry.getAddress(member);
104
+ if (!addr) {
105
+ return { content: [{ type: "text" as const, text: `Agent "${member}" not found.` }], details: null };
106
+ }
107
+ addresses.push(addr);
108
+ }
109
+ }
110
+ await bridge.addGroupMembers(params.conversationId, addresses);
111
+ return {
112
+ content: [{ type: "text" as const, text: `Added ${addresses.length} member(s) to group.` }],
113
+ details: { action: "add_member", conversationId: params.conversationId, added: addresses },
114
+ };
115
+ }
116
+
117
+ case "remove_member": {
118
+ if (!params.conversationId) {
119
+ return { content: [{ type: "text" as const, text: "Parameter 'conversationId' is required for remove_member action." }], details: null };
120
+ }
121
+ if (!params.members?.length) {
122
+ return { content: [{ type: "text" as const, text: "Parameter 'members' is required for remove_member action." }], details: null };
123
+ }
124
+ const addresses: string[] = [];
125
+ for (const member of params.members) {
126
+ if (member.startsWith("0x")) {
127
+ addresses.push(member);
128
+ } else {
129
+ const addr = registry.getAddress(member);
130
+ if (!addr) {
131
+ return { content: [{ type: "text" as const, text: `Agent "${member}" not found.` }], details: null };
132
+ }
133
+ addresses.push(addr);
134
+ }
135
+ }
136
+ await bridge.removeGroupMembers(params.conversationId, addresses);
137
+ return {
138
+ content: [{ type: "text" as const, text: `Removed ${addresses.length} member(s) from group.` }],
139
+ details: { action: "remove_member", conversationId: params.conversationId, removed: addresses },
140
+ };
141
+ }
142
+
143
+ default:
144
+ return {
145
+ content: [{ type: "text" as const, text: `Unknown action "${action}". Supported: create, list, members, add_member, remove_member.` }],
146
+ details: null,
147
+ };
148
+ }
149
+ } catch (err: unknown) {
150
+ const msg = err instanceof Error ? err.message : String(err);
151
+ return { content: [{ type: "text" as const, text: `Group operation failed: ${msg}` }], details: null };
152
+ }
153
+ }
@@ -13,7 +13,7 @@ export async function handleXmtpInbox(
13
13
  ) {
14
14
  const bridge = bridges.get(agentId) ?? bridges.values().next().value;
15
15
  if (!bridge) {
16
- return { content: [{ type: "text" as const, text: "No XMTP bridge available." }] };
16
+ return { content: [{ type: "text" as const, text: "No XMTP bridge available." }], details: null };
17
17
  }
18
18
 
19
19
  // 解析 from
@@ -27,12 +27,13 @@ export async function handleXmtpInbox(
27
27
  const messages = await bridge.getInbox({ limit: params.limit ?? 10, from: fromFilter });
28
28
 
29
29
  if (messages.length === 0) {
30
- return { content: [{ type: "text" as const, text: "No messages in inbox." }] };
30
+ return { content: [{ type: "text" as const, text: "No messages in inbox." }], details: null };
31
31
  }
32
32
 
33
33
  const lines = messages.map((m, i) => {
34
34
  const sender = m.from.agentId ?? m.from.xmtpAddress;
35
- return `${i + 1}. [${m.timestamp}] from ${sender}: ${m.content}`;
35
+ const groupTag = m.isGroup ? " [group]" : "";
36
+ return `${i + 1}. [${m.timestamp}]${groupTag} from ${sender}: ${m.content}`;
36
37
  });
37
38
 
38
39
  return {
@@ -41,6 +42,6 @@ export async function handleXmtpInbox(
41
42
  };
42
43
  } catch (err: unknown) {
43
44
  const msg = err instanceof Error ? err.message : String(err);
44
- return { content: [{ type: "text" as const, text: `Failed to fetch inbox: ${msg}` }] };
45
+ return { content: [{ type: "text" as const, text: `Failed to fetch inbox: ${msg}` }], details: null };
45
46
  }
46
47
  }
@@ -10,48 +10,58 @@ export async function handleXmtpSend(
10
10
  bridges: Map<string, XmtpBridge>,
11
11
  registry: IdentityRegistry,
12
12
  policyEngine: PolicyEngine,
13
- params: { to: string; message: string; conversationId?: string; contentType?: "text" | "markdown" },
13
+ params: { to?: string; message: string; conversationId?: string; contentType?: "text" | "markdown" },
14
14
  senderAgentId: string,
15
15
  ) {
16
16
  // 找到发送者 bridge(使用第一个可用的 bridge)
17
17
  const bridge = bridges.get(senderAgentId) ?? bridges.values().next().value;
18
18
  if (!bridge) {
19
- return { content: [{ type: "text" as const, text: "No XMTP bridge available. Plugin not initialized." }] };
19
+ return { content: [{ type: "text" as const, text: "No XMTP bridge available. Plugin not initialized." }], details: null };
20
20
  }
21
21
 
22
- // 解析目标地址
23
- let targetAddress: string;
24
- if (params.to.startsWith("0x")) {
25
- targetAddress = params.to;
26
- } else {
27
- const addr = registry.getAddress(params.to);
28
- if (!addr) {
29
- return {
30
- content: [{ type: "text" as const, text: `Agent "${params.to}" not found. Use xmtp_agents to discover available agents.` }],
31
- };
32
- }
33
- targetAddress = addr;
22
+ // 当提供 conversationId 时,to 可省略(直接向已有会话发送)
23
+ if (!params.to && !params.conversationId) {
24
+ return { content: [{ type: "text" as const, text: "Either 'to' or 'conversationId' must be provided." }], details: null };
34
25
  }
35
26
 
36
- if (targetAddress.toLowerCase() === bridge.address.toLowerCase()) {
37
- return { content: [{ type: "text" as const, text: "Cannot send message to yourself." }] };
27
+ // 解析目标地址(仅在提供 to 时需要)
28
+ let targetAddress: string | undefined;
29
+ if (params.to) {
30
+ if (params.to.startsWith("0x")) {
31
+ targetAddress = params.to;
32
+ } else {
33
+ const addr = registry.getAddress(params.to);
34
+ if (!addr) {
35
+ return {
36
+ content: [{ type: "text" as const, text: `Agent "${params.to}" not found. Use xmtp_agents to discover available agents.` }],
37
+ details: null,
38
+ };
39
+ }
40
+ targetAddress = addr;
41
+ }
42
+
43
+ if (targetAddress.toLowerCase() === bridge.address.toLowerCase()) {
44
+ return { content: [{ type: "text" as const, text: "Cannot send message to yourself." }], details: null };
45
+ }
38
46
  }
39
47
 
40
48
  // Policy 检查
49
+ const toLabel = params.to ?? params.conversationId!;
41
50
  const convId = params.conversationId ?? `dm:${senderAgentId}:${params.to}`;
42
- const check = policyEngine.checkOutgoing({ from: senderAgentId, to: params.to, conversationId: convId });
51
+ const check = policyEngine.checkOutgoing({ from: senderAgentId, to: toLabel, conversationId: convId });
43
52
  if (!check.allowed) {
44
- return { content: [{ type: "text" as const, text: `Blocked: ${check.reason}` }] };
53
+ return { content: [{ type: "text" as const, text: `Blocked: ${check.reason}` }], details: null };
45
54
  }
46
55
 
47
56
  try {
48
- const result = await bridge.sendMessage(targetAddress, params.message, {
57
+ const result = await bridge.sendMessage(targetAddress ?? "", params.message, {
49
58
  contentType: params.contentType,
50
59
  conversationId: params.conversationId,
51
60
  });
52
61
 
62
+ const recipientLabel = params.to ?? `conversation ${result.conversationId}`;
53
63
  return {
54
- content: [{ type: "text" as const, text: `Message sent to ${params.to} (${targetAddress}).` }],
64
+ content: [{ type: "text" as const, text: `Message sent to ${recipientLabel}.` }],
55
65
  details: {
56
66
  status: "sent",
57
67
  conversationId: result.conversationId,
@@ -62,6 +72,6 @@ export async function handleXmtpSend(
62
72
  };
63
73
  } catch (err: unknown) {
64
74
  const msg = err instanceof Error ? err.message : String(err);
65
- return { content: [{ type: "text" as const, text: `Failed to send: ${msg}` }] };
75
+ return { content: [{ type: "text" as const, text: `Failed to send: ${msg}` }], details: null };
66
76
  }
67
77
  }
package/src/types.ts CHANGED
@@ -76,11 +76,20 @@ export interface InboxMessage {
76
76
  xmtpAddress: string;
77
77
  };
78
78
  conversationId: string;
79
+ isGroup: boolean;
79
80
  content: string;
80
81
  contentType: A2AContentType;
81
82
  timestamp: string;
82
83
  }
83
84
 
85
+ /** 群聊信息 */
86
+ export interface GroupInfo {
87
+ conversationId: string;
88
+ name: string;
89
+ memberAddresses: string[];
90
+ createdAt: string;
91
+ }
92
+
84
93
  /** 默认策略配置 */
85
94
  export const DEFAULT_POLICY: ConversationPolicy = {
86
95
  maxTurns: 10,
@@ -3,22 +3,26 @@
3
3
  // 每个 Agent 对应一个 XMTP Client,消息 stream + 收件箱缓存
4
4
  // ============================================================
5
5
 
6
- import { Agent, createUser, createSigner, IdentifierKind } from "@xmtp/agent-sdk";
6
+ import { Agent, createUser, createSigner } from "@xmtp/agent-sdk";
7
7
  import type { IdentityRegistry } from "./identity-registry.js";
8
8
  import { PolicyEngine } from "./policy-engine.js";
9
- import type { XmtpWalletConfig, InboxMessage, A2AContentType } from "./types.js";
10
- import { createA2APayload, formatA2AMessage } from "./types.js";
9
+ import type { XmtpWalletConfig, InboxMessage, A2AContentType, A2AInjectPayload, GroupInfo } from "./types.js";
10
+ import { createA2APayload } from "./types.js";
11
+
12
+ /** IdentifierKind.Ethereum = 0 (const enum, 不能在 isolatedModules 下直接访问) */
13
+ const IDENTIFIER_KIND_ETHEREUM = 0;
11
14
 
12
15
  export class XmtpBridge {
13
16
  private agent: InstanceType<typeof Agent> | null = null;
14
17
  private running = false;
18
+ private syncTimer: ReturnType<typeof setInterval> | null = null;
15
19
 
16
20
  /** 收件箱缓存 */
17
21
  private inboxBuffer: InboxMessage[] = [];
18
22
  private readonly maxInboxBuffer = 100;
19
23
 
20
- /** 消息回调(由 plugin 入口设置,用于注入 session) */
21
- onMessage?: (agentId: string, formattedMessage: string) => void;
24
+ /** 消息回调(由 plugin 入口设置,用于触发 subagent 处理) */
25
+ onMessage?: (agentId: string, payload: A2AInjectPayload) => void;
22
26
 
23
27
  constructor(
24
28
  readonly agentId: string,
@@ -40,7 +44,7 @@ export class XmtpBridge {
40
44
  dbPath: `${this.dbPath}/${this.agentId}`,
41
45
  });
42
46
 
43
- await this.registry.updateInboxId(this.agentId, this.agent.address);
47
+ await this.registry.updateInboxId(this.agentId, this.agent.address!);
44
48
 
45
49
  this.agent.on("text", async (ctx: any) => {
46
50
  await this.handleIncoming(ctx, "text");
@@ -52,10 +56,21 @@ export class XmtpBridge {
52
56
 
53
57
  await this.agent.start();
54
58
  this.running = true;
59
+
60
+ // 定期同步会话列表,发现被外部添加到的新群聊(每 30 秒)
61
+ this.syncTimer = setInterval(async () => {
62
+ try {
63
+ await this.agent?.client.conversations.sync();
64
+ } catch { /* 同步失败不影响正常运行 */ }
65
+ }, 30_000);
55
66
  }
56
67
 
57
68
  async stop(): Promise<void> {
58
69
  if (!this.running || !this.agent) return;
70
+ if (this.syncTimer) {
71
+ clearInterval(this.syncTimer);
72
+ this.syncTimer = null;
73
+ }
59
74
  await this.agent.stop();
60
75
  this.running = false;
61
76
  }
@@ -96,16 +111,97 @@ export class XmtpBridge {
96
111
  return messages.slice(0, limit);
97
112
  }
98
113
 
99
- async createGroup(memberAddresses: string[]): Promise<string> {
114
+ async createGroup(memberAddresses: string[], name?: string): Promise<{ conversationId: string; name: string }> {
115
+ if (!this.agent) throw new Error(`Bridge for ${this.agentId} not started`);
116
+ const opts = name ? { groupName: name } : undefined;
117
+ const group = await (this.agent as any).createGroupWithAddresses(memberAddresses, opts);
118
+ if (name) {
119
+ try { await group.updateName(name); } catch { /* 部分 SDK 版本 createGroupWithAddresses 不支持 opts */ }
120
+ }
121
+ return { conversationId: group.id, name: group.name ?? name ?? "" };
122
+ }
123
+
124
+ async listGroups(): Promise<GroupInfo[]> {
125
+ if (!this.agent) throw new Error(`Bridge for ${this.agentId} not started`);
126
+ const conversations = await this.agent.client.conversations.list();
127
+ const groups: GroupInfo[] = [];
128
+ for (const conv of conversations) {
129
+ const meta = await conv.metadata();
130
+ if (meta.conversationType !== 1) continue; // 1 = Group, 0 = Dm
131
+ const members = await conv.members();
132
+ groups.push({
133
+ conversationId: conv.id,
134
+ name: (conv as any).name ?? "",
135
+ memberAddresses: members.map((m: any) =>
136
+ m.accountAddresses?.[0]?.toLowerCase() ?? m.inboxId,
137
+ ),
138
+ createdAt: conv.createdAt.toISOString(),
139
+ });
140
+ }
141
+ return groups;
142
+ }
143
+
144
+ async getGroupMembers(conversationId: string): Promise<string[]> {
100
145
  if (!this.agent) throw new Error(`Bridge for ${this.agentId} not started`);
101
- const group = await (this.agent as any).createGroupWithAddresses(memberAddresses);
102
- return group.id;
146
+ const conv = await this.agent.client.conversations.getConversationById(conversationId);
147
+ if (!conv) throw new Error(`Conversation ${conversationId} not found`);
148
+ const members = await conv.members();
149
+ return members.map((m: any) => m.accountAddresses?.[0]?.toLowerCase() ?? m.inboxId);
150
+ }
151
+
152
+ async addGroupMembers(conversationId: string, addresses: string[]): Promise<void> {
153
+ if (!this.agent) throw new Error(`Bridge for ${this.agentId} not started`);
154
+ const conv = await this.agent.client.conversations.getConversationById(conversationId);
155
+ if (!conv) throw new Error(`Conversation ${conversationId} not found`);
156
+ const identifiers = addresses.map((addr) => ({
157
+ identifier: addr,
158
+ identifierKind: IDENTIFIER_KIND_ETHEREUM,
159
+ }));
160
+ await (conv as any).addMembersByIdentifiers(identifiers);
161
+ }
162
+
163
+ async removeGroupMembers(conversationId: string, addresses: string[]): Promise<void> {
164
+ if (!this.agent) throw new Error(`Bridge for ${this.agentId} not started`);
165
+ const conv = await this.agent.client.conversations.getConversationById(conversationId);
166
+ if (!conv) throw new Error(`Conversation ${conversationId} not found`);
167
+ const identifiers = addresses.map((addr) => ({
168
+ identifier: addr,
169
+ identifierKind: IDENTIFIER_KIND_ETHEREUM,
170
+ }));
171
+ await (conv as any).removeMembersByIdentifiers(identifiers);
172
+ }
173
+
174
+ /**
175
+ * 检查群聊中在 afterTimestamp 之后是否有新消息(排除自身和指定 sender)
176
+ * 用于防止多 agent 同时回复同一条消息
177
+ */
178
+ async hasNewGroupReplies(
179
+ conversationId: string,
180
+ afterTimestamp: string,
181
+ excludeAddresses: string[],
182
+ ): Promise<boolean> {
183
+ if (!this.agent) return false;
184
+ try {
185
+ const conv = await this.agent.client.conversations.getConversationById(conversationId);
186
+ if (!conv) return false;
187
+ await conv.sync();
188
+ const messages = await conv.messages({ limit: BigInt(10) as any });
189
+ const cutoffMs = new Date(afterTimestamp).getTime();
190
+ const excluded = new Set(excludeAddresses.map((a) => a.toLowerCase()));
191
+ return messages.some((m: any) => {
192
+ const sentMs = Number(BigInt(m.sentAtNs) / 1_000_000n);
193
+ const sender = (m.senderInboxId ?? m.senderAddress ?? "").toLowerCase();
194
+ return sentMs > cutoffMs && !excluded.has(sender);
195
+ });
196
+ } catch {
197
+ return false;
198
+ }
103
199
  }
104
200
 
105
201
  async canMessage(address: string): Promise<boolean> {
106
202
  if (!this.agent) return false;
107
203
  const result = await this.agent.client.canMessage([
108
- { identifier: address, identifierKind: IdentifierKind.Ethereum },
204
+ { identifier: address, identifierKind: IDENTIFIER_KIND_ETHEREUM },
109
205
  ]);
110
206
  return result.get(address.toLowerCase()) ?? false;
111
207
  }
@@ -130,12 +226,24 @@ export class XmtpBridge {
130
226
 
131
227
  this.policyEngine.recordTurn(ctx.conversation.id, depth + 1);
132
228
 
229
+ // 群聊时获取参与者列表
230
+ let participants: string[] = [];
231
+ const isGroup = ctx.conversation.isGroup ?? false;
232
+ if (isGroup) {
233
+ try {
234
+ const members = await ctx.conversation.members();
235
+ participants = members.map((m: any) =>
236
+ m.accountAddresses?.[0]?.toLowerCase() ?? m.inboxId,
237
+ );
238
+ } catch { /* 获取成员失败时保持空数组 */ }
239
+ }
240
+
133
241
  const payload = createA2APayload({
134
242
  fromAgentId: senderAgentId,
135
243
  fromAddress: senderAddress,
136
244
  conversationId: ctx.conversation.id,
137
- isGroup: ctx.conversation.isGroup ?? false,
138
- participants: [],
245
+ isGroup,
246
+ participants,
139
247
  messageId: ctx.message.id ?? crypto.randomUUID(),
140
248
  content: String(ctx.message.content),
141
249
  contentType,
@@ -148,15 +256,16 @@ export class XmtpBridge {
148
256
  id: payload.message.id,
149
257
  from: { agentId: senderAgentId, xmtpAddress: senderAddress },
150
258
  conversationId: ctx.conversation.id,
259
+ isGroup,
151
260
  content: String(ctx.message.content),
152
261
  contentType,
153
262
  timestamp: payload.timestamp,
154
263
  });
155
264
  if (this.inboxBuffer.length > this.maxInboxBuffer) this.inboxBuffer.pop();
156
265
 
157
- // 通知 plugin
266
+ // 通知 plugin 层(传递结构化 payload,由调用方决定如何处理)
158
267
  if (this.onMessage) {
159
- this.onMessage(this.agentId, formatA2AMessage(payload));
268
+ this.onMessage(this.agentId, payload);
160
269
  }
161
270
  }
162
271