Skip to content

Commit 96e7a5d

Browse files
committed
feat(compat): AgentCard backward compatibility helpers and tests
This commit implements the backwards compatibility helpers for exchanging legacy v0.3 Agent Cards across the v1.0 protocol bounds.
1 parent 13a092f commit 96e7a5d

File tree

13 files changed

+1280
-17
lines changed

13 files changed

+1280
-17
lines changed

src/a2a/client/card_resolver.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@
66

77
import httpx
88

9-
from google.protobuf.json_format import ParseDict, ParseError
9+
from google.protobuf.json_format import ParseError
1010

1111
from a2a.client.errors import AgentCardResolutionError
12+
from a2a.client.helpers import parse_agent_card
1213
from a2a.types.a2a_pb2 import (
1314
AgentCard,
1415
)
@@ -85,7 +86,7 @@ async def get_agent_card(
8586
target_url,
8687
agent_card_data,
8788
)
88-
agent_card = ParseDict(agent_card_data, AgentCard())
89+
agent_card = parse_agent_card(agent_card_data)
8990
if signature_verifier:
9091
signature_verifier(agent_card)
9192
except httpx.HTTPStatusError as e:

src/a2a/client/helpers.py

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,116 @@
11
"""Helper functions for the A2A client."""
22

3+
from typing import Any
34
from uuid import uuid4
45

5-
from a2a.types.a2a_pb2 import Message, Part, Role
6+
from google.protobuf.json_format import ParseDict
7+
8+
from a2a.types.a2a_pb2 import AgentCard, Message, Part, Role
9+
10+
11+
def parse_agent_card(agent_card_data: dict[str, Any]) -> AgentCard:
12+
"""Parse AgentCard JSON dictionary and handle backward compatibility."""
13+
_handle_extended_card_compatibility(agent_card_data)
14+
_handle_connection_fields_compatibility(agent_card_data)
15+
_handle_security_compatibility(agent_card_data)
16+
17+
return ParseDict(agent_card_data, AgentCard(), ignore_unknown_fields=True)
18+
19+
20+
def _handle_extended_card_compatibility(
21+
agent_card_data: dict[str, Any],
22+
) -> None:
23+
"""Map legacy supportsAuthenticatedExtendedCard to capabilities."""
24+
if agent_card_data.pop('supportsAuthenticatedExtendedCard', None):
25+
capabilities = agent_card_data.setdefault('capabilities', {})
26+
if 'extendedAgentCard' not in capabilities:
27+
capabilities['extendedAgentCard'] = True
28+
29+
30+
def _handle_connection_fields_compatibility(
31+
agent_card_data: dict[str, Any],
32+
) -> None:
33+
"""Map legacy connection and transport fields to supportedInterfaces."""
34+
main_url = agent_card_data.pop('url', None)
35+
main_transport = agent_card_data.pop('preferredTransport', 'JSONRPC')
36+
version = agent_card_data.pop('protocolVersion', '0.3.0')
37+
additional_interfaces = (
38+
agent_card_data.pop('additionalInterfaces', None) or []
39+
)
40+
41+
if 'supportedInterfaces' not in agent_card_data and main_url:
42+
supported_interfaces = []
43+
supported_interfaces.append(
44+
{
45+
'url': main_url,
46+
'protocolBinding': main_transport,
47+
'protocolVersion': version,
48+
}
49+
)
50+
supported_interfaces.extend(
51+
{
52+
'url': iface.get('url'),
53+
'protocolBinding': iface.get('transport'),
54+
'protocolVersion': version,
55+
}
56+
for iface in additional_interfaces
57+
)
58+
agent_card_data['supportedInterfaces'] = supported_interfaces
59+
60+
61+
def _map_legacy_security(
62+
sec_list: list[dict[str, list[str]]],
63+
) -> list[dict[str, Any]]:
64+
"""Convert a legacy security requirement list into the 1.0.0 Protobuf format."""
65+
return [
66+
{
67+
'schemes': {
68+
scheme_name: {'list': scopes}
69+
for scheme_name, scopes in sec_dict.items()
70+
}
71+
}
72+
for sec_dict in sec_list
73+
]
74+
75+
76+
def _handle_security_compatibility(agent_card_data: dict[str, Any]) -> None:
77+
"""Map legacy security requirements and schemas to their 1.0.0 Protobuf equivalents."""
78+
legacy_security = agent_card_data.pop('security', None)
79+
if (
80+
'securityRequirements' not in agent_card_data
81+
and legacy_security is not None
82+
):
83+
agent_card_data['securityRequirements'] = _map_legacy_security(
84+
legacy_security
85+
)
86+
87+
for skill in agent_card_data.get('skills', []):
88+
legacy_skill_sec = skill.pop('security', None)
89+
if 'securityRequirements' not in skill and legacy_skill_sec is not None:
90+
skill['securityRequirements'] = _map_legacy_security(
91+
legacy_skill_sec
92+
)
93+
94+
security_schemes = agent_card_data.get('securitySchemes', {})
95+
if security_schemes:
96+
type_mapping = {
97+
'apiKey': 'apiKeySecurityScheme',
98+
'http': 'httpAuthSecurityScheme',
99+
'oauth2': 'oauth2SecurityScheme',
100+
'openIdConnect': 'openIdConnectSecurityScheme',
101+
'mutualTLS': 'mtlsSecurityScheme',
102+
}
103+
for scheme in security_schemes.values():
104+
scheme_type = scheme.pop('type', None)
105+
if scheme_type in type_mapping:
106+
# Map legacy 'in' to modern 'location'
107+
if scheme_type == 'apiKey' and 'in' in scheme:
108+
scheme['location'] = scheme.pop('in')
109+
110+
mapped_name = type_mapping[scheme_type]
111+
new_scheme_wrapper = {mapped_name: scheme.copy()}
112+
scheme.clear()
113+
scheme.update(new_scheme_wrapper)
6114

