a2a-xmtp 1.4.2 → 1.4.4

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
@@ -17,37 +17,7 @@ Decentralized Agent-to-Agent E2EE messaging for [OpenClaw](https://openclaw.ai)
17
17
  openclaw plugins install a2a-xmtp
18
18
  ```
19
19
 
20
- ## Configure
21
-
22
- Edit `~/.openclaw/openclaw.json`:
23
-
24
- ```json
25
- {
26
- "tools": {
27
- "profile": "full"
28
- },
29
- "plugins": {
30
- "entries": {
31
- "a2a-xmtp": {
32
- "enabled": true,
33
- "config": {
34
- "xmtp": {
35
- "env": "dev",
36
- "dbPath": "~/.openclaw/xmtp-data"
37
- }
38
- }
39
- }
40
- }
41
- }
42
- }
43
- ```
44
-
45
- Then restart:
46
-
47
- ```bash
48
- mkdir -p ~/.openclaw/xmtp-data
49
- openclaw gateway restart
50
- ```
20
+ That's it. The plugin auto-generates an XMTP wallet, creates data directories, and starts with sensible defaults.
51
21
 
52
22
  ## Verify
53
23
 
@@ -2,6 +2,7 @@
2
2
  "id": "a2a-xmtp",
3
3
  "name": "Agent-to-Agent IM (XMTP)",
4
4
  "description": "Decentralized Agent-to-Agent E2EE messaging powered by XMTP protocol",
5
+ "enabledByDefault": true,
5
6
  "configSchema": {
6
7
  "type": "object",
7
8
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2a-xmtp",
3
- "version": "1.4.2",
3
+ "version": "1.4.4",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "description": "Decentralized Agent-to-Agent E2EE messaging for OpenClaw via XMTP",
@@ -66,6 +66,14 @@ export class MessageOrchestrator {
66
66
  }
67
67
  }
68
68
 
69
+ /**
70
+ * 群聊 self 消息计数(由 bridge.onSelfGroupMessage 触发)
71
+ * 保持 messageCount 与其他 agent 同步。
72
+ */
73
+ handleSelfGroupMessage(conversationId: string): void {
74
+ this.groupScheduler.recordMessage(conversationId);
75
+ }
76
+
69
77
  /**
70
78
  * 处理收到的 XMTP 消息:群聊 round-robin 调度 → LLM 推理 → 安全校验 → 回复
71
79
  */
@@ -100,43 +108,49 @@ export class MessageOrchestrator {
100
108
  const result = await this.subagentApi.waitForRun({ runId, timeoutMs: 60000 });
101
109
  if (result.status === "error") {
102
110
  this.logger.error(`[a2a-xmtp] Subagent error: ${result.error}`);
103
- } else if (result.status === "timeout") {
104
- this.logger.warn(`[a2a-xmtp] Subagent timeout for ${sessionKey}`);
111
+ return;
112
+ }
113
+ if (result.status === "timeout") {
114
+ this.logger.warn(`[a2a-xmtp] Subagent timeout for ${sessionKey}, checking for late reply...`);
105
115
  }
106
116
 
107
- if (result.status === "ok") {
108
- const { messages } = await this.subagentApi.getSessionMessages({
109
- sessionKey,
110
- limit: 5,
111
- });
117
+ // timeout LLM 可能已经返回了内容(如限流重试后成功),仍尝试提取回复
118
+ const { messages } = await this.subagentApi.getSessionMessages({
119
+ sessionKey,
120
+ limit: 5,
121
+ });
112
122
 
113
- // 安全检查:白名单机制
114
- if (this.hasForbiddenToolCalls(messages)) {
115
- this.logger.warn(
116
- `[a2a-xmtp] SECURITY: Blocked reply — LLM called non-whitelisted tool, triggered by XMTP message from ${senderLabel}. Possible prompt injection.`,
117
- );
118
- return;
119
- }
123
+ // 安全检查:白名单机制
124
+ if (this.hasForbiddenToolCalls(messages)) {
125
+ this.logger.warn(
126
+ `[a2a-xmtp] SECURITY: Blocked reply — LLM called non-whitelisted tool, triggered by XMTP message from ${senderLabel}. Possible prompt injection.`,
127
+ );
128
+ return;
129
+ }
120
130
 
121
- // 提取回复文本
122
- const replyText = this.extractReplyText(messages);
123
- if (!replyText) return;
124
-
125
- // recordTurn 由 bridge.sendMessage 内部调用,此处不重复
126
- await bridge.sendMessage(payload.from.xmtpAddress, replyText, {
127
- conversationId: payload.conversation.id,
128
- });
129
-
130
- // 群聊:计入自己的消息 + 清除 claim
131
- if (payload.conversation.isGroup) {
132
- // 自己发的消息 handleIncoming 不会收到(跳过 self),
133
- // 手动递增以保持与其他 agent 的 messageCount 同步
134
- this.groupScheduler.recordMessage(payload.conversation.id);
135
- this.groupScheduler.clearClaim(payload.conversation.id);
136
- }
131
+ // 提取回复文本
132
+ const replyText = this.extractReplyText(messages);
133
+ if (!replyText) return;
137
134
 
138
- this.logger.info(`[a2a-xmtp] Replied to ${senderLabel} in ${payload.conversation.id}`);
135
+ // 发送前最终检查 turn budget(收窄 race window)
136
+ if (payload.conversation.isGroup && this.policyEngine.isTurnExhausted(payload.conversation.id)) {
137
+ this.logger.info(
138
+ `[a2a-xmtp] Turn budget exhausted before send in ${payload.conversation.id}, dropping reply`,
139
+ );
140
+ return;
139
141
  }
142
+
143
+ // recordTurn 由 bridge.sendMessage 内部调用,此处不重复
144
+ await bridge.sendMessage(payload.from.xmtpAddress, replyText, {
145
+ conversationId: payload.conversation.id,
146
+ });
147
+
148
+ // 群聊:清除 claim(self 消息计数由 bridge.onSelfGroupMessage 处理)
149
+ if (payload.conversation.isGroup) {
150
+ this.groupScheduler.clearClaim(payload.conversation.id);
151
+ }
152
+
153
+ this.logger.info(`[a2a-xmtp] Replied to ${senderLabel} in ${payload.conversation.id}`);
140
154
  } catch (err) {
141
155
  this.logger.error(
142
156
  `[a2a-xmtp] Failed to trigger subagent: ${err instanceof Error ? err.message : String(err)}`,
@@ -195,7 +209,10 @@ export class MessageOrchestrator {
195
209
 
196
210
  // 发送 claim
197
211
  try {
198
- await bridge.sendClaim(convId, payload.message.id);
212
+ const claimMsgId = await bridge.sendClaim(convId, payload.message.id);
213
+ this.logger.info(
214
+ `[a2a-xmtp] Claim sent: ${claimMsgId} in ${convId}`,
215
+ );
199
216
  } catch (err) {
200
217
  this.logger.warn(
201
218
  `[a2a-xmtp] Failed to send claim: ${err instanceof Error ? err.message : String(err)}`,
@@ -216,27 +233,48 @@ export class MessageOrchestrator {
216
233
  const timedOut = await waitWithAbort(decision.timeoutMs, abortCtrl.signal);
217
234
 
218
235
  if (!timedOut) {
219
- // 被取消 = 收到了 claim 或回复 保持沉默
236
+ // 收到了 claim → 等待 claim 过期,看指定回复者是否真的发出了回复
220
237
  this.logger.info(
221
- `[a2a-xmtp] Saw claim/reply in ${convId}, staying silent`,
238
+ `[a2a-xmtp] Saw claim in ${convId}, waiting for actual reply...`,
222
239
  );
223
- return false;
224
- }
240
+ const claimExpireMs = this.groupScheduler.getConfig().claimExpireMs;
241
+ await sleep(claimExpireMs);
242
+
243
+ // claim 已被清除 = 回复已发送 → 不需要接管
244
+ if (!this.groupScheduler.hasActiveClaim(convId) && !this.groupScheduler.isClaimExpired(convId)) {
245
+ this.logger.info(
246
+ `[a2a-xmtp] Claim cleared (reply sent) in ${convId}, staying silent`,
247
+ );
248
+ return false;
249
+ }
250
+
251
+ // claim 过期且未清除 = 指定回复者失败 → failover
252
+ this.logger.warn(
253
+ `[a2a-xmtp] Claim expired without reply in ${convId}, failing over`,
254
+ );
255
+ } else {
256
+ // 超时且没有 claim → 检查是否有未过期的 claim(可能在等待期间收到)
257
+ if (this.groupScheduler.hasActiveClaim(convId)) {
258
+ this.logger.info(
259
+ `[a2a-xmtp] Active claim exists in ${convId}, staying silent`,
260
+ );
261
+ return false;
262
+ }
225
263
 
226
- // 超时且没有 claim → 检查是否有未过期的 claim(可能在等待期间收到)
227
- if (this.groupScheduler.hasActiveClaim(convId)) {
228
264
  this.logger.info(
229
- `[a2a-xmtp] Active claim exists in ${convId}, staying silent`,
265
+ `[a2a-xmtp] No claim received in ${convId}, failing over`,
230
266
  );
231
- return false;
232
267
  }
233
268
 
234
- // 接管:发送 claim 并回复
269
+ // Failover:接管回复
235
270
  this.logger.info(
236
271
  `[a2a-xmtp] Failover: taking over msg #${msgIndex} in ${convId}`,
237
272
  );
