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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any