Skip to content

Commit a95abdd

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. Also resolves linting and validation constraints.
1 parent 4cf5a15 commit a95abdd

15 files changed

Lines changed: 1261 additions & 23 deletions

File tree

src/a2a/client/card_resolver.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@
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 (
1212
A2AClientHTTPError,
1313
A2AClientJSONError,
1414
)
15+
from a2a.client.helpers import parse_agent_card
1516
from a2a.types.a2a_pb2 import (
1617
AgentCard,
1718
)
@@ -88,7 +89,7 @@ async def get_agent_card(
8889
target_url,
8990
agent_card_data,
9091
)
91-
agent_card = ParseDict(agent_card_data, AgentCard())
92+
agent_card = parse_agent_card(agent_card_data)
9293
if signature_verifier:
9394
signature_verifier(agent_card)
9495
except httpx.HTTPStatusError as e:

src/a2a/client/helpers.py

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,135 @@
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+
supports_extended_card = agent_card_data.pop(
25+
'supportsAuthenticatedExtendedCard', None
26+
)
27+
if supports_extended_card is None:
28+
supports_extended_card = agent_card_data.pop(
29+
'supports_authenticated_extended_card', None
30+
)
31+
32+
if supports_extended_card:
33+
capabilities = agent_card_data.setdefault('capabilities', {})
34+
if (
35+
'extendedAgentCard' not in capabilities
36+
and 'extended_agent_card' not in capabilities
37+
):
38+
capabilities['extendedAgentCard'] = True
39+
40+
41+
def _handle_connection_fields_compatibility(
42+
agent_card_data: dict[str, Any],
43+
) -> None:
44+
"""Map legacy connection and transport fields to supportedInterfaces."""
45+
main_url = agent_card_data.pop('url', None)
46+
47+
main_transport = agent_card_data.pop('preferredTransport', None)
48+
if main_transport is None:
49+
main_transport = agent_card_data.pop('preferred_transport', 'JSONRPC')
50+
51+
version = agent_card_data.pop('protocolVersion', None)
52+
if version is None:
53+
version = agent_card_data.pop('protocol_version', '0.3.0')
54+
55+
additional_interfaces = agent_card_data.pop('additionalInterfaces', None)
56+
if additional_interfaces is None:
57+
additional_interfaces = agent_card_data.pop('additional_interfaces', [])
58+
59+
if (
60+
'supportedInterfaces' not in agent_card_data
61+
and 'supported_interfaces' not in agent_card_data
62+
and main_url
63+
):
64+
supported_interfaces = []
65+
supported_interfaces.append(
66+
{
67+
'url': main_url,
68+
'protocolBinding': main_transport,
69+
'protocolVersion': version,
70+
}
71+
)
72+
supported_interfaces.extend(
73+
{
74+
'url': iface.get('url'),
75+
'protocolBinding': iface.get('transport'),
76+
'protocolVersion': version,
77+
}
78+
for iface in additional_interfaces
79+
)
80+
agent_card_data['supportedInterfaces'] = supported_interfaces
81+
82+
83+
def _map_legacy_security(sec_list: list[dict[str, list[str]]]) -> list[dict[str, Any]]:
84+
"""Convert a legacy security requirement list into the 1.0.0 Protobuf format."""
85+
return [
86+
{
87+
'schemes': {
88+
scheme_name: {'list': scopes}
89+
for scheme_name, scopes in sec_dict.items()
90+
}
91+
}
92+
for sec_dict in sec_list
93+
]
94+
95+
96+
def _handle_security_compatibility(agent_card_data: dict[str, Any]) -> None:
97+
"""Map legacy security requirements and schemas to their 1.0.0 Protobuf equivalents."""
98+
legacy_security = agent_card_data.pop('security', None)
99+
if (
100+
'securityRequirements' not in agent_card_data
101+
and legacy_security is not None
102+
):
103+
agent_card_data['securityRequirements'] = _map_legacy_security(
104+
legacy_security
105+
)
106+
107+
for skill in agent_card_data.get('skills', []):
108+
legacy_skill_sec = skill.pop('security', None)
109+
if 'securityRequirements' not in skill and legacy_skill_sec is not None:
110+
skill['securityRequirements'] = _map_legacy_security(
111+
legacy_skill_sec
112+
)
113+
114+
if 'securitySchemes' in agent_card_data:
115+
type_mapping = {
116+
'apiKey': 'apiKeySecurityScheme',
117+
'http': 'httpAuthSecurityScheme',
118+
'oauth2': 'oauth2SecurityScheme',
119+
'openIdConnect': 'openIdConnectSecurityScheme',
120+
'mutualTLS': 'mtlsSecurityScheme',
121+
}
122+
for scheme in agent_card_data['securitySchemes'].values():
123+
scheme_type = scheme.pop('type', None)
124+
if scheme_type in type_mapping:
125+
# Map legacy 'in' to modern 'location'
126+
if scheme_type == 'apiKey' and 'in' in scheme:
127+
scheme['location'] = scheme.pop('in')
128+
129+
mapped_name = type_mapping[scheme_type]
130+
new_scheme_wrapper = {mapped_name: scheme.copy()}
131+
scheme.clear()
132+
scheme.update(new_scheme_wrapper)
6133

7134

