Skip to content

Commit f573741

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 12b5edf commit f573741

15 files changed

Lines changed: 1278 additions & 31 deletions

File tree

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: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,137 @@
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(
84+
sec_list: list[dict[str, list[str]]],
85+
) -> list[dict[str, Any]]:
86+
"""Convert a legacy security requirement list into the 1.0.0 Protobuf format."""
87+
return [
88+
{
89+
'schemes': {
90+
scheme_name: {'list': scopes}
91+
for scheme_name, scopes in sec_dict.items()
92+
}
93+
}
94+
for sec_dict in sec_list
95+
]
96+
97+
98+
def _handle_security_compatibility(agent_card_data: dict[str, Any]) -> None:
99+
"""Map legacy security requirements and schemas to their 1.0.0 Protobuf equivalents."""
100+
legacy_security = agent_card_data.pop('security', None)
101+
if (
102+
'securityRequirements' not in agent_card_data
103+
and legacy_security is not None
104+
):
105+
agent_card_data['securityRequirements'] = _map_legacy_security(
106+
legacy_security
107+
)
108+
109+
for skill in agent_card_data.get('skills', []):
110+
legacy_skill_sec = skill.pop('security', None)
111+
if 'securityRequirements' not in skill and legacy_skill_sec is not None:
112+
skill['securityRequirements'] = _map_legacy_security(
113+
legacy_skill_sec
114+
)
115+
116+
if 'securitySchemes' in agent_card_data:
117+
type_mapping = {
118+
'apiKey': 'apiKeySecurityScheme',
119+
'http': 'httpAuthSecurityScheme',
120+
'oauth2': 'oauth2SecurityScheme',
121+
'openIdConnect': 'openIdConnectSecurityScheme',
122+
'mutualTLS': 'mtlsSecurityScheme',
123+
}
124+
for scheme in agent_card_data['securitySchemes'].values():
125+
scheme_type = scheme.pop('type', None)
126+
if scheme_type in type_mapping:
127+
# Map legacy 'in' to modern 'location'
128+
if scheme_type == 'apiKey' and 'in' in scheme:
129+
scheme['location'] = scheme.pop('in')
130+
131+
mapped_name = type_mapping[scheme_type]
132+
new_scheme_wrapper = {mapped_name: scheme.copy()}
133+
scheme.clear()
134+
scheme.update(new_scheme_wrapper)
6135

7136

