@aliwey/bmo 2.0.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 +90 -0
- package/bin/bmo.js +188 -0
- package/cli.py +1129 -0
- package/config/__init__.py +0 -0
- package/config/__pycache__/__init__.cpython-313.pyc +0 -0
- package/config/__pycache__/settings.cpython-313.pyc +0 -0
- package/config/__pycache__/system-prompt.cpython-313.pyc +0 -0
- package/config/settings.py +104 -0
- package/config/system-prompt.json +18 -0
- package/core/__init__.py +0 -0
- package/core/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_a2a_bridge.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_agent.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_agent_card.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_connector.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_discovery.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_identity.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_tasks.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_transport.cpython-313.pyc +0 -0
- package/core/__pycache__/bmo_engine.cpython-313.pyc +0 -0
- package/core/__pycache__/bot_client.cpython-313.pyc +0 -0
- package/core/__pycache__/budget_tracker.cpython-313.pyc +0 -0
- package/core/__pycache__/cli_renderer.cpython-313.pyc +0 -0
- package/core/__pycache__/goal_runner.cpython-313.pyc +0 -0
- package/core/__pycache__/request_worker.cpython-313.pyc +0 -0
- package/core/__pycache__/security.cpython-313.pyc +0 -0
- package/core/__pycache__/shared_state.cpython-313.pyc +0 -0
- package/core/__pycache__/worker_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/worker_multiproc.cpython-313.pyc +0 -0
- package/core/__pycache__/worker_protocol.cpython-313.pyc +0 -0
- package/core/__pycache__/worker_subprocess.cpython-313.pyc +0 -0
- package/core/bfp_a2a_bridge.py +399 -0
- package/core/bfp_agent.py +98 -0
- package/core/bfp_agent_card.py +161 -0
- package/core/bfp_connector.py +177 -0
- package/core/bfp_discovery.py +105 -0
- package/core/bfp_identity.py +83 -0
- package/core/bfp_tasks.py +70 -0
- package/core/bfp_transport.py +368 -0
- package/core/bmo_engine.py +405 -0
- package/core/bot_client.py +838 -0
- package/core/budget_tracker.py +62 -0
- package/core/cli_renderer.py +177 -0
- package/core/goal_runner.py +129 -0
- package/core/request_worker.py +242 -0
- package/core/security.py +42 -0
- package/core/shared_state.py +4 -0
- package/core/worker_manager.py +71 -0
- package/core/worker_multiproc.py +155 -0
- package/core/worker_protocol.py +30 -0
- package/core/worker_subprocess.py +222 -0
- package/handlers/__init__.py +0 -0
- package/handlers/__pycache__/__init__.cpython-313.pyc +0 -0
- package/handlers/__pycache__/messages.cpython-313.pyc +0 -0
- package/handlers/messages.py +2761 -0
- package/main.py +125 -0
- package/memory.md +43 -0
- package/models/__init__.py +0 -0
- package/models/__pycache__/__init__.cpython-313.pyc +0 -0
- package/models/__pycache__/chat_models.cpython-313.pyc +0 -0
- package/models/chat_models.py +143 -0
- package/package.json +50 -0
- package/registry/worker.js +108 -0
- package/registry/wrangler.toml +11 -0
- package/requirements.txt +13 -0
- package/scripts/bmo_init.js +115 -0
- package/scripts/postinstall.js +265 -0
- package/scripts/relay_cmd.js +276 -0
- package/scripts/web_cmd.js +136 -0
- package/setup.py +26 -0
- package/storage/__init__.py +0 -0
- package/storage/__pycache__/__init__.cpython-313.pyc +0 -0
- package/storage/__pycache__/sqlite_storage.cpython-313.pyc +0 -0
- package/storage/__pycache__/storage.cpython-313.pyc +0 -0
- package/storage/sqlite_storage.py +658 -0
- package/storage/storage.py +265 -0
- package/tools/__pycache__/bfp_relay.cpython-313.pyc +0 -0
- package/tools/__pycache__/get_session_summaries.cpython-313.pyc +0 -0
- package/tools/__pycache__/mcp_bridge.cpython-313.pyc +0 -0
- package/tools/__pycache__/mcp_server.cpython-313.pyc +0 -0
- package/tools/__pycache__/run_mcp_standalone.cpython-313.pyc +0 -0
- package/tools/__pycache__/task_registry.cpython-313.pyc +0 -0
- package/tools/__pycache__/test_mcp_connection.cpython-313.pyc +0 -0
- package/tools/bfp_relay.py +359 -0
- package/tools/bot.db +0 -0
- package/tools/get_session_summaries.py +45 -0
- package/tools/mcp_bridge.py +109 -0
- package/tools/mcp_server.py +531 -0
- package/tools/register_mcp_task.py +20 -0
- package/tools/run_detached.bat +32 -0
- package/tools/run_mcp_standalone.py +16 -0
- package/tools/task_registry.py +184 -0
- package/tools/test_mcp_connection.py +80 -0
- package/webchat/package-lock.json +1528 -0
- package/webchat/package.json +12 -0
- package/webchat/public/app.js +1293 -0
- package/webchat/public/index.html +226 -0
- package/webchat/public/index.html.bak +416 -0
- package/webchat/public/styles.css +2435 -0
- package/webchat/server.js +645 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""
|
|
2
|
+
BFP Agent — orchestrates BFP identity, card, transport, discovery, and relay communication.
|
|
3
|
+
Started automatically when BMO boots.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from core.bfp_identity import get_did, sign, verify
|
|
11
|
+
from core.bfp_agent_card import get_signed_card, serve_agent_card, generate_agent_card
|
|
12
|
+
from core.bfp_transport import BFPServer, BFPClient
|
|
13
|
+
from core.bfp_a2a_bridge import get_a2a_agent_card, start_a2a_endpoint
|
|
14
|
+
from core.bfp_connector import BFPConnector
|
|
15
|
+
from core.bfp_discovery import BFPDiscovery
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BFPAgent:
|
|
21
|
+
"""Manages BFP lifecycle within BMO."""
|
|
22
|
+
|
|
23
|
+
def __init__(self):
|
|
24
|
+
self.did = get_did()
|
|
25
|
+
self.card = get_signed_card()
|
|
26
|
+
self.bfp_server: Optional[BFPServer] = None
|
|
27
|
+
self.a2a_server = None
|
|
28
|
+
self.connector = BFPConnector()
|
|
29
|
+
self.discovery: Optional[BFPDiscovery] = None
|
|
30
|
+
self.relay_url: Optional[str] = None
|
|
31
|
+
self._running = False
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def is_running(self):
|
|
35
|
+
return self._running
|
|
36
|
+
|
|
37
|
+
async def start(self, bfp_port: int = 8765, a2a_port: int = 8766, relay_url: str = None):
|
|
38
|
+
"""Start BFP server, A2A bridge, and optionally connect to relay."""
|
|
39
|
+
self.bfp_server = BFPServer(port=bfp_port)
|
|
40
|
+
# BFPServer.start() is synchronous — it spawns a daemon thread internally
|
|
41
|
+
self.bfp_server.start()
|
|
42
|
+
try:
|
|
43
|
+
self.a2a_server = start_a2a_endpoint(port=a2a_port)
|
|
44
|
+
if asyncio.iscoroutine(self.a2a_server):
|
|
45
|
+
self.a2a_server = await self.a2a_server
|
|
46
|
+
except Exception as e:
|
|
47
|
+
logger.warning("A2A endpoint start failed (non-fatal): %s", e)
|
|
48
|
+
self.a2a_server = None
|
|
49
|
+
if relay_url:
|
|
50
|
+
self.relay_url = relay_url
|
|
51
|
+
self.discovery = BFPDiscovery(relay_url)
|
|
52
|
+
try:
|
|
53
|
+
await self.connector.connect(relay_url, on_message=self._on_relay_message)
|
|
54
|
+
except Exception as e:
|
|
55
|
+
logger.warning("BFP relay not available (non-fatal): %s", e)
|
|
56
|
+
self._running = True
|
|
57
|
+
logger.info("BFP Agent started — DID: %s", self.did)
|
|
58
|
+
|
|
59
|
+
async def stop(self):
|
|
60
|
+
"""Shut down BFP servers gracefully."""
|
|
61
|
+
if self.connector:
|
|
62
|
+
await self.connector.disconnect()
|
|
63
|
+
if self.bfp_server:
|
|
64
|
+
await self.bfp_server.stop()
|
|
65
|
+
if hasattr(self, 'a2a_server') and self.a2a_server:
|
|
66
|
+
import threading
|
|
67
|
+
threading.Thread(target=self.a2a_server.shutdown, daemon=True).start()
|
|
68
|
+
self._running = False
|
|
69
|
+
logger.info("BFP Agent stopped")
|
|
70
|
+
|
|
71
|
+
async def _on_relay_message(self, from_did: str, task_id: str, result: str):
|
|
72
|
+
logger.info("BFP relay message from %s — task %s: %s", from_did, task_id, result[:100])
|
|
73
|
+
|
|
74
|
+
def get_status(self) -> dict:
|
|
75
|
+
"""Return BFP status for /bfp status command."""
|
|
76
|
+
return {
|
|
77
|
+
"did": self.did,
|
|
78
|
+
"running": self._running,
|
|
79
|
+
"connected_to_relay": self.connector.is_connected,
|
|
80
|
+
"relay_url": self.relay_url,
|
|
81
|
+
"bfp_port": self.bfp_server.get_port() if self.bfp_server else None,
|
|
82
|
+
"capabilities": [c["name"] for c in self.card.get("capabilities", [])],
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async def find_agents(self, capability: str = None) -> list[dict]:
|
|
86
|
+
if not self.connector.is_connected:
|
|
87
|
+
raise RuntimeError("Not connected to relay — BFP relay required for discovery")
|
|
88
|
+
return await self.connector.find_agents(capability)
|
|
89
|
+
|
|
90
|
+
async def delegate(self, target_did: str, task_data: dict) -> dict:
|
|
91
|
+
if not self.connector.is_connected:
|
|
92
|
+
raise RuntimeError("Not connected to relay")
|
|
93
|
+
return await self.connector.delegate(target_did, task_data)
|
|
94
|
+
|
|
95
|
+
async def talk(self, target_did: str, message: str) -> str:
|
|
96
|
+
if not self.connector.is_connected:
|
|
97
|
+
raise RuntimeError("Not connected to relay")
|
|
98
|
+
return await self.connector.talk(target_did, message)
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""
|
|
2
|
+
BFP Agent Card system - Phase 2 of BMO Friendship Protocol
|
|
3
|
+
A2A-compatible agent card generation, caching, and serving
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import threading
|
|
9
|
+
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from config.settings import DATA_DIR
|
|
13
|
+
from core.bfp_identity import get_did, sign, verify, get_agent_card as _get_did_card
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
BFP_DIR = DATA_DIR / "bfp"
|
|
18
|
+
CARD_FILE = BFP_DIR / "signed-card.json"
|
|
19
|
+
|
|
20
|
+
AGENT_NAME = "BMO-Aliwey"
|
|
21
|
+
AGENT_VERSION = "1.0.0"
|
|
22
|
+
AGENT_HUMAN = "Aliwey"
|
|
23
|
+
|
|
24
|
+
DEFAULT_CAPABILITIES = [
|
|
25
|
+
{"id": "code", "name": "Code Assistant", "description": "Write, debug, refactor code"},
|
|
26
|
+
{"id": "research", "name": "Research", "description": "Web search and analysis"},
|
|
27
|
+
{"id": "files", "name": "File Operations", "description": "Read, write, edit files"},
|
|
28
|
+
{"id": "web", "name": "Web Access", "description": "Fetch URLs and browse the web"},
|
|
29
|
+
{"id": "terminal", "name": "Terminal", "description": "Execute shell commands"},
|
|
30
|
+
{"id": "memory", "name": "Memory", "description": "Store and recall information"},
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
DEFAULT_ENDPOINTS = [
|
|
34
|
+
{"type": "ws", "url": None, "description": "WebSocket for direct BFP communication"},
|
|
35
|
+
{"type": "a2a", "url": None, "description": "A2A-compatible HTTP endpoint"},
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
_signed_card = None
|
|
39
|
+
_lock = threading.Lock()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_capabilities() -> list:
|
|
43
|
+
return list(DEFAULT_CAPABILITIES)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def add_capability(cap_id: str, name: str, desc: str):
|
|
47
|
+
caps = get_capabilities()
|
|
48
|
+
for c in caps:
|
|
49
|
+
if c["id"] == cap_id:
|
|
50
|
+
c["name"] = name
|
|
51
|
+
c["description"] = desc
|
|
52
|
+
break
|
|
53
|
+
else:
|
|
54
|
+
caps.append({"id": cap_id, "name": name, "description": desc})
|
|
55
|
+
global _signed_card
|
|
56
|
+
_signed_card = None
|
|
57
|
+
generate_agent_card()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def generate_agent_card(endpoints: list = None) -> dict:
|
|
61
|
+
did = get_did()
|
|
62
|
+
card = {
|
|
63
|
+
"did": did,
|
|
64
|
+
"name": AGENT_NAME,
|
|
65
|
+
"version": AGENT_VERSION,
|
|
66
|
+
"human": AGENT_HUMAN,
|
|
67
|
+
"capabilities": get_capabilities(),
|
|
68
|
+
"endpoints": endpoints or list(DEFAULT_ENDPOINTS),
|
|
69
|
+
"publicKey": did.replace("did:bfp:", ""),
|
|
70
|
+
}
|
|
71
|
+
signed = sign(card)
|
|
72
|
+
BFP_DIR.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
with open(CARD_FILE, "w") as f:
|
|
74
|
+
json.dump(signed, f, indent=2)
|
|
75
|
+
global _signed_card
|
|
76
|
+
_signed_card = signed
|
|
77
|
+
return signed
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def get_signed_card() -> dict:
|
|
81
|
+
global _signed_card
|
|
82
|
+
if _signed_card is not None:
|
|
83
|
+
return _signed_card
|
|
84
|
+
with _lock:
|
|
85
|
+
if _signed_card is not None:
|
|
86
|
+
return _signed_card
|
|
87
|
+
if CARD_FILE.exists():
|
|
88
|
+
with open(CARD_FILE) as f:
|
|
89
|
+
_signed_card = json.load(f)
|
|
90
|
+
return _signed_card
|
|
91
|
+
return generate_agent_card()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def to_a2a_format(card: dict) -> dict:
|
|
95
|
+
return {
|
|
96
|
+
"name": card.get("name", AGENT_NAME),
|
|
97
|
+
"description": f"BFP Agent: {card.get('name', AGENT_NAME)}",
|
|
98
|
+
"url": None,
|
|
99
|
+
"skills": [
|
|
100
|
+
{"id": c["id"], "name": c["name"], "description": c["description"]}
|
|
101
|
+
for c in card.get("capabilities", [])
|
|
102
|
+
],
|
|
103
|
+
"authentication": {"schemes": ["did:bfp"]},
|
|
104
|
+
"version": card.get("version", AGENT_VERSION),
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class AgentCardHandler(BaseHTTPRequestHandler):
|
|
109
|
+
def do_GET(self):
|
|
110
|
+
if self.path == "/.well-known/agent-card.json":
|
|
111
|
+
card = get_signed_card()
|
|
112
|
+
body = json.dumps(card).encode("utf-8")
|
|
113
|
+
self.send_response(200)
|
|
114
|
+
self.send_header("Content-Type", "application/json")
|
|
115
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
116
|
+
self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
|
|
117
|
+
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
|
118
|
+
self.send_header("Content-Length", str(len(body)))
|
|
119
|
+
self.end_headers()
|
|
120
|
+
self.wfile.write(body)
|
|
121
|
+
else:
|
|
122
|
+
self.send_response(404)
|
|
123
|
+
self.end_headers()
|
|
124
|
+
|
|
125
|
+
def do_OPTIONS(self):
|
|
126
|
+
self.send_response(204)
|
|
127
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
128
|
+
self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
|
|
129
|
+
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
|
130
|
+
self.end_headers()
|
|
131
|
+
|
|
132
|
+
def log_message(self, fmt, *args):
|
|
133
|
+
logger.debug("AgentCard: %s", fmt % args)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class AgentCardServer:
|
|
137
|
+
def __init__(self, host: str = "0.0.0.0", port: int = 8765):
|
|
138
|
+
self.host = host
|
|
139
|
+
self.port = port
|
|
140
|
+
self.server = HTTPServer((host, port), AgentCardHandler)
|
|
141
|
+
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
|
142
|
+
|
|
143
|
+
def start(self):
|
|
144
|
+
self.thread.start()
|
|
145
|
+
logger.info("Agent Card server listening on %s:%s", self.host, self.port)
|
|
146
|
+
|
|
147
|
+
def stop(self):
|
|
148
|
+
self.server.shutdown()
|
|
149
|
+
logger.info("Agent Card server stopped")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
_server_instance = None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def serve_agent_card(host: str = "0.0.0.0", port: int = 8765):
|
|
156
|
+
global _server_instance
|
|
157
|
+
if _server_instance is not None:
|
|
158
|
+
return _server_instance
|
|
159
|
+
_server_instance = AgentCardServer(host, port)
|
|
160
|
+
_server_instance.start()
|
|
161
|
+
return _server_instance
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""BFP Connector — high-level relay-based agent communication.
|
|
2
|
+
|
|
3
|
+
Manages a persistent WebSocket connection to the BFP relay for:
|
|
4
|
+
- Discovery (find agents by capability)
|
|
5
|
+
- Message passing (send/receive through relay, no NAT issues)
|
|
6
|
+
- Task delegation and response
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import uuid
|
|
13
|
+
from typing import Callable, Optional
|
|
14
|
+
|
|
15
|
+
import websockets
|
|
16
|
+
|
|
17
|
+
from core.bfp_discovery import BFPDiscovery
|
|
18
|
+
from core.bfp_identity import get_did
|
|
19
|
+
from core.bfp_agent_card import get_capabilities
|
|
20
|
+
from core.bfp_tasks import create_task, get_task, process_task
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class BFPConnector:
|
|
26
|
+
"""Persistent relay connection for discovery + message passing.
|
|
27
|
+
|
|
28
|
+
Flow:
|
|
29
|
+
1. connect(relay_url) → opens WebSocket to relay, registers DID
|
|
30
|
+
2. find_agents(capability) → REST query to relay directory
|
|
31
|
+
3. send(target_did, payload) → relay forwards to target's WS connection
|
|
32
|
+
4. Incoming messages from relay are dispatched to on_message handler
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self):
|
|
36
|
+
self.my_did = get_did()
|
|
37
|
+
self.relay_url = None
|
|
38
|
+
self._ws = None
|
|
39
|
+
self._reader_task = None
|
|
40
|
+
self._running = False
|
|
41
|
+
self._pending: dict[str, asyncio.Future] = {}
|
|
42
|
+
self._on_message: Optional[Callable] = None
|
|
43
|
+
self._inbox: asyncio.Queue = asyncio.Queue()
|
|
44
|
+
self._reconnect_task = None
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def is_connected(self) -> bool:
|
|
48
|
+
return self._running and self._ws is not None
|
|
49
|
+
|
|
50
|
+
async def connect(self, relay_url: str, on_message: Callable = None):
|
|
51
|
+
"""Open persistent WS to relay and register."""
|
|
52
|
+
self.relay_url = relay_url.rstrip("/")
|
|
53
|
+
self._on_message = on_message
|
|
54
|
+
ws_url = self.relay_url.replace("http://", "ws://").replace("https://", "wss://") + "/ws"
|
|
55
|
+
self._ws = await websockets.connect(ws_url)
|
|
56
|
+
self._running = True
|
|
57
|
+
|
|
58
|
+
caps = [c["id"] for c in get_capabilities()]
|
|
59
|
+
await self._ws.send(json.dumps({
|
|
60
|
+
"action": "register",
|
|
61
|
+
"did": self.my_did,
|
|
62
|
+
"endpoint": f"relay:{self.relay_url}",
|
|
63
|
+
"capabilities": caps,
|
|
64
|
+
"name": "BMO",
|
|
65
|
+
}))
|
|
66
|
+
resp = json.loads(await self._ws.recv())
|
|
67
|
+
logger.info("BFP relay register: %s", resp.get("status", "ok"))
|
|
68
|
+
|
|
69
|
+
self._reader_task = asyncio.create_task(self._reader())
|
|
70
|
+
logger.info("BFP connector connected to relay %s", relay_url)
|
|
71
|
+
|
|
72
|
+
async def _reader(self):
|
|
73
|
+
try:
|
|
74
|
+
async for raw in self._ws:
|
|
75
|
+
try:
|
|
76
|
+
msg = json.loads(raw)
|
|
77
|
+
except json.JSONDecodeError:
|
|
78
|
+
continue
|
|
79
|
+
msg_type = msg.get("type", "")
|
|
80
|
+
|
|
81
|
+
if msg_type == "relay_message":
|
|
82
|
+
request_id = msg.get("requestId", "")
|
|
83
|
+
from_did = msg.get("fromDid", "")
|
|
84
|
+
payload = msg.get("payload", {})
|
|
85
|
+
await self._handle_incoming(from_did, payload, request_id)
|
|
86
|
+
elif msg_type == "send_result":
|
|
87
|
+
request_id = msg.get("requestId", "")
|
|
88
|
+
future = self._pending.get(request_id)
|
|
89
|
+
if future and not future.done():
|
|
90
|
+
future.set_result(msg)
|
|
91
|
+
elif msg_type == "agent_list":
|
|
92
|
+
pass
|
|
93
|
+
except websockets.exceptions.ConnectionClosed:
|
|
94
|
+
logger.warning("BFP relay WS disconnected")
|
|
95
|
+
finally:
|
|
96
|
+
self._running = False
|
|
97
|
+
if self._reconnect_task is None:
|
|
98
|
+
self._reconnect_task = asyncio.create_task(self._reconnect_loop())
|
|
99
|
+
|
|
100
|
+
async def _handle_incoming(self, from_did: str, payload: dict, request_id: str):
|
|
101
|
+
task_data = payload.get("params", {}).get("task", payload)
|
|
102
|
+
task_id = create_task(task_data, from_did)
|
|
103
|
+
task = get_task(task_id)
|
|
104
|
+
result = task.get("result", "") if task else ""
|
|
105
|
+
|
|
106
|
+
if request_id and self._ws and not self._ws.closed:
|
|
107
|
+
await self._ws.send(json.dumps({
|
|
108
|
+
"action": "relay_response",
|
|
109
|
+
"requestId": request_id,
|
|
110
|
+
"payload": {"task_id": task_id, "result": result, "from": self.my_did},
|
|
111
|
+
}))
|
|
112
|
+
|
|
113
|
+
if self._on_message:
|
|
114
|
+
await self._on_message(from_did, task_id, result)
|
|
115
|
+
|
|
116
|
+
async def _reconnect_loop(self):
|
|
117
|
+
await asyncio.sleep(5)
|
|
118
|
+
while not self._running:
|
|
119
|
+
try:
|
|
120
|
+
await self.connect(self.relay_url, self._on_message)
|
|
121
|
+
logger.info("BFP reconnected to relay")
|
|
122
|
+
self._reconnect_task = None
|
|
123
|
+
return
|
|
124
|
+
except Exception as e:
|
|
125
|
+
logger.warning("BFP reconnect failed: %s, retry in 10s", e)
|
|
126
|
+
await asyncio.sleep(10)
|
|
127
|
+
|
|
128
|
+
async def send(self, target_did: str, payload: dict, timeout: float = 60.0) -> dict:
|
|
129
|
+
if not self._ws or self._ws.closed:
|
|
130
|
+
raise RuntimeError("Not connected to relay")
|
|
131
|
+
request_id = f"bfp-{uuid.uuid4().hex[:12]}"
|
|
132
|
+
future = asyncio.get_event_loop().create_future()
|
|
133
|
+
self._pending[request_id] = future
|
|
134
|
+
try:
|
|
135
|
+
await self._ws.send(json.dumps({
|
|
136
|
+
"action": "send",
|
|
137
|
+
"targetDid": target_did,
|
|
138
|
+
"fromDid": self.my_did,
|
|
139
|
+
"payload": payload,
|
|
140
|
+
"requestId": request_id,
|
|
141
|
+
}))
|
|
142
|
+
result = await asyncio.wait_for(future, timeout=timeout)
|
|
143
|
+
return result.get("response", {})
|
|
144
|
+
finally:
|
|
145
|
+
self._pending.pop(request_id, None)
|
|
146
|
+
|
|
147
|
+
async def delegate(self, target_did: str, task_data: dict) -> dict:
|
|
148
|
+
return await self.send(target_did, {
|
|
149
|
+
"method": "bfp.delegate",
|
|
150
|
+
"params": {
|
|
151
|
+
"from": self.my_did,
|
|
152
|
+
"to": target_did,
|
|
153
|
+
"task": task_data,
|
|
154
|
+
},
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
async def find_agents(self, capability: str = None) -> list[dict]:
|
|
158
|
+
discovery = BFPDiscovery(self.relay_url)
|
|
159
|
+
try:
|
|
160
|
+
if capability:
|
|
161
|
+
return await discovery.find_by_capability(capability)
|
|
162
|
+
return await discovery.list_all()
|
|
163
|
+
finally:
|
|
164
|
+
await discovery.close()
|
|
165
|
+
|
|
166
|
+
async def talk(self, target_did: str, message: str) -> str:
|
|
167
|
+
result = await self.delegate(target_did, {"query": message, "action": "talk"})
|
|
168
|
+
return result.get("result", "")
|
|
169
|
+
|
|
170
|
+
async def disconnect(self):
|
|
171
|
+
self._running = False
|
|
172
|
+
if self._reader_task:
|
|
173
|
+
self._reader_task.cancel()
|
|
174
|
+
self._reader_task = None
|
|
175
|
+
if self._ws:
|
|
176
|
+
await self._ws.close()
|
|
177
|
+
self._ws = None
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""BFP Discovery — Cloudflare Workers registry client.
|
|
2
|
+
|
|
3
|
+
REST client for registering, resolving, and finding agents by capability
|
|
4
|
+
through the BFP Registry (Cloudflare Workers KV).
|
|
5
|
+
|
|
6
|
+
Registry URL: https://bfp-registry.aliwey.workers.dev
|
|
7
|
+
(override via BFP_REGISTRY_URL env var or bmo_init config)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
# Default public registry — hosted on Cloudflare Workers (free tier)
|
|
19
|
+
DEFAULT_REGISTRY_URL = "https://bfp-registry.aliwey.workers.dev"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BFPDiscovery:
|
|
23
|
+
"""Client for the BFP Registry (Cloudflare Workers KV)."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, relay_url: str = None):
|
|
26
|
+
# relay_url kept for backward compat but we use the registry URL
|
|
27
|
+
self.relay_url = relay_url # local relay WebSocket URL (if any)
|
|
28
|
+
self.registry_url = (
|
|
29
|
+
os.getenv("BFP_REGISTRY_URL") or DEFAULT_REGISTRY_URL
|
|
30
|
+
).rstrip("/")
|
|
31
|
+
self._http = httpx.AsyncClient(timeout=10.0)
|
|
32
|
+
|
|
33
|
+
# ── Registry (Cloudflare Workers) ─────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
async def register(
|
|
36
|
+
self,
|
|
37
|
+
did: str,
|
|
38
|
+
endpoint: str,
|
|
39
|
+
caps: list,
|
|
40
|
+
public_key: str = None, # unused by Workers registry, kept for compat
|
|
41
|
+
name: str = None, # unused by Workers registry, kept for compat
|
|
42
|
+
) -> dict:
|
|
43
|
+
resp = await self._http.post(f"{self.registry_url}/register", json={
|
|
44
|
+
"did": did,
|
|
45
|
+
"endpoint": endpoint,
|
|
46
|
+
"caps": caps,
|
|
47
|
+
})
|
|
48
|
+
resp.raise_for_status()
|
|
49
|
+
return resp.json()
|
|
50
|
+
|
|
51
|
+
async def unregister(self, did: str) -> None:
|
|
52
|
+
try:
|
|
53
|
+
resp = await self._http.post(f"{self.registry_url}/unregister", json={"did": did})
|
|
54
|
+
resp.raise_for_status()
|
|
55
|
+
except Exception as e:
|
|
56
|
+
logger.warning(f"BFP unregister failed: {e}")
|
|
57
|
+
|
|
58
|
+
async def lookup(self, did: str) -> Optional[dict]:
|
|
59
|
+
"""Resolve a DID to its current endpoint and capabilities."""
|
|
60
|
+
try:
|
|
61
|
+
resp = await self._http.get(f"{self.registry_url}/lookup", params={"did": did})
|
|
62
|
+
if resp.status_code == 404:
|
|
63
|
+
return None
|
|
64
|
+
resp.raise_for_status()
|
|
65
|
+
return resp.json()
|
|
66
|
+
except httpx.HTTPStatusError as e:
|
|
67
|
+
if e.response.status_code == 404:
|
|
68
|
+
return None
|
|
69
|
+
raise
|
|
70
|
+
|
|
71
|
+
# kept for backward compat (old code calls resolve(), new calls lookup())
|
|
72
|
+
async def resolve(self, did: str) -> Optional[dict]:
|
|
73
|
+
return await self.lookup(did)
|
|
74
|
+
|
|
75
|
+
async def find_agents(self, capability: str = None) -> list[dict]:
|
|
76
|
+
"""Find online agents by capability (or list all if no capability given)."""
|
|
77
|
+
params = {"capability": capability} if capability else {}
|
|
78
|
+
resp = await self._http.get(f"{self.registry_url}/list", params=params)
|
|
79
|
+
resp.raise_for_status()
|
|
80
|
+
data = resp.json()
|
|
81
|
+
# Registry returns a list; old relay returned {"agents": [...]}
|
|
82
|
+
if isinstance(data, list):
|
|
83
|
+
return data
|
|
84
|
+
return data.get("agents", [])
|
|
85
|
+
|
|
86
|
+
# kept for backward compat (old code calls find_by_capability)
|
|
87
|
+
async def find_by_capability(self, capability: str) -> list[dict]:
|
|
88
|
+
return await self.find_agents(capability)
|
|
89
|
+
|
|
90
|
+
async def list_all(self) -> list[dict]:
|
|
91
|
+
resp = await self._http.get(f"{self.registry_url}/list")
|
|
92
|
+
resp.raise_for_status()
|
|
93
|
+
result = resp.json()
|
|
94
|
+
if isinstance(result, list):
|
|
95
|
+
return result
|
|
96
|
+
return result.get("agents", [])
|
|
97
|
+
|
|
98
|
+
async def health(self) -> dict:
|
|
99
|
+
"""Check registry health."""
|
|
100
|
+
resp = await self._http.get(f"{self.registry_url}/health")
|
|
101
|
+
resp.raise_for_status()
|
|
102
|
+
return resp.json()
|
|
103
|
+
|
|
104
|
+
async def close(self):
|
|
105
|
+
await self._http.aclose()
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import base58
|
|
6
|
+
from nacl.encoding import RawEncoder
|
|
7
|
+
from nacl.signing import SigningKey, VerifyKey
|
|
8
|
+
|
|
9
|
+
from config.settings import DATA_DIR
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
BFP_DIR = DATA_DIR / "bfp"
|
|
13
|
+
IDENTITY_KEY_FILE = BFP_DIR / "identity.key"
|
|
14
|
+
AGENT_CARD_FILE = BFP_DIR / "agent-card.json"
|
|
15
|
+
|
|
16
|
+
_signing_key: SigningKey | None = None
|
|
17
|
+
_verify_key: VerifyKey | None = None
|
|
18
|
+
_did: str | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _ensure_identity():
|
|
22
|
+
global _signing_key, _verify_key, _did
|
|
23
|
+
if _signing_key is not None:
|
|
24
|
+
return
|
|
25
|
+
|
|
26
|
+
BFP_DIR.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
|
|
28
|
+
if IDENTITY_KEY_FILE.exists():
|
|
29
|
+
seed = IDENTITY_KEY_FILE.read_bytes()
|
|
30
|
+
else:
|
|
31
|
+
seed = os.urandom(32)
|
|
32
|
+
IDENTITY_KEY_FILE.write_bytes(seed)
|
|
33
|
+
|
|
34
|
+
_signing_key = SigningKey(seed, encoder=RawEncoder)
|
|
35
|
+
_verify_key = _signing_key.verify_key
|
|
36
|
+
pub_bytes = _verify_key.encode(encoder=RawEncoder)
|
|
37
|
+
_did = "did:bfp:" + base58.b58encode(pub_bytes).decode("ascii")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_did() -> str:
|
|
41
|
+
_ensure_identity()
|
|
42
|
+
return _did
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def sign(data: dict) -> dict:
|
|
46
|
+
_ensure_identity()
|
|
47
|
+
payload = json.dumps(data, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
48
|
+
signed = _signing_key.sign(payload, encoder=RawEncoder)
|
|
49
|
+
return {**data, "signature": base58.b58encode(signed.signature).decode("ascii")}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def verify(did: str, data: dict) -> bool:
|
|
53
|
+
if "signature" not in data:
|
|
54
|
+
return False
|
|
55
|
+
signature_b58 = data["signature"]
|
|
56
|
+
remaining = {k: v for k, v in data.items() if k != "signature"}
|
|
57
|
+
payload = json.dumps(remaining, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
58
|
+
try:
|
|
59
|
+
if not did.startswith("did:bfp:"):
|
|
60
|
+
return False
|
|
61
|
+
pub_b58 = did[len("did:bfp:"):]
|
|
62
|
+
pub_bytes = base58.b58decode(pub_b58)
|
|
63
|
+
sig_bytes = base58.b58decode(signature_b58)
|
|
64
|
+
verify_key = VerifyKey(pub_bytes)
|
|
65
|
+
verify_key.verify(payload, sig_bytes)
|
|
66
|
+
return True
|
|
67
|
+
except Exception:
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def get_agent_card() -> dict:
|
|
72
|
+
_ensure_identity()
|
|
73
|
+
if AGENT_CARD_FILE.exists():
|
|
74
|
+
return json.loads(AGENT_CARD_FILE.read_text("utf-8"))
|
|
75
|
+
pub_bytes = _verify_key.encode(encoder=RawEncoder)
|
|
76
|
+
card = {
|
|
77
|
+
"did": _did,
|
|
78
|
+
"name": "BMO",
|
|
79
|
+
"version": "1.0.0",
|
|
80
|
+
"publicKey": base58.b58encode(pub_bytes).decode("ascii"),
|
|
81
|
+
}
|
|
82
|
+
AGENT_CARD_FILE.write_text(json.dumps(card, indent=2), "utf-8")
|
|
83
|
+
return card
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Shared task registry for BFP delegation.
|
|
2
|
+
|
|
3
|
+
Both the WebSocket transport server and the A2A HTTP bridge use this
|
|
4
|
+
module to create, track, and process delegated tasks — no WebSocket
|
|
5
|
+
loopback required.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import uuid
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
_tasks: dict[str, dict] = {}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def create_task(task_data: dict, source_did: str) -> str:
|
|
19
|
+
task_id = f"task-{uuid.uuid4().hex[:12]}"
|
|
20
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
21
|
+
_tasks[task_id] = {
|
|
22
|
+
"id": task_id,
|
|
23
|
+
"source": source_did,
|
|
24
|
+
"data": task_data,
|
|
25
|
+
"status": "pending",
|
|
26
|
+
"result": None,
|
|
27
|
+
"error": None,
|
|
28
|
+
"created_at": now,
|
|
29
|
+
"updated_at": now,
|
|
30
|
+
"completed_at": None,
|
|
31
|
+
}
|
|
32
|
+
logger.info("Task %s created by %s", task_id, source_did)
|
|
33
|
+
process_task(task_id)
|
|
34
|
+
return task_id
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_task(task_id: str) -> Optional[dict]:
|
|
38
|
+
return _tasks.get(task_id)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def update_task(task_id: str, status: str, result: str = None, error: str = None):
|
|
42
|
+
task = _tasks.get(task_id)
|
|
43
|
+
if task is None:
|
|
44
|
+
return
|
|
45
|
+
task["status"] = status
|
|
46
|
+
task["updated_at"] = datetime.now(timezone.utc).isoformat()
|
|
47
|
+
if result is not None:
|
|
48
|
+
task["result"] = result
|
|
49
|
+
if error is not None:
|
|
50
|
+
task["error"] = error
|
|
51
|
+
if status in ("completed", "failed", "cancelled"):
|
|
52
|
+
task["completed_at"] = task["updated_at"]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def process_task(task_id: str) -> None:
|
|
56
|
+
"""Process a task — currently auto-completes with placeholder.
|
|
57
|
+
|
|
58
|
+
In the future this will route through BMO's engine or dispatch to
|
|
59
|
+
worker processes for real execution.
|
|
60
|
+
"""
|
|
61
|
+
task = _tasks.get(task_id)
|
|
62
|
+
if task is None or task["status"] != "pending":
|
|
63
|
+
return
|
|
64
|
+
task["status"] = "processing"
|
|
65
|
+
task["updated_at"] = datetime.now(timezone.utc).isoformat()
|
|
66
|
+
task["status"] = "completed"
|
|
67
|
+
task["result"] = f"Delegated task processed. Payload: {task['data']}"
|
|
68
|
+
task["completed_at"] = datetime.now(timezone.utc).isoformat()
|
|
69
|
+
task["updated_at"] = task["completed_at"]
|
|
70
|
+
logger.info("Task %s completed", task_id)
|