8135
def create_text_message_object(

src/a2a/client/transports/jsonrpc.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
A2AClientJSONRPCError,
1818
A2AClientTimeoutError,
1919
)
20+
from a2a.client.helpers import parse_agent_card
2021
from a2a.client.middleware import ClientCallContext, ClientCallInterceptor
2122
from a2a.client.transports.base import ClientTransport
2223
from a2a.extensions.common import update_extension_header
@@ -521,9 +522,7 @@ async def get_extended_agent_card(
521522
json_rpc_response = JSONRPC20Response(**response_data)
522523
if json_rpc_response.error:
523524
raise A2AClientJSONRPCError(json_rpc_response.error)
524-
response: AgentCard = json_format.ParseDict(
525-
json_rpc_response.result, AgentCard()
526-
)
525+
response: AgentCard = parse_agent_card(json_rpc_response.result)
527526
if signature_verifier:
528527
signature_verifier(response)
529528

src/a2a/client/transports/rest.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
A2AClientJSONError,
1616
A2AClientTimeoutError,
1717
)
18+
from a2a.client.helpers import parse_agent_card
1819
from a2a.client.middleware import ClientCallContext, ClientCallInterceptor
1920
from a2a.client.transports.base import ClientTransport
2021
from a2a.extensions.common import update_extension_header
@@ -478,7 +479,7 @@ async def get_extended_agent_card(
478479
response_data = await self._send_get_request(
479480
'/v1/card', {}, modified_kwargs
480481
)
481-
response: AgentCard = ParseDict(response_data, AgentCard())
482+
response: AgentCard = parse_agent_card(response_data)
482483

483484
if signature_verifier:
484485
signature_verifier(response)

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

Lines changed: 6 additions & 3 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,7 +578,7 @@ 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,
580583
preserving_proto_field_name=False,
581584
)

src/a2a/server/apps/rest/rest_adapter.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,7 @@
44
from collections.abc import AsyncIterable, AsyncIterator, Awaitable, Callable
55
from typing import TYPE_CHECKING, Any
66

7-
from google.protobuf.json_format import MessageToDict
8-
7+
from a2a.server.request_handlers.response_helpers import agent_card_to_dict
98
from a2a.utils.helpers import maybe_await
109

1110

@@ -158,7 +157,7 @@ async def handle_get_agent_card(
158157
if self.card_modifier:
159158
card_to_serve = await maybe_await(self.card_modifier(card_to_serve))
160159

161-
return MessageToDict(card_to_serve)
160+
return agent_card_to_dict(card_to_serve)
162161

163162
async def handle_authenticated_agent_card(
164163
self, request: Request, call_context: ServerCallContext | None = None
@@ -192,7 +191,9 @@ async def handle_authenticated_agent_card(
192191
elif self.card_modifier:
193192
card_to_serve = await maybe_await(self.card_modifier(card_to_serve))
194193

195-
return MessageToDict(card_to_serve, preserving_proto_field_name=True)
194+
return agent_card_to_dict(
195+
card_to_serve, preserving_proto_field_name=True
196+
)
196197

197198
def routes(self) -> dict[tuple[str, str], Callable[[Request], Any]]:
198199
"""Constructs a dictionary of API routes and their corresponding handlers.

src/a2a/server/request_handlers/jsonrpc_handler.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
JSONRPCError,
1717
)
1818
from a2a.server.request_handlers.request_handler import RequestHandler
19+
from a2a.server.request_handlers.response_helpers import agent_card_to_dict
1920
from a2a.types.a2a_pb2 import (
2021
AgentCard,
2122
CancelTaskRequest,
@@ -470,5 +471,7 @@ async def get_authenticated_extended_card(
470471
elif self.card_modifier:
471472
card_to_serve = await maybe_await(self.card_modifier(base_card))
472473

473-
result = MessageToDict(card_to_serve, preserving_proto_field_name=False)
474+
result = agent_card_to_dict(
475+
card_to_serve, preserving_proto_field_name=False
476+
)
474477
return _build_success_response(request_id, result)

src/a2a/server/request_handlers/response_helpers.py

Lines changed: 39 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,43 @@
8991
"""Type alias for possible event types produced by handlers."""
9092

9193

94+
def agent_card_to_dict(
95+
card: AgentCard, preserving_proto_field_name: bool = False
96+
) -> dict[str, Any]:
97+
"""Convert AgentCard to dict and inject backward compatibility fields."""
98+
result = MessageToDict(
99+
card, preserving_proto_field_name=preserving_proto_field_name
100+
)
101+
102+
compat_card = to_compat_agent_card(card)
103+
compat_dict = compat_card.model_dump(
104+
by_alias=not preserving_proto_field_name, exclude_none=True
105+
)
106+
107+
# Do not include supportsAuthenticatedExtendedCard if false
108+
key = (
109+
'supports_authenticated_extended_card'
110+
if preserving_proto_field_name
111+
else 'supportsAuthenticatedExtendedCard'
112+
)
113+
if not compat_dict.get(key):
114+
compat_dict.pop(key, None)
115+
116+
def merge(dict1: dict[str, Any], dict2: dict[str, Any]) -> dict[str, Any]:
117+
for k, v in dict2.items():
118+
if k not in dict1:
119+
dict1[k] = v
120+
elif isinstance(v, dict) and isinstance(dict1[k], dict):
121+
merge(dict1[k], v)
122+
elif isinstance(v, list) and isinstance(dict1[k], list):
123+
for i in range(min(len(dict1[k]), len(v))):
124+
if isinstance(dict1[k][i], dict) and isinstance(v[i], dict):
125+
merge(dict1[k][i], v[i])
126+
return dict1
127+
128+
return merge(result, compat_dict)
129+
130+
92131
def build_error_response(
93132
request_id: str | int | None,
94133
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(A2AClientJSONError) as exc_info:

0 commit comments

Comments
 (0)