agentplane-registry 0.0.1__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.
- agentplane_registry/__init__.py +8 -0
- agentplane_registry/api.py +252 -0
- agentplane_registry/app.py +60 -0
- agentplane_registry/auth.py +139 -0
- agentplane_registry/db.py +137 -0
- agentplane_registry/embeddings.py +42 -0
- agentplane_registry/health.py +158 -0
- agentplane_registry/search.py +159 -0
- agentplane_registry/settings.py +29 -0
- agentplane_registry/tracing.py +52 -0
- agentplane_registry/urlcheck.py +32 -0
- agentplane_registry-0.0.1.dist-info/METADATA +43 -0
- agentplane_registry-0.0.1.dist-info/RECORD +14 -0
- agentplane_registry-0.0.1.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""agentplane-registry: standalone-capable discovery service."""
|
|
2
|
+
|
|
3
|
+
from agentplane_registry.app import create_app
|
|
4
|
+
from agentplane_registry.settings import REGISTRY_VERSION, RegistrySettings
|
|
5
|
+
|
|
6
|
+
__version__ = REGISTRY_VERSION
|
|
7
|
+
|
|
8
|
+
__all__ = ["REGISTRY_VERSION", "RegistrySettings", "create_app"]
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Registry REST API (SPEC §5.1), prefix ``/api/v1``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import uuid
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from typing import Annotated, Literal
|
|
10
|
+
|
|
11
|
+
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
|
|
12
|
+
from sqlalchemy import func, select
|
|
13
|
+
from sqlalchemy.exc import IntegrityError
|
|
14
|
+
|
|
15
|
+
from agentplane_core import (
|
|
16
|
+
Capabilities,
|
|
17
|
+
EntryKind,
|
|
18
|
+
HealthStatus,
|
|
19
|
+
Page,
|
|
20
|
+
RegistryEntry,
|
|
21
|
+
RegistryEntryCreate,
|
|
22
|
+
RegistryEntryPatch,
|
|
23
|
+
SearchQuery,
|
|
24
|
+
serialize_card,
|
|
25
|
+
)
|
|
26
|
+
from agentplane_registry.auth import Principal
|
|
27
|
+
from agentplane_registry.db import Database, EntryRow, row_to_entry
|
|
28
|
+
from agentplane_registry.search import RegistrySearch
|
|
29
|
+
from agentplane_registry.settings import REGISTRY_VERSION, RegistrySettings
|
|
30
|
+
from agentplane_registry.urlcheck import is_private_url
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class RegistryState:
|
|
35
|
+
"""Shared service state stored on the FastAPI app."""
|
|
36
|
+
|
|
37
|
+
db: Database
|
|
38
|
+
settings: RegistrySettings
|
|
39
|
+
search: RegistrySearch
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _state(request: Request) -> RegistryState:
|
|
43
|
+
state: RegistryState = request.app.state.registry
|
|
44
|
+
return state
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def _principal(request: Request) -> Principal:
|
|
48
|
+
principal: Principal = await request.app.state.authenticator.authenticate(request)
|
|
49
|
+
return principal
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
State = Annotated[RegistryState, Depends(_state)]
|
|
53
|
+
Caller = Annotated[Principal, Depends(_principal)]
|
|
54
|
+
|
|
55
|
+
router = APIRouter(prefix="/api/v1")
|
|
56
|
+
health_router = APIRouter()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _visible(row: EntryRow, caller: Principal, auth_mode: str) -> bool:
|
|
60
|
+
if auth_mode == "none" or caller.is_admin:
|
|
61
|
+
return True
|
|
62
|
+
return row.owner == caller.sub
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _check_url(url: str, settings: RegistrySettings) -> None:
|
|
66
|
+
if not settings.allow_private_urls and is_private_url(url):
|
|
67
|
+
raise HTTPException(
|
|
68
|
+
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
69
|
+
detail="url must be a public gateway URL (set ALLOW_PRIVATE_URLS to override)",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@router.post("/agents", status_code=status.HTTP_201_CREATED, response_model=RegistryEntry)
|
|
74
|
+
async def register_entry(body: RegistryEntryCreate, state: State, caller: Caller) -> RegistryEntry:
|
|
75
|
+
_check_url(body.url, state.settings)
|
|
76
|
+
now = datetime.now(UTC)
|
|
77
|
+
row = EntryRow(
|
|
78
|
+
id=str(uuid.uuid4()),
|
|
79
|
+
kind=body.kind,
|
|
80
|
+
name=body.card.name,
|
|
81
|
+
owner=caller.sub,
|
|
82
|
+
url=body.url,
|
|
83
|
+
card_json=_dump_card(body),
|
|
84
|
+
tags_json=list(body.tags),
|
|
85
|
+
status="starting",
|
|
86
|
+
created_at=now,
|
|
87
|
+
updated_at=now,
|
|
88
|
+
)
|
|
89
|
+
try:
|
|
90
|
+
async with state.db.session() as session, session.begin():
|
|
91
|
+
session.add(row)
|
|
92
|
+
except IntegrityError:
|
|
93
|
+
raise HTTPException(
|
|
94
|
+
status.HTTP_409_CONFLICT,
|
|
95
|
+
detail=f"entry named {body.card.name!r} already exists for this owner",
|
|
96
|
+
) from None
|
|
97
|
+
entry = row_to_entry(row)
|
|
98
|
+
await state.search.index(entry)
|
|
99
|
+
return entry
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _dump_card(body: RegistryEntryCreate | RegistryEntryPatch) -> str:
|
|
103
|
+
return json.dumps(serialize_card(body.card), sort_keys=True)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@router.get("/agents", response_model=Page)
|
|
107
|
+
async def list_entries(
|
|
108
|
+
state: State,
|
|
109
|
+
caller: Caller,
|
|
110
|
+
kind: Annotated[EntryKind | None, Query()] = None,
|
|
111
|
+
status_filter: Annotated[HealthStatus | None, Query(alias="status")] = None,
|
|
112
|
+
tags: Annotated[str, Query(description="comma-separated, AND semantics")] = "",
|
|
113
|
+
owner: Annotated[Literal["me", "all"], Query()] = "me",
|
|
114
|
+
limit: Annotated[int, Query(ge=1, le=200)] = 50,
|
|
115
|
+
offset: Annotated[int, Query(ge=0)] = 0,
|
|
116
|
+
) -> Page:
|
|
117
|
+
if owner == "all" and state.settings.auth_mode == "oidc" and not caller.is_admin:
|
|
118
|
+
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="owner=all requires admin")
|
|
119
|
+
stmt = select(EntryRow).order_by(EntryRow.created_at.desc())
|
|
120
|
+
count_stmt = select(func.count()).select_from(EntryRow)
|
|
121
|
+
if state.settings.auth_mode == "oidc" and owner == "me" and not caller.is_admin:
|
|
122
|
+
stmt = stmt.where(EntryRow.owner == caller.sub)
|
|
123
|
+
count_stmt = count_stmt.where(EntryRow.owner == caller.sub)
|
|
124
|
+
if kind is not None:
|
|
125
|
+
stmt = stmt.where(EntryRow.kind == kind)
|
|
126
|
+
count_stmt = count_stmt.where(EntryRow.kind == kind)
|
|
127
|
+
if status_filter is not None:
|
|
128
|
+
stmt = stmt.where(EntryRow.status == status_filter)
|
|
129
|
+
count_stmt = count_stmt.where(EntryRow.status == status_filter)
|
|
130
|
+
|
|
131
|
+
async with state.db.session() as session:
|
|
132
|
+
rows = (await session.execute(stmt)).scalars().all()
|
|
133
|
+
|
|
134
|
+
wanted_tags = {t.strip() for t in tags.split(",") if t.strip()}
|
|
135
|
+
entries = [
|
|
136
|
+
row_to_entry(row) for row in rows if not wanted_tags or wanted_tags <= set(row.tags_json)
|
|
137
|
+
]
|
|
138
|
+
total = len(entries)
|
|
139
|
+
return Page(items=entries[offset : offset + limit], total=total, limit=limit, offset=offset)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@router.get("/agents/search", response_model=Page)
|
|
143
|
+
async def search_entries(
|
|
144
|
+
state: State,
|
|
145
|
+
caller: Caller,
|
|
146
|
+
response: Response,
|
|
147
|
+
q: Annotated[str, Query()] = "",
|
|
148
|
+
tags: Annotated[str, Query()] = "",
|
|
149
|
+
kind: Annotated[EntryKind | None, Query()] = None,
|
|
150
|
+
status_filter: Annotated[HealthStatus | None, Query(alias="status")] = None,
|
|
151
|
+
semantic: Annotated[bool, Query()] = False,
|
|
152
|
+
limit: Annotated[int, Query(ge=1, le=200)] = 50,
|
|
153
|
+
offset: Annotated[int, Query(ge=0)] = 0,
|
|
154
|
+
) -> Page:
|
|
155
|
+
owner_filter: str | None = None
|
|
156
|
+
if state.settings.auth_mode == "oidc" and not caller.is_admin:
|
|
157
|
+
owner_filter = caller.sub
|
|
158
|
+
if semantic and not state.search.semantic_enabled:
|
|
159
|
+
response.headers["X-Degraded"] = "semantic"
|
|
160
|
+
semantic = False
|
|
161
|
+
query = SearchQuery(
|
|
162
|
+
q=q,
|
|
163
|
+
tags=[t.strip() for t in tags.split(",") if t.strip()],
|
|
164
|
+
kind=kind,
|
|
165
|
+
status=status_filter,
|
|
166
|
+
semantic=semantic,
|
|
167
|
+
owner=owner_filter,
|
|
168
|
+
limit=limit,
|
|
169
|
+
offset=offset,
|
|
170
|
+
)
|
|
171
|
+
return await state.search.search(query)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@router.get("/agents/{entry_id}", response_model=RegistryEntry)
|
|
175
|
+
async def get_entry(entry_id: uuid.UUID, state: State, caller: Caller) -> RegistryEntry:
|
|
176
|
+
row = await _load_visible(entry_id, state, caller)
|
|
177
|
+
return row_to_entry(row)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
async def _load_visible(entry_id: uuid.UUID, state: RegistryState, caller: Principal) -> EntryRow:
|
|
181
|
+
async with state.db.session() as session:
|
|
182
|
+
row = await session.get(EntryRow, str(entry_id))
|
|
183
|
+
if row is None or not _visible(row, caller, state.settings.auth_mode):
|
|
184
|
+
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="entry not found")
|
|
185
|
+
return row
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _require_owner(row: EntryRow, caller: Principal, auth_mode: str) -> None:
|
|
189
|
+
if auth_mode == "oidc" and not caller.is_admin and row.owner != caller.sub:
|
|
190
|
+
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="owner or admin required")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@router.put("/agents/{entry_id}", response_model=RegistryEntry)
|
|
194
|
+
async def update_entry(
|
|
195
|
+
entry_id: uuid.UUID, body: RegistryEntryPatch, state: State, caller: Caller
|
|
196
|
+
) -> RegistryEntry:
|
|
197
|
+
row = await _load_visible(entry_id, state, caller)
|
|
198
|
+
_require_owner(row, caller, state.settings.auth_mode)
|
|
199
|
+
if body.url is not None:
|
|
200
|
+
_check_url(body.url, state.settings)
|
|
201
|
+
async with state.db.session() as session, session.begin():
|
|
202
|
+
fresh = await session.get(EntryRow, str(entry_id))
|
|
203
|
+
if fresh is None:
|
|
204
|
+
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="entry not found")
|
|
205
|
+
if body.card is not None:
|
|
206
|
+
fresh.card_json = _dump_card(body)
|
|
207
|
+
fresh.name = body.card.name
|
|
208
|
+
if body.url is not None:
|
|
209
|
+
fresh.url = body.url
|
|
210
|
+
if body.tags is not None:
|
|
211
|
+
fresh.tags_json = list(body.tags)
|
|
212
|
+
fresh.updated_at = datetime.now(UTC)
|
|
213
|
+
row = fresh
|
|
214
|
+
entry = row_to_entry(row)
|
|
215
|
+
await state.search.index(entry)
|
|
216
|
+
return entry
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@router.delete("/agents/{entry_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
220
|
+
async def delete_entry(entry_id: uuid.UUID, state: State, caller: Caller) -> None:
|
|
221
|
+
row = await _load_visible(entry_id, state, caller)
|
|
222
|
+
_require_owner(row, caller, state.settings.auth_mode)
|
|
223
|
+
async with state.db.session() as session, session.begin():
|
|
224
|
+
fresh = await session.get(EntryRow, str(entry_id))
|
|
225
|
+
if fresh is not None:
|
|
226
|
+
await session.delete(fresh)
|
|
227
|
+
await state.search.remove(entry_id)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@router.get("/capabilities", response_model=Capabilities)
|
|
231
|
+
async def capabilities(state: State) -> Capabilities:
|
|
232
|
+
return Capabilities(
|
|
233
|
+
semantic_search=state.search.semantic_enabled,
|
|
234
|
+
auth=state.settings.auth_mode,
|
|
235
|
+
version=REGISTRY_VERSION,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@health_router.get("/healthz")
|
|
240
|
+
async def healthz() -> dict[str, str]:
|
|
241
|
+
return {"status": "ok"}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@health_router.get("/readyz")
|
|
245
|
+
async def readyz(request: Request) -> dict[str, str]:
|
|
246
|
+
state: RegistryState = request.app.state.registry
|
|
247
|
+
async with state.db.session() as session:
|
|
248
|
+
await session.execute(select(1))
|
|
249
|
+
return {"status": "ready"}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
__all__ = ["RegistryState", "health_router", "router"]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Registry app factory: standalone-capable, SQLite by default, auth optional."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncIterator
|
|
6
|
+
from contextlib import asynccontextmanager
|
|
7
|
+
|
|
8
|
+
from fastapi import FastAPI
|
|
9
|
+
|
|
10
|
+
from agentplane_registry.api import RegistryState, health_router, router
|
|
11
|
+
from agentplane_registry.auth import Authenticator
|
|
12
|
+
from agentplane_registry.db import Database
|
|
13
|
+
from agentplane_registry.embeddings import EmbeddingsClient
|
|
14
|
+
from agentplane_registry.health import HealthJob
|
|
15
|
+
from agentplane_registry.search import RegistrySearch
|
|
16
|
+
from agentplane_registry.settings import REGISTRY_VERSION, RegistrySettings
|
|
17
|
+
from agentplane_registry.tracing import setup_tracing
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def create_app(settings: RegistrySettings | None = None, *, run_health_job: bool = True) -> FastAPI:
|
|
21
|
+
"""Build the registry service; everything optional degrades gracefully."""
|
|
22
|
+
cfg = settings or RegistrySettings()
|
|
23
|
+
db = Database(cfg.db_url)
|
|
24
|
+
embeddings = (
|
|
25
|
+
EmbeddingsClient(cfg.embeddings_base_url, cfg.embeddings_model)
|
|
26
|
+
if cfg.embeddings_base_url and cfg.embeddings_model
|
|
27
|
+
else None
|
|
28
|
+
)
|
|
29
|
+
search = RegistrySearch(db, embeddings)
|
|
30
|
+
health_job = HealthJob(db, cfg)
|
|
31
|
+
|
|
32
|
+
@asynccontextmanager
|
|
33
|
+
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
|
34
|
+
await db.create_all()
|
|
35
|
+
if run_health_job:
|
|
36
|
+
health_job.start()
|
|
37
|
+
try:
|
|
38
|
+
yield
|
|
39
|
+
finally:
|
|
40
|
+
if run_health_job:
|
|
41
|
+
await health_job.stop()
|
|
42
|
+
await db.dispose()
|
|
43
|
+
|
|
44
|
+
app = FastAPI(title="agentplane-registry", version=REGISTRY_VERSION, lifespan=lifespan)
|
|
45
|
+
app.state.registry = RegistryState(db=db, settings=cfg, search=search)
|
|
46
|
+
app.state.authenticator = Authenticator(cfg)
|
|
47
|
+
app.state.health_job = health_job
|
|
48
|
+
app.include_router(router)
|
|
49
|
+
app.include_router(health_router)
|
|
50
|
+
setup_tracing(app, service_name="agentplane-registry")
|
|
51
|
+
return app
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def main() -> None: # pragma: no cover - process entrypoint
|
|
55
|
+
import uvicorn # noqa: PLC0415 - optional server dep, only needed for the entrypoint
|
|
56
|
+
|
|
57
|
+
uvicorn.run(create_app(), host="0.0.0.0", port=8100)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
__all__ = ["create_app", "main"]
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Optional OIDC auth (SPEC §5.5): any issuer, JWKS cache, role mapping."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import jwt
|
|
10
|
+
from fastapi import HTTPException, Request, status
|
|
11
|
+
from jwt.types import Options
|
|
12
|
+
|
|
13
|
+
from agentplane_registry.settings import RegistrySettings
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Principal:
|
|
18
|
+
"""Authenticated caller."""
|
|
19
|
+
|
|
20
|
+
sub: str
|
|
21
|
+
roles: frozenset[str] = field(default_factory=frozenset)
|
|
22
|
+
is_admin: bool = False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
ANONYMOUS = Principal(sub="anonymous", roles=frozenset(), is_admin=False)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _claim_path(claims: dict[str, object], path: str) -> object:
|
|
29
|
+
value: object = claims
|
|
30
|
+
for part in path.split("."):
|
|
31
|
+
if not isinstance(value, dict):
|
|
32
|
+
return None
|
|
33
|
+
value = value.get(part)
|
|
34
|
+
return value
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class OidcValidator:
|
|
38
|
+
"""Validates JWTs against a generic OIDC issuer (discovery + JWKS cache)."""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
issuer: str,
|
|
43
|
+
audience: str,
|
|
44
|
+
roles_claim: str,
|
|
45
|
+
admin_role: str,
|
|
46
|
+
*,
|
|
47
|
+
jwks_ttl_s: float = 300.0,
|
|
48
|
+
) -> None:
|
|
49
|
+
self._issuer = issuer.rstrip("/")
|
|
50
|
+
self._audience = audience
|
|
51
|
+
self._roles_claim = roles_claim
|
|
52
|
+
self._admin_role = admin_role
|
|
53
|
+
self._jwks_ttl_s = jwks_ttl_s
|
|
54
|
+
self._jwks: dict[str, jwt.PyJWK] = {}
|
|
55
|
+
self._jwks_fetched_at = 0.0
|
|
56
|
+
|
|
57
|
+
async def _refresh_jwks(self) -> None:
|
|
58
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
59
|
+
discovery = await client.get(f"{self._issuer}/.well-known/openid-configuration")
|
|
60
|
+
discovery.raise_for_status()
|
|
61
|
+
jwks_uri = discovery.json().get("jwks_uri")
|
|
62
|
+
if not isinstance(jwks_uri, str):
|
|
63
|
+
raise RuntimeError("issuer discovery returned no jwks_uri")
|
|
64
|
+
jwks_response = await client.get(jwks_uri)
|
|
65
|
+
jwks_response.raise_for_status()
|
|
66
|
+
keys = jwt.PyJWKSet.from_dict(jwks_response.json()).keys
|
|
67
|
+
self._jwks = {key.key_id: key for key in keys if key.key_id}
|
|
68
|
+
self._jwks_fetched_at = time.monotonic()
|
|
69
|
+
|
|
70
|
+
async def _key_for(self, kid: str) -> jwt.PyJWK:
|
|
71
|
+
stale = time.monotonic() - self._jwks_fetched_at > self._jwks_ttl_s
|
|
72
|
+
if kid not in self._jwks or stale:
|
|
73
|
+
await self._refresh_jwks()
|
|
74
|
+
key = self._jwks.get(kid)
|
|
75
|
+
if key is None:
|
|
76
|
+
raise jwt.InvalidTokenError(f"unknown key id {kid!r}")
|
|
77
|
+
return key
|
|
78
|
+
|
|
79
|
+
async def validate(self, token: str) -> Principal:
|
|
80
|
+
header = jwt.get_unverified_header(token)
|
|
81
|
+
kid = header.get("kid")
|
|
82
|
+
if not isinstance(kid, str):
|
|
83
|
+
raise jwt.InvalidTokenError("token has no kid header")
|
|
84
|
+
key = await self._key_for(kid)
|
|
85
|
+
options: Options = {"verify_aud": bool(self._audience)}
|
|
86
|
+
claims = jwt.decode(
|
|
87
|
+
token,
|
|
88
|
+
key=key.key,
|
|
89
|
+
algorithms=["RS256", "ES256"],
|
|
90
|
+
audience=self._audience or None,
|
|
91
|
+
issuer=self._issuer,
|
|
92
|
+
options=options,
|
|
93
|
+
)
|
|
94
|
+
roles_value = _claim_path(claims, self._roles_claim)
|
|
95
|
+
roles = (
|
|
96
|
+
frozenset(str(role) for role in roles_value)
|
|
97
|
+
if isinstance(roles_value, list)
|
|
98
|
+
else frozenset()
|
|
99
|
+
)
|
|
100
|
+
sub = claims.get("sub")
|
|
101
|
+
if not isinstance(sub, str) or not sub:
|
|
102
|
+
raise jwt.InvalidTokenError("token has no sub")
|
|
103
|
+
return Principal(sub=sub, roles=roles, is_admin=self._admin_role in roles)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class Authenticator:
|
|
107
|
+
"""Resolves the request principal according to AUTH_MODE."""
|
|
108
|
+
|
|
109
|
+
def __init__(self, settings: RegistrySettings) -> None:
|
|
110
|
+
self.mode = settings.auth_mode
|
|
111
|
+
self._validator: OidcValidator | None = None
|
|
112
|
+
if self.mode == "oidc":
|
|
113
|
+
if not settings.oidc_issuer:
|
|
114
|
+
raise RuntimeError("AUTH_MODE=oidc requires AGENTPLANE_REGISTRY_OIDC_ISSUER")
|
|
115
|
+
self._validator = OidcValidator(
|
|
116
|
+
settings.oidc_issuer,
|
|
117
|
+
settings.oidc_audience,
|
|
118
|
+
settings.roles_claim,
|
|
119
|
+
settings.admin_role,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
async def authenticate(self, request: Request) -> Principal:
|
|
123
|
+
if self.mode == "none" or self._validator is None:
|
|
124
|
+
return ANONYMOUS
|
|
125
|
+
header = request.headers.get("Authorization", "")
|
|
126
|
+
scheme, _, token = header.partition(" ")
|
|
127
|
+
if scheme.lower() != "bearer" or not token:
|
|
128
|
+
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="missing bearer token")
|
|
129
|
+
try:
|
|
130
|
+
return await self._validator.validate(token)
|
|
131
|
+
except jwt.InvalidTokenError as exc:
|
|
132
|
+
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
|
|
133
|
+
except httpx.HTTPError as exc:
|
|
134
|
+
raise HTTPException(
|
|
135
|
+
status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"issuer unreachable: {exc}"
|
|
136
|
+
) from exc
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
__all__ = ["ANONYMOUS", "Authenticator", "OidcValidator", "Principal"]
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Persistence layer (SPEC §5.2): SQLAlchemy async, SQLite by default."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import uuid
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
|
|
9
|
+
from sqlalchemy import JSON, DateTime, Float, String, UniqueConstraint, select
|
|
10
|
+
from sqlalchemy.ext.asyncio import (
|
|
11
|
+
AsyncEngine,
|
|
12
|
+
AsyncSession,
|
|
13
|
+
async_sessionmaker,
|
|
14
|
+
create_async_engine,
|
|
15
|
+
)
|
|
16
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
17
|
+
|
|
18
|
+
from agentplane_core import (
|
|
19
|
+
EntryKind,
|
|
20
|
+
HealthStatus,
|
|
21
|
+
JsonObject,
|
|
22
|
+
RegistryEntry,
|
|
23
|
+
serialize_card,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Base(DeclarativeBase):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _utcnow() -> datetime:
|
|
32
|
+
return datetime.now(UTC)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class EntryRow(Base):
|
|
36
|
+
__tablename__ = "entries"
|
|
37
|
+
__table_args__ = (UniqueConstraint("owner", "name", name="uq_entries_owner_name"),)
|
|
38
|
+
|
|
39
|
+
id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
|
40
|
+
kind: Mapped[str] = mapped_column(String(16), index=True)
|
|
41
|
+
name: Mapped[str] = mapped_column(String(255), index=True)
|
|
42
|
+
owner: Mapped[str] = mapped_column(String(255), index=True)
|
|
43
|
+
url: Mapped[str] = mapped_column(String(2048))
|
|
44
|
+
card_json: Mapped[str] = mapped_column(String)
|
|
45
|
+
tags_json: Mapped[list[str]] = mapped_column(JSON, default=list)
|
|
46
|
+
status: Mapped[str] = mapped_column(String(16), default="starting", index=True)
|
|
47
|
+
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
48
|
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
|
49
|
+
updated_at: Mapped[datetime] = mapped_column(
|
|
50
|
+
DateTime(timezone=True), default=_utcnow, onupdate=_utcnow
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class EntryEmbeddingRow(Base):
|
|
55
|
+
"""Entry embedding vector.
|
|
56
|
+
|
|
57
|
+
SPEC §5.2 requires this table only under [semantic]+Postgres; we persist
|
|
58
|
+
JSON vectors on every backend so SQLite restarts do not re-embed, and use
|
|
59
|
+
an in-process numpy brute-force search (fine <= a few thousand entries).
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
__tablename__ = "entry_embeddings"
|
|
63
|
+
|
|
64
|
+
entry_id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
|
65
|
+
vector: Mapped[list[float]] = mapped_column(JSON)
|
|
66
|
+
norm: Mapped[float] = mapped_column(Float, default=1.0)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def row_to_entry(row: EntryRow) -> RegistryEntry:
|
|
70
|
+
card: JsonObject = json.loads(row.card_json)
|
|
71
|
+
return RegistryEntry.model_validate(
|
|
72
|
+
{
|
|
73
|
+
"id": uuid.UUID(row.id),
|
|
74
|
+
"kind": row.kind,
|
|
75
|
+
"card": card,
|
|
76
|
+
"url": row.url,
|
|
77
|
+
"tags": list(row.tags_json),
|
|
78
|
+
"owner": row.owner,
|
|
79
|
+
"status": row.status,
|
|
80
|
+
"last_seen": row.last_seen,
|
|
81
|
+
"created_at": row.created_at,
|
|
82
|
+
"updated_at": row.updated_at,
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def entry_kind(value: str) -> EntryKind:
|
|
88
|
+
if value not in ("agent", "mcp_server"):
|
|
89
|
+
raise ValueError(f"invalid entry kind {value!r}")
|
|
90
|
+
return "agent" if value == "agent" else "mcp_server"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def health_status(value: str) -> HealthStatus:
|
|
94
|
+
if value not in ("starting", "healthy", "unhealthy", "unknown"):
|
|
95
|
+
raise ValueError(f"invalid health status {value!r}")
|
|
96
|
+
result: HealthStatus = value # type: ignore[assignment] # narrowed by the check above
|
|
97
|
+
return result
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def card_to_json_str(entry: RegistryEntry) -> str:
|
|
101
|
+
return json.dumps(serialize_card(entry.card), sort_keys=True)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class Database:
|
|
105
|
+
"""Engine + session factory wrapper."""
|
|
106
|
+
|
|
107
|
+
def __init__(self, db_url: str) -> None:
|
|
108
|
+
self.engine: AsyncEngine = create_async_engine(db_url)
|
|
109
|
+
self.session_factory = async_sessionmaker(self.engine, expire_on_commit=False)
|
|
110
|
+
|
|
111
|
+
async def create_all(self) -> None:
|
|
112
|
+
async with self.engine.begin() as conn:
|
|
113
|
+
await conn.run_sync(Base.metadata.create_all)
|
|
114
|
+
|
|
115
|
+
async def dispose(self) -> None:
|
|
116
|
+
await self.engine.dispose()
|
|
117
|
+
|
|
118
|
+
def session(self) -> AsyncSession:
|
|
119
|
+
return self.session_factory()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
async def get_row(session: AsyncSession, entry_id: str) -> EntryRow | None:
|
|
123
|
+
result = await session.execute(select(EntryRow).where(EntryRow.id == entry_id))
|
|
124
|
+
return result.scalar_one_or_none()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
__all__ = [
|
|
128
|
+
"Base",
|
|
129
|
+
"Database",
|
|
130
|
+
"EntryEmbeddingRow",
|
|
131
|
+
"EntryRow",
|
|
132
|
+
"card_to_json_str",
|
|
133
|
+
"entry_kind",
|
|
134
|
+
"get_row",
|
|
135
|
+
"health_status",
|
|
136
|
+
"row_to_entry",
|
|
137
|
+
]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Entry embeddings via an OpenAI-compatible /embeddings endpoint (SPEC §5.4)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from agentplane_core import RegistryEntry, ToolCard
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def embedding_text(entry: RegistryEntry) -> str:
|
|
11
|
+
"""``f"{name}\\n{description}\\n" + skill descriptions`` (SPEC §5.4)."""
|
|
12
|
+
card = entry.card
|
|
13
|
+
if isinstance(card, ToolCard):
|
|
14
|
+
skills = [f"{tool.name} {tool.description}".strip() for tool in card.tools]
|
|
15
|
+
name, description = card.name, card.description
|
|
16
|
+
else:
|
|
17
|
+
skills = [f"{skill.name} {skill.description}".strip() for skill in card.skills]
|
|
18
|
+
name, description = card.name, card.description
|
|
19
|
+
return f"{name}\n{description}\n" + "\n".join(skills)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class EmbeddingsClient:
|
|
23
|
+
"""Minimal OpenAI-compatible embeddings client, pointed at the gateway."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, base_url: str, model: str, *, timeout: float = 30.0) -> None:
|
|
26
|
+
self._base_url = base_url.rstrip("/")
|
|
27
|
+
self.model = model
|
|
28
|
+
self._timeout = timeout
|
|
29
|
+
|
|
30
|
+
async def embed(self, text: str) -> list[float]:
|
|
31
|
+
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
|
32
|
+
response = await client.post(
|
|
33
|
+
f"{self._base_url}/embeddings",
|
|
34
|
+
json={"model": self.model, "input": text},
|
|
35
|
+
)
|
|
36
|
+
response.raise_for_status()
|
|
37
|
+
data = response.json()["data"]
|
|
38
|
+
vector = data[0]["embedding"]
|
|
39
|
+
return [float(v) for v in vector]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
__all__ = ["EmbeddingsClient", "embedding_text"]
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Background health job (SPEC §5.3).
|
|
2
|
+
|
|
3
|
+
Transitions ``starting -> healthy <-> unhealthy`` are made only here.
|
|
4
|
+
Agents: fetch the agent card THROUGH the gateway (the stored URL *is* the
|
|
5
|
+
gateway URL). MCP servers: ``tools/list`` via streamable HTTP when
|
|
6
|
+
``HEALTH_MCP=true``, else ``unknown``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import contextlib
|
|
13
|
+
import logging
|
|
14
|
+
import random
|
|
15
|
+
from datetime import UTC, datetime
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
from sqlalchemy import select
|
|
19
|
+
|
|
20
|
+
from agentplane_core import agent_card_from_dict
|
|
21
|
+
from agentplane_registry.db import Database, EntryRow
|
|
22
|
+
from agentplane_registry.settings import RegistrySettings
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
FAILURES_BEFORE_UNHEALTHY = 3
|
|
27
|
+
STARTING_RETRY_DELAY_S = 5.0
|
|
28
|
+
STARTING_RETRY_ATTEMPTS = 6
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def check_agent(url: str, *, timeout: float) -> bool:
|
|
32
|
+
"""2xx + parseable agent card -> healthy."""
|
|
33
|
+
card_url = url.rstrip("/") + "/.well-known/agent-card.json"
|
|
34
|
+
try:
|
|
35
|
+
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
|
36
|
+
response = await client.get(card_url)
|
|
37
|
+
if not response.is_success:
|
|
38
|
+
return False
|
|
39
|
+
agent_card_from_dict(response.json())
|
|
40
|
+
except Exception:
|
|
41
|
+
return False
|
|
42
|
+
return True
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def check_mcp_server(url: str, *, timeout: float) -> bool:
|
|
46
|
+
"""Initialize + tools/list via streamable HTTP (FastMCP client)."""
|
|
47
|
+
from fastmcp import Client # noqa: PLC0415 - deferred: heavy import
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
async with asyncio.timeout(timeout):
|
|
51
|
+
client = Client(url)
|
|
52
|
+
async with client:
|
|
53
|
+
await client.list_tools()
|
|
54
|
+
except Exception:
|
|
55
|
+
return False
|
|
56
|
+
return True
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class HealthJob:
|
|
60
|
+
"""Periodic checker with fast retry for fresh ``starting`` entries."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, db: Database, settings: RegistrySettings) -> None:
|
|
63
|
+
self._db = db
|
|
64
|
+
self._settings = settings
|
|
65
|
+
self._failures: dict[str, int] = {}
|
|
66
|
+
self._task: asyncio.Task[None] | None = None
|
|
67
|
+
|
|
68
|
+
def start(self) -> None:
|
|
69
|
+
self._task = asyncio.create_task(self._loop(), name="registry-health-job")
|
|
70
|
+
|
|
71
|
+
async def stop(self) -> None:
|
|
72
|
+
if self._task is not None:
|
|
73
|
+
self._task.cancel()
|
|
74
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
75
|
+
await self._task
|
|
76
|
+
self._task = None
|
|
77
|
+
|
|
78
|
+
async def _loop(self) -> None:
|
|
79
|
+
while True:
|
|
80
|
+
try:
|
|
81
|
+
await self.run_once()
|
|
82
|
+
except Exception:
|
|
83
|
+
logger.exception("health job pass failed")
|
|
84
|
+
interval = self._settings.health_interval_s
|
|
85
|
+
await asyncio.sleep(interval + random.uniform(0, interval * 0.1))
|
|
86
|
+
|
|
87
|
+
async def _check(self, row: EntryRow) -> bool | None:
|
|
88
|
+
"""True/False = healthy/unhealthy signal; None = unknown (not checkable)."""
|
|
89
|
+
if row.kind == "agent":
|
|
90
|
+
return await check_agent(row.url, timeout=self._settings.health_timeout_s)
|
|
91
|
+
if self._settings.health_mcp:
|
|
92
|
+
return await check_mcp_server(row.url, timeout=self._settings.health_timeout_s)
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
async def run_once(self) -> None:
|
|
96
|
+
"""One health pass over all entries (checks run concurrently)."""
|
|
97
|
+
async with self._db.session() as session:
|
|
98
|
+
rows = (await session.execute(select(EntryRow))).scalars().all()
|
|
99
|
+
tasks = [
|
|
100
|
+
self._check_starting(row.id)
|
|
101
|
+
if row.status == "starting"
|
|
102
|
+
else self._check_and_apply(row.id)
|
|
103
|
+
for row in rows
|
|
104
|
+
]
|
|
105
|
+
if tasks:
|
|
106
|
+
await asyncio.gather(*tasks)
|
|
107
|
+
|
|
108
|
+
async def _check_and_apply(self, entry_id: str) -> None:
|
|
109
|
+
await self._apply(entry_id, await self._check_row(entry_id))
|
|
110
|
+
|
|
111
|
+
async def _check_row(self, entry_id: str) -> bool | None:
|
|
112
|
+
async with self._db.session() as session:
|
|
113
|
+
row = await session.get(EntryRow, entry_id)
|
|
114
|
+
if row is None:
|
|
115
|
+
return None
|
|
116
|
+
return await self._check(row)
|
|
117
|
+
|
|
118
|
+
async def _check_starting(self, entry_id: str) -> None:
|
|
119
|
+
"""Fast retry (5s x6) so fresh deployments turn healthy quickly."""
|
|
120
|
+
for attempt in range(STARTING_RETRY_ATTEMPTS):
|
|
121
|
+
healthy = await self._check_row(entry_id)
|
|
122
|
+
if healthy is None:
|
|
123
|
+
await self._set_status(entry_id, "unknown")
|
|
124
|
+
return
|
|
125
|
+
if healthy:
|
|
126
|
+
await self._set_status(entry_id, "healthy", touch_last_seen=True)
|
|
127
|
+
return
|
|
128
|
+
if attempt < STARTING_RETRY_ATTEMPTS - 1:
|
|
129
|
+
await asyncio.sleep(STARTING_RETRY_DELAY_S)
|
|
130
|
+
await self._set_status(entry_id, "unhealthy")
|
|
131
|
+
|
|
132
|
+
async def _apply(self, entry_id: str, healthy: bool | None) -> None:
|
|
133
|
+
if healthy is None:
|
|
134
|
+
await self._set_status(entry_id, "unknown")
|
|
135
|
+
self._failures.pop(entry_id, None)
|
|
136
|
+
return
|
|
137
|
+
if healthy:
|
|
138
|
+
self._failures.pop(entry_id, None)
|
|
139
|
+
await self._set_status(entry_id, "healthy", touch_last_seen=True)
|
|
140
|
+
return
|
|
141
|
+
failures = self._failures.get(entry_id, 0) + 1
|
|
142
|
+
self._failures[entry_id] = failures
|
|
143
|
+
if failures >= FAILURES_BEFORE_UNHEALTHY:
|
|
144
|
+
await self._set_status(entry_id, "unhealthy")
|
|
145
|
+
|
|
146
|
+
async def _set_status(
|
|
147
|
+
self, entry_id: str, status: str, *, touch_last_seen: bool = False
|
|
148
|
+
) -> None:
|
|
149
|
+
async with self._db.session() as session, session.begin():
|
|
150
|
+
row = await session.get(EntryRow, entry_id)
|
|
151
|
+
if row is None:
|
|
152
|
+
return
|
|
153
|
+
row.status = status
|
|
154
|
+
if touch_last_seen:
|
|
155
|
+
row.last_seen = datetime.now(UTC)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
__all__ = ["HealthJob", "check_agent", "check_mcp_server"]
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Search backend (SPEC §5.1): text match + optional semantic with RRF merge.
|
|
2
|
+
|
|
3
|
+
Text search: case-insensitive match over name, description and skill
|
|
4
|
+
names/descriptions plus AND tag filter. Semantic search: cosine top-k over
|
|
5
|
+
the entry embedding, merged with the text hits via reciprocal rank fusion.
|
|
6
|
+
Without the ``[semantic]`` extra (numpy) or embeddings config, semantic
|
|
7
|
+
requests silently degrade to text search (the API adds ``X-Degraded``).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import math
|
|
14
|
+
from importlib.util import find_spec
|
|
15
|
+
from uuid import UUID
|
|
16
|
+
|
|
17
|
+
from sqlalchemy import delete, select
|
|
18
|
+
|
|
19
|
+
from agentplane_core import (
|
|
20
|
+
Page,
|
|
21
|
+
RegistryEntry,
|
|
22
|
+
SearchBackend,
|
|
23
|
+
SearchQuery,
|
|
24
|
+
ToolCard,
|
|
25
|
+
)
|
|
26
|
+
from agentplane_registry.db import Database, EntryEmbeddingRow, EntryRow, row_to_entry
|
|
27
|
+
from agentplane_registry.embeddings import EmbeddingsClient, embedding_text
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
_RRF_K = 60 # standard reciprocal-rank-fusion constant
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def semantic_available() -> bool:
|
|
35
|
+
"""The [semantic] extra is installed when numpy imports."""
|
|
36
|
+
return find_spec("numpy") is not None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _haystack(entry: RegistryEntry) -> str:
|
|
40
|
+
card = entry.card
|
|
41
|
+
parts = [card.name, card.description]
|
|
42
|
+
if isinstance(card, ToolCard):
|
|
43
|
+
parts += [f"{tool.name} {tool.description}" for tool in card.tools]
|
|
44
|
+
else:
|
|
45
|
+
parts += [f"{skill.name} {skill.description}" for skill in card.skills]
|
|
46
|
+
return "\n".join(parts).lower()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _matches(entry: RegistryEntry, query: SearchQuery) -> bool:
|
|
50
|
+
if query.tags and not set(query.tags) <= set(entry.tags):
|
|
51
|
+
return False
|
|
52
|
+
return not query.q or query.q.lower() in _haystack(entry)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class RegistrySearch(SearchBackend):
|
|
56
|
+
"""SQL-filtered text search with optional in-process semantic ranking."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, db: Database, embeddings: EmbeddingsClient | None) -> None:
|
|
59
|
+
self._db = db
|
|
60
|
+
self._embeddings = embeddings
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def semantic_enabled(self) -> bool:
|
|
64
|
+
return self._embeddings is not None and semantic_available()
|
|
65
|
+
|
|
66
|
+
async def index(self, entry: RegistryEntry) -> None:
|
|
67
|
+
"""(Re-)embed an entry; no-op without semantic support."""
|
|
68
|
+
if self._embeddings is None or not semantic_available():
|
|
69
|
+
return
|
|
70
|
+
try:
|
|
71
|
+
vector = await self._embeddings.embed(embedding_text(entry))
|
|
72
|
+
except Exception:
|
|
73
|
+
logger.warning("embedding failed for entry %s", entry.id, exc_info=True)
|
|
74
|
+
return
|
|
75
|
+
norm = math.sqrt(sum(v * v for v in vector)) or 1.0
|
|
76
|
+
async with self._db.session() as session, session.begin():
|
|
77
|
+
existing = await session.get(EntryEmbeddingRow, str(entry.id))
|
|
78
|
+
if existing is None:
|
|
79
|
+
session.add(EntryEmbeddingRow(entry_id=str(entry.id), vector=vector, norm=norm))
|
|
80
|
+
else:
|
|
81
|
+
existing.vector = vector
|
|
82
|
+
existing.norm = norm
|
|
83
|
+
|
|
84
|
+
async def remove(self, entry_id: UUID) -> None:
|
|
85
|
+
async with self._db.session() as session, session.begin():
|
|
86
|
+
await session.execute(
|
|
87
|
+
delete(EntryEmbeddingRow).where(EntryEmbeddingRow.entry_id == str(entry_id))
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
async def _load_candidates(self, query: SearchQuery) -> list[RegistryEntry]:
|
|
91
|
+
stmt = select(EntryRow).order_by(EntryRow.created_at.desc())
|
|
92
|
+
if query.kind is not None:
|
|
93
|
+
stmt = stmt.where(EntryRow.kind == query.kind)
|
|
94
|
+
if query.status is not None:
|
|
95
|
+
stmt = stmt.where(EntryRow.status == query.status)
|
|
96
|
+
if query.owner is not None:
|
|
97
|
+
stmt = stmt.where(EntryRow.owner == query.owner)
|
|
98
|
+
async with self._db.session() as session:
|
|
99
|
+
rows = (await session.execute(stmt)).scalars().all()
|
|
100
|
+
return [row_to_entry(row) for row in rows]
|
|
101
|
+
|
|
102
|
+
async def _semantic_ranking(self, query: SearchQuery) -> list[UUID]:
|
|
103
|
+
"""Entry ids ranked by cosine similarity to the query embedding."""
|
|
104
|
+
if self._embeddings is None:
|
|
105
|
+
return []
|
|
106
|
+
query_vector = await self._embeddings.embed(query.q)
|
|
107
|
+
query_norm = math.sqrt(sum(v * v for v in query_vector)) or 1.0
|
|
108
|
+
async with self._db.session() as session:
|
|
109
|
+
rows = (await session.execute(select(EntryEmbeddingRow))).scalars().all()
|
|
110
|
+
if not rows:
|
|
111
|
+
return []
|
|
112
|
+
import numpy as np # noqa: PLC0415 - [semantic] extra, imported only when present
|
|
113
|
+
|
|
114
|
+
matrix = np.array([row.vector for row in rows], dtype=np.float64)
|
|
115
|
+
norms = np.array([row.norm for row in rows], dtype=np.float64)
|
|
116
|
+
scores = (matrix @ np.array(query_vector)) / (norms * query_norm)
|
|
117
|
+
order = np.argsort(-scores)
|
|
118
|
+
return [UUID(rows[int(i)].entry_id) for i in order]
|
|
119
|
+
|
|
120
|
+
async def search(self, q: SearchQuery) -> Page:
|
|
121
|
+
candidates = await self._load_candidates(q)
|
|
122
|
+
text_hits = [entry for entry in candidates if _matches(entry, q)]
|
|
123
|
+
|
|
124
|
+
use_semantic = q.semantic and self.semantic_enabled and bool(q.q)
|
|
125
|
+
if use_semantic:
|
|
126
|
+
try:
|
|
127
|
+
semantic_ids = await self._semantic_ranking(q)
|
|
128
|
+
except Exception:
|
|
129
|
+
logger.warning("semantic search failed; degrading to text", exc_info=True)
|
|
130
|
+
semantic_ids = []
|
|
131
|
+
by_id = {entry.id: entry for entry in candidates if _matches_filters(entry, q)}
|
|
132
|
+
ranked = _rrf_merge(
|
|
133
|
+
[entry.id for entry in text_hits],
|
|
134
|
+
[entry_id for entry_id in semantic_ids if entry_id in by_id],
|
|
135
|
+
)
|
|
136
|
+
hits = [by_id[entry_id] for entry_id in ranked if entry_id in by_id]
|
|
137
|
+
else:
|
|
138
|
+
hits = text_hits
|
|
139
|
+
|
|
140
|
+
total = len(hits)
|
|
141
|
+
page_items = hits[q.offset : q.offset + q.limit]
|
|
142
|
+
return Page(items=page_items, total=total, limit=q.limit, offset=q.offset)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _matches_filters(entry: RegistryEntry, query: SearchQuery) -> bool:
|
|
146
|
+
"""Tag filter only (semantic hits skip the text match but not the filters)."""
|
|
147
|
+
return not query.tags or set(query.tags) <= set(entry.tags)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _rrf_merge(first: list[UUID], second: list[UUID]) -> list[UUID]:
|
|
151
|
+
"""Reciprocal rank fusion of two rankings."""
|
|
152
|
+
scores: dict[UUID, float] = {}
|
|
153
|
+
for ranking in (first, second):
|
|
154
|
+
for rank, entry_id in enumerate(ranking):
|
|
155
|
+
scores[entry_id] = scores.get(entry_id, 0.0) + 1.0 / (_RRF_K + rank + 1)
|
|
156
|
+
return sorted(scores, key=lambda entry_id: -scores[entry_id])
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
__all__ = ["RegistrySearch", "semantic_available"]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Registry configuration (SPEC §7.2), env prefix ``AGENTPLANE_REGISTRY_``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
8
|
+
|
|
9
|
+
REGISTRY_VERSION = "0.0.1"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RegistrySettings(BaseSettings):
|
|
13
|
+
model_config = SettingsConfigDict(env_prefix="AGENTPLANE_REGISTRY_", extra="ignore")
|
|
14
|
+
|
|
15
|
+
db_url: str = "sqlite+aiosqlite:///registry.db"
|
|
16
|
+
auth_mode: Literal["none", "oidc"] = "none"
|
|
17
|
+
oidc_issuer: str = ""
|
|
18
|
+
oidc_audience: str = ""
|
|
19
|
+
roles_claim: str = "realm_access.roles"
|
|
20
|
+
admin_role: str = "admin"
|
|
21
|
+
health_interval_s: float = 60.0
|
|
22
|
+
health_mcp: bool = True
|
|
23
|
+
health_timeout_s: float = 10.0
|
|
24
|
+
embeddings_base_url: str = ""
|
|
25
|
+
embeddings_model: str = ""
|
|
26
|
+
allow_private_urls: bool = False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
__all__ = ["REGISTRY_VERSION", "RegistrySettings"]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""OpenTelemetry wiring — OTLP endpoint is configuration, never a vendor SDK.
|
|
2
|
+
|
|
3
|
+
Uses the standard ``OTEL_EXPORTER_OTLP_ENDPOINT`` variable; without it the
|
|
4
|
+
no-op tracer provider stays active and nothing is exported.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from collections.abc import Awaitable, Callable
|
|
11
|
+
|
|
12
|
+
from fastapi import FastAPI, Request, Response
|
|
13
|
+
from opentelemetry import trace
|
|
14
|
+
from opentelemetry.sdk.resources import Resource
|
|
15
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
16
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
|
17
|
+
from opentelemetry.trace import Status, StatusCode
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def setup_tracing(app: FastAPI, *, service_name: str) -> None:
|
|
21
|
+
"""Install an OTLP exporter (when configured) and a request span middleware."""
|
|
22
|
+
endpoint = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT", "")
|
|
23
|
+
if endpoint:
|
|
24
|
+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( # noqa: PLC0415
|
|
25
|
+
OTLPSpanExporter,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
provider = TracerProvider(resource=Resource.create({"service.name": service_name}))
|
|
29
|
+
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
|
|
30
|
+
trace.set_tracer_provider(provider)
|
|
31
|
+
|
|
32
|
+
tracer = trace.get_tracer(service_name)
|
|
33
|
+
|
|
34
|
+
@app.middleware("http")
|
|
35
|
+
async def _trace_requests(
|
|
36
|
+
request: Request, call_next: Callable[[Request], Awaitable[Response]]
|
|
37
|
+
) -> Response:
|
|
38
|
+
with tracer.start_as_current_span(
|
|
39
|
+
f"{request.method} {request.url.path}",
|
|
40
|
+
attributes={
|
|
41
|
+
"http.request.method": request.method,
|
|
42
|
+
"url.path": request.url.path,
|
|
43
|
+
},
|
|
44
|
+
) as span:
|
|
45
|
+
response = await call_next(request)
|
|
46
|
+
span.set_attribute("http.response.status_code", response.status_code)
|
|
47
|
+
if response.status_code >= 500: # noqa: PLR2004
|
|
48
|
+
span.set_status(Status(StatusCode.ERROR))
|
|
49
|
+
return response
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
__all__ = ["setup_tracing"]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Gateway-URL guard: reject private/internal hosts (SPEC §3.4).
|
|
2
|
+
|
|
3
|
+
The registry stores gateway URLs only. Unless ``ALLOW_PRIVATE_URLS`` is set,
|
|
4
|
+
URLs resolving to loopback/private/link-local hosts are refused so internal
|
|
5
|
+
service addresses can never leak into the registry.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ipaddress
|
|
11
|
+
from urllib.parse import urlparse
|
|
12
|
+
|
|
13
|
+
_PRIVATE_HOSTNAMES = {"localhost", "host.docker.internal"}
|
|
14
|
+
_PRIVATE_SUFFIXES = (".local", ".internal", ".localdomain")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def is_private_url(url: str) -> bool:
|
|
18
|
+
"""True when the URL's host looks private/internal (best-effort, no DNS)."""
|
|
19
|
+
parsed = urlparse(url)
|
|
20
|
+
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
|
21
|
+
return True
|
|
22
|
+
host = parsed.hostname.lower()
|
|
23
|
+
if host in _PRIVATE_HOSTNAMES or host.endswith(_PRIVATE_SUFFIXES):
|
|
24
|
+
return True
|
|
25
|
+
try:
|
|
26
|
+
address = ipaddress.ip_address(host)
|
|
27
|
+
except ValueError:
|
|
28
|
+
return "." not in host # bare docker-compose service names etc.
|
|
29
|
+
return address.is_private or address.is_loopback or address.is_link_local
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
__all__ = ["is_private_url"]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentplane-registry
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: agentplane discovery service (standalone-capable)
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Requires-Dist: agentplane-core<0.1.0,>=0.0.1
|
|
8
|
+
Requires-Dist: aiosqlite>=0.20
|
|
9
|
+
Requires-Dist: fastapi>=0.115
|
|
10
|
+
Requires-Dist: fastmcp==3.4.4
|
|
11
|
+
Requires-Dist: httpx>=0.27
|
|
12
|
+
Requires-Dist: opentelemetry-api>=1.27
|
|
13
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.27
|
|
14
|
+
Requires-Dist: opentelemetry-sdk>=1.27
|
|
15
|
+
Requires-Dist: pydantic-settings>=2.4
|
|
16
|
+
Requires-Dist: pyjwt[crypto]>=2.9
|
|
17
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0.30
|
|
18
|
+
Requires-Dist: uvicorn>=0.30
|
|
19
|
+
Provides-Extra: postgres
|
|
20
|
+
Requires-Dist: asyncpg>=0.29; extra == 'postgres'
|
|
21
|
+
Requires-Dist: pgvector>=0.3; extra == 'postgres'
|
|
22
|
+
Provides-Extra: semantic
|
|
23
|
+
Requires-Dist: numpy>=1.26; extra == 'semantic'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# agentplane-registry
|
|
27
|
+
|
|
28
|
+
Standalone-capable discovery service for A2A agents and MCP servers:
|
|
29
|
+
register, tag, search (text + optional semantic), health-check.
|
|
30
|
+
|
|
31
|
+
- Runs alone: SQLite by default, auth optional (generic OIDC, any issuer),
|
|
32
|
+
no calls to other agentplane services.
|
|
33
|
+
- Extras: `agentplane-registry[semantic]` (numpy + embeddings via an
|
|
34
|
+
OpenAI-compatible endpoint), `[postgres]` (asyncpg). Without extras the
|
|
35
|
+
service degrades gracefully and announces features via `GET /capabilities`.
|
|
36
|
+
- Stores **gateway URLs only**; private/internal hosts are rejected unless
|
|
37
|
+
`AGENTPLANE_REGISTRY_ALLOW_PRIVATE_URLS=true`.
|
|
38
|
+
|
|
39
|
+
Run it:
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
uvicorn --factory agentplane_registry.app:create_app --port 8100
|
|
43
|
+
```
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
agentplane_registry/__init__.py,sha256=Hpwnkn63lmalMkL94XP5zPfZu8SPJCP6A3bvmk2aaF0,287
|
|
2
|
+
agentplane_registry/api.py,sha256=1m7TPwfrSU9BJUAeW9YhPdqcCDy0zKSb57oSQlkf9R0,8977
|
|
3
|
+
agentplane_registry/app.py,sha256=lbp-NTiFm0vzwYXEKWZCgE0utysU8PU-If4QcHRgZSo,2170
|
|
4
|
+
agentplane_registry/auth.py,sha256=5rz7721MyT_hGQNFgExc30Zd7CuonWbS5S2CjXnNQF8,4956
|
|
5
|
+
agentplane_registry/db.py,sha256=OhsCa6XHO5ZbbncoghpKTEaJC8gxV5jQnbbtBzZZcrY,4226
|
|
6
|
+
agentplane_registry/embeddings.py,sha256=X3LovdGR99D9-QC6hH_sSl0Nh7-BMEDBiUMDfF_9GRg,1518
|
|
7
|
+
agentplane_registry/health.py,sha256=Z5AlbkVchtv_9kh18-yhiW2Ds6pY-4RTCCqH-NVgviM,5678
|
|
8
|
+
agentplane_registry/search.py,sha256=72XuXui6Z6OIZLHp5BfBjns4QCg_w1c48bqLuuj8se4,6455
|
|
9
|
+
agentplane_registry/settings.py,sha256=sZlIAPduAul5hwgxiA8GsLhkHfpD6gwL30lPrMnfvgY,848
|
|
10
|
+
agentplane_registry/tracing.py,sha256=8Wzse1Se3U4m80W7OWuMB84MVaDuk43mci1dDf5OPmA,1949
|
|
11
|
+
agentplane_registry/urlcheck.py,sha256=W4RxEid9pB1aGOZ0O1TPDCK01kwOCjq4gLUZWXfEwKs,1107
|
|
12
|
+
agentplane_registry-0.0.1.dist-info/METADATA,sha256=levYmtG_9hyqrsiT5DHYlT0rjPuodc-RGFK3aYDV7E4,1555
|
|
13
|
+
agentplane_registry-0.0.1.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
14
|
+
agentplane_registry-0.0.1.dist-info/RECORD,,
|