Skip to content

Commit a1679da

Browse files
seanzhougooglecopybara-github
authored andcommitted
feat: Allow users to pass their own agent card to to_a2a method
PiperOrigin-RevId: 802763510
1 parent a30851e commit a1679da

File tree

2 files changed

+235
-4
lines changed

2 files changed

+235
-4
lines changed

src/google/adk/a2a/utils/agent_to_a2a.py

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from a2a.server.apps import A2AStarletteApplication
2222
from a2a.server.request_handlers import DefaultRequestHandler
2323
from a2a.server.tasks import InMemoryTaskStore
24+
from a2a.types import AgentCard
2425
except ImportError as e:
2526
if sys.version_info < (3, 10):
2627
raise ImportError(
@@ -29,6 +30,9 @@
2930
else:
3031
raise e
3132

33+
from typing import Optional
34+
from typing import Union
35+
3236
from starlette.applications import Starlette
3337

3438
from ...agents.base_agent import BaseAgent
@@ -43,13 +47,49 @@
4347
from .agent_card_builder import AgentCardBuilder
4448

4549

50+
def _load_agent_card(
51+
agent_card: Optional[Union[AgentCard, str]],
52+
) -> Optional[AgentCard]:
53+
"""Load agent card from various sources.
54+
55+
Args:
56+
agent_card: AgentCard object, path to JSON file, or None
57+
58+
Returns:
59+
AgentCard object or None if no agent card provided
60+
61+
Raises:
62+
ValueError: If loading agent card from file fails
63+
"""
64+
if agent_card is None:
65+
return None
66+
67+
if isinstance(agent_card, str):
68+
# Load agent card from file path
69+
import json
70+
from pathlib import Path
71+
72+
try:
73+
path = Path(agent_card)
74+
with path.open("r", encoding="utf-8") as f:
75+
agent_card_data = json.load(f)
76+
return AgentCard(**agent_card_data)
77+
except Exception as e:
78+
raise ValueError(
79+
f"Failed to load agent card from {agent_card}: {e}"
80+
) from e
81+
else:
82+
return agent_card
83+
84+
4685
@a2a_experimental
4786
def to_a2a(
4887
agent: BaseAgent,
4988
*,
5089
host: str = "localhost",
5190
port: int = 8000,
5291
protocol: str = "http",
92+
agent_card: Optional[Union[AgentCard, str]] = None,
5393
) -> Starlette:
5494
"""Convert an ADK agent to a A2A Starlette application.
5595
@@ -58,6 +98,9 @@ def to_a2a(
5898
host: The host for the A2A RPC URL (default: "localhost")
5999
port: The port for the A2A RPC URL (default: 8000)
60100
protocol: The protocol for the A2A RPC URL (default: "http")
101+
agent_card: Optional pre-built AgentCard object or path to agent card
102+
JSON. If not provided, will be built automatically from the
103+
agent.
61104
62105
Returns:
63106
A Starlette application that can be run with uvicorn
@@ -66,6 +109,9 @@ def to_a2a(
66109
agent = MyAgent()
67110
app = to_a2a(agent, host="localhost", port=8000, protocol="http")
68111
# Then run with: uvicorn module:app --host localhost --port 8000
112+
113+
# Or with custom agent card:
114+
app = to_a2a(agent, agent_card=my_custom_agent_card)
69115
"""
70116
# Set up ADK logging to ensure logs are visible when using uvicorn directly
71117
setup_adk_logger(logging.INFO)
@@ -93,8 +139,10 @@ async def create_runner() -> Runner:
93139
agent_executor=agent_executor, task_store=task_store
94140
)
95141

