"""A2A protocol layer (plan §7.2), built on the official a2a-sdk (0.3.x, pydantic). - Each agent publishes an AgentCard at /.well-known/agent-card.json. - Messaging is JSON-RPC 2.0 `message/send`; payloads travel as DataPart. - `serve_agent` runs an agent as a Starlette app on its own port (background thread). - `send_payload` is the client: resolves the peer's card, sends, returns the reply dict. """ from __future__ import annotations import asyncio import threading import uuid from collections.abc import Callable import httpx import uvicorn from a2a.client import A2ACardResolver, ClientConfig, ClientFactory from a2a.server.agent_execution import AgentExecutor, RequestContext from a2a.server.apps import A2AStarletteApplication from a2a.server.events import EventQueue from a2a.server.request_handlers import DefaultRequestHandler from a2a.server.tasks import InMemoryTaskStore from a2a.types import ( AgentCapabilities, AgentCard, AgentSkill, DataPart, Message, Part, Role, ) def make_card(name: str, description: str, port: int, skills: list[dict], streaming: bool = False) -> AgentCard: return AgentCard( protocol_version="0.3.0", name=name, description=description, version="1.0.0", url=f"http://127.0.0.1:{port}", capabilities=AgentCapabilities(streaming=streaming), default_input_modes=["application/json"], default_output_modes=["application/json"], skills=[AgentSkill(id=s["id"], name=s.get("name", s["id"]), description=s["description"], tags=s.get("tags", [])) for s in skills], ) def extract_payload(message: Message) -> dict | None: """First DataPart payload in a message, else None.""" for part in message.parts or []: inner = part.root if isinstance(part, Part) else part if isinstance(inner, DataPart): return inner.data return None def payload_message(payload: dict, role: Role = Role.agent) -> Message: return Message( message_id=uuid.uuid4().hex, role=role, parts=[Part(root=DataPart(data=payload))], ) class PayloadExecutor(AgentExecutor): """Adapts a plain `handler(payload: dict) -> dict` into an A2A AgentExecutor.""" def __init__(self, handler: Callable[[dict], dict]): self.handler = handler async def execute(self, context: RequestContext, event_queue: EventQueue) -> None: payload = extract_payload(context.message) if context.message else None try: if asyncio.iscoroutinefunction(self.handler): reply = await self.handler(payload or {}) else: reply = await asyncio.to_thread(self.handler, payload or {}) except Exception as e: # noqa: BLE001 — surface agent errors as structured replies reply = {"status": "error", "detail": str(e)} await event_queue.enqueue_event(payload_message(reply)) async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None: await event_queue.enqueue_event(payload_message({"status": "cancelled"})) class AgentServer: """Runs one agent as an A2A Starlette app on a background uvicorn thread.""" def __init__(self, card: AgentCard, handler: Callable[[dict], dict], port: int): self.card = card self.port = port handler_ = DefaultRequestHandler( agent_executor=PayloadExecutor(handler), task_store=InMemoryTaskStore(), ) self.app = A2AStarletteApplication(agent_card=card, http_handler=handler_).build() self._server: uvicorn.Server | None = None self._thread: threading.Thread | None = None def start(self, wait_ready_s: float = 10.0) -> None: config = uvicorn.Config(self.app, host="127.0.0.1", port=self.port, log_level="warning") self._server = uvicorn.Server(config) self._thread = threading.Thread(target=self._server.run, daemon=True) self._thread.start() # block until the card endpoint answers deadline = wait_ready_s import time while deadline > 0: try: r = httpx.get(f"http://127.0.0.1:{self.port}/.well-known/agent-card.json", timeout=1.0) if r.status_code == 200: return except httpx.HTTPError: pass time.sleep(0.2) deadline -= 0.2 raise RuntimeError(f"A2A agent on port {self.port} did not become ready") def stop(self) -> None: if self._server: self._server.should_exit = True if self._thread: self._thread.join(timeout=5) async def send_payload(base_url: str, payload: dict, timeout_s: float = 120.0) -> dict: """A2A client: resolve the peer's AgentCard, send one message/send, return reply.""" async with httpx.AsyncClient(timeout=timeout_s) as http: card = await A2ACardResolver(http, base_url).get_agent_card() client = ClientFactory(ClientConfig(httpx_client=http, streaming=False)).create(card) message = payload_message(payload, role=Role.user) reply_payload: dict | None = None async for event in client.send_message(message): if isinstance(event, Message): reply_payload = extract_payload(event) elif isinstance(event, tuple): # (Task, UpdateEvent) task = event[0] if task and task.artifacts: for artifact in task.artifacts: for part in artifact.parts: inner = part.root if isinstance(part, Part) else part if isinstance(inner, DataPart): reply_payload = inner.data if task and task.status and task.status.message: got = extract_payload(task.status.message) if got is not None: reply_payload = got if reply_payload is None: raise RuntimeError(f"no DataPart reply from {base_url}") return reply_payload def export_card(card: AgentCard, path) -> None: """Write the AgentCard JSON (spec camelCase) to protocols/a2a_cards/.""" import json from pathlib import Path Path(path).parent.mkdir(parents=True, exist_ok=True) Path(path).write_text( json.dumps(card.model_dump(by_alias=True, exclude_none=True), indent=2), encoding="utf-8", )