Skip to content

Commit ee92aab

Browse files
dmandarholtskinnerkthota-g
authored
feat: Allow agent cards (default and extended) to be dynamic (#365)
Adds support for dynamic agent card serving for default and extended-authenticated cards. Also fix some build errors. Add tests. --------- Co-authored-by: Holt Skinner <[email protected]> Co-authored-by: Holt Skinner <[email protected]> Co-authored-by: Krishna Thota <[email protected]>
1 parent a319334 commit ee92aab

File tree

9 files changed

+268
-100
lines changed

9 files changed

+268
-100
lines changed

src/a2a/server/apps/jsonrpc/fastapi_app.py

Lines changed: 5 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,9 @@
77
from fastapi import FastAPI
88

99
from a2a.server.apps.jsonrpc.jsonrpc_app import (
10-
CallContextBuilder,
1110
JSONRPCApplication,
1211
)
13-
from a2a.server.request_handlers.jsonrpc_handler import RequestHandler
14-
from a2a.types import A2ARequest, AgentCard
12+
from a2a.types import A2ARequest
1513
from a2a.utils.constants import (
1614
AGENT_CARD_WELL_KNOWN_PATH,
1715
DEFAULT_RPC_URL,
@@ -31,32 +29,6 @@ class A2AFastAPIApplication(JSONRPCApplication):
3129
(SSE).
3230
"""
3331

34-
def __init__(
35-
self,
36-
agent_card: AgentCard,
37-
http_handler: RequestHandler,
38-
extended_agent_card: AgentCard | None = None,
39-
context_builder: CallContextBuilder | None = None,
40-
) -> None:
41-
"""Initializes the A2AStarletteApplication.
42-
43-
Args:
44-
agent_card: The AgentCard describing the agent's capabilities.
45-
http_handler: The handler instance responsible for processing A2A
46-
requests via http.
47-
extended_agent_card: An optional, distinct AgentCard to be served
48-
at the authenticated extended card endpoint.
49-
context_builder: The CallContextBuilder used to construct the
50-
ServerCallContext passed to the http_handler. If None, no
51-
ServerCallContext is passed.
52-
"""
53-
super().__init__(
54-
agent_card=agent_card,
55-
http_handler=http_handler,
56-
extended_agent_card=extended_agent_card,
57-
context_builder=context_builder,
58-
)
59-
6032
def add_routes_to_app(
6133
self,
6234
app: FastAPI,
@@ -90,13 +62,13 @@ def add_routes_to_app(
9062
)(self._handle_requests)
9163
app.get(agent_card_url)(self._handle_get_agent_card)
9264

93-
# add deprecated path only if the agent_card_url uses default well-known path
9465
if agent_card_url == AGENT_CARD_WELL_KNOWN_PATH:
95-
app.get(PREV_AGENT_CARD_WELL_KNOWN_PATH, include_in_schema=False)(
96-
self.handle_deprecated_agent_card_path
66+
# For backward compatibility, serve the agent card at the deprecated path as well.
67+
# TODO: remove in a future release
68+
app.get(PREV_AGENT_CARD_WELL_KNOWN_PATH)(
69+
self._handle_get_agent_card
9770
)
9871

99-
# TODO: deprecated endpoint to be removed in a future release
10072
if self.agent_card.supports_authenticated_extended_card:
10173
app.get(extended_agent_card_url)(
10274
self._handle_get_authenticated_extended_agent_card

src/a2a/server/apps/jsonrpc/jsonrpc_app.py

Lines changed: 40 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import traceback
55

66
from abc import ABC, abstractmethod
7-
from collections.abc import AsyncGenerator
7+
from collections.abc import AsyncGenerator, Callable
88
from typing import Any
99

1010
from fastapi import FastAPI
@@ -123,12 +123,17 @@ class JSONRPCApplication(ABC):
123123
(SSE).
124124
"""
125125

126-
def __init__(
126+
def __init__( # noqa: PLR0913
127127
self,
128128
agent_card: AgentCard,
129129
http_handler: RequestHandler,
130130
extended_agent_card: AgentCard | None = None,
131131
context_builder: CallContextBuilder | None = None,
132+
card_modifier: Callable[[AgentCard], AgentCard] | None = None,
133+
extended_card_modifier: Callable[
134+
[AgentCard, ServerCallContext], AgentCard
135+
]
136+
| None = None,
132137
) -> None:
133138
"""Initializes the A2AStarletteApplication.
134139
@@ -141,17 +146,26 @@ def __init__(
141146
context_builder: The CallContextBuilder used to construct the
142147
ServerCallContext passed to the http_handler. If None, no
143148
ServerCallContext is passed.
149+
card_modifier: An optional callback to dynamically modify the public
150+
agent card before it is served.
151+
extended_card_modifier: An optional callback to dynamically modify
152+
the extended agent card before it is served. It receives the
153+
call context.
144154
"""
145155
self.agent_card = agent_card
146156
self.extended_agent_card = extended_agent_card
157+
self.card_modifier = card_modifier
158+
self.extended_card_modifier = extended_card_modifier
147159
self.handler = JSONRPCHandler(
148160
agent_card=agent_card,
149161
request_handler=http_handler,
150162
extended_agent_card=extended_agent_card,
163+
extended_card_modifier=extended_card_modifier,
151164
)
152165
if (
153166
self.agent_card.supports_authenticated_extended_card
154167
and self.extended_agent_card is None
168+
and self.extended_card_modifier is None
155169
):
156170
logger.error(
157171
'AgentCard.supports_authenticated_extended_card is True, but no extended_agent_card was provided. The /agent/authenticatedExtendedCard endpoint will return 404.'
@@ -448,24 +462,23 @@ async def _handle_get_agent_card(self, request: Request) -> JSONResponse:
448462
Returns:
449463
A JSONResponse containing the agent card data.
450464
"""
451-
# The public agent card is a direct serialization of the agent_card
452-
# provided at initialization.
465+
if request.url.path == PREV_AGENT_CARD_WELL_KNOWN_PATH:
466+
logger.warning(
467+
f"Deprecated agent card endpoint '{PREV_AGENT_CARD_WELL_KNOWN_PATH}' accessed. "
468+
f"Please use '{AGENT_CARD_WELL_KNOWN_PATH}' instead. This endpoint will be removed in a future version."
469+
)
470+
471+
card_to_serve = self.agent_card
472+
if self.card_modifier:
473+
card_to_serve = self.card_modifier(card_to_serve)
474+
453475
return JSONResponse(
454-
self.agent_card.model_dump(
476+
card_to_serve.model_dump(
455477
exclude_none=True,
456478
by_alias=True,
457479
)
458480
)
459481

460-
async def handle_deprecated_agent_card_path(
461-
self, request: Request
462-
) -> JSONResponse:
463-
"""Handles GET requests for the deprecated agent card endpoint."""
464-
logger.warning(
465-
f"Deprecated agent card endpoint '{PREV_AGENT_CARD_WELL_KNOWN_PATH}' accessed. Please use '{AGENT_CARD_WELL_KNOWN_PATH}' instead. This endpoint will be removed in a future version."
466-
)
467-
return await self._handle_get_agent_card(request)
468-
469482
async def _handle_get_authenticated_extended_agent_card(
470483
self, request: Request
471484
) -> JSONResponse:
@@ -480,17 +493,24 @@ async def _handle_get_authenticated_extended_agent_card(
480493
status_code=404,
481494
)
482495

483-
# If an explicit extended_agent_card is provided, serve that.
484-
if self.extended_agent_card:
496+
card_to_serve = self.extended_agent_card
497+
498+
if self.extended_card_modifier:
499+
context = self._context_builder.build(request)
500+
# If no base extended card is provided, pass the public card to the modifier
501+
base_card = card_to_serve if card_to_serve else self.agent_card
502+
card_to_serve = self.extended_card_modifier(base_card, context)
503+
504+
if card_to_serve:
485505
return JSONResponse(
486-
self.extended_agent_card.model_dump(
506+
card_to_serve.model_dump(
487507
exclude_none=True,
488508
by_alias=True,
489509
)
490510
)
491-
# If supports_authenticated_extended_card is true, but no specific
492-
# extended_agent_card was provided during server initialization,
493-
# return a 404
511+
# If supports_authenticated_extended_card is true, but no
512+
# extended_agent_card was provided, and no modifier produced a card,
513+
# return a 404.
494514
return JSONResponse(
495515
{
496516
'error': 'Authenticated extended agent card is supported but not configured on the server.'

src/a2a/server/apps/jsonrpc/starlette_app.py

Lines changed: 4 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,8 @@
66
from starlette.routing import Route
77

88
from a2a.server.apps.jsonrpc.jsonrpc_app import (
9-
CallContextBuilder,
109
JSONRPCApplication,
1110
)
12-
from a2a.server.request_handlers.jsonrpc_handler import RequestHandler
13-
from a2a.types import AgentCard
1411
from a2a.utils.constants import (
1512
AGENT_CARD_WELL_KNOWN_PATH,
1613
DEFAULT_RPC_URL,
@@ -30,32 +27,6 @@ class A2AStarletteApplication(JSONRPCApplication):
3027
(SSE).
3128
"""
3229

33-
def __init__(
34-
self,
35-
agent_card: AgentCard,
36-
http_handler: RequestHandler,
37-
extended_agent_card: AgentCard | None = None,
38-
context_builder: CallContextBuilder | None = None,
39-
) -> None:
40-
"""Initializes the A2AStarletteApplication.
41-
42-
Args:
43-
agent_card: The AgentCard describing the agent's capabilities.
44-
http_handler: The handler instance responsible for processing A2A
45-
requests via http.
46-
extended_agent_card: An optional, distinct AgentCard to be served
47-
at the authenticated extended card endpoint.
48-
context_builder: The CallContextBuilder used to construct the
49-
ServerCallContext passed to the http_handler. If None, no
50-
ServerCallContext is passed.
51-
"""
52-
super().__init__(
53-
agent_card=agent_card,
54-
http_handler=http_handler,
55-
extended_agent_card=extended_agent_card,
56-
context_builder=context_builder,
57-
)
58-
5930
def routes(
6031
self,
6132
agent_card_url: str = AGENT_CARD_WELL_KNOWN_PATH,
@@ -87,14 +58,15 @@ def routes(
8758
),
8859
]
8960

90-
# add deprecated path only if the agent_card_url uses default well-known path
9161
if agent_card_url == AGENT_CARD_WELL_KNOWN_PATH:
62+
# For backward compatibility, serve the agent card at the deprecated path as well.
63+
# TODO: remove in a future release
9264
app_routes.append(
9365
Route(
9466
PREV_AGENT_CARD_WELL_KNOWN_PATH,
95-
self.handle_deprecated_agent_card_path,
67+
self._handle_get_agent_card,
9668
methods=['GET'],
97-
name='agent_card_path_deprecated',
69+
name='deprecated_agent_card',
9870
)
9971
)
10072

src/a2a/server/request_handlers/grpc_handler.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
"'pip install a2a-sdk[grpc]'"
1919
) from e
2020

21+
from collections.abc import Callable
22+
2123
import a2a.grpc.a2a_pb2_grpc as a2a_grpc
2224

2325
from a2a import types
@@ -87,6 +89,7 @@ def __init__(
8789
agent_card: AgentCard,
8890
request_handler: RequestHandler,
8991
context_builder: CallContextBuilder | None = None,
92+
card_modifier: Callable[[AgentCard], AgentCard] | None = None,
9093
):
9194
"""Initializes the GrpcHandler.
9295
@@ -96,10 +99,13 @@ def __init__(
9699
delegate requests to.
97100
context_builder: The CallContextBuilder object. If none the
98101
DefaultCallContextBuilder is used.
102+
card_modifier: An optional callback to dynamically modify the public
103+
agent card before it is served.
99104
"""
100105
self.agent_card = agent_card
101106
self.request_handler = request_handler
102107
self.context_builder = context_builder or DefaultCallContextBuilder()
108+
self.card_modifier = card_modifier
103109

104110
async def SendMessage(
105111
self,
@@ -331,7 +337,10 @@ async def GetAgentCard(
331337
context: grpc.aio.ServicerContext,
332338
) -> a2a_pb2.AgentCard:
333339
"""Get the agent card for the agent served."""
334-
return proto_utils.ToProto.agent_card(self.agent_card)
340+
card_to_serve = self.agent_card
341+
if self.card_modifier:
342+
card_to_serve = self.card_modifier(card_to_serve)
343+
return proto_utils.ToProto.agent_card(card_to_serve)
335344

336345
async def abort_context(
337346
self, error: ServerError, context: grpc.aio.ServicerContext

src/a2a/server/request_handlers/jsonrpc_handler.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import logging
22

3-
from collections.abc import AsyncIterable
3+
from collections.abc import AsyncIterable, Callable
44

55
from a2a.server.context import ServerCallContext
66
from a2a.server.request_handlers.request_handler import RequestHandler
@@ -62,17 +62,25 @@ def __init__(
6262
agent_card: AgentCard,
6363
request_handler: RequestHandler,
6464
extended_agent_card: AgentCard | None = None,
65+
extended_card_modifier: Callable[
66+
[AgentCard, ServerCallContext], AgentCard
67+
]
68+
| None = None,
6569
):
6670
"""Initializes the JSONRPCHandler.
6771
6872
Args:
6973
agent_card: The AgentCard describing the agent's capabilities.
7074
request_handler: The underlying `RequestHandler` instance to delegate requests to.
7175
extended_agent_card: An optional, distinct Extended AgentCard to be served
76+
extended_card_modifier: An optional callback to dynamically modify
77+
the extended agent card before it is served. It receives the
78+
call context.
7279
"""
7380
self.agent_card = agent_card
7481
self.request_handler = request_handler
7582
self.extended_agent_card = extended_agent_card
83+
self.extended_card_modifier = extended_card_modifier
7684

7785
async def on_message_send(
7886
self,
@@ -417,16 +425,27 @@ async def get_authenticated_extended_card(
417425
Returns:
418426
A `GetAuthenticatedExtendedCardResponse` object containing the config or a JSON-RPC error.
419427
"""
420-
if self.extended_agent_card is None:
428+
if (
429+
self.extended_agent_card is None
430+
and self.extended_card_modifier is None
431+
):
421432
return GetAuthenticatedExtendedCardResponse(
422433
root=JSONRPCErrorResponse(
423434
id=request.id,
424435
error=AuthenticatedExtendedCardNotConfiguredError(),
425436
)
426437
)
427438

439+
base_card = self.extended_agent_card
440+
if base_card is None:
441+
base_card = self.agent_card
442+
443+
card_to_serve = base_card
444+
if self.extended_card_modifier and context:
445+
card_to_serve = self.extended_card_modifier(base_card, context)
446+
428447
return GetAuthenticatedExtendedCardResponse(
429448
root=GetAuthenticatedExtendedCardSuccessResponse(
430-
id=request.id, result=self.extended_agent_card
449+
id=request.id, result=card_to_serve
431450
)
432451
)

0 commit comments

Comments
 (0)