initial a2a-pack

This commit is contained in:
robert
2026-05-08 21:59:51 -03:00
commit b6f6cd1643
29 changed files with 3218 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
from .asgi import build_app, serve
__all__ = ["build_app", "serve"]

104
a2a_pack/serve/asgi.py Normal file
View File

@@ -0,0 +1,104 @@
"""HTTP adapter that turns any :class:`A2AAgent` into a service.
This is intentionally minimal: it covers the surface needed to plug into
the wider A2A ecosystem and the platform's control plane.
Endpoints:
GET /healthz -> liveness
GET /.well-known/agent-card -> Agent Card JSON
POST /invoke/{skill} -> invoke skill (JSON in, JSON out)
Auth: a single bearer token is read from the ``A2A_API_KEY`` env var. If set,
all routes except ``/healthz`` and the card require ``Authorization: Bearer
<key>``. The bearer token is materialized into the agent's declared
``auth_model`` (best-effort: APIKeyAuth, otherwise NoAuth).
"""
from __future__ import annotations
import os
from typing import Any
from fastapi import FastAPI, Header, HTTPException
from pydantic import BaseModel
from ..agent import A2AAgent, SkillInputError, SkillNotFound
from ..auth import APIKeyAuth, NoAuth
from ..context import LocalRunContext, MissingScopes
class _InvokeIn(BaseModel):
arguments: dict[str, Any] = {}
def build_app(agent: A2AAgent) -> FastAPI:
"""Build a FastAPI app for the given agent instance."""
app = FastAPI(title=type(agent).name, version=type(agent).version)
api_key = os.environ.get("A2A_API_KEY")
def _build_auth(provided_key: str | None) -> Any:
auth_cls = type(agent).auth_model
if auth_cls is APIKeyAuth:
return APIKeyAuth(api_key_id=provided_key or "anonymous")
if auth_cls is NoAuth:
return NoAuth()
# Unknown auth model: try default-construct, else fail loudly.
try:
return auth_cls()
except Exception as exc: # pragma: no cover - depends on user model
raise HTTPException(
status_code=500,
detail=f"cannot materialize auth_model {auth_cls.__name__}: {exc}",
) from exc
def _check_key(authorization: str | None) -> str | None:
if api_key is None:
return None
if not authorization or not authorization.lower().startswith("bearer "):
raise HTTPException(401, "missing bearer token")
token = authorization.split(None, 1)[1].strip()
if token != api_key:
raise HTTPException(401, "invalid bearer token")
return token
@app.get("/healthz")
async def healthz() -> dict[str, Any]:
ok = await agent.health()
return {"ok": ok, "agent": type(agent).name, "version": type(agent).version}
@app.get("/.well-known/agent-card")
async def agent_card() -> dict[str, Any]:
return agent.card().model_dump(mode="json")
@app.post("/invoke/{skill_name}")
async def invoke(
skill_name: str,
body: _InvokeIn,
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
token = _check_key(authorization)
ctx: LocalRunContext[Any] = LocalRunContext(
auth=_build_auth(token), task_id=f"http-{skill_name}"
)
try:
result = await agent.invoke_json(skill_name, ctx, body.arguments)
except SkillNotFound:
raise HTTPException(404, f"unknown skill: {skill_name}")
except SkillInputError as exc:
raise HTTPException(400, str(exc))
except MissingScopes as exc:
raise HTTPException(403, str(exc))
return {
"result": result,
"events": [
{"kind": e.kind, "payload": e.payload} for e in ctx.events
],
}
return app
def serve(agent: A2AAgent, *, host: str = "0.0.0.0", port: int = 8000) -> None:
"""Run the agent's HTTP server with uvicorn (blocking)."""
import uvicorn
uvicorn.run(build_app(agent), host=host, port=port, log_level="info")