From 58977c2ca094d4ed452b295777e0dc0b4767afb7 Mon Sep 17 00:00:00 2001 From: mlzerotoheroyoutube <351383126+mlzerotoheroyoutube@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:07:07 -0800 Subject: [PATCH] fix(a2a): look up the agent card at the origin for sub-path endpoints A2A 1.0 serves the agent card at the origin's /.well-known/agent-card.json, but the resolver only tried well-known paths under the agent URL, so message/send failed for agents whose JSON-RPC endpoint is a sub-path such as /jsonrpc. After the existing path-relative probes, also try the two well-known paths at the origin. --- litellm/a2a_protocol/card_resolver.py | 46 +++++++--- tests/unit/a2a_protocol/test_card_resolver.py | 83 +++++++++++++++++++ 2 files changed, 119 insertions(+), 10 deletions(-) diff --git a/litellm/a2a_protocol/card_resolver.py b/litellm/a2a_protocol/card_resolver.py index 08e95f99c87..aab61bc447f 100644 --- a/litellm/a2a_protocol/card_resolver.py +++ b/litellm/a2a_protocol/card_resolver.py @@ -7,6 +7,7 @@ Extends the A2A SDK's card resolver to support multiple well-known paths. from collections.abc import Mapping from types import MappingProxyType from typing import TYPE_CHECKING, Any, Final, Protocol, runtime_checkable +from urllib.parse import urlsplit from litellm._logging import verbose_logger from litellm.a2a_protocol.exceptions import A2AAgentCardDiscoveryError @@ -47,6 +48,14 @@ def _discovery_status_code(failures: tuple[tuple[str, Exception], ...]) -> int: return statuses[0] if statuses else 404 +def _origin_of_sub_path_url(url: str) -> str | None: + """Return the origin of a URL that has a path, or None when the URL is already the origin.""" + parts: Final = urlsplit(url) + if not parts.scheme or not parts.netloc or not parts.path.strip("/"): + return None + return f"{parts.scheme}://{parts.netloc}" + + def is_localhost_or_internal_url(url: str | None) -> bool: """ Check if a URL is a localhost or internal URL. @@ -192,6 +201,8 @@ class LiteLLMA2ACardResolver(_A2ACardResolver): - /.well-known/agent-card.json (standard) - /.well-known/agent.json (previous/alternative) - /agentCard/v1.0 + When the base URL has a path (an endpoint such as /jsonrpc), the two well-known paths are + then tried at the origin, where A2A 1.0 ยง8.2 places the card. """ async def get_agent_card( @@ -206,30 +217,45 @@ class LiteLLMA2ACardResolver(_A2ACardResolver): http_kwargs=http_kwargs, ) + origin: Final = _origin_of_sub_path_url(self.base_url) + origin_attempts: Final = ( + () if origin is None else ((origin, AGENT_CARD_WELL_KNOWN_PATH), (origin, PREV_AGENT_CARD_WELL_KNOWN_PATH)) + ) return await self._get_agent_card_from_first_reachable_path( - paths=(AGENT_CARD_WELL_KNOWN_PATH, PREV_AGENT_CARD_WELL_KNOWN_PATH, FOUNDRY_AGENT_CARD_PATH), + attempts=( + (None, AGENT_CARD_WELL_KNOWN_PATH), + (None, PREV_AGENT_CARD_WELL_KNOWN_PATH), + (None, FOUNDRY_AGENT_CARD_PATH), + *origin_attempts, + ), http_kwargs=http_kwargs, failures=(), ) async def _get_agent_card_from_first_reachable_path( self, - paths: tuple[str, ...], + attempts: tuple[tuple[str | None, str], ...], http_kwargs: Mapping[str, object] | None, failures: tuple[tuple[str, Exception], ...], ) -> "AgentCard": - if not paths: + """Try each (base, path) in order; a None base means the resolver's own base URL.""" + if not attempts: raise A2AAgentCardDiscoveryError( base_url=self.base_url, failures=failures, status_code=_discovery_status_code(failures), ) - path: Final = paths[0] + base, path = attempts[0] + label: Final = path if base is None else f"{base}{path}" try: - verbose_logger.debug("Attempting to fetch agent card from %s%s", self.base_url, path) - return await super().get_agent_card(relative_card_path=path, http_kwargs=http_kwargs) - except Exception as e: - verbose_logger.debug("Failed to fetch agent card from %s%s: %s", self.base_url, path, e) - return await self._get_agent_card_from_first_reachable_path( - paths=paths[1:], http_kwargs=http_kwargs, failures=(*failures, (path, e)) + verbose_logger.debug("Attempting to fetch agent card from %s%s", base or self.base_url, path) + if base is None: + return await super().get_agent_card(relative_card_path=path, http_kwargs=http_kwargs) + return await _A2ACardResolver(httpx_client=self.httpx_client, base_url=base).get_agent_card( + relative_card_path=path, http_kwargs=http_kwargs + ) + except Exception as e: + verbose_logger.debug("Failed to fetch agent card from %s%s: %s", base or self.base_url, path, e) + return await self._get_agent_card_from_first_reachable_path( + attempts=attempts[1:], http_kwargs=http_kwargs, failures=(*failures, (label, e)) ) diff --git a/tests/unit/a2a_protocol/test_card_resolver.py b/tests/unit/a2a_protocol/test_card_resolver.py index 4cfa8dab45b..eb87a9c5450 100644 --- a/tests/unit/a2a_protocol/test_card_resolver.py +++ b/tests/unit/a2a_protocol/test_card_resolver.py @@ -315,3 +315,86 @@ async def test_card_resolver_discovery_error_is_404_when_every_probe_is_404(): await resolver.get_agent_card() assert raised.value.status_code == 404 + + +_RS_ORIGIN: Final = "http://rsagent:3000" +_RS_CARD_JSON: Final = {**_FOUNDRY_CARD_JSON, "name": "rs agent", "url": f"{_RS_ORIGIN}/jsonrpc"} +_NOT_FOUND: Final = (404, {"error": "not found"}) + + +@pytest.mark.asyncio +async def test_card_resolver_falls_back_to_the_origin_well_known_path_for_a_sub_path_endpoint(): + """A2A 1.0 serves the card at the origin even when the JSON-RPC endpoint is a sub-path.""" + httpx_client = _FakeHttpxClient( + base_url=_RS_ORIGIN, + responses={ + "/jsonrpc/.well-known/agent-card.json": _NOT_FOUND, + "/jsonrpc/.well-known/agent.json": _NOT_FOUND, + "/jsonrpc/agentCard/v1.0": _NOT_FOUND, + "/.well-known/agent-card.json": (200, dict(_RS_CARD_JSON)), + }, + ) + + resolver = LiteLLMA2ACardResolver(httpx_client=httpx_client, base_url=f"{_RS_ORIGIN}/jsonrpc") + result = await resolver.get_agent_card() + + assert httpx_client.calls == [ + "/jsonrpc/.well-known/agent-card.json", + "/jsonrpc/.well-known/agent.json", + "/jsonrpc/agentCard/v1.0", + "/.well-known/agent-card.json", + ] + assert result.name == "rs agent" + + +@pytest.mark.asyncio +async def test_card_resolver_prefers_the_path_relative_card_over_the_origin(): + httpx_client = _FakeHttpxClient( + base_url=_RS_ORIGIN, + responses={"/jsonrpc/.well-known/agent-card.json": (200, dict(_RS_CARD_JSON))}, + ) + + resolver = LiteLLMA2ACardResolver(httpx_client=httpx_client, base_url=f"{_RS_ORIGIN}/jsonrpc") + await resolver.get_agent_card() + + assert httpx_client.calls == ["/jsonrpc/.well-known/agent-card.json"] + + +@pytest.mark.asyncio +async def test_card_resolver_does_not_probe_the_origin_twice_when_the_base_url_is_the_origin(): + httpx_client = _FakeHttpxClient( + base_url=_RS_ORIGIN, + responses={ + "/.well-known/agent-card.json": _NOT_FOUND, + "/.well-known/agent.json": _NOT_FOUND, + "/agentCard/v1.0": _NOT_FOUND, + }, + ) + + resolver = LiteLLMA2ACardResolver(httpx_client=httpx_client, base_url=f"{_RS_ORIGIN}/") + with pytest.raises(A2AAgentCardDiscoveryError): + await resolver.get_agent_card() + + assert httpx_client.calls == ["/.well-known/agent-card.json", "/.well-known/agent.json", "/agentCard/v1.0"] + + +@pytest.mark.asyncio +async def test_card_resolver_names_the_origin_probes_when_discovery_fails(): + httpx_client = _FakeHttpxClient( + base_url=_RS_ORIGIN, + responses={ + "/jsonrpc/.well-known/agent-card.json": _NOT_FOUND, + "/jsonrpc/.well-known/agent.json": _NOT_FOUND, + "/jsonrpc/agentCard/v1.0": _NOT_FOUND, + "/.well-known/agent-card.json": _NOT_FOUND, + "/.well-known/agent.json": _NOT_FOUND, + }, + ) + + resolver = LiteLLMA2ACardResolver(httpx_client=httpx_client, base_url=f"{_RS_ORIGIN}/jsonrpc") + with pytest.raises(A2AAgentCardDiscoveryError) as raised: + await resolver.get_agent_card() + + assert raised.value.status_code == 404 + assert f"{_RS_ORIGIN}/.well-known/agent-card.json (" in str(raised.value) + assert f"{_RS_ORIGIN}/.well-known/agent.json (" in str(raised.value)