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/security.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Módulo de segurança do Elo — NKEYS, assinatura.
|
|
2
|
+
|
|
3
|
+
Camadas:
|
|
4
|
+
1. Geração e armazenamento de identidade (ed25519)
|
|
5
|
+
2. Assinatura de mensagens (prova de autoria)
|
|
6
|
+
3. Verificação de assinaturas
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from cryptography.hazmat.primitives import serialization
|
|
18
|
+
from cryptography.hazmat.primitives.asymmetric import ed25519
|
|
19
|
+
|
|
20
|
+
# ── Caminho padrão da identidade ──────────────────────────
|
|
21
|
+
|
|
22
|
+
DEFAULT_KEY_DIR = Path.home() / ".elo"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ── Geração de identidade ──────────────────────────────────
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def generate_identity() -> tuple[ed25519.Ed25519PrivateKey, ed25519.Ed25519PublicKey]:
|
|
29
|
+
"""Gera um par de chaves ed25519 para identidade do nó.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
(private_key, public_key)
|
|
33
|
+
"""
|
|
34
|
+
private_key = ed25519.Ed25519PrivateKey.generate()
|
|
35
|
+
public_key = private_key.public_key()
|
|
36
|
+
return private_key, public_key
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ── Codificação/decodificação ──────────────────────────────
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def pubkey_to_id(public_key: ed25519.Ed25519PublicKey) -> str:
|
|
43
|
+
"""Converte chave pública ed25519 em node_id (base64 URL-safe sem padding)."""
|
|
44
|
+
raw = public_key.public_bytes(
|
|
45
|
+
encoding=serialization.Encoding.Raw,
|
|
46
|
+
format=serialization.PublicFormat.Raw,
|
|
47
|
+
)
|
|
48
|
+
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def id_to_pubkey(node_id: str) -> ed25519.Ed25519PublicKey:
|
|
52
|
+
"""Converte node_id de volta para chave pública ed25519."""
|
|
53
|
+
raw = base64.urlsafe_b64decode(node_id + "==")
|
|
54
|
+
return ed25519.Ed25519PublicKey.from_public_bytes(raw)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ── Armazenamento de identidade ────────────────────────────
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _chmod_0600(path: Path) -> None:
|
|
61
|
+
"""Define permissão 0o600 no arquivo (Unix). No-op no Windows."""
|
|
62
|
+
try:
|
|
63
|
+
path.chmod(0o600)
|
|
64
|
+
except NotImplementedError:
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def save_identity(
|
|
69
|
+
private_key: ed25519.Ed25519PrivateKey,
|
|
70
|
+
key_dir: Path = DEFAULT_KEY_DIR,
|
|
71
|
+
) -> Path:
|
|
72
|
+
"""Salva a identidade do nó em disco.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
private_key: Chave privada ed25519.
|
|
76
|
+
key_dir: Diretório para salvar os arquivos.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
Caminho do arquivo de seed salvo.
|
|
80
|
+
"""
|
|
81
|
+
key_dir.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
|
|
83
|
+
# Salva seed ed25519 (PKCS#8 PEM)
|
|
84
|
+
seed_path = key_dir / "identity.seed"
|
|
85
|
+
seed = private_key.private_bytes(
|
|
86
|
+
encoding=serialization.Encoding.PEM,
|
|
87
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
88
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
89
|
+
)
|
|
90
|
+
seed_path.write_bytes(seed)
|
|
91
|
+
_chmod_0600(seed_path)
|
|
92
|
+
|
|
93
|
+
return seed_path
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def load_identity(
|
|
97
|
+
key_dir: Path = DEFAULT_KEY_DIR,
|
|
98
|
+
) -> tuple[ed25519.Ed25519PrivateKey, None]:
|
|
99
|
+
"""Carrega a identidade do nó do disco.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
(private_key, None) — None mantido para compatibilidade de interface.
|
|
103
|
+
"""
|
|
104
|
+
seed_path = key_dir / "identity.seed"
|
|
105
|
+
if not seed_path.exists():
|
|
106
|
+
raise FileNotFoundError(
|
|
107
|
+
f"Identidade não encontrada em {seed_path}. "
|
|
108
|
+
"Use elo.security.generate_and_save_identity() primeiro."
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
private_key: ed25519.Ed25519PrivateKey = serialization.load_pem_private_key(
|
|
112
|
+
seed_path.read_bytes(),
|
|
113
|
+
password=None,
|
|
114
|
+
) # type: ignore[assignment]
|
|
115
|
+
|
|
116
|
+
return private_key, None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def generate_and_save_identity(
|
|
120
|
+
key_dir: Path = DEFAULT_KEY_DIR,
|
|
121
|
+
) -> tuple[ed25519.Ed25519PublicKey, ed25519.Ed25519PrivateKey]:
|
|
122
|
+
"""Gera e salva identidade (ed25519).
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
(public_key, private_key)
|
|
126
|
+
"""
|
|
127
|
+
private_key, public_key = generate_identity()
|
|
128
|
+
save_identity(private_key, key_dir)
|
|
129
|
+
return public_key, private_key
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ── Assinatura de mensagens ────────────────────────────────
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def sign_message(private_key: ed25519.Ed25519PrivateKey, payload: dict[str, Any]) -> str:
|
|
136
|
+
"""Assina um payload com a chave privada ed25519.
|
|
137
|
+
|
|
138
|
+
O payload é canonicalizado como JSON compacto antes de assinar.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
private_key: Chave privada do nó.
|
|
142
|
+
payload: Dicionário a ser assinado.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
Assinatura em base64 URL-safe.
|
|
146
|
+
"""
|
|
147
|
+
canonical = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
|
|
148
|
+
signature = private_key.sign(canonical)
|
|
149
|
+
return base64.urlsafe_b64encode(signature).rstrip(b"=").decode()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def verify_signature(
|
|
153
|
+
public_key: ed25519.Ed25519PublicKey,
|
|
154
|
+
payload: dict[str, Any],
|
|
155
|
+
signature_b64: str,
|
|
156
|
+
) -> bool:
|
|
157
|
+
"""Verifica a assinatura de um payload.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
public_key: Chave pública do remetente.
|
|
161
|
+
payload: Dicionário original.
|
|
162
|
+
signature_b64: Assinatura em base64 URL-safe.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
True se a assinatura for válida.
|
|
166
|
+
"""
|
|
167
|
+
canonical = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
|
|
168
|
+
try:
|
|
169
|
+
sig = base64.urlsafe_b64decode(signature_b64 + "==")
|
|
170
|
+
public_key.verify(sig, canonical)
|
|
171
|
+
return True
|
|
172
|
+
except Exception:
|
|
173
|
+
return False
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ── Utilitário de identidade efêmera (sem disco) ───────────
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class EphemeralIdentity:
|
|
180
|
+
"""Identidade temporária — útil para testes ou nós sem persistência."""
|
|
181
|
+
|
|
182
|
+
def __init__(self) -> None:
|
|
183
|
+
self._private_key, self._public_key = generate_identity()
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def node_id(self) -> str:
|
|
187
|
+
return pubkey_to_id(self._public_key)
|
|
188
|
+
|
|
189
|
+
@property
|
|
190
|
+
def private_key(self) -> ed25519.Ed25519PrivateKey:
|
|
191
|
+
return self._private_key
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def public_key(self) -> ed25519.Ed25519PublicKey:
|
|
195
|
+
return self._public_key
|
|
196
|
+
|
|
197
|
+
def sign(self, payload: dict[str, Any]) -> str:
|
|
198
|
+
return sign_message(self._private_key, payload)
|
|
199
|
+
|
|
200
|
+
def verify(self, payload: dict[str, Any], signature: str) -> bool:
|
|
201
|
+
return verify_signature(self._public_key, payload, signature)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Elo P2P transport layer — zero-infra mesh networking.
|
|
2
|
+
|
|
3
|
+
Modules:
|
|
4
|
+
- tcp: TCP connection manager (server + peer connections)
|
|
5
|
+
- protocol: Wire protocol — framed JSON over TCP
|
|
6
|
+
- routing: Interest-based routing table
|
|
7
|
+
- tracker: Local capability registry (public/private)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from elo.transport.tcp import TCPManager, PeerConnection
|
|
11
|
+
from elo.transport.protocol import (
|
|
12
|
+
encode_frame, read_frame, write_frame,
|
|
13
|
+
hello_msg, hello_ack_msg,
|
|
14
|
+
query_msg, query_resp_msg,
|
|
15
|
+
interest_update_msg,
|
|
16
|
+
task_msg, result_msg,
|
|
17
|
+
heartbeat_msg, bye_msg,
|
|
18
|
+
MessageType, FrameError,
|
|
19
|
+
)
|
|
20
|
+
from elo.transport.routing import InterestTable
|
|
21
|
+
from elo.transport.tracker import LocalTracker
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"TCPManager", "PeerConnection",
|
|
25
|
+
"encode_frame", "read_frame", "write_frame",
|
|
26
|
+
"hello_msg", "hello_ack_msg",
|
|
27
|
+
"query_msg", "query_resp_msg",
|
|
28
|
+
"interest_update_msg",
|
|
29
|
+
"task_msg", "result_msg",
|
|
30
|
+
"heartbeat_msg", "bye_msg",
|
|
31
|
+
"MessageType", "FrameError",
|
|
32
|
+
"InterestTable",
|
|
33
|
+
"LocalTracker",
|
|
34
|
+
]
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Wire protocol — framed JSON messages over TCP.
|
|
2
|
+
|
|
3
|
+
Frame format: [4-byte big-endian length][JSON payload]
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
import struct
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
# ── Frame constants ────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
FRAME_HEADER_SIZE = 4 # uint32 big-endian
|
|
17
|
+
MAX_PAYLOAD_SIZE = 1024 * 1024 # 1 MB
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FrameError(Exception):
|
|
21
|
+
"""Invalid frame received."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ── Message types ──────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
class MessageType:
|
|
27
|
+
HELLO = "hello"
|
|
28
|
+
HELLO_ACK = "hello_ack"
|
|
29
|
+
QUERY = "query"
|
|
30
|
+
QUERY_RESP = "query_resp"
|
|
31
|
+
INTEREST_UPDATE = "interest_update"
|
|
32
|
+
TASK = "task"
|
|
33
|
+
RESULT = "result"
|
|
34
|
+
EVENT = "event"
|
|
35
|
+
HEARTBEAT = "heartbeat"
|
|
36
|
+
BYE = "bye"
|
|
37
|
+
# DHT messages (v0.3+)
|
|
38
|
+
DHT_PING = "dht_ping"
|
|
39
|
+
DHT_PONG = "dht_pong"
|
|
40
|
+
DHT_FIND_NODE = "dht_find_node"
|
|
41
|
+
DHT_FIND_NODE_RESP = "dht_find_node_resp"
|
|
42
|
+
DHT_FIND_VALUE = "dht_find_value"
|
|
43
|
+
DHT_FIND_VALUE_RESP = "dht_find_value_resp"
|
|
44
|
+
DHT_STORE = "dht_store"
|
|
45
|
+
DHT_STORE_RESP = "dht_store_resp"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ── Framing ────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def encode_frame(payload: dict[str, Any]) -> bytes:
|
|
52
|
+
"""Encodes a JSON dict into a framed message."""
|
|
53
|
+
data = json.dumps(payload, ensure_ascii=False).encode()
|
|
54
|
+
if len(data) > MAX_PAYLOAD_SIZE:
|
|
55
|
+
raise FrameError(f"Payload too large: {len(data)} bytes (max {MAX_PAYLOAD_SIZE})")
|
|
56
|
+
header = struct.pack("!I", len(data))
|
|
57
|
+
return header + data
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def read_frame(reader: asyncio.StreamReader) -> dict[str, Any]:
|
|
61
|
+
"""Reads one frame from a StreamReader. Returns the decoded JSON dict."""
|
|
62
|
+
header = await reader.readexactly(FRAME_HEADER_SIZE)
|
|
63
|
+
length = struct.unpack("!I", header)[0]
|
|
64
|
+
if length > MAX_PAYLOAD_SIZE:
|
|
65
|
+
raise FrameError(f"Invalid payload length: {length} (max {MAX_PAYLOAD_SIZE})")
|
|
66
|
+
data = await reader.readexactly(length)
|
|
67
|
+
return json.loads(data.decode())
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def write_frame(writer: asyncio.StreamWriter, payload: dict[str, Any]) -> None:
|
|
71
|
+
"""Writes one framed JSON message to a StreamWriter."""
|
|
72
|
+
writer.write(encode_frame(payload))
|
|
73
|
+
await writer.drain()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ── Message builders ───────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def hello_msg(node_id: str, caps: dict, interests: list[str],
|
|
80
|
+
tracker: str = "public", version: str = "0.2.0") -> dict:
|
|
81
|
+
return {
|
|
82
|
+
"type": MessageType.HELLO,
|
|
83
|
+
"node_id": node_id,
|
|
84
|
+
"caps": caps,
|
|
85
|
+
"interests": interests,
|
|
86
|
+
"tracker": tracker,
|
|
87
|
+
"version": version,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def hello_ack_msg(node_id: str, caps: dict, interests: list[str],
|
|
92
|
+
tracker: str = "public") -> dict:
|
|
93
|
+
return {
|
|
94
|
+
"type": MessageType.HELLO_ACK,
|
|
95
|
+
"node_id": node_id,
|
|
96
|
+
"caps": caps,
|
|
97
|
+
"interests": interests,
|
|
98
|
+
"tracker": tracker,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def query_msg(capability: str, query_id: str, ttl: int = 5) -> dict:
|
|
103
|
+
return {
|
|
104
|
+
"type": MessageType.QUERY,
|
|
105
|
+
"capability": capability,
|
|
106
|
+
"id": query_id,
|
|
107
|
+
"ttl": ttl,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def query_resp_msg(query_id: str, nodes: list[dict]) -> dict:
|
|
112
|
+
return {
|
|
113
|
+
"type": MessageType.QUERY_RESP,
|
|
114
|
+
"id": query_id,
|
|
115
|
+
"nodes": nodes,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def interest_update_msg(interests: list[str]) -> dict:
|
|
120
|
+
return {
|
|
121
|
+
"type": MessageType.INTEREST_UPDATE,
|
|
122
|
+
"interests": interests,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def task_msg(task_id: str, target: str, caller: str, capability: str,
|
|
127
|
+
payload: dict, signature: str = "") -> dict:
|
|
128
|
+
return {
|
|
129
|
+
"type": MessageType.TASK,
|
|
130
|
+
"id": task_id,
|
|
131
|
+
"target": target,
|
|
132
|
+
"caller": caller,
|
|
133
|
+
"capability": capability,
|
|
134
|
+
"payload": payload,
|
|
135
|
+
"signature": signature,
|
|
136
|
+
"protocol": "elo.v1",
|
|
137
|
+
"timestamp": int(time.time()),
|
|
138
|
+
"ttl_s": 60,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def result_msg(task_id: str, status: str, payload: dict | None = None,
|
|
143
|
+
error: dict | None = None) -> dict:
|
|
144
|
+
msg = {
|
|
145
|
+
"type": MessageType.RESULT,
|
|
146
|
+
"id": task_id,
|
|
147
|
+
"status": status,
|
|
148
|
+
"protocol": "elo.v1",
|
|
149
|
+
}
|
|
150
|
+
if payload is not None:
|
|
151
|
+
msg["payload"] = payload
|
|
152
|
+
if error is not None:
|
|
153
|
+
msg["error"] = error
|
|
154
|
+
return msg
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def heartbeat_msg(node_id: str) -> dict:
|
|
158
|
+
return {
|
|
159
|
+
"type": MessageType.HEARTBEAT,
|
|
160
|
+
"node_id": node_id,
|
|
161
|
+
"ts": int(time.time()),
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def bye_msg() -> dict:
|
|
166
|
+
return {"type": MessageType.BYE}
|
elo/transport/routing.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Interest-based routing table.
|
|
2
|
+
|
|
3
|
+
Each node maintains:
|
|
4
|
+
- Local caps: what this node provides
|
|
5
|
+
- Local interests: what this node is looking for
|
|
6
|
+
- Remote caps: per-peer, what capabilities each peer has
|
|
7
|
+
- Remote interests: per-peer, what each peer is looking for
|
|
8
|
+
- Forwarding table: capability → set of peer addresses that can serve it
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections import defaultdict
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class InterestTable:
|
|
18
|
+
"""Interest-based routing for P2P capability discovery."""
|
|
19
|
+
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
self._local_caps: set[str] = set()
|
|
22
|
+
self._local_interests: set[str] = set()
|
|
23
|
+
|
|
24
|
+
# peer_address → {capability, ...}
|
|
25
|
+
self._peer_caps: dict[str, set[str]] = {}
|
|
26
|
+
self._peer_interests: dict[str, set[str]] = {}
|
|
27
|
+
|
|
28
|
+
# capability → {peer_address, ...}
|
|
29
|
+
self._forwarding: dict[str, set[str]] = defaultdict(set)
|
|
30
|
+
|
|
31
|
+
# ── local ───────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
def set_local_caps(self, caps: dict[str, list[dict[str, Any]]]) -> None:
|
|
34
|
+
"""Register local capabilities from agents, models, and tools."""
|
|
35
|
+
self._local_caps.clear()
|
|
36
|
+
for agent in caps.get("agents", []):
|
|
37
|
+
self._local_caps.add(agent.get("name", ""))
|
|
38
|
+
for model in caps.get("models", []):
|
|
39
|
+
self._local_caps.add(model.get("name", ""))
|
|
40
|
+
for tool in caps.get("tools", []):
|
|
41
|
+
self._local_caps.add(tool.get("name", ""))
|
|
42
|
+
|
|
43
|
+
def set_local_interests(self, interests: list[str]) -> None:
|
|
44
|
+
self._local_interests = set(interests)
|
|
45
|
+
|
|
46
|
+
def has_local(self, capability: str) -> bool:
|
|
47
|
+
return capability in self._local_caps
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def local_caps(self) -> set[str]:
|
|
51
|
+
return self._local_caps
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def local_interests(self) -> set[str]:
|
|
55
|
+
return self._local_interests
|
|
56
|
+
|
|
57
|
+
# ── peer ────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
def register_peer(self, peer_addr: str, caps: dict[str, list[dict[str, Any]]],
|
|
60
|
+
interests: list[str]) -> None:
|
|
61
|
+
"""Register or update a peer's capabilities and interests."""
|
|
62
|
+
cap_set: set[str] = set()
|
|
63
|
+
for agent in caps.get("agents", []):
|
|
64
|
+
cap_set.add(agent.get("name", ""))
|
|
65
|
+
for model in caps.get("models", []):
|
|
66
|
+
cap_set.add(model.get("name", ""))
|
|
67
|
+
for tool in caps.get("tools", []):
|
|
68
|
+
cap_set.add(tool.get("name", ""))
|
|
69
|
+
|
|
70
|
+
self._peer_caps[peer_addr] = cap_set
|
|
71
|
+
self._peer_interests[peer_addr] = set(interests)
|
|
72
|
+
|
|
73
|
+
# Update forwarding table
|
|
74
|
+
for cap in cap_set:
|
|
75
|
+
self._forwarding[cap].add(peer_addr)
|
|
76
|
+
|
|
77
|
+
def remove_peer(self, peer_addr: str) -> None:
|
|
78
|
+
"""Remove a peer and clean forwarding entries."""
|
|
79
|
+
old_caps = self._peer_caps.pop(peer_addr, set())
|
|
80
|
+
for cap in old_caps:
|
|
81
|
+
self._forwarding.get(cap, set()).discard(peer_addr)
|
|
82
|
+
self._peer_interests.pop(peer_addr, None)
|
|
83
|
+
|
|
84
|
+
def find_peer_for(self, capability: str) -> str | None:
|
|
85
|
+
"""Find a peer that can serve the given capability."""
|
|
86
|
+
peers = self._forwarding.get(capability, set())
|
|
87
|
+
if peers:
|
|
88
|
+
return next(iter(peers))
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
def find_all_peers_for(self, capability: str) -> set[str]:
|
|
92
|
+
"""Return all known peers for a capability."""
|
|
93
|
+
return self._forwarding.get(capability, set()).copy()
|
|
94
|
+
|
|
95
|
+
def query_peers(self, capability: str, exclude: set[str] | None = None) -> list[str]:
|
|
96
|
+
"""Find peers that might know about a capability.
|
|
97
|
+
|
|
98
|
+
First: direct matches from forwarding table.
|
|
99
|
+
Second: peers whose interests overlap (they're looking for similar things).
|
|
100
|
+
Third: random peers as a fallback.
|
|
101
|
+
"""
|
|
102
|
+
exclude = exclude or set()
|
|
103
|
+
results: list[str] = []
|
|
104
|
+
|
|
105
|
+
# Direct matches
|
|
106
|
+
for peer in self._forwarding.get(capability, set()):
|
|
107
|
+
if peer not in exclude:
|
|
108
|
+
results.append(peer)
|
|
109
|
+
|
|
110
|
+
# Peers with overlapping interests (might know about this capability)
|
|
111
|
+
if not results:
|
|
112
|
+
for peer_addr, interests in self._peer_interests.items():
|
|
113
|
+
if peer_addr in exclude:
|
|
114
|
+
continue
|
|
115
|
+
# If the peer is interested in something, it might know
|
|
116
|
+
# about similar capabilities
|
|
117
|
+
if any(capability.startswith(i.split(":")[0])
|
|
118
|
+
for i in interests):
|
|
119
|
+
results.append(peer_addr)
|
|
120
|
+
|
|
121
|
+
# Fallback: all known peers
|
|
122
|
+
if not results:
|
|
123
|
+
results = [p for p in self._peer_caps if p not in exclude]
|
|
124
|
+
|
|
125
|
+
return results
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def known_peers(self) -> list[str]:
|
|
129
|
+
return list(self._peer_caps.keys())
|
|
130
|
+
|
|
131
|
+
def get_peer_caps(self, peer_addr: str) -> dict[str, set[str]]:
|
|
132
|
+
return {
|
|
133
|
+
"caps": self._peer_caps.get(peer_addr, set()),
|
|
134
|
+
"interests": self._peer_interests.get(peer_addr, set()),
|
|
135
|
+
}
|