Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
e49c692
feat: add a2a agent card generation with the CLI
guillaumeblaquiere Nov 18, 2025
291a473
feat: add a2a agent card generation with the CLI
guillaumeblaquiere Nov 18, 2025
6051de6
Merge remote-tracking branch 'origin/add-generate-card-command' into …
guillaumeblaquiere Nov 18, 2025
921a203
Update src/google/adk/cli/cli_generate_agent_card.py
guillaumeblaquiere Nov 18, 2025
0e97442
Update src/google/adk/cli/cli_generate_agent_card.py
guillaumeblaquiere Nov 18, 2025
3141abc
chore: remove useless line
guillaumeblaquiere Nov 18, 2025
6044144
Merge branch 'main' into add-generate-card-command
guillaumeblaquiere Nov 19, 2025
3557f02
Merge branch 'main' into add-generate-card-command
guillaumeblaquiere Nov 19, 2025
2d49d1d
chore: fix format
guillaumeblaquiere Nov 21, 2025
67a6739
Merge remote-tracking branch 'origin/add-generate-card-command' into …
guillaumeblaquiere Nov 21, 2025
b06f74d
fix: remove useless test
guillaumeblaquiere Nov 23, 2025
d0a7b90
test: add tests on new CLI entry point
guillaumeblaquiere Nov 23, 2025
5fa7105
Merge branch 'main' into add-generate-card-command
guillaumeblaquiere Nov 23, 2025
7fe4931
Merge branch 'main' into add-generate-card-command
guillaumeblaquiere Nov 25, 2025
10202f1
Merge branch 'main' into add-generate-card-command
guillaumeblaquiere Nov 25, 2025
c1de9a7
Merge branch 'main' into add-generate-card-command
guillaumeblaquiere Nov 25, 2025
191a5ea
Merge branch 'main' into add-generate-card-command
guillaumeblaquiere Nov 26, 2025
d9dcce1
Merge branch 'main' into add-generate-card-command
guillaumeblaquiere Nov 26, 2025
b5ef552
Merge branch 'main' into add-generate-card-command
guillaumeblaquiere Nov 28, 2025
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
1 change: 1 addition & 0 deletions src/google/adk/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
from ..utils.env_utils import is_env_enabled
from .utils import envs
from .utils.agent_loader import AgentLoader
from .cli_generate_agent_card import generate_agent_card


class InputFile(BaseModel):
Expand Down
100 changes: 100 additions & 0 deletions src/google/adk/cli/cli_generate_agent_card.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import asyncio
import json
import os
import click

from .utils.agent_loader import AgentLoader


@click.command(name="generate_agent_card")
@click.option(
"--protocol",
default="https",
help="Protocol for the agent URL (default: https)",
)
@click.option(
"--host",
default="127.0.0.1",
help="Host for the agent URL (default: 127.0.0.1)",
)
@click.option(
"--port",
default="8000",
help="Port for the agent URL (default: 8000)",
)
@click.option(
"--create-file",
is_flag=True,
default=False,
help="Create agent.json file in each agent directory",
)
def generate_agent_card(
protocol: str, host: str, port: str, create_file: bool
) -> None:
"""Generates agent cards for all detected agents."""
asyncio.run(
_generate_agent_card_async(protocol, host, port, create_file)
)


async def _generate_agent_card_async(
protocol: str, host: str, port: str, create_file: bool
) -> None:
try:
from ..a2a.utils.agent_card_builder import AgentCardBuilder
except ImportError as e:
click.secho(
"Error: 'a2a' package is required for this command. "
"Please install it with 'pip install google-adk[a2a]'.",
fg="red",
err=True,
)
return

cwd = os.getcwd()
loader = AgentLoader(agents_dir=cwd)
agent_names = loader.list_agents()

agent_cards = []

for agent_name in agent_names:
try:
agent = loader.load_agent(agent_name)
# If it's an App, get the root agent
if hasattr(agent, "root_agent"):
agent = agent.root_agent

builder = AgentCardBuilder(
agent=agent,
rpc_url=f"{protocol}://{host}:{port}/{agent_name}",
)
card = await builder.build()
card_dict = card.model_dump(exclude_none=True)
agent_cards.append(card_dict)

if create_file:
agent_dir = os.path.join(cwd, agent_name)
agent_json_path = os.path.join(agent_dir, "agent.json")
with open(agent_json_path, "w", encoding="utf-8") as f:
json.dump(card_dict, f, indent=2)

except Exception as e:
# Log error but continue with other agents
# Using click.echo to print to stderr to not mess up JSON output on stdout
click.echo(f"Error processing agent {agent_name}: {e}", err=True)

click.echo(json.dumps(agent_cards, indent=2))
4 changes: 3 additions & 1 deletion src/google/adk/cli/cli_tools_click.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
from . import cli_deploy
from .. import version
from ..evaluation.constants import MISSING_EVAL_DEPENDENCIES_MESSAGE
from .cli import run_cli
from .cli import run_cli, generate_agent_card
from .fast_api import get_fast_api_app
from .utils import envs
from .utils import evals
Expand Down Expand Up @@ -1811,3 +1811,5 @@ def cli_deploy_gke(
)
except Exception as e:
click.secho(f"Deploy failed: {e}", fg="red", err=True)

