diff --git a/tests/e2e/a2a/a2a_client.py b/tests/e2e/a2a/a2a_client.py index 4017602c959..5707e7bbd2c 100644 --- a/tests/e2e/a2a/a2a_client.py +++ b/tests/e2e/a2a/a2a_client.py @@ -18,7 +18,7 @@ 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 e2e_http import NoBody, Result, Success, is_ok from e2e_metadata import STEP_FRAMES, step from proxy_client import ProxyClient @@ -36,11 +36,6 @@ class A2ASkill(BaseModel): 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.""" @@ -57,29 +52,6 @@ class AgentCardParams(BaseModel): 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.""" @@ -93,7 +65,7 @@ class A2ABridgeParams(BaseModel): class AgentRegisterBody(BaseModel): agent_name: str - agent_card_params: AgentCardParams | UpstreamAgentCard + agent_card_params: AgentCardParams litellm_params: A2ABridgeParams | None = None @@ -136,32 +108,9 @@ class A2ATextPart(BaseModel): 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] + parts: list[A2ATextPart] message_id: str = Field(serialization_alias="messageId") @@ -189,57 +138,10 @@ class A2AResponseMessage(BaseModel): 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.""" + (`kind`/`role`/`parts`); in 1.0 they are nested under `message`. `text` reads the + agent's reply from whichever shape the served version produced.""" model_config = ConfigDict(populate_by_name=True) @@ -248,8 +150,6 @@ class A2AResult(BaseModel): 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: @@ -257,8 +157,6 @@ class A2AResult(BaseModel): 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) @@ -267,14 +165,6 @@ class A2AResult(BaseModel): 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 @@ -380,11 +270,3 @@ class A2AClient: def build_a2a_client(proxy: ProxyClient) -> A2AClient: return A2AClient(proxy=proxy) - - -@step("Fetch a published A2A agent card from its /.well-known endpoint") -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) diff --git a/tests/e2e/a2a/test_a2a_agent_e2e.py b/tests/e2e/a2a/test_a2a_agent_e2e.py index c0427368a67..abd6f475d7b 100644 --- a/tests/e2e/a2a/test_a2a_agent_e2e.py +++ b/tests/e2e/a2a/test_a2a_agent_e2e.py @@ -17,18 +17,14 @@ import pytest from a2a_client import ( A2ABridgeParams, A2AClient, - A2ADataPart, A2AJsonRpcRequest, A2AMessageSendParams, A2AOutboundMessage, - A2ASearchPropertiesParams, A2ASkill, - A2ASkillInvocation, A2ATextPart, AgentCardParams, AgentRegisterBody, AgentResponse, - fetch_agent_card, ) from e2e_config import unique_marker from e2e_http import Result, UnknownApiError, unwrap @@ -50,9 +46,6 @@ BRIDGE = A2ABridgeParams( model=BRIDGE_MODEL, ) -MOVEHOME_AGENT_CARD_URL = "https://movehome.org/.well-known/agent.json" -MOVEHOME_ORIGIN = "https://movehome.org" - pytestmark = pytest.mark.e2e @@ -135,48 +128,6 @@ class TestA2AAgentLifecycle: assert result is not None assert result.text != "" - @pytest.mark.covers("other.a2a.message_send.real_world_agent_replies") - @meta( - Subject( - domain=Domain.AGENTS_API, - route=Route.A2A, - ) - ) - def test_real_world_agent_replies_to_property_query(self, client: A2AClient, resources: ResourceManager, scoped_key: str) -> None: - upstream = unwrap(fetch_agent_card(MOVEHOME_AGENT_CARD_URL)).model_copy(update={"url": MOVEHOME_ORIGIN}) - assert upstream.protocol_version == "0.3.0" - marker = unique_marker() - body = AgentRegisterBody(agent_name=f"e2e-a2a-real-{marker}", agent_card_params=upstream) - agent = unwrap(client.register_agent(body)) - resources.defer(lambda: client.delete_agent(agent.agent_id)) - assert agent.agent_card_params.protocol_version == "0.3" - location = "GBLON" - request = A2AJsonRpcRequest( - id=f"e2e-{unique_marker()}", - params=A2AMessageSendParams( - message=A2AOutboundMessage( - parts=[ - A2ADataPart( - data=A2ASkillInvocation( - skill="search_properties", - params=A2ASearchPropertiesParams(un_locode=location, service_type="long_term", limit=3), - ) - ) - ], - message_id=unique_marker(), - ) - ), - ) - response = unwrap(client.send_message(agent.agent_id, scoped_key, request)) - assert response.error is None - assert response.result is not None - results = response.result.search_results - assert results is not None, "agent returned no search_results artifact; skill did not run" - assert results.total > 0 - assert results.listings, "search_properties matched nothing; agent returned no property cards" - assert all(listing.raia_id for listing in results.listings) - assert all(listing.location.un_locode == location for listing in results.listings) - @pytest.mark.covers("other.a2a.discovery.proxy_fronted_card") @meta( Subject( diff --git a/tests/e2e/coverage_registry/other.yaml b/tests/e2e/coverage_registry/other.yaml index 25fd76c9786..ff0f2263515 100644 --- a/tests/e2e/coverage_registry/other.yaml +++ b/tests/e2e/coverage_registry/other.yaml @@ -55,7 +55,6 @@ - {id: other.a2a.register.persists, module: other, tier: P1, area: a2a, assertions: [persists], source: "agent_endpoints/endpoints.py:325-443", rationale: "POST /v1/agents registers an agent card; GET /v1/agents/{id} reads it back"} - {id: other.a2a.register.unsupported_version_rejected, module: other, tier: P1, area: a2a, assertions: [unsupported_version_rejected], source: "agent_endpoints/endpoints.py _validate_protocol_version", rationale: "A card pinning a protocolVersion outside SUPPORTED_A2A_PROTOCOL_VERSIONS is refused with 400"} - {id: other.a2a.register.semver_version_accepted, module: other, tier: P1, area: a2a, assertions: [semver_version_accepted], source: "agent_endpoints/endpoints.py _validate_protocol_version", rationale: "A card pinning a patch-level semver like 0.3.0 (what the Google A2A SDK emits) registers, stores and serves the canonical 0.3 rather than 400ing; regression guard for the v1.92 report"} -- {id: other.a2a.message_send.real_world_agent_replies, module: other, tier: P1, area: a2a, assertions: [real_world_agent_replies], source: "agent_endpoints/a2a_endpoints.py asend_message", rationale: "A real published a2a agent fetched live from a public /.well-known endpoint (pinning the full semver 0.3.0 the a2a-sdk emits) registers, serves the canonical 0.3, and a message/send skill invocation proxies to the live upstream and returns the agent's reply"} - {id: other.a2a.register.malformed_version_rejected, module: other, tier: P1, area: a2a, assertions: [malformed_version_rejected], source: "a2a/agent_card.py normalize_protocol_version", rationale: "A malformed protocolVersion like 0.3.garbage fails full-string semver validation and is refused with 400 instead of truncating to a supported family"} - {id: other.a2a.discovery.proxy_fronted_card, module: other, tier: P1, area: a2a, assertions: [proxy_fronted_card], source: "agent_endpoints/a2a_endpoints.py get_agent_card", rationale: "/.well-known/agent-card.json serves the proxy url + supportedInterfaces and the LiteLLM virtual-key bearer scheme, not the upstream"} - {id: other.a2a.message_send.bridge_invokes, module: other, tier: P1, area: a2a, assertions: [bridge_invokes], source: "a2a_protocol/litellm_completion_bridge/handler.py", rationale: "A2A message/send routes through the completion bridge to a real provider and logs an asend_message spend row"} diff --git a/tests/integration/compatibility/test_a2a_wire_versions.py b/tests/integration/compatibility/test_a2a_wire_versions.py index 621dcfe060e..af623c91823 100644 --- a/tests/integration/compatibility/test_a2a_wire_versions.py +++ b/tests/integration/compatibility/test_a2a_wire_versions.py @@ -755,6 +755,59 @@ def test_a2a_message_send_aliases_and_agent_name_reach_the_same_agent_with_the_s ), posts +def test_a2a_data_part_skill_call_and_data_artifact_reach_each_side_unchanged(gateway: Gateway) -> None: + marker: Final = "a2adata" + uuid.uuid4().hex + invocation: Final[dict[str, JsonValue]] = {"skill": "search_items", "params": {"region": "emea", "limit": 3}} + found: Final[dict[str, JsonValue]] = { + "total": 2, + "count": 2, + "listings": [{"id": marker + "-1", "region": "emea"}, {"id": marker + "-2", "region": "emea"}], + } + task: Final[dict[str, JsonValue]] = { + "kind": "task", + "id": marker + "-task", + "contextId": marker + "-context", + "status": {"state": "completed"}, + "artifacts": [ + {"artifactId": marker + "-artifact", "name": "search_results", "parts": [{"kind": "data", "data": found}]} + ], + } + + def upstream(request: Request) -> Reply: + if request.method == "GET": + return Reply(body=json.dumps(_peer_card(wire.url, marker)).encode()) + body: Final = json.loads(request.body) + return Reply(body=json.dumps({"jsonrpc": "2.0", "id": body["id"], "result": task}).encode()) + + with wire_server(upstream) as wire, gateway.scenario() as scenario: + identity: Final = _register_agent( + gateway, scenario, {"agent_name": marker, "agent_card_params": _peer_card(wire.url, marker)} + ) + response: Final = gateway.client.post( + f"/a2a/{identity}", + headers={"Authorization": f"Bearer {gateway.key}"}, + json={ + "jsonrpc": "2.0", + "id": marker, + "method": "message/send", + "params": { + "message": { + "kind": "message", + "role": "user", + "messageId": marker + "-in", + "parts": [{"kind": "data", "data": invocation}], + } + }, + }, + ) + assert response.status_code == 200, response.text + assert response.json() == {"jsonrpc": "2.0", "id": marker, "result": task}, response.text + posts: Final = tuple(json.loads(item.body) for item in wire.drain() if item.method == "POST") + assert len(posts) == 1, posts + assert posts[0]["method"] == "message/send", posts + assert posts[0]["params"]["message"]["parts"] == [{"kind": "data", "data": invocation}], posts + + def test_a2a_task_methods_pass_through_verbatim_and_push_callbacks_are_validated_before_forwarding( gateway: Gateway, ) -> None: