a2a-xmtp 1.3.1 → 1.4.1

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
@@ -7,7 +7,7 @@ Decentralized Agent-to-Agent E2EE messaging for [OpenClaw](https://openclaw.ai)
7
7
  - **E2EE Messaging** — End-to-end encrypted Agent-to-Agent communication over XMTP
8
8
  - **Group Chat** — Create and manage multi-agent group conversations with natural turn-taking
9
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
10
+ - **Anti-Loop** — 3-layer protection (turn budget, cool-down, consent) prevents infinite loops
11
11
  - **Cross-Gateway** — Agents on different servers communicate directly, no VPN or API sharing needed
12
12
  - **4 Agent Tools** — `xmtp_send`, `xmtp_inbox`, `xmtp_agents`, `xmtp_group`
13
13
 
@@ -138,17 +138,17 @@ This works for both DMs and group chats. In group chats, the system prompt inclu
138
138
 
139
139
  ## Multi-Agent Group Chat
140
140
 
141
- When multiple OpenClaw agents are in the same group, the plugin ensures natural turn-taking:
141
+ When multiple OpenClaw agents are in the same group, the plugin coordinates turn-taking automatically:
142
142
 
143
- - **No parallel replies** — When a human sends a message, agents use random delay + dedup check so only one responds first
144
- - **Sequential discussion** — After one agent replies, the other naturally follows, creating a back-and-forth discussion
145
- - **Natural conversation flow** — The LLM is prompted to behave like a human in group chat, only speaking when it has something to add
143
+ - **Deterministic round-robin** — Each message has exactly one designated responder, agents take turns equally
144
+ - **Automatic failover** — If the designated agent is offline, the next agent in line takes over after a timeout
145
+ - **No duplicate replies** — Claim mechanism prevents multiple agents from responding to the same message
146
146
 
147
147
  ```
148
148
  Admin: "Discuss the pros and cons of Rust vs Go"
149
- Agent A: "Rust offers memory safety without GC..." (responds first)
150
- Agent B: "I agree on safety, but Go's simplicity..." (follows up)
151
- Agent A: "Good point. For our use case though..." (continues)
149
+ Agent A: "Rust offers memory safety without GC..." (designated for msg #0)
150
+ Agent B: "I agree on safety, but Go's simplicity..." (designated for msg #1)
151
+ Agent C: "For our use case, Go's concurrency model..." (designated for msg #2)
152
152
  ```
153
153
 
154
154
  ## Cross-Server Communication
@@ -163,16 +163,23 @@ No VPN, no API keys, no shared infrastructure needed.
163
163
 
164
164
  ## Policy Configuration
165
165
 
166
- Anti-loop protection with 4 guards:
166
+ Anti-loop protection with 3 guards:
167
167
 
168
168
  | Setting | Default | Description |
169
169
  |---------|---------|-------------|
170
- | `policy.maxTurns` | 10 | Max turns per conversation |
171
- | `policy.maxDepth` | 5 | Max reply depth |
170
+ | `policy.maxTurns` | 10 | Max messages this agent can send per conversation |
172
171
  | `policy.minIntervalMs` | 5000 | Cool-down between sends (ms) |
173
172
  | `policy.ttlMinutes` | 60 | Conversation TTL |
174
173
  | `policy.consentMode` | `auto-allow-local` | `auto-allow-local` or `explicit-only` |
175
174
 
175
+ Group chat scheduling:
176
+
177
+ | Setting | Default | Description |
178
+ |---------|---------|-------------|
179
+ | `groupScheduling.baseDelayMs` | 1000 | Delay before designated agent claims a message (ms) |
180
+ | `groupScheduling.slotTimeoutMs` | 6000 | Wait time before next agent takes over (ms) |
181
+ | `groupScheduling.claimExpireMs` | 30000 | Max wait after claim before failover (ms) |
182
+
176
183
  ## Architecture
177
184
 
178
185
  ```
@@ -184,6 +191,7 @@ Plugin Entry (index.ts)
184
191
  │ IdentityRegistry ── agentId <-> wallet mapping
185
192
  └── Coordination Layer (coordination/)
186
193
  MessageOrchestrator ── message flow, LLM subagent, security
194
+ GroupScheduler ── round-robin turn-taking, claim, failover
187
195
  PolicyEngine ── anti-loop guards, consent
188
196
  ```
189
197
 
@@ -26,7 +26,6 @@
26
26
  "additionalProperties": false,
27
27
  "properties": {
28
28
  "maxTurns": { "type": "number", "default": 10 },
29
- "maxDepth": { "type": "number", "default": 5 },
30
29
  "minIntervalMs": { "type": "number", "default": 5000 },
31
30
  "ttlMinutes": { "type": "number", "default": 60 },
32
31
  "consentMode": {
@@ -36,6 +35,15 @@
36
35
  }
37
36
  }
38
37
  },