8137
def create_text_message_object(

src/a2a/client/transports/jsonrpc.py

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from jsonrpc.jsonrpc2 import JSONRPC20Request, JSONRPC20Response
1111

1212
from a2a.client.errors import A2AClientError
13+
from a2a.client.helpers import parse_agent_card
1314
from a2a.client.middleware import ClientCallContext, ClientCallInterceptor
1415
from a2a.client.transports.base import ClientTransport
1516
from a2a.client.transports.http_helpers import (
@@ -93,7 +94,7 @@ async def send_message(
9394
response_data = await self._send_request(payload, modified_kwargs)
9495
json_rpc_response = JSONRPC20Response(**response_data)
9596
if json_rpc_response.error:
96-
raise self._create_jsonrpc_error(json_rpc_response.error)
97+
raise self._create_jsonrpc_error(json_rpc_response.error)
9798
response: SendMessageResponse = json_format.ParseDict(
9899
json_rpc_response.result, SendMessageResponse()
99100
)
@@ -154,7 +155,7 @@ async def get_task(
154155
response_data = await self._send_request(payload, modified_kwargs)
155156
json_rpc_response = JSONRPC20Response(**response_data)
156157
if json_rpc_response.error:
157-
raise self._create_jsonrpc_error(json_rpc_response.error)
158+
raise self._create_jsonrpc_error(json_rpc_response.error)
158159
response: Task = json_format.ParseDict(json_rpc_response.result, Task())
159160
return response
160161

@@ -184,7 +185,7 @@ async def list_tasks(
184185
response_data = await self._send_request(payload, modified_kwargs)
185186
json_rpc_response = JSONRPC20Response(**response_data)
186187
if json_rpc_response.error:
187-
raise self._create_jsonrpc_error(json_rpc_response.error)
188+
raise self._create_jsonrpc_error(json_rpc_response.error)
188189
response: ListTasksResponse = json_format.ParseDict(
189190
json_rpc_response.result, ListTasksResponse()
190191
)
@@ -216,7 +217,7 @@ async def cancel_task(
216217
response_data = await self._send_request(payload, modified_kwargs)
217218
json_rpc_response = JSONRPC20Response(**response_data)
218219
if json_rpc_response.error:
219-
raise self._create_jsonrpc_error(json_rpc_response.error)
220+
raise self._create_jsonrpc_error(json_rpc_response.error)
220221
response: Task = json_format.ParseDict(json_rpc_response.result, Task())
221222
return response
222223

@@ -246,7 +247,7 @@ async def create_task_push_notification_config(
246247
response_data = await self._send_request(payload, modified_kwargs)
247248
json_rpc_response = JSONRPC20Response(**response_data)
248249
if json_rpc_response.error:
249-
raise self._create_jsonrpc_error(json_rpc_response.error)
250+
raise self._create_jsonrpc_error(json_rpc_response.error)
250251
response: TaskPushNotificationConfig = json_format.ParseDict(
251252
json_rpc_response.result, TaskPushNotificationConfig()
252253
)
@@ -278,7 +279,7 @@ async def get_task_push_notification_config(
278279
response_data = await self._send_request(payload, modified_kwargs)
279280
json_rpc_response = JSONRPC20Response(**response_data)
280281
if json_rpc_response.error:
281-
raise self._create_jsonrpc_error(json_rpc_response.error)
282+
raise self._create_jsonrpc_error(json_rpc_response.error)
282283
response: TaskPushNotificationConfig = json_format.ParseDict(
283284
json_rpc_response.result, TaskPushNotificationConfig()
284285
)
@@ -310,7 +311,7 @@ async def list_task_push_notification_configs(
310311
response_data = await self._send_request(payload, modified_kwargs)
311312
json_rpc_response = JSONRPC20Response(**response_data)
312313
if json_rpc_response.error:
313-
raise self._create_jsonrpc_error(json_rpc_response.error)
314+
raise self._create_jsonrpc_error(json_rpc_response.error)
314315
response: ListTaskPushNotificationConfigsResponse = (
315316
json_format.ParseDict(
316317
json_rpc_response.result,
@@ -345,7 +346,7 @@ async def delete_task_push_notification_config(
345346
response_data = await self._send_request(payload, modified_kwargs)
346347
json_rpc_response = JSONRPC20Response(**response_data)
347348
if json_rpc_response.error:
348-
raise self._create_jsonrpc_error(json_rpc_response.error)
349+
raise self._create_jsonrpc_error(json_rpc_response.error)
349350

350351
async def subscribe(
351352
self,
@@ -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 = parse_agent_card(
423+
cast('dict[str, Any]', json_rpc_response.result)
418424
)
419425
if signature_verifier:
420426
signature_verifier(response)
@@ -498,7 +504,7 @@ async def _send_stream_request(
498504
):
499505
json_rpc_response = JSONRPC20Response.from_json(sse_data)
500506
if json_rpc_response.error:
501-
raise self._create_jsonrpc_error(json_rpc_response.error)
507+
raise self._create_jsonrpc_error(json_rpc_response.error)
502508
response: StreamResponse = json_format.ParseDict(
503509
json_rpc_response.result, StreamResponse()
504510
)

src/a2a/client/transports/rest.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from google.protobuf.message import Message
1111

1212
from a2a.client.errors import A2AClientError
13+
from a2a.client.helpers import parse_agent_card
1314
from a2a.client.middleware import ClientCallContext, ClientCallInterceptor
1415
from a2a.client.transports.base import ClientTransport
1516
from a2a.client.transports.http_helpers import (
@@ -347,7 +348,7 @@ async def get_extended_agent_card(
347348
response_data = await self._send_get_request(
348349
'/v1/card', {}, modified_kwargs
349350
)
350-
response: AgentCard = ParseDict(response_data, AgentCard())
351+
response: AgentCard = parse_agent_card(response_data)
351352

352353
if signature_verifier:
353354
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,
@@ -458,5 +459,7 @@ async def get_authenticated_extended_card(
458459
elif self.card_modifier:
459460
card_to_serve = await maybe_await(self.card_modifier(base_card))
460461

461-
result = MessageToDict(card_to_serve, preserving_proto_field_name=False)
462+
result = agent_card_to_dict(
463+
card_to_serve, preserving_proto_field_name=False
464+
)
462465
return _build_success_response(request_id, result)

0 commit comments

Comments
 (0)