Skip to content

Commit 115fa4e

Browse files
authored
feat: Keep only 0.3 compatible endpoints in compat version of AgentCard (#847)
When generating backward compatible AgentCard format, keep only 0.3 compatible endpoints. This affects /.well-known/agent-card.json and AgentCard generation in 0.3 compat layer. Fixes #742
1 parent 0e583f5 commit 115fa4e

15 files changed

Lines changed: 411 additions & 54 deletions

File tree

src/a2a/client/client_factory.py

Lines changed: 4 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from a2a.client.transports.jsonrpc import JsonRpcTransport
1717
from a2a.client.transports.rest import RestTransport
1818
from a2a.client.transports.tenant_decorator import TenantTransportDecorator
19+
from a2a.compat.v0_3.versions import is_legacy_version
1920
from a2a.types.a2a_pb2 import (
2021
AgentCapabilities,
2122
AgentCard,
@@ -111,7 +112,7 @@ def jsonrpc_transport_producer(
111112
else PROTOCOL_VERSION_CURRENT
112113
)
113114

114-
if ClientFactory._is_legacy_version(version):
115+
if is_legacy_version(version):
115116
from a2a.compat.v0_3.jsonrpc_transport import ( # noqa: PLC0415
116117
CompatJsonRpcTransport,
117118
)
@@ -150,7 +151,7 @@ def rest_transport_producer(
150151
else PROTOCOL_VERSION_CURRENT
151152
)
152153

153-
if ClientFactory._is_legacy_version(version):
154+
if is_legacy_version(version):
154155
from a2a.compat.v0_3.rest_transport import ( # noqa: PLC0415
155156
CompatRestTransport,
156157
)
@@ -197,7 +198,7 @@ def grpc_transport_producer(
197198
)
198199

199200
if (
200-
ClientFactory._is_legacy_version(version)
201+
is_legacy_version(version)
201202
and CompatGrpcTransport is not None
202203
):
203204
return CompatGrpcTransport.create(card, url, config)
@@ -215,21 +216,6 @@ def grpc_transport_producer(
215216
grpc_transport_producer,
216217
)
217218

218-
@staticmethod
219-
def _is_legacy_version(version: str | None) -> bool:
220-
"""Determines if the given version is a legacy protocol version (>=0.3 and <1.0)."""
221-
if not version:
222-
return False
223-
try:
224-
v = Version(version)
225-
return (
226-
Version(PROTOCOL_VERSION_0_3)
227-
<= v
228-
< Version(PROTOCOL_VERSION_1_0)
229-
)
230-
except InvalidVersion:
231-
return False
232-
233219
@staticmethod
234220
def _find_best_interface(
235221
interfaces: list[AgentInterface],

src/a2a/compat/v0_3/conversions.py

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@
99
from google.protobuf.json_format import MessageToDict, ParseDict
1010

1111
from a2a.compat.v0_3 import types as types_v03
12+
from a2a.compat.v0_3.versions import is_legacy_version
1213
from a2a.server.models import PushNotificationConfigModel, TaskModel
1314
from a2a.types import a2a_pb2 as pb2_v10
15+
from a2a.utils import constants, errors
1416

1517

1618
_COMPAT_TO_CORE_TASK_STATE: dict[types_v03.TaskState, Any] = {
@@ -676,7 +678,7 @@ def to_core_agent_interface(
676678
return pb2_v10.AgentInterface(
677679
url=compat_interface.url,
678680
protocol_binding=compat_interface.transport,
679-
protocol_version='0.3.0', # Defaulting for legacy
681+
protocol_version=constants.PROTOCOL_VERSION_0_3, # Defaulting for legacy
680682
)
681683

682684

@@ -857,7 +859,8 @@ def to_core_agent_card(compat_card: types_v03.AgentCard) -> pb2_v10.AgentCard:
857859
primary_interface = pb2_v10.AgentInterface(
858860
url=compat_card.url,
859861
protocol_binding=compat_card.preferred_transport or 'JSONRPC',
860-
protocol_version=compat_card.protocol_version or '0.3.0',
862+
protocol_version=compat_card.protocol_version
863+
or constants.PROTOCOL_VERSION_0_3,
861864
)
862865
core_card.supported_interfaces.append(primary_interface)
863866

@@ -918,21 +921,23 @@ def to_core_agent_card(compat_card: types_v03.AgentCard) -> pb2_v10.AgentCard:
918921
def to_compat_agent_card(core_card: pb2_v10.AgentCard) -> types_v03.AgentCard:
919922
# Map supported interfaces back to legacy layout
920923
"""Convert agent card to v0.3 compat type."""
921-
primary_interface = (
922-
core_card.supported_interfaces[0]
923-
if core_card.supported_interfaces
924-
else pb2_v10.AgentInterface(
925-
url='', protocol_binding='JSONRPC', protocol_version='0.3.0'
924+
compat_interfaces = [
925+
interface
926+
for interface in core_card.supported_interfaces
927+
if (
928+
(not interface.protocol_version)
929+
or is_legacy_version(interface.protocol_version)
926930
)
927-
)
928-
additional_interfaces = (
929-
[
930-
to_compat_agent_interface(i)
931-
for i in core_card.supported_interfaces[1:]
932-
]
933-
if len(core_card.supported_interfaces) > 1
934-
else None
935-
)
931+
]
932+
if not compat_interfaces:
933+
raise errors.VersionNotSupportedError(
934+
'AgentCard must have at least one interface with compatible protocol version.'
935+
)
936+
937+
primary_interface = compat_interfaces[0]
938+
additional_interfaces = [
939+
to_compat_agent_interface(i) for i in compat_interfaces[1:]
940+
]
936941

937942
compat_cap = to_compat_agent_capabilities(core_card.capabilities)
938943
supports_authenticated_extended_card = (
@@ -947,8 +952,9 @@ def to_compat_agent_card(core_card: pb2_v10.AgentCard) -> types_v03.AgentCard:
947952
version=core_card.version,
948953
url=primary_interface.url,
949954
preferred_transport=primary_interface.protocol_binding,
950-
protocol_version=primary_interface.protocol_version,
951-
additional_interfaces=additional_interfaces,
955+
protocol_version=primary_interface.protocol_version
956+
or constants.PROTOCOL_VERSION_0_3,
957+
additional_interfaces=additional_interfaces or None,
952958
provider=to_compat_agent_provider(core_card.provider)
953959
if core_card.HasField('provider')
954960
else None,

src/a2a/compat/v0_3/versions.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
"""Utility functions for protocol version comparison and validation."""
2+
3+
from packaging.version import InvalidVersion, Version
4+
5+
from a2a.utils.constants import PROTOCOL_VERSION_0_3, PROTOCOL_VERSION_1_0
6+
7+
8+
def is_legacy_version(version: str | None) -> bool:
9+
"""Determines if the given version is a legacy protocol version (>=0.3 and <1.0)."""
10+
if not version:
11+
return False
12+
try:
13+
v = Version(version)
14+
return (
15+
Version(PROTOCOL_VERSION_0_3) <= v < Version(PROTOCOL_VERSION_1_0)
16+
)
17+
except InvalidVersion:
18+
return False

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@
3939
)
4040
from a2a.server.context import ServerCallContext
4141
from a2a.server.request_handlers.request_handler import RequestHandler
42+
from a2a.server.request_handlers.response_helpers import (
43+
agent_card_to_dict,
44+
)
4245
from a2a.server.request_handlers.rest_handler import RESTHandler
4346
from a2a.types.a2a_pb2 import AgentCard
4447
from a2a.utils.error_handlers import (
@@ -175,7 +178,7 @@ async def handle_get_agent_card(
175178
if self.card_modifier:
176179
card_to_serve = await maybe_await(self.card_modifier(card_to_serve))
177180

178-
return MessageToDict(card_to_serve)
181+
return agent_card_to_dict(card_to_serve)
179182

180183
async def _handle_authenticated_agent_card(
181184
self, request: Request, call_context: ServerCallContext | None = None

src/a2a/server/request_handlers/response_helpers.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,11 @@ def agent_card_to_dict(card: AgentCard) -> dict[str, Any]:
8787
"""Convert AgentCard to dict and inject backward compatibility fields."""
8888
result = MessageToDict(card)
8989

90-
compat_card = to_compat_agent_card(card)
91-
compat_dict = compat_card.model_dump(exclude_none=True)
90+
try:
91+
compat_card = to_compat_agent_card(card)
92+
compat_dict = compat_card.model_dump(exclude_none=True)
93+
except VersionNotSupportedError:
94+
compat_dict = {}
9295

9396
# Do not include supportsAuthenticatedExtendedCard if false
9497
if not compat_dict.get('supportsAuthenticatedExtendedCard'):

tests/client/transports/__init__.py

Whitespace-only changes.

tests/client/transports/test_rest_client.py

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from httpx_sse import EventSource, ServerSentEvent
1010

1111
from a2a.client import create_text_message_object
12+
from a2a.client.client import ClientCallContext
1213
from a2a.client.errors import A2AClientError
1314
from a2a.client.transports.rest import RestTransport
1415
from a2a.extensions.common import HTTP_EXTENSION_HEADER
@@ -162,7 +163,6 @@ async def test_send_message_with_timeout_context(
162163
self, mock_httpx_client: AsyncMock, mock_agent_card: MagicMock
163164
):
164165
"""Test that send_message passes context timeout to build_request."""
165-
from a2a.client.client import ClientCallContext
166166

167167
client = RestTransport(
168168
httpx_client=mock_httpx_client,
@@ -258,8 +258,6 @@ async def test_send_message_with_default_extensions(
258258
mock_response.status_code = 200
259259
mock_httpx_client.send.return_value = mock_response
260260

261-
from a2a.client.client import ClientCallContext
262-
263261
context = ClientCallContext(
264262
service_parameters={
265263
'X-A2A-Extensions': 'https://example.com/test-ext/v1,https://example.com/test-ext/v2'
@@ -302,8 +300,6 @@ async def test_send_message_streaming_with_new_extensions(
302300
mock_event_source
303301
)
304302

305-
from a2a.client.client import ClientCallContext
306-
307303
context = ClientCallContext(
308304
service_parameters={
309305
'X-A2A-Extensions': 'https://example.com/test-ext/v2'
@@ -404,8 +400,6 @@ async def test_get_card_with_extended_card_support_with_extensions(
404400

405401
request = GetExtendedAgentCardRequest()
406402

407-
from a2a.client.client import ClientCallContext
408-
409403
context = ClientCallContext(
410404
service_parameters={HTTP_EXTENSION_HEADER: extensions_str}
411405
)
@@ -419,7 +413,6 @@ async def test_get_card_with_extended_card_support_with_extensions(
419413
await client.get_extended_agent_card(request, context=context)
420414

421415
mock_execute_request.assert_called_once()
422-
# _execute_request(method, target, tenant, context)
423416
call_args = mock_execute_request.call_args
424417
assert (
425418
call_args[1].get('context') == context or call_args[0][3] == context
@@ -694,7 +687,7 @@ async def test_rest_get_task_prepend_empty_tenant(
694687
)
695688
@pytest.mark.asyncio
696689
@patch('a2a.client.transports.http_helpers.aconnect_sse')
697-
async def test_rest_streaming_methods_prepend_tenant(
690+
async def test_rest_streaming_methods_prepend_tenant( # noqa: PLR0913
698691
self,
699692
mock_aconnect_sse,
700693
method_name,

tests/compat/v0_3/test_conversions.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@
8181
from a2a.server.models import PushNotificationConfigModel, TaskModel
8282
from cryptography.fernet import Fernet
8383
from a2a.types import a2a_pb2 as pb2_v10
84+
from a2a.utils.errors import VersionNotSupportedError
8485

8586

8687
def test_text_part_conversion():
@@ -986,7 +987,7 @@ def test_security_scheme_mtls_minimal():
986987
def test_agent_interface_conversion():
987988
v03_int = types_v03.AgentInterface(url='http', transport='JSONRPC')
988989
v10_expected = pb2_v10.AgentInterface(
989-
url='http', protocol_binding='JSONRPC', protocol_version='0.3.0'
990+
url='http', protocol_binding='JSONRPC', protocol_version='0.3'
990991
)
991992
v10_int = to_core_agent_interface(v03_int)
992993
assert v10_int == v10_expected
@@ -1131,7 +1132,7 @@ def test_agent_card_conversion():
11311132
url='u1', protocol_binding='JSONRPC', protocol_version='0.3.0'
11321133
),
11331134
pb2_v10.AgentInterface(
1134-
url='u2', protocol_binding='HTTP', protocol_version='0.3.0'
1135+
url='u2', protocol_binding='HTTP', protocol_version='0.3'
11351136
),
11361137
]
11371138
)
@@ -2014,3 +2015,24 @@ def test_push_notification_config_persistence_conversion_with_encryption():
20142015
assert v10_restored.id == v10_config.id
20152016
assert v10_restored.url == v10_config.url
20162017
assert v10_restored.token == v10_config.token
2018+
2019+
2020+
def test_to_compat_agent_card_unsupported_version():
2021+
card = pb2_v10.AgentCard(
2022+
name='Modern Agent',
2023+
description='Only supports 1.0',
2024+
version='1.0.0',
2025+
supported_interfaces=[
2026+
pb2_v10.AgentInterface(
2027+
url='http://grpc.v10.com',
2028+
protocol_binding='GRPC',
2029+
protocol_version='1.0.0',
2030+
),
2031+
],
2032+
capabilities=pb2_v10.AgentCapabilities(),
2033+
)
2034+
with pytest.raises(
2035+
VersionNotSupportedError,
2036+
match='AgentCard must have at least one interface with compatible protocol version.',
2037+
):
2038+
to_compat_agent_card(card)

tests/compat/v0_3/test_grpc_handler.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@ def sample_agent_card() -> a2a_pb2.AgentCard:
3434
name='Test Agent',
3535
description='A test agent',
3636
version='1.0.0',
37+
supported_interfaces=[
38+
a2a_pb2.AgentInterface(
39+
url='http://jsonrpc.v03.com',
40+
protocol_binding='JSONRPC',
41+
protocol_version='0.3',
42+
),
43+
],
3744
)
3845

3946

@@ -434,8 +441,9 @@ async def test_get_agent_card_success(
434441
expected_res = a2a_v0_3_pb2.AgentCard(
435442
name='Test Agent',
436443
description='A test agent',
444+
url='http://jsonrpc.v03.com',
437445
version='1.0.0',
438-
protocol_version='0.3.0',
446+
protocol_version='0.3',
439447
preferred_transport='JSONRPC',
440448
capabilities=a2a_v0_3_pb2.AgentCapabilities(),
441449
)

tests/compat/v0_3/test_rest_transport.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -333,9 +333,7 @@ async def test_compat_rest_transport_subscribe_post_405_get_405_fails(
333333

334334
async def mock_stream(method, path, context=None, json=None):
335335
method_count[method] = method_count.get(method, 0) + 1
336-
if method == 'POST':
337-
assert json is None
338-
elif method == 'GET':
336+
if method in {'POST', 'GET'}:
339337
assert json is None
340338
# To make it an async generator even when it raises
341339
if False:

0 commit comments

Comments
 (0)