a2a-xmtp 1.3.1 → 1.4.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 +19 -11
- package/openclaw.plugin.json +9 -1
- package/package.json +1 -1
- package/src/coordination/group-scheduler.ts +192 -0
- package/src/coordination/message-orchestrator.ts +166 -37
- package/src/coordination/policy-engine.ts +11 -12
- package/src/index.ts +18 -3
- package/src/transport/xmtp-bridge.ts +29 -7
- package/src/types.ts +32 -6
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** —
|
|
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
|
|
141
|
+
When multiple OpenClaw agents are in the same group, the plugin coordinates turn-taking automatically:
|
|
142
142
|
|
|
143
|
-
- **
|
|
144
|
-
- **
|
|
145
|
-
- **
|
|
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..." (
|
|
150
|
-
Agent B: "I agree on safety, but Go's simplicity..." (
|
|
151
|
-
Agent
|
|
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
|
|
166
|
+
Anti-loop protection with 3 guards:
|
|
167
167
|
|
|
168
168
|
| Setting | Default | Description |
|
|
169
169
|
|---------|---------|-------------|
|
|
170
|
-
| `policy.maxTurns` | 10 | Max
|
|
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
|
|
package/openclaw.plugin.json
CHANGED
|
@@ -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
|
@@ -0,0 +1,192 @@
|
|
|
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
|
+
*/
|
|
43
|
+
getOrInitGroup(groupId: string, members: string[]): GroupConversationState {
|
|
44
|
+
const existing = this.groups.get(groupId);
|
|
45
|
+
const order = this.computeSpeakingOrder(groupId, members);
|
|
46
|
+
|
|
47
|
+
// 成员变化 → 重算并重置计数
|
|
48
|
+
if (existing && !arraysEqual(existing.speakingOrder, order)) {
|
|
49
|
+
const reset: GroupConversationState = {
|
|
50
|
+
speakingOrder: order,
|
|
51
|
+
messageCount: 0,
|
|
52
|
+
lastClaim: null,
|
|
53
|
+
};
|
|
54
|
+
this.groups.set(groupId, reset);
|
|
55
|
+
return reset;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (existing) return existing;
|
|
59
|
+
|
|
60
|
+
const state: GroupConversationState = {
|
|
61
|
+
speakingOrder: order,
|
|
62
|
+
messageCount: 0,
|
|
63
|
+
lastClaim: null,
|
|
64
|
+
};
|
|
65
|
+
this.groups.set(groupId, state);
|
|
66
|
+
return state;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
getGroupState(groupId: string): GroupConversationState | null {
|
|
70
|
+
return this.groups.get(groupId) ?? null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── Claim 消息识别 ──
|
|
74
|
+
|
|
75
|
+
static isClaimMessage(content: string): boolean {
|
|
76
|
+
return content.startsWith(CLAIM_PREFIX);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
static formatClaimMessage(messageId: string): string {
|
|
80
|
+
return `${CLAIM_PREFIX}${messageId}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
static parseClaimMessageId(content: string): string | null {
|
|
84
|
+
if (!GroupScheduler.isClaimMessage(content)) return null;
|
|
85
|
+
return content.slice(CLAIM_PREFIX.length);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── 消息计数 ──
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 收到非 claim 消息时递增计数。返回该消息的 index。
|
|
92
|
+
*/
|
|
93
|
+
recordMessage(groupId: string): number {
|
|
94
|
+
const state = this.groups.get(groupId);
|
|
95
|
+
if (!state) return 0;
|
|
96
|
+
const idx = state.messageCount;
|
|
97
|
+
state.messageCount += 1;
|
|
98
|
+
return idx;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 记录收到的 claim。
|
|
103
|
+
*/
|
|
104
|
+
recordClaim(groupId: string, sender: string, messageId: string): void {
|
|
105
|
+
const state = this.groups.get(groupId);
|
|
106
|
+
if (!state) return;
|
|
107
|
+
state.lastClaim = { sender: sender.toLowerCase(), timestamp: Date.now(), messageId };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── 调度决策 ──
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 为当前 agent 计算调度决策:应该回复、监听还是跳过。
|
|
114
|
+
*/
|
|
115
|
+
decide(
|
|
116
|
+
groupId: string,
|
|
117
|
+
myAddress: string,
|
|
118
|
+
messageIndex: number,
|
|
119
|
+
): ScheduleDecision {
|
|
120
|
+
const state = this.groups.get(groupId);
|
|
121
|
+
if (!state || state.speakingOrder.length === 0) {
|
|
122
|
+
return { action: "skip", reason: "Group not initialized" };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const order = state.speakingOrder;
|
|
126
|
+
const myAddr = myAddress.toLowerCase();
|
|
127
|
+
const mySlot = order.indexOf(myAddr);
|
|
128
|
+
|
|
129
|
+
if (mySlot === -1) {
|
|
130
|
+
return { action: "skip", reason: "Not a member of speaking order" };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const designatedSlot = messageIndex % order.length;
|
|
134
|
+
|
|
135
|
+
if (mySlot === designatedSlot) {
|
|
136
|
+
// 我是指定回复者
|
|
137
|
+
return { action: "respond", delayMs: this.config.baseDelayMs };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 计算与指定回复者的距离 → failover 超时
|
|
141
|
+
const distance = (mySlot - designatedSlot + order.length) % order.length;
|
|
142
|
+
const timeoutMs = this.config.baseDelayMs + distance * this.config.slotTimeoutMs;
|
|
143
|
+
|
|
144
|
+
return { action: "watch", timeoutMs };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* 检查是否有未过期的 claim(某 agent 已声明要回复)。
|
|
149
|
+
*/
|
|
150
|
+
hasActiveClaim(groupId: string): boolean {
|
|
151
|
+
const state = this.groups.get(groupId);
|
|
152
|
+
if (!state?.lastClaim) return false;
|
|
153
|
+
const elapsed = Date.now() - state.lastClaim.timestamp;
|
|
154
|
+
return elapsed < this.config.claimExpireMs;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* 检查 claim 是否已过期(agent 声明了但 LLM 挂起)。
|
|
159
|
+
*/
|
|
160
|
+
isClaimExpired(groupId: string): boolean {
|
|
161
|
+
const state = this.groups.get(groupId);
|
|
162
|
+
if (!state?.lastClaim) return false;
|
|
163
|
+
const elapsed = Date.now() - state.lastClaim.timestamp;
|
|
164
|
+
return elapsed >= this.config.claimExpireMs;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 清除 claim 状态(收到实际回复后调用)。
|
|
169
|
+
*/
|
|
170
|
+
clearClaim(groupId: string): void {
|
|
171
|
+
const state = this.groups.get(groupId);
|
|
172
|
+
if (state) state.lastClaim = null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
getConfig(): GroupSchedulingConfig {
|
|
176
|
+
return { ...this.config };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── Helpers ──
|
|
181
|
+
|
|
182
|
+
function sha256(input: string): string {
|
|
183
|
+
return createHash("sha256").update(input).digest("hex");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function arraysEqual(a: string[], b: string[]): boolean {
|
|
187
|
+
if (a.length !== b.length) return false;
|
|
188
|
+
for (let i = 0; i < a.length; i++) {
|
|
189
|
+
if (a[i] !== b[i]) return false;
|
|
190
|
+
}
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// ============================================================
|
|
2
2
|
// Message Orchestrator
|
|
3
|
-
//
|
|
4
|
-
// 调用、安全校验、回复提取与发送
|
|
3
|
+
// 群聊 round-robin 调度、LLM subagent 调用、安全校验、回复提取与发送
|
|
5
4
|
// ============================================================
|
|
6
5
|
|
|
7
6
|
import type { XmtpBridge } from "../transport/xmtp-bridge.js";
|
|
8
7
|
import { formatA2AMessage, type A2AInjectPayload } from "../types.js";
|
|
8
|
+
import { GroupScheduler } from "./group-scheduler.js";
|
|
9
|
+
import type { PolicyEngine } from "./policy-engine.js";
|
|
9
10
|
|
|
10
11
|
/** OpenClaw subagent runtime 接口(由 api.runtime.subagent 提供) */
|
|
11
12
|
export interface SubagentAPI {
|
|
@@ -39,13 +40,34 @@ export interface Logger {
|
|
|
39
40
|
const ALLOWED_TOOLS = new Set(["web_search"]);
|
|
40
41
|
|
|
41
42
|
export class MessageOrchestrator {
|
|
43
|
+
private groupScheduler: GroupScheduler;
|
|
44
|
+
/** 每个会话的 pending watch(failover 等待),用于取消 */
|
|
45
|
+
private pendingWatches = new Map<string, AbortController>();
|
|
46
|
+
|
|
42
47
|
constructor(
|
|
43
48
|
private subagentApi: SubagentAPI,
|
|
44
49
|
private logger: Logger,
|
|
45
|
-
|
|
50
|
+
private policyEngine: PolicyEngine,
|
|
51
|
+
groupScheduler: GroupScheduler,
|
|
52
|
+
) {
|
|
53
|
+
this.groupScheduler = groupScheduler;
|
|
54
|
+
}
|
|
46
55
|
|
|
47
56
|
/**
|
|
48
|
-
*
|
|
57
|
+
* 处理 claim 消息回调(由 bridge.onClaim 触发)
|
|
58
|
+
*/
|
|
59
|
+
handleClaim(conversationId: string, senderAddress: string, messageId: string): void {
|
|
60
|
+
this.groupScheduler.recordClaim(conversationId, senderAddress, messageId);
|
|
61
|
+
// 取消该会话的 pending watch(指定回复者已 claim)
|
|
62
|
+
const ctrl = this.pendingWatches.get(conversationId);
|
|
63
|
+
if (ctrl) {
|
|
64
|
+
ctrl.abort();
|
|
65
|
+
this.pendingWatches.delete(conversationId);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 处理收到的 XMTP 消息:群聊 round-robin 调度 → LLM 推理 → 安全校验 → 回复
|
|
49
71
|
*/
|
|
50
72
|
async handleMessage(
|
|
51
73
|
bridge: XmtpBridge,
|
|
@@ -56,26 +78,13 @@ export class MessageOrchestrator {
|
|
|
56
78
|
const formattedMsg = formatA2AMessage(payload);
|
|
57
79
|
const senderLabel = payload.from.agentId || payload.from.xmtpAddress;
|
|
58
80
|
|
|
59
|
-
// ──
|
|
60
|
-
if (payload.conversation.isGroup) {
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
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],
|
|
81
|
+
// ── 群聊 round-robin 调度 ──
|
|
82
|
+
if (payload.conversation.isGroup && payload.conversation.participants.length > 0) {
|
|
83
|
+
const shouldRespond = await this.scheduleGroupResponse(
|
|
84
|
+
bridge,
|
|
85
|
+
payload,
|
|
72
86
|
);
|
|
73
|
-
if (
|
|
74
|
-
this.logger.info(
|
|
75
|
-
`[a2a-xmtp] Skipping reply — another agent already responded in ${payload.conversation.id}`,
|
|
76
|
-
);
|
|
77
|
-
return;
|
|
78
|
-
}
|
|
87
|
+
if (!shouldRespond) return;
|
|
79
88
|
}
|
|
80
89
|
|
|
81
90
|
const extraSystemPrompt = this.buildSystemPrompt(payload, senderLabel);
|
|
@@ -113,24 +122,18 @@ export class MessageOrchestrator {
|
|
|
113
122
|
const replyText = this.extractReplyText(messages);
|
|
114
123
|
if (!replyText) return;
|
|
115
124
|
|
|
116
|
-
//
|
|
117
|
-
|
|
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
|
-
}
|
|
125
|
+
// 记录本 agent 的 turn
|
|
126
|
+
this.policyEngine.recordTurn(payload.conversation.id);
|
|
130
127
|
|
|
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
|
+
this.groupScheduler.clearClaim(payload.conversation.id);
|
|
135
|
+
}
|
|
136
|
+
|
|
134
137
|
this.logger.info(`[a2a-xmtp] Replied to ${senderLabel} in ${payload.conversation.id}`);
|
|
135
138
|
}
|
|
136
139
|
} catch (err) {
|
|
@@ -140,6 +143,104 @@ export class MessageOrchestrator {
|
|
|
140
143
|
}
|
|
141
144
|
}
|
|
142
145
|
|
|
146
|
+
/**
|
|
147
|
+
* 群聊调度:根据 round-robin 决定是否应该回复。
|
|
148
|
+
* 返回 true 表示应该回复,false 表示让其他 agent 处理。
|
|
149
|
+
*/
|
|
150
|
+
private async scheduleGroupResponse(
|
|
151
|
+
bridge: XmtpBridge,
|
|
152
|
+
payload: A2AInjectPayload,
|
|
153
|
+
): Promise<boolean> {
|
|
154
|
+
const convId = payload.conversation.id;
|
|
155
|
+
const members = payload.conversation.participants;
|
|
156
|
+
const myAddress = bridge.address;
|
|
157
|
+
|
|
158
|
+
// 初始化群组状态
|
|
159
|
+
this.groupScheduler.getOrInitGroup(convId, members);
|
|
160
|
+
|
|
161
|
+
// 检查 turn budget 是否耗尽
|
|
162
|
+
if (this.policyEngine.isTurnExhausted(convId)) {
|
|
163
|
+
this.logger.info(
|
|
164
|
+
`[a2a-xmtp] Turn budget exhausted for ${convId}, skipping`,
|
|
165
|
+
);
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 记录消息并获取 index
|
|
170
|
+
const msgIndex = this.groupScheduler.recordMessage(convId);
|
|
171
|
+
|
|
172
|
+
// 计算调度决策
|
|
173
|
+
const decision = this.groupScheduler.decide(convId, myAddress, msgIndex);
|
|
174
|
+
|
|
175
|
+
if (decision.action === "skip") {
|
|
176
|
+
this.logger.info(
|
|
177
|
+
`[a2a-xmtp] Skipping group message: ${decision.reason}`,
|
|
178
|
+
);
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (decision.action === "respond") {
|
|
183
|
+
// 我是指定回复者:等待 baseDelay 后发送 claim
|
|
184
|
+
this.logger.info(
|
|
185
|
+
`[a2a-xmtp] I am designated responder for msg #${msgIndex} in ${convId}`,
|
|
186
|
+
);
|
|
187
|
+
await sleep(decision.delayMs);
|
|
188
|
+
|
|
189
|
+
// 发送 claim
|
|
190
|
+
try {
|
|
191
|
+
await bridge.sendClaim(convId, payload.message.id);
|
|
192
|
+
} catch (err) {
|
|
193
|
+
this.logger.warn(
|
|
194
|
+
`[a2a-xmtp] Failed to send claim: ${err instanceof Error ? err.message : String(err)}`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// decision.action === "watch": 等待指定回复者的 claim 或回复
|
|
201
|
+
this.logger.info(
|
|
202
|
+
`[a2a-xmtp] Watching for claim/reply in ${convId}, timeout ${decision.timeoutMs}ms`,
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
const abortCtrl = new AbortController();
|
|
206
|
+
this.pendingWatches.set(convId, abortCtrl);
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
const timedOut = await waitWithAbort(decision.timeoutMs, abortCtrl.signal);
|
|
210
|
+
|
|
211
|
+
if (!timedOut) {
|
|
212
|
+
// 被取消 = 收到了 claim 或回复 → 保持沉默
|
|
213
|
+
this.logger.info(
|
|
214
|
+
`[a2a-xmtp] Saw claim/reply in ${convId}, staying silent`,
|
|
215
|
+
);
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// 超时且没有 claim → 检查是否有未过期的 claim(可能在等待期间收到)
|
|
220
|
+
if (this.groupScheduler.hasActiveClaim(convId)) {
|
|
221
|
+
this.logger.info(
|
|
222
|
+
`[a2a-xmtp] Active claim exists in ${convId}, staying silent`,
|
|
223
|
+
);
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// 接管:发送 claim 并回复
|
|
228
|
+
this.logger.info(
|
|
229
|
+
`[a2a-xmtp] Failover: taking over msg #${msgIndex} in ${convId}`,
|
|
230
|
+
);
|
|
231
|
+
try {
|
|
232
|
+
await bridge.sendClaim(convId, payload.message.id);
|
|
233
|
+
} catch (err) {
|
|
234
|
+
this.logger.warn(
|
|
235
|
+
`[a2a-xmtp] Failed to send failover claim: ${err instanceof Error ? err.message : String(err)}`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
return true;
|
|
239
|
+
} finally {
|
|
240
|
+
this.pendingWatches.delete(convId);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
143
244
|
/**
|
|
144
245
|
* 构建安全约束的系统提示词
|
|
145
246
|
*/
|
|
@@ -207,3 +308,31 @@ export class MessageOrchestrator {
|
|
|
207
308
|
return replyText.trim() || null;
|
|
208
309
|
}
|
|
209
310
|
}
|
|
311
|
+
|
|
312
|
+
// ── Helpers ──
|
|
313
|
+
|
|
314
|
+
function sleep(ms: number): Promise<void> {
|
|
315
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* 等待指定时间,可被 AbortSignal 取消。
|
|
320
|
+
* 返回 true = 超时(自然结束),false = 被取消。
|
|
321
|
+
*/
|
|
322
|
+
function waitWithAbort(ms: number, signal: AbortSignal): Promise<boolean> {
|
|
323
|
+
return new Promise((resolve) => {
|
|
324
|
+
if (signal.aborted) {
|
|
325
|
+
resolve(false);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
const timer = setTimeout(() => {
|
|
329
|
+
signal.removeEventListener("abort", onAbort);
|
|
330
|
+
resolve(true);
|
|
331
|
+
}, ms);
|
|
332
|
+
function onAbort() {
|
|
333
|
+
clearTimeout(timer);
|
|
334
|
+
resolve(false);
|
|
335
|
+
}
|
|
336
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
337
|
+
});
|
|
338
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// ============================================================
|
|
2
2
|
// Module 5: Policy Engine
|
|
3
|
-
//
|
|
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 {
|
|
@@ -63,21 +63,20 @@ export class PolicyEngine {
|
|
|
63
63
|
return { allowed: false, reason: `Cool-down active (${this.policy.minIntervalMs}ms between sends)` };
|
|
64
64
|
}
|
|
65
65
|
|
|
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
66
|
return { allowed: true };
|
|
72
67
|
}
|
|
73
68
|
|
|
74
|
-
recordTurn(conversationId: string
|
|
69
|
+
recordTurn(conversationId: string): void {
|
|
75
70
|
const state = this.getOrCreateState(conversationId);
|
|
76
71
|
state.turn += 1;
|
|
77
72
|
state.lastSendTime = Date.now();
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 检查此 agent 在该会话中的 turn 是否已用完 */
|
|
76
|
+
isTurnExhausted(conversationId: string): boolean {
|
|
77
|
+
const state = this.conversations.get(conversationId);
|
|
78
|
+
if (!state) return false;
|
|
79
|
+
return state.turn >= this.policy.maxTurns;
|
|
81
80
|
}
|
|
82
81
|
|
|
83
82
|
getConversationState(conversationId: string): ConversationState | null {
|
|
@@ -113,7 +112,7 @@ export class PolicyEngine {
|
|
|
113
112
|
private getOrCreateState(conversationId: string): ConversationState {
|
|
114
113
|
let state = this.conversations.get(conversationId);
|
|
115
114
|
if (!state) {
|
|
116
|
-
state = { turn: 0,
|
|
115
|
+
state = { turn: 0, lastSendTime: 0, createdAt: Date.now() };
|
|
117
116
|
this.conversations.set(conversationId, state);
|
|
118
117
|
}
|
|
119
118
|
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,14 @@ 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
|
|
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
|
+
);
|
|
156
167
|
|
|
157
168
|
// 初始化主 Agent 的 XMTP Bridge
|
|
158
169
|
try {
|
|
@@ -171,6 +182,10 @@ export default definePluginEntry({
|
|
|
171
182
|
bridge.onMessage = (agentId, payload) =>
|
|
172
183
|
orchestrator.handleMessage(bridge, agentId, payload);
|
|
173
184
|
|
|
185
|
+
// Claim 回调:通知 orchestrator 取消 failover 等待
|
|
186
|
+
bridge.onClaim = (conversationId, senderAddress, messageId) =>
|
|
187
|
+
orchestrator.handleClaim(conversationId, senderAddress, messageId);
|
|
188
|
+
|
|
174
189
|
await bridge.start();
|
|
175
190
|
bridges.set(AGENT_ID, bridge);
|
|
176
191
|
|
|
@@ -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
|
|
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
|
|
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(),
|