Skip to content

Commit 20f0826

Browse files
dmandarholtskinner
andauthored
feat: Add functionality for extended agent card. (#31)
* Add functionality for extended agent card. Does not support authentication yet * Formatting * Pass a default and an extended card to the starlette app. Also add tests * Address review comments * Add docstring back and remove extra attribute not used anymore * Use model_copy for specific_extended_agent_card * return a 404 instead of public card from extended card EP * eat any exception thrown during auth card get. --------- Co-authored-by: Holt Skinner <[email protected]> Co-authored-by: Holt Skinner <[email protected]>
1 parent 18d1954 commit 20f0826

File tree

8 files changed

+405
-142
lines changed

8 files changed

+405
-142
lines changed

examples/helloworld/__main__.py

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@
55
from a2a.server.apps import A2AStarletteApplication
66
from a2a.server.request_handlers import DefaultRequestHandler
77
from a2a.server.tasks import InMemoryTaskStore
8-
from a2a.types import AgentCapabilities, AgentCard, AgentSkill
8+
from a2a.types import (
9+
AgentCapabilities,
10+
AgentCard,
11+
AgentSkill,
12+
)
913

1014

1115
if __name__ == '__main__':
@@ -17,25 +21,48 @@
1721
examples=['hi', 'hello world'],
1822
)
1923

