elo-node 0.4.0__py3-none-any.whl
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.
- elo/__init__.py +29 -0
- elo/__main__.py +153 -0
- elo/node.py +498 -0
- elo/security.py +201 -0
- elo/transport/__init__.py +34 -0
- elo/transport/protocol.py +166 -0
- elo/transport/routing.py +135 -0
- elo/transport/tcp.py +327 -0
- elo/transport/tracker.py +97 -0
- elo/types.py +126 -0
- elo_node-0.4.0.dist-info/METADATA +107 -0
- elo_node-0.4.0.dist-info/RECORD +15 -0
- elo_node-0.4.0.dist-info/WHEEL +5 -0
- elo_node-0.4.0.dist-info/entry_points.txt +2 -0
- elo_node-0.4.0.dist-info/top_level.txt +1 -0
elo/transport/tcp.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""TCP connection manager — persistent peer connections with framing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Callable, Awaitable
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from elo.transport.protocol import (
|
|
12
|
+
encode_frame,
|
|
13
|
+
read_frame,
|
|
14
|
+
write_frame,
|
|
15
|
+
bye_msg,
|
|
16
|
+
heartbeat_msg,
|
|
17
|
+
FrameError,
|
|
18
|
+
MessageType,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("elo.transport.tcp")
|
|
22
|
+
|
|
23
|
+
# Message handler signature: (peer_addr: str, message: dict) → None
|
|
24
|
+
MessageHandler = Callable[[str, dict[str, Any]], Awaitable[None]]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PeerConnection:
|
|
28
|
+
"""A single TCP connection to a peer."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, addr: str, reader: asyncio.StreamReader,
|
|
31
|
+
writer: asyncio.StreamWriter):
|
|
32
|
+
self.addr = addr
|
|
33
|
+
self._reader = reader
|
|
34
|
+
self._writer = writer
|
|
35
|
+
self._alive = True
|
|
36
|
+
self._last_read = time.monotonic()
|
|
37
|
+
|
|
38
|
+
async def send(self, message: dict[str, Any]) -> None:
|
|
39
|
+
"""Send a framed message to the peer."""
|
|
40
|
+
if not self._alive:
|
|
41
|
+
raise ConnectionError("Peer disconnected")
|
|
42
|
+
try:
|
|
43
|
+
await write_frame(self._writer, message)
|
|
44
|
+
except Exception:
|
|
45
|
+
self._alive = False
|
|
46
|
+
raise
|
|
47
|
+
|
|
48
|
+
async def recv(self) -> dict[str, Any] | None:
|
|
49
|
+
"""Receive one framed message from the peer. Returns None on EOF."""
|
|
50
|
+
try:
|
|
51
|
+
msg = await read_frame(self._reader)
|
|
52
|
+
self._last_read = time.monotonic()
|
|
53
|
+
return msg
|
|
54
|
+
except (FrameError, asyncio.IncompleteReadError, ConnectionError):
|
|
55
|
+
self._alive = False
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
def close(self) -> None:
|
|
59
|
+
"""Close the connection."""
|
|
60
|
+
self._alive = False
|
|
61
|
+
try:
|
|
62
|
+
self._writer.close()
|
|
63
|
+
except Exception:
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def alive(self) -> bool:
|
|
68
|
+
return self._alive
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def idle_seconds(self) -> float:
|
|
72
|
+
return time.monotonic() - self._last_read
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class TCPManager:
|
|
76
|
+
"""Manages all TCP peer connections: server + client connections."""
|
|
77
|
+
|
|
78
|
+
# Timeout configuration
|
|
79
|
+
CONNECT_TIMEOUT = 10 # seconds
|
|
80
|
+
HEARTBEAT_INTERVAL = 30 # seconds
|
|
81
|
+
HEARTBEAT_IDLE_TIMEOUT = 90 # 3 missed heartbeats
|
|
82
|
+
|
|
83
|
+
def __init__(self, node_id: str, host: str = "0.0.0.0", port: int = 7878):
|
|
84
|
+
self._node_id = node_id
|
|
85
|
+
self._host = host
|
|
86
|
+
self._port = port
|
|
87
|
+
self._server: asyncio.Server | None = None
|
|
88
|
+
self._peers: dict[str, PeerConnection] = {}
|
|
89
|
+
self._handler: MessageHandler | None = None
|
|
90
|
+
self._shutdown = asyncio.Event()
|
|
91
|
+
self._heartbeat_task: asyncio.Task | None = None
|
|
92
|
+
self._listen_task: asyncio.Task | None = None
|
|
93
|
+
|
|
94
|
+
# ── lifecycle ────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
async def start(self) -> int:
|
|
97
|
+
"""Start the TCP server. Returns the actual port."""
|
|
98
|
+
self._server = await asyncio.start_server(
|
|
99
|
+
self._handle_incoming,
|
|
100
|
+
host=self._host,
|
|
101
|
+
port=self._port,
|
|
102
|
+
)
|
|
103
|
+
# Get the actual port (useful if port=0 for random assignment)
|
|
104
|
+
if self._port == 0 and self._server.sockets:
|
|
105
|
+
self._port = self._server.sockets[0].getsockname()[1]
|
|
106
|
+
|
|
107
|
+
logger.info("[tcp] listening on %s:%d", self._host, self._port)
|
|
108
|
+
return self._port
|
|
109
|
+
|
|
110
|
+
async def stop(self) -> None:
|
|
111
|
+
"""Stop the server and close all peer connections."""
|
|
112
|
+
self._shutdown.set()
|
|
113
|
+
|
|
114
|
+
if self._heartbeat_task:
|
|
115
|
+
self._heartbeat_task.cancel()
|
|
116
|
+
|
|
117
|
+
# Send BYE to all peers
|
|
118
|
+
for addr, peer in list(self._peers.items()):
|
|
119
|
+
try:
|
|
120
|
+
await peer.send(bye_msg())
|
|
121
|
+
except Exception:
|
|
122
|
+
pass
|
|
123
|
+
peer.close()
|
|
124
|
+
|
|
125
|
+
self._peers.clear()
|
|
126
|
+
|
|
127
|
+
if self._server:
|
|
128
|
+
self._server.close()
|
|
129
|
+
await self._server.wait_closed()
|
|
130
|
+
|
|
131
|
+
logger.info("[tcp] stopped")
|
|
132
|
+
|
|
133
|
+
# ── handlers ─────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
def on_message(self, handler: MessageHandler) -> None:
|
|
136
|
+
"""Register a message handler."""
|
|
137
|
+
self._handler = handler
|
|
138
|
+
|
|
139
|
+
async def _handle_incoming(self, reader: asyncio.StreamReader,
|
|
140
|
+
writer: asyncio.StreamWriter) -> None:
|
|
141
|
+
"""Handle a new incoming TCP connection."""
|
|
142
|
+
peername = writer.get_extra_info("peername")
|
|
143
|
+
addr = f"{peername[0]}:{peername[1]}" if peername else "unknown"
|
|
144
|
+
logger.debug("[tcp] incoming from %s", addr)
|
|
145
|
+
|
|
146
|
+
peer = PeerConnection(addr, reader, writer)
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
# Read HELLO as first message
|
|
150
|
+
hello = await peer.recv()
|
|
151
|
+
if hello is None or hello.get("type") != MessageType.HELLO:
|
|
152
|
+
logger.warning("[tcp] no hello from %s", addr)
|
|
153
|
+
peer.close()
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
remote_id = hello.get("node_id", "")
|
|
157
|
+
addr = f"{remote_id[:12]}@{addr}"
|
|
158
|
+
peer.addr = addr
|
|
159
|
+
|
|
160
|
+
self._peers[addr] = peer
|
|
161
|
+
logger.info("[tcp] peer connected: %s", addr)
|
|
162
|
+
|
|
163
|
+
# Notify handler about the peer + its HELLO
|
|
164
|
+
if self._handler:
|
|
165
|
+
await self._handler(addr, hello)
|
|
166
|
+
|
|
167
|
+
except Exception:
|
|
168
|
+
logger.debug("[tcp] connection error from %s", addr, exc_info=True)
|
|
169
|
+
peer.close()
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
# Start read loop (shared by inbound and outbound)
|
|
173
|
+
await self._read_loop(addr, peer)
|
|
174
|
+
|
|
175
|
+
# ── read loop (shared by inbound + outbound) ───────────
|
|
176
|
+
|
|
177
|
+
async def _read_loop(self, addr: str, peer: PeerConnection) -> None:
|
|
178
|
+
"""Continuously read messages from a peer until disconnect."""
|
|
179
|
+
try:
|
|
180
|
+
while not self._shutdown.is_set():
|
|
181
|
+
msg = await peer.recv()
|
|
182
|
+
if msg is None:
|
|
183
|
+
break
|
|
184
|
+
if self._handler:
|
|
185
|
+
await self._handler(addr, msg)
|
|
186
|
+
except Exception:
|
|
187
|
+
logger.debug("[tcp] read error from %s", addr, exc_info=True)
|
|
188
|
+
finally:
|
|
189
|
+
peer.close()
|
|
190
|
+
self._peers.pop(addr, None)
|
|
191
|
+
if self._handler:
|
|
192
|
+
try:
|
|
193
|
+
await self._handler(addr, {"type": MessageType.BYE, "node_id": ""})
|
|
194
|
+
except Exception:
|
|
195
|
+
pass
|
|
196
|
+
logger.info("[tcp] peer disconnected: %s", addr)
|
|
197
|
+
|
|
198
|
+
# ── outbound connections ─────────────────────────────────
|
|
199
|
+
|
|
200
|
+
async def connect_to_peer(self, addr: str, node_id: str = "unknown",
|
|
201
|
+
hello_payload: dict | None = None) -> str | None:
|
|
202
|
+
"""Connect to a remote peer. Returns the assigned peer address on success."""
|
|
203
|
+
if ":" not in addr:
|
|
204
|
+
logger.warning("[tcp] invalid address: %s", addr)
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
# Normalize: if no port, use default
|
|
208
|
+
host, _, port_str = addr.partition(":")
|
|
209
|
+
port = int(port_str) if port_str else self._port
|
|
210
|
+
canonical = f"{host}:{port}"
|
|
211
|
+
|
|
212
|
+
if canonical in self._peers:
|
|
213
|
+
return canonical
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
reader, writer = await asyncio.wait_for(
|
|
217
|
+
asyncio.open_connection(host, port),
|
|
218
|
+
timeout=self.CONNECT_TIMEOUT,
|
|
219
|
+
)
|
|
220
|
+
except Exception as e:
|
|
221
|
+
logger.debug("[tcp] connect to %s failed: %s", canonical, e)
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
peer = PeerConnection(canonical, reader, writer)
|
|
225
|
+
|
|
226
|
+
# Send HELLO
|
|
227
|
+
if hello_payload:
|
|
228
|
+
await peer.send(hello_payload)
|
|
229
|
+
|
|
230
|
+
# Wait for HELLO_ACK
|
|
231
|
+
try:
|
|
232
|
+
ack = await asyncio.wait_for(peer.recv(), timeout=5)
|
|
233
|
+
except asyncio.TimeoutError:
|
|
234
|
+
peer.close()
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
if ack is None or ack.get("type") != MessageType.HELLO_ACK:
|
|
238
|
+
peer.close()
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
remote_id = ack.get("node_id", "")
|
|
242
|
+
display_addr = f"{remote_id[:12]}@{canonical}"
|
|
243
|
+
peer.addr = display_addr
|
|
244
|
+
|
|
245
|
+
self._peers[display_addr] = peer
|
|
246
|
+
|
|
247
|
+
# Notify handler with HELLO_ACK
|
|
248
|
+
if self._handler:
|
|
249
|
+
await self._handler(display_addr, ack)
|
|
250
|
+
|
|
251
|
+
logger.info("[tcp] connected to %s", display_addr)
|
|
252
|
+
|
|
253
|
+
# Start read loop in background
|
|
254
|
+
asyncio.create_task(self._read_loop(display_addr, peer))
|
|
255
|
+
|
|
256
|
+
return display_addr
|
|
257
|
+
|
|
258
|
+
async def send_to(self, peer_addr: str, message: dict[str, Any]) -> None:
|
|
259
|
+
"""Send a message to a specific peer."""
|
|
260
|
+
peer = self._peers.get(peer_addr)
|
|
261
|
+
if peer is None:
|
|
262
|
+
raise ConnectionError(f"Peer not connected: {peer_addr}")
|
|
263
|
+
await peer.send(message)
|
|
264
|
+
|
|
265
|
+
# ── broadcast ────────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
async def broadcast(self, message: dict[str, Any],
|
|
268
|
+
exclude: set[str] | None = None) -> None:
|
|
269
|
+
"""Send a message to all connected peers."""
|
|
270
|
+
exclude = exclude or set()
|
|
271
|
+
for addr, peer in list(self._peers.items()):
|
|
272
|
+
if addr in exclude:
|
|
273
|
+
continue
|
|
274
|
+
try:
|
|
275
|
+
await peer.send(message)
|
|
276
|
+
except Exception:
|
|
277
|
+
logger.debug("[tcp] broadcast failed to %s", addr)
|
|
278
|
+
|
|
279
|
+
# ── heartbeat ────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
async def start_heartbeat(self) -> None:
|
|
282
|
+
"""Periodically send heartbeats and purge dead peers."""
|
|
283
|
+
while not self._shutdown.is_set():
|
|
284
|
+
try:
|
|
285
|
+
await asyncio.wait_for(
|
|
286
|
+
self._shutdown.wait(),
|
|
287
|
+
timeout=self.HEARTBEAT_INTERVAL,
|
|
288
|
+
)
|
|
289
|
+
break
|
|
290
|
+
except asyncio.TimeoutError:
|
|
291
|
+
pass
|
|
292
|
+
|
|
293
|
+
hb = heartbeat_msg(self._node_id)
|
|
294
|
+
dead = []
|
|
295
|
+
|
|
296
|
+
for addr, peer in list(self._peers.items()):
|
|
297
|
+
try:
|
|
298
|
+
await peer.send(hb)
|
|
299
|
+
except Exception:
|
|
300
|
+
dead.append(addr)
|
|
301
|
+
|
|
302
|
+
# Check idle timeout
|
|
303
|
+
if peer.idle_seconds > self.HEARTBEAT_IDLE_TIMEOUT:
|
|
304
|
+
dead.append(addr)
|
|
305
|
+
|
|
306
|
+
for addr in set(dead):
|
|
307
|
+
peer = self._peers.pop(addr, None)
|
|
308
|
+
if peer:
|
|
309
|
+
peer.close()
|
|
310
|
+
if self._handler:
|
|
311
|
+
await self._handler(addr,
|
|
312
|
+
{"type": MessageType.BYE, "node_id": ""})
|
|
313
|
+
logger.info("[tcp] peer timed out: %s", addr)
|
|
314
|
+
|
|
315
|
+
# ── accessors ────────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
@property
|
|
318
|
+
def peer_count(self) -> int:
|
|
319
|
+
return len(self._peers)
|
|
320
|
+
|
|
321
|
+
@property
|
|
322
|
+
def peer_addresses(self) -> list[str]:
|
|
323
|
+
return list(self._peers.keys())
|
|
324
|
+
|
|
325
|
+
@property
|
|
326
|
+
def port(self) -> int:
|
|
327
|
+
return self._port
|
elo/transport/tracker.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Local tracker — each node publishes its own agent/tool registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LocalTracker:
|
|
9
|
+
"""A node's local registry of agents, models, and tools.
|
|
10
|
+
|
|
11
|
+
Visibility modes:
|
|
12
|
+
- "public": any connected peer can query capabilities
|
|
13
|
+
- "private": only peers in the allowlist can query
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, visibility: str = "public"):
|
|
17
|
+
self._visibility = visibility
|
|
18
|
+
self._caps: dict[str, list[dict[str, str]]] = {
|
|
19
|
+
"agents": [],
|
|
20
|
+
"models": [],
|
|
21
|
+
"tools": [],
|
|
22
|
+
}
|
|
23
|
+
self._allowlist: set[str] = set() # node_ids (for private mode)
|
|
24
|
+
self._interests: list[str] = []
|
|
25
|
+
|
|
26
|
+
# ── visibility ───────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def visibility(self) -> str:
|
|
30
|
+
return self._visibility
|
|
31
|
+
|
|
32
|
+
def set_visibility(self, mode: str) -> None:
|
|
33
|
+
if mode not in ("public", "private"):
|
|
34
|
+
raise ValueError(f"Invalid visibility: {mode}")
|
|
35
|
+
self._visibility = mode
|
|
36
|
+
|
|
37
|
+
def allow_peer(self, node_id: str) -> None:
|
|
38
|
+
self._allowlist.add(node_id)
|
|
39
|
+
|
|
40
|
+
def revoke_peer(self, node_id: str) -> None:
|
|
41
|
+
self._allowlist.discard(node_id)
|
|
42
|
+
|
|
43
|
+
def is_allowed(self, node_id: str) -> bool:
|
|
44
|
+
if self._visibility == "public":
|
|
45
|
+
return True
|
|
46
|
+
return node_id in self._allowlist
|
|
47
|
+
|
|
48
|
+
# ── caps ─────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
def register(self, *, agents: list[dict[str, str]] | None = None,
|
|
51
|
+
models: list[dict[str, str]] | None = None,
|
|
52
|
+
tools: list[dict[str, str]] | None = None) -> None:
|
|
53
|
+
if agents:
|
|
54
|
+
self._caps["agents"] = agents
|
|
55
|
+
if models:
|
|
56
|
+
self._caps["models"] = models
|
|
57
|
+
if tools:
|
|
58
|
+
self._caps["tools"] = tools
|
|
59
|
+
|
|
60
|
+
def set_interests(self, interests: list[str]) -> None:
|
|
61
|
+
self._interests = interests
|
|
62
|
+
|
|
63
|
+
def get_caps_for_peer(self, node_id: str) -> dict[str, list[dict[str, str]]]:
|
|
64
|
+
"""Return capabilities visible to the given peer."""
|
|
65
|
+
if self.is_allowed(node_id):
|
|
66
|
+
return self._caps
|
|
67
|
+
return {"agents": [], "models": [], "tools": []}
|
|
68
|
+
|
|
69
|
+
def get_public_caps(self) -> dict[str, list[dict[str, str]]]:
|
|
70
|
+
"""Return capabilities for public advertisement (HELLO)."""
|
|
71
|
+
if self._visibility == "public":
|
|
72
|
+
return self._caps
|
|
73
|
+
return {"agents": [], "models": [], "tools": []}
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def caps(self) -> dict[str, list[dict[str, str]]]:
|
|
77
|
+
return self._caps
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def interests(self) -> list[str]:
|
|
81
|
+
return self._interests
|
|
82
|
+
|
|
83
|
+
def has_capability(self, name: str) -> bool:
|
|
84
|
+
"""Check if this node has a specific capability."""
|
|
85
|
+
for category in ("agents", "models", "tools"):
|
|
86
|
+
for item in self._caps[category]:
|
|
87
|
+
if item.get("name") == name:
|
|
88
|
+
return True
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
def match(self, capability: str) -> dict[str, Any] | None:
|
|
92
|
+
"""Match a capability by name. Returns the matched item or None."""
|
|
93
|
+
for category in ("agents", "models", "tools"):
|
|
94
|
+
for item in self._caps[category]:
|
|
95
|
+
if item.get("name") == capability:
|
|
96
|
+
return {"category": category, **item}
|
|
97
|
+
return None
|
elo/types.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Tipos do protocolo Elo v1."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field, asdict
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Any
|
|
8
|
+
import uuid
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class AgentCap:
|
|
13
|
+
name: str
|
|
14
|
+
description: str = ""
|
|
15
|
+
model: str = ""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class ModelCap:
|
|
20
|
+
name: str
|
|
21
|
+
provider: str = ""
|
|
22
|
+
context: int = 0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ToolCap:
|
|
27
|
+
name: str
|
|
28
|
+
description: str = ""
|
|
29
|
+
version: str = ""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Capabilities:
|
|
34
|
+
agents: list[AgentCap] = field(default_factory=list)
|
|
35
|
+
models: list[ModelCap] = field(default_factory=list)
|
|
36
|
+
tools: list[ToolCap] = field(default_factory=list)
|
|
37
|
+
|
|
38
|
+
def to_dict(self) -> dict:
|
|
39
|
+
return asdict(self)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class NodeInfo:
|
|
44
|
+
name: str
|
|
45
|
+
version: str = "0.1.0"
|
|
46
|
+
started_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
|
47
|
+
status: str = "online"
|
|
48
|
+
public_key: str = ""
|
|
49
|
+
nats_url: str = ""
|
|
50
|
+
labels: dict[str, str] = field(default_factory=dict)
|
|
51
|
+
|
|
52
|
+
def to_dict(self) -> dict:
|
|
53
|
+
return asdict(self)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class Task:
|
|
58
|
+
target: str
|
|
59
|
+
caller: str
|
|
60
|
+
capability: str
|
|
61
|
+
payload: dict[str, Any] = field(default_factory=dict)
|
|
62
|
+
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
63
|
+
protocol: str = "elo.v1"
|
|
64
|
+
type: str = "task"
|
|
65
|
+
timestamp: int = field(default_factory=lambda: int(datetime.now(timezone.utc).timestamp()))
|
|
66
|
+
ttl_s: int = 60
|
|
67
|
+
signature: str = "" # Assinatura ed25519 do caller
|
|
68
|
+
|
|
69
|
+
def to_dict(self) -> dict:
|
|
70
|
+
d = asdict(self)
|
|
71
|
+
d["type"] = "task"
|
|
72
|
+
return d
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
def from_dict(cls, data: dict) -> "Task":
|
|
76
|
+
return cls(
|
|
77
|
+
id=data.get("id", ""),
|
|
78
|
+
protocol=data.get("protocol", "elo.v1"),
|
|
79
|
+
timestamp=data.get("timestamp", 0),
|
|
80
|
+
target=data.get("target", ""),
|
|
81
|
+
caller=data.get("caller", ""),
|
|
82
|
+
capability=data.get("capability", ""),
|
|
83
|
+
payload=data.get("payload", {}),
|
|
84
|
+
ttl_s=data.get("ttl_s", 60),
|
|
85
|
+
signature=data.get("signature", ""),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class Result:
|
|
91
|
+
id: str
|
|
92
|
+
status: str # "success" | "error" | "timeout"
|
|
93
|
+
payload: dict[str, Any] = field(default_factory=dict)
|
|
94
|
+
error: dict[str, str] | None = None
|
|
95
|
+
protocol: str = "elo.v1"
|
|
96
|
+
type: str = "result"
|
|
97
|
+
|
|
98
|
+
def to_dict(self) -> dict:
|
|
99
|
+
d = asdict(self)
|
|
100
|
+
d["type"] = "result"
|
|
101
|
+
if self.error is None:
|
|
102
|
+
del d["error"]
|
|
103
|
+
return d
|
|
104
|
+
|
|
105
|
+
@classmethod
|
|
106
|
+
def success(cls, task_id: str, payload: dict[str, Any]) -> "Result":
|
|
107
|
+
return cls(id=task_id, status="success", payload=payload)
|
|
108
|
+
|
|
109
|
+
@classmethod
|
|
110
|
+
def make_error(cls, task_id: str, code: str, message: str) -> "Result":
|
|
111
|
+
return cls(id=task_id, status="error", error={"code": code, "message": message})
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass
|
|
115
|
+
class Event:
|
|
116
|
+
event_type: str # "task.completed" | "node.online" | "node.offline" | "capability.changed"
|
|
117
|
+
data: dict[str, Any] = field(default_factory=dict)
|
|
118
|
+
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
119
|
+
protocol: str = "elo.v1"
|
|
120
|
+
type: str = "event"
|
|
121
|
+
timestamp: int = field(default_factory=lambda: int(datetime.now(timezone.utc).timestamp()))
|
|
122
|
+
|
|
123
|
+
def to_dict(self) -> dict:
|
|
124
|
+
d = asdict(self)
|
|
125
|
+
d["type"] = "event"
|
|
126
|
+
return d
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: elo-node
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Elo — malha P2P de mensagens para agentes de IA. Zero infraestrutura.
|
|
5
|
+
Author: Elo Contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/xalq/elo
|
|
8
|
+
Project-URL: Repository, https://github.com/xalq/elo
|
|
9
|
+
Keywords: p2p,agents,ai,distributed,messaging,mesh
|
|
10
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Requires-Python: >=3.11
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
Requires-Dist: cryptography>=42.0
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
21
|
+
Requires-Dist: pytest-asyncio>=0.25; extra == "dev"
|
|
22
|
+
|
|
23
|
+
# Elo Node — Malha P2P para Agentes de IA
|
|
24
|
+
|
|
25
|
+
**Zero infraestrutura. Um processo. Uma porta TCP. Uma chave ed25519.**
|
|
26
|
+
|
|
27
|
+
Elo é uma malha de mensagens P2P descentralizada para comunicação entre agentes de IA. Sem servidor central, sem Kafka, sem Redis, sem NATS. Apenas TCP direto entre nós.
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install elo-node
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
import asyncio
|
|
35
|
+
from elo import Node
|
|
36
|
+
|
|
37
|
+
async def main():
|
|
38
|
+
node = Node("meu-agente", port=7878)
|
|
39
|
+
await node.connect()
|
|
40
|
+
await node.register(agents=["analyst"], tools=["web-search"])
|
|
41
|
+
|
|
42
|
+
@node.on_task
|
|
43
|
+
async def handle(task):
|
|
44
|
+
return {"result": f"processed by {node.node_id}"}
|
|
45
|
+
|
|
46
|
+
await node.run()
|
|
47
|
+
|
|
48
|
+
asyncio.run(main())
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Recursos
|
|
52
|
+
|
|
53
|
+
- **P2P descentralizado** — descoberta via tracker público ou DHT Kademlia
|
|
54
|
+
- **Assinatura ed25519** — identidade criptográfica, mensagens autenticadas
|
|
55
|
+
- **Capabilities** — publish/subscribe de capacidades entre nós
|
|
56
|
+
- **Zero infra** — sem Kafka, Redis, NATS, ou servidor central
|
|
57
|
+
- **CLI nativo** — `python -m elo serve`, `status`, `init`, `id`
|
|
58
|
+
|
|
59
|
+
## CLI
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
python -m elo status # Node ID, hash, chaves
|
|
63
|
+
python -m elo id # Apenas o node_id
|
|
64
|
+
python -m elo pubkey # Chave pública (hex + b64)
|
|
65
|
+
python -m elo init # Gerar identidade persistente
|
|
66
|
+
python -m elo serve # Iniciar nó interativo
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Arquitetura
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
┌──────────────────┐ TCP/JSON ┌──────────────────┐
|
|
73
|
+
│ Node A │◄──────────────►│ Node B │
|
|
74
|
+
│ ed25519 key │ │ ed25519 key │
|
|
75
|
+
│ Capabilities │ │ Capabilities │
|
|
76
|
+
│ Interests │ │ Interests │
|
|
77
|
+
└──────────────────┘ └──────────────────┘
|
|
78
|
+
│ │
|
|
79
|
+
│ Tracker (opcional) │
|
|
80
|
+
└───────────── DHT ────────────────┘
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Cada nó:
|
|
84
|
+
1. Gera identidade ed25519 na primeira execução
|
|
85
|
+
2. Escuta em uma porta TCP
|
|
86
|
+
3. Anuncia capacidades (ex: "analyst", "web-search")
|
|
87
|
+
4. Descobre outros nós via tracker compartilhado ou peers manuais
|
|
88
|
+
5. Troca mensagens assinadas (tasks, results, events)
|
|
89
|
+
|
|
90
|
+
## Compatibilidade
|
|
91
|
+
|
|
92
|
+
- Python 3.11+
|
|
93
|
+
- Linux, macOS, Windows
|
|
94
|
+
|
|
95
|
+
## Desenvolvimento
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
git clone https://github.com/xalq/elo
|
|
99
|
+
cd elo/py
|
|
100
|
+
pip install -e ".[dev]"
|
|
101
|
+
pytest
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Projetos Relacionados
|
|
105
|
+
|
|
106
|
+
- [Hermes Agent](https://hermes-agent.nousresearch.com) — runtime de agentes autônomos
|
|
107
|
+
- [Honcho](https://github.com/argmax-inc/honcho) — memória persistente para agentes
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
elo/__init__.py,sha256=2C5vFJ_VprJtHJU-hIjyjF3ZJ7A_A-UX_YKz-XnZuEg,756
|
|
2
|
+
elo/__main__.py,sha256=YMQli0HxHjng28aZJ48lafLjcDD6hEBmnr9B0KeNjVM,4418
|
|
3
|
+
elo/node.py,sha256=wInHu_gPUAMBNR2Pdp1B2We9ynxdUgSYpZXnLo_LLEo,19965
|
|
4
|
+
elo/security.py,sha256=fdrWtFVViGDf2SXWv_1O3OK4MKYBz83oxBxnyk5Xkkc,6096
|
|
5
|
+
elo/types.py,sha256=anbfTA1SagT3ARyYdLzc5yj-knAaIjSlY45z40vTfkI,3344
|
|
6
|
+
elo/transport/__init__.py,sha256=ZAPtJiG1wyuJv3mBhD305QlK59HHJ-vNnfLBmuQ2_jo,1012
|
|
7
|
+
elo/transport/protocol.py,sha256=T7O69vmdV1HB1bgq-j3-6n1ZmU1GyU0KCEQPG3ovhLk,4781
|
|
8
|
+
elo/transport/routing.py,sha256=Yfn2goRHwHKdaPHHV3IS-dug6lSG651cbSsoyeDSFSc,5142
|
|
9
|
+
elo/transport/tcp.py,sha256=O4J4ivs1r4oL8h3MTxwEhjPs_Ec2u_sRfYQESL1im0Q,11211
|
|
10
|
+
elo/transport/tracker.py,sha256=vIs_VVlJgWzTJBRkb81a28L0kX6NpHcnbIptUT6YBFM,3497
|
|
11
|
+
elo_node-0.4.0.dist-info/METADATA,sha256=wQDKUgyQWqEiUVq6UZw-Z39sdJaGyTu0c7UlckNc8sA,3615
|
|
12
|
+
elo_node-0.4.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
13
|
+
elo_node-0.4.0.dist-info/entry_points.txt,sha256=AwKAQRa5DkVuiU0137P1fyZ6_0Q_7Dwp_nhNngoUIRI,42
|
|
14
|
+
elo_node-0.4.0.dist-info/top_level.txt,sha256=krnAob5lCi3ezo5jJ0hZx5fZcVFQfTGwEG1kdpKCrjM,4
|
|
15
|
+
elo_node-0.4.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
elo
|