feat(a2a): add agent card and rpc skeleton

master
oabrivard 7 days ago
parent d1238f186c
commit 9603483648

@ -32,4 +32,7 @@ UNSUBSCRIBE_AUTO_ENABLED=true
UNSUBSCRIBE_AUTO_INTERVAL_MINUTES=720 UNSUBSCRIBE_AUTO_INTERVAL_MINUTES=720
UNSUBSCRIBE_HTTP_TIMEOUT_SECONDS=12 UNSUBSCRIBE_HTTP_TIMEOUT_SECONDS=12
UNSUBSCRIBE_USER_AGENT=Mozilla/5.0 (compatible; PersonalAgentUnsubscribe/1.0) UNSUBSCRIBE_USER_AGENT=Mozilla/5.0 (compatible; PersonalAgentUnsubscribe/1.0)
A2A_PUBLIC_BASE_URL=
A2A_AGENT_NAME=Personal Agent
A2A_AGENT_DESCRIPTION=Personal productivity agent for calendar availability and email operations.
LOG_LEVEL=INFO LOG_LEVEL=INFO

@ -0,0 +1,3 @@
from app.a2a.router import router as a2a_router
__all__ = ["a2a_router"]

@ -0,0 +1,52 @@
from __future__ import annotations
from typing import Any
from fastapi import Request
from app.config import Settings
def build_agent_card(settings: Settings, request: Request) -> dict[str, Any]:
base_url = _resolve_base_url(settings=settings, request=request)
return {
"name": settings.a2a_agent_name,
"description": settings.a2a_agent_description,
"url": f"{base_url}/a2a/rpc",
"version": "0.1.0",
"protocolVersion": "1.0",
"defaultInputModes": ["application/json"],
"defaultOutputModes": ["application/json"],
"capabilities": {
"streaming": False,
"pushNotifications": False,
"stateTransitionHistory": False,
},
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"description": "Use the same Bearer/API key auth as the REST API.",
}
},
"security": [{"bearerAuth": []}],
"skills": [
{
"id": "availability.query",
"name": "Check Availability",
"description": "Checks Google Calendar availability for a given time range.",
"tags": ["calendar", "availability", "scheduling"],
"examples": [
"Is calendar primary free from 2026-03-10T09:00:00+01:00 to 2026-03-10T10:00:00+01:00?"
],
"inputModes": ["application/json"],
"outputModes": ["application/json"],
}
],
}
def _resolve_base_url(*, settings: Settings, request: Request) -> str:
if settings.a2a_public_base_url:
return settings.a2a_public_base_url.rstrip("/")
return str(request.base_url).rstrip("/")

@ -0,0 +1,25 @@
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
class A2ARpcRequest(BaseModel):
jsonrpc: str = "2.0"
id: str | int | None = None
method: str
params: dict[str, Any] = Field(default_factory=dict)
class A2ARpcError(BaseModel):
code: int
message: str
data: dict[str, Any] | None = None
class A2ARpcResponse(BaseModel):
jsonrpc: str = "2.0"
id: str | int | None = None
result: dict[str, Any] | None = None
error: A2ARpcError | None = None

@ -0,0 +1,49 @@
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Request, Response
from app.a2a.agent_card import build_agent_card
from app.a2a.models import A2ARpcError, A2ARpcRequest, A2ARpcResponse
from app.config import get_settings
settings = get_settings()
router = APIRouter(tags=["a2a"])
@router.get("/.well-known/agent-card.json")
def get_agent_card(request: Request, response: Response) -> dict[str, Any]:
response.headers["A2A-Version"] = "1.0"
return build_agent_card(settings=settings, request=request)
@router.post("/a2a/rpc", response_model=A2ARpcResponse)
def a2a_rpc(payload: A2ARpcRequest, response: Response) -> A2ARpcResponse:
response.headers["A2A-Version"] = "1.0"
if payload.jsonrpc != "2.0":
return _error_response(
request_id=payload.id,
code=-32600,
message="Invalid Request: jsonrpc must be '2.0'.",
)
if payload.method in {"ping", "health.ping", "health/ping"}:
return A2ARpcResponse(
id=payload.id,
result={"status": "ok", "agent": settings.a2a_agent_name},
)
return _error_response(
request_id=payload.id,
code=-32601,
message=f"Method '{payload.method}' is not implemented yet.",
)
def _error_response(request_id: str | int | None, code: int, message: str) -> A2ARpcResponse:
return A2ARpcResponse(
id=request_id,
error=A2ARpcError(code=code, message=message),
)

@ -42,6 +42,9 @@ class Settings:
unsubscribe_auto_interval_minutes: int unsubscribe_auto_interval_minutes: int
unsubscribe_http_timeout_seconds: float unsubscribe_http_timeout_seconds: float
unsubscribe_user_agent: str unsubscribe_user_agent: str
a2a_public_base_url: str | None
a2a_agent_name: str
a2a_agent_description: str
log_level: str log_level: str
@ -96,6 +99,12 @@ def get_settings() -> Settings:
"UNSUBSCRIBE_USER_AGENT", "UNSUBSCRIBE_USER_AGENT",
"Mozilla/5.0 (compatible; PersonalAgentUnsubscribe/1.0; +https://example.local)", "Mozilla/5.0 (compatible; PersonalAgentUnsubscribe/1.0; +https://example.local)",
), ),
a2a_public_base_url=os.getenv("A2A_PUBLIC_BASE_URL", "").strip() or None,
a2a_agent_name=os.getenv("A2A_AGENT_NAME", "Personal Agent"),
a2a_agent_description=os.getenv(
"A2A_AGENT_DESCRIPTION",
"Personal productivity agent for calendar availability and email operations.",
),
log_level=os.getenv("LOG_LEVEL", "INFO"), log_level=os.getenv("LOG_LEVEL", "INFO"),
) )

@ -10,6 +10,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
from fastapi import Depends, FastAPI, Header, HTTPException, Query, status from fastapi import Depends, FastAPI, Header, HTTPException, Query, status
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.a2a import a2a_router
from app.config import get_settings from app.config import get_settings
from app.core.service import CoreAgentService from app.core.service import CoreAgentService
from app.security import AuthBackend from app.security import AuthBackend
@ -345,6 +346,7 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="Personal Agent", version="0.3.0", lifespan=lifespan) # type: ignore app = FastAPI(title="Personal Agent", version="0.3.0", lifespan=lifespan) # type: ignore
app.include_router(a2a_router)
@app.get("/health") @app.get("/health")

Loading…
Cancel
Save