main.add_command(generate_agent_card)
101 changes: 101 additions & 0 deletions tests/unittests/cli/test_fast_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,17 @@
from google.adk.agents.base_agent import BaseAgent
from google.adk.agents.run_config import RunConfig
from google.adk.apps.app import App
from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService
from google.adk.cli.fast_api import get_fast_api_app
from google.adk.auth.credential_service.in_memory_credential_service import InMemoryCredentialService
from google.adk.evaluation.eval_case import EvalCase
from google.adk.evaluation.eval_case import Invocation
from google.adk.evaluation.eval_result import EvalSetResult
from google.adk.evaluation.eval_set import EvalSet
from google.adk.evaluation.in_memory_eval_sets_manager import InMemoryEvalSetsManager
from google.adk.events.event import Event
from google.adk.events.event_actions import EventActions
from google.adk.memory.in_memory_memory_service import InMemoryMemoryService
from google.adk.runners import Runner
from google.adk.sessions.in_memory_session_service import InMemorySessionService
from google.adk.sessions.session import Session
Expand Down Expand Up @@ -963,6 +966,104 @@ def test_a2a_agent_discovery(test_app_with_a2a):
logger.info("A2A agent discovery test passed")


@pytest.mark.skipif(
sys.version_info < (3, 10), reason="A2A requires Python 3.10+"
)
def test_a2a_runner_factory_creates_isolated_runner(temp_agents_dir_with_a2a):
"""Verify the A2A runner factory creates a copy of the runner with in-memory services."""
# 1. Setup Mocks for the original runner and its services
original_runner = Runner(
agent=MagicMock(),
app_name="test_app",
session_service=MagicMock(),
)
original_runner.memory_service = MagicMock()
original_runner.artifact_service = MagicMock()
original_runner.credential_service = MagicMock()

# Mock the AdkWebServer to control the runner it returns
mock_web_server_instance = MagicMock()
mock_web_server_instance.get_runner_async = AsyncMock(
return_value=original_runner
)
# The factory captures the app_name, so we need to mock list_agents
mock_web_server_instance.list_agents.return_value = ["test_a2a_agent"]

# 2. Patch dependencies in the fast_api module
with patch(
"google.adk.cli.fast_api.AdkWebServer"
) as mock_web_server, patch(
"a2a.server.apps.A2AStarletteApplication"
) as mock_a2a_app, patch(
"a2a.server.tasks.InMemoryTaskStore"
) as mock_task_store, patch(
"google.adk.a2a.executor.a2a_agent_executor.A2aAgentExecutor"
) as mock_executor, patch(
"a2a.server.request_handlers.DefaultRequestHandler"
) as mock_handler, patch(
"a2a.types.AgentCard"
) as mock_agent_card, patch(
"a2a.utils.constants.AGENT_CARD_WELL_KNOWN_PATH", "/agent.json"
):
mock_web_server.return_value = mock_web_server_instance
mock_task_store.return_value = MagicMock()
mock_executor.return_value = MagicMock()
mock_handler.return_value = MagicMock()
mock_agent_card.return_value = MagicMock()

# Change to temp directory
original_cwd = os.getcwd()
os.chdir(temp_agents_dir_with_a2a)
try:
# 3. Call get_fast_api_app to trigger the factory creation
get_fast_api_app(
agents_dir=".",
web=False,
session_service_uri="",
artifact_service_uri="",
memory_service_uri="",
allow_origins=[],
a2a=True, # Enable A2A to create the factory
host="127.0.0.1",
port=8000,
)
finally:
os.chdir(original_cwd)

# 4. Capture the factory from the mocked A2aAgentExecutor
assert mock_executor.call_args is not None, "A2aAgentExecutor not called"
kwargs = mock_executor.call_args.kwargs
assert "runner" in kwargs
runner_factory = kwargs["runner"]

# 5. Execute the factory to get the new runner
# Since runner_factory is an async function, we need to run it.
a2a_runner = asyncio.run(runner_factory())

# 6. Assert that the new runner is a separate, modified copy
assert a2a_runner is not original_runner, "Runner should be a copy"

# Assert that services have been replaced with InMemory versions
assert isinstance(a2a_runner.memory_service, InMemoryMemoryService)
assert isinstance(a2a_runner.session_service, InMemorySessionService)
assert isinstance(a2a_runner.artifact_service, InMemoryArtifactService)
assert isinstance(
a2a_runner.credential_service, InMemoryCredentialService
)

# Assert that the original runner's services are unchanged
assert not isinstance(original_runner.memory_service, InMemoryMemoryService)
assert not isinstance(
original_runner.session_service, InMemorySessionService
)
assert not isinstance(
original_runner.artifact_service, InMemoryArtifactService
)
assert not isinstance(
original_runner.credential_service, InMemoryCredentialService
)


@pytest.mark.skipif(
sys.version_info < (3, 10), reason="A2A requires Python 3.10+"
)
Expand Down