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 +1 -31
- package/openclaw.plugin.json +1 -0
- package/package.json +1 -1
- package/src/coordination/message-orchestrator.ts +80 -42
- package/src/coordination/policy-engine.ts +7 -3
- package/src/index.ts +12 -2
- package/src/transport/xmtp-bridge.ts +16 -6
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
|
-
|
|
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
|
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
117
|
+
// timeout 时 LLM 可能已经返回了内容(如限流重试后成功),仍尝试提取回复
|
|
118
|
+
const { messages } = await this.subagentApi.getSessionMessages({
|
|
119
|
+
sessionKey,
|
|
120
|
+
limit: 5,
|
|
121
|
+
});
|
|
112
122
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
236
|
+
// 收到了 claim → 等待 claim 过期,看指定回复者是否真的发出了回复
|
|
220
237
|
this.logger.info(
|
|
221
|
-
`[a2a-xmtp] Saw claim
|
|
238
|
+
`[a2a-xmtp] Saw claim in ${convId}, waiting for actual reply...`,
|
|
222
239
|
);
|
|
223
|
-
|
|
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]
|
|
265
|
+
`[a2a-xmtp] No claim received in ${convId}, failing over`,
|
|
230
266
|
);
|
|
231
|
-
return false;
|
|
232
267
|
}
|
|
233
268
|
|
|
234
|
-
//
|
|
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
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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 ??
|
|
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<
|
|
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();
|