From fe567bd8467f7e9702f26abd61bc866acf659d10 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:51:14 -0700 Subject: [PATCH] fix(a2a): speak the 0.3 dialect to servers with mis-cased protocol bindings --- litellm/a2a_protocol/card_resolver.py | 18 +++++-- litellm/a2a_protocol/main.py | 4 +- .../a2a_protocol/test_card_resolver.py | 24 +++++++++ tests/test_litellm/a2a_protocol/test_main.py | 53 +++++++++++++++---- 4 files changed, 84 insertions(+), 15 deletions(-) diff --git a/litellm/a2a_protocol/card_resolver.py b/litellm/a2a_protocol/card_resolver.py index 9b3e4ec13b9..25f2e1a9a0d 100644 --- a/litellm/a2a_protocol/card_resolver.py +++ b/litellm/a2a_protocol/card_resolver.py @@ -57,22 +57,32 @@ _CANONICAL_PROTOCOL_BINDINGS: Final = MappingProxyType( } ) +_LEGACY_PROTOCOL_VERSION: Final = "0.3" -def normalize_agent_card_protocol_bindings(agent_card: "AgentCard") -> "AgentCard": + +def normalize_agent_card_interfaces(agent_card: "AgentCard") -> "AgentCard": """ - Canonicalize protocolBinding casing on the card's supported interfaces. + Canonicalize the supported interfaces of spec-adjacent agent cards. Some A2A servers (e.g. LangGraph Platform) serve agent cards with lowercase bindings like "jsonrpc", but a2a-sdk's ClientFactory matches bindings case-sensitively against its uppercase TransportProtocol constants and fails with "no compatible transports found." for spec-adjacent casings. + + The same servers also speak the A2A 0.3 JSON dialect ("kind"-discriminated + payloads) while declaring protocolVersion "1.0", which a2a-sdk's strict v1 + proto parsing rejects. A mis-cased binding fingerprints such a server, so its + declared version is downgraded to 0.3 to route the SDK's ClientFactory onto + its v0.3 compat transport, which speaks that dialect. """ normalized: Final = type(agent_card)() normalized.CopyFrom(agent_card) for interface in normalized.supported_interfaces: canonical: str | None = _CANONICAL_PROTOCOL_BINDINGS.get(interface.protocol_binding.lower()) - if canonical is not None: - interface.protocol_binding = canonical + if canonical is None or canonical == interface.protocol_binding: + continue + interface.protocol_binding = canonical + interface.protocol_version = _LEGACY_PROTOCOL_VERSION return normalized diff --git a/litellm/a2a_protocol/main.py b/litellm/a2a_protocol/main.py index 2d4267cc0db..56b8089b0af 100644 --- a/litellm/a2a_protocol/main.py +++ b/litellm/a2a_protocol/main.py @@ -73,7 +73,7 @@ except ImportError: from litellm.a2a_protocol.card_resolver import ( LiteLLMA2ACardResolver, get_agent_card_url, - normalize_agent_card_protocol_bindings, + normalize_agent_card_interfaces, ) from litellm.a2a_protocol.exception_mapping_utils import ( handle_a2a_localhost_retry, @@ -784,7 +784,7 @@ async def create_a2a_client( verbose_proxy_logger.debug("A2A client created with extra_headers=%s", list(extra_headers.keys())) resolver: Final = A2ACardResolver(httpx_client=httpx_client, base_url=base_url) - agent_card: Final = normalize_agent_card_protocol_bindings( + agent_card: Final = normalize_agent_card_interfaces( await resolver.get_agent_card(http_kwargs={"headers": extra_headers} if extra_headers else None) ) diff --git a/tests/test_litellm/a2a_protocol/test_card_resolver.py b/tests/test_litellm/a2a_protocol/test_card_resolver.py index 053f28c940f..5cbfa51fa08 100644 --- a/tests/test_litellm/a2a_protocol/test_card_resolver.py +++ b/tests/test_litellm/a2a_protocol/test_card_resolver.py @@ -13,6 +13,7 @@ from litellm.a2a_protocol.card_resolver import ( LiteLLMA2ACardResolver, fix_agent_card_url, is_localhost_or_internal_url, + normalize_agent_card_interfaces, set_agent_card_url, ) @@ -114,3 +115,26 @@ def test_fix_agent_card_url_updates_interface_when_top_level_is_localhost(): assert result.url == "https://my-public-agent.example.com/" assert result.supported_interfaces[0].url == "https://my-public-agent.example.com/" + + +def test_normalize_agent_card_interfaces_downgrades_miscased_interfaces_to_the_0_3_dialect(): + pb2 = pytest.importorskip("a2a.types.a2a_pb2") + + card = pb2.AgentCard( + name="langgraph", + supported_interfaces=[ + pb2.AgentInterface(url="http://a/", protocol_binding="jsonrpc", protocol_version="1.0"), + pb2.AgentInterface(url="http://b/", protocol_binding="JSONRPC", protocol_version="1.0"), + pb2.AgentInterface(url="http://c/", protocol_binding="websocket", protocol_version="1.0"), + ], + ) + + normalized = normalize_agent_card_interfaces(card) + + assert [(i.protocol_binding, i.protocol_version) for i in normalized.supported_interfaces] == [ + ("JSONRPC", "0.3"), + ("JSONRPC", "1.0"), + ("websocket", "1.0"), + ] + assert card.supported_interfaces[0].protocol_binding == "jsonrpc" + assert card.supported_interfaces[0].protocol_version == "1.0" diff --git a/tests/test_litellm/a2a_protocol/test_main.py b/tests/test_litellm/a2a_protocol/test_main.py index aa988bc5b99..8850a2eca6c 100644 --- a/tests/test_litellm/a2a_protocol/test_main.py +++ b/tests/test_litellm/a2a_protocol/test_main.py @@ -176,10 +176,40 @@ _AGENT_A_HEADERS = {"x-agent-token": "token-for-a", "x-tenant": "tenant-a"} _AGENT_B_HEADERS = {"x-agent-token": "token-for-b", "x-tenant": "tenant-b"} -_V1_RPC_REPLY = { +_LANGGRAPH_TASK_REPLY = { "jsonrpc": "2.0", "id": "reply", - "result": {"message": {"messageId": "reply-1", "role": "ROLE_AGENT", "parts": [{"text": "pong"}]}}, + "result": { + "kind": "task", + "id": "run-1:task-1", + "contextId": "thread-1", + "history": [ + { + "kind": "message", + "role": "user", + "parts": [{"kind": "text", "text": "hi"}], + "messageId": "m-user", + "taskId": "run-1:task-1", + "contextId": "thread-1", + }, + { + "kind": "message", + "role": "agent", + "parts": [{"kind": "text", "text": "langgraph echo: hi"}], + "messageId": "m-agent", + "taskId": "run-1:task-1", + "contextId": "thread-1", + }, + ], + "status": {"state": "completed", "timestamp": "2026-08-24T00:00:00+00:00"}, + "artifacts": [ + { + "artifactId": "art-1", + "name": "Assistant Response", + "parts": [{"kind": "text", "text": "langgraph echo: hi"}], + } + ], + }, } @@ -334,17 +364,22 @@ async def test_streaming_send_carries_only_its_own_caller_headers(isolated_clien @pytest.mark.asyncio -async def test_lowercase_protocol_binding_in_agent_card_still_gets_a_client(isolated_client_cache): - """LangGraph Platform serves cards with protocolBinding "jsonrpc"; a2a-sdk matches - bindings case-sensitively, so without normalization client creation raises - ValueError("no compatible transports found.").""" - await _seed_shared_a2a_client(card=_LOWERCASE_BINDING_CARD, rpc_reply=_V1_RPC_REPLY) +async def test_lowercase_protocol_binding_card_round_trips_the_langgraph_dialect(isolated_client_cache): + """LangGraph Platform serves cards with protocolBinding "jsonrpc" and answers in the + A2A 0.3 JSON dialect ("kind"-discriminated) while declaring protocolVersion "1.0". + Without binding normalization client creation raises ValueError("no compatible + transports found."); without the version downgrade the SDK's strict v1 transport + rejects the reply with 'Message type "lf.a2a.v1.Task" has no field named "kind"'.""" + await _seed_shared_a2a_client(card=_LOWERCASE_BINDING_CARD, rpc_reply=_LANGGRAPH_TASK_REPLY) a2a_client = await create_a2a_client(base_url="http://127.0.0.1:9") response = await _send_message(a2a_client, _send_request("lc")) - assert type(response.root.result).__name__ == "Message" - assert a2a_client._litellm_agent_card.supported_interfaces[0].protocol_binding == "JSONRPC" + assert type(response.root.result).__name__ == "Task" + assert response.root.result.artifacts[0].parts[0].root.text == "langgraph echo: hi" + interface = a2a_client._litellm_agent_card.supported_interfaces[0] + assert interface.protocol_binding == "JSONRPC" + assert interface.protocol_version == "0.3" @pytest.mark.asyncio