238
273
  try {
239
- await bridge.sendClaim(convId, payload.message.id);
274
+ const claimMsgId = await bridge.sendClaim(convId, payload.message.id);
275
+ this.logger.info(
276
+ `[a2a-xmtp] Failover claim sent: ${claimMsgId} in ${convId}`,
277
+ );
240
278
  } catch (err) {
241
279
  this.logger.warn(
242
280
  `[a2a-xmtp] Failed to send failover claim: ${err instanceof Error ? err.message : String(err)}`,
@@ -29,9 +29,10 @@ export class PolicyEngine {
29
29
  }
30
30
 
31
31
  /**
32
- * 入站检查:consent + TTL(不检查 turn budget cool-down
33
- * turn budget 仅在出站/调度层检查,确保 turn 耗尽后仍能接收消息、
34
- * 缓存到 inbox、维持 messageCount 同步。
32
+ * 入站检查:consent + TTL + turn budget(不检查 cool-down
33
+ * 否则刚发完消息后收到的回复会被静默丢弃)。
34
+ * 注意:turn budget 拦截不影响 messageCount 同步,因为
35
+ * self 消息通过 bridge.onSelfGroupMessage 独立计数。
35
36
  */
36
37
  checkIncoming(params: PolicyCheckParams): PolicyCheckResult {
37
38
  const consent = this.getConsent(params.from);
@@ -50,6 +51,9 @@ export class PolicyEngine {
50
51
  if (now - state.createdAt > ttlMs) {
51
52
  return { allowed: false, reason: `Conversation TTL expired (${this.policy.ttlMinutes} min)` };
52
53
  }
54
+ if (state.turn >= this.policy.maxTurns) {
55
+ return { allowed: false, reason: `Turn budget exhausted (${state.turn}/${this.policy.maxTurns})` };
56
+ }
53
57
  return { allowed: true };
54
58
  }
55
59
 
package/src/index.ts CHANGED
@@ -5,6 +5,8 @@
5
5
 
6
6
  import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
7
7
  import { Type } from "@sinclair/typebox";
8
+ import { mkdirSync } from "node:fs";
9
+ import { join } from "node:path";
8
10
  import { IdentityRegistry } from "./transport/identity-registry.js";
9
11
  import { PolicyEngine } from "./coordination/policy-engine.js";
10
12
  import { GroupScheduler } from "./coordination/group-scheduler.js";
@@ -131,12 +133,13 @@ export default definePluginEntry({
131
133
  async start(ctx) {
132
134
  ctx.logger.info("[a2a-xmtp] Starting XMTP Bridge Service...");
133
135
 
134
- // 加载配置
136
+ // 加载配置(所有选项均有默认值,用户无需手动编辑配置文件)
135
137
  const pluginCfg = (ctx.config as any)?.plugins?.entries?.["a2a-xmtp"]?.config;
138
+ const defaultDbPath = join(ctx.stateDir, "xmtp-data");
136
139
  const config: PluginConfig = {
137
140
  xmtp: {
138
141
  env: pluginCfg?.xmtp?.env ?? DEFAULT_PLUGIN_CONFIG.xmtp.env,
139
- dbPath: pluginCfg?.xmtp?.dbPath ?? DEFAULT_PLUGIN_CONFIG.xmtp.dbPath,
142
+ dbPath: pluginCfg?.xmtp?.dbPath ?? defaultDbPath,
140
143
  },
141
144
  policy: {
142
145
  maxTurns: pluginCfg?.policy?.maxTurns ?? DEFAULT_PLUGIN_CONFIG.policy.maxTurns,
@@ -152,6 +155,9 @@ export default definePluginEntry({
152
155
  walletKey: pluginCfg?.walletKey,
153
156
  };
154
157
 
158
+ // 自动创建数据目录
159
+ mkdirSync(config.xmtp.dbPath, { recursive: true });
160
+
155
161
  // 初始化核心组件
156
162
  registry = new IdentityRegistry(ctx.stateDir, config.xmtp.env);
157
163
  policyEngine = new PolicyEngine(config.policy);
@@ -186,6 +192,10 @@ export default definePluginEntry({
186
192
  bridge.onClaim = (conversationId, senderAddress, messageId) =>
187
193
  orchestrator.handleClaim(conversationId, senderAddress, messageId);
188
194
 
195
+ // 群聊 self 消息计数回调:保持 messageCount 同步
196
+ bridge.onSelfGroupMessage = (conversationId) =>
197
+ orchestrator.handleSelfGroupMessage(conversationId);
198
+
189
199
  await bridge.start();
190
200
  bridges.set(AGENT_ID, bridge);
191
201
 
@@ -28,6 +28,9 @@ export class XmtpBridge {
28
28
  /** Claim 消息回调(由 orchestrator 设置,用于群聊调度) */
29
29
  onClaim?: (conversationId: string, senderAddress: string, messageId: string) => void;
30
30
 
31
+ /** 群聊 self 消息计数回调(由 orchestrator 设置,用于 round-robin 同步) */
32
+ onSelfGroupMessage?: (conversationId: string) => void;
33
+
31
34
  constructor(
32
35
  readonly agentId: string,
33
36
  private walletConfig: XmtpWalletConfig,
@@ -106,12 +109,13 @@ export class XmtpBridge {
106
109
  /**
107
110
  * 发送 claim 消息到群聊,表明本 agent 将要回复。
108
111
  */
109
- async sendClaim(conversationId: string, messageId: string): Promise<void> {
112
+ async sendClaim(conversationId: string, messageId: string): Promise<string> {
110
113
  if (!this.agent) throw new Error(`Bridge for ${this.agentId} not started`);
111
114
  const conv = await this.agent.client.conversations.getConversationById(conversationId);
112
115
  if (!conv) throw new Error(`Conversation ${conversationId} not found`);
113
116
  const claimText = GroupScheduler.formatClaimMessage(messageId);
114
- await conv.sendText(claimText);
117
+ const claimMsgId = await conv.sendText(claimText);
118
+ return String(claimMsgId);
115
119
  }
116
120
 
117
121
  async getInbox(opts?: { limit?: number; from?: string }): Promise<InboxMessage[]> {
@@ -232,9 +236,7 @@ export class XmtpBridge {
232
236
 
233
237
  private async handleIncoming(ctx: any, contentType: A2AContentType): Promise<void> {
234
238
  const senderAddress: string = await ctx.getSenderAddress();
235
-
236
- if (senderAddress.toLowerCase() === this.address.toLowerCase()) return;
237
-
239
+ const isSelf = senderAddress.toLowerCase() === this.address.toLowerCase();
238
240
  const content = String(ctx.message.content);
239
241
 
240
242
  // 识别 claim 消息:不计数、不缓存、通知调度器
@@ -246,6 +248,15 @@ export class XmtpBridge {
246
248
  return;
247
249
  }
248
250
 
251
+ // 群聊 self 消息:仅计数(保持 messageCount 同步),不触发响应
252
+ const isGroup = ctx.conversation.isGroup ?? false;
253
+ if (isSelf) {
254
+ if (isGroup && this.onSelfGroupMessage) {
255
+ this.onSelfGroupMessage(ctx.conversation.id);
256
+ }
257
+ return;
258
+ }
259
+
249
260
  const senderAgentId = await this.registry.resolveAgentId(senderAddress);
250
261
 
251
262
  const policyResult = this.policyEngine.checkIncoming({
@@ -261,7 +272,6 @@ export class XmtpBridge {
261
272
  // 群聊时获取参与者列表(含角色信息)
262
273
  let participants: string[] = [];
263
274
  let participantDetails: GroupParticipant[] | undefined;
264
- const isGroup = ctx.conversation.isGroup ?? false;
265
275
  if (isGroup) {
266
276
  try {
267
277
  const members = await ctx.conversation.members();