20-
agent_card = AgentCard(
24+
extended_skill = AgentSkill(
25+
id='super_hello_world',
26+
name='Returns a SUPER Hello World',
27+
description='A more enthusiastic greeting, only for authenticated users.',
28+
tags=['hello world', 'super', 'extended'],
29+
examples=['super hi', 'give me a super hello'],
30+
)
31+
32+
# This will be the public-facing agent card
33+
public_agent_card = AgentCard(
2134
name='Hello World Agent',
2235
description='Just a hello world agent',
2336
url='http://localhost:9999/',
2437
version='1.0.0',
2538
defaultInputModes=['text'],
2639
defaultOutputModes=['text'],
2740
capabilities=AgentCapabilities(streaming=True),
28-
skills=[skill],
41+
skills=[skill], # Only the basic skill for the public card
42+
supportsAuthenticatedExtendedCard=True,
43+
)
44+
45+
# This will be the authenticated extended agent card
46+
# It includes the additional 'extended_skill'
47+
specific_extended_agent_card = public_agent_card.model_copy(
48+
update={
49+
'name': 'Hello World Agent - Extended Edition', # Different name for clarity
50+
'description': 'The full-featured hello world agent for authenticated users.',
51+
'version': '1.0.1', # Could even be a different version
52+
# Capabilities and other fields like url, defaultInputModes, defaultOutputModes,
53+
# supportsAuthenticatedExtendedCard are inherited from public_agent_card unless specified here.
54+
'skills': [skill, extended_skill], # Both skills for the extended card
55+
}
2956
)
3057

3158
request_handler = DefaultRequestHandler(
3259
agent_executor=HelloWorldAgentExecutor(),
3360
task_store=InMemoryTaskStore(),
3461
)
3562

36-
server = A2AStarletteApplication(
37-
agent_card=agent_card, http_handler=request_handler
38-
)
63+
server = A2AStarletteApplication(agent_card=public_agent_card,
64+
http_handler=request_handler,
65+
extended_agent_card=specific_extended_agent_card)
3966
import uvicorn
4067

4168
uvicorn.run(server.build(), host='0.0.0.0', port=9999)

examples/helloworld/test_client.py

Lines changed: 60 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,70 @@
1-
from a2a.client import A2AClient
1+
import logging # Import the logging module
22
from typing import Any
3-
import httpx
43
from uuid import uuid4
5-
from a2a.types import (
6-
SendMessageRequest,
7-
MessageSendParams,
8-
SendStreamingMessageRequest,
9-
)
4+
5+
import httpx
6+
7+
from a2a.client import A2ACardResolver, A2AClient
8+
from a2a.types import (AgentCard, MessageSendParams, SendMessageRequest,
9+
SendStreamingMessageRequest)
1010

1111

1212
async def main() -> None:
13+
PUBLIC_AGENT_CARD_PATH = "/.well-known/agent.json"
14+
EXTENDED_AGENT_CARD_PATH = "/agent/authenticatedExtendedCard"
15+
16+
# Configure logging to show INFO level messages
17+
logging.basicConfig(level=logging.INFO)
18+
logger = logging.getLogger(__name__) # Get a logger instance
19+
20+
base_url = 'http://localhost:9999'
21+
1322
async with httpx.AsyncClient() as httpx_client:
14-
client = await A2AClient.get_client_from_agent_card_url(
15-
httpx_client, 'http://localhost:9999'
23+
# Initialize A2ACardResolver
24+
resolver = A2ACardResolver(
25+
httpx_client=httpx_client,
26+
base_url=base_url,
27+
# agent_card_path uses default, extended_agent_card_path also uses default
1628
)
29+
30+
# Fetch Public Agent Card and Initialize Client
31+
final_agent_card_to_use: AgentCard | None = None
32+
33+
try:
34+
logger.info(f"Attempting to fetch public agent card from: {base_url}{PUBLIC_AGENT_CARD_PATH}")
35+
_public_card = await resolver.get_agent_card() # Fetches from default public path
36+
logger.info("Successfully fetched public agent card:")
37+
logger.info(_public_card.model_dump_json(indent=2, exclude_none=True))
38+
final_agent_card_to_use = _public_card
39+
logger.info("\nUsing PUBLIC agent card for client initialization (default).")
40+
41+
if _public_card.supportsAuthenticatedExtendedCard:
42+
try:
43+
logger.info(f"\nPublic card supports authenticated extended card. Attempting to fetch from: {base_url}{EXTENDED_AGENT_CARD_PATH}")
44+
auth_headers_dict = {"Authorization": "Bearer dummy-token-for-extended-card"}
45+
_extended_card = await resolver.get_agent_card(
46+
relative_card_path=EXTENDED_AGENT_CARD_PATH,
47+
http_kwargs={"headers": auth_headers_dict}
48+
)
49+
logger.info("Successfully fetched authenticated extended agent card:")
50+
logger.info(_extended_card.model_dump_json(indent=2, exclude_none=True))
51+
final_agent_card_to_use = _extended_card # Update to use the extended card
52+
logger.info("\nUsing AUTHENTICATED EXTENDED agent card for client initialization.")
53+
except Exception as e_extended:
54+
logger.warning(f"Failed to fetch extended agent card: {e_extended}. Will proceed with public card.", exc_info=True)
55+
elif _public_card: # supportsAuthenticatedExtendedCard is False or None
56+
logger.info("\nPublic card does not indicate support for an extended card. Using public card.")
57+
58+
except Exception as e:
59+
logger.error(f"Critical error fetching public agent card: {e}", exc_info=True)
60+
raise RuntimeError("Failed to fetch the public agent card. Cannot continue.") from e
61+
62+
63+
client = A2AClient(
64+
httpx_client=httpx_client, agent_card=final_agent_card_to_use
65+
)
66+
logger.info("A2AClient initialized.")
67+
1768
send_message_payload: dict[str, Any] = {
1869
'message': {
1970
'role': 'user',

src/a2a/client/client.py

Lines changed: 59 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,25 @@
11
import json
2-
2+
import logging
33
from collections.abc import AsyncGenerator
44
from typing import Any
55
from uuid import uuid4
66

77
import httpx
8-
98
from httpx_sse import SSEError, aconnect_sse
9+
from pydantic import ValidationError
1010

1111
from a2a.client.errors import A2AClientHTTPError, A2AClientJSONError
12-
from a2a.types import (
13-
AgentCard,
14-
CancelTaskRequest,
15-
CancelTaskResponse,
16-
GetTaskPushNotificationConfigRequest,
17-
GetTaskPushNotificationConfigResponse,
18-
GetTaskRequest,
19-
GetTaskResponse,
20-
SendMessageRequest,
21-
SendMessageResponse,
22-
SendStreamingMessageRequest,
23-
SendStreamingMessageResponse,
24-
SetTaskPushNotificationConfigRequest,
25-
SetTaskPushNotificationConfigResponse,
26-
)
12+
from a2a.types import (AgentCard, CancelTaskRequest, CancelTaskResponse,
13+
GetTaskPushNotificationConfigRequest,
14+
GetTaskPushNotificationConfigResponse, GetTaskRequest,
15+
GetTaskResponse, SendMessageRequest,
16+
SendMessageResponse, SendStreamingMessageRequest,
17+
SendStreamingMessageResponse,
18+
SetTaskPushNotificationConfigRequest,
19+
SetTaskPushNotificationConfigResponse)
2720
from a2a.utils.telemetry import SpanKind, trace_class
2821

22+
logger = logging.getLogger(__name__)
2923

3024
class A2ACardResolver:
3125
"""Agent Card resolver."""
@@ -48,11 +42,19 @@ def __init__(
4842
self.httpx_client = httpx_client
4943

5044
async def get_agent_card(
51-
self, http_kwargs: dict[str, Any] | None = None
45+
self,
46+
relative_card_path: str | None = None,
47+
http_kwargs: dict[str, Any] | None = None,
5248
) -> AgentCard:
53-
"""Fetches the agent card from the specified URL.
49+
"""Fetches an agent card from a specified path relative to the base_url.
50+
51+
If relative_card_path is None, it defaults to the resolver's configured
52+
agent_card_path (for the public agent card).
5453
5554
Args:
55+
relative_card_path: Optional path to the agent card endpoint,
56+
relative to the base URL. If None, uses the default public
57+
agent card path.
5658
http_kwargs: Optional dictionary of keyword arguments to pass to the
5759
underlying httpx.get request.
5860
@@ -64,21 +66,47 @@ async def get_agent_card(
6466
A2AClientJSONError: If the response body cannot be decoded as JSON
6567
or validated against the AgentCard schema.
6668
"""
69+
if relative_card_path is None:
70+
# Use the default public agent card path configured during initialization
71+
path_segment = self.agent_card_path
72+
else:
73+
path_segment = relative_card_path.lstrip('/')
74+
75+
target_url = f'{self.base_url}/{path_segment}'
76+
6777
try:
6878
response = await self.httpx_client.get(
69-
f'{self.base_url}/{self.agent_card_path}',
79+
target_url,
7080
**(http_kwargs or {}),
7181
)
7282
response.raise_for_status()
73-
return AgentCard.model_validate(response.json())
83+
agent_card_data = response.json()
84+
logger.info(
85+
'Successfully fetched agent card data from %s: %s',
86+
target_url,
87+
agent_card_data,
88+
)
89+
agent_card = AgentCard.model_validate(agent_card_data)
7490
except httpx.HTTPStatusError as e:
75-
raise A2AClientHTTPError(e.response.status_code, str(e)) from e
91+
raise A2AClientHTTPError(
92+
e.response.status_code,
93+
f'Failed to fetch agent card from {target_url}: {e}',
94+
) from e
7695
except json.JSONDecodeError as e:
77-
raise A2AClientJSONError(str(e)) from e
96+
raise A2AClientJSONError(
97+
f'Failed to parse JSON for agent card from {target_url}: {e}'
98+
) from e
7899
except httpx.RequestError as e:
79100
raise A2AClientHTTPError(
80-
503, f'Network communication error: {e}'
101+
503,
102+
f'Network communication error fetching agent card from {target_url}: {e}',
81103
) from e
104+
except ValidationError as e: # Pydantic validation error
105+
raise A2AClientJSONError(
106+
f'Failed to validate agent card structure from {target_url}: {e.json()}'
107+
) from e
108+
109+
return agent_card
82110

83111

84112
@trace_class(kind=SpanKind.CLIENT)
@@ -119,15 +147,19 @@ async def get_client_from_agent_card_url(
119147
agent_card_path: str = '/.well-known/agent.json',
120148
http_kwargs: dict[str, Any] | None = None,
121149
) -> 'A2AClient':
122-
"""Fetches the AgentCard and initializes an A2A client.
150+
"""Fetches the public AgentCard and initializes an A2A client.
151+
152+
This method will always fetch the public agent card. If an authenticated
153+
or extended agent card is required, the A2ACardResolver should be used
154+
directly to fetch the specific card, and then the A2AClient should be
155+
instantiated with it.
123156
124157
Args:
125158
httpx_client: An async HTTP client instance (e.g., httpx.AsyncClient).
126159
base_url: The base URL of the agent's host.
127160
agent_card_path: The path to the agent card endpoint, relative to the base URL.
128161
http_kwargs: Optional dictionary of keyword arguments to pass to the
129162
underlying httpx.get request when fetching the agent card.
130-
131163
Returns:
132164
An initialized `A2AClient` instance.
133165
@@ -137,7 +169,7 @@ async def get_client_from_agent_card_url(
137169
"""
138170
agent_card: AgentCard = await A2ACardResolver(
139171
httpx_client, base_url=base_url, agent_card_path=agent_card_path
140-
).get_agent_card(http_kwargs=http_kwargs)
172+
).get_agent_card(http_kwargs=http_kwargs) # Fetches public card by default
141173
return A2AClient(httpx_client=httpx_client, agent_card=agent_card)
142174

143175
async def send_message(

0 commit comments

Comments
 (0)