"""Client for the proxy's A2A (agent-to-agent) surface. An A2A agent is registered admin-side via POST /v1/agents with an agent card and litellm_params; the proxy fronts it at /a2a/{id}, serving a proxy-owned agent card at /.well-known/agent-card.json and accepting A2A JSON-RPC calls at /a2a/{id}. This suite registers agents backed by the litellm_completion_bridge (custom_llm_provider + model), so message/send runs a real provider completion and comes back in the agent's pinned A2A protocol version. The A2A request/response models are co-located here because only this suite uses them. """ from __future__ import annotations import time import warnings from dataclasses import dataclass from pydantic import BaseModel, ConfigDict, Field from e2e_config import settle_propagation from e2e_http import NoBody, Result, Success, get_external, is_ok from proxy_client import ProxyClient class A2ACapabilities(BaseModel): streaming: bool | None = None push_notifications: bool | None = Field(default=None, serialization_alias="pushNotifications") class A2ASkill(BaseModel): id: str name: str description: str tags: list[str] examples: list[str] | None = None class A2AProvider(BaseModel): organization: str url: str class AgentCardParams(BaseModel): """The upstream agent card an admin registers. `protocolVersion` is the field the proxy validates against SUPPORTED_A2A_PROTOCOL_VERSIONS on registration.""" protocol_version: str = Field(serialization_alias="protocolVersion") name: str description: str version: str url: str | None = None capabilities: A2ACapabilities = A2ACapabilities() skills: list[A2ASkill] default_input_modes: list[str] = Field(default=["text"], serialization_alias="defaultInputModes") default_output_modes: list[str] = Field(default=["text"], serialization_alias="defaultOutputModes") preferred_transport: str | None = Field(default=None, serialization_alias="preferredTransport") class UpstreamAgentCard(BaseModel): """A real published agent card parsed from a public /.well-known endpoint. Keys on the A2A wire aliases so `model_validate_json` reads the served JSON and `model_dump(by_alias=True)` re-emits it unchanged for verbatim registration; it is only ever fetched-and-validated, never hand-constructed, so aliasing on the wire names does not affect any call site.""" model_config = ConfigDict(populate_by_name=True) protocol_version: str = Field(alias="protocolVersion") name: str description: str version: str url: str provider: A2AProvider | None = None documentation_url: str | None = Field(default=None, alias="documentationUrl") capabilities: A2ACapabilities = A2ACapabilities() skills: list[A2ASkill] default_input_modes: list[str] = Field(default=["text"], alias="defaultInputModes") default_output_modes: list[str] = Field(default=["text"], alias="defaultOutputModes") preferred_transport: str | None = Field(default=None, alias="preferredTransport") class A2ABridgeParams(BaseModel): """litellm_params that route the agent through the completion bridge: an A2A message/send is transformed into a litellm.acompletion against this provider.""" model_config = ConfigDict(protected_namespaces=()) custom_llm_provider: str model: str api_key: str | None = None class AgentRegisterBody(BaseModel): agent_name: str agent_card_params: AgentCardParams | UpstreamAgentCard litellm_params: A2ABridgeParams | None = None class A2ASecurityScheme(BaseModel): type: str scheme: str class A2AInterface(BaseModel): model_config = ConfigDict(populate_by_name=True) url: str protocol_version: str | None = Field(default=None, alias="protocolVersion") class ServedAgentCard(BaseModel): """The proxy-owned card, either nested under a registration response's `agent_card_params` or served raw at /.well-known/agent-card.json. The proxy rewrites `url`/`supportedInterfaces` to itself and replaces the security scheme with its own virtual-key bearer scheme.""" model_config = ConfigDict(populate_by_name=True) protocol_version: str = Field(alias="protocolVersion") name: str url: str | None = None security_schemes: dict[str, A2ASecurityScheme] | None = Field(default=None, alias="securitySchemes") security: list[dict[str, list[str]]] | None = None supported_interfaces: list[A2AInterface] | None = Field(default=None, alias="supportedInterfaces") class AgentResponse(BaseModel): agent_id: str agent_name: str agent_card_params: ServedAgentCard class A2ATextPart(BaseModel): kind: str = "text" text: str class A2ASearchPropertiesParams(BaseModel): """The strict param schema of the published property agent's `search_properties` skill (unknown keys are rejected upstream), so a natural-language query like "properties for sale in SF under $2M" is expressed as typed fields.""" un_locode: str | None = None service_type: str | None = None property_type: str | None = None bedrooms_min: int | None = None asking_price_max: float | None = None limit: int | None = None class A2ASkillInvocation(BaseModel): skill: str params: A2ASearchPropertiesParams class A2ADataPart(BaseModel): kind: str = "data" data: A2ASkillInvocation class A2AOutboundMessage(BaseModel): role: str = "user" parts: list[A2ATextPart | A2ADataPart] message_id: str = Field(serialization_alias="messageId") class A2AMessageSendParams(BaseModel): message: A2AOutboundMessage class A2AJsonRpcRequest(BaseModel): jsonrpc: str = "2.0" id: str method: str = "message/send" params: A2AMessageSendParams class A2AResponsePart(BaseModel): kind: str | None = None text: str | None = None class A2AResponseMessage(BaseModel): model_config = ConfigDict(populate_by_name=True) message_id: str | None = Field(default=None, alias="messageId") role: str | None = None parts: list[A2AResponsePart] = [] class A2ATaskStatus(BaseModel): state: str | None = None message: A2AResponseMessage | None = None class A2AListingLocation(BaseModel): """Only the location fields a test reads back off a returned listing.""" un_locode: str | None = None class A2AListing(BaseModel): """A single property card from the agent's `search_results` artifact; only the identity/location fields a test asserts on are modelled.""" raia_id: str property_type: str | None = None service_type: str | None = None location: A2AListingLocation = A2AListingLocation() class A2ASearchResults(BaseModel): """The DataPart payload the property agent's `search_properties` skill returns: the run count plus the listing cards themselves. Proof the tool actually ran and matched, not just that the task completed with some text.""" total: int count: int listings: list[A2AListing] = [] class A2AArtifactPart(BaseModel): kind: str | None = None data: A2ASearchResults | None = None class A2AArtifact(BaseModel): model_config = ConfigDict(populate_by_name=True) artifact_id: str | None = Field(default=None, alias="artifactId") name: str | None = None parts: list[A2AArtifactPart] = [] class A2AResult(BaseModel): """A message/send result. In 0.3 the message fields sit directly on the result (`kind`/`role`/`parts`); in 1.0 they are nested under `message`; a real agent that runs a task replies with a `task` whose agent text lives on `status.message` and whose tool output lives on `artifacts`. `text` reads the agent's reply from whichever shape the served version produced; `search_results` reads the tool's structured output when the agent ran a skill.""" model_config = ConfigDict(populate_by_name=True) kind: str | None = None role: str | None = None message_id: str | None = Field(default=None, alias="messageId") parts: list[A2AResponsePart] = [] message: A2AResponseMessage | None = None status: A2ATaskStatus | None = None artifacts: list[A2AArtifact] = [] @property def text(self) -> str: if self.message is not None: parts = self.message.parts elif self.parts: parts = self.parts elif self.status is not None and self.status.message is not None: parts = self.status.message.parts else: parts = [] return "".join(part.text or "" for part in parts) @property def is_nested_v1_shape(self) -> bool: return self.message is not None @property def search_results(self) -> A2ASearchResults | None: for artifact in self.artifacts: for part in artifact.parts: if part.data is not None: return part.data return None class A2AError(BaseModel): code: int message: str class A2AResponse(BaseModel): jsonrpc: str id: str | None = None result: A2AResult | None = None error: A2AError | None = None @dataclass(frozen=True, slots=True) class A2AClient: proxy: ProxyClient def register_agent(self, body: AgentRegisterBody) -> Result[AgentResponse]: """Register an agent and, on success, wait until the data plane serves it. /v1/agents is a control-plane route; the /a2a/{agent_id} routes that serve the card and run message/send are data plane, and only see the agent after the next DB reload. A card read or message/send issued the instant this returns can therefore 404 on the agent it just created. Waiting here keeps every caller from having to poll, the same way ProxyClient.create_model waits for a new model to become servable -- including the settle that covers the other replicas, since one successful card read only proves the replica that answered it has the agent. """ result = self.proxy.transport.post( "/v1/agents", headers=self.proxy.transport.master, json=body, response_type=AgentResponse, ) if isinstance(result, Success): written_at = time.monotonic() self._await_agent_servable(result.data.agent_id) settle_propagation(written_at) return result def _await_agent_servable(self, agent_id: str) -> None: """Block until the data plane serves `agent_id`'s card, or fail loudly at poll_timeout (a real propagation problem, surfaced here rather than as a downstream 404 on whichever /a2a call the test happened to make first).""" deadline = time.monotonic() + self.proxy.poll_timeout while True: result = self.proxy.transport.get( f"/a2a/{agent_id}/.well-known/agent-card.json", headers=self.proxy.transport.master, params=NoBody(), response_type=ServedAgentCard, ) if isinstance(result, Success): return if time.monotonic() >= deadline: raise AssertionError( f"agent {agent_id!r} was registered but never became servable on the " f"data plane within {self.proxy.poll_timeout}s of POST /v1/agents " f"(control/data-plane propagation issue); last card read: {result}" ) time.sleep(self.proxy.poll_interval) def get_agent(self, agent_id: str) -> Result[AgentResponse]: return self.proxy.transport.get( f"/v1/agents/{agent_id}", headers=self.proxy.transport.master, params=NoBody(), response_type=AgentResponse, ) def delete_agent(self, agent_id: str) -> None: result = self.proxy.transport.delete( f"/v1/agents/{agent_id}", headers=self.proxy.transport.master, json=NoBody(), response_type=NoBody, ) if not is_ok(result): warnings.warn(f"delete_agent({agent_id!r}) failed: {result}", stacklevel=2) def agent_card(self, agent_id: str, key: str) -> Result[ServedAgentCard]: return self.proxy.transport.get( f"/a2a/{agent_id}/.well-known/agent-card.json", headers=self.proxy.transport.bearer(key), params=NoBody(), response_type=ServedAgentCard, ) def send_message(self, agent_id: str, key: str, body: A2AJsonRpcRequest) -> Result[A2AResponse]: return self.proxy.transport.post( f"/a2a/{agent_id}", headers=self.proxy.transport.bearer(key), json=body, response_type=A2AResponse, ) def build_a2a_client(proxy: ProxyClient) -> A2AClient: return A2AClient(proxy=proxy) def fetch_agent_card(url: str, *, timeout: float = 20.0) -> Result[UpstreamAgentCard]: """Fetch a live A2A agent card from its /.well-known endpoint and parse it into the registration model, so a test can register a real published card verbatim rather than a hand-rolled one.""" return get_external(url, response_type=UpstreamAgentCard, timeout=timeout)