From d1d646e9a373d7de918c39946aa45cbb136e6f5e Mon Sep 17 00:00:00 2001 From: Mandar Deolalikar Date: Thu, 15 May 2025 15:42:09 -0700 Subject: [PATCH 1/9] Add functionality for extended agent card. Does not support authentication yet --- examples/helloworld/__main__.py | 13 ++++- src/a2a/client/client.py | 75 +++++++++++++++++++++++++--- src/a2a/server/apps/starlette_app.py | 72 +++++++++++++++++++++++--- src/a2a/types.py | 9 ++-- 4 files changed, 151 insertions(+), 18 deletions(-) diff --git a/examples/helloworld/__main__.py b/examples/helloworld/__main__.py index ba06207b..e41ddb47 100644 --- a/examples/helloworld/__main__.py +++ b/examples/helloworld/__main__.py @@ -10,7 +10,6 @@ AgentSkill, ) - if __name__ == '__main__': skill = AgentSkill( id='hello_world', @@ -20,6 +19,14 @@ examples=['hi', 'hello world'], ) + extended_skill = AgentSkill( + id='super_hello_world', + name='Returns a SUPER Hello World', + description='A more enthusiastic greeting, only for authenticated users.', + tags=['hello world', 'super', 'extended'], + examples=['super hi', 'give me a super hello'], + ) + agent_card = AgentCard( name='Hello World Agent', description='Just a hello world agent', @@ -28,8 +35,10 @@ defaultInputModes=['text'], defaultOutputModes=['text'], capabilities=AgentCapabilities(streaming=True), - skills=[skill], + skills=[skill, extended_skill], # Include both skills authentication=AgentAuthentication(schemes=['public']), + # Adding this line to enable extended card support: + supportsAuthenticatedExtendedCard=True, ) request_handler = DefaultRequestHandler( diff --git a/src/a2a/client/client.py b/src/a2a/client/client.py index 2f032707..bfd9686a 100644 --- a/src/a2a/client/client.py +++ b/src/a2a/client/client.py @@ -1,11 +1,10 @@ import json - +import logging from collections.abc import AsyncGenerator from typing import Any from uuid import uuid4 import httpx - from httpx_sse import SSEError, aconnect_sse from a2a.client.errors import A2AClientHTTPError, A2AClientJSONError @@ -34,30 +33,92 @@ def __init__( httpx_client: httpx.AsyncClient, base_url: str, agent_card_path: str = '/.well-known/agent.json', + extended_agent_card_path: str = '/agent/authenticatedExtendedCard', ): self.base_url = base_url.rstrip('/') self.agent_card_path = agent_card_path.lstrip('/') + self.extended_agent_card_path = extended_agent_card_path.lstrip('/') self.httpx_client = httpx_client async def get_agent_card( self, http_kwargs: dict[str, Any] | None = None ) -> AgentCard: + # Fetch the initial public agent card + public_card_url = f'{self.base_url}/{self.agent_card_path}' try: response = await self.httpx_client.get( - f'{self.base_url}/{self.agent_card_path}', + public_card_url, **(http_kwargs or {}), ) response.raise_for_status() - return AgentCard.model_validate(response.json()) + public_agent_card_data = response.json() + logger.info("Successfully fetched public agent card data: %s", public_agent_card_data) # Added for verbosity + # print(f"DEBUG: Fetched public agent card data:\n{json.dumps(public_agent_card_data, indent=2)}") # Added for direct output + agent_card = AgentCard.model_validate(public_agent_card_data) except httpx.HTTPStatusError as e: - raise A2AClientHTTPError(e.response.status_code, str(e)) from e + raise A2AClientHTTPError( + e.response.status_code, + f'Failed to fetch public agent card from {public_card_url}: {e}', + ) from e except json.JSONDecodeError as e: - raise A2AClientJSONError(str(e)) from e + raise A2AClientJSONError( + f'Failed to parse JSON for public agent card from {public_card_url}: {e}' + ) from e except httpx.RequestError as e: raise A2AClientHTTPError( - 503, f'Network communication error: {e}' + 503, + f'Network communication error fetching public agent card from {public_card_url}: {e}', ) from e + # Check for supportsAuthenticatedExtendedCard + if agent_card.supportsAuthenticatedExtendedCard: + # Construct URL for the extended card. + # The extended card URL is relative to the agent's base URL specified *in* the agent card. + if not agent_card.url: + logger.warning( + "Agent card (from %s) indicates support for an extended card " + "but does not specify its own base 'url' field. " + "Cannot fetch extended card. Proceeding with public card.", + public_card_url, + ) + return agent_card + + extended_card_base_url = agent_card.url.rstrip('/') + full_extended_card_url = ( + f'{extended_card_base_url}/{self.extended_agent_card_path}' + ) + + logger.info( + 'Attempting to fetch extended agent card from %s', + full_extended_card_url, + ) + try: + # Make another GET request for the extended card + # Note: Authentication headers will be added here when auth is implemented. + extended_response = await self.httpx_client.get( + full_extended_card_url, + **(http_kwargs or {}), # Passing original http_kwargs + ) + extended_response.raise_for_status() + extended_agent_card_data = extended_response.json() + logger.info("Successfully fetched extended agent card data: %s", extended_agent_card_data) # Added for verbosity + print(f"DEBUG: Fetched extended agent card data:\n{json.dumps(extended_agent_card_data, indent=2)}") # Added for direct output + # This new card data replaces the old one entirely + agent_card = AgentCard.model_validate(extended_agent_card_data) + logger.info( + 'Successfully fetched and using extended agent card from %s', + full_extended_card_url, + ) + except (httpx.HTTPStatusError, httpx.RequestError, json.JSONDecodeError, ValidationError) as e: + logger.warning( + 'Failed to fetch or parse extended agent card from %s. Error: %s. ' + 'Proceeding with the initially fetched public agent card.', + full_extended_card_url, e + ) + # Fallback to the already parsed public_agent_card (which is 'agent_card' at this point) + + return agent_card + class A2AClient: """A2A Client.""" diff --git a/src/a2a/server/apps/starlette_app.py b/src/a2a/server/apps/starlette_app.py index 5bbdcced..1be24d4f 100644 --- a/src/a2a/server/apps/starlette_app.py +++ b/src/a2a/server/apps/starlette_app.py @@ -1,7 +1,6 @@ import json import logging import traceback - from collections.abc import AsyncGenerator from typing import Any @@ -38,7 +37,6 @@ ) from a2a.utils.errors import MethodNotImplementedError - logger = logging.getLogger(__name__) @@ -247,6 +245,53 @@ async def event_generator( async def _handle_get_agent_card(self, request: Request) -> JSONResponse: """Handles GET requests for the agent card.""" + # Construct the public view of the agent card. + public_card_data = { + "version": self.agent_card.version, + "name": self.agent_card.name, + "providerName": self.agent_card.provider.organization if self.agent_card.provider else None, + "url": self.agent_card.url, + "authentication": self.agent_card.authentication.model_dump(mode='json', exclude_none=True) + if self.agent_card.authentication else None, # authentication is a single object, can be None if made Optional + "skills": [ + f.model_dump(mode='json', exclude_none=True) + for f in self.agent_card.skills if f.id == 'hello_world' # Explicitly filter for public skills + ] + if self.agent_card.skills + else [], # Default to empty list if no skills + "capabilities": self.agent_card.capabilities.model_dump( + mode='json', exclude_none=True + ), + "supportsAuthenticatedExtendedCard": ( + self.agent_card.supportsAuthenticatedExtendedCard + ), + # Include other fields from types.py AgentCard designated as public + "description": self.agent_card.description, + "documentationUrl": self.agent_card.documentationUrl, + "defaultInputModes": self.agent_card.defaultInputModes, + "defaultOutputModes": self.agent_card.defaultOutputModes, + } + # Filter out None values from the public card data. + public_card_data_cleaned = { + k: v for k, v in public_card_data.items() if v is not None + } + return JSONResponse(public_card_data_cleaned) + + async def _handle_get_authenticated_extended_agent_card( + self, request: Request + ) -> JSONResponse: + """Handles GET requests for the authenticated extended agent card.""" + if not self.agent_card.supportsAuthenticatedExtendedCard: + return JSONResponse( + {"error": "Extended agent card not supported or not enabled."}, + status_code=404, + ) + + # Authentication and authorization are NOT YET IMPLEMENTED for this endpoint. + # As per current requirements, if 'supportsAuthenticatedExtendedCard' is true, + # this endpoint returns the complete agent card. + # In the future, proper authentication checks will be added here, and the + # returned card may be filtered based on the client's authorization scopes. return JSONResponse( self.agent_card.model_dump(mode='json', exclude_none=True) ) @@ -254,6 +299,7 @@ async def _handle_get_agent_card(self, request: Request) -> JSONResponse: def routes( self, agent_card_url: str = '/.well-known/agent.json', + extended_agent_card_url: str = '/agent/authenticatedExtendedCard', rpc_url: str = '/', ) -> list[Route]: """Returns the Starlette Routes for handling A2A requests. @@ -261,11 +307,12 @@ def routes( Args: agent_card_url: The URL for the agent card endpoint. rpc_url: The URL for the A2A JSON-RPC endpoint + extended_agent_card_url: The URL for the authenticated extended agent card endpoint. Returns: The Starlette Routes serving A2A requests. """ - return [ + app_routes = [ Route( rpc_url, self._handle_requests, @@ -280,9 +327,21 @@ def routes( ), ] + if self.agent_card.supportsAuthenticatedExtendedCard: + app_routes.append( + Route( + extended_agent_card_url, + self._handle_get_authenticated_extended_agent_card, + methods=['GET'], + name='authenticated_extended_agent_card', + ) + ) + return app_routes + def build( self, agent_card_url: str = '/.well-known/agent.json', + extended_agent_card_url: str = '/agent/authenticatedExtendedCard', rpc_url: str = '/', **kwargs: Any, ) -> Starlette: @@ -291,16 +350,17 @@ def build( Args: agent_card_url: The URL for the agent card endpoint. rpc_url: The URL for the A2A JSON-RPC endpoint + extended_agent_card_url: The URL for the authenticated extended agent card endpoint. **kwargs: Additional keyword arguments to pass to the Starlette constructor. Returns: A configured Starlette application instance. """ - routes = self.routes(agent_card_url, rpc_url) + app_routes = self.routes(agent_card_url, extended_agent_card_url, rpc_url) if 'routes' in kwargs: - kwargs['routes'] += routes + kwargs['routes'].extend(app_routes) else: - kwargs['routes'] = routes + kwargs['routes'] = app_routes return Starlette(**kwargs) diff --git a/src/a2a/types.py b/src/a2a/types.py index cfff0d6e..798bdf26 100644 --- a/src/a2a/types.py +++ b/src/a2a/types.py @@ -4,9 +4,9 @@ from __future__ import annotations from enum import Enum -from typing import Any, Literal +from typing import Any, Literal, Optional -from pydantic import BaseModel, RootModel +from pydantic import BaseModel, Field, RootModel class A2A(RootModel[Any]): @@ -710,7 +710,10 @@ class AgentCard(BaseModel): """ The version of the agent - format is up to the provider. """ - + supportsAuthenticatedExtendedCard: Optional[bool] = Field(default=None) + """ + Optional field indicating there is an extended card available post authentication at the /agent/authenticatedExtendedCard endpoint. + """ class CancelTaskRequest(BaseModel): """ From 1221aa94780dc20ba0e0c1016a45802a86405544 Mon Sep 17 00:00:00 2001 From: Holt Skinner Date: Mon, 19 May 2025 17:00:34 -0700 Subject: [PATCH 2/9] Formatting --- examples/helloworld/__main__.py | 10 ++++-- src/a2a/client/client.py | 31 ++++++++++++----- src/a2a/server/apps/starlette_app.py | 50 +++++++++++++++++----------- src/a2a/types.py | 4 +-- 4 files changed, 63 insertions(+), 32 deletions(-) diff --git a/examples/helloworld/__main__.py b/examples/helloworld/__main__.py index 355342d0..2cde3afd 100644 --- a/examples/helloworld/__main__.py +++ b/examples/helloworld/__main__.py @@ -5,7 +5,13 @@ from a2a.server.apps import A2AStarletteApplication from a2a.server.request_handlers import DefaultRequestHandler from a2a.server.tasks import InMemoryTaskStore -from a2a.types import AgentCapabilities, AgentCard, AgentSkill +from a2a.types import ( + AgentAuthentication, + AgentCapabilities, + AgentCard, + AgentSkill, +) + if __name__ == '__main__': skill = AgentSkill( @@ -32,7 +38,7 @@ defaultInputModes=['text'], defaultOutputModes=['text'], capabilities=AgentCapabilities(streaming=True), - skills=[skill, extended_skill], # Include both skills + skills=[skill, extended_skill], # Include both skills authentication=AgentAuthentication(schemes=['public']), # Adding this line to enable extended card support: supportsAuthenticatedExtendedCard=True, diff --git a/src/a2a/client/client.py b/src/a2a/client/client.py index 5cdc30bf..af9e746f 100644 --- a/src/a2a/client/client.py +++ b/src/a2a/client/client.py @@ -1,10 +1,11 @@ import json -import logging + from collections.abc import AsyncGenerator from typing import Any from uuid import uuid4 import httpx + from httpx_sse import SSEError, aconnect_sse from a2a.client.errors import A2AClientHTTPError, A2AClientJSONError @@ -53,7 +54,10 @@ async def get_agent_card( ) response.raise_for_status() public_agent_card_data = response.json() - logger.info("Successfully fetched public agent card data: %s", public_agent_card_data) # Added for verbosity + logger.info( + 'Successfully fetched public agent card data: %s', + public_agent_card_data, + ) # Added for verbosity # print(f"DEBUG: Fetched public agent card data:\n{json.dumps(public_agent_card_data, indent=2)}") # Added for direct output agent_card = AgentCard.model_validate(public_agent_card_data) except httpx.HTTPStatusError as e: @@ -77,9 +81,9 @@ async def get_agent_card( # The extended card URL is relative to the agent's base URL specified *in* the agent card. if not agent_card.url: logger.warning( - "Agent card (from %s) indicates support for an extended card " + 'Agent card (from %s) indicates support for an extended card ' "but does not specify its own base 'url' field. " - "Cannot fetch extended card. Proceeding with public card.", + 'Cannot fetch extended card. Proceeding with public card.', public_card_url, ) return agent_card @@ -102,19 +106,30 @@ async def get_agent_card( ) extended_response.raise_for_status() extended_agent_card_data = extended_response.json() - logger.info("Successfully fetched extended agent card data: %s", extended_agent_card_data) # Added for verbosity - print(f"DEBUG: Fetched extended agent card data:\n{json.dumps(extended_agent_card_data, indent=2)}") # Added for direct output + logger.info( + 'Successfully fetched extended agent card data: %s', + extended_agent_card_data, + ) # Added for verbosity + print( + f'DEBUG: Fetched extended agent card data:\n{json.dumps(extended_agent_card_data, indent=2)}' + ) # Added for direct output # This new card data replaces the old one entirely agent_card = AgentCard.model_validate(extended_agent_card_data) logger.info( 'Successfully fetched and using extended agent card from %s', full_extended_card_url, ) - except (httpx.HTTPStatusError, httpx.RequestError, json.JSONDecodeError, ValidationError) as e: + except ( + httpx.HTTPStatusError, + httpx.RequestError, + json.JSONDecodeError, + ValidationError, + ) as e: logger.warning( 'Failed to fetch or parse extended agent card from %s. Error: %s. ' 'Proceeding with the initially fetched public agent card.', - full_extended_card_url, e + full_extended_card_url, + e, ) # Fallback to the already parsed public_agent_card (which is 'agent_card' at this point) diff --git a/src/a2a/server/apps/starlette_app.py b/src/a2a/server/apps/starlette_app.py index 1be24d4f..2600149b 100644 --- a/src/a2a/server/apps/starlette_app.py +++ b/src/a2a/server/apps/starlette_app.py @@ -1,6 +1,7 @@ import json import logging import traceback + from collections.abc import AsyncGenerator from typing import Any @@ -37,6 +38,7 @@ ) from a2a.utils.errors import MethodNotImplementedError + logger = logging.getLogger(__name__) @@ -202,7 +204,7 @@ async def _process_non_streaming_request( def _create_response( self, handler_result: ( - AsyncGenerator[SendStreamingMessageResponse, None] + AsyncGenerator[SendStreamingMessageResponse] | JSONRPCErrorResponse | JSONRPCResponse ), @@ -225,8 +227,8 @@ def _create_response( if isinstance(handler_result, AsyncGenerator): # Result is a stream of SendStreamingMessageResponse objects async def event_generator( - stream: AsyncGenerator[SendStreamingMessageResponse, None], - ) -> AsyncGenerator[dict[str, str], None]: + stream: AsyncGenerator[SendStreamingMessageResponse], + ) -> AsyncGenerator[dict[str, str]]: async for item in stream: yield {'data': item.root.model_dump_json(exclude_none=True)} @@ -247,29 +249,35 @@ async def _handle_get_agent_card(self, request: Request) -> JSONResponse: """Handles GET requests for the agent card.""" # Construct the public view of the agent card. public_card_data = { - "version": self.agent_card.version, - "name": self.agent_card.name, - "providerName": self.agent_card.provider.organization if self.agent_card.provider else None, - "url": self.agent_card.url, - "authentication": self.agent_card.authentication.model_dump(mode='json', exclude_none=True) - if self.agent_card.authentication else None, # authentication is a single object, can be None if made Optional - "skills": [ + 'version': self.agent_card.version, + 'name': self.agent_card.name, + 'providerName': self.agent_card.provider.organization + if self.agent_card.provider + else None, + 'url': self.agent_card.url, + 'authentication': self.agent_card.authentication.model_dump( + mode='json', exclude_none=True + ) + if self.agent_card.authentication + else None, # authentication is a single object, can be None if made Optional + 'skills': [ f.model_dump(mode='json', exclude_none=True) - for f in self.agent_card.skills if f.id == 'hello_world' # Explicitly filter for public skills + for f in self.agent_card.skills + if f.id == 'hello_world' # Explicitly filter for public skills ] if self.agent_card.skills - else [], # Default to empty list if no skills - "capabilities": self.agent_card.capabilities.model_dump( + else [], # Default to empty list if no skills + 'capabilities': self.agent_card.capabilities.model_dump( mode='json', exclude_none=True ), - "supportsAuthenticatedExtendedCard": ( + 'supportsAuthenticatedExtendedCard': ( self.agent_card.supportsAuthenticatedExtendedCard ), # Include other fields from types.py AgentCard designated as public - "description": self.agent_card.description, - "documentationUrl": self.agent_card.documentationUrl, - "defaultInputModes": self.agent_card.defaultInputModes, - "defaultOutputModes": self.agent_card.defaultOutputModes, + 'description': self.agent_card.description, + 'documentationUrl': self.agent_card.documentationUrl, + 'defaultInputModes': self.agent_card.defaultInputModes, + 'defaultOutputModes': self.agent_card.defaultOutputModes, } # Filter out None values from the public card data. public_card_data_cleaned = { @@ -283,7 +291,7 @@ async def _handle_get_authenticated_extended_agent_card( """Handles GET requests for the authenticated extended agent card.""" if not self.agent_card.supportsAuthenticatedExtendedCard: return JSONResponse( - {"error": "Extended agent card not supported or not enabled."}, + {'error': 'Extended agent card not supported or not enabled.'}, status_code=404, ) @@ -357,7 +365,9 @@ def build( Returns: A configured Starlette application instance. """ - app_routes = self.routes(agent_card_url, extended_agent_card_url, rpc_url) + app_routes = self.routes( + agent_card_url, extended_agent_card_url, rpc_url + ) if 'routes' in kwargs: kwargs['routes'].extend(app_routes) else: diff --git a/src/a2a/types.py b/src/a2a/types.py index 89dd6209..0e9602c6 100644 --- a/src/a2a/types.py +++ b/src/a2a/types.py @@ -4,7 +4,7 @@ from __future__ import annotations from enum import Enum -from typing import Any, Literal, Optional +from typing import Any, Literal from pydantic import BaseModel, Field, RootModel @@ -1408,7 +1408,7 @@ class AgentCard(BaseModel): """ The version of the agent - format is up to the provider. """ - supportsAuthenticatedExtendedCard: Optional[bool] = Field(default=None) + supportsAuthenticatedExtendedCard: bool | None = Field(default=None) """ Optional field indicating there is an extended card available post authentication at the /agent/authenticatedExtendedCard endpoint. """ From afa681a72e01a1ad863ab8400be34558af2d75fd Mon Sep 17 00:00:00 2001 From: Mandar Deolalikar Date: Wed, 21 May 2025 22:43:47 -0700 Subject: [PATCH 3/9] Pass a default and an extended card to the starlette app. Also add tests --- examples/helloworld/__main__.py | 28 +++- examples/helloworld/test_client.py | 10 +- src/a2a/client/client.py | 7 +- src/a2a/server/apps/starlette_app.py | 67 +++----- tests/README.md | 11 ++ tests/client/test_client.py | 227 ++++++++++++++++++++++----- tests/server/test_integration.py | 75 ++++++++- 7 files changed, 326 insertions(+), 99 deletions(-) create mode 100644 tests/README.md diff --git a/examples/helloworld/__main__.py b/examples/helloworld/__main__.py index 2cde3afd..9396aade 100644 --- a/examples/helloworld/__main__.py +++ b/examples/helloworld/__main__.py @@ -6,7 +6,6 @@ from a2a.server.request_handlers import DefaultRequestHandler from a2a.server.tasks import InMemoryTaskStore from a2a.types import ( - AgentAuthentication, AgentCapabilities, AgentCard, AgentSkill, @@ -30,7 +29,8 @@ examples=['super hi', 'give me a super hello'], ) - agent_card = AgentCard( + # This will be the public-facing agent card + public_agent_card = AgentCard( name='Hello World Agent', description='Just a hello world agent', url='http://localhost:9999/', @@ -38,9 +38,21 @@ defaultInputModes=['text'], defaultOutputModes=['text'], capabilities=AgentCapabilities(streaming=True), - skills=[skill, extended_skill], # Include both skills - authentication=AgentAuthentication(schemes=['public']), - # Adding this line to enable extended card support: + skills=[skill], # Only the basic skill for the public card + supportsAuthenticatedExtendedCard=True, + ) + + # This will be the authenticated extended agent card + # It includes the additional 'extended_skill' + specific_extended_agent_card = AgentCard( + name='Hello World Agent - Extended Edition', # Different name for clarity + description='The full-featured hello world agent for authenticated users.', + url='http://localhost:9999/', + version='1.0.1', # Could even be a different version + defaultInputModes=['text'], + defaultOutputModes=['text'], + capabilities=AgentCapabilities(streaming=True), # Could have different capabilities + skills=[skill, extended_skill], # Both skills for the extended card supportsAuthenticatedExtendedCard=True, ) @@ -49,9 +61,9 @@ task_store=InMemoryTaskStore(), ) - server = A2AStarletteApplication( - agent_card=agent_card, http_handler=request_handler - ) + server = A2AStarletteApplication(agent_card=public_agent_card, + http_handler=request_handler, + extended_agent_card=specific_extended_agent_card) import uvicorn uvicorn.run(server.build(), host='0.0.0.0', port=9999) diff --git a/examples/helloworld/test_client.py b/examples/helloworld/test_client.py index 561784ad..e89508dc 100644 --- a/examples/helloworld/test_client.py +++ b/examples/helloworld/test_client.py @@ -1,7 +1,10 @@ -from a2a.client import A2AClient +import logging # Import the logging module from typing import Any -import httpx from uuid import uuid4 + +import httpx + +from a2a.client import A2AClient from a2a.types import ( SendMessageRequest, MessageSendParams, @@ -10,6 +13,9 @@ async def main() -> None: + # Configure logging to show INFO level messages + logging.basicConfig(level=logging.INFO) + async with httpx.AsyncClient() as httpx_client: client = await A2AClient.get_client_from_agent_card_url( httpx_client, 'http://localhost:9999' diff --git a/src/a2a/client/client.py b/src/a2a/client/client.py index 49bda410..3a58ec9c 100644 --- a/src/a2a/client/client.py +++ b/src/a2a/client/client.py @@ -1,10 +1,12 @@ import json +import logging from collections.abc import AsyncGenerator from typing import Any from uuid import uuid4 import httpx from httpx_sse import SSEError, aconnect_sse +from pydantic import ValidationError from a2a.client.errors import A2AClientHTTPError, A2AClientJSONError from a2a.types import (AgentCard, CancelTaskRequest, CancelTaskResponse, @@ -17,6 +19,7 @@ SetTaskPushNotificationConfigResponse) from a2a.utils.telemetry import SpanKind, trace_class +logger = logging.getLogger(__name__) class A2ACardResolver: """Agent Card resolver.""" @@ -122,9 +125,7 @@ async def get_agent_card( 'Successfully fetched extended agent card data: %s', extended_agent_card_data, ) # Added for verbosity - print( - f'DEBUG: Fetched extended agent card data:\n{json.dumps(extended_agent_card_data, indent=2)}' - ) # Added for direct output + # This new card data replaces the old one entirely agent_card = AgentCard.model_validate(extended_agent_card_data) logger.info( diff --git a/src/a2a/server/apps/starlette_app.py b/src/a2a/server/apps/starlette_app.py index a40bc56c..a236d692 100644 --- a/src/a2a/server/apps/starlette_app.py +++ b/src/a2a/server/apps/starlette_app.py @@ -34,15 +34,23 @@ class A2AStarletteApplication: (SSE). """ - def __init__(self, agent_card: AgentCard, http_handler: RequestHandler): + def __init__( + self, + agent_card: AgentCard, + http_handler: RequestHandler, + extended_agent_card: AgentCard | None = None, + ): """Initializes the A2AStarletteApplication. Args: agent_card: The AgentCard describing the agent's capabilities. http_handler: The handler instance responsible for processing A2A requests via http. + extended_agent_card: An optional, distinct AgentCard to be served + at the authenticated extended card endpoint. """ self.agent_card = agent_card + self.extended_agent_card = extended_agent_card self.handler = JSONRPCHandler( agent_card=agent_card, request_handler=http_handler ) @@ -264,44 +272,11 @@ async def _handle_get_agent_card(self, request: Request) -> JSONResponse: Returns: A JSONResponse containing the agent card data. """ - - # Construct the public view of the agent card. - public_card_data = { - 'version': self.agent_card.version, - 'name': self.agent_card.name, - 'providerName': self.agent_card.provider.organization - if self.agent_card.provider - else None, - 'url': self.agent_card.url, - 'authentication': self.agent_card.authentication.model_dump( - mode='json', exclude_none=True - ) - if self.agent_card.authentication - else None, # authentication is a single object, can be None if made Optional - 'skills': [ - f.model_dump(mode='json', exclude_none=True) - for f in self.agent_card.skills - if f.id == 'hello_world' # Explicitly filter for public skills - ] - if self.agent_card.skills - else [], # Default to empty list if no skills - 'capabilities': self.agent_card.capabilities.model_dump( - mode='json', exclude_none=True - ), - 'supportsAuthenticatedExtendedCard': ( - self.agent_card.supportsAuthenticatedExtendedCard - ), - # Include other fields from types.py AgentCard designated as public - 'description': self.agent_card.description, - 'documentationUrl': self.agent_card.documentationUrl, - 'defaultInputModes': self.agent_card.defaultInputModes, - 'defaultOutputModes': self.agent_card.defaultOutputModes, - } - # Filter out None values from the public card data. - public_card_data_cleaned = { - k: v for k, v in public_card_data.items() if v is not None - } - return JSONResponse(public_card_data_cleaned) + # The public agent card is a direct serialization of the agent_card + # provided at initialization. + return JSONResponse( + self.agent_card.model_dump(mode='json', exclude_none=True) + ) async def _handle_get_authenticated_extended_agent_card( self, request: Request @@ -313,11 +288,15 @@ async def _handle_get_authenticated_extended_agent_card( status_code=404, ) - # Authentication and authorization are NOT YET IMPLEMENTED for this endpoint. - # As per current requirements, if 'supportsAuthenticatedExtendedCard' is true, - # this endpoint returns the complete agent card. - # In the future, proper authentication checks will be added here, and the - # returned card may be filtered based on the client's authorization scopes. + # If an explicit extended_agent_card is provided, serve that. + if self.extended_agent_card: + return JSONResponse( + self.extended_agent_card.model_dump( + mode='json', exclude_none=True + ) + ) + # Otherwise, if supportsAuthenticatedExtendedCard is true but no specific + # extended card is set, serve the main agent_card. return JSONResponse( self.agent_card.model_dump(mode='json', exclude_none=True) ) diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 00000000..bab99450 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,11 @@ +## Running the tests + +1. Run the tests + ```bash + uv run pytest -v -s client/test_client.py + ``` + +In case of failures, you can cleanup the cache: + +1. `uv clean` +2. `rm -fR .pytest_cache .venv __pycache__` diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 7c7926ce..24d06570 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -1,45 +1,23 @@ import json - from collections.abc import AsyncGenerator from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import httpx import pytest - from httpx_sse import EventSource, ServerSentEvent -from a2a.client import ( - A2ACardResolver, - A2AClient, - A2AClientHTTPError, - A2AClientJSONError, - create_text_message_object, -) -from a2a.types import ( - A2ARequest, - AgentCapabilities, - AgentCard, - AgentSkill, - CancelTaskRequest, - CancelTaskResponse, - CancelTaskSuccessResponse, - GetTaskRequest, - GetTaskResponse, - InvalidParamsError, - JSONRPCErrorResponse, - MessageSendParams, - Role, - SendMessageRequest, - SendMessageResponse, - SendMessageSuccessResponse, - SendStreamingMessageRequest, - SendStreamingMessageResponse, - TaskIdParams, - TaskNotCancelableError, - TaskQueryParams, -) - +from a2a.client import (A2ACardResolver, A2AClient, A2AClientHTTPError, + A2AClientJSONError, create_text_message_object) +from a2a.types import (A2ARequest, AgentCapabilities, AgentCard, AgentSkill, + CancelTaskRequest, CancelTaskResponse, + CancelTaskSuccessResponse, GetTaskRequest, + GetTaskResponse, InvalidParamsError, + JSONRPCErrorResponse, MessageSendParams, Role, + SendMessageRequest, SendMessageResponse, + SendMessageSuccessResponse, SendStreamingMessageRequest, + SendStreamingMessageResponse, TaskIdParams, + TaskNotCancelableError, TaskQueryParams) AGENT_CARD = AgentCard( name='Hello World Agent', @@ -60,6 +38,30 @@ ], ) +AGENT_CARD_EXTENDED = AGENT_CARD.model_copy( + update={ + 'name': 'Hello World Agent - Extended Edition', + 'skills': AGENT_CARD.skills + + [ + AgentSkill( + id='extended_skill', + name='Super Greet', + description='A more enthusiastic greeting.', + tags=['extended'], + examples=['super hi'], + ) + ], + 'version': '1.0.1', + } +) + +AGENT_CARD_SUPPORTS_EXTENDED = AGENT_CARD.model_copy( + update={'supportsAuthenticatedExtendedCard': True} +) +AGENT_CARD_NO_URL_SUPPORTS_EXTENDED = AGENT_CARD_SUPPORTS_EXTENDED.model_copy( + update={'url': ''} +) + MINIMAL_TASK: dict[str, Any] = { 'id': 'task-abc', 'contextId': 'session-xyz', @@ -97,6 +99,7 @@ class TestA2ACardResolver: BASE_URL = 'http://example.com' AGENT_CARD_PATH = '/.well-known/agent.json' FULL_AGENT_CARD_URL = f'{BASE_URL}{AGENT_CARD_PATH}' + EXTENDED_AGENT_CARD_PATH = '/agent/authenticatedExtendedCard' # Default path @pytest.mark.asyncio async def test_init_strips_slashes(self, mock_httpx_client: AsyncMock): @@ -104,11 +107,13 @@ async def test_init_strips_slashes(self, mock_httpx_client: AsyncMock): httpx_client=mock_httpx_client, base_url='http://example.com/', agent_card_path='/.well-known/agent.json/', + extended_agent_card_path='/agent/authenticatedExtendedCard/', ) assert resolver.base_url == 'http://example.com' assert ( resolver.agent_card_path == '.well-known/agent.json/' ) # Path is only lstrip'd + assert resolver.extended_agent_card_path == 'agent/authenticatedExtendedCard/' resolver_no_leading_slash_path = A2ACardResolver( httpx_client=AsyncMock(), @@ -122,10 +127,12 @@ async def test_init_strips_slashes(self, mock_httpx_client: AsyncMock): ) @pytest.mark.asyncio - async def test_get_agent_card_success(self, mock_httpx_client: AsyncMock): + async def test_get_agent_card_success_public_only( + self, mock_httpx_client: AsyncMock + ): mock_response = AsyncMock(spec=httpx.Response) mock_response.status_code = 200 - mock_response.json.return_value = AGENT_CARD.model_dump() + mock_response.json.return_value = AGENT_CARD.model_dump(mode='json') mock_httpx_client.get.return_value = mock_response resolver = A2ACardResolver( @@ -141,6 +148,143 @@ async def test_get_agent_card_success(self, mock_httpx_client: AsyncMock): mock_response.raise_for_status.assert_called_once() assert isinstance(agent_card, AgentCard) assert agent_card == AGENT_CARD + # Ensure only one call was made (for the public card) + assert mock_httpx_client.get.call_count == 1 + + @pytest.mark.asyncio + async def test_get_agent_card_success_with_extended_card( + self, mock_httpx_client: AsyncMock + ): + public_card_response = AsyncMock(spec=httpx.Response) + public_card_response.status_code = 200 + public_card_response.json.return_value = ( + AGENT_CARD_SUPPORTS_EXTENDED.model_dump(mode='json') + ) + + extended_card_response = AsyncMock(spec=httpx.Response) + extended_card_response.status_code = 200 + extended_card_response.json.return_value = AGENT_CARD_EXTENDED.model_dump( + mode='json' + ) + + # Configure side_effect to return different responses for different calls + mock_httpx_client.get.side_effect = [ + public_card_response, + extended_card_response, + ] + + resolver = A2ACardResolver( + httpx_client=mock_httpx_client, + base_url=self.BASE_URL, + agent_card_path=self.AGENT_CARD_PATH, + extended_agent_card_path=self.EXTENDED_AGENT_CARD_PATH, + ) + agent_card_result = await resolver.get_agent_card() + + assert mock_httpx_client.get.call_count == 2 + public_call_args = mock_httpx_client.get.call_args_list[0] + extended_call_args = mock_httpx_client.get.call_args_list[1] + + assert public_call_args[0][0] == self.FULL_AGENT_CARD_URL + # The extended card URL is based on the AGENT_CARD_SUPPORTS_EXTENDED.url + expected_extended_url = ( + f'{AGENT_CARD_SUPPORTS_EXTENDED.url.rstrip("/")}/' + f'{self.EXTENDED_AGENT_CARD_PATH.lstrip("/")}' + ) + assert extended_call_args[0][0] == expected_extended_url + + public_card_response.raise_for_status.assert_called_once() + extended_card_response.raise_for_status.assert_called_once() + + assert isinstance(agent_card_result, AgentCard) + assert agent_card_result == AGENT_CARD_EXTENDED # Should return the extended card + + @pytest.mark.asyncio + async def test_get_agent_card_extended_card_fetch_fails_http_error( + self, mock_httpx_client: AsyncMock + ): + public_card_response = AsyncMock(spec=httpx.Response) + public_card_response.status_code = 200 + public_card_response.json.return_value = ( + AGENT_CARD_SUPPORTS_EXTENDED.model_dump(mode='json') + ) + + extended_card_http_error = httpx.HTTPStatusError( + 'Extended card not found', + request=MagicMock(), + response=MagicMock(status_code=404), + ) + + mock_httpx_client.get.side_effect = [ + public_card_response, + extended_card_http_error, + ] + + resolver = A2ACardResolver( + httpx_client=mock_httpx_client, base_url=self.BASE_URL + ) + agent_card_result = await resolver.get_agent_card() + + assert mock_httpx_client.get.call_count == 2 + assert agent_card_result == AGENT_CARD_SUPPORTS_EXTENDED # Fallback to public + + @pytest.mark.asyncio + async def test_get_agent_card_extended_card_fetch_fails_json_error( + self, mock_httpx_client: AsyncMock + ): + public_card_response = AsyncMock(spec=httpx.Response) + public_card_response.status_code = 200 + public_card_response.json.return_value = ( + AGENT_CARD_SUPPORTS_EXTENDED.model_dump(mode='json') + ) + + extended_card_response_bad_json = AsyncMock(spec=httpx.Response) + extended_card_response_bad_json.status_code = 200 + extended_card_response_bad_json.json.side_effect = json.JSONDecodeError( + 'Bad JSON', 'doc', 0 + ) + + mock_httpx_client.get.side_effect = [ + public_card_response, + extended_card_response_bad_json, + ] + + resolver = A2ACardResolver( + httpx_client=mock_httpx_client, base_url=self.BASE_URL + ) + agent_card_result = await resolver.get_agent_card() + + assert mock_httpx_client.get.call_count == 2 + assert agent_card_result == AGENT_CARD_SUPPORTS_EXTENDED # Fallback to public + + @pytest.mark.asyncio + async def test_get_agent_card_supports_extended_but_no_url_in_card( + self, mock_httpx_client: AsyncMock + ): + # Public card indicates support for extended, but has no 'url' field itself + public_card_response = AsyncMock(spec=httpx.Response) + public_card_response.status_code = 200 + public_card_response.json.return_value = ( + AGENT_CARD_NO_URL_SUPPORTS_EXTENDED.model_dump(mode='json') + ) + + mock_httpx_client.get.return_value = public_card_response + + resolver = A2ACardResolver( + httpx_client=mock_httpx_client, base_url=self.BASE_URL + ) + + # Patch logger to check for warning + with patch('a2a.client.client.logger') as mock_logger: + agent_card_result = await resolver.get_agent_card() + + assert mock_httpx_client.get.call_count == 1 # Only public card fetched + assert agent_card_result == AGENT_CARD_NO_URL_SUPPORTS_EXTENDED + mock_logger.warning.assert_called_once() + assert ( + "does not specify its own base 'url' field" + in mock_logger.warning.call_args[0][0] + ) @pytest.mark.asyncio async def test_get_agent_card_http_status_error( @@ -167,7 +311,8 @@ async def test_get_agent_card_http_status_error( await resolver.get_agent_card() assert exc_info.value.status_code == 404 - assert 'HTTP Error 404: Not Found' in str(exc_info.value) + assert f'Failed to fetch public agent card from {self.FULL_AGENT_CARD_URL}' in str(exc_info.value) + assert 'Not Found' in str(exc_info.value) mock_httpx_client.get.assert_called_once_with(self.FULL_AGENT_CARD_URL) @pytest.mark.asyncio @@ -176,6 +321,7 @@ async def test_get_agent_card_json_decode_error( ): mock_response = AsyncMock(spec=httpx.Response) mock_response.status_code = 200 + # Define json_error before using it json_error = json.JSONDecodeError('Expecting value', 'doc', 0) mock_response.json.side_effect = json_error mock_httpx_client.get.return_value = mock_response @@ -189,7 +335,9 @@ async def test_get_agent_card_json_decode_error( with pytest.raises(A2AClientJSONError) as exc_info: await resolver.get_agent_card() - assert 'JSON Error: Expecting value' in str(exc_info.value) + # Assertions using exc_info must be after the with block + assert f'Failed to parse JSON for public agent card from {self.FULL_AGENT_CARD_URL}' in str(exc_info.value) + assert 'Expecting value' in str(exc_info.value) mock_httpx_client.get.assert_called_once_with(self.FULL_AGENT_CARD_URL) @pytest.mark.asyncio @@ -209,9 +357,8 @@ async def test_get_agent_card_request_error( await resolver.get_agent_card() assert exc_info.value.status_code == 503 - assert 'Network communication error: Network issue' in str( - exc_info.value - ) + assert f'Network communication error fetching public agent card from {self.FULL_AGENT_CARD_URL}' in str(exc_info.value) + assert 'Network issue' in str(exc_info.value) mock_httpx_client.get.assert_called_once_with(self.FULL_AGENT_CARD_URL) diff --git a/tests/server/test_integration.py b/tests/server/test_integration.py index b116b2cc..72241efc 100644 --- a/tests/server/test_integration.py +++ b/tests/server/test_integration.py @@ -1,10 +1,8 @@ import asyncio - from typing import Any from unittest import mock import pytest - from starlette.responses import JSONResponse from starlette.routing import Route from starlette.testclient import TestClient @@ -58,6 +56,20 @@ 'version': '1.0', } +EXTENDED_AGENT_CARD_DATA: dict[str, Any] = { + **MINIMAL_AGENT_CARD, + 'name': 'TestAgent Extended', + 'description': 'Test Agent with more details', + 'skills': [ + MINIMAL_AGENT_SKILL, + { + 'id': 'skill-extended', + 'name': 'Extended Skill', + 'description': 'Does more things', + 'tags': ['extended'], + }, + ], +} TEXT_PART_DATA: dict[str, Any] = {'kind': 'text', 'text': 'Hello'} DATA_PART_DATA: dict[str, Any] = {'kind': 'data', 'data': {'key': 'value'}} @@ -83,6 +95,11 @@ def agent_card(): return AgentCard(**MINIMAL_AGENT_CARD) +@pytest.fixture +def extended_agent_card_fixture(): + return AgentCard(**EXTENDED_AGENT_CARD_DATA) + + @pytest.fixture def handler(): handler = mock.AsyncMock() @@ -120,6 +137,60 @@ def test_agent_card_endpoint(client: TestClient, agent_card: AgentCard): assert 'streaming' in data['capabilities'] +def test_authenticated_extended_agent_card_endpoint_not_supported( + agent_card: AgentCard, handler: mock.AsyncMock +): + """Test extended card endpoint returns 404 if not supported by main card.""" + # Ensure supportsAuthenticatedExtendedCard is False or None + agent_card.supportsAuthenticatedExtendedCard = False + app_instance = A2AStarletteApplication(agent_card, handler) + # The route should not even be added if supportsAuthenticatedExtendedCard is false + # So, building the app and trying to hit it should result in 404 from Starlette itself + client = TestClient(app_instance.build()) + response = client.get('/agent/authenticatedExtendedCard') + assert response.status_code == 404 # Starlette's default for no route + + +def test_authenticated_extended_agent_card_endpoint_supported_no_specific_extended_card( + agent_card: AgentCard, handler: mock.AsyncMock +): + """Test extended card endpoint returns main card if supported but no specific extended card is set.""" + agent_card.supportsAuthenticatedExtendedCard = True + app_instance = A2AStarletteApplication(agent_card, handler) + client = TestClient(app_instance.build()) + + response = client.get('/agent/authenticatedExtendedCard') + assert response.status_code == 200 + data = response.json() + assert data['name'] == agent_card.name # Should be the main agent card + assert data['version'] == agent_card.version + + +def test_authenticated_extended_agent_card_endpoint_supported_with_specific_extended_card( + agent_card: AgentCard, + extended_agent_card_fixture: AgentCard, + handler: mock.AsyncMock, +): + """Test extended card endpoint returns the specific extended card when provided.""" + agent_card.supportsAuthenticatedExtendedCard = True # Main card must support it + app_instance = A2AStarletteApplication( + agent_card, handler, extended_agent_card=extended_agent_card_fixture + ) + client = TestClient(app_instance.build()) + + response = client.get('/agent/authenticatedExtendedCard') + assert response.status_code == 200 + data = response.json() + # Verify it's the extended card's data + assert data['name'] == extended_agent_card_fixture.name + assert data['version'] == extended_agent_card_fixture.version + assert len(data['skills']) == len(extended_agent_card_fixture.skills) + assert any( + skill['id'] == 'skill-extended' for skill in data['skills'] + ), "Extended skill not found in served card" + + + def test_agent_card_custom_url( app: A2AStarletteApplication, agent_card: AgentCard ): From 1490d7ef44b3b7f486e767b13caa4294383d54f1 Mon Sep 17 00:00:00 2001 From: Mandar Deolalikar Date: Thu, 22 May 2025 21:37:14 -0700 Subject: [PATCH 4/9] Address review comments --- examples/helloworld/test_client.py | 68 ++++++++++++-- src/a2a/client/client.py | 113 ++++++++--------------- tests/client/test_client.py | 143 +++++++---------------------- 3 files changed, 131 insertions(+), 193 deletions(-) diff --git a/examples/helloworld/test_client.py b/examples/helloworld/test_client.py index e89508dc..146d5485 100644 --- a/examples/helloworld/test_client.py +++ b/examples/helloworld/test_client.py @@ -4,22 +4,74 @@ import httpx -from a2a.client import A2AClient -from a2a.types import ( - SendMessageRequest, - MessageSendParams, - SendStreamingMessageRequest, -) +from a2a.client import A2ACardResolver, A2AClient +from a2a.types import (AgentCard, MessageSendParams, SendMessageRequest, + SendStreamingMessageRequest) async def main() -> None: + PUBLIC_AGENT_CARD_PATH = "/.well-known/agent.json" + EXTENDED_AGENT_CARD_PATH = "/agent/authenticatedExtendedCard" + # Configure logging to show INFO level messages logging.basicConfig(level=logging.INFO) + logger = logging.getLogger(__name__) # Get a logger instance + + base_url = 'http://localhost:9999' async with httpx.AsyncClient() as httpx_client: - client = await A2AClient.get_client_from_agent_card_url( - httpx_client, 'http://localhost:9999' + # Initialize A2ACardResolver + resolver = A2ACardResolver( + httpx_client=httpx_client, + base_url=base_url, + # agent_card_path uses default, extended_agent_card_path also uses default + ) + + # Fetch Public Agent Card and Initialize Client + public_agent_card: AgentCard | None = None + extended_agent_card: AgentCard | None = None + final_agent_card_to_use: AgentCard | None = None + + try: + logger.info(f"Attempting to fetch public agent card from: {base_url}{PUBLIC_AGENT_CARD_PATH}") + public_agent_card = await resolver.get_agent_card() # Fetches from default public path + logger.info("Successfully fetched public agent card:") + logger.info(public_agent_card.model_dump_json(indent=2, exclude_none=True)) + + # --- Conditional Step: Fetch Extended Agent Card + if public_agent_card and public_agent_card.supportsAuthenticatedExtendedCard: + logger.info(f"\nPublic card supports authenticated extended card. Attempting to fetch from: {base_url}{EXTENDED_AGENT_CARD_PATH}") + auth_headers_dict = {"Authorization": "Bearer dummy-token-for-extended-card"} + extended_agent_card = await resolver.get_agent_card( + relative_card_path=EXTENDED_AGENT_CARD_PATH, # Or resolver.extended_agent_card_path + http_kwargs={"headers": auth_headers_dict} + ) + logger.info("Successfully fetched authenticated extended agent card:") + logger.info(extended_agent_card.model_dump_json(indent=2, exclude_none=True)) + else: + logger.info("\nPublic card does not support authenticated extended card, or public card not fetched.") + + except Exception as e: + logger.error(f"Error during agent card fetching: {e}", exc_info=True) + # If public card fetching failed, or extended card fetching failed after public card indicated support, + # we might not have a card to use. + + # Determine which card to use and Initialize Client + if extended_agent_card: + final_agent_card_to_use = extended_agent_card + logger.info("\nUsing AUTHENTICATED EXTENDED agent card for client initialization.") + elif public_agent_card: + final_agent_card_to_use = public_agent_card + logger.info("\nUsing PUBLIC agent card for client initialization.") + else: + logger.error("\nNo agent card successfully fetched. Cannot initialize client.") + return # Cannot proceed + + client = A2AClient( + httpx_client=httpx_client, agent_card=final_agent_card_to_use ) + logger.info("A2AClient initialized.") + send_message_payload: dict[str, Any] = { 'message': { 'role': 'user', diff --git a/src/a2a/client/client.py b/src/a2a/client/client.py index 3a58ec9c..2dc748a1 100644 --- a/src/a2a/client/client.py +++ b/src/a2a/client/client.py @@ -44,13 +44,19 @@ def __init__( self.httpx_client = httpx_client async def get_agent_card( - self, http_kwargs: dict[str, Any] | None = None + self, + relative_card_path: str | None = None, + http_kwargs: dict[str, Any] | None = None, ) -> AgentCard: - # Fetch the initial public agent card - public_card_url = f'{self.base_url}/{self.agent_card_path}' - """Fetches the agent card from the specified URL. + """Fetches an agent card from a specified path relative to the base_url. + + If relative_card_path is None, it defaults to the resolver's configured + agent_card_path (for the public agent card). Args: + relative_card_path: Optional path to the agent card endpoint, + relative to the base URL. If None, uses the default public + agent card path. http_kwargs: Optional dictionary of keyword arguments to pass to the underlying httpx.get request. @@ -62,89 +68,45 @@ async def get_agent_card( A2AClientJSONError: If the response body cannot be decoded as JSON or validated against the AgentCard schema. """ + if relative_card_path is None: + # Use the default public agent card path configured during initialization + path_segment = self.agent_card_path + else: + path_segment = relative_card_path.lstrip('/') + + target_url = f'{self.base_url}/{path_segment}' + try: response = await self.httpx_client.get( - public_card_url, + target_url, **(http_kwargs or {}), ) response.raise_for_status() - public_agent_card_data = response.json() + agent_card_data = response.json() logger.info( - 'Successfully fetched public agent card data: %s', - public_agent_card_data, - ) # Added for verbosity - # print(f"DEBUG: Fetched public agent card data:\n{json.dumps(public_agent_card_data, indent=2)}") # Added for direct output - agent_card = AgentCard.model_validate(public_agent_card_data) + 'Successfully fetched agent card data from %s: %s', + target_url, + agent_card_data, + ) + agent_card = AgentCard.model_validate(agent_card_data) except httpx.HTTPStatusError as e: raise A2AClientHTTPError( e.response.status_code, - f'Failed to fetch public agent card from {public_card_url}: {e}', + f'Failed to fetch agent card from {target_url}: {e}', ) from e except json.JSONDecodeError as e: raise A2AClientJSONError( - f'Failed to parse JSON for public agent card from {public_card_url}: {e}' + f'Failed to parse JSON for agent card from {target_url}: {e}' ) from e except httpx.RequestError as e: raise A2AClientHTTPError( 503, - f'Network communication error fetching public agent card from {public_card_url}: {e}', + f'Network communication error fetching agent card from {target_url}: {e}', + ) from e + except ValidationError as e: # Pydantic validation error + raise A2AClientJSONError( + f'Failed to validate agent card structure from {target_url}: {e.json()}' ) from e - - # Check for supportsAuthenticatedExtendedCard - if agent_card.supportsAuthenticatedExtendedCard: - # Construct URL for the extended card. - # The extended card URL is relative to the agent's base URL specified *in* the agent card. - if not agent_card.url: - logger.warning( - 'Agent card (from %s) indicates support for an extended card ' - "but does not specify its own base 'url' field. " - 'Cannot fetch extended card. Proceeding with public card.', - public_card_url, - ) - return agent_card - - extended_card_base_url = agent_card.url.rstrip('/') - full_extended_card_url = ( - f'{extended_card_base_url}/{self.extended_agent_card_path}' - ) - - logger.info( - 'Attempting to fetch extended agent card from %s', - full_extended_card_url, - ) - try: - # Make another GET request for the extended card - # Note: Authentication headers will be added here when auth is implemented. - extended_response = await self.httpx_client.get( - full_extended_card_url, - **(http_kwargs or {}), # Passing original http_kwargs - ) - extended_response.raise_for_status() - extended_agent_card_data = extended_response.json() - logger.info( - 'Successfully fetched extended agent card data: %s', - extended_agent_card_data, - ) # Added for verbosity - - # This new card data replaces the old one entirely - agent_card = AgentCard.model_validate(extended_agent_card_data) - logger.info( - 'Successfully fetched and using extended agent card from %s', - full_extended_card_url, - ) - except ( - httpx.HTTPStatusError, - httpx.RequestError, - json.JSONDecodeError, - ValidationError, - ) as e: - logger.warning( - 'Failed to fetch or parse extended agent card from %s. Error: %s. ' - 'Proceeding with the initially fetched public agent card.', - full_extended_card_url, - e, - ) - # Fallback to the already parsed public_agent_card (which is 'agent_card' at this point) return agent_card @@ -187,14 +149,17 @@ async def get_client_from_agent_card_url( agent_card_path: str = '/.well-known/agent.json', http_kwargs: dict[str, Any] | None = None, ) -> 'A2AClient': - """Fetches the AgentCard and initializes an A2A client. + """Fetches the public AgentCard and initializes an A2A client. + + This method will always fetch the public agent card. If an authenticated + or extended agent card is required, the A2ACardResolver should be used + directly to fetch the specific card, and then the A2AClient should be + instantiated with it. Args: httpx_client: An async HTTP client instance (e.g., httpx.AsyncClient). base_url: The base URL of the agent's host. agent_card_path: The path to the agent card endpoint, relative to the base URL. - http_kwargs: Optional dictionary of keyword arguments to pass to the - underlying httpx.get request when fetching the agent card. Returns: An initialized `A2AClient` instance. @@ -205,7 +170,7 @@ async def get_client_from_agent_card_url( """ agent_card: AgentCard = await A2ACardResolver( httpx_client, base_url=base_url, agent_card_path=agent_card_path - ).get_agent_card(http_kwargs=http_kwargs) + ).get_agent_card(http_kwargs=http_kwargs) # Fetches public card by default return A2AClient(httpx_client=httpx_client, agent_card=agent_card) async def send_message( diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 24d06570..69fa034d 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -6,6 +6,7 @@ import httpx import pytest from httpx_sse import EventSource, ServerSentEvent +from pydantic import ValidationError as PydanticValidationError from a2a.client import (A2ACardResolver, A2AClient, A2AClientHTTPError, A2AClientJSONError, create_text_message_object) @@ -152,139 +153,58 @@ async def test_get_agent_card_success_public_only( assert mock_httpx_client.get.call_count == 1 @pytest.mark.asyncio - async def test_get_agent_card_success_with_extended_card( - self, mock_httpx_client: AsyncMock - ): - public_card_response = AsyncMock(spec=httpx.Response) - public_card_response.status_code = 200 - public_card_response.json.return_value = ( - AGENT_CARD_SUPPORTS_EXTENDED.model_dump(mode='json') - ) - + async def test_get_agent_card_success_with_specified_path_for_extended_card( + self, mock_httpx_client: AsyncMock): extended_card_response = AsyncMock(spec=httpx.Response) extended_card_response.status_code = 200 extended_card_response.json.return_value = AGENT_CARD_EXTENDED.model_dump( mode='json' ) - # Configure side_effect to return different responses for different calls - mock_httpx_client.get.side_effect = [ - public_card_response, - extended_card_response, - ] + # Mock the single call for the extended card + mock_httpx_client.get.return_value = extended_card_response resolver = A2ACardResolver( httpx_client=mock_httpx_client, base_url=self.BASE_URL, agent_card_path=self.AGENT_CARD_PATH, - extended_agent_card_path=self.EXTENDED_AGENT_CARD_PATH, + extended_agent_card_path=self.EXTENDED_AGENT_CARD_PATH.lstrip('/'), ) - agent_card_result = await resolver.get_agent_card() - - assert mock_httpx_client.get.call_count == 2 - public_call_args = mock_httpx_client.get.call_args_list[0] - extended_call_args = mock_httpx_client.get.call_args_list[1] - - assert public_call_args[0][0] == self.FULL_AGENT_CARD_URL - # The extended card URL is based on the AGENT_CARD_SUPPORTS_EXTENDED.url - expected_extended_url = ( - f'{AGENT_CARD_SUPPORTS_EXTENDED.url.rstrip("/")}/' - f'{self.EXTENDED_AGENT_CARD_PATH.lstrip("/")}' + + # Fetch the extended card by providing its relative path and example auth + auth_kwargs = {"headers": {"Authorization": "Bearer testtoken"}} + agent_card_result = await resolver.get_agent_card( + relative_card_path=resolver.extended_agent_card_path, + http_kwargs=auth_kwargs ) - assert extended_call_args[0][0] == expected_extended_url - public_card_response.raise_for_status.assert_called_once() + expected_extended_url = f'{self.BASE_URL}/{resolver.extended_agent_card_path}' + mock_httpx_client.get.assert_called_once_with(expected_extended_url, **auth_kwargs) extended_card_response.raise_for_status.assert_called_once() assert isinstance(agent_card_result, AgentCard) assert agent_card_result == AGENT_CARD_EXTENDED # Should return the extended card @pytest.mark.asyncio - async def test_get_agent_card_extended_card_fetch_fails_http_error( - self, mock_httpx_client: AsyncMock - ): - public_card_response = AsyncMock(spec=httpx.Response) - public_card_response.status_code = 200 - public_card_response.json.return_value = ( - AGENT_CARD_SUPPORTS_EXTENDED.model_dump(mode='json') - ) - - extended_card_http_error = httpx.HTTPStatusError( - 'Extended card not found', - request=MagicMock(), - response=MagicMock(status_code=404), - ) - - mock_httpx_client.get.side_effect = [ - public_card_response, - extended_card_http_error, - ] - - resolver = A2ACardResolver( - httpx_client=mock_httpx_client, base_url=self.BASE_URL - ) - agent_card_result = await resolver.get_agent_card() - - assert mock_httpx_client.get.call_count == 2 - assert agent_card_result == AGENT_CARD_SUPPORTS_EXTENDED # Fallback to public - - @pytest.mark.asyncio - async def test_get_agent_card_extended_card_fetch_fails_json_error( + async def test_get_agent_card_validation_error( self, mock_httpx_client: AsyncMock ): - public_card_response = AsyncMock(spec=httpx.Response) - public_card_response.status_code = 200 - public_card_response.json.return_value = ( - AGENT_CARD_SUPPORTS_EXTENDED.model_dump(mode='json') - ) - - extended_card_response_bad_json = AsyncMock(spec=httpx.Response) - extended_card_response_bad_json.status_code = 200 - extended_card_response_bad_json.json.side_effect = json.JSONDecodeError( - 'Bad JSON', 'doc', 0 - ) - - mock_httpx_client.get.side_effect = [ - public_card_response, - extended_card_response_bad_json, - ] - - resolver = A2ACardResolver( - httpx_client=mock_httpx_client, base_url=self.BASE_URL - ) - agent_card_result = await resolver.get_agent_card() - - assert mock_httpx_client.get.call_count == 2 - assert agent_card_result == AGENT_CARD_SUPPORTS_EXTENDED # Fallback to public - - @pytest.mark.asyncio - async def test_get_agent_card_supports_extended_but_no_url_in_card( - self, mock_httpx_client: AsyncMock - ): - # Public card indicates support for extended, but has no 'url' field itself - public_card_response = AsyncMock(spec=httpx.Response) - public_card_response.status_code = 200 - public_card_response.json.return_value = ( - AGENT_CARD_NO_URL_SUPPORTS_EXTENDED.model_dump(mode='json') - ) - - mock_httpx_client.get.return_value = public_card_response + mock_response = AsyncMock(spec=httpx.Response) + mock_response.status_code = 200 + # Data that will cause a Pydantic ValidationError + mock_response.json.return_value = {"invalid_field": "value", "name": "Test Agent"} + mock_httpx_client.get.return_value = mock_response resolver = A2ACardResolver( httpx_client=mock_httpx_client, base_url=self.BASE_URL ) - - # Patch logger to check for warning - with patch('a2a.client.client.logger') as mock_logger: - agent_card_result = await resolver.get_agent_card() - - assert mock_httpx_client.get.call_count == 1 # Only public card fetched - assert agent_card_result == AGENT_CARD_NO_URL_SUPPORTS_EXTENDED - mock_logger.warning.assert_called_once() - assert ( - "does not specify its own base 'url' field" - in mock_logger.warning.call_args[0][0] - ) + # The call that is expected to raise an error should be within pytest.raises + with pytest.raises(A2AClientJSONError) as exc_info: + await resolver.get_agent_card() # Fetches from default path + + assert f'Failed to validate agent card structure from {self.FULL_AGENT_CARD_URL}' in str(exc_info.value) + assert 'invalid_field' in str(exc_info.value) # Check if Pydantic error details are present + assert mock_httpx_client.get.call_count == 1 # Should only be called once @pytest.mark.asyncio async def test_get_agent_card_http_status_error( @@ -311,7 +231,7 @@ async def test_get_agent_card_http_status_error( await resolver.get_agent_card() assert exc_info.value.status_code == 404 - assert f'Failed to fetch public agent card from {self.FULL_AGENT_CARD_URL}' in str(exc_info.value) + assert f'Failed to fetch agent card from {self.FULL_AGENT_CARD_URL}' in str(exc_info.value) assert 'Not Found' in str(exc_info.value) mock_httpx_client.get.assert_called_once_with(self.FULL_AGENT_CARD_URL) @@ -336,7 +256,7 @@ async def test_get_agent_card_json_decode_error( await resolver.get_agent_card() # Assertions using exc_info must be after the with block - assert f'Failed to parse JSON for public agent card from {self.FULL_AGENT_CARD_URL}' in str(exc_info.value) + assert f'Failed to parse JSON for agent card from {self.FULL_AGENT_CARD_URL}' in str(exc_info.value) assert 'Expecting value' in str(exc_info.value) mock_httpx_client.get.assert_called_once_with(self.FULL_AGENT_CARD_URL) @@ -357,7 +277,7 @@ async def test_get_agent_card_request_error( await resolver.get_agent_card() assert exc_info.value.status_code == 503 - assert f'Network communication error fetching public agent card from {self.FULL_AGENT_CARD_URL}' in str(exc_info.value) + assert f'Network communication error fetching agent card from {self.FULL_AGENT_CARD_URL}' in str(exc_info.value) assert 'Network issue' in str(exc_info.value) mock_httpx_client.get.assert_called_once_with(self.FULL_AGENT_CARD_URL) @@ -426,7 +346,8 @@ async def test_get_client_from_agent_card_url_success( agent_card_path=agent_card_path, ) mock_resolver_instance.get_agent_card.assert_called_once_with( - http_kwargs=resolver_kwargs + http_kwargs=resolver_kwargs, + # relative_card_path=None is implied by not passing it ) assert isinstance(client, A2AClient) assert client.url == mock_agent_card.url From ef115e6471903707c30596152212f5ee8c81cabb Mon Sep 17 00:00:00 2001 From: Mandar Deolalikar Date: Fri, 23 May 2025 10:07:03 -0700 Subject: [PATCH 5/9] Add docstring back and remove extra attribute not used anymore --- src/a2a/client/client.py | 5 ++--- tests/client/test_client.py | 18 ++---------------- 2 files changed, 4 insertions(+), 19 deletions(-) diff --git a/src/a2a/client/client.py b/src/a2a/client/client.py index 2dc748a1..1899f0b2 100644 --- a/src/a2a/client/client.py +++ b/src/a2a/client/client.py @@ -29,7 +29,6 @@ def __init__( httpx_client: httpx.AsyncClient, base_url: str, agent_card_path: str = '/.well-known/agent.json', - extended_agent_card_path: str = '/agent/authenticatedExtendedCard', ): """Initializes the A2ACardResolver. @@ -40,7 +39,6 @@ def __init__( """ self.base_url = base_url.rstrip('/') self.agent_card_path = agent_card_path.lstrip('/') - self.extended_agent_card_path = extended_agent_card_path.lstrip('/') self.httpx_client = httpx_client async def get_agent_card( @@ -160,7 +158,8 @@ async def get_client_from_agent_card_url( httpx_client: An async HTTP client instance (e.g., httpx.AsyncClient). base_url: The base URL of the agent's host. agent_card_path: The path to the agent card endpoint, relative to the base URL. - + http_kwargs: Optional dictionary of keyword arguments to pass to the + underlying httpx.get request when fetching the agent card. Returns: An initialized `A2AClient` instance. diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 69fa034d..83d82385 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -108,24 +108,11 @@ async def test_init_strips_slashes(self, mock_httpx_client: AsyncMock): httpx_client=mock_httpx_client, base_url='http://example.com/', agent_card_path='/.well-known/agent.json/', - extended_agent_card_path='/agent/authenticatedExtendedCard/', ) assert resolver.base_url == 'http://example.com' assert ( resolver.agent_card_path == '.well-known/agent.json/' ) # Path is only lstrip'd - assert resolver.extended_agent_card_path == 'agent/authenticatedExtendedCard/' - - resolver_no_leading_slash_path = A2ACardResolver( - httpx_client=AsyncMock(), - base_url='http://example.com', - agent_card_path='.well-known/agent.json', - ) - assert resolver_no_leading_slash_path.base_url == 'http://example.com' - assert ( - resolver_no_leading_slash_path.agent_card_path - == '.well-known/agent.json' - ) @pytest.mark.asyncio async def test_get_agent_card_success_public_only( @@ -168,17 +155,16 @@ async def test_get_agent_card_success_with_specified_path_for_extended_card( httpx_client=mock_httpx_client, base_url=self.BASE_URL, agent_card_path=self.AGENT_CARD_PATH, - extended_agent_card_path=self.EXTENDED_AGENT_CARD_PATH.lstrip('/'), ) # Fetch the extended card by providing its relative path and example auth auth_kwargs = {"headers": {"Authorization": "Bearer testtoken"}} agent_card_result = await resolver.get_agent_card( - relative_card_path=resolver.extended_agent_card_path, + relative_card_path=self.EXTENDED_AGENT_CARD_PATH, http_kwargs=auth_kwargs ) - expected_extended_url = f'{self.BASE_URL}/{resolver.extended_agent_card_path}' + expected_extended_url = f'{self.BASE_URL}/{self.EXTENDED_AGENT_CARD_PATH.lstrip("/")}' mock_httpx_client.get.assert_called_once_with(expected_extended_url, **auth_kwargs) extended_card_response.raise_for_status.assert_called_once() From 5750f5de4b2707cfc537eaa241b2d559779f6522 Mon Sep 17 00:00:00 2001 From: Mandar Deolalikar Date: Fri, 23 May 2025 11:30:06 -0700 Subject: [PATCH 6/9] Use model_copy for specific_extended_agent_card --- examples/helloworld/__main__.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/examples/helloworld/__main__.py b/examples/helloworld/__main__.py index 9396aade..b1cb4095 100644 --- a/examples/helloworld/__main__.py +++ b/examples/helloworld/__main__.py @@ -44,16 +44,15 @@ # This will be the authenticated extended agent card # It includes the additional 'extended_skill' - specific_extended_agent_card = AgentCard( - name='Hello World Agent - Extended Edition', # Different name for clarity - description='The full-featured hello world agent for authenticated users.', - url='http://localhost:9999/', - version='1.0.1', # Could even be a different version - defaultInputModes=['text'], - defaultOutputModes=['text'], - capabilities=AgentCapabilities(streaming=True), # Could have different capabilities - skills=[skill, extended_skill], # Both skills for the extended card - supportsAuthenticatedExtendedCard=True, + specific_extended_agent_card = public_agent_card.model_copy( + update={ + 'name': 'Hello World Agent - Extended Edition', # Different name for clarity + 'description': 'The full-featured hello world agent for authenticated users.', + 'version': '1.0.1', # Could even be a different version + # Capabilities and other fields like url, defaultInputModes, defaultOutputModes, + # supportsAuthenticatedExtendedCard are inherited from public_agent_card unless specified here. + 'skills': [skill, extended_skill], # Both skills for the extended card + } ) request_handler = DefaultRequestHandler( From 4e68072f0b48287c7fc80a16616bd73e77739bcb Mon Sep 17 00:00:00 2001 From: Mandar Deolalikar Date: Fri, 23 May 2025 12:51:04 -0700 Subject: [PATCH 7/9] return a 404 instead of public card from extended card EP --- src/a2a/server/apps/starlette_app.py | 45 +++++++++++++--------------- tests/server/test_integration.py | 40 ++++--------------------- 2 files changed, 27 insertions(+), 58 deletions(-) diff --git a/src/a2a/server/apps/starlette_app.py b/src/a2a/server/apps/starlette_app.py index 90518203..b9e5c14d 100644 --- a/src/a2a/server/apps/starlette_app.py +++ b/src/a2a/server/apps/starlette_app.py @@ -12,29 +12,17 @@ from starlette.responses import JSONResponse, Response from starlette.routing import Route +from a2a.server.context import ServerCallContext from a2a.server.request_handlers.jsonrpc_handler import JSONRPCHandler from a2a.server.request_handlers.request_handler import RequestHandler -from a2a.server.context import ServerCallContext -from a2a.types import ( - A2AError, - A2ARequest, - AgentCard, - CancelTaskRequest, - GetTaskPushNotificationConfigRequest, - GetTaskRequest, - InternalError, - InvalidRequestError, - JSONParseError, - JSONRPCError, - JSONRPCErrorResponse, - JSONRPCResponse, - SendMessageRequest, - SendStreamingMessageRequest, - SendStreamingMessageResponse, - SetTaskPushNotificationConfigRequest, - TaskResubscriptionRequest, - UnsupportedOperationError, -) +from a2a.types import (A2AError, A2ARequest, AgentCard, CancelTaskRequest, + GetTaskPushNotificationConfigRequest, GetTaskRequest, + InternalError, InvalidRequestError, JSONParseError, + JSONRPCError, JSONRPCErrorResponse, JSONRPCResponse, + SendMessageRequest, SendStreamingMessageRequest, + SendStreamingMessageResponse, + SetTaskPushNotificationConfigRequest, + TaskResubscriptionRequest, UnsupportedOperationError) from a2a.utils.errors import MethodNotImplementedError logger = logging.getLogger(__name__) @@ -80,6 +68,13 @@ def __init__( self.handler = JSONRPCHandler( agent_card=agent_card, request_handler=http_handler ) + if ( + self.agent_card.supportsAuthenticatedExtendedCard + and self.extended_agent_card is None + ): + logger.error( + 'AgentCard.supportsAuthenticatedExtendedCard is True, but no extended_agent_card was provided. The /agent/authenticatedExtendedCard endpoint will return 404.' + ) self._context_builder = context_builder def _generate_error_response( @@ -345,10 +340,12 @@ async def _handle_get_authenticated_extended_agent_card( mode='json', exclude_none=True ) ) - # Otherwise, if supportsAuthenticatedExtendedCard is true but no specific - # extended card is set, serve the main agent_card. + # If supportsAuthenticatedExtendedCard is true, but no specific + # extended_agent_card was provided during server initialization, + # return a 404 return JSONResponse( - self.agent_card.model_dump(mode='json', exclude_none=True) + {'error': 'Authenticated extended agent card is supported but not configured on the server.'}, + status_code=404, ) def routes( diff --git a/tests/server/test_integration.py b/tests/server/test_integration.py index 72241efc..c0a54e94 100644 --- a/tests/server/test_integration.py +++ b/tests/server/test_integration.py @@ -8,27 +8,14 @@ from starlette.testclient import TestClient from a2a.server.apps.starlette_app import A2AStarletteApplication -from a2a.types import ( - AgentCapabilities, - AgentCard, - Artifact, - DataPart, - InternalError, - InvalidRequestError, - JSONParseError, - Part, - PushNotificationConfig, - Task, - TaskArtifactUpdateEvent, - TaskPushNotificationConfig, - TaskState, - TaskStatus, - TextPart, - UnsupportedOperationError, -) +from a2a.types import (AgentCapabilities, AgentCard, Artifact, DataPart, + InternalError, InvalidRequestError, JSONParseError, + Part, PushNotificationConfig, Task, + TaskArtifactUpdateEvent, TaskPushNotificationConfig, + TaskState, TaskStatus, TextPart, + UnsupportedOperationError) from a2a.utils.errors import MethodNotImplementedError - # === TEST SETUP === MINIMAL_AGENT_SKILL: dict[str, Any] = { @@ -151,21 +138,6 @@ def test_authenticated_extended_agent_card_endpoint_not_supported( assert response.status_code == 404 # Starlette's default for no route -def test_authenticated_extended_agent_card_endpoint_supported_no_specific_extended_card( - agent_card: AgentCard, handler: mock.AsyncMock -): - """Test extended card endpoint returns main card if supported but no specific extended card is set.""" - agent_card.supportsAuthenticatedExtendedCard = True - app_instance = A2AStarletteApplication(agent_card, handler) - client = TestClient(app_instance.build()) - - response = client.get('/agent/authenticatedExtendedCard') - assert response.status_code == 200 - data = response.json() - assert data['name'] == agent_card.name # Should be the main agent card - assert data['version'] == agent_card.version - - def test_authenticated_extended_agent_card_endpoint_supported_with_specific_extended_card( agent_card: AgentCard, extended_agent_card_fixture: AgentCard, From 793ba843d7b2e5d75e4013a0d1c0c6c8a49c6146 Mon Sep 17 00:00:00 2001 From: Mandar Deolalikar Date: Fri, 23 May 2025 13:29:21 -0700 Subject: [PATCH 8/9] eat any exception thrown during auth card get. --- examples/helloworld/test_client.py | 55 +++++++++++++----------------- 1 file changed, 24 insertions(+), 31 deletions(-) diff --git a/examples/helloworld/test_client.py b/examples/helloworld/test_client.py index 146d5485..d5409171 100644 --- a/examples/helloworld/test_client.py +++ b/examples/helloworld/test_client.py @@ -28,45 +28,38 @@ async def main() -> None: ) # Fetch Public Agent Card and Initialize Client - public_agent_card: AgentCard | None = None - extended_agent_card: AgentCard | None = None final_agent_card_to_use: AgentCard | None = None try: logger.info(f"Attempting to fetch public agent card from: {base_url}{PUBLIC_AGENT_CARD_PATH}") - public_agent_card = await resolver.get_agent_card() # Fetches from default public path + _public_card = await resolver.get_agent_card() # Fetches from default public path logger.info("Successfully fetched public agent card:") - logger.info(public_agent_card.model_dump_json(indent=2, exclude_none=True)) - - # --- Conditional Step: Fetch Extended Agent Card - if public_agent_card and public_agent_card.supportsAuthenticatedExtendedCard: - logger.info(f"\nPublic card supports authenticated extended card. Attempting to fetch from: {base_url}{EXTENDED_AGENT_CARD_PATH}") - auth_headers_dict = {"Authorization": "Bearer dummy-token-for-extended-card"} - extended_agent_card = await resolver.get_agent_card( - relative_card_path=EXTENDED_AGENT_CARD_PATH, # Or resolver.extended_agent_card_path - http_kwargs={"headers": auth_headers_dict} - ) - logger.info("Successfully fetched authenticated extended agent card:") - logger.info(extended_agent_card.model_dump_json(indent=2, exclude_none=True)) - else: - logger.info("\nPublic card does not support authenticated extended card, or public card not fetched.") + logger.info(_public_card.model_dump_json(indent=2, exclude_none=True)) + final_agent_card_to_use = _public_card + logger.info("\nUsing PUBLIC agent card for client initialization (default).") + + if _public_card.supportsAuthenticatedExtendedCard: + try: + logger.info(f"\nPublic card supports authenticated extended card. Attempting to fetch from: {base_url}{EXTENDED_AGENT_CARD_PATH}") + auth_headers_dict = {"Authorization": "Bearer dummy-token-for-extended-card"} + _extended_card = await resolver.get_agent_card( + relative_card_path=EXTENDED_AGENT_CARD_PATH, + http_kwargs={"headers": auth_headers_dict} + ) + logger.info("Successfully fetched authenticated extended agent card:") + logger.info(_extended_card.model_dump_json(indent=2, exclude_none=True)) + final_agent_card_to_use = _extended_card # Update to use the extended card + logger.info("\nUsing AUTHENTICATED EXTENDED agent card for client initialization.") + except Exception as e_extended: + logger.warning(f"Failed to fetch extended agent card: {e_extended}. Will proceed with public card.", exc_info=True) + elif _public_card: # supportsAuthenticatedExtendedCard is False or None + logger.info("\nPublic card does not indicate support for an extended card. Using public card.") except Exception as e: - logger.error(f"Error during agent card fetching: {e}", exc_info=True) - # If public card fetching failed, or extended card fetching failed after public card indicated support, - # we might not have a card to use. - - # Determine which card to use and Initialize Client - if extended_agent_card: - final_agent_card_to_use = extended_agent_card - logger.info("\nUsing AUTHENTICATED EXTENDED agent card for client initialization.") - elif public_agent_card: - final_agent_card_to_use = public_agent_card - logger.info("\nUsing PUBLIC agent card for client initialization.") - else: - logger.error("\nNo agent card successfully fetched. Cannot initialize client.") - return # Cannot proceed + logger.error(f"Critical error fetching public agent card: {e}", exc_info=True) + raise RuntimeError("Failed to fetch the public agent card. Cannot continue.") from e + client = A2AClient( httpx_client=httpx_client, agent_card=final_agent_card_to_use ) From a8cab98aab8853b24a6490f291337ab33d364b42 Mon Sep 17 00:00:00 2001 From: Mandar Deolalikar Date: Fri, 23 May 2025 17:38:05 -0700 Subject: [PATCH 9/9] fix spelling --- tests/client/test_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 83d82385..e7cf5fe7 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -158,7 +158,7 @@ async def test_get_agent_card_success_with_specified_path_for_extended_card( ) # Fetch the extended card by providing its relative path and example auth - auth_kwargs = {"headers": {"Authorization": "Bearer testtoken"}} + auth_kwargs = {"headers": {"Authorization": "Bearer test token"}} agent_card_result = await resolver.get_agent_card( relative_card_path=self.EXTENDED_AGENT_CARD_PATH, http_kwargs=auth_kwargs