96-
# Build agent card
142+
# Use provided agent card or build one from the agent
97143
rpc_url = f"{protocol}://{host}:{port}/"
144+
provided_agent_card = _load_agent_card(agent_card)
145+
98146
card_builder = AgentCardBuilder(
99147
agent=agent,
100148
rpc_url=rpc_url,
@@ -105,12 +153,15 @@ async def create_runner() -> Runner:
105153

106154
# Add startup handler to build the agent card and configure A2A routes
107155
async def setup_a2a():
108-
# Build the agent card asynchronously
109-
agent_card = await card_builder.build()
156+
# Use provided agent card or build one asynchronously
157+
if provided_agent_card is not None:
158+
final_agent_card = provided_agent_card
159+
else:
160+
final_agent_card = await card_builder.build()
110161

111162
# Create the A2A Starlette application
112163
a2a_app = A2AStarletteApplication(
113-
agent_card=agent_card,
164+
agent_card=final_agent_card,
114165
http_handler=request_handler,
115166
)
116167

tests/unittests/a2a/utils/test_agent_to_a2a.py

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -689,3 +689,183 @@ def test_to_a2a_with_ip_address_host(
689689
mock_card_builder_class.assert_called_once_with(
690690
agent=self.mock_agent, rpc_url="http://192.168.1.1:8000/"
691691
)
692+
693+
@patch("google.adk.a2a.utils.agent_to_a2a.A2aAgentExecutor")
694+
@patch("google.adk.a2a.utils.agent_to_a2a.DefaultRequestHandler")
695+
@patch("google.adk.a2a.utils.agent_to_a2a.InMemoryTaskStore")
696+
@patch("google.adk.a2a.utils.agent_to_a2a.AgentCardBuilder")
697+
@patch("google.adk.a2a.utils.agent_to_a2a.Starlette")
698+
@patch("google.adk.a2a.utils.agent_to_a2a.A2AStarletteApplication")
699+
async def test_to_a2a_with_custom_agent_card_object(
700+
self,
701+
mock_a2a_app_class,
702+
mock_starlette_class,
703+
mock_card_builder_class,
704+
mock_task_store_class,
705+
mock_request_handler_class,
706+
mock_agent_executor_class,
707+
):
708+
"""Test to_a2a with custom AgentCard object."""
709+
# Arrange
710+
mock_app = Mock(spec=Starlette)
711+
mock_starlette_class.return_value = mock_app
712+
mock_task_store = Mock(spec=InMemoryTaskStore)
713+
mock_task_store_class.return_value = mock_task_store
714+
mock_agent_executor = Mock(spec=A2aAgentExecutor)
715+
mock_agent_executor_class.return_value = mock_agent_executor
716+
mock_request_handler = Mock(spec=DefaultRequestHandler)
717+
mock_request_handler_class.return_value = mock_request_handler
718+
mock_card_builder = Mock(spec=AgentCardBuilder)
719+
mock_card_builder_class.return_value = mock_card_builder
720+
mock_a2a_app = Mock(spec=A2AStarletteApplication)
721+
mock_a2a_app_class.return_value = mock_a2a_app
722+
723+
# Create a custom agent card
724+
custom_agent_card = Mock(spec=AgentCard)
725+
custom_agent_card.name = "custom_agent"
726+
727+
# Act
728+
result = to_a2a(self.mock_agent, agent_card=custom_agent_card)
729+
730+
# Assert
731+
assert result == mock_app
732+
# Get the setup_a2a function that was added as startup handler
733+
startup_handler = mock_app.add_event_handler.call_args[0][1]
734+
735+
# Call the setup_a2a function
736+
await startup_handler()
737+
738+
# Verify the card builder build method was NOT called since we provided a card
739+
mock_card_builder.build.assert_not_called()
740+
741+
# Verify A2A Starlette application was created with custom card
742+
mock_a2a_app_class.assert_called_once_with(
743+
agent_card=custom_agent_card,
744+
http_handler=mock_request_handler,
745+
)
746+
747+
# Verify routes were added to the main app
748+
mock_a2a_app.add_routes_to_app.assert_called_once_with(mock_app)
749+
750+
@patch("google.adk.a2a.utils.agent_to_a2a.A2aAgentExecutor")
751+
@patch("google.adk.a2a.utils.agent_to_a2a.DefaultRequestHandler")
752+
@patch("google.adk.a2a.utils.agent_to_a2a.InMemoryTaskStore")
753+
@patch("google.adk.a2a.utils.agent_to_a2a.AgentCardBuilder")
754+
@patch("google.adk.a2a.utils.agent_to_a2a.Starlette")
755+
@patch("google.adk.a2a.utils.agent_to_a2a.A2AStarletteApplication")
756+
@patch("json.load")
757+
@patch("pathlib.Path.open")
758+
@patch("pathlib.Path")
759+
async def test_to_a2a_with_agent_card_file_path(
760+
self,
761+
mock_path_class,
762+
mock_open,
763+
mock_json_load,
764+
mock_a2a_app_class,
765+
mock_starlette_class,
766+
mock_card_builder_class,
767+
mock_task_store_class,
768+
mock_request_handler_class,
769+
mock_agent_executor_class,
770+
):
771+
"""Test to_a2a with agent card file path."""
772+
# Arrange
773+
mock_app = Mock(spec=Starlette)
774+
mock_starlette_class.return_value = mock_app
775+
mock_task_store = Mock(spec=InMemoryTaskStore)
776+
mock_task_store_class.return_value = mock_task_store
777+
mock_agent_executor = Mock(spec=A2aAgentExecutor)
778+
mock_agent_executor_class.return_value = mock_agent_executor
779+
mock_request_handler = Mock(spec=DefaultRequestHandler)
780+
mock_request_handler_class.return_value = mock_request_handler
781+
mock_card_builder = Mock(spec=AgentCardBuilder)
782+
mock_card_builder_class.return_value = mock_card_builder
783+
mock_a2a_app = Mock(spec=A2AStarletteApplication)
784+
mock_a2a_app_class.return_value = mock_a2a_app
785+
786+
# Mock file operations
787+
mock_path = Mock()
788+
mock_path_class.return_value = mock_path
789+
mock_file_handle = Mock()
790+
# Create a proper context manager mock
791+
mock_context_manager = Mock()
792+
mock_context_manager.__enter__ = Mock(return_value=mock_file_handle)
793+
mock_context_manager.__exit__ = Mock(return_value=None)
794+
mock_path.open = Mock(return_value=mock_context_manager)
795+
796+
# Mock agent card data from file with all required fields
797+
agent_card_data = {
798+
"name": "file_agent",
799+
"url": "http://example.com",
800+
"description": "Test agent from file",
801+
"version": "1.0.0",
802+
"capabilities": {},
803+
"skills": [],
804+
"defaultInputModes": ["text/plain"],
805+
"defaultOutputModes": ["text/plain"],
806+
"supportsAuthenticatedExtendedCard": False,
807+
}
808+
mock_json_load.return_value = agent_card_data
809+
810+
# Act
811+
result = to_a2a(self.mock_agent, agent_card="/path/to/agent_card.json")
812+
813+
# Assert
814+
assert result == mock_app
815+
# Get the setup_a2a function that was added as startup handler
816+
startup_handler = mock_app.add_event_handler.call_args[0][1]
817+
818+
# Call the setup_a2a function
819+
await startup_handler()
820+
821+
# Verify file was opened and JSON was loaded
822+
mock_path_class.assert_called_once_with("/path/to/agent_card.json")
823+
mock_path.open.assert_called_once_with("r", encoding="utf-8")
824+
mock_json_load.assert_called_once_with(mock_file_handle)
825+
826+
# Verify the card builder build method was NOT called since we provided a card
827+
mock_card_builder.build.assert_not_called()
828+
829+
# Verify A2A Starlette application was created with loaded card
830+
mock_a2a_app_class.assert_called_once()
831+
args, kwargs = mock_a2a_app_class.call_args
832+
assert kwargs["http_handler"] == mock_request_handler
833+
# The agent_card should be an AgentCard object created from loaded data
834+
assert hasattr(kwargs["agent_card"], "name")
835+
836+
@patch("google.adk.a2a.utils.agent_to_a2a.A2aAgentExecutor")
837+
@patch("google.adk.a2a.utils.agent_to_a2a.DefaultRequestHandler")
838+
@patch("google.adk.a2a.utils.agent_to_a2a.InMemoryTaskStore")
839+
@patch("google.adk.a2a.utils.agent_to_a2a.AgentCardBuilder")
840+
@patch("google.adk.a2a.utils.agent_to_a2a.Starlette")
841+
@patch("pathlib.Path.open", side_effect=FileNotFoundError("File not found"))
842+
@patch("pathlib.Path")
843+
def test_to_a2a_with_invalid_agent_card_file_path(
844+
self,
845+
mock_path_class,
846+
mock_open,
847+
mock_starlette_class,
848+
mock_card_builder_class,
849+
mock_task_store_class,
850+
mock_request_handler_class,
851+
mock_agent_executor_class,
852+
):
853+
"""Test to_a2a with invalid agent card file path."""
854+
# Arrange
855+
mock_app = Mock(spec=Starlette)
856+
mock_starlette_class.return_value = mock_app
857+
mock_task_store = Mock(spec=InMemoryTaskStore)
858+
mock_task_store_class.return_value = mock_task_store
859+
mock_agent_executor = Mock(spec=A2aAgentExecutor)
860+
mock_agent_executor_class.return_value = mock_agent_executor
861+
mock_request_handler = Mock(spec=DefaultRequestHandler)
862+
mock_request_handler_class.return_value = mock_request_handler
863+
mock_card_builder = Mock(spec=AgentCardBuilder)
864+
mock_card_builder_class.return_value = mock_card_builder
865+
866+
mock_path = Mock()
867+
mock_path_class.return_value = mock_path
868+
869+
# Act & Assert
870+
with pytest.raises(ValueError, match="Failed to load agent card from"):
871+
to_a2a(self.mock_agent, agent_card="/invalid/path.json")

0 commit comments

Comments
 (0)