fix(a2a): speak the 0.3 dialect to servers with mis-cased protocol bindings

This commit is contained in:
mateo-berri 2026-08-24 10:51:14 -07:00
parent de0d8ceb25
commit fe567bd846
4 changed files with 84 additions and 15 deletions

View file

@ -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

View file

@ -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)
)

View file

@ -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"

View file

@ -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