autosource / core /a2a_layer.py
Shanmuk4622's picture
deploy replay dashboard
97ce220 verified
Raw
History Blame Contribute Delete
6.62 kB
"""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",
)