"""Agent-to-agent invocation surface available via ``ctx.call(...)``. An agent never speaks raw HTTP to another agent. It calls ``ctx.call(target, skill, args, grant=...)`` and the runtime-attached :class:`A2AClient` handles transport: HTTP for cross-pod, in-memory for local tests, anything else (gRPC, message bus) for future runtimes. The grant token (see :mod:`a2a_pack.grants`) is the *only* way to hand workspace access across agents. Callee-side runtime validates it before materializing a :class:`WorkspaceClient`. """ from __future__ import annotations from abc import ABC, abstractmethod from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from .agent import A2AAgent from .context import RunContext @dataclass(frozen=True) class CallResult: """What an A2A invocation returns to the calling skill.""" result: Any events: tuple[dict[str, Any], ...] = () artifacts: tuple[dict[str, Any], ...] = () grant_id: str | None = None # echoed for audit class A2AClient(ABC): """Transport-shaped agent-to-agent client.""" @abstractmethod async def call( self, target: str, skill: str, *, args: dict[str, Any] | None = None, grant: str | None = None, timeout: float | None = None, ) -> CallResult: """Invoke ``skill`` on ``target`` and return its :class:`CallResult`. ``target`` is opaque to this layer — for the HTTP impl it's an agent URL; for the in-memory impl it's an agent name. """ # --------------------------------------------------------------------------- # In-memory: routes calls to A2AAgent instances in the same process. Useful # for the demo + tests. # --------------------------------------------------------------------------- @dataclass class InMemoryA2AClient(A2AClient): """Routes calls to agent instances registered by name. The receiving agent gets a *new* :class:`RunContext` built by the ``ctx_factory`` callable, so caller and callee don't share state. Pass ``ctx_factory=lambda agent, grant: ...`` to control how scoped workspaces / sandboxes are wired in. """ agents: dict[str, "A2AAgent"] ctx_factory: Any = None # Callable[[A2AAgent, str | None], RunContext] async def call( self, target: str, skill: str, *, args: dict[str, Any] | None = None, grant: str | None = None, timeout: float | None = None, ) -> CallResult: if target not in self.agents: raise KeyError(f"no agent registered: {target!r}") agent = self.agents[target] ctx = self.ctx_factory(agent, grant) if self.ctx_factory else None if ctx is None: from .context import LocalRunContext from .auth import NoAuth ctx = LocalRunContext(auth=NoAuth(), task_id=f"a2a-{target}") result = await agent.invoke_json(skill, ctx, args or {}) events = tuple( {"kind": e.kind, "payload": e.payload} for e in getattr(ctx, "events", ()) ) # surface artifacts captured by LocalRunContext, if present artifacts: tuple[dict[str, Any], ...] = () local_arts = getattr(ctx, "artifacts", None) if isinstance(local_arts, dict): artifacts = tuple( {"name": name, "size_bytes": len(data)} for name, data in local_arts.items() ) return CallResult( result=result, events=events, artifacts=artifacts, grant_id=_grant_id_or_none(grant), ) # --------------------------------------------------------------------------- # HTTP: posts to /invoke/ with {arguments, grant} body. # --------------------------------------------------------------------------- @dataclass class HttpA2AClient(A2AClient): """A2A client that POSTs to the standard /invoke/{skill} endpoint.""" default_timeout: float = 60.0 async def call( self, target: str, skill: str, *, args: dict[str, Any] | None = None, grant: str | None = None, timeout: float | None = None, ) -> CallResult: import httpx # late import: server-side needs no client body: dict[str, Any] = {"arguments": args or {}} if grant is not None: body["grant"] = grant url = f"{target.rstrip('/')}/invoke/{skill}" async with httpx.AsyncClient(timeout=timeout or self.default_timeout) as c: resp = await c.post(url, json=body) if resp.status_code >= 400: raise RuntimeError(f"a2a {url} -> {resp.status_code}: {resp.text}") data = resp.json() return CallResult( result=data.get("result"), events=tuple(data.get("events") or ()), artifacts=tuple(data.get("artifacts") or ()), grant_id=_grant_id_or_none(grant), ) def _grant_id_or_none(grant: str | None) -> str | None: """Extract grant_id without re-validating the signature (audit only).""" if not grant or "." not in grant: return None try: from .grants import _b64decode payload = _b64decode(grant.rsplit(".", 1)[0]) import json return json.loads(payload).get("grant_id") except Exception: # noqa: BLE001 return None __all__ = [ "A2AClient", "CallResult", "HttpA2AClient", "InMemoryA2AClient", ]