7115

8116
def create_text_message_object(

src/a2a/client/transports/jsonrpc.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import httpx
88

99
from google.protobuf import json_format
10+
from google.protobuf.json_format import ParseDict
1011
from jsonrpc.jsonrpc2 import JSONRPC20Request, JSONRPC20Response
1112

1213
from a2a.client.errors import A2AClientError
@@ -413,8 +414,13 @@ async def get_extended_agent_card(
413414
json_rpc_response = JSONRPC20Response(**response_data)
414415
if json_rpc_response.error:
415416
raise self._create_jsonrpc_error(json_rpc_response.error)
416-
response: AgentCard = json_format.ParseDict(
417-
json_rpc_response.result, AgentCard()
417+
# Validate type of the response
418+
if not isinstance(json_rpc_response.result, dict):
419+
raise A2AClientError(
420+
f'Invalid response type: {type(json_rpc_response.result)}'
421+
)
422+
response: AgentCard = ParseDict(
423+
cast('dict[str, Any]', json_rpc_response.result), AgentCard()
418424
)
419425
if signature_verifier:
420426
signature_verifier(response)

src/a2a/server/apps/jsonrpc/jsonrpc_app.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from collections.abc import AsyncGenerator, Awaitable, Callable
1010
from typing import TYPE_CHECKING, Any
1111

12-
from google.protobuf.json_format import MessageToDict, ParseDict
12+
from google.protobuf.json_format import ParseDict
1313
from jsonrpc.jsonrpc2 import JSONRPC20Request
1414

1515
from a2a.auth.user import UnauthenticatedUser
@@ -29,7 +29,10 @@
2929
)
3030
from a2a.server.request_handlers.jsonrpc_handler import JSONRPCHandler
3131
from a2a.server.request_handlers.request_handler import RequestHandler
32-
from a2a.server.request_handlers.response_helpers import build_error_response
32+
from a2a.server.request_handlers.response_helpers import (
33+
agent_card_to_dict,
34+
build_error_response,
35+
)
3336
from a2a.types import A2ARequest
3437
from a2a.types.a2a_pb2 import (
3538
AgentCard,
@@ -575,9 +578,8 @@ async def _handle_get_agent_card(self, request: Request) -> JSONResponse:
575578
card_to_serve = await maybe_await(self.card_modifier(card_to_serve))
576579

577580
return JSONResponse(
578-
MessageToDict(
581+
agent_card_to_dict(
579582
card_to_serve,
580-
preserving_proto_field_name=False,
581583
)
582584
)
583585

src/a2a/server/request_handlers/response_helpers.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@
66
from google.protobuf.message import Message as ProtoMessage
77
from jsonrpc.jsonrpc2 import JSONRPC20Response
88

9+
from a2a.compat.v0_3.conversions import to_compat_agent_card
910
from a2a.server.jsonrpc_models import (
1011
InternalError as JSONRPCInternalError,
1112
)
1213
from a2a.server.jsonrpc_models import (
1314
JSONRPCError,
1415
)
1516
from a2a.types.a2a_pb2 import (
17+
AgentCard,
1618
ListTasksResponse,
1719
Message,
1820
StreamResponse,
@@ -89,6 +91,32 @@
8991
"""Type alias for possible event types produced by handlers."""
9092

9193

94+
def agent_card_to_dict(card: AgentCard) -> dict[str, Any]:
95+
"""Convert AgentCard to dict and inject backward compatibility fields."""
96+
result = MessageToDict(card)
97+
98+
compat_card = to_compat_agent_card(card)
99+
compat_dict = compat_card.model_dump(exclude_none=True)
100+
101+
# Do not include supportsAuthenticatedExtendedCard if false
102+
if not compat_dict.get('supportsAuthenticatedExtendedCard'):
103+
compat_dict.pop('supportsAuthenticatedExtendedCard', None)
104+
105+
def merge(dict1: dict[str, Any], dict2: dict[str, Any]) -> dict[str, Any]:
106+
for k, v in dict2.items():
107+
if k not in dict1:
108+
dict1[k] = v
109+
elif isinstance(v, dict) and isinstance(dict1[k], dict):
110+
merge(dict1[k], v)
111+
elif isinstance(v, list) and isinstance(dict1[k], list):
112+
for i in range(min(len(dict1[k]), len(v))):
113+
if isinstance(dict1[k][i], dict) and isinstance(v[i], dict):
114+
merge(dict1[k][i], v[i])
115+
return dict1
116+
117+
return merge(result, compat_dict)
118+
119+
92120
def build_error_response(
93121
request_id: str | int | None,
94122
error: A2AError | JSONRPCError,

tests/client/test_card_resolver.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,7 @@ async def test_get_agent_card_validation_error(
260260
valid_agent_card_data,
261261
):
262262
"""Test A2AClientJSONError is raised on agent card validation error."""
263-
return_json = {'invalid': 'data'}
263+
return_json = {'name': {'invalid': 'type'}}
264264
mock_response.json.return_value = return_json
265265
mock_httpx_client.get.return_value = mock_response
266266
with pytest.raises(AgentCardResolutionError) as exc_info:

0 commit comments

Comments
 (0)