a2a-xmtp 1.0.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 +105 -0
- package/openclaw.plugin.json +45 -0
- package/package.json +36 -0
- package/src/identity-registry.ts +134 -0
- package/src/index.ts +176 -0
- package/src/policy-engine.ts +121 -0
- package/src/tools/xmtp-agents.ts +38 -0
- package/src/tools/xmtp-inbox.ts +46 -0
- package/src/tools/xmtp-send.ts +67 -0
- package/src/types.ts +146 -0
- package/src/xmtp-bridge.ts +174 -0
package/README.md
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# a2a-xmtp
|
|
2
|
+
|
|
3
|
+
Decentralized Agent-to-Agent E2EE messaging for [OpenClaw](https://openclaw.ai) via [XMTP](https://xmtp.org) protocol.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **E2EE Messaging** — End-to-end encrypted Agent-to-Agent communication
|
|
8
|
+
- **Cross-Gateway** — Agents on different servers can communicate directly
|
|
9
|
+
- **XMTP Network** — Decentralized, no VPN or API sharing needed
|
|
10
|
+
- **3 Agent Tools** — `xmtp_send`, `xmtp_inbox`, `xmtp_agents`
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
openclaw plugins install a2a-xmtp
|
|
16
|
+
chown -R root:root ~/.openclaw/extensions/a2a-xmtp/
|
|
17
|
+
|
|
18
|
+
Configure
|
|
19
|
+
|
|
20
|
+
Edit ~/.openclaw/openclaw.json:
|
|
21
|
+
|
|
22
|
+
{
|
|
23
|
+
"tools": {
|
|
24
|
+
"profile": "full"
|
|
25
|
+
},
|
|
26
|
+
"plugins": {
|
|
27
|
+
"entries": {
|
|
28
|
+
"a2a-xmtp": {
|
|
29
|
+
"enabled": true,
|
|
30
|
+
"config": {
|
|
31
|
+
"xmtp": {
|
|
32
|
+
"env": "dev",
|
|
33
|
+
"dbPath": "/root/.openclaw/xmtp-data"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
Then restart:
|
|
42
|
+
|
|
43
|
+
mkdir -p /root/.openclaw/xmtp-data
|
|
44
|
+
openclaw gateway restart
|
|
45
|
+
|
|
46
|
+
Verify
|
|
47
|
+
|
|
48
|
+
# Check plugin loaded
|
|
49
|
+
openclaw plugins list | grep a2a-xmtp
|
|
50
|
+
|
|
51
|
+
# Check bridge status (replace TOKEN with your gateway auth token)
|
|
52
|
+
TOKEN=$(python3 -c "import json; print(json.load(open('/root/.openclaw/openclaw.json'))['gateway']['auth']['token'])")
|
|
53
|
+
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:18789/a2a-xmtp/status
|
|
54
|
+
|
|
55
|
+
Expected output:
|
|
56
|
+
|
|
57
|
+
{
|
|
58
|
+
"plugin": "a2a-xmtp",
|
|
59
|
+
"bridgeCount": 1,
|
|
60
|
+
"agents": [{"agentId": "main", "xmtpAddress": "0x...", "connected": true}]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
Usage
|
|
64
|
+
|
|
65
|
+
Talk to your Agent via Telegram or any OpenClaw channel:
|
|
66
|
+
|
|
67
|
+
┌─────────────────┬──────────────────────────────────────────┐
|
|
68
|
+
│ Command │ Example │
|
|
69
|
+
├─────────────────┼──────────────────────────────────────────┤
|
|
70
|
+
│ Discover agents │ "使用 xmtp_agents 列出可通信的 Agent" │
|
|
71
|
+
├─────────────────┼──────────────────────────────────────────┤
|
|
72
|
+
│ Send message │ "使用 xmtp_send 给 0xAddress 发送 Hello" │
|
|
73
|
+
├─────────────────┼──────────────────────────────────────────┤
|
|
74
|
+
│ Check inbox │ "使用 xmtp_inbox 查看消息" │
|
|
75
|
+
└─────────────────┴──────────────────────────────────────────┘
|
|
76
|
+
|
|
77
|
+
Cross-Server Communication
|
|
78
|
+
|
|
79
|
+
Two OpenClaw agents on different servers can message each other — just exchange XMTP addresses:
|
|
80
|
+
|
|
81
|
+
1. Get your agent's address from /a2a-xmtp/status
|
|
82
|
+
2. Share it with the other party
|
|
83
|
+
3. Send messages using xmtp_send with the other agent's 0x address
|
|
84
|
+
|
|
85
|
+
No VPN, no API keys, no shared infrastructure needed.
|
|
86
|
+
|
|
87
|
+
Policy Configuration
|
|
88
|
+
|
|
89
|
+
┌──────────────────────┬──────────────────┬────────────────────────────┐
|
|
90
|
+
│ Setting │ Default │ Description │
|
|
91
|
+
├──────────────────────┼──────────────────┼────────────────────────────┤
|
|
92
|
+
│ policy.maxTurns │ 10 │ Max turns per conversation │
|
|
93
|
+
├──────────────────────┼──────────────────┼────────────────────────────┤
|
|
94
|
+
│ policy.maxDepth │ 5 │ Max reply depth │
|
|
95
|
+
├──────────────────────┼──────────────────┼────────────────────────────┤
|
|
96
|
+
│ policy.minIntervalMs │ 5000 │ Cool-down between sends │
|
|
97
|
+
├──────────────────────┼──────────────────┼────────────────────────────┤
|
|
98
|
+
│ policy.ttlMinutes │ 60 │ Conversation TTL │
|
|
99
|
+
├──────────────────────┼──────────────────┼────────────────────────────┤
|
|
100
|
+
│ policy.consentMode │ auto-allow-local │ Consent for local agents │
|
|
101
|
+
└──────────────────────┴──────────────────┴────────────────────────────┘
|
|
102
|
+
|
|
103
|
+
License
|
|
104
|
+
|
|
105
|
+
MIT
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "a2a-xmtp",
|
|
3
|
+
"name": "Agent-to-Agent IM (XMTP)",
|
|
4
|
+
"description": "Decentralized Agent-to-Agent E2EE messaging powered by XMTP protocol",
|
|
5
|
+
"configSchema": {
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"properties": {
|
|
9
|
+
"xmtp": {
|
|
10
|
+
"type": "object",
|
|
11
|
+
"additionalProperties": false,
|
|
12
|
+
"properties": {
|
|
13
|
+
"env": {
|
|
14
|
+
"type": "string",
|
|
15
|
+
"enum": ["dev", "production"],
|
|
16
|
+
"default": "dev"
|
|
17
|
+
},
|
|
18
|
+
"dbPath": {
|
|
19
|
+
"type": "string",
|
|
20
|
+
"default": "./xmtp-data"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"policy": {
|
|
25
|
+
"type": "object",
|
|
26
|
+
"additionalProperties": false,
|
|
27
|
+
"properties": {
|
|
28
|
+
"maxTurns": { "type": "number", "default": 10 },
|
|
29
|
+
"maxDepth": { "type": "number", "default": 5 },
|
|
30
|
+
"minIntervalMs": { "type": "number", "default": 5000 },
|
|
31
|
+
"ttlMinutes": { "type": "number", "default": 60 },
|
|
32
|
+
"consentMode": {
|
|
33
|
+
"type": "string",
|
|
34
|
+
"enum": ["auto-allow-local", "explicit-only"],
|
|
35
|
+
"default": "auto-allow-local"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"walletKey": {
|
|
40
|
+
"type": "string",
|
|
41
|
+
"description": "Optional: pre-existing XMTP wallet private key (hex). If omitted, auto-generated."
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "a2a-xmtp",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"@xmtp/agent-sdk": "^2.3.0",
|
|
9
|
+
"@sinclair/typebox": "^0.34.0"
|
|
10
|
+
},
|
|
11
|
+
"peerDependencies": {
|
|
12
|
+
"openclaw": "*"
|
|
13
|
+
},
|
|
14
|
+
"openclaw": {
|
|
15
|
+
"extensions": [
|
|
16
|
+
"./src/index.ts"
|
|
17
|
+
]
|
|
18
|
+
},
|
|
19
|
+
"description": "Decentralized Agent-to-Agent E2EE messaging for OpenClaw via XMTP",
|
|
20
|
+
"keywords": [
|
|
21
|
+
"openclaw",
|
|
22
|
+
"xmtp",
|
|
23
|
+
"a2a",
|
|
24
|
+
"agent",
|
|
25
|
+
"messaging",
|
|
26
|
+
"e2ee",
|
|
27
|
+
"web3"
|
|
28
|
+
],
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"files": [
|
|
31
|
+
"src/",
|
|
32
|
+
"openclaw.plugin.json",
|
|
33
|
+
"package.json",
|
|
34
|
+
"README.md"
|
|
35
|
+
]
|
|
36
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Module 3: Identity Registry
|
|
3
|
+
// agentId ↔ XMTP wallet address 双向映射,wallet key 生成/加载
|
|
4
|
+
// 使用文件系统持久化(stateDir)
|
|
5
|
+
// ============================================================
|
|
6
|
+
|
|
7
|
+
import { createUser, createSigner } from "@xmtp/agent-sdk";
|
|
8
|
+
import { privateKeyToAccount } from "viem/accounts";
|
|
9
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import type { XmtpWalletConfig, XmtpEnv } from "./types.js";
|
|
12
|
+
|
|
13
|
+
export class IdentityRegistry {
|
|
14
|
+
private agentToConfig = new Map<string, XmtpWalletConfig>();
|
|
15
|
+
private addressToAgent = new Map<string, string>();
|
|
16
|
+
private storePath: string;
|
|
17
|
+
|
|
18
|
+
constructor(
|
|
19
|
+
stateDir: string,
|
|
20
|
+
private env: XmtpEnv = "dev",
|
|
21
|
+
) {
|
|
22
|
+
this.storePath = join(stateDir, "identities");
|
|
23
|
+
mkdirSync(this.storePath, { recursive: true });
|
|
24
|
+
this.loadFromDisk();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 为 Agent 初始化 XMTP wallet。
|
|
29
|
+
* 如果已有配置则加载,否则生成新的 wallet。
|
|
30
|
+
*/
|
|
31
|
+
async initAgent(agentId: string, existingKey?: string): Promise<XmtpWalletConfig> {
|
|
32
|
+
const cached = this.agentToConfig.get(agentId);
|
|
33
|
+
if (cached) return cached;
|
|
34
|
+
|
|
35
|
+
// 尝试从磁盘加载
|
|
36
|
+
const filePath = join(this.storePath, `${agentId}.json`);
|
|
37
|
+
if (existsSync(filePath)) {
|
|
38
|
+
const config: XmtpWalletConfig = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
39
|
+
this.cacheMapping(agentId, config);
|
|
40
|
+
return config;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 生成新 wallet 或使用提供的 key
|
|
44
|
+
let privateKey: string;
|
|
45
|
+
let address: string;
|
|
46
|
+
|
|
47
|
+
if (existingKey) {
|
|
48
|
+
privateKey = existingKey;
|
|
49
|
+
const account = privateKeyToAccount(existingKey as `0x${string}`);
|
|
50
|
+
address = account.address;
|
|
51
|
+
} else {
|
|
52
|
+
const user = createUser();
|
|
53
|
+
privateKey = user.key; // createUser() returns { key, account, wallet }
|
|
54
|
+
address = user.account.address;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const config: XmtpWalletConfig = {
|
|
58
|
+
privateKey,
|
|
59
|
+
address,
|
|
60
|
+
xmtpInboxId: "",
|
|
61
|
+
env: this.env,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// 持久化到磁盘
|
|
65
|
+
writeFileSync(filePath, JSON.stringify(config, null, 2));
|
|
66
|
+
this.cacheMapping(agentId, config);
|
|
67
|
+
|
|
68
|
+
return config;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 更新 inboxId */
|
|
72
|
+
async updateInboxId(agentId: string, inboxId: string): Promise<void> {
|
|
73
|
+
const config = this.agentToConfig.get(agentId);
|
|
74
|
+
if (!config) return;
|
|
75
|
+
config.xmtpInboxId = inboxId;
|
|
76
|
+
const filePath = join(this.storePath, `${agentId}.json`);
|
|
77
|
+
writeFileSync(filePath, JSON.stringify(config, null, 2));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
getAddress(agentId: string): string | undefined {
|
|
81
|
+
return this.agentToConfig.get(agentId)?.address;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
getConfig(agentId: string): XmtpWalletConfig | undefined {
|
|
85
|
+
return this.agentToConfig.get(agentId);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
getAgentId(address: string): string | undefined {
|
|
89
|
+
return this.addressToAgent.get(address.toLowerCase());
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async resolveAgentId(address: string): Promise<string | null> {
|
|
93
|
+
return this.addressToAgent.get(address.toLowerCase()) ?? null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
listAgents(): Array<{ agentId: string; address: string; env: XmtpEnv }> {
|
|
97
|
+
return Array.from(this.agentToConfig.entries()).map(([id, config]) => ({
|
|
98
|
+
agentId: id,
|
|
99
|
+
address: config.address,
|
|
100
|
+
env: config.env,
|
|
101
|
+
}));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
has(agentId: string): boolean {
|
|
105
|
+
return this.agentToConfig.has(agentId);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
registerExternal(address: string, agentId: string): void {
|
|
109
|
+
this.addressToAgent.set(address.toLowerCase(), agentId);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private cacheMapping(agentId: string, config: XmtpWalletConfig): void {
|
|
113
|
+
this.agentToConfig.set(agentId, config);
|
|
114
|
+
this.addressToAgent.set(config.address.toLowerCase(), agentId);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 从磁盘加载所有已有 identity */
|
|
118
|
+
private loadFromDisk(): void {
|
|
119
|
+
if (!existsSync(this.storePath)) return;
|
|
120
|
+
const { readdirSync } = require("node:fs") as typeof import("node:fs");
|
|
121
|
+
for (const file of readdirSync(this.storePath)) {
|
|
122
|
+
if (!file.endsWith(".json")) continue;
|
|
123
|
+
const agentId = file.replace(".json", "");
|
|
124
|
+
try {
|
|
125
|
+
const config: XmtpWalletConfig = JSON.parse(
|
|
126
|
+
readFileSync(join(this.storePath, file), "utf-8"),
|
|
127
|
+
);
|
|
128
|
+
this.cacheMapping(agentId, config);
|
|
129
|
+
} catch {
|
|
130
|
+
// skip corrupt files
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Module 1: Plugin 入口
|
|
3
|
+
// 使用 OpenClaw Plugin SDK 真实 API
|
|
4
|
+
// ============================================================
|
|
5
|
+
|
|
6
|
+
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
|
7
|
+
import { Type } from "@sinclair/typebox";
|
|
8
|
+
import { IdentityRegistry } from "./identity-registry.js";
|
|
9
|
+
import { PolicyEngine } from "./policy-engine.js";
|
|
10
|
+
import { XmtpBridge } from "./xmtp-bridge.js";
|
|
11
|
+
import { handleXmtpSend } from "./tools/xmtp-send.js";
|
|
12
|
+
import { handleXmtpInbox } from "./tools/xmtp-inbox.js";
|
|
13
|
+
import { handleDiscoverAgents } from "./tools/xmtp-agents.js";
|
|
14
|
+
import { DEFAULT_PLUGIN_CONFIG, type PluginConfig } from "./types.js";
|
|
15
|
+
|
|
16
|
+
const AGENT_ID = "main"; // OpenClaw 默认 agent
|
|
17
|
+
|
|
18
|
+
export default definePluginEntry({
|
|
19
|
+
id: "a2a-xmtp",
|
|
20
|
+
name: "Agent-to-Agent IM (XMTP)",
|
|
21
|
+
description: "Decentralized Agent-to-Agent E2EE messaging powered by XMTP protocol",
|
|
22
|
+
|
|
23
|
+
register(api) {
|
|
24
|
+
const bridges = new Map<string, XmtpBridge>();
|
|
25
|
+
let registry: IdentityRegistry;
|
|
26
|
+
let policyEngine: PolicyEngine;
|
|
27
|
+
|
|
28
|
+
// ── 1. 注册 Tools ──
|
|
29
|
+
|
|
30
|
+
api.registerTool({
|
|
31
|
+
name: "xmtp_send",
|
|
32
|
+
description:
|
|
33
|
+
"Send an E2EE message to another agent via XMTP. " +
|
|
34
|
+
"Supports cross-gateway and cross-organization communication.",
|
|
35
|
+
parameters: Type.Object({
|
|
36
|
+
to: Type.String({ description: "Target agent ID or XMTP address (0x...)" }),
|
|
37
|
+
message: Type.String({ description: "Message content to send" }),
|
|
38
|
+
conversationId: Type.Optional(Type.String({ description: "Reuse existing conversation" })),
|
|
39
|
+
contentType: Type.Optional(
|
|
40
|
+
Type.String({ description: "Message type: text or markdown", default: "text" }),
|
|
41
|
+
),
|
|
42
|
+
}),
|
|
43
|
+
async execute(_toolCallId, params) {
|
|
44
|
+
const p = params as { to: string; message: string; conversationId?: string; contentType?: "text" | "markdown" };
|
|
45
|
+
return await handleXmtpSend(bridges, registry, policyEngine, p, AGENT_ID);
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
api.registerTool({
|
|
50
|
+
name: "xmtp_inbox",
|
|
51
|
+
description:
|
|
52
|
+
"Check your XMTP inbox for messages from other agents. " +
|
|
53
|
+
"Messages are E2E encrypted and only you can read them.",
|
|
54
|
+
parameters: Type.Object({
|
|
55
|
+
limit: Type.Optional(Type.Number({ description: "Max messages to return (default: 10)" })),
|
|
56
|
+
from: Type.Optional(Type.String({ description: "Filter by sender agent ID or address" })),
|
|
57
|
+
}),
|
|
58
|
+
async execute(_toolCallId, params) {
|
|
59
|
+
const p = params as { limit?: number; from?: string };
|
|
60
|
+
return await handleXmtpInbox(bridges, registry, p, AGENT_ID);
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
api.registerTool({
|
|
65
|
+
name: "xmtp_agents",
|
|
66
|
+
description:
|
|
67
|
+
"Discover agents available for XMTP communication. " +
|
|
68
|
+
"Lists registered agents and their connection status.",
|
|
69
|
+
parameters: Type.Object({
|
|
70
|
+
includeExternal: Type.Optional(
|
|
71
|
+
Type.Boolean({ description: "Include ERC-8004 external registry (Phase 3)" }),
|
|
72
|
+
),
|
|
73
|
+
}),
|
|
74
|
+
async execute(_toolCallId, params) {
|
|
75
|
+
const p = params as { includeExternal?: boolean };
|
|
76
|
+
return await handleDiscoverAgents(bridges, registry, p);
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// ── 2. 注册 HTTP 状态路由 ──
|
|
81
|
+
|
|
82
|
+
api.registerHttpRoute({
|
|
83
|
+
path: "/a2a-xmtp/status",
|
|
84
|
+
auth: "gateway",
|
|
85
|
+
handler: async (_req, res) => {
|
|
86
|
+
const status = {
|
|
87
|
+
plugin: "a2a-xmtp",
|
|
88
|
+
bridgeCount: bridges.size,
|
|
89
|
+
agents: Array.from(bridges.entries()).map(([id, b]) => ({
|
|
90
|
+
agentId: id,
|
|
91
|
+
xmtpAddress: b.address,
|
|
92
|
+
connected: b.isConnected,
|
|
93
|
+
env: b.env,
|
|
94
|
+
})),
|
|
95
|
+
policy: policyEngine?.getPolicy(),
|
|
96
|
+
};
|
|
97
|
+
res.setHeader("Content-Type", "application/json");
|
|
98
|
+
res.end(JSON.stringify(status, null, 2));
|
|
99
|
+
return true;
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// ── 3. 注册 Service(生命周期管理) ──
|
|
104
|
+
|
|
105
|
+
api.registerService({
|
|
106
|
+
id: "a2a-xmtp-bridge",
|
|
107
|
+
async start(ctx) {
|
|
108
|
+
ctx.logger.info("[a2a-xmtp] Starting XMTP Bridge Service...");
|
|
109
|
+
|
|
110
|
+
// 加载配置
|
|
111
|
+
const pluginCfg = (ctx.config as any)?.plugins?.entries?.["a2a-xmtp"]?.config;
|
|
112
|
+
const config: PluginConfig = {
|
|
113
|
+
xmtp: {
|
|
114
|
+
env: pluginCfg?.xmtp?.env ?? DEFAULT_PLUGIN_CONFIG.xmtp.env,
|
|
115
|
+
dbPath: pluginCfg?.xmtp?.dbPath ?? DEFAULT_PLUGIN_CONFIG.xmtp.dbPath,
|
|
116
|
+
},
|
|
117
|
+
policy: {
|
|
118
|
+
maxTurns: pluginCfg?.policy?.maxTurns ?? DEFAULT_PLUGIN_CONFIG.policy.maxTurns,
|
|
119
|
+
maxDepth: pluginCfg?.policy?.maxDepth ?? DEFAULT_PLUGIN_CONFIG.policy.maxDepth,
|
|
120
|
+
minIntervalMs: pluginCfg?.policy?.minIntervalMs ?? DEFAULT_PLUGIN_CONFIG.policy.minIntervalMs,
|
|
121
|
+
ttlMinutes: pluginCfg?.policy?.ttlMinutes ?? DEFAULT_PLUGIN_CONFIG.policy.ttlMinutes,
|
|
122
|
+
consentMode: pluginCfg?.policy?.consentMode ?? DEFAULT_PLUGIN_CONFIG.policy.consentMode,
|
|
123
|
+
},
|
|
124
|
+
walletKey: pluginCfg?.walletKey,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// 初始化核心组件
|
|
128
|
+
registry = new IdentityRegistry(ctx.stateDir, config.xmtp.env);
|
|
129
|
+
policyEngine = new PolicyEngine(config.policy);
|
|
130
|
+
|
|
131
|
+
// 初始化主 Agent 的 XMTP Bridge
|
|
132
|
+
try {
|
|
133
|
+
const walletConfig = await registry.initAgent(AGENT_ID, config.walletKey);
|
|
134
|
+
policyEngine.registerLocalAgent(AGENT_ID);
|
|
135
|
+
|
|
136
|
+
const bridge = new XmtpBridge(
|
|
137
|
+
AGENT_ID,
|
|
138
|
+
walletConfig,
|
|
139
|
+
policyEngine,
|
|
140
|
+
registry,
|
|
141
|
+
config.xmtp.dbPath,
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
// 消息回调:收到 XMTP 消息时记录日志
|
|
145
|
+
bridge.onMessage = (agentId, message) => {
|
|
146
|
+
ctx.logger.info(`[a2a-xmtp] Incoming message for ${agentId}: ${message}`);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
await bridge.start();
|
|
150
|
+
bridges.set(AGENT_ID, bridge);
|
|
151
|
+
|
|
152
|
+
ctx.logger.info(
|
|
153
|
+
`[a2a-xmtp] Bridge started: ${AGENT_ID} → ${bridge.address} (env: ${config.xmtp.env})`,
|
|
154
|
+
);
|
|
155
|
+
} catch (err) {
|
|
156
|
+
ctx.logger.error(
|
|
157
|
+
`[a2a-xmtp] Failed to start bridge: ${err instanceof Error ? err.message : String(err)}`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
async stop(ctx) {
|
|
163
|
+
ctx.logger.info("[a2a-xmtp] Stopping XMTP bridges...");
|
|
164
|
+
for (const [id, bridge] of bridges) {
|
|
165
|
+
try {
|
|
166
|
+
await bridge.stop();
|
|
167
|
+
ctx.logger.info(`[a2a-xmtp] Bridge stopped: ${id}`);
|
|
168
|
+
} catch (err) {
|
|
169
|
+
ctx.logger.error(`[a2a-xmtp] Error stopping bridge ${id}: ${err}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
bridges.clear();
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
},
|
|
176
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Module 5: Policy Engine
|
|
3
|
+
// 四重保护:Turn Budget / Cool-down / Depth Guard / Consent
|
|
4
|
+
// ============================================================
|
|
5
|
+
|
|
6
|
+
import type { ConversationPolicy, ConversationState, ConsentState } from "./types.js";
|
|
7
|
+
|
|
8
|
+
export interface PolicyCheckParams {
|
|
9
|
+
from: string;
|
|
10
|
+
to: string;
|
|
11
|
+
conversationId: string;
|
|
12
|
+
depth?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface PolicyCheckResult {
|
|
16
|
+
allowed: boolean;
|
|
17
|
+
reason?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class PolicyEngine {
|
|
21
|
+
private conversations = new Map<string, ConversationState>();
|
|
22
|
+
private consents = new Map<string, ConsentState>();
|
|
23
|
+
private localAgentIds = new Set<string>();
|
|
24
|
+
|
|
25
|
+
constructor(private policy: ConversationPolicy) {}
|
|
26
|
+
|
|
27
|
+
registerLocalAgent(agentId: string): void {
|
|
28
|
+
this.localAgentIds.add(agentId);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
checkOutgoing(params: PolicyCheckParams): PolicyCheckResult {
|
|
32
|
+
return this.check(params);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
checkIncoming(params: PolicyCheckParams): PolicyCheckResult {
|
|
36
|
+
const consent = this.getConsent(params.from);
|
|
37
|
+
if (consent === "deny") {
|
|
38
|
+
return { allowed: false, reason: `Sender ${params.from} is denied` };
|
|
39
|
+
}
|
|
40
|
+
if (this.policy.consentMode === "explicit-only" && consent !== "allow") {
|
|
41
|
+
return {
|
|
42
|
+
allowed: false,
|
|
43
|
+
reason: `Sender ${params.from} not explicitly allowed (consent: ${consent})`,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return this.check(params);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private check(params: PolicyCheckParams): PolicyCheckResult {
|
|
50
|
+
const state = this.getOrCreateState(params.conversationId);
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
|
|
53
|
+
const ttlMs = this.policy.ttlMinutes * 60 * 1000;
|
|
54
|
+
if (now - state.createdAt > ttlMs) {
|
|
55
|
+
return { allowed: false, reason: `Conversation TTL expired (${this.policy.ttlMinutes} min)` };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (state.turn >= this.policy.maxTurns) {
|
|
59
|
+
return { allowed: false, reason: `Turn budget exhausted (${state.turn}/${this.policy.maxTurns})` };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (state.lastSendTime > 0 && now - state.lastSendTime < this.policy.minIntervalMs) {
|
|
63
|
+
return { allowed: false, reason: `Cool-down active (${this.policy.minIntervalMs}ms between sends)` };
|
|
64
|
+
}
|
|
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
|
+
return { allowed: true };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
recordTurn(conversationId: string, depth?: number): void {
|
|
75
|
+
const state = this.getOrCreateState(conversationId);
|
|
76
|
+
state.turn += 1;
|
|
77
|
+
state.lastSendTime = Date.now();
|
|
78
|
+
if (depth !== undefined) {
|
|
79
|
+
state.depth = Math.max(state.depth, depth);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
getConversationState(conversationId: string): ConversationState | null {
|
|
84
|
+
return this.conversations.get(conversationId) ?? null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
resetConversation(conversationId: string): void {
|
|
88
|
+
this.conversations.delete(conversationId);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
setConsent(address: string, consent: ConsentState): void {
|
|
92
|
+
this.consents.set(address.toLowerCase(), consent);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
getConsent(identifier: string): ConsentState {
|
|
96
|
+
const normalized = identifier.toLowerCase();
|
|
97
|
+
if (this.policy.consentMode === "auto-allow-local" && this.localAgentIds.has(identifier)) {
|
|
98
|
+
return "allow";
|
|
99
|
+
}
|
|
100
|
+
return this.consents.get(normalized) ?? "unknown";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
loadAclRules(rules: Array<{ address: string; consent: ConsentState }>): void {
|
|
104
|
+
for (const rule of rules) {
|
|
105
|
+
this.consents.set(rule.address.toLowerCase(), rule.consent);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
getPolicy(): ConversationPolicy {
|
|
110
|
+
return { ...this.policy };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private getOrCreateState(conversationId: string): ConversationState {
|
|
114
|
+
let state = this.conversations.get(conversationId);
|
|
115
|
+
if (!state) {
|
|
116
|
+
state = { turn: 0, depth: 0, lastSendTime: 0, createdAt: Date.now() };
|
|
117
|
+
this.conversations.set(conversationId, state);
|
|
118
|
+
}
|
|
119
|
+
return state;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Tool: xmtp_agents — 发现可通信的 Agent
|
|
3
|
+
// ============================================================
|
|
4
|
+
|
|
5
|
+
import type { XmtpBridge } from "../xmtp-bridge.js";
|
|
6
|
+
import type { IdentityRegistry } from "../identity-registry.js";
|
|
7
|
+
|
|
8
|
+
export async function handleDiscoverAgents(
|
|
9
|
+
bridges: Map<string, XmtpBridge>,
|
|
10
|
+
registry: IdentityRegistry,
|
|
11
|
+
params: { includeExternal?: boolean },
|
|
12
|
+
) {
|
|
13
|
+
const localAgents = registry.listAgents();
|
|
14
|
+
|
|
15
|
+
const lines = localAgents.map((agent) => {
|
|
16
|
+
const bridge = bridges.get(agent.agentId);
|
|
17
|
+
const status = bridge?.isConnected ? "online" : "offline";
|
|
18
|
+
return `- ${agent.agentId} (${agent.address}) [${status}]`;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
let text = `Available agents (${localAgents.length}):\n${lines.join("\n")}`;
|
|
22
|
+
|
|
23
|
+
if (params.includeExternal) {
|
|
24
|
+
text += "\n\nNote: ERC-8004 external registry lookup is planned for Phase 3.";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
content: [{ type: "text" as const, text }],
|
|
29
|
+
details: {
|
|
30
|
+
count: localAgents.length,
|
|
31
|
+
agents: localAgents.map((a) => ({
|
|
32
|
+
agentId: a.agentId,
|
|
33
|
+
address: a.address,
|
|
34
|
+
connected: bridges.get(a.agentId)?.isConnected ?? false,
|
|
35
|
+
})),
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Tool: xmtp_inbox — 查看 XMTP 收件箱消息
|
|
3
|
+
// ============================================================
|
|
4
|
+
|
|
5
|
+
import type { XmtpBridge } from "../xmtp-bridge.js";
|
|
6
|
+
import type { IdentityRegistry } from "../identity-registry.js";
|
|
7
|
+
|
|
8
|
+
export async function handleXmtpInbox(
|
|
9
|
+
bridges: Map<string, XmtpBridge>,
|
|
10
|
+
registry: IdentityRegistry,
|
|
11
|
+
params: { limit?: number; from?: string },
|
|
12
|
+
agentId: string,
|
|
13
|
+
) {
|
|
14
|
+
const bridge = bridges.get(agentId) ?? bridges.values().next().value;
|
|
15
|
+
if (!bridge) {
|
|
16
|
+
return { content: [{ type: "text" as const, text: "No XMTP bridge available." }] };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// 解析 from
|
|
20
|
+
let fromFilter = params.from;
|
|
21
|
+
if (fromFilter && !fromFilter.startsWith("0x")) {
|
|
22
|
+
const addr = registry.getAddress(fromFilter);
|
|
23
|
+
if (addr) fromFilter = addr;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const messages = await bridge.getInbox({ limit: params.limit ?? 10, from: fromFilter });
|
|
28
|
+
|
|
29
|
+
if (messages.length === 0) {
|
|
30
|
+
return { content: [{ type: "text" as const, text: "No messages in inbox." }] };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const lines = messages.map((m, i) => {
|
|
34
|
+
const sender = m.from.agentId ?? m.from.xmtpAddress;
|
|
35
|
+
return `${i + 1}. [${m.timestamp}] from ${sender}: ${m.content}`;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
content: [{ type: "text" as const, text: lines.join("\n") }],
|
|
40
|
+
details: { count: messages.length, myAddress: bridge.address },
|
|
41
|
+
};
|
|
42
|
+
} catch (err: unknown) {
|
|
43
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
44
|
+
return { content: [{ type: "text" as const, text: `Failed to fetch inbox: ${msg}` }] };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Tool: xmtp_send — 发送 E2EE 消息到另一个 Agent
|
|
3
|
+
// ============================================================
|
|
4
|
+
|
|
5
|
+
import type { XmtpBridge } from "../xmtp-bridge.js";
|
|
6
|
+
import type { IdentityRegistry } from "../identity-registry.js";
|
|
7
|
+
import type { PolicyEngine } from "../policy-engine.js";
|
|
8
|
+
|
|
9
|
+
export async function handleXmtpSend(
|
|
10
|
+
bridges: Map<string, XmtpBridge>,
|
|
11
|
+
registry: IdentityRegistry,
|
|
12
|
+
policyEngine: PolicyEngine,
|
|
13
|
+
params: { to: string; message: string; conversationId?: string; contentType?: "text" | "markdown" },
|
|
14
|
+
senderAgentId: string,
|
|
15
|
+
) {
|
|
16
|
+
// 找到发送者 bridge(使用第一个可用的 bridge)
|
|
17
|
+
const bridge = bridges.get(senderAgentId) ?? bridges.values().next().value;
|
|
18
|
+
if (!bridge) {
|
|
19
|
+
return { content: [{ type: "text" as const, text: "No XMTP bridge available. Plugin not initialized." }] };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// 解析目标地址
|
|
23
|
+
let targetAddress: string;
|
|
24
|
+
if (params.to.startsWith("0x")) {
|
|
25
|
+
targetAddress = params.to;
|
|
26
|
+
} else {
|
|
27
|
+
const addr = registry.getAddress(params.to);
|
|
28
|
+
if (!addr) {
|
|
29
|
+
return {
|
|
30
|
+
content: [{ type: "text" as const, text: `Agent "${params.to}" not found. Use xmtp_agents to discover available agents.` }],
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
targetAddress = addr;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (targetAddress.toLowerCase() === bridge.address.toLowerCase()) {
|
|
37
|
+
return { content: [{ type: "text" as const, text: "Cannot send message to yourself." }] };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Policy 检查
|
|
41
|
+
const convId = params.conversationId ?? `dm:${senderAgentId}:${params.to}`;
|
|
42
|
+
const check = policyEngine.checkOutgoing({ from: senderAgentId, to: params.to, conversationId: convId });
|
|
43
|
+
if (!check.allowed) {
|
|
44
|
+
return { content: [{ type: "text" as const, text: `Blocked: ${check.reason}` }] };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const result = await bridge.sendMessage(targetAddress, params.message, {
|
|
49
|
+
contentType: params.contentType,
|
|
50
|
+
conversationId: params.conversationId,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
content: [{ type: "text" as const, text: `Message sent to ${params.to} (${targetAddress}).` }],
|
|
55
|
+
details: {
|
|
56
|
+
status: "sent",
|
|
57
|
+
conversationId: result.conversationId,
|
|
58
|
+
messageId: result.messageId,
|
|
59
|
+
to: params.to,
|
|
60
|
+
toAddress: targetAddress,
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
} catch (err: unknown) {
|
|
64
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
65
|
+
return { content: [{ type: "text" as const, text: `Failed to send: ${msg}` }] };
|
|
66
|
+
}
|
|
67
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Module 6: 类型定义 & 消息协议适配
|
|
3
|
+
// ============================================================
|
|
4
|
+
|
|
5
|
+
/** Agent 的 XMTP 钱包配置 */
|
|
6
|
+
export interface XmtpWalletConfig {
|
|
7
|
+
privateKey: string;
|
|
8
|
+
address: string;
|
|
9
|
+
xmtpInboxId: string;
|
|
10
|
+
env: XmtpEnv;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type XmtpEnv = "dev" | "production";
|
|
14
|
+
|
|
15
|
+
/** 注入 Agent session 的消息格式 */
|
|
16
|
+
export interface A2AInjectPayload {
|
|
17
|
+
type: "a2a-xmtp";
|
|
18
|
+
from: {
|
|
19
|
+
agentId: string | null;
|
|
20
|
+
xmtpAddress: string;
|
|
21
|
+
displayName?: string;
|
|
22
|
+
};
|
|
23
|
+
conversation: {
|
|
24
|
+
id: string;
|
|
25
|
+
isGroup: boolean;
|
|
26
|
+
participants: string[];
|
|
27
|
+
};
|
|
28
|
+
message: {
|
|
29
|
+
id: string;
|
|
30
|
+
content: string;
|
|
31
|
+
contentType: A2AContentType;
|
|
32
|
+
turn: number;
|
|
33
|
+
depth: number;
|
|
34
|
+
replyTo?: string;
|
|
35
|
+
};
|
|
36
|
+
timestamp: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type A2AContentType = "text" | "markdown" | "transaction" | "action";
|
|
40
|
+
|
|
41
|
+
/** 防循环策略配置 */
|
|
42
|
+
export interface ConversationPolicy {
|
|
43
|
+
maxTurns: number;
|
|
44
|
+
maxDepth: number;
|
|
45
|
+
minIntervalMs: number;
|
|
46
|
+
ttlMinutes: number;
|
|
47
|
+
consentMode: ConsentMode;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type ConsentMode = "auto-allow-local" | "explicit-only";
|
|
51
|
+
export type ConsentState = "allow" | "deny" | "unknown";
|
|
52
|
+
|
|
53
|
+
/** 插件运行时配置(从 openclaw.json entries.a2a-xmtp.config 解析) */
|
|
54
|
+
export interface PluginConfig {
|
|
55
|
+
xmtp: {
|
|
56
|
+
env: XmtpEnv;
|
|
57
|
+
dbPath: string;
|
|
58
|
+
};
|
|
59
|
+
policy: ConversationPolicy;
|
|
60
|
+
walletKey?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 会话状态(Policy Engine 内部使用) */
|
|
64
|
+
export interface ConversationState {
|
|
65
|
+
turn: number;
|
|
66
|
+
depth: number;
|
|
67
|
+
lastSendTime: number;
|
|
68
|
+
createdAt: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 收件箱消息 */
|
|
72
|
+
export interface InboxMessage {
|
|
73
|
+
id: string;
|
|
74
|
+
from: {
|
|
75
|
+
agentId: string | null;
|
|
76
|
+
xmtpAddress: string;
|
|
77
|
+
};
|
|
78
|
+
conversationId: string;
|
|
79
|
+
content: string;
|
|
80
|
+
contentType: A2AContentType;
|
|
81
|
+
timestamp: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 默认策略配置 */
|
|
85
|
+
export const DEFAULT_POLICY: ConversationPolicy = {
|
|
86
|
+
maxTurns: 10,
|
|
87
|
+
maxDepth: 5,
|
|
88
|
+
minIntervalMs: 5000,
|
|
89
|
+
ttlMinutes: 60,
|
|
90
|
+
consentMode: "auto-allow-local",
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** 默认插件配置 */
|
|
94
|
+
export const DEFAULT_PLUGIN_CONFIG: PluginConfig = {
|
|
95
|
+
xmtp: {
|
|
96
|
+
env: "dev",
|
|
97
|
+
dbPath: "./xmtp-data",
|
|
98
|
+
},
|
|
99
|
+
policy: DEFAULT_POLICY,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/** 格式化 A2A 消息(注入 session 时的文本表示) */
|
|
103
|
+
export function formatA2AMessage(payload: A2AInjectPayload): string {
|
|
104
|
+
const sender = payload.from.agentId || payload.from.xmtpAddress;
|
|
105
|
+
const prefix = payload.conversation.isGroup ? "[A2A Group]" : "[A2A]";
|
|
106
|
+
return `${prefix} from ${sender} (turn ${payload.message.turn}): ${payload.message.content}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 创建 A2AInjectPayload */
|
|
110
|
+
export function createA2APayload(params: {
|
|
111
|
+
fromAgentId: string | null;
|
|
112
|
+
fromAddress: string;
|
|
113
|
+
conversationId: string;
|
|
114
|
+
isGroup: boolean;
|
|
115
|
+
participants: string[];
|
|
116
|
+
messageId: string;
|
|
117
|
+
content: string;
|
|
118
|
+
contentType: A2AContentType;
|
|
119
|
+
turn: number;
|
|
120
|
+
depth: number;
|
|
121
|
+
replyTo?: string;
|
|
122
|
+
displayName?: string;
|
|
123
|
+
}): A2AInjectPayload {
|
|
124
|
+
return {
|
|
125
|
+
type: "a2a-xmtp",
|
|
126
|
+
from: {
|
|
127
|
+
agentId: params.fromAgentId,
|
|
128
|
+
xmtpAddress: params.fromAddress,
|
|
129
|
+
displayName: params.displayName,
|
|
130
|
+
},
|
|
131
|
+
conversation: {
|
|
132
|
+
id: params.conversationId,
|
|
133
|
+
isGroup: params.isGroup,
|
|
134
|
+
participants: params.participants,
|
|
135
|
+
},
|
|
136
|
+
message: {
|
|
137
|
+
id: params.messageId,
|
|
138
|
+
content: params.content,
|
|
139
|
+
contentType: params.contentType,
|
|
140
|
+
turn: params.turn,
|
|
141
|
+
depth: params.depth,
|
|
142
|
+
replyTo: params.replyTo,
|
|
143
|
+
},
|
|
144
|
+
timestamp: new Date().toISOString(),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Module 2: XMTP Bridge Service
|
|
3
|
+
// 每个 Agent 对应一个 XMTP Client,消息 stream + 收件箱缓存
|
|
4
|
+
// ============================================================
|
|
5
|
+
|
|
6
|
+
import { Agent, createUser, createSigner, IdentifierKind } from "@xmtp/agent-sdk";
|
|
7
|
+
import type { IdentityRegistry } from "./identity-registry.js";
|
|
8
|
+
import { PolicyEngine } from "./policy-engine.js";
|
|
9
|
+
import type { XmtpWalletConfig, InboxMessage, A2AContentType } from "./types.js";
|
|
10
|
+
import { createA2APayload, formatA2AMessage } from "./types.js";
|
|
11
|
+
|
|
12
|
+
export class XmtpBridge {
|
|
13
|
+
private agent: InstanceType<typeof Agent> | null = null;
|
|
14
|
+
private running = false;
|
|
15
|
+
|
|
16
|
+
/** 收件箱缓存 */
|
|
17
|
+
private inboxBuffer: InboxMessage[] = [];
|
|
18
|
+
private readonly maxInboxBuffer = 100;
|
|
19
|
+
|
|
20
|
+
/** 消息回调(由 plugin 入口设置,用于注入 session) */
|
|
21
|
+
onMessage?: (agentId: string, formattedMessage: string) => void;
|
|
22
|
+
|
|
23
|
+
constructor(
|
|
24
|
+
readonly agentId: string,
|
|
25
|
+
private walletConfig: XmtpWalletConfig,
|
|
26
|
+
private policyEngine: PolicyEngine,
|
|
27
|
+
private registry: IdentityRegistry,
|
|
28
|
+
private dbPath: string,
|
|
29
|
+
) {}
|
|
30
|
+
|
|
31
|
+
async start(): Promise<void> {
|
|
32
|
+
if (this.running) return;
|
|
33
|
+
|
|
34
|
+
// Reconstruct full user from stored key — createUser() accepts an existing private key
|
|
35
|
+
const user = createUser(this.walletConfig.privateKey as `0x${string}`);
|
|
36
|
+
const signer = createSigner(user);
|
|
37
|
+
|
|
38
|
+
this.agent = await Agent.create(signer, {
|
|
39
|
+
env: this.walletConfig.env,
|
|
40
|
+
dbPath: `${this.dbPath}/${this.agentId}`,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
await this.registry.updateInboxId(this.agentId, this.agent.address);
|
|
44
|
+
|
|
45
|
+
this.agent.on("text", async (ctx: any) => {
|
|
46
|
+
await this.handleIncoming(ctx, "text");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
this.agent.on("markdown", async (ctx: any) => {
|
|
50
|
+
await this.handleIncoming(ctx, "markdown");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
await this.agent.start();
|
|
54
|
+
this.running = true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async stop(): Promise<void> {
|
|
58
|
+
if (!this.running || !this.agent) return;
|
|
59
|
+
await this.agent.stop();
|
|
60
|
+
this.running = false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async sendMessage(
|
|
64
|
+
toAddress: string,
|
|
65
|
+
content: string,
|
|
66
|
+
opts?: { contentType?: "text" | "markdown"; conversationId?: string },
|
|
67
|
+
): Promise<{ conversationId: string; messageId: string }> {
|
|
68
|
+
if (!this.agent) throw new Error(`Bridge for ${this.agentId} not started`);
|
|
69
|
+
|
|
70
|
+
let conversation: any;
|
|
71
|
+
if (opts?.conversationId) {
|
|
72
|
+
conversation = await this.agent.client.conversations.getConversationById(opts.conversationId);
|
|
73
|
+
if (!conversation) throw new Error(`Conversation ${opts.conversationId} not found`);
|
|
74
|
+
} else {
|
|
75
|
+
conversation = await (this.agent as any).createDmWithAddress(toAddress);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const messageId = opts?.contentType === "markdown"
|
|
79
|
+
? await conversation.sendMarkdown(content)
|
|
80
|
+
: await conversation.sendText(content);
|
|
81
|
+
|
|
82
|
+
this.policyEngine.recordTurn(conversation.id);
|
|
83
|
+
|
|
84
|
+
return { conversationId: conversation.id, messageId: String(messageId) };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async getInbox(opts?: { limit?: number; from?: string }): Promise<InboxMessage[]> {
|
|
88
|
+
const limit = opts?.limit ?? 10;
|
|
89
|
+
let messages = [...this.inboxBuffer];
|
|
90
|
+
if (opts?.from) {
|
|
91
|
+
const f = opts.from.toLowerCase();
|
|
92
|
+
messages = messages.filter(
|
|
93
|
+
(m) => m.from.agentId === opts.from || m.from.xmtpAddress.toLowerCase() === f,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
return messages.slice(0, limit);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async createGroup(memberAddresses: string[]): Promise<string> {
|
|
100
|
+
if (!this.agent) throw new Error(`Bridge for ${this.agentId} not started`);
|
|
101
|
+
const group = await (this.agent as any).createGroupWithAddresses(memberAddresses);
|
|
102
|
+
return group.id;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async canMessage(address: string): Promise<boolean> {
|
|
106
|
+
if (!this.agent) return false;
|
|
107
|
+
const result = await this.agent.client.canMessage([
|
|
108
|
+
{ identifier: address, identifierKind: IdentifierKind.Ethereum },
|
|
109
|
+
]);
|
|
110
|
+
return result.get(address.toLowerCase()) ?? false;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private async handleIncoming(ctx: any, contentType: A2AContentType): Promise<void> {
|
|
114
|
+
const senderAddress: string = await ctx.getSenderAddress();
|
|
115
|
+
|
|
116
|
+
if (senderAddress.toLowerCase() === this.address.toLowerCase()) return;
|
|
117
|
+
|
|
118
|
+
const senderAgentId = await this.registry.resolveAgentId(senderAddress);
|
|
119
|
+
|
|
120
|
+
const policyResult = this.policyEngine.checkIncoming({
|
|
121
|
+
from: senderAgentId || senderAddress,
|
|
122
|
+
to: this.agentId,
|
|
123
|
+
conversationId: ctx.conversation.id,
|
|
124
|
+
});
|
|
125
|
+
if (!policyResult.allowed) return;
|
|
126
|
+
|
|
127
|
+
const convState = this.policyEngine.getConversationState(ctx.conversation.id);
|
|
128
|
+
const turn = convState?.turn ?? 0;
|
|
129
|
+
const depth = convState?.depth ?? 0;
|
|
130
|
+
|
|
131
|
+
this.policyEngine.recordTurn(ctx.conversation.id, depth + 1);
|
|
132
|
+
|
|
133
|
+
const payload = createA2APayload({
|
|
134
|
+
fromAgentId: senderAgentId,
|
|
135
|
+
fromAddress: senderAddress,
|
|
136
|
+
conversationId: ctx.conversation.id,
|
|
137
|
+
isGroup: ctx.conversation.isGroup ?? false,
|
|
138
|
+
participants: [],
|
|
139
|
+
messageId: ctx.message.id ?? crypto.randomUUID(),
|
|
140
|
+
content: String(ctx.message.content),
|
|
141
|
+
contentType,
|
|
142
|
+
turn: turn + 1,
|
|
143
|
+
depth: depth + 1,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// 缓存到 inbox
|
|
147
|
+
this.inboxBuffer.unshift({
|
|
148
|
+
id: payload.message.id,
|
|
149
|
+
from: { agentId: senderAgentId, xmtpAddress: senderAddress },
|
|
150
|
+
conversationId: ctx.conversation.id,
|
|
151
|
+
content: String(ctx.message.content),
|
|
152
|
+
contentType,
|
|
153
|
+
timestamp: payload.timestamp,
|
|
154
|
+
});
|
|
155
|
+
if (this.inboxBuffer.length > this.maxInboxBuffer) this.inboxBuffer.pop();
|
|
156
|
+
|
|
157
|
+
// 通知 plugin 层
|
|
158
|
+
if (this.onMessage) {
|
|
159
|
+
this.onMessage(this.agentId, formatA2AMessage(payload));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
get address(): string {
|
|
164
|
+
return this.agent?.address ?? this.walletConfig.address;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
get isConnected(): boolean {
|
|
168
|
+
return this.running;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
get env() {
|
|
172
|
+
return this.walletConfig.env;
|
|
173
|
+
}
|
|
174
|
+
}
|