Skip to content

Commit 1dfcaea

Browse files
feat: AgentMesh Registry service — pre-keys, discovery, presence (D2a) (microsoft#1283)
First-party agent registry implementing Wire Protocol v1.0 Section 11. Deployable FastAPI service with 8 REST endpoints. New files: - registry/__init__.py, store.py, app.py, __main__.py - tests/test_registry.py — 17 tests Endpoints: - POST /v1/agents — register agent - GET /v1/agents/{did} — get metadata - DELETE /v1/agents/{did} — deregister - PUT /v1/agents/{did}/prekeys — upload pre-key bundle - GET /v1/agents/{did}/prekeys — fetch bundle (atomic OPK consumption) - GET /v1/agents/{did}/presence — last-seen/online status - POST /v1/agents/{did}/reputation — submit feedback - GET /v1/discover — search by capability Features: - Ed25519-Timestamp auth (spec Section 13.1) - Atomic one-time pre-key consumption - In-memory store with pluggable RegistryStore protocol - Exponential moving average reputation scoring - Runnable: python -m agentmesh.registry Clean-room: implements against wire spec only. Closes microsoft#1283 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 028a8b9 commit 1dfcaea

File tree

5 files changed

+613
-0
lines changed

5 files changed

+613
-0
lines changed
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
"""AgentMesh Registry — agent registration, pre-key distribution, and discovery."""
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
"""Run AgentMesh Registry server."""
4+
5+
import argparse
6+
import os
7+
8+
import uvicorn
9+
10+
from agentmesh.registry.app import RegistryServer
11+
12+
13+
def main() -> None:
14+
parser = argparse.ArgumentParser(description="AgentMesh Registry server")
15+
parser.add_argument("--host", default=os.environ.get("HOST", "127.0.0.1"))
16+
parser.add_argument("--port", type=int, default=int(os.environ.get("PORT", "8082")))
17+
parser.add_argument("--log-level", default=os.environ.get("LOG_LEVEL", "info"))
18+
args = parser.parse_args()
19+
20+
server = RegistryServer()
21+
uvicorn.run(server.app, host=args.host, port=args.port, log_level=args.log_level)
22+
23+
24+
if __name__ == "__main__":
25+
main()
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
"""AgentMesh Registry — FastAPI application.
4+
5+
Spec: docs/specs/AGENTMESH-WIRE-1.0.md Section 11
6+
Independent design: implements against wire spec only.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import base64
12+
import logging
13+
from datetime import datetime, timedelta, timezone
14+
from typing import Any
15+
16+
from fastapi import FastAPI, HTTPException, Header, Query, Request
17+
from fastapi.responses import JSONResponse
18+
from pydantic import BaseModel, Field
19+
20+
from agentmesh.registry.store import AgentRecord, InMemoryRegistryStore, RegistryStore
21+
22+
logger = logging.getLogger(__name__)
23+
24+
REPLAY_WINDOW = timedelta(minutes=5)
25+
26+
27+
def _utcnow() -> datetime:
28+
return datetime.now(timezone.utc)
29+
30+
31+
# ── Request/Response Models ──────────────────────────────────────────
32+
33+
34+
class RegisterAgentRequest(BaseModel):
35+
did: str
36+
public_key: str # base64url
37+
capabilities: list[str] = Field(default_factory=list)
38+
metadata: dict[str, str] = Field(default_factory=dict)
39+
40+
41+
class PreKeyBundleRequest(BaseModel):
42+
identity_key: str # base64url
43+
signed_pre_key: dict[str, Any]
44+
one_time_pre_keys: list[dict[str, Any]] = Field(default_factory=list)
45+
46+
47+
class ReputationRequest(BaseModel):
48+
score: float = Field(ge=0.0, le=1.0)
49+
reason: str = ""
50+
51+
52+
# ── Auth ─────────────────────────────────────────────────────────────
53+
54+
55+
def verify_ed25519_timestamp_auth(
56+
authorization: str | None,
57+
store: RegistryStore,
58+
) -> str:
59+
"""Verify Ed25519-Timestamp auth header. Returns the agent DID.
60+
61+
Format: Ed25519-Timestamp <did> <iso8601> <base64url(signature)>
62+
63+
Spec: docs/specs/AGENTMESH-WIRE-1.0.md Section 13.1
64+
"""
65+
if not authorization or not authorization.startswith("Ed25519-Timestamp "):
66+
raise HTTPException(status_code=401, detail="Missing or invalid Authorization header")
67+
68+
parts = authorization.split(" ", 3)
69+
if len(parts) != 4:
70+
raise HTTPException(status_code=401, detail="Malformed Ed25519-Timestamp header")
71+
72+
_, did, timestamp_str, sig_b64 = parts
73+
74+
# Check timestamp within replay window
75+
try:
76+
ts = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00"))
77+
except ValueError:
78+
raise HTTPException(status_code=401, detail="Invalid timestamp format")
79+
80+
now = _utcnow()
81+
if abs((now - ts).total_seconds()) > REPLAY_WINDOW.total_seconds():
82+
raise HTTPException(status_code=401, detail="Timestamp outside replay window")
83+
84+
# Look up agent
85+
agent = store.get_agent(did)
86+
if not agent:
87+
raise HTTPException(status_code=401, detail="Agent not registered")
88+
89+
# Verify Ed25519 signature over timestamp
90+
try:
91+
from nacl.signing import VerifyKey
92+
93+
sig = base64.urlsafe_b64decode(sig_b64 + "==")
94+
vk = VerifyKey(agent.public_key)
95+
vk.verify(timestamp_str.encode("utf-8"), sig)
96+
except Exception:
97+
raise HTTPException(status_code=401, detail="Invalid signature")
98+
99+
return did
100+
101+
102+
# ── Application ──────────────────────────────────────────────────────
103+
104+
105+
class RegistryServer:
106+
"""AgentMesh Registry — FastAPI application."""
107+
108+
def __init__(self, store: RegistryStore | None = None) -> None:
109+
self._store = store or InMemoryRegistryStore()
110+
self._app = self._create_app()
111+
112+
@property
113+
def app(self) -> FastAPI:
114+
return self._app
115+
116+
@property
117+
def store(self) -> RegistryStore:
118+
return self._store
119+
120+
def _create_app(self) -> FastAPI:
121+
app = FastAPI(
122+
title="AgentMesh Registry",
123+
version="1.0.0",
124+
description="Agent registration, pre-key distribution, and discovery.",
125+
)
126+
127+
store = self._store
128+
129+
# ── Registration ─────────────────────────────────────────
130+
131+
@app.post("/v1/agents", status_code=201)
132+
async def register_agent(req: RegisterAgentRequest) -> dict:
133+
"""Register a new agent."""
134+
if store.get_agent(req.did):
135+
raise HTTPException(status_code=409, detail="Agent already registered")
136+
137+
try:
138+
public_key = base64.urlsafe_b64decode(req.public_key + "==")
139+
except Exception:
140+
raise HTTPException(status_code=400, detail="Invalid public_key encoding")
141+
142+
if len(public_key) != 32:
143+
raise HTTPException(status_code=400, detail="public_key must be 32 bytes")
144+
145+
record = AgentRecord(
146+
did=req.did,
147+
public_key=public_key,
148+
capabilities=req.capabilities,
149+
metadata=req.metadata,
150+
)
151+
store.put_agent(record)
152+
logger.info("Registered agent %s", req.did)
153+
return {"did": req.did, "status": "registered"}
154+
155+
@app.get("/v1/agents/{did}")
156+
async def get_agent(did: str) -> dict:
157+
"""Get agent metadata."""
158+
agent = store.get_agent(did)
159+
if not agent:
160+
raise HTTPException(status_code=404, detail="Agent not found")
161+
return {
162+
"did": agent.did,
163+
"capabilities": agent.capabilities,
164+
"metadata": agent.metadata,
165+
"registered_at": agent.registered_at.isoformat(),
166+
"last_seen": agent.last_seen.isoformat(),
167+
"reputation_score": agent.reputation_score,
168+
}
169+
170+
@app.delete("/v1/agents/{did}", status_code=204)
171+
async def deregister_agent(did: str) -> None:
172+
"""Deregister an agent."""
173+
if not store.delete_agent(did):
174+
raise HTTPException(status_code=404, detail="Agent not found")
175+
logger.info("Deregistered agent %s", did)
176+
177+
# ── Pre-Keys ─────────────────────────────────────────────
178+
179+
@app.put("/v1/agents/{did}/prekeys")
180+
async def upload_prekeys(did: str, req: PreKeyBundleRequest) -> dict:
181+
"""Upload a pre-key bundle."""
182+
agent = store.get_agent(did)
183+
if not agent:
184+
raise HTTPException(status_code=404, detail="Agent not found")
185+
186+
try:
187+
agent.identity_key = base64.urlsafe_b64decode(req.identity_key + "==")
188+
spk = req.signed_pre_key
189+
agent.signed_pre_key = base64.urlsafe_b64decode(spk["public_key"] + "==")
190+
agent.signed_pre_key_signature = base64.urlsafe_b64decode(spk["signature"] + "==")
191+
agent.signed_pre_key_id = spk["key_id"]
192+
agent.one_time_pre_keys = list(req.one_time_pre_keys)
193+
except Exception as e:
194+
raise HTTPException(status_code=400, detail=f"Invalid pre-key bundle: {e}")
195+
196+
store.put_agent(agent)
197+
return {"did": did, "otk_count": len(agent.one_time_pre_keys)}
198+
199+
@app.get("/v1/agents/{did}/prekeys")
200+
async def fetch_prekeys(did: str) -> dict:
201+
"""Fetch a pre-key bundle. Atomically consumes one OPK."""
202+
agent = store.get_agent(did)
203+
if not agent or not agent.signed_pre_key:
204+
raise HTTPException(status_code=404, detail="Pre-key bundle not found")
205+
206+
otk = store.consume_one_time_key(did)
207+
208+
result: dict[str, Any] = {
209+
"identity_key": base64.urlsafe_b64encode(agent.identity_key or b"").decode().rstrip("="),
210+
"signed_pre_key": {
211+
"key_id": agent.signed_pre_key_id,
212+
"public_key": base64.urlsafe_b64encode(agent.signed_pre_key).decode().rstrip("="),
213+
"signature": base64.urlsafe_b64encode(
214+
agent.signed_pre_key_signature or b""
215+
).decode().rstrip("="),
216+
},
217+
}
218+
219+
if otk:
220+
result["one_time_pre_key"] = otk
221+
else:
222+
result["one_time_pre_key"] = None
223+
224+
return result
225+
226+
# ── Presence ─────────────────────────────────────────────
227+
228+
@app.get("/v1/agents/{did}/presence")
229+
async def get_presence(did: str) -> dict:
230+
"""Get agent presence / last-seen."""
231+
agent = store.get_agent(did)
232+
if not agent:
233+
raise HTTPException(status_code=404, detail="Agent not found")
234+
return {
235+
"did": agent.did,
236+
"last_seen": agent.last_seen.isoformat(),
237+
"online": (_utcnow() - agent.last_seen).total_seconds() < 90,
238+
}
239+
240+
# ── Reputation ───────────────────────────────────────────
241+
242+
@app.post("/v1/agents/{did}/reputation")
243+
async def submit_reputation(did: str, req: ReputationRequest) -> dict:
244+
"""Submit reputation feedback for an agent."""
245+
agent = store.get_agent(did)
246+
if not agent:
247+
raise HTTPException(status_code=404, detail="Agent not found")
248+
249+
# Simple exponential moving average
250+
alpha = 0.3
251+
agent.reputation_score = alpha * req.score + (1 - alpha) * agent.reputation_score
252+
store.put_agent(agent)
253+
return {"did": did, "reputation_score": round(agent.reputation_score, 4)}
254+
255+
# ── Discovery ────────────────────────────────────────────
256+
257+
@app.get("/v1/discover")
258+
async def discover(
259+
capability: str = Query(..., description="Capability to search for"),
260+
limit: int = Query(default=50, ge=1, le=200),
261+
) -> dict:
262+
"""Search agents by capability."""
263+
results = store.search_by_capability(capability, limit)
264+
return {
265+
"results": [
266+
{
267+
"did": a.did,
268+
"capabilities": a.capabilities,
269+
"reputation_score": a.reputation_score,
270+
"last_seen": a.last_seen.isoformat(),
271+
}
272+
for a in results
273+
],
274+
"total": len(results),
275+
}
276+
277+
# ── Health ───────────────────────────────────────────────
278+
279+
@app.get("/health")
280+
async def health() -> dict:
281+
return {"status": "healthy", "service": "agentmesh-registry"}
282+
283+
return app

0 commit comments

Comments
 (0)