Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions examples/helloworld/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
AgentSkill,
)


if __name__ == '__main__':
skill = AgentSkill(
id='hello_world',
Expand All @@ -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',
Expand All @@ -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(
Expand Down
75 changes: 68 additions & 7 deletions src/a2a/client/client.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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."""
Expand Down
72 changes: 66 additions & 6 deletions src/a2a/server/apps/starlette_app.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import json
import logging
import traceback

from collections.abc import AsyncGenerator
from typing import Any

Expand Down Expand Up @@ -38,7 +37,6 @@
)
from a2a.utils.errors import MethodNotImplementedError


logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -247,25 +245,74 @@ 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)
)

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.

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,
Expand All @@ -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:
Expand All @@ -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)
9 changes: 6 additions & 3 deletions src/a2a/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]):
Expand Down Expand Up @@ -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):
"""
Expand Down