38
+ "groupScheduling": {
39
+ "type": "object",
40
+ "additionalProperties": false,
41
+ "properties": {
42
+ "baseDelayMs": { "type": "number", "default": 1000, "description": "Base delay before designated agent claims (ms)" },
43
+ "slotTimeoutMs": { "type": "number", "default": 6000, "description": "Time to wait for previous slot's claim before promoting (ms)" },
44
+ "claimExpireMs": { "type": "number", "default": 30000, "description": "Max time after claim before failover (ms)" }
45
+ }
46
+ },
39
47
  "walletKey": {
40
48
  "type": "string",
41
49
  "description": "Optional: pre-existing XMTP wallet private key (hex). If omitted, auto-generated."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2a-xmtp",
3
- "version": "1.3.1",
3
+ "version": "1.4.1",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "description": "Decentralized Agent-to-Agent E2EE messaging for OpenClaw via XMTP",
@@ -0,0 +1,198 @@
1
+ // ============================================================
2
+ // Group Scheduler
3
+ // 固定 Round-Robin 轮询 + Claim 机制的群聊调度器
4
+ // 每个 agent 独立计算相同的发言顺序,确定性地分配回复权
5
+ // ============================================================
6
+
7
+ import { createHash } from "node:crypto";
8
+ import type { GroupSchedulingConfig, GroupConversationState } from "../types.js";
9
+ import { CLAIM_PREFIX, DEFAULT_GROUP_SCHEDULING } from "../types.js";
10
+
11
+ export type ScheduleDecision =
12
+ | { action: "respond"; delayMs: number }
13
+ | { action: "watch"; timeoutMs: number }
14
+ | { action: "skip"; reason: string };
15
+
16
+ export class GroupScheduler {
17
+ private groups = new Map<string, GroupConversationState>();
18
+
19
+ constructor(private config: GroupSchedulingConfig = DEFAULT_GROUP_SCHEDULING) {}
20
+
21
+ // ── 发言顺序计算 ──
22
+
23
+ /**
24
+ * 根据 groupId 和成员列表计算固定发言顺序。
25
+ * 所有 agent 独立计算结果一致(相同输入 → 相同输出)。
26
+ */
27
+ computeSpeakingOrder(groupId: string, members: string[]): string[] {
28
+ const sorted = [...members].map((m) => m.toLowerCase()).sort();
29
+ const seed = sha256(`${groupId}+${sorted.join(",")}`);
30
+ const scored = sorted.map((addr) => ({
31
+ addr,
32
+ score: sha256(`${addr}+${seed}`),
33
+ }));
34
+ scored.sort((a, b) => (a.score < b.score ? -1 : a.score > b.score ? 1 : 0));
35
+ return scored.map((s) => s.addr);
36
+ }
37
+
38
+ // ── 群组状态管理 ──
39
+
40
+ /**
41
+ * 初始化或获取群组状态。成员变化时自动重算。
42
+ * @param agentAddresses 已知的 agent 地址集合,仅 agent 参与轮询;
43
+ * 非 agent(如群主/人类)不参与 speaking order。
44
+ */
45
+ getOrInitGroup(groupId: string, members: string[], agentAddresses?: Set<string>): GroupConversationState {
46
+ const existing = this.groups.get(groupId);
47
+ // 仅保留 agent 地址参与轮询,非 agent 跳过
48
+ const agentMembers = agentAddresses
49
+ ? members.filter((m) => agentAddresses.has(m.toLowerCase()))
50
+ : members;
51
+ const order = this.computeSpeakingOrder(groupId, agentMembers);
52
+
53
+ // 成员变化 → 重算并重置计数
54
+ if (existing && !arraysEqual(existing.speakingOrder, order)) {
55
+ const reset: GroupConversationState = {
56
+ speakingOrder: order,
57
+ messageCount: 0,
58
+ lastClaim: null,
59
+ };
60
+ this.groups.set(groupId, reset);
61
+ return reset;
62
+ }
63
+
64
+ if (existing) return existing;
65
+
66
+ const state: GroupConversationState = {
67
+ speakingOrder: order,
68
+ messageCount: 0,
69
+ lastClaim: null,
70
+ };
71
+ this.groups.set(groupId, state);
72
+ return state;
73
+ }
74
+
75
+ getGroupState(groupId: string): GroupConversationState | null {
76
+ return this.groups.get(groupId) ?? null;
77
+ }
78
+
79
+ // ── Claim 消息识别 ──
80
+
81
+ static isClaimMessage(content: string): boolean {
82
+ return content.startsWith(CLAIM_PREFIX);
83
+ }
84
+
85
+ static formatClaimMessage(messageId: string): string {
86
+ return `${CLAIM_PREFIX}${messageId}`;
87
+ }
88
+
89
+ static parseClaimMessageId(content: string): string | null {
90
+ if (!GroupScheduler.isClaimMessage(content)) return null;
91
+ return content.slice(CLAIM_PREFIX.length);
92
+ }
93
+
94
+ // ── 消息计数 ──
95
+
96
+ /**
97
+ * 收到非 claim 消息时递增计数。返回该消息的 index。
98
+ */
99
+ recordMessage(groupId: string): number {
100
+ const state = this.groups.get(groupId);
101
+ if (!state) return 0;
102
+ const idx = state.messageCount;
103
+ state.messageCount += 1;
104
+ return idx;
105
+ }
106
+
107
+ /**
108
+ * 记录收到的 claim。
109
+ */
110
+ recordClaim(groupId: string, sender: string, messageId: string): void {
111
+ const state = this.groups.get(groupId);
112
+ if (!state) return;
113
+ state.lastClaim = { sender: sender.toLowerCase(), timestamp: Date.now(), messageId };
114
+ }
115
+
116
+ // ── 调度决策 ──
117
+
118
+ /**
119
+ * 为当前 agent 计算调度决策:应该回复、监听还是跳过。
120
+ */
121
+ decide(
122
+ groupId: string,
123
+ myAddress: string,
124
+ messageIndex: number,
125
+ ): ScheduleDecision {
126
+ const state = this.groups.get(groupId);
127
+ if (!state || state.speakingOrder.length === 0) {
128
+ return { action: "skip", reason: "Group not initialized" };
129
+ }
130
+
131
+ const order = state.speakingOrder;
132
+ const myAddr = myAddress.toLowerCase();
133
+ const mySlot = order.indexOf(myAddr);
134
+
135
+ if (mySlot === -1) {
136
+ return { action: "skip", reason: "Not a member of speaking order" };
137
+ }
138
+
139
+ const designatedSlot = messageIndex % order.length;
140
+
141
+ if (mySlot === designatedSlot) {
142
+ // 我是指定回复者
143
+ return { action: "respond", delayMs: this.config.baseDelayMs };
144
+ }
145
+
146
+ // 计算与指定回复者的距离 → failover 超时
147
+ const distance = (mySlot - designatedSlot + order.length) % order.length;
148
+ const timeoutMs = this.config.baseDelayMs + distance * this.config.slotTimeoutMs;
149
+
150
+ return { action: "watch", timeoutMs };
151
+ }
152
+
153
+ /**
154
+ * 检查是否有未过期的 claim(某 agent 已声明要回复)。
155
+ */
156
+ hasActiveClaim(groupId: string): boolean {
157
+ const state = this.groups.get(groupId);
158
+ if (!state?.lastClaim) return false;
159
+ const elapsed = Date.now() - state.lastClaim.timestamp;
160
+ return elapsed < this.config.claimExpireMs;
161
+ }
162
+
163
+ /**
164
+ * 检查 claim 是否已过期(agent 声明了但 LLM 挂起)。
165
+ */
166
+ isClaimExpired(groupId: string): boolean {
167
+ const state = this.groups.get(groupId);
168
+ if (!state?.lastClaim) return false;
169
+ const elapsed = Date.now() - state.lastClaim.timestamp;
170
+ return elapsed >= this.config.claimExpireMs;
171
+ }
172
+
173
+ /**
174
+ * 清除 claim 状态(收到实际回复后调用)。
175
+ */
176
+ clearClaim(groupId: string): void {
177
+ const state = this.groups.get(groupId);
178
+ if (state) state.lastClaim = null;
179
+ }
180
+
181
+ getConfig(): GroupSchedulingConfig {
182
+ return { ...this.config };
183
+ }
184
+ }
185
+
186
+ // ── Helpers ──
187
+
188
+ function sha256(input: string): string {
189
+ return createHash("sha256").update(input).digest("hex");
190
+ }
191
+
192
+ function arraysEqual(a: string[], b: string[]): boolean {
193
+ if (a.length !== b.length) return false;
194
+ for (let i = 0; i < a.length; i++) {
195
+ if (a[i] !== b[i]) return false;
196
+ }
197
+ return true;
198
+ }
@@ -1,11 +1,13 @@
1
1
  // ============================================================
2
2
  // Message Orchestrator
3
- // index.ts 提取的消息回调逻辑:群聊防抢答、LLM subagent
4
- // 调用、安全校验、回复提取与发送
3
+ // 群聊 round-robin 调度、LLM subagent 调用、安全校验、回复提取与发送
5
4
  // ============================================================
6
5
 
7
6
  import type { XmtpBridge } from "../transport/xmtp-bridge.js";
7
+ import type { IdentityRegistry } from "../transport/identity-registry.js";
8
8
  import { formatA2AMessage, type A2AInjectPayload } from "../types.js";
9
+ import { GroupScheduler } from "./group-scheduler.js";
10
+ import type { PolicyEngine } from "./policy-engine.js";
9
11
 
10
12
  /** OpenClaw subagent runtime 接口(由 api.runtime.subagent 提供) */
11
13
  export interface SubagentAPI {
@@ -39,13 +41,35 @@ export interface Logger {
39
41
  const ALLOWED_TOOLS = new Set(["web_search"]);
40
42
 
41
43
  export class MessageOrchestrator {
44
+ private groupScheduler: GroupScheduler;
45
+ /** 每个会话的 pending watch(failover 等待),用于取消 */
46
+ private pendingWatches = new Map<string, AbortController>();
47
+
42
48
  constructor(
43
49
  private subagentApi: SubagentAPI,
44
50
  private logger: Logger,
45
- ) {}
51
+ private policyEngine: PolicyEngine,
52
+ groupScheduler: GroupScheduler,
53
+ private registry: IdentityRegistry,
54
+ ) {
55
+ this.groupScheduler = groupScheduler;
56
+ }
57
+
58
+ /**
59
+ * 处理 claim 消息回调(由 bridge.onClaim 触发)
60
+ */
61
+ handleClaim(conversationId: string, senderAddress: string, messageId: string): void {
62
+ this.groupScheduler.recordClaim(conversationId, senderAddress, messageId);
63
+ // 取消该会话的 pending watch(指定回复者已 claim)
64
+ const ctrl = this.pendingWatches.get(conversationId);
65
+ if (ctrl) {
66
+ ctrl.abort();
67
+ this.pendingWatches.delete(conversationId);
68
+ }
69
+ }
46
70
 
47
71
  /**
48
- * 处理收到的 XMTP 消息:群聊防抢答 → LLM 推理 → 安全校验 → 回复
72
+ * 处理收到的 XMTP 消息:群聊 round-robin 调度 → LLM 推理 → 安全校验 → 回复
49
73
  */
50
74
  async handleMessage(
51
75
  bridge: XmtpBridge,
@@ -56,26 +80,13 @@ export class MessageOrchestrator {
56
80
  const formattedMsg = formatA2AMessage(payload);
57
81
  const senderLabel = payload.from.agentId || payload.from.xmtpAddress;
58
82
 
59
- // ── 群聊防抢答:随机延迟 + 发送前检查 ──
60
- if (payload.conversation.isGroup) {
61
- const delay = 3000 + Math.random() * 5000; // 3-8 秒随机延迟
62
- this.logger.info(
63
- `[a2a-xmtp] Group message from ${senderLabel}, waiting ${Math.round(delay)}ms before responding...`,
64
- );
65
- await new Promise((r) => setTimeout(r, delay));
66
-
67
- // 检查延迟期间是否已有其他人回复
68
- const alreadyReplied = await bridge.hasNewGroupReplies(
69
- payload.conversation.id,
70
- payload.timestamp,
71
- [bridge.address, payload.from.xmtpAddress],
83
+ // ── 群聊 round-robin 调度 ──
84
+ if (payload.conversation.isGroup && payload.conversation.participants.length > 0) {
85
+ const shouldRespond = await this.scheduleGroupResponse(
86
+ bridge,
87
+ payload,
72
88
  );
73
- if (alreadyReplied) {
74
- this.logger.info(
75
- `[a2a-xmtp] Skipping reply — another agent already responded in ${payload.conversation.id}`,
76
- );
77
- return;
78
- }
89
+ if (!shouldRespond) return;
79
90
  }
80
91
 
81
92
  const extraSystemPrompt = this.buildSystemPrompt(payload, senderLabel);
@@ -113,24 +124,19 @@ export class MessageOrchestrator {
113
124
  const replyText = this.extractReplyText(messages);
114
125
  if (!replyText) return;
115
126
 
116
- // 群聊:发送前再次检查竞态
117
- if (payload.conversation.isGroup) {
118
- const raceCheck = await bridge.hasNewGroupReplies(
119
- payload.conversation.id,
120
- payload.timestamp,
121
- [bridge.address, payload.from.xmtpAddress],
122
- );
123
- if (raceCheck) {
124
- this.logger.info(
125
- `[a2a-xmtp] Skipping reply (post-LLM check) — another agent responded in ${payload.conversation.id}`,
126
- );
127
- return;
128
- }
129
- }
130
-
127
+ // recordTurn 由 bridge.sendMessage 内部调用,此处不重复
131
128
  await bridge.sendMessage(payload.from.xmtpAddress, replyText, {
132
129
  conversationId: payload.conversation.id,
133
130
  });
131
+
132
+ // 群聊:计入自己的消息 + 清除 claim
133
+ if (payload.conversation.isGroup) {
134
+ // 自己发的消息 handleIncoming 不会收到(跳过 self),
135
+ // 手动递增以保持与其他 agent 的 messageCount 同步
136
+ this.groupScheduler.recordMessage(payload.conversation.id);
137
+ this.groupScheduler.clearClaim(payload.conversation.id);
138
+ }
139
+
134
140
  this.logger.info(`[a2a-xmtp] Replied to ${senderLabel} in ${payload.conversation.id}`);
135
141
  }
136
142
  } catch (err) {
@@ -140,6 +146,113 @@ export class MessageOrchestrator {
140
146
  }
141
147
  }
142
148
 
149
+ /**
150
+ * 群聊调度:根据 round-robin 决定是否应该回复。
151
+ * 返回 true 表示应该回复,false 表示让其他 agent 处理。
152
+ */
153
+ private async scheduleGroupResponse(
154
+ bridge: XmtpBridge,
155
+ payload: A2AInjectPayload,
156
+ ): Promise<boolean> {
157
+ const convId = payload.conversation.id;
158
+ const members = payload.conversation.participants;
159
+ const myAddress = bridge.address;
160
+
161
+ // 筛选出 agent 地址:自己 + registry 中已知的 agent
162
+ const agentAddresses = new Set<string>();
163
+ agentAddresses.add(myAddress.toLowerCase());
164
+ for (const addr of members) {
165
+ if (this.registry.getAgentId(addr)) {
166
+ agentAddresses.add(addr.toLowerCase());
167
+ }
168
+ }
169
+
170
+ // 初始化群组状态(仅 agent 参与轮询,人类/admin 不参与)
171
+ this.groupScheduler.getOrInitGroup(convId, members, agentAddresses);
172
+
173
+ // 检查 turn budget 是否耗尽
174
+ if (this.policyEngine.isTurnExhausted(convId)) {
175
+ this.logger.info(
176
+ `[a2a-xmtp] Turn budget exhausted for ${convId}, skipping`,
177
+ );
178
+ return false;
179
+ }
180
+
181
+ // 记录消息并获取 index
182
+ const msgIndex = this.groupScheduler.recordMessage(convId);
183
+
184
+ // 计算调度决策
185
+ const decision = this.groupScheduler.decide(convId, myAddress, msgIndex);
186
+
187
+ if (decision.action === "skip") {
188
+ this.logger.info(
189
+ `[a2a-xmtp] Skipping group message: ${decision.reason}`,
190
+ );
191
+ return false;
192
+ }
193
+
194
+ if (decision.action === "respond") {
195
+ // 我是指定回复者:等待 baseDelay 后发送 claim
196
+ this.logger.info(
197
+ `[a2a-xmtp] I am designated responder for msg #${msgIndex} in ${convId}`,
198
+ );
199
+ await sleep(decision.delayMs);
200
+
201
+ // 发送 claim
202
+ try {
203
+ await bridge.sendClaim(convId, payload.message.id);
204
+ } catch (err) {
205
+ this.logger.warn(
206
+ `[a2a-xmtp] Failed to send claim: ${err instanceof Error ? err.message : String(err)}`,
207
+ );
208
+ }
209
+ return true;
210
+ }
211
+
212
+ // decision.action === "watch": 等待指定回复者的 claim 或回复
213
+ this.logger.info(
214
+ `[a2a-xmtp] Watching for claim/reply in ${convId}, timeout ${decision.timeoutMs}ms`,
215
+ );
216
+
217
+ const abortCtrl = new AbortController();
218
+ this.pendingWatches.set(convId, abortCtrl);
219
+
220
+ try {
221
+ const timedOut = await waitWithAbort(decision.timeoutMs, abortCtrl.signal);
222
+
223
+ if (!timedOut) {
224
+ // 被取消 = 收到了 claim 或回复 → 保持沉默
225
+ this.logger.info(
226
+ `[a2a-xmtp] Saw claim/reply in ${convId}, staying silent`,
227
+ );
228
+ return false;
229
+ }
230
+
231
+ // 超时且没有 claim → 检查是否有未过期的 claim(可能在等待期间收到)
232
+ if (this.groupScheduler.hasActiveClaim(convId)) {
233
+ this.logger.info(
234
+ `[a2a-xmtp] Active claim exists in ${convId}, staying silent`,
235
+ );
236
+ return false;
237
+ }
238
+
239
+ // 接管:发送 claim 并回复
240
+ this.logger.info(
241
+ `[a2a-xmtp] Failover: taking over msg #${msgIndex} in ${convId}`,
242
+ );
243
+ try {
244
+ await bridge.sendClaim(convId, payload.message.id);
245
+ } catch (err) {
246
+ this.logger.warn(
247
+ `[a2a-xmtp] Failed to send failover claim: ${err instanceof Error ? err.message : String(err)}`,
248
+ );
249
+ }
250
+ return true;
251
+ } finally {
252
+ this.pendingWatches.delete(convId);
253
+ }
254
+ }
255
+
143
256
  /**
144
257
  * 构建安全约束的系统提示词
145
258
  */
@@ -207,3 +320,31 @@ export class MessageOrchestrator {
207
320
  return replyText.trim() || null;
208
321
  }
209
322
  }
323
+
324
+ // ── Helpers ──
325
+
326
+ function sleep(ms: number): Promise<void> {
327
+ return new Promise((r) => setTimeout(r, ms));
328
+ }
329
+
330
+ /**
331
+ * 等待指定时间,可被 AbortSignal 取消。
332
+ * 返回 true = 超时(自然结束),false = 被取消。
333
+ */
334
+ function waitWithAbort(ms: number, signal: AbortSignal): Promise<boolean> {
335
+ return new Promise((resolve) => {
336
+ if (signal.aborted) {
337
+ resolve(false);
338
+ return;
339
+ }
340
+ const timer = setTimeout(() => {
341
+ signal.removeEventListener("abort", onAbort);
342
+ resolve(true);
343
+ }, ms);
344
+ function onAbort() {
345
+ clearTimeout(timer);
346
+ resolve(false);
347
+ }
348
+ signal.addEventListener("abort", onAbort, { once: true });
349
+ });
350
+ }
@@ -1,6 +1,7 @@
1
1
  // ============================================================
2
2
  // Module 5: Policy Engine
3
- // 四重保护:Turn Budget / Cool-down / Depth Guard / Consent
3
+ // 三重保护:Turn Budget / Cool-down / Consent
4
+ // Depth Guard 已移除,由 GroupScheduler round-robin + turn budget 取代
4
5
  // ============================================================
5
6
 
6
7
  import type { ConversationPolicy, ConversationState, ConsentState } from "../types.js";
@@ -9,7 +10,6 @@ export interface PolicyCheckParams {
9
10
  from: string;
10
11
  to: string;
11
12
  conversationId: string;
12
- depth?: number;
13
13
  }
14
14
 
15
15
  export interface PolicyCheckResult {
@@ -28,10 +28,10 @@ export class PolicyEngine {
28
28
  this.localAgentIds.add(agentId);
29
29
  }
30
30
 
31
- checkOutgoing(params: PolicyCheckParams): PolicyCheckResult {
32
- return this.check(params);
33
- }
34
-
31
+ /**
32
+ * 入站检查:consent + TTL + turn budget(不检查 cool-down,
33
+ * 否则刚发完消息后收到的回复会被静默丢弃)
34
+ */
35
35
  checkIncoming(params: PolicyCheckParams): PolicyCheckResult {
36
36
  const consent = this.getConsent(params.from);
37
37
  if (consent === "deny") {
@@ -43,10 +43,23 @@ export class PolicyEngine {
43
43
  reason: `Sender ${params.from} not explicitly allowed (consent: ${consent})`,
44
44
  };
45
45
  }
46
- return this.check(params);
46
+ // 仅检查 TTL + turn budget,不检查 cool-down
47
+ const state = this.getOrCreateState(params.conversationId);
48
+ const now = Date.now();
49
+ const ttlMs = this.policy.ttlMinutes * 60 * 1000;
50
+ if (now - state.createdAt > ttlMs) {
51
+ return { allowed: false, reason: `Conversation TTL expired (${this.policy.ttlMinutes} min)` };
52
+ }
53
+ if (state.turn >= this.policy.maxTurns) {
54
+ return { allowed: false, reason: `Turn budget exhausted (${state.turn}/${this.policy.maxTurns})` };
55
+ }
56
+ return { allowed: true };
47
57
  }
48
58
 
49
- private check(params: PolicyCheckParams): PolicyCheckResult {
59
+ /**
60
+ * 出站检查:TTL + turn budget + cool-down
61
+ */
62
+ checkOutgoing(params: PolicyCheckParams): PolicyCheckResult {
50
63
  const state = this.getOrCreateState(params.conversationId);
51
64
  const now = Date.now();
52
65
 
@@ -63,21 +76,20 @@ export class PolicyEngine {
63
76
  return { allowed: false, reason: `Cool-down active (${this.policy.minIntervalMs}ms between sends)` };
64
77
  }
65
78
 
66
- const depth = params.depth ?? state.depth;
67
- if (depth >= this.policy.maxDepth) {
68
- return { allowed: false, reason: `Depth limit reached (${depth}/${this.policy.maxDepth})` };
69
- }
70
-
71
79
  return { allowed: true };
72
80
  }
73
81
 
74
- recordTurn(conversationId: string, depth?: number): void {
82
+ recordTurn(conversationId: string): void {
75
83
  const state = this.getOrCreateState(conversationId);
76
84
  state.turn += 1;
77
85
  state.lastSendTime = Date.now();
78
- if (depth !== undefined) {
79
- state.depth = Math.max(state.depth, depth);
80
- }
86
+ }
87
+
88
+ /** 检查此 agent 在该会话中的 turn 是否已用完 */
89
+ isTurnExhausted(conversationId: string): boolean {
90
+ const state = this.conversations.get(conversationId);
91
+ if (!state) return false;
92
+ return state.turn >= this.policy.maxTurns;
81
93
  }
82
94
 
83
95
  getConversationState(conversationId: string): ConversationState | null {
@@ -113,7 +125,7 @@ export class PolicyEngine {
113
125
  private getOrCreateState(conversationId: string): ConversationState {
114
126
  let state = this.conversations.get(conversationId);
115
127
  if (!state) {
116
- state = { turn: 0, depth: 0, lastSendTime: 0, createdAt: Date.now() };
128
+ state = { turn: 0, lastSendTime: 0, createdAt: Date.now() };
117
129
  this.conversations.set(conversationId, state);
118
130
  }
119
131
  return state;
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
7
7
  import { Type } from "@sinclair/typebox";
8
8
  import { IdentityRegistry } from "./transport/identity-registry.js";
9
9
  import { PolicyEngine } from "./coordination/policy-engine.js";
10
+ import { GroupScheduler } from "./coordination/group-scheduler.js";
10
11
  import { MessageOrchestrator } from "./coordination/message-orchestrator.js";
11
12
  import { XmtpBridge } from "./transport/xmtp-bridge.js";
12
13
  import { handleXmtpSend } from "./tools/xmtp-send.js";
@@ -139,11 +140,15 @@ export default definePluginEntry({
139
140
  },
140
141
  policy: {
141
142
  maxTurns: pluginCfg?.policy?.maxTurns ?? DEFAULT_PLUGIN_CONFIG.policy.maxTurns,
142
- maxDepth: pluginCfg?.policy?.maxDepth ?? DEFAULT_PLUGIN_CONFIG.policy.maxDepth,
143
143
  minIntervalMs: pluginCfg?.policy?.minIntervalMs ?? DEFAULT_PLUGIN_CONFIG.policy.minIntervalMs,
144
144
  ttlMinutes: pluginCfg?.policy?.ttlMinutes ?? DEFAULT_PLUGIN_CONFIG.policy.ttlMinutes,
145
145
  consentMode: pluginCfg?.policy?.consentMode ?? DEFAULT_PLUGIN_CONFIG.policy.consentMode,
146
146
  },
147
+ groupScheduling: {
148
+ baseDelayMs: pluginCfg?.groupScheduling?.baseDelayMs ?? DEFAULT_PLUGIN_CONFIG.groupScheduling.baseDelayMs,
149
+ slotTimeoutMs: pluginCfg?.groupScheduling?.slotTimeoutMs ?? DEFAULT_PLUGIN_CONFIG.groupScheduling.slotTimeoutMs,
150
+ claimExpireMs: pluginCfg?.groupScheduling?.claimExpireMs ?? DEFAULT_PLUGIN_CONFIG.groupScheduling.claimExpireMs,
151
+ },
147
152
  walletKey: pluginCfg?.walletKey,
148
153
  };
149
154
 
@@ -151,8 +156,15 @@ export default definePluginEntry({
151
156
  registry = new IdentityRegistry(ctx.stateDir, config.xmtp.env);
152
157
  policyEngine = new PolicyEngine(config.policy);
153
158
 
154
- // 初始化消息编排器
155
- const orchestrator = new MessageOrchestrator(api.runtime.subagent, ctx.logger);
159
+ // 初始化群聊调度器和消息编排器
160
+ const groupScheduler = new GroupScheduler(config.groupScheduling);
161
+ const orchestrator = new MessageOrchestrator(
162
+ api.runtime.subagent,
163
+ ctx.logger,
164
+ policyEngine,
165
+ groupScheduler,
166
+ registry,
167
+ );
156
168
 
157
169
  // 初始化主 Agent 的 XMTP Bridge
158
170
  try {
@@ -171,6 +183,10 @@ export default definePluginEntry({
171
183
  bridge.onMessage = (agentId, payload) =>
172
184
  orchestrator.handleMessage(bridge, agentId, payload);
173
185
 
186
+ // Claim 回调:通知 orchestrator 取消 failover 等待
187
+ bridge.onClaim = (conversationId, senderAddress, messageId) =>
188
+ orchestrator.handleClaim(conversationId, senderAddress, messageId);
189
+
174
190
  await bridge.start();
175
191
  bridges.set(AGENT_ID, bridge);
176
192
 
@@ -7,7 +7,8 @@ import { Agent, createUser, createSigner } from "@xmtp/agent-sdk";
7
7
  import type { IdentityRegistry } from "./identity-registry.js";
8
8
  import { PolicyEngine } from "../coordination/policy-engine.js";
9
9
  import type { XmtpWalletConfig, InboxMessage, A2AContentType, A2AInjectPayload, GroupInfo } from "../types.js";
10
- import { createA2APayload } from "../types.js";
10
+ import { createA2APayload, CLAIM_PREFIX } from "../types.js";
11
+ import { GroupScheduler } from "../coordination/group-scheduler.js";
11
12
 
12
13
  /** IdentifierKind.Ethereum = 0 (const enum, 不能在 isolatedModules 下直接访问) */
13
14
  const IDENTIFIER_KIND_ETHEREUM = 0;
@@ -24,6 +25,9 @@ export class XmtpBridge {
24
25
  /** 消息回调(由 plugin 入口设置,用于触发 subagent 处理) */
25
26
  onMessage?: (agentId: string, payload: A2AInjectPayload) => void;
26
27
 
28
+ /** Claim 消息回调(由 orchestrator 设置,用于群聊调度) */
29
+ onClaim?: (conversationId: string, senderAddress: string, messageId: string) => void;
30
+
27
31
  constructor(
28
32
  readonly agentId: string,
29
33
  private walletConfig: XmtpWalletConfig,
@@ -99,6 +103,17 @@ export class XmtpBridge {
99
103
  return { conversationId: conversation.id, messageId: String(messageId) };
100
104
  }
101
105
 
106
+ /**
107
+ * 发送 claim 消息到群聊,表明本 agent 将要回复。
108
+ */
109
+ async sendClaim(conversationId: string, messageId: string): Promise<void> {
110
+ if (!this.agent) throw new Error(`Bridge for ${this.agentId} not started`);
111
+ const conv = await this.agent.client.conversations.getConversationById(conversationId);
112
+ if (!conv) throw new Error(`Conversation ${conversationId} not found`);
113
+ const claimText = GroupScheduler.formatClaimMessage(messageId);
114
+ await conv.sendText(claimText);
115
+ }
116
+
102
117
  async getInbox(opts?: { limit?: number; from?: string }): Promise<InboxMessage[]> {
103
118
  const limit = opts?.limit ?? 10;
104
119
  let messages = [...this.inboxBuffer];
@@ -220,6 +235,17 @@ export class XmtpBridge {
220
235
 
221
236
  if (senderAddress.toLowerCase() === this.address.toLowerCase()) return;
222
237
 
238
+ const content = String(ctx.message.content);
239
+
240
+ // 识别 claim 消息:不计数、不缓存、通知调度器
241
+ if (GroupScheduler.isClaimMessage(content)) {
242
+ const claimedMsgId = GroupScheduler.parseClaimMessageId(content);
243
+ if (this.onClaim && claimedMsgId) {
244
+ this.onClaim(ctx.conversation.id, senderAddress, claimedMsgId);
245
+ }
246
+ return;
247
+ }
248
+
223
249
  const senderAgentId = await this.registry.resolveAgentId(senderAddress);
224
250
 
225
251
  const policyResult = this.policyEngine.checkIncoming({
@@ -231,9 +257,6 @@ export class XmtpBridge {
231
257
 
232
258
  const convState = this.policyEngine.getConversationState(ctx.conversation.id);
233
259
  const turn = convState?.turn ?? 0;
234
- const depth = convState?.depth ?? 0;
235
-
236
- this.policyEngine.recordTurn(ctx.conversation.id, depth + 1);
237
260
 
238
261
  // 群聊时获取参与者列表
239
262
  let participants: string[] = [];
@@ -254,10 +277,9 @@ export class XmtpBridge {
254
277
  isGroup,
255
278
  participants,
256
279
  messageId: ctx.message.id ?? crypto.randomUUID(),
257
- content: String(ctx.message.content),
280
+ content,
258
281
  contentType,
259
282
  turn: turn + 1,
260
- depth: depth + 1,
261
283
  });
262
284
 
263
285
  // 缓存到 inbox
@@ -266,7 +288,7 @@ export class XmtpBridge {
266
288
  from: { agentId: senderAgentId, xmtpAddress: senderAddress },
267
289
  conversationId: ctx.conversation.id,
268
290
  isGroup,
269
- content: String(ctx.message.content),
291
+ content,
270
292
  contentType,
271
293
  timestamp: payload.timestamp,
272
294
  });
package/src/types.ts CHANGED
@@ -30,7 +30,6 @@ export interface A2AInjectPayload {
30
30
  content: string;
31
31
  contentType: A2AContentType;
32
32
  turn: number;
33
- depth: number;
34
33
  replyTo?: string;
35
34
  };
36
35
  timestamp: string;
@@ -41,12 +40,21 @@ export type A2AContentType = "text" | "markdown" | "transaction" | "action";
41
40
  /** 防循环策略配置 */
42
41
  export interface ConversationPolicy {
43
42
  maxTurns: number;
44
- maxDepth: number;
45
43
  minIntervalMs: number;
46
44
  ttlMinutes: number;
47
45
  consentMode: ConsentMode;
48
46
  }
49
47
 
48
+ /** 群聊调度配置 */
49
+ export interface GroupSchedulingConfig {
50
+ /** 指定回复者发送 claim 前的基础延迟。Default: 1000ms */
51
+ baseDelayMs: number;
52
+ /** 等待前一个 slot 发送 claim 的超时时间。Default: 6000ms */
53
+ slotTimeoutMs: number;
54
+ /** 看到 claim 后等待实际回复的最大时间。Default: 30000ms */
55
+ claimExpireMs: number;
56
+ }
57
+
50
58
  export type ConsentMode = "auto-allow-local" | "explicit-only";
51
59
  export type ConsentState = "allow" | "deny" | "unknown";
52
60
 
@@ -57,17 +65,37 @@ export interface PluginConfig {
57
65
  dbPath: string;
58
66
  };
59
67
  policy: ConversationPolicy;
68
+ groupScheduling: GroupSchedulingConfig;
60
69
  walletKey?: string;
61
70
  }
62
71
 
63
72
  /** 会话状态(Policy Engine 内部使用) */
64
73
  export interface ConversationState {
65
74
  turn: number;
66
- depth: number;
67
75
  lastSendTime: number;
68
76
  createdAt: number;
69
77
  }
70
78
 
79
+ /** 群聊调度状态(per-conversation) */
80
+ export interface GroupConversationState {
81
+ /** 固定发言顺序(首次计算后缓存) */
82
+ speakingOrder: string[];
83
+ /** 已收到的非 claim 消息计数 */
84
+ messageCount: number;
85
+ /** 最近一次 claim 信息 */
86
+ lastClaim: { sender: string; timestamp: number; messageId: string } | null;
87
+ }
88
+
89
+ /** Claim 消息前缀(零宽空格 + [processing]:) */
90
+ export const CLAIM_PREFIX = "\u200B[processing]:";
91
+
92
+ /** 默认群聊调度配置 */
93
+ export const DEFAULT_GROUP_SCHEDULING: GroupSchedulingConfig = {
94
+ baseDelayMs: 1000,
95
+ slotTimeoutMs: 6000,
96
+ claimExpireMs: 30000,
97
+ };
98
+
71
99
  /** 收件箱消息 */
72
100
  export interface InboxMessage {
73
101
  id: string;
@@ -93,7 +121,6 @@ export interface GroupInfo {
93
121
  /** 默认策略配置 */
94
122
  export const DEFAULT_POLICY: ConversationPolicy = {
95
123
  maxTurns: 10,
96
- maxDepth: 5,
97
124
  minIntervalMs: 5000,
98
125
  ttlMinutes: 60,
99
126
  consentMode: "auto-allow-local",
@@ -106,6 +133,7 @@ export const DEFAULT_PLUGIN_CONFIG: PluginConfig = {
106
133
  dbPath: "./xmtp-data",
107
134
  },
108
135
  policy: DEFAULT_POLICY,
136
+ groupScheduling: DEFAULT_GROUP_SCHEDULING,
109
137
  };
110
138
 
111
139
  /** 格式化 A2A 消息(注入 session 时的文本表示) */
@@ -126,7 +154,6 @@ export function createA2APayload(params: {
126
154
  content: string;
127
155
  contentType: A2AContentType;
128
156
  turn: number;
129
- depth: number;
130
157
  replyTo?: string;
131
158
  displayName?: string;
132
159
  }): A2AInjectPayload {
@@ -147,7 +174,6 @@ export function createA2APayload(params: {
147
174
  content: params.content,
148
175
  contentType: params.contentType,
149
176
  turn: params.turn,
150
- depth: params.depth,
151
177
  replyTo: params.replyTo,
152
178
  },
153
179
  timestamp: new Date().toISOString(),