From aac8b00866e0dd495056950f482c5f6fcf61a9f7 Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Fri, 17 Apr 2026 10:42:39 +0000 Subject: [PATCH 1/6] wip --- src/a2a/client/card_resolver.py | 108 ++- src/a2a/client/helpers.py | 112 --- tests/client/test_card_resolver.py | 701 +++++++++++++++++- tests/client/test_client_helpers.py | 696 ----------------- tests/helpers/test_proto_helpers.py | 8 + .../test_cross_version_card_validation.py | 2 +- 6 files changed, 815 insertions(+), 812 deletions(-) delete mode 100644 src/a2a/client/helpers.py delete mode 100644 tests/client/test_client_helpers.py diff --git a/src/a2a/client/card_resolver.py b/src/a2a/client/card_resolver.py index 6d98a5361..815916014 100644 --- a/src/a2a/client/card_resolver.py +++ b/src/a2a/client/card_resolver.py @@ -6,10 +6,9 @@ import httpx -from google.protobuf.json_format import ParseError +from google.protobuf.json_format import ParseDict, ParseError from a2a.client.errors import AgentCardResolutionError -from a2a.client.helpers import parse_agent_card from a2a.types.a2a_pb2 import ( AgentCard, ) @@ -19,6 +18,111 @@ logger = logging.getLogger(__name__) +def parse_agent_card(agent_card_data: dict[str, Any]) -> AgentCard: + """Parse AgentCard JSON dictionary and handle backward compatibility.""" + _handle_extended_card_compatibility(agent_card_data) + _handle_connection_fields_compatibility(agent_card_data) + _handle_security_compatibility(agent_card_data) + + return ParseDict(agent_card_data, AgentCard(), ignore_unknown_fields=True) + + +def _handle_extended_card_compatibility( + agent_card_data: dict[str, Any], +) -> None: + """Map legacy supportsAuthenticatedExtendedCard to capabilities.""" + if agent_card_data.pop('supportsAuthenticatedExtendedCard', None): + capabilities = agent_card_data.setdefault('capabilities', {}) + if 'extendedAgentCard' not in capabilities: + capabilities['extendedAgentCard'] = True + + +def _handle_connection_fields_compatibility( + agent_card_data: dict[str, Any], +) -> None: + """Map legacy connection and transport fields to supportedInterfaces.""" + main_url = agent_card_data.pop('url', None) + main_transport = agent_card_data.pop('preferredTransport', 'JSONRPC') + version = agent_card_data.pop('protocolVersion', '0.3.0') + additional_interfaces = ( + agent_card_data.pop('additionalInterfaces', None) or [] + ) + + if 'supportedInterfaces' not in agent_card_data and main_url: + supported_interfaces = [] + supported_interfaces.append( + { + 'url': main_url, + 'protocolBinding': main_transport, + 'protocolVersion': version, + } + ) + supported_interfaces.extend( + { + 'url': iface.get('url'), + 'protocolBinding': iface.get('transport'), + 'protocolVersion': version, + } + for iface in additional_interfaces + ) + agent_card_data['supportedInterfaces'] = supported_interfaces + + +def _map_legacy_security( + sec_list: list[dict[str, list[str]]], +) -> list[dict[str, Any]]: + """Convert a legacy security requirement list into the 1.0.0 Protobuf format.""" + return [ + { + 'schemes': { + scheme_name: {'list': scopes} + for scheme_name, scopes in sec_dict.items() + } + } + for sec_dict in sec_list + ] + + +def _handle_security_compatibility(agent_card_data: dict[str, Any]) -> None: + """Map legacy security requirements and schemas to their 1.0.0 Protobuf equivalents.""" + legacy_security = agent_card_data.pop('security', None) + if ( + 'securityRequirements' not in agent_card_data + and legacy_security is not None + ): + agent_card_data['securityRequirements'] = _map_legacy_security( + legacy_security + ) + + for skill in agent_card_data.get('skills', []): + legacy_skill_sec = skill.pop('security', None) + if 'securityRequirements' not in skill and legacy_skill_sec is not None: + skill['securityRequirements'] = _map_legacy_security( + legacy_skill_sec + ) + + security_schemes = agent_card_data.get('securitySchemes', {}) + if security_schemes: + type_mapping = { + 'apiKey': 'apiKeySecurityScheme', + 'http': 'httpAuthSecurityScheme', + 'oauth2': 'oauth2SecurityScheme', + 'openIdConnect': 'openIdConnectSecurityScheme', + 'mutualTLS': 'mtlsSecurityScheme', + } + for scheme in security_schemes.values(): + scheme_type = scheme.pop('type', None) + if scheme_type in type_mapping: + # Map legacy 'in' to modern 'location' + if scheme_type == 'apiKey' and 'in' in scheme: + scheme['location'] = scheme.pop('in') + + mapped_name = type_mapping[scheme_type] + new_scheme_wrapper = {mapped_name: scheme.copy()} + scheme.clear() + scheme.update(new_scheme_wrapper) + + class A2ACardResolver: """Agent Card resolver.""" diff --git a/src/a2a/client/helpers.py b/src/a2a/client/helpers.py deleted file mode 100644 index f8207f03b..000000000 --- a/src/a2a/client/helpers.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Helper functions for the A2A client.""" - -from typing import Any - -from google.protobuf.json_format import ParseDict - -from a2a.types.a2a_pb2 import AgentCard - - -def parse_agent_card(agent_card_data: dict[str, Any]) -> AgentCard: - """Parse AgentCard JSON dictionary and handle backward compatibility.""" - _handle_extended_card_compatibility(agent_card_data) - _handle_connection_fields_compatibility(agent_card_data) - _handle_security_compatibility(agent_card_data) - - return ParseDict(agent_card_data, AgentCard(), ignore_unknown_fields=True) - - -def _handle_extended_card_compatibility( - agent_card_data: dict[str, Any], -) -> None: - """Map legacy supportsAuthenticatedExtendedCard to capabilities.""" - if agent_card_data.pop('supportsAuthenticatedExtendedCard', None): - capabilities = agent_card_data.setdefault('capabilities', {}) - if 'extendedAgentCard' not in capabilities: - capabilities['extendedAgentCard'] = True - - -def _handle_connection_fields_compatibility( - agent_card_data: dict[str, Any], -) -> None: - """Map legacy connection and transport fields to supportedInterfaces.""" - main_url = agent_card_data.pop('url', None) - main_transport = agent_card_data.pop('preferredTransport', 'JSONRPC') - version = agent_card_data.pop('protocolVersion', '0.3.0') - additional_interfaces = ( - agent_card_data.pop('additionalInterfaces', None) or [] - ) - - if 'supportedInterfaces' not in agent_card_data and main_url: - supported_interfaces = [] - supported_interfaces.append( - { - 'url': main_url, - 'protocolBinding': main_transport, - 'protocolVersion': version, - } - ) - supported_interfaces.extend( - { - 'url': iface.get('url'), - 'protocolBinding': iface.get('transport'), - 'protocolVersion': version, - } - for iface in additional_interfaces - ) - agent_card_data['supportedInterfaces'] = supported_interfaces - - -def _map_legacy_security( - sec_list: list[dict[str, list[str]]], -) -> list[dict[str, Any]]: - """Convert a legacy security requirement list into the 1.0.0 Protobuf format.""" - return [ - { - 'schemes': { - scheme_name: {'list': scopes} - for scheme_name, scopes in sec_dict.items() - } - } - for sec_dict in sec_list - ] - - -def _handle_security_compatibility(agent_card_data: dict[str, Any]) -> None: - """Map legacy security requirements and schemas to their 1.0.0 Protobuf equivalents.""" - legacy_security = agent_card_data.pop('security', None) - if ( - 'securityRequirements' not in agent_card_data - and legacy_security is not None - ): - agent_card_data['securityRequirements'] = _map_legacy_security( - legacy_security - ) - - for skill in agent_card_data.get('skills', []): - legacy_skill_sec = skill.pop('security', None) - if 'securityRequirements' not in skill and legacy_skill_sec is not None: - skill['securityRequirements'] = _map_legacy_security( - legacy_skill_sec - ) - - security_schemes = agent_card_data.get('securitySchemes', {}) - if security_schemes: - type_mapping = { - 'apiKey': 'apiKeySecurityScheme', - 'http': 'httpAuthSecurityScheme', - 'oauth2': 'oauth2SecurityScheme', - 'openIdConnect': 'openIdConnectSecurityScheme', - 'mutualTLS': 'mtlsSecurityScheme', - } - for scheme in security_schemes.values(): - scheme_type = scheme.pop('type', None) - if scheme_type in type_mapping: - # Map legacy 'in' to modern 'location' - if scheme_type == 'apiKey' and 'in' in scheme: - scheme['location'] = scheme.pop('in') - - mapped_name = type_mapping[scheme_type] - new_scheme_wrapper = {mapped_name: scheme.copy()} - scheme.clear() - scheme.update(new_scheme_wrapper) diff --git a/tests/client/test_card_resolver.py b/tests/client/test_card_resolver.py index 9a684a4ac..ff60632ad 100644 --- a/tests/client/test_card_resolver.py +++ b/tests/client/test_card_resolver.py @@ -1,13 +1,35 @@ +import copy +import difflib import json import logging - from unittest.mock import AsyncMock, MagicMock, Mock +from google.protobuf.json_format import MessageToDict import httpx import pytest from a2a.client import A2ACardResolver, AgentCardResolutionError +from a2a.client.card_resolver import parse_agent_card +from a2a.server.request_handlers.response_helpers import agent_card_to_dict from a2a.types import AgentCard +from a2a.types.a2a_pb2 import ( + APIKeySecurityScheme, + AgentCapabilities, + AgentCardSignature, + AgentInterface, + AgentProvider, + AgentSkill, + AuthorizationCodeOAuthFlow, + HTTPAuthSecurityScheme, + MutualTlsSecurityScheme, + OAuth2SecurityScheme, + OAuthFlows, + OpenIdConnectSecurityScheme, + Role, + SecurityRequirement, + SecurityScheme, + StringList, +) from a2a.utils import AGENT_CARD_WELL_KNOWN_PATH @@ -388,3 +410,680 @@ async def test_get_agent_card_with_signature_verifier( ) mock_verifier.assert_called_once_with(agent_card) + + +class TestParseAgentCard: + """Tests for parse_agent_card function.""" + + @staticmethod + def _assert_agent_card_diff( + original_data: dict, serialized_data: dict + ) -> None: + """Helper to assert that the re-serialized 1.0.0 JSON payload contains all original 0.3.0 data (no dropped fields).""" + original_json_str = json.dumps(original_data, indent=2, sort_keys=True) + serialized_json_str = json.dumps( + serialized_data, indent=2, sort_keys=True + ) + + diff_lines = list( + difflib.unified_diff( + original_json_str.splitlines(), + serialized_json_str.splitlines(), + lineterm='', + ) + ) + + removed_lines = [] + for line in diff_lines: + if line.startswith('-') and not line.startswith('---'): + removed_lines.append(line) + + if removed_lines: + error_msg = ( + 'Re-serialization dropped fields from the original payload:\n' + + '\n'.join(removed_lines) + ) + raise AssertionError(error_msg) + + def test_parse_agent_card_legacy_support(self) -> None: + data = { + 'name': 'Legacy Agent', + 'description': 'Legacy Description', + 'version': '1.0', + 'supportsAuthenticatedExtendedCard': True, + } + card = parse_agent_card(data) + assert card.name == 'Legacy Agent' + assert card.capabilities.extended_agent_card is True + # Ensure it's popped from the dict + assert 'supportsAuthenticatedExtendedCard' not in data + + def test_parse_agent_card_new_support(self) -> None: + data = { + 'name': 'New Agent', + 'description': 'New Description', + 'version': '1.0', + 'capabilities': {'extendedAgentCard': True}, + } + card = parse_agent_card(data) + assert card.name == 'New Agent' + assert card.capabilities.extended_agent_card is True + + def test_parse_agent_card_no_support(self) -> None: + data = { + 'name': 'No Support Agent', + 'description': 'No Support Description', + 'version': '1.0', + 'capabilities': {'extendedAgentCard': False}, + } + card = parse_agent_card(data) + assert card.name == 'No Support Agent' + assert card.capabilities.extended_agent_card is False + + def test_parse_agent_card_both_legacy_and_new(self) -> None: + data = { + 'name': 'Mixed Agent', + 'description': 'Mixed Description', + 'version': '1.0', + 'supportsAuthenticatedExtendedCard': True, + 'capabilities': {'streaming': True}, + } + card = parse_agent_card(data) + assert card.name == 'Mixed Agent' + assert card.capabilities.streaming is True + assert card.capabilities.extended_agent_card is True + + def test_parse_typical_030_agent_card(self) -> None: + data = { + 'additionalInterfaces': [ + { + 'transport': 'GRPC', + 'url': 'http://agent.example.com/api/grpc', + } + ], + 'capabilities': {'streaming': True}, + 'defaultInputModes': ['text/plain'], + 'defaultOutputModes': ['application/json'], + 'description': 'A typical agent from 0.3.0', + 'name': 'Typical Agent 0.3', + 'preferredTransport': 'JSONRPC', + 'protocolVersion': '0.3.0', + 'security': [{'test_oauth': ['read', 'write']}], + 'securitySchemes': { + 'test_oauth': { + 'description': 'OAuth2 authentication', + 'flows': { + 'authorizationCode': { + 'authorizationUrl': 'http://auth.example.com', + 'scopes': { + 'read': 'Read access', + 'write': 'Write access', + }, + 'tokenUrl': 'http://token.example.com', + } + }, + 'type': 'oauth2', + } + }, + 'skills': [ + { + 'description': 'The first skill', + 'id': 'skill-1', + 'name': 'Skill 1', + 'security': [{'test_oauth': ['read']}], + 'tags': ['example'], + } + ], + 'supportsAuthenticatedExtendedCard': True, + 'url': 'http://agent.example.com/api', + 'version': '1.0', + } + original_data = copy.deepcopy(data) + card = parse_agent_card(data) + + expected_card = AgentCard( + name='Typical Agent 0.3', + description='A typical agent from 0.3.0', + version='1.0', + capabilities=AgentCapabilities( + extended_agent_card=True, streaming=True + ), + default_input_modes=['text/plain'], + default_output_modes=['application/json'], + supported_interfaces=[ + AgentInterface( + url='http://agent.example.com/api', + protocol_binding='JSONRPC', + protocol_version='0.3.0', + ), + AgentInterface( + url='http://agent.example.com/api/grpc', + protocol_binding='GRPC', + protocol_version='0.3.0', + ), + ], + security_requirements=[ + SecurityRequirement( + schemes={'test_oauth': StringList(list=['read', 'write'])} + ) + ], + security_schemes={ + 'test_oauth': SecurityScheme( + oauth2_security_scheme=OAuth2SecurityScheme( + description='OAuth2 authentication', + flows=OAuthFlows( + authorization_code=AuthorizationCodeOAuthFlow( + authorization_url='http://auth.example.com', + token_url='http://token.example.com', + scopes={ + 'read': 'Read access', + 'write': 'Write access', + }, + ) + ), + ) + ) + }, + skills=[ + AgentSkill( + id='skill-1', + name='Skill 1', + description='The first skill', + tags=['example'], + security_requirements=[ + SecurityRequirement( + schemes={'test_oauth': StringList(list=['read'])} + ) + ], + ) + ], + ) + + assert card == expected_card + + # Serialize back to JSON and compare + serialized_data = agent_card_to_dict(card) + + self._assert_agent_card_diff(original_data, serialized_data) + assert 'preferredTransport' in serialized_data + + # Re-parse from the serialized payload and verify identical to original parsing + re_parsed_card = parse_agent_card(copy.deepcopy(serialized_data)) + assert re_parsed_card == card + + def test_parse_agent_card_security_scheme_without_in(self) -> None: + data = { + 'name': 'API Key Agent', + 'description': 'API Key without in param', + 'version': '1.0', + 'securitySchemes': { + 'test_api_key': {'type': 'apiKey', 'name': 'X-API-KEY'} + }, + } + card = parse_agent_card(data) + assert 'test_api_key' in card.security_schemes + assert ( + card.security_schemes['test_api_key'].api_key_security_scheme.name + == 'X-API-KEY' + ) + assert ( + card.security_schemes[ + 'test_api_key' + ].api_key_security_scheme.location + == '' + ) + + def test_parse_agent_card_security_scheme_unknown_type(self) -> None: + data = { + 'name': 'Unknown Scheme Agent', + 'description': 'Has unknown scheme type', + 'version': '1.0', + 'securitySchemes': { + 'test_unknown': { + 'type': 'someFutureType', + 'future_prop': 'value', + }, + 'test_missing_type': {'prop': 'value'}, + }, + } + card = parse_agent_card(data) + assert 'test_unknown' in card.security_schemes + assert not card.security_schemes['test_unknown'].WhichOneof('scheme') + + assert 'test_missing_type' in card.security_schemes + assert not card.security_schemes['test_missing_type'].WhichOneof( + 'scheme' + ) + + def test_parse_030_agent_card_route_planner(self) -> None: + data = { + 'protocolVersion': '0.3', + 'name': 'GeoSpatial Route Planner Agent', + 'description': 'Provides advanced route planning.', + 'url': 'https://georoute-agent.example.com/a2a/v1', + 'preferredTransport': 'JSONRPC', + 'additionalInterfaces': [ + { + 'url': 'https://georoute-agent.example.com/a2a/v1', + 'transport': 'JSONRPC', + }, + { + 'url': 'https://georoute-agent.example.com/a2a/grpc', + 'transport': 'GRPC', + }, + { + 'url': 'https://georoute-agent.example.com/a2a/json', + 'transport': 'HTTP+JSON', + }, + ], + 'provider': { + 'organization': 'Example Geo Services Inc.', + 'url': 'https://www.examplegeoservices.com', + }, + 'iconUrl': 'https://georoute-agent.example.com/icon.png', + 'version': '1.2.0', + 'documentationUrl': 'https://docs.examplegeoservices.com/georoute-agent/api', + 'supportsAuthenticatedExtendedCard': True, + 'capabilities': { + 'streaming': True, + 'pushNotifications': True, + 'stateTransitionHistory': False, + }, + 'securitySchemes': { + 'google': { + 'type': 'openIdConnect', + 'openIdConnectUrl': 'https://accounts.google.com/.well-known/openid-configuration', + } + }, + 'security': [{'google': ['openid', 'profile', 'email']}], + 'defaultInputModes': ['application/json', 'text/plain'], + 'defaultOutputModes': ['application/json', 'image/png'], + 'skills': [ + { + 'id': 'route-optimizer-traffic', + 'name': 'Traffic-Aware Route Optimizer', + 'description': 'Calculates the optimal driving route between two or more locations, taking into account real-time traffic conditions, road closures, and user preferences (e.g., avoid tolls, prefer highways).', + 'tags': [ + 'maps', + 'routing', + 'navigation', + 'directions', + 'traffic', + ], + 'examples': [ + "Plan a route from '1600 Amphitheatre Parkway, Mountain View, CA' to 'San Francisco International Airport' avoiding tolls.", + '{"origin": {"lat": 37.422, "lng": -122.084}, "destination": {"lat": 37.7749, "lng": -122.4194}, "preferences": ["avoid_ferries"]}', + ], + 'inputModes': ['application/json', 'text/plain'], + 'outputModes': [ + 'application/json', + 'application/vnd.geo+json', + 'text/html', + ], + 'security': [ + {'example': []}, + {'google': ['openid', 'profile', 'email']}, + ], + }, + { + 'id': 'custom-map-generator', + 'name': 'Personalized Map Generator', + 'description': 'Creates custom map images or interactive map views based on user-defined points of interest, routes, and style preferences. Can overlay data layers.', + 'tags': [ + 'maps', + 'customization', + 'visualization', + 'cartography', + ], + 'examples': [ + 'Generate a map of my upcoming road trip with all planned stops highlighted.', + 'Show me a map visualizing all coffee shops within a 1-mile radius of my current location.', + ], + 'inputModes': ['application/json'], + 'outputModes': [ + 'image/png', + 'image/jpeg', + 'application/json', + 'text/html', + ], + }, + ], + 'signatures': [ + { + 'protected': 'eyJhbGciOiJFUzI1NiIsInR5cCI6IkpPU0UiLCJraWQiOiJrZXktMSIsImprdSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYWdlbnQvandrcy5qc29uIn0', + 'signature': 'QFdkNLNszlGj3z3u0YQGt_T9LixY3qtdQpZmsTdDHDe3fXV9y9-B3m2-XgCpzuhiLt8E0tV6HXoZKHv4GtHgKQ', + } + ], + } + + original_data = copy.deepcopy(data) + card = parse_agent_card(data) + + expected_card = AgentCard( + name='GeoSpatial Route Planner Agent', + description='Provides advanced route planning.', + version='1.2.0', + documentation_url='https://docs.examplegeoservices.com/georoute-agent/api', + icon_url='https://georoute-agent.example.com/icon.png', + provider=AgentProvider( + organization='Example Geo Services Inc.', + url='https://www.examplegeoservices.com', + ), + capabilities=AgentCapabilities( + extended_agent_card=True, + streaming=True, + push_notifications=True, + ), + default_input_modes=['application/json', 'text/plain'], + default_output_modes=['application/json', 'image/png'], + supported_interfaces=[ + AgentInterface( + url='https://georoute-agent.example.com/a2a/v1', + protocol_binding='JSONRPC', + protocol_version='0.3', + ), + AgentInterface( + url='https://georoute-agent.example.com/a2a/v1', + protocol_binding='JSONRPC', + protocol_version='0.3', + ), + AgentInterface( + url='https://georoute-agent.example.com/a2a/grpc', + protocol_binding='GRPC', + protocol_version='0.3', + ), + AgentInterface( + url='https://georoute-agent.example.com/a2a/json', + protocol_binding='HTTP+JSON', + protocol_version='0.3', + ), + ], + security_requirements=[ + SecurityRequirement( + schemes={ + 'google': StringList( + list=['openid', 'profile', 'email'] + ) + } + ) + ], + security_schemes={ + 'google': SecurityScheme( + open_id_connect_security_scheme=OpenIdConnectSecurityScheme( + open_id_connect_url='https://accounts.google.com/.well-known/openid-configuration' + ) + ) + }, + skills=[ + AgentSkill( + id='route-optimizer-traffic', + name='Traffic-Aware Route Optimizer', + description='Calculates the optimal driving route between two or more locations, taking into account real-time traffic conditions, road closures, and user preferences (e.g., avoid tolls, prefer highways).', + tags=[ + 'maps', + 'routing', + 'navigation', + 'directions', + 'traffic', + ], + examples=[ + "Plan a route from '1600 Amphitheatre Parkway, Mountain View, CA' to 'San Francisco International Airport' avoiding tolls.", + '{"origin": {"lat": 37.422, "lng": -122.084}, "destination": {"lat": 37.7749, "lng": -122.4194}, "preferences": ["avoid_ferries"]}', + ], + input_modes=['application/json', 'text/plain'], + output_modes=[ + 'application/json', + 'application/vnd.geo+json', + 'text/html', + ], + security_requirements=[ + SecurityRequirement(schemes={'example': StringList()}), + SecurityRequirement( + schemes={ + 'google': StringList( + list=['openid', 'profile', 'email'] + ) + } + ), + ], + ), + AgentSkill( + id='custom-map-generator', + name='Personalized Map Generator', + description='Creates custom map images or interactive map views based on user-defined points of interest, routes, and style preferences. Can overlay data layers.', + tags=[ + 'maps', + 'customization', + 'visualization', + 'cartography', + ], + examples=[ + 'Generate a map of my upcoming road trip with all planned stops highlighted.', + 'Show me a map visualizing all coffee shops within a 1-mile radius of my current location.', + ], + input_modes=['application/json'], + output_modes=[ + 'image/png', + 'image/jpeg', + 'application/json', + 'text/html', + ], + ), + ], + signatures=[ + AgentCardSignature( + protected='eyJhbGciOiJFUzI1NiIsInR5cCI6IkpPU0UiLCJraWQiOiJrZXktMSIsImprdSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYWdlbnQvandrcy5qc29uIn0', + signature='QFdkNLNszlGj3z3u0YQGt_T9LixY3qtdQpZmsTdDHDe3fXV9y9-B3m2-XgCpzuhiLt8E0tV6HXoZKHv4GtHgKQ', + ) + ], + ) + + assert card == expected_card + serialized_data = agent_card_to_dict(card) + del original_data['capabilities']['stateTransitionHistory'] + self._assert_agent_card_diff(original_data, serialized_data) + re_parsed_card = parse_agent_card(copy.deepcopy(serialized_data)) + assert re_parsed_card == card + + def test_parse_complex_030_agent_card(self) -> None: + data = { + 'additionalInterfaces': [ + { + 'transport': 'GRPC', + 'url': 'http://complex.agent.example.com/grpc', + }, + { + 'transport': 'JSONRPC', + 'url': 'http://complex.agent.example.com/jsonrpc', + }, + ], + 'capabilities': {'pushNotifications': True, 'streaming': True}, + 'defaultInputModes': ['text/plain', 'application/json'], + 'defaultOutputModes': ['application/json', 'image/png'], + 'description': 'A very complex agent from 0.3.0', + 'name': 'Complex Agent 0.3', + 'preferredTransport': 'HTTP+JSON', + 'protocolVersion': '0.3.0', + 'security': [ + {'test_oauth': ['read', 'write'], 'test_api_key': []}, + {'test_http': []}, + {'test_oidc': ['openid', 'profile']}, + {'test_mtls': []}, + ], + 'securitySchemes': { + 'test_oauth': { + 'description': 'OAuth2 authentication', + 'flows': { + 'authorizationCode': { + 'authorizationUrl': 'http://auth.example.com', + 'scopes': { + 'read': 'Read access', + 'write': 'Write access', + }, + 'tokenUrl': 'http://token.example.com', + } + }, + 'type': 'oauth2', + }, + 'test_api_key': { + 'description': 'API Key auth', + 'in': 'header', + 'name': 'X-API-KEY', + 'type': 'apiKey', + }, + 'test_http': { + 'bearerFormat': 'JWT', + 'description': 'HTTP Basic auth', + 'scheme': 'basic', + 'type': 'http', + }, + 'test_oidc': { + 'description': 'OIDC Auth', + 'openIdConnectUrl': 'https://example.com/.well-known/openid-configuration', + 'type': 'openIdConnect', + }, + 'test_mtls': {'description': 'mTLS Auth', 'type': 'mutualTLS'}, + }, + 'skills': [ + { + 'description': 'The first complex skill', + 'id': 'skill-1', + 'inputModes': ['application/json'], + 'name': 'Complex Skill 1', + 'outputModes': ['application/json'], + 'security': [{'test_api_key': []}], + 'tags': ['example', 'complex'], + }, + { + 'description': 'The second complex skill', + 'id': 'skill-2', + 'name': 'Complex Skill 2', + 'security': [{'test_oidc': ['openid']}], + 'tags': ['example2'], + }, + ], + 'supportsAuthenticatedExtendedCard': True, + 'url': 'http://complex.agent.example.com/api', + 'version': '1.5.2', + } + original_data = copy.deepcopy(data) + card = parse_agent_card(data) + + expected_card = AgentCard( + name='Complex Agent 0.3', + description='A very complex agent from 0.3.0', + version='1.5.2', + capabilities=AgentCapabilities( + extended_agent_card=True, + streaming=True, + push_notifications=True, + ), + default_input_modes=['text/plain', 'application/json'], + default_output_modes=['application/json', 'image/png'], + supported_interfaces=[ + AgentInterface( + url='http://complex.agent.example.com/api', + protocol_binding='HTTP+JSON', + protocol_version='0.3.0', + ), + AgentInterface( + url='http://complex.agent.example.com/grpc', + protocol_binding='GRPC', + protocol_version='0.3.0', + ), + AgentInterface( + url='http://complex.agent.example.com/jsonrpc', + protocol_binding='JSONRPC', + protocol_version='0.3.0', + ), + ], + security_requirements=[ + SecurityRequirement( + schemes={ + 'test_oauth': StringList(list=['read', 'write']), + 'test_api_key': StringList(), + } + ), + SecurityRequirement(schemes={'test_http': StringList()}), + SecurityRequirement( + schemes={ + 'test_oidc': StringList(list=['openid', 'profile']) + } + ), + SecurityRequirement(schemes={'test_mtls': StringList()}), + ], + security_schemes={ + 'test_oauth': SecurityScheme( + oauth2_security_scheme=OAuth2SecurityScheme( + description='OAuth2 authentication', + flows=OAuthFlows( + authorization_code=AuthorizationCodeOAuthFlow( + authorization_url='http://auth.example.com', + token_url='http://token.example.com', + scopes={ + 'read': 'Read access', + 'write': 'Write access', + }, + ) + ), + ) + ), + 'test_api_key': SecurityScheme( + api_key_security_scheme=APIKeySecurityScheme( + description='API Key auth', + location='header', + name='X-API-KEY', + ) + ), + 'test_http': SecurityScheme( + http_auth_security_scheme=HTTPAuthSecurityScheme( + description='HTTP Basic auth', + scheme='basic', + bearer_format='JWT', + ) + ), + 'test_oidc': SecurityScheme( + open_id_connect_security_scheme=OpenIdConnectSecurityScheme( + description='OIDC Auth', + open_id_connect_url='https://example.com/.well-known/openid-configuration', + ) + ), + 'test_mtls': SecurityScheme( + mtls_security_scheme=MutualTlsSecurityScheme( + description='mTLS Auth' + ) + ), + }, + skills=[ + AgentSkill( + id='skill-1', + name='Complex Skill 1', + description='The first complex skill', + tags=['example', 'complex'], + input_modes=['application/json'], + output_modes=['application/json'], + security_requirements=[ + SecurityRequirement( + schemes={'test_api_key': StringList()} + ) + ], + ), + AgentSkill( + id='skill-2', + name='Complex Skill 2', + description='The second complex skill', + tags=['example2'], + security_requirements=[ + SecurityRequirement( + schemes={'test_oidc': StringList(list=['openid'])} + ) + ], + ), + ], + ) + + assert card == expected_card + serialized_data = agent_card_to_dict(card) + self._assert_agent_card_diff(original_data, serialized_data) + re_parsed_card = parse_agent_card(copy.deepcopy(serialized_data)) + assert re_parsed_card == card diff --git a/tests/client/test_client_helpers.py b/tests/client/test_client_helpers.py deleted file mode 100644 index 0eb394f43..000000000 --- a/tests/client/test_client_helpers.py +++ /dev/null @@ -1,696 +0,0 @@ -import copy -import difflib -import json -from google.protobuf.json_format import MessageToDict - -from a2a.client.helpers import parse_agent_card -from a2a.helpers.proto_helpers import new_text_message -from a2a.server.request_handlers.response_helpers import agent_card_to_dict -from a2a.types.a2a_pb2 import ( - APIKeySecurityScheme, - AgentCapabilities, - AgentCard, - AgentCardSignature, - AgentInterface, - AgentProvider, - AgentSkill, - AuthorizationCodeOAuthFlow, - HTTPAuthSecurityScheme, - MutualTlsSecurityScheme, - OAuth2SecurityScheme, - OAuthFlows, - OpenIdConnectSecurityScheme, - Role, - SecurityRequirement, - SecurityScheme, - StringList, -) - - -def test_parse_agent_card_legacy_support() -> None: - data = { - 'name': 'Legacy Agent', - 'description': 'Legacy Description', - 'version': '1.0', - 'supportsAuthenticatedExtendedCard': True, - } - card = parse_agent_card(data) - assert card.name == 'Legacy Agent' - assert card.capabilities.extended_agent_card is True - # Ensure it's popped from the dict - assert 'supportsAuthenticatedExtendedCard' not in data - - -def test_parse_agent_card_new_support() -> None: - data = { - 'name': 'New Agent', - 'description': 'New Description', - 'version': '1.0', - 'capabilities': {'extendedAgentCard': True}, - } - card = parse_agent_card(data) - assert card.name == 'New Agent' - assert card.capabilities.extended_agent_card is True - - -def test_parse_agent_card_no_support() -> None: - data = { - 'name': 'No Support Agent', - 'description': 'No Support Description', - 'version': '1.0', - 'capabilities': {'extendedAgentCard': False}, - } - card = parse_agent_card(data) - assert card.name == 'No Support Agent' - assert card.capabilities.extended_agent_card is False - - -def test_parse_agent_card_both_legacy_and_new() -> None: - data = { - 'name': 'Mixed Agent', - 'description': 'Mixed Description', - 'version': '1.0', - 'supportsAuthenticatedExtendedCard': True, - 'capabilities': {'streaming': True}, - } - card = parse_agent_card(data) - assert card.name == 'Mixed Agent' - assert card.capabilities.streaming is True - assert card.capabilities.extended_agent_card is True - - -def _assert_agent_card_diff(original_data: dict, serialized_data: dict) -> None: - """Helper to assert that the re-serialized 1.0.0 JSON payload contains all original 0.3.0 data (no dropped fields).""" - original_json_str = json.dumps(original_data, indent=2, sort_keys=True) - serialized_json_str = json.dumps(serialized_data, indent=2, sort_keys=True) - - diff_lines = list( - difflib.unified_diff( - original_json_str.splitlines(), - serialized_json_str.splitlines(), - lineterm='', - ) - ) - - removed_lines = [] - for line in diff_lines: - if line.startswith('-') and not line.startswith('---'): - removed_lines.append(line) - - if removed_lines: - error_msg = ( - 'Re-serialization dropped fields from the original payload:\n' - + '\n'.join(removed_lines) - ) - raise AssertionError(error_msg) - - -def test_parse_typical_030_agent_card() -> None: - data = { - 'additionalInterfaces': [ - {'transport': 'GRPC', 'url': 'http://agent.example.com/api/grpc'} - ], - 'capabilities': {'streaming': True}, - 'defaultInputModes': ['text/plain'], - 'defaultOutputModes': ['application/json'], - 'description': 'A typical agent from 0.3.0', - 'name': 'Typical Agent 0.3', - 'preferredTransport': 'JSONRPC', - 'protocolVersion': '0.3.0', - 'security': [{'test_oauth': ['read', 'write']}], - 'securitySchemes': { - 'test_oauth': { - 'description': 'OAuth2 authentication', - 'flows': { - 'authorizationCode': { - 'authorizationUrl': 'http://auth.example.com', - 'scopes': { - 'read': 'Read access', - 'write': 'Write access', - }, - 'tokenUrl': 'http://token.example.com', - } - }, - 'type': 'oauth2', - } - }, - 'skills': [ - { - 'description': 'The first skill', - 'id': 'skill-1', - 'name': 'Skill 1', - 'security': [{'test_oauth': ['read']}], - 'tags': ['example'], - } - ], - 'supportsAuthenticatedExtendedCard': True, - 'url': 'http://agent.example.com/api', - 'version': '1.0', - } - original_data = copy.deepcopy(data) - card = parse_agent_card(data) - - expected_card = AgentCard( - name='Typical Agent 0.3', - description='A typical agent from 0.3.0', - version='1.0', - capabilities=AgentCapabilities( - extended_agent_card=True, streaming=True - ), - default_input_modes=['text/plain'], - default_output_modes=['application/json'], - supported_interfaces=[ - AgentInterface( - url='http://agent.example.com/api', - protocol_binding='JSONRPC', - protocol_version='0.3.0', - ), - AgentInterface( - url='http://agent.example.com/api/grpc', - protocol_binding='GRPC', - protocol_version='0.3.0', - ), - ], - security_requirements=[ - SecurityRequirement( - schemes={'test_oauth': StringList(list=['read', 'write'])} - ) - ], - security_schemes={ - 'test_oauth': SecurityScheme( - oauth2_security_scheme=OAuth2SecurityScheme( - description='OAuth2 authentication', - flows=OAuthFlows( - authorization_code=AuthorizationCodeOAuthFlow( - authorization_url='http://auth.example.com', - token_url='http://token.example.com', - scopes={ - 'read': 'Read access', - 'write': 'Write access', - }, - ) - ), - ) - ) - }, - skills=[ - AgentSkill( - id='skill-1', - name='Skill 1', - description='The first skill', - tags=['example'], - security_requirements=[ - SecurityRequirement( - schemes={'test_oauth': StringList(list=['read'])} - ) - ], - ) - ], - ) - - assert card == expected_card - - # Serialize back to JSON and compare - serialized_data = agent_card_to_dict(card) - - _assert_agent_card_diff(original_data, serialized_data) - assert 'preferredTransport' in serialized_data - - # Re-parse from the serialized payload and verify identical to original parsing - re_parsed_card = parse_agent_card(copy.deepcopy(serialized_data)) - assert re_parsed_card == card - - -def test_parse_agent_card_security_scheme_without_in() -> None: - data = { - 'name': 'API Key Agent', - 'description': 'API Key without in param', - 'version': '1.0', - 'securitySchemes': { - 'test_api_key': {'type': 'apiKey', 'name': 'X-API-KEY'} - }, - } - card = parse_agent_card(data) - assert 'test_api_key' in card.security_schemes - assert ( - card.security_schemes['test_api_key'].api_key_security_scheme.name - == 'X-API-KEY' - ) - assert ( - card.security_schemes['test_api_key'].api_key_security_scheme.location - == '' - ) - - -def test_parse_agent_card_security_scheme_unknown_type() -> None: - data = { - 'name': 'Unknown Scheme Agent', - 'description': 'Has unknown scheme type', - 'version': '1.0', - 'securitySchemes': { - 'test_unknown': {'type': 'someFutureType', 'future_prop': 'value'}, - 'test_missing_type': {'prop': 'value'}, - }, - } - card = parse_agent_card(data) - # the ParseDict ignore_unknown_fields=True handles the unknown fields. - # Because there is no mapping logic for 'someFutureType', the Protobuf - # creates an empty SecurityScheme message under those keys. - assert 'test_unknown' in card.security_schemes - assert not card.security_schemes['test_unknown'].WhichOneof('scheme') - - assert 'test_missing_type' in card.security_schemes - assert not card.security_schemes['test_missing_type'].WhichOneof('scheme') - - -def test_create_text_message_object() -> None: - msg = new_text_message(text='Hello', role=Role.ROLE_AGENT) - assert msg.role == Role.ROLE_AGENT - assert len(msg.parts) == 1 - assert msg.parts[0].text == 'Hello' - assert msg.message_id != '' - - -def test_parse_030_agent_card_route_planner() -> None: - data = { - 'protocolVersion': '0.3', - 'name': 'GeoSpatial Route Planner Agent', - 'description': 'Provides advanced route planning.', - 'url': 'https://georoute-agent.example.com/a2a/v1', - 'preferredTransport': 'JSONRPC', - 'additionalInterfaces': [ - { - 'url': 'https://georoute-agent.example.com/a2a/v1', - 'transport': 'JSONRPC', - }, - { - 'url': 'https://georoute-agent.example.com/a2a/grpc', - 'transport': 'GRPC', - }, - { - 'url': 'https://georoute-agent.example.com/a2a/json', - 'transport': 'HTTP+JSON', - }, - ], - 'provider': { - 'organization': 'Example Geo Services Inc.', - 'url': 'https://www.examplegeoservices.com', - }, - 'iconUrl': 'https://georoute-agent.example.com/icon.png', - 'version': '1.2.0', - 'documentationUrl': 'https://docs.examplegeoservices.com/georoute-agent/api', - 'supportsAuthenticatedExtendedCard': True, - 'capabilities': { - 'streaming': True, - 'pushNotifications': True, - 'stateTransitionHistory': False, - }, - 'securitySchemes': { - 'google': { - 'type': 'openIdConnect', - 'openIdConnectUrl': 'https://accounts.google.com/.well-known/openid-configuration', - } - }, - 'security': [{'google': ['openid', 'profile', 'email']}], - 'defaultInputModes': ['application/json', 'text/plain'], - 'defaultOutputModes': ['application/json', 'image/png'], - 'skills': [ - { - 'id': 'route-optimizer-traffic', - 'name': 'Traffic-Aware Route Optimizer', - 'description': 'Calculates the optimal driving route between two or more locations, taking into account real-time traffic conditions, road closures, and user preferences (e.g., avoid tolls, prefer highways).', - 'tags': [ - 'maps', - 'routing', - 'navigation', - 'directions', - 'traffic', - ], - 'examples': [ - "Plan a route from '1600 Amphitheatre Parkway, Mountain View, CA' to 'San Francisco International Airport' avoiding tolls.", - '{"origin": {"lat": 37.422, "lng": -122.084}, "destination": {"lat": 37.7749, "lng": -122.4194}, "preferences": ["avoid_ferries"]}', - ], - 'inputModes': ['application/json', 'text/plain'], - 'outputModes': [ - 'application/json', - 'application/vnd.geo+json', - 'text/html', - ], - 'security': [ - {'example': []}, - {'google': ['openid', 'profile', 'email']}, - ], - }, - { - 'id': 'custom-map-generator', - 'name': 'Personalized Map Generator', - 'description': 'Creates custom map images or interactive map views based on user-defined points of interest, routes, and style preferences. Can overlay data layers.', - 'tags': [ - 'maps', - 'customization', - 'visualization', - 'cartography', - ], - 'examples': [ - 'Generate a map of my upcoming road trip with all planned stops highlighted.', - 'Show me a map visualizing all coffee shops within a 1-mile radius of my current location.', - ], - 'inputModes': ['application/json'], - 'outputModes': [ - 'image/png', - 'image/jpeg', - 'application/json', - 'text/html', - ], - }, - ], - 'signatures': [ - { - 'protected': 'eyJhbGciOiJFUzI1NiIsInR5cCI6IkpPU0UiLCJraWQiOiJrZXktMSIsImprdSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYWdlbnQvandrcy5qc29uIn0', - 'signature': 'QFdkNLNszlGj3z3u0YQGt_T9LixY3qtdQpZmsTdDHDe3fXV9y9-B3m2-XgCpzuhiLt8E0tV6HXoZKHv4GtHgKQ', - } - ], - } - - original_data = copy.deepcopy(data) - card = parse_agent_card(data) - - expected_card = AgentCard( - name='GeoSpatial Route Planner Agent', - description='Provides advanced route planning.', - version='1.2.0', - documentation_url='https://docs.examplegeoservices.com/georoute-agent/api', - icon_url='https://georoute-agent.example.com/icon.png', - provider=AgentProvider( - organization='Example Geo Services Inc.', - url='https://www.examplegeoservices.com', - ), - capabilities=AgentCapabilities( - extended_agent_card=True, streaming=True, push_notifications=True - ), - default_input_modes=['application/json', 'text/plain'], - default_output_modes=['application/json', 'image/png'], - supported_interfaces=[ - AgentInterface( - url='https://georoute-agent.example.com/a2a/v1', - protocol_binding='JSONRPC', - protocol_version='0.3', - ), - AgentInterface( - url='https://georoute-agent.example.com/a2a/v1', - protocol_binding='JSONRPC', - protocol_version='0.3', - ), - AgentInterface( - url='https://georoute-agent.example.com/a2a/grpc', - protocol_binding='GRPC', - protocol_version='0.3', - ), - AgentInterface( - url='https://georoute-agent.example.com/a2a/json', - protocol_binding='HTTP+JSON', - protocol_version='0.3', - ), - ], - security_requirements=[ - SecurityRequirement( - schemes={ - 'google': StringList(list=['openid', 'profile', 'email']) - } - ) - ], - security_schemes={ - 'google': SecurityScheme( - open_id_connect_security_scheme=OpenIdConnectSecurityScheme( - open_id_connect_url='https://accounts.google.com/.well-known/openid-configuration' - ) - ) - }, - skills=[ - AgentSkill( - id='route-optimizer-traffic', - name='Traffic-Aware Route Optimizer', - description='Calculates the optimal driving route between two or more locations, taking into account real-time traffic conditions, road closures, and user preferences (e.g., avoid tolls, prefer highways).', - tags=['maps', 'routing', 'navigation', 'directions', 'traffic'], - examples=[ - "Plan a route from '1600 Amphitheatre Parkway, Mountain View, CA' to 'San Francisco International Airport' avoiding tolls.", - '{"origin": {"lat": 37.422, "lng": -122.084}, "destination": {"lat": 37.7749, "lng": -122.4194}, "preferences": ["avoid_ferries"]}', - ], - input_modes=['application/json', 'text/plain'], - output_modes=[ - 'application/json', - 'application/vnd.geo+json', - 'text/html', - ], - security_requirements=[ - SecurityRequirement(schemes={'example': StringList()}), - SecurityRequirement( - schemes={ - 'google': StringList( - list=['openid', 'profile', 'email'] - ) - } - ), - ], - ), - AgentSkill( - id='custom-map-generator', - name='Personalized Map Generator', - description='Creates custom map images or interactive map views based on user-defined points of interest, routes, and style preferences. Can overlay data layers.', - tags=['maps', 'customization', 'visualization', 'cartography'], - examples=[ - 'Generate a map of my upcoming road trip with all planned stops highlighted.', - 'Show me a map visualizing all coffee shops within a 1-mile radius of my current location.', - ], - input_modes=['application/json'], - output_modes=[ - 'image/png', - 'image/jpeg', - 'application/json', - 'text/html', - ], - ), - ], - signatures=[ - AgentCardSignature( - protected='eyJhbGciOiJFUzI1NiIsInR5cCI6IkpPU0UiLCJraWQiOiJrZXktMSIsImprdSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYWdlbnQvandrcy5qc29uIn0', - signature='QFdkNLNszlGj3z3u0YQGt_T9LixY3qtdQpZmsTdDHDe3fXV9y9-B3m2-XgCpzuhiLt8E0tV6HXoZKHv4GtHgKQ', - ) - ], - ) - - assert card == expected_card - - # Serialize back to JSON and compare - serialized_data = agent_card_to_dict(card) - - # Remove deprecated stateTransitionHistory before diffing - del original_data['capabilities']['stateTransitionHistory'] - - _assert_agent_card_diff(original_data, serialized_data) - - # Re-parse from the serialized payload and verify identical to original parsing - re_parsed_card = parse_agent_card(copy.deepcopy(serialized_data)) - assert re_parsed_card == card - - -def test_parse_complex_030_agent_card() -> None: - data = { - 'additionalInterfaces': [ - { - 'transport': 'GRPC', - 'url': 'http://complex.agent.example.com/grpc', - }, - { - 'transport': 'JSONRPC', - 'url': 'http://complex.agent.example.com/jsonrpc', - }, - ], - 'capabilities': {'pushNotifications': True, 'streaming': True}, - 'defaultInputModes': ['text/plain', 'application/json'], - 'defaultOutputModes': ['application/json', 'image/png'], - 'description': 'A very complex agent from 0.3.0', - 'name': 'Complex Agent 0.3', - 'preferredTransport': 'HTTP+JSON', - 'protocolVersion': '0.3.0', - 'security': [ - {'test_oauth': ['read', 'write'], 'test_api_key': []}, - {'test_http': []}, - {'test_oidc': ['openid', 'profile']}, - {'test_mtls': []}, - ], - 'securitySchemes': { - 'test_oauth': { - 'description': 'OAuth2 authentication', - 'flows': { - 'authorizationCode': { - 'authorizationUrl': 'http://auth.example.com', - 'scopes': { - 'read': 'Read access', - 'write': 'Write access', - }, - 'tokenUrl': 'http://token.example.com', - } - }, - 'type': 'oauth2', - }, - 'test_api_key': { - 'description': 'API Key auth', - 'in': 'header', - 'name': 'X-API-KEY', - 'type': 'apiKey', - }, - 'test_http': { - 'bearerFormat': 'JWT', - 'description': 'HTTP Basic auth', - 'scheme': 'basic', - 'type': 'http', - }, - 'test_oidc': { - 'description': 'OIDC Auth', - 'openIdConnectUrl': 'https://example.com/.well-known/openid-configuration', - 'type': 'openIdConnect', - }, - 'test_mtls': {'description': 'mTLS Auth', 'type': 'mutualTLS'}, - }, - 'skills': [ - { - 'description': 'The first complex skill', - 'id': 'skill-1', - 'inputModes': ['application/json'], - 'name': 'Complex Skill 1', - 'outputModes': ['application/json'], - 'security': [{'test_api_key': []}], - 'tags': ['example', 'complex'], - }, - { - 'description': 'The second complex skill', - 'id': 'skill-2', - 'name': 'Complex Skill 2', - 'security': [{'test_oidc': ['openid']}], - 'tags': ['example2'], - }, - ], - 'supportsAuthenticatedExtendedCard': True, - 'url': 'http://complex.agent.example.com/api', - 'version': '1.5.2', - } - original_data = copy.deepcopy(data) - card = parse_agent_card(data) - - expected_card = AgentCard( - name='Complex Agent 0.3', - description='A very complex agent from 0.3.0', - version='1.5.2', - capabilities=AgentCapabilities( - extended_agent_card=True, streaming=True, push_notifications=True - ), - default_input_modes=['text/plain', 'application/json'], - default_output_modes=['application/json', 'image/png'], - supported_interfaces=[ - AgentInterface( - url='http://complex.agent.example.com/api', - protocol_binding='HTTP+JSON', - protocol_version='0.3.0', - ), - AgentInterface( - url='http://complex.agent.example.com/grpc', - protocol_binding='GRPC', - protocol_version='0.3.0', - ), - AgentInterface( - url='http://complex.agent.example.com/jsonrpc', - protocol_binding='JSONRPC', - protocol_version='0.3.0', - ), - ], - security_requirements=[ - SecurityRequirement( - schemes={ - 'test_oauth': StringList(list=['read', 'write']), - 'test_api_key': StringList(), - } - ), - SecurityRequirement(schemes={'test_http': StringList()}), - SecurityRequirement( - schemes={'test_oidc': StringList(list=['openid', 'profile'])} - ), - SecurityRequirement(schemes={'test_mtls': StringList()}), - ], - security_schemes={ - 'test_oauth': SecurityScheme( - oauth2_security_scheme=OAuth2SecurityScheme( - description='OAuth2 authentication', - flows=OAuthFlows( - authorization_code=AuthorizationCodeOAuthFlow( - authorization_url='http://auth.example.com', - token_url='http://token.example.com', - scopes={ - 'read': 'Read access', - 'write': 'Write access', - }, - ) - ), - ) - ), - 'test_api_key': SecurityScheme( - api_key_security_scheme=APIKeySecurityScheme( - description='API Key auth', - location='header', - name='X-API-KEY', - ) - ), - 'test_http': SecurityScheme( - http_auth_security_scheme=HTTPAuthSecurityScheme( - description='HTTP Basic auth', - scheme='basic', - bearer_format='JWT', - ) - ), - 'test_oidc': SecurityScheme( - open_id_connect_security_scheme=OpenIdConnectSecurityScheme( - description='OIDC Auth', - open_id_connect_url='https://example.com/.well-known/openid-configuration', - ) - ), - 'test_mtls': SecurityScheme( - mtls_security_scheme=MutualTlsSecurityScheme( - description='mTLS Auth' - ) - ), - }, - skills=[ - AgentSkill( - id='skill-1', - name='Complex Skill 1', - description='The first complex skill', - tags=['example', 'complex'], - input_modes=['application/json'], - output_modes=['application/json'], - security_requirements=[ - SecurityRequirement(schemes={'test_api_key': StringList()}) - ], - ), - AgentSkill( - id='skill-2', - name='Complex Skill 2', - description='The second complex skill', - tags=['example2'], - security_requirements=[ - SecurityRequirement( - schemes={'test_oidc': StringList(list=['openid'])} - ) - ], - ), - ], - ) - - assert card == expected_card - - # Serialize back to JSON and compare - serialized_data = agent_card_to_dict(card) - _assert_agent_card_diff(original_data, serialized_data) - - # Re-parse from the serialized payload and verify identical to original parsing - re_parsed_card = parse_agent_card(copy.deepcopy(serialized_data)) - assert re_parsed_card == card diff --git a/tests/helpers/test_proto_helpers.py b/tests/helpers/test_proto_helpers.py index a4f6498ab..1939b92f3 100644 --- a/tests/helpers/test_proto_helpers.py +++ b/tests/helpers/test_proto_helpers.py @@ -52,6 +52,14 @@ def test_new_text_message() -> None: assert msg.message_id != '' +def test_create_text_message_object() -> None: + msg = new_text_message(text='Hello', role=Role.ROLE_AGENT) + assert msg.role == Role.ROLE_AGENT + assert len(msg.parts) == 1 + assert msg.parts[0].text == 'Hello' + assert msg.message_id != '' + + def test_get_message_text() -> None: msg = Message(parts=[Part(text='hello'), Part(text='world')]) assert get_message_text(msg) == 'hello\nworld' diff --git a/tests/integration/cross_version/test_cross_version_card_validation.py b/tests/integration/cross_version/test_cross_version_card_validation.py index 85379c3a3..25972b075 100644 --- a/tests/integration/cross_version/test_cross_version_card_validation.py +++ b/tests/integration/cross_version/test_cross_version_card_validation.py @@ -18,7 +18,7 @@ SecurityScheme, StringList, ) -from a2a.client.helpers import parse_agent_card +from a2a.client.card_resolver import parse_agent_card from google.protobuf.json_format import MessageToDict, ParseDict From 06390e6d4c5f548996196aeb7c864821391edd31 Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Fri, 17 Apr 2026 11:59:16 +0000 Subject: [PATCH 2/6] wip --- docs/ai/ai_learnings.md | 2 - scripts/test_minimal_install.py | 2 +- src/a2a/compat/v0_3/jsonrpc_adapter.py | 2 +- src/a2a/compat/v0_3/rest_handler.py | 4 +- .../default_request_handler.py | 7 +- .../default_request_handler_v2.py | 7 +- src/a2a/server/routes/agent_card_routes.py | 6 +- src/a2a/server/routes/jsonrpc_dispatcher.py | 2 +- src/a2a/server/routes/rest_dispatcher.py | 2 +- .../{helpers.py => version_validator.py} | 10 +- tests/server/tasks/test_task_manager.py | 97 ++++++ tests/utils/test_helpers.py | 312 ------------------ tests/utils/test_signing.py | 108 ++++++ ...lidation.py => test_version_validation.py} | 2 +- 14 files changed, 220 insertions(+), 343 deletions(-) rename src/a2a/utils/{helpers.py => version_validator.py} (94%) delete mode 100644 tests/utils/test_helpers.py rename tests/utils/{test_helpers_validation.py => test_version_validation.py} (98%) diff --git a/docs/ai/ai_learnings.md b/docs/ai/ai_learnings.md index 9e9a37a9f..50b5fb84a 100644 --- a/docs/ai/ai_learnings.md +++ b/docs/ai/ai_learnings.md @@ -15,5 +15,3 @@ derived from them. Every entry must follow the format below. **Mistake**: What went wrong. **Root cause**: Why it happened. **Rule**: The concrete rule added to prevent recurrence. - ---- diff --git a/scripts/test_minimal_install.py b/scripts/test_minimal_install.py index 0b29a48b6..5f607b3a7 100755 --- a/scripts/test_minimal_install.py +++ b/scripts/test_minimal_install.py @@ -52,7 +52,7 @@ 'a2a.utils', 'a2a.utils.constants', 'a2a.utils.error_handlers', - 'a2a.utils.helpers', + 'a2a.utils.version_validator', 'a2a.utils.proto_utils', 'a2a.utils.task', 'a2a.helpers.agent_card', diff --git a/src/a2a/compat/v0_3/jsonrpc_adapter.py b/src/a2a/compat/v0_3/jsonrpc_adapter.py index baa2bcda8..5cf0bd449 100644 --- a/src/a2a/compat/v0_3/jsonrpc_adapter.py +++ b/src/a2a/compat/v0_3/jsonrpc_adapter.py @@ -40,7 +40,7 @@ ServerCallContextBuilder, ) from a2a.utils import constants -from a2a.utils.helpers import validate_version +from a2a.utils.version_validator import validate_version logger = logging.getLogger(__name__) diff --git a/src/a2a/compat/v0_3/rest_handler.py b/src/a2a/compat/v0_3/rest_handler.py index 0c64506cb..bd5fcd2e6 100644 --- a/src/a2a/compat/v0_3/rest_handler.py +++ b/src/a2a/compat/v0_3/rest_handler.py @@ -28,10 +28,8 @@ from a2a.compat.v0_3.request_handler import RequestHandler03 from a2a.server.context import ServerCallContext from a2a.utils import constants -from a2a.utils.helpers import ( - validate_version, -) from a2a.utils.telemetry import SpanKind, trace_class +from a2a.utils.version_validator import validate_version logger = logging.getLogger(__name__) diff --git a/src/a2a/server/request_handlers/default_request_handler.py b/src/a2a/server/request_handlers/default_request_handler.py index fea5184d6..e803b567f 100644 --- a/src/a2a/server/request_handlers/default_request_handler.py +++ b/src/a2a/server/request_handlers/default_request_handler.py @@ -58,7 +58,6 @@ TaskNotFoundError, UnsupportedOperationError, ) -from a2a.utils.helpers import maybe_await from a2a.utils.task import ( apply_history_length, validate_history_length, @@ -100,7 +99,7 @@ def __init__( # noqa: PLR0913 request_context_builder: RequestContextBuilder | None = None, extended_agent_card: AgentCard | None = None, extended_card_modifier: Callable[ - [AgentCard, ServerCallContext], Awaitable[AgentCard] | AgentCard + [AgentCard, ServerCallContext], Awaitable[AgentCard] ] | None = None, ) -> None: @@ -695,8 +694,8 @@ async def on_get_extended_agent_card( raise ExtendedAgentCardNotConfiguredError if self.extended_card_modifier: - return await maybe_await( - self.extended_card_modifier(extended_card, context) + extended_card = await self.extended_card_modifier( + extended_card, context ) return extended_card diff --git a/src/a2a/server/request_handlers/default_request_handler_v2.py b/src/a2a/server/request_handlers/default_request_handler_v2.py index 1a8464687..c0c6b5445 100644 --- a/src/a2a/server/request_handlers/default_request_handler_v2.py +++ b/src/a2a/server/request_handlers/default_request_handler_v2.py @@ -47,7 +47,6 @@ TaskNotCancelableError, TaskNotFoundError, ) -from a2a.utils.helpers import maybe_await from a2a.utils.task import ( apply_history_length, validate_history_length, @@ -93,7 +92,7 @@ def __init__( # noqa: PLR0913 request_context_builder: RequestContextBuilder | None = None, extended_agent_card: AgentCard | None = None, extended_card_modifier: Callable[ - [AgentCard, ServerCallContext], Awaitable[AgentCard] | AgentCard + [AgentCard, ServerCallContext], Awaitable[AgentCard] ] | None = None, ) -> None: @@ -467,8 +466,8 @@ async def on_get_extended_agent_card( raise ExtendedAgentCardNotConfiguredError if self.extended_card_modifier: - return await maybe_await( - self.extended_card_modifier(extended_card, context) + extended_card = await self.extended_card_modifier( + extended_card, context ) return extended_card diff --git a/src/a2a/server/routes/agent_card_routes.py b/src/a2a/server/routes/agent_card_routes.py index 9b850ff4f..924a3d9dc 100644 --- a/src/a2a/server/routes/agent_card_routes.py +++ b/src/a2a/server/routes/agent_card_routes.py @@ -26,13 +26,11 @@ from a2a.server.request_handlers.response_helpers import agent_card_to_dict from a2a.types.a2a_pb2 import AgentCard from a2a.utils.constants import AGENT_CARD_WELL_KNOWN_PATH -from a2a.utils.helpers import maybe_await def create_agent_card_routes( agent_card: AgentCard, - card_modifier: Callable[[AgentCard], Awaitable[AgentCard] | AgentCard] - | None = None, + card_modifier: Callable[[AgentCard], Awaitable[AgentCard]] | None = None, card_url: str = AGENT_CARD_WELL_KNOWN_PATH, ) -> list['Route']: """Creates the Starlette Route for the A2A protocol agent card endpoint.""" @@ -45,7 +43,7 @@ def create_agent_card_routes( async def _get_agent_card(request: Request) -> Response: card_to_serve = agent_card if card_modifier: - card_to_serve = await maybe_await(card_modifier(card_to_serve)) + card_to_serve = await card_modifier(card_to_serve) return JSONResponse(agent_card_to_dict(card_to_serve)) return [ diff --git a/src/a2a/server/routes/jsonrpc_dispatcher.py b/src/a2a/server/routes/jsonrpc_dispatcher.py index 60620081a..d5c466d1f 100644 --- a/src/a2a/server/routes/jsonrpc_dispatcher.py +++ b/src/a2a/server/routes/jsonrpc_dispatcher.py @@ -52,8 +52,8 @@ TaskNotFoundError, UnsupportedOperationError, ) -from a2a.utils.helpers import validate_version from a2a.utils.telemetry import SpanKind, trace_class +from a2a.utils.version_validator import validate_version INTERNAL_ERROR_CODE = -32603 diff --git a/src/a2a/server/routes/rest_dispatcher.py b/src/a2a/server/routes/rest_dispatcher.py index 8af384893..adbdba96e 100644 --- a/src/a2a/server/routes/rest_dispatcher.py +++ b/src/a2a/server/routes/rest_dispatcher.py @@ -28,8 +28,8 @@ InvalidRequestError, TaskNotFoundError, ) -from a2a.utils.helpers import validate_version from a2a.utils.telemetry import SpanKind, trace_class +from a2a.utils.version_validator import validate_version if TYPE_CHECKING: diff --git a/src/a2a/utils/helpers.py b/src/a2a/utils/version_validator.py similarity index 94% rename from src/a2a/utils/helpers.py rename to src/a2a/utils/version_validator.py index 9a974a4c2..4a776c27e 100644 --- a/src/a2a/utils/helpers.py +++ b/src/a2a/utils/version_validator.py @@ -4,7 +4,7 @@ import inspect import logging -from collections.abc import AsyncIterator, Awaitable, Callable +from collections.abc import AsyncIterator, Callable from typing import Any, TypeVar, cast from packaging.version import InvalidVersion, Version @@ -14,20 +14,12 @@ from a2a.utils.errors import VersionNotSupportedError -T = TypeVar('T') F = TypeVar('F', bound=Callable[..., Any]) logger = logging.getLogger(__name__) -async def maybe_await(value: T | Awaitable[T]) -> T: - """Awaits a value if it's awaitable, otherwise simply provides it back.""" - if inspect.isawaitable(value): - return await value - return value - - def validate_version(expected_version: str) -> Callable[[F], F]: """Decorator that validates the A2A-Version header in the request context. diff --git a/tests/server/tasks/test_task_manager.py b/tests/server/tasks/test_task_manager.py index bdfbf525c..eba8d2f14 100644 --- a/tests/server/tasks/test_task_manager.py +++ b/tests/server/tasks/test_task_manager.py @@ -6,6 +6,7 @@ from a2a.auth.user import User from a2a.server.context import ServerCallContext from a2a.server.tasks import TaskManager +from a2a.server.tasks.task_manager import append_artifact_to_task from a2a.types.a2a_pb2 import ( Artifact, Message, @@ -345,3 +346,99 @@ async def test_save_task_event_no_task_existing( assert saved_task.status.state == TaskState.TASK_STATE_COMPLETED assert task_manager_without_id.task_id == 'event-task-id' assert task_manager_without_id.context_id == 'some-context' + + +def test_append_artifact_to_task(): + # Prepare base task + task = create_minimal_task() + assert task.id == 'task-abc' + assert task.context_id == 'session-xyz' + assert task.status.state == TaskState.TASK_STATE_SUBMITTED + assert len(task.history) == 0 # proto repeated fields are empty, not None + assert len(task.artifacts) == 0 + + # Prepare appending artifact and event + artifact_1 = Artifact( + artifact_id='artifact-123', parts=[Part(text='Hello')] + ) + append_event_1 = TaskArtifactUpdateEvent( + artifact=artifact_1, append=False, task_id='123', context_id='123' + ) + + # Test adding a new artifact (not appending) + append_artifact_to_task(task, append_event_1) + assert len(task.artifacts) == 1 + assert task.artifacts[0].artifact_id == 'artifact-123' + assert task.artifacts[0].name == '' # proto default for string + assert len(task.artifacts[0].parts) == 1 + assert task.artifacts[0].parts[0].text == 'Hello' + + # Test replacing the artifact + artifact_2 = Artifact( + artifact_id='artifact-123', + name='updated name', + parts=[Part(text='Updated')], + metadata={'existing_key': 'existing_value'}, + ) + append_event_2 = TaskArtifactUpdateEvent( + artifact=artifact_2, append=False, task_id='123', context_id='123' + ) + append_artifact_to_task(task, append_event_2) + assert len(task.artifacts) == 1 # Should still have one artifact + assert task.artifacts[0].artifact_id == 'artifact-123' + assert task.artifacts[0].name == 'updated name' + assert len(task.artifacts[0].parts) == 1 + assert task.artifacts[0].parts[0].text == 'Updated' + assert task.artifacts[0].metadata['existing_key'] == 'existing_value' + + # Test appending parts to an existing artifact + artifact_with_parts = Artifact( + artifact_id='artifact-123', + parts=[Part(text='Part 2')], + metadata={'new_key': 'new_value'}, + ) + append_event_3 = TaskArtifactUpdateEvent( + artifact=artifact_with_parts, + append=True, + task_id='123', + context_id='123', + ) + append_artifact_to_task(task, append_event_3) + assert len(task.artifacts[0].parts) == 2 + assert task.artifacts[0].parts[0].text == 'Updated' + assert task.artifacts[0].parts[1].text == 'Part 2' + assert task.artifacts[0].metadata['existing_key'] == 'existing_value' + assert task.artifacts[0].metadata['new_key'] == 'new_value' + + # Test adding another new artifact + another_artifact_with_parts = Artifact( + artifact_id='new_artifact', + parts=[Part(text='new artifact Part 1')], + ) + append_event_4 = TaskArtifactUpdateEvent( + artifact=another_artifact_with_parts, + append=False, + task_id='123', + context_id='123', + ) + append_artifact_to_task(task, append_event_4) + assert len(task.artifacts) == 2 + assert task.artifacts[0].artifact_id == 'artifact-123' + assert task.artifacts[1].artifact_id == 'new_artifact' + assert len(task.artifacts[0].parts) == 2 + assert len(task.artifacts[1].parts) == 1 + + # Test appending part to a task that does not have a matching artifact + non_existing_artifact_with_parts = Artifact( + artifact_id='artifact-456', parts=[Part(text='Part 1')] + ) + append_event_5 = TaskArtifactUpdateEvent( + artifact=non_existing_artifact_with_parts, + append=True, + task_id='123', + context_id='123', + ) + append_artifact_to_task(task, append_event_5) + assert len(task.artifacts) == 2 + assert len(task.artifacts[0].parts) == 2 + assert len(task.artifacts[1].parts) == 1 diff --git a/tests/utils/test_helpers.py b/tests/utils/test_helpers.py deleted file mode 100644 index c2c990c0d..000000000 --- a/tests/utils/test_helpers.py +++ /dev/null @@ -1,312 +0,0 @@ -import uuid - -from typing import Any -from unittest.mock import patch - -import pytest - -from a2a.types import ( - AgentCapabilities, - AgentCard, - AgentCardSignature, - AgentInterface, - AgentSkill, - Artifact, - Message, - Part, - Role, - SendMessageRequest, - Task, - TaskArtifactUpdateEvent, - TaskState, - TaskStatus, -) -from a2a.utils.errors import UnsupportedOperationError - -from a2a.utils.signing import _clean_empty, _canonicalize_agent_card -from a2a.server.tasks.task_manager import append_artifact_to_task - - -# --- Helper Functions --- -def create_test_message( - role: Role = Role.ROLE_USER, - text: str = 'Hello', - message_id: str = 'msg-123', -) -> Message: - return Message( - role=role, - parts=[Part(text=text)], - message_id=message_id, - ) - - -def create_test_task( - task_id: str = 'task-abc', - context_id: str = 'session-xyz', -) -> Task: - return Task( - id=task_id, - context_id=context_id, - status=TaskStatus(state=TaskState.TASK_STATE_SUBMITTED), - ) - - -SAMPLE_AGENT_CARD: dict[str, Any] = { - 'name': 'Test Agent', - 'description': 'A test agent', - 'supported_interfaces': [ - AgentInterface( - url='http://localhost', - protocol_binding='HTTP+JSON', - ) - ], - 'version': '1.0.0', - 'capabilities': AgentCapabilities( - streaming=None, - push_notifications=True, - ), - 'default_input_modes': ['text/plain'], - 'default_output_modes': ['text/plain'], - 'documentation_url': None, - 'icon_url': '', - 'skills': [ - AgentSkill( - id='skill1', - name='Test Skill', - description='A test skill', - tags=['test'], - ) - ], - 'signatures': [ - AgentCardSignature( - protected='protected_header', signature='test_signature' - ) - ], -} - - -# Test append_artifact_to_task -def test_append_artifact_to_task(): - # Prepare base task - task = create_test_task() - assert task.id == 'task-abc' - assert task.context_id == 'session-xyz' - assert task.status.state == TaskState.TASK_STATE_SUBMITTED - assert len(task.history) == 0 # proto repeated fields are empty, not None - assert len(task.artifacts) == 0 - - # Prepare appending artifact and event - artifact_1 = Artifact( - artifact_id='artifact-123', parts=[Part(text='Hello')] - ) - append_event_1 = TaskArtifactUpdateEvent( - artifact=artifact_1, append=False, task_id='123', context_id='123' - ) - - # Test adding a new artifact (not appending) - append_artifact_to_task(task, append_event_1) - assert len(task.artifacts) == 1 - assert task.artifacts[0].artifact_id == 'artifact-123' - assert task.artifacts[0].name == '' # proto default for string - assert len(task.artifacts[0].parts) == 1 - assert task.artifacts[0].parts[0].text == 'Hello' - - # Test replacing the artifact - artifact_2 = Artifact( - artifact_id='artifact-123', - name='updated name', - parts=[Part(text='Updated')], - metadata={'existing_key': 'existing_value'}, - ) - append_event_2 = TaskArtifactUpdateEvent( - artifact=artifact_2, append=False, task_id='123', context_id='123' - ) - append_artifact_to_task(task, append_event_2) - assert len(task.artifacts) == 1 # Should still have one artifact - assert task.artifacts[0].artifact_id == 'artifact-123' - assert task.artifacts[0].name == 'updated name' - assert len(task.artifacts[0].parts) == 1 - assert task.artifacts[0].parts[0].text == 'Updated' - assert task.artifacts[0].metadata['existing_key'] == 'existing_value' - - # Test appending parts to an existing artifact - artifact_with_parts = Artifact( - artifact_id='artifact-123', - parts=[Part(text='Part 2')], - metadata={'new_key': 'new_value'}, - ) - append_event_3 = TaskArtifactUpdateEvent( - artifact=artifact_with_parts, - append=True, - task_id='123', - context_id='123', - ) - append_artifact_to_task(task, append_event_3) - assert len(task.artifacts[0].parts) == 2 - assert task.artifacts[0].parts[0].text == 'Updated' - assert task.artifacts[0].parts[1].text == 'Part 2' - assert task.artifacts[0].metadata['existing_key'] == 'existing_value' - assert task.artifacts[0].metadata['new_key'] == 'new_value' - - # Test adding another new artifact - another_artifact_with_parts = Artifact( - artifact_id='new_artifact', - parts=[Part(text='new artifact Part 1')], - ) - append_event_4 = TaskArtifactUpdateEvent( - artifact=another_artifact_with_parts, - append=False, - task_id='123', - context_id='123', - ) - append_artifact_to_task(task, append_event_4) - assert len(task.artifacts) == 2 - assert task.artifacts[0].artifact_id == 'artifact-123' - assert task.artifacts[1].artifact_id == 'new_artifact' - assert len(task.artifacts[0].parts) == 2 - assert len(task.artifacts[1].parts) == 1 - - # Test appending part to a task that does not have a matching artifact - non_existing_artifact_with_parts = Artifact( - artifact_id='artifact-456', parts=[Part(text='Part 1')] - ) - append_event_5 = TaskArtifactUpdateEvent( - artifact=non_existing_artifact_with_parts, - append=True, - task_id='123', - context_id='123', - ) - append_artifact_to_task(task, append_event_5) - assert len(task.artifacts) == 2 - assert len(task.artifacts[0].parts) == 2 - assert len(task.artifacts[1].parts) == 1 - - -def build_text_artifact(text: str, artifact_id: str) -> Artifact: - return Artifact(artifact_id=artifact_id, parts=[Part(text=text)]) - - -# Test build_text_artifact -def test_build_text_artifact(): - artifact_id = 'text_artifact' - text = 'This is a sample text' - artifact = build_text_artifact(text, artifact_id) - - assert artifact.artifact_id == artifact_id - assert len(artifact.parts) == 1 - assert artifact.parts[0].text == text - - -def test_canonicalize_agent_card(): - """Test canonicalize_agent_card with defaults, optionals, and exceptions. - - - extensions is omitted as it's not set and optional. - - protocolVersion is included because it's always added by canonicalize_agent_card. - - signatures should be omitted. - """ - agent_card = AgentCard(**SAMPLE_AGENT_CARD) - expected_jcs = ( - '{"capabilities":{"pushNotifications":true},' - '"defaultInputModes":["text/plain"],"defaultOutputModes":["text/plain"],' - '"description":"A test agent","name":"Test Agent",' - '"skills":[{"description":"A test skill","id":"skill1","name":"Test Skill","tags":["test"]}],' - '"supportedInterfaces":[{"protocolBinding":"HTTP+JSON","url":"http://localhost"}],' - '"version":"1.0.0"}' - ) - result = _canonicalize_agent_card(agent_card) - assert result == expected_jcs - - -def test_canonicalize_agent_card_preserves_false_capability(): - """Regression #692: streaming=False must not be stripped from canonical JSON.""" - card = AgentCard( - **{ - **SAMPLE_AGENT_CARD, - 'capabilities': AgentCapabilities( - streaming=False, - push_notifications=True, - ), - } - ) - result = _canonicalize_agent_card(card) - assert '"streaming":false' in result - - -@pytest.mark.parametrize( - 'input_val', - [ - pytest.param({'a': ''}, id='empty-string'), - pytest.param({'a': []}, id='empty-list'), - pytest.param({'a': {}}, id='empty-dict'), - pytest.param({'a': {'b': []}}, id='nested-empty'), - pytest.param({'a': '', 'b': [], 'c': {}}, id='all-empties'), - pytest.param({'a': {'b': {'c': ''}}}, id='deeply-nested'), - ], -) -def test_clean_empty_removes_empties(input_val): - """_clean_empty removes empty strings, lists, and dicts recursively.""" - assert _clean_empty(input_val) is None - - -def test_clean_empty_top_level_list_becomes_none(): - """Top-level list that becomes empty after cleaning should return None.""" - assert _clean_empty(['', {}, []]) is None - - -@pytest.mark.parametrize( - 'input_val,expected', - [ - pytest.param({'retries': 0}, {'retries': 0}, id='int-zero'), - pytest.param({'enabled': False}, {'enabled': False}, id='bool-false'), - pytest.param({'score': 0.0}, {'score': 0.0}, id='float-zero'), - pytest.param([0, 1, 2], [0, 1, 2], id='zero-in-list'), - pytest.param([False, True], [False, True], id='false-in-list'), - pytest.param( - {'config': {'max_retries': 0, 'name': 'agent'}}, - {'config': {'max_retries': 0, 'name': 'agent'}}, - id='nested-zero', - ), - ], -) -def test_clean_empty_preserves_falsy_values(input_val, expected): - """_clean_empty preserves legitimate falsy values (0, False, 0.0).""" - assert _clean_empty(input_val) == expected - - -@pytest.mark.parametrize( - 'input_val,expected', - [ - pytest.param( - {'count': 0, 'label': '', 'items': []}, - {'count': 0}, - id='falsy-with-empties', - ), - pytest.param( - {'a': 0, 'b': 'hello', 'c': False, 'd': ''}, - {'a': 0, 'b': 'hello', 'c': False}, - id='mixed-types', - ), - pytest.param( - {'name': 'agent', 'retries': 0, 'tags': [], 'desc': ''}, - {'name': 'agent', 'retries': 0}, - id='realistic-mixed', - ), - ], -) -def test_clean_empty_mixed(input_val, expected): - """_clean_empty handles mixed empty and falsy values correctly.""" - assert _clean_empty(input_val) == expected - - -def test_clean_empty_does_not_mutate_input(): - """_clean_empty should not mutate the original input object.""" - original = {'a': '', 'b': 1, 'c': {'d': ''}} - original_copy = { - 'a': '', - 'b': 1, - 'c': {'d': ''}, - } - - _clean_empty(original) - - assert original == original_copy diff --git a/tests/utils/test_signing.py b/tests/utils/test_signing.py index 162f28e28..2a09943fe 100644 --- a/tests/utils/test_signing.py +++ b/tests/utils/test_signing.py @@ -178,3 +178,111 @@ def test_signer_and_verifier_asymmetric(sample_agent_card: AgentCard): ) with pytest.raises(signing.InvalidSignaturesError): verifier_wrong_key(signed_card) + + +def test_canonicalize_agent_card(sample_agent_card: AgentCard): + """Test canonicalize_agent_card with defaults, optionals, and exceptions. + + - extensions is omitted as it's not set and optional. + - protocolVersion is included because it's always added by canonicalize_agent_card. + - signatures should be omitted. + """ + expected_jcs = ( + '{"capabilities":{"pushNotifications":true},' + '"defaultInputModes":["text/plain"],"defaultOutputModes":["text/plain"],' + '"description":"A test agent","name":"Test Agent",' + '"skills":[{"description":"A test skill","id":"skill1","name":"Test Skill","tags":["test"]}],' + '"supportedInterfaces":[{"protocolBinding":"HTTP+JSON","url":"http://localhost"}],' + '"version":"1.0.0"}' + ) + result = signing._canonicalize_agent_card(sample_agent_card) + assert result == expected_jcs + + +def test_canonicalize_agent_card_preserves_false_capability( + sample_agent_card: AgentCard, +): + """Regression #692: streaming=False must not be stripped from canonical JSON.""" + sample_agent_card.capabilities.streaming = False + result = signing._canonicalize_agent_card(sample_agent_card) + assert '"streaming":false' in result + + +@pytest.mark.parametrize( + 'input_val', + [ + pytest.param({'a': ''}, id='empty-string'), + pytest.param({'a': []}, id='empty-list'), + pytest.param({'a': {}}, id='empty-dict'), + pytest.param({'a': {'b': []}}, id='nested-empty'), + pytest.param({'a': '', 'b': [], 'c': {}}, id='all-empties'), + pytest.param({'a': {'b': {'c': ''}}}, id='deeply-nested'), + ], +) +def test_clean_empty_removes_empties(input_val): + """_clean_empty removes empty strings, lists, and dicts recursively.""" + assert signing._clean_empty(input_val) is None + + +def test_clean_empty_top_level_list_becomes_none(): + """Top-level list that becomes empty after cleaning should return None.""" + assert signing._clean_empty(['', {}, []]) is None + + +@pytest.mark.parametrize( + 'input_val,expected', + [ + pytest.param({'retries': 0}, {'retries': 0}, id='int-zero'), + pytest.param({'enabled': False}, {'enabled': False}, id='bool-false'), + pytest.param({'score': 0.0}, {'score': 0.0}, id='float-zero'), + pytest.param([0, 1, 2], [0, 1, 2], id='zero-in-list'), + pytest.param([False, True], [False, True], id='false-in-list'), + pytest.param( + {'config': {'max_retries': 0, 'name': 'agent'}}, + {'config': {'max_retries': 0, 'name': 'agent'}}, + id='nested-zero', + ), + ], +) +def test_clean_empty_preserves_falsy_values(input_val, expected): + """_clean_empty preserves legitimate falsy values (0, False, 0.0).""" + assert signing._clean_empty(input_val) == expected + + +@pytest.mark.parametrize( + 'input_val,expected', + [ + pytest.param( + {'count': 0, 'label': '', 'items': []}, + {'count': 0}, + id='falsy-with-empties', + ), + pytest.param( + {'a': 0, 'b': 'hello', 'c': False, 'd': ''}, + {'a': 0, 'b': 'hello', 'c': False}, + id='mixed-types', + ), + pytest.param( + {'name': 'agent', 'retries': 0, 'tags': [], 'desc': ''}, + {'name': 'agent', 'retries': 0}, + id='realistic-mixed', + ), + ], +) +def test_clean_empty_mixed(input_val, expected): + """_clean_empty handles mixed empty and falsy values correctly.""" + assert signing._clean_empty(input_val) == expected + + +def test_clean_empty_does_not_mutate_input(): + """_clean_empty should not mutate the original input object.""" + original = {'a': '', 'b': 1, 'c': {'d': ''}} + original_copy = { + 'a': '', + 'b': 1, + 'c': {'d': ''}, + } + + signing._clean_empty(original) + + assert original == original_copy diff --git a/tests/utils/test_helpers_validation.py b/tests/utils/test_version_validation.py similarity index 98% rename from tests/utils/test_helpers_validation.py rename to tests/utils/test_version_validation.py index 571f8ae9b..b2ae0594e 100644 --- a/tests/utils/test_helpers_validation.py +++ b/tests/utils/test_version_validation.py @@ -6,7 +6,7 @@ from a2a.server.context import ServerCallContext from a2a.utils import constants from a2a.utils.errors import VersionNotSupportedError -from a2a.utils.helpers import validate_version +from a2a.utils.version_validator import validate_version class TestHandler: From dfa9e563ae1a78322e553d85194ec64794f580df Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Fri, 17 Apr 2026 12:15:49 +0000 Subject: [PATCH 3/6] fix --- scripts/test_minimal_install.py | 1 - tests/helpers/test_proto_helpers.py | 8 -------- 2 files changed, 9 deletions(-) diff --git a/scripts/test_minimal_install.py b/scripts/test_minimal_install.py index 5f607b3a7..84e3ee3fc 100755 --- a/scripts/test_minimal_install.py +++ b/scripts/test_minimal_install.py @@ -38,7 +38,6 @@ 'a2a.client.client', 'a2a.client.client_factory', 'a2a.client.errors', - 'a2a.client.helpers', 'a2a.client.interceptors', 'a2a.client.optionals', 'a2a.client.transports', diff --git a/tests/helpers/test_proto_helpers.py b/tests/helpers/test_proto_helpers.py index 1939b92f3..a4f6498ab 100644 --- a/tests/helpers/test_proto_helpers.py +++ b/tests/helpers/test_proto_helpers.py @@ -52,14 +52,6 @@ def test_new_text_message() -> None: assert msg.message_id != '' -def test_create_text_message_object() -> None: - msg = new_text_message(text='Hello', role=Role.ROLE_AGENT) - assert msg.role == Role.ROLE_AGENT - assert len(msg.parts) == 1 - assert msg.parts[0].text == 'Hello' - assert msg.message_id != '' - - def test_get_message_text() -> None: msg = Message(parts=[Part(text='hello'), Part(text='world')]) assert get_message_text(msg) == 'hello\nworld' From 2d0e567f70270fb1cddeee7ba2154889d461faa4 Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Fri, 17 Apr 2026 12:32:27 +0000 Subject: [PATCH 4/6] fix --- docs/ai/ai_learnings.md | 17 ----------------- src/a2a/compat/v0_3/jsonrpc_adapter.py | 3 ++- .../test_client_server_integration.py | 10 ++++++++-- tests/server/test_integration.py | 4 ++-- 4 files changed, 12 insertions(+), 22 deletions(-) delete mode 100644 docs/ai/ai_learnings.md diff --git a/docs/ai/ai_learnings.md b/docs/ai/ai_learnings.md deleted file mode 100644 index 50b5fb84a..000000000 --- a/docs/ai/ai_learnings.md +++ /dev/null @@ -1,17 +0,0 @@ -> [!NOTE] for Users: -> This document is meant to be read by an AI assistant (Gemini) in order to -> learn from its mistakes and improve its behavior on this project. Use -> its findings to improve GEMINI.md setup. - -# AI Learnings - -A living record of mistakes made during this project and the rules -derived from them. Every entry must follow the format below. - ---- - -## Entry format - -**Mistake**: What went wrong. -**Root cause**: Why it happened. -**Rule**: The concrete rule added to prevent recurrence. diff --git a/src/a2a/compat/v0_3/jsonrpc_adapter.py b/src/a2a/compat/v0_3/jsonrpc_adapter.py index 5cf0bd449..90853205e 100644 --- a/src/a2a/compat/v0_3/jsonrpc_adapter.py +++ b/src/a2a/compat/v0_3/jsonrpc_adapter.py @@ -120,7 +120,8 @@ async def handle_request( CoreInvalidRequestError(data=str(e)), ) - call_context = self._context_builder.build(request) + call_context = self._context_builder.bui + ld(request) call_context.tenant = ( getattr(specific_request.params, 'tenant', '') if hasattr(specific_request, 'params') diff --git a/tests/integration/test_client_server_integration.py b/tests/integration/test_client_server_integration.py index 1ac8a7162..eff9f760b 100644 --- a/tests/integration/test_client_server_integration.py +++ b/tests/integration/test_client_server_integration.py @@ -709,8 +709,11 @@ async def test_json_transport_get_signed_base_card( }, ) + async def async_signer(card: AgentCard) -> AgentCard: + return signer(card) + agent_card_routes = create_agent_card_routes( - agent_card=agent_card, card_url='/', card_modifier=signer + agent_card=agent_card, card_url='/', card_modifier=async_signer ) jsonrpc_routes = create_jsonrpc_routes( request_handler=mock_request_handler, rpc_url='/' @@ -863,8 +866,11 @@ async def get_extended_agent_card_mock_3(*args, **kwargs): mock_request_handler.on_get_extended_agent_card.side_effect = ( get_extended_agent_card_mock_3 # type: ignore[union-attr] ) + async def async_signer(card: AgentCard) -> AgentCard: + return signer(card) + agent_card_routes = create_agent_card_routes( - agent_card=agent_card, card_url='/', card_modifier=signer + agent_card=agent_card, card_url='/', card_modifier=async_signer ) jsonrpc_routes = create_jsonrpc_routes( request_handler=mock_request_handler, rpc_url='/' diff --git a/tests/server/test_integration.py b/tests/server/test_integration.py index ddab2661a..56663e7e9 100644 --- a/tests/server/test_integration.py +++ b/tests/server/test_integration.py @@ -775,7 +775,7 @@ def test_dynamic_agent_card_modifier_sync( ): """Test that a synchronous card_modifier dynamically alters the public agent card.""" - def modifier(card: AgentCard) -> AgentCard: + async def modifier(card: AgentCard) -> AgentCard: modified_card = AgentCard() modified_card.CopyFrom(card) modified_card.name = 'Dynamically Modified Agent' @@ -818,7 +818,7 @@ def test_fastapi_dynamic_agent_card_modifier_sync( ): """Test that a synchronous card_modifier dynamically alters the public agent card for FastAPI.""" - def modifier(card: AgentCard) -> AgentCard: + async def modifier(card: AgentCard) -> AgentCard: modified_card = AgentCard() modified_card.CopyFrom(card) modified_card.name = 'Dynamically Modified Agent' From 65421d42c663b1aaa4bbd3d822851009e545b5b0 Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Fri, 17 Apr 2026 12:39:15 +0000 Subject: [PATCH 5/6] fix --- src/a2a/compat/v0_3/jsonrpc_adapter.py | 3 +- task.md | 30 +++++++++++++++++++ .../test_client_server_integration.py | 1 + 3 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 task.md diff --git a/src/a2a/compat/v0_3/jsonrpc_adapter.py b/src/a2a/compat/v0_3/jsonrpc_adapter.py index 90853205e..5cf0bd449 100644 --- a/src/a2a/compat/v0_3/jsonrpc_adapter.py +++ b/src/a2a/compat/v0_3/jsonrpc_adapter.py @@ -120,8 +120,7 @@ async def handle_request( CoreInvalidRequestError(data=str(e)), ) - call_context = self._context_builder.bui - ld(request) + call_context = self._context_builder.build(request) call_context.tenant = ( getattr(specific_request.params, 'tenant', '') if hasattr(specific_request, 'params') diff --git a/task.md b/task.md new file mode 100644 index 000000000..c63f46259 --- /dev/null +++ b/task.md @@ -0,0 +1,30 @@ +# Task: Check if anything needs to be fixed in ITK/TCK + +## Todo +- [x] Clarify if "itk" means TCK or something else. (It is a directory `itk` with a sample agent) +- [x] Search for `itk` or `tck` in the codebase to identify relevant files. (Found `itk/main.py`) +- [x] Check if tests in those files need similar fixes (async card modifiers). (No fixes needed, doesn't use `card_modifier` or `helpers`) +- [ ] Run mandatory checks. + +## Mandatory Checks +1. **Formatting & Linting**: + ```bash + uv run ruff check --fix + uv run ruff format + ``` + +2. **Type Checking**: + ```bash + uv run mypy src + uv run pyright src + ``` + +3. **Testing**: + ```bash + uv run pytest + ``` + +4. **Coverage**: + ```bash + uv run pytest --cov=src --cov-report=term-missing + ``` diff --git a/tests/integration/test_client_server_integration.py b/tests/integration/test_client_server_integration.py index eff9f760b..40f9241fd 100644 --- a/tests/integration/test_client_server_integration.py +++ b/tests/integration/test_client_server_integration.py @@ -866,6 +866,7 @@ async def get_extended_agent_card_mock_3(*args, **kwargs): mock_request_handler.on_get_extended_agent_card.side_effect = ( get_extended_agent_card_mock_3 # type: ignore[union-attr] ) + async def async_signer(card: AgentCard) -> AgentCard: return signer(card) From 4c5f48e9eb0750f6c5ba3a56e2b73fe647744ce5 Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Fri, 17 Apr 2026 12:50:15 +0000 Subject: [PATCH 6/6] reomve file --- task.md | 30 ------------------------------ 1 file changed, 30 deletions(-) delete mode 100644 task.md diff --git a/task.md b/task.md deleted file mode 100644 index c63f46259..000000000 --- a/task.md +++ /dev/null @@ -1,30 +0,0 @@ -# Task: Check if anything needs to be fixed in ITK/TCK - -## Todo -- [x] Clarify if "itk" means TCK or something else. (It is a directory `itk` with a sample agent) -- [x] Search for `itk` or `tck` in the codebase to identify relevant files. (Found `itk/main.py`) -- [x] Check if tests in those files need similar fixes (async card modifiers). (No fixes needed, doesn't use `card_modifier` or `helpers`) -- [ ] Run mandatory checks. - -## Mandatory Checks -1. **Formatting & Linting**: - ```bash - uv run ruff check --fix - uv run ruff format - ``` - -2. **Type Checking**: - ```bash - uv run mypy src - uv run pyright src - ``` - -3. **Testing**: - ```bash - uv run pytest - ``` - -4. **Coverage**: - ```bash - uv run pytest --cov=src --cov-report=term-missing - ```