Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
3 changes: 0 additions & 3 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,5 @@ jobs:
with:
python-version-file: "pyproject.toml"

- name: Install Dependencies
run: uv sync --all-packages

- name: Run checks
run: make check
55 changes: 26 additions & 29 deletions adk/agenticlayer/agent_to_a2a.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import contextlib
import logging
import os
from typing import AsyncIterator

from a2a.server.apps import A2AStarletteApplication
from a2a.server.request_handlers import DefaultRequestHandler
Expand All @@ -10,6 +8,7 @@
from google.adk.a2a.executor.a2a_agent_executor import A2aAgentExecutor
from google.adk.a2a.utils.agent_card_builder import AgentCardBuilder
from google.adk.agents.base_agent import BaseAgent
from google.adk.apps.app import App
from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService
from google.adk.auth.credential_service.in_memory_credential_service import InMemoryCredentialService
from google.adk.cli.utils.logs import setup_adk_logger
Expand All @@ -28,7 +27,7 @@ def filter(self, record: logging.LogRecord) -> bool:
return record.getMessage().find(AGENT_CARD_WELL_KNOWN_PATH) == -1


def to_a2a(agent: BaseAgent) -> Starlette:
async def to_a2a(agent: BaseAgent) -> Starlette:
"""Convert an ADK agent to a A2A Starlette application.
This is an adaption of google.adk.a2a.utils.agent_to_a2a.

Expand All @@ -55,13 +54,15 @@ def to_a2a(agent: BaseAgent) -> Starlette:
async def create_runner() -> Runner:
"""Create a runner for the agent."""
return Runner(
app_name=agent.name or "adk_agent",
agent=agent,
app=App(
name=agent.name or "adk_agent",
root_agent=agent,
plugins=[CallbackTracerPlugin()],
),
artifact_service=InMemoryArtifactService(),
session_service=InMemorySessionService(), # type: ignore
memory_service=InMemoryMemoryService(), # type: ignore
credential_service=InMemoryCredentialService(), # type: ignore
plugins=[CallbackTracerPlugin()],
)

# Create A2A components
Expand All @@ -79,32 +80,28 @@ async def create_runner() -> Runner:
agent_card_url = os.environ.get("AGENT_A2A_RPC_URL", os.environ.get("A2A_AGENT_CARD_URL", None))
logger.debug(f"Using agent card url: {agent_card_url}")

# Add startup handler to build the agent card and configure A2A routes
@contextlib.asynccontextmanager
async def lifespan(app: Starlette) -> AsyncIterator[None]:
logger.debug("Setting up A2A app")
# Build agent card
card_builder = AgentCardBuilder(
agent=agent,
rpc_url=agent_card_url,
)
# Build the agent card asynchronously
agent_card = await card_builder.build()

# Create the A2A Starlette application
a2a_app = A2AStarletteApplication(
agent_card=agent_card,
http_handler=request_handler,
)
logger.debug("Setting up A2A app")
# Build agent card
card_builder = AgentCardBuilder(
agent=agent,
rpc_url=agent_card_url,
)
# Build the agent card asynchronously
agent_card = await card_builder.build()

# Add A2A routes to the main app
a2a_app.add_routes_to_app(
app,
)
yield
# Create the A2A Starlette application
a2a_app = A2AStarletteApplication(
agent_card=agent_card,
http_handler=request_handler,
)

# Create a Starlette app that will be configured during startup
starlette_app = Starlette(lifespan=lifespan)
starlette_app = Starlette()

# Add A2A routes to the main app
a2a_app.add_routes_to_app(
starlette_app,
)

# Instrument the Starlette app with OpenTelemetry
StarletteInstrumentor().instrument_app(starlette_app)
Expand Down
72 changes: 72 additions & 0 deletions adk/agenticlayer/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import json
import logging

from google.adk.agents import BaseAgent
from google.adk.agents.llm_agent import ToolUnion
from google.adk.agents.remote_a2a_agent import RemoteA2aAgent
from google.adk.tools.agent_tool import AgentTool
from google.adk.tools.mcp_tool import StreamableHTTPConnectionParams
from google.adk.tools.mcp_tool.mcp_toolset import McpToolset


def parse_sub_agents(sub_agents_config: str) -> tuple[list[BaseAgent], list[ToolUnion]]:
"""
Get sub agents from JSON string.
Format: {"agent_name": {"url": "http://agent_url", "interaction_type", "transfer|tool_call"}, ...}

:return: A tuple of:
- list of sub agents for transfer interaction type
- list of agent tools for tool_call interaction type
"""

try:
agents_map = json.loads(sub_agents_config)
except json.JSONDecodeError as e:
raise ValueError("Warning: Invalid JSON in SUB_AGENTS environment variable: " + sub_agents_config, e)

sub_agents: list[BaseAgent] = []
tools: list[ToolUnion] = []
for agent_name, config in agents_map.items():
if "url" not in config:
raise ValueError(f"Missing 'url' for agent '{agent_name}': " + str(config))

interaction_type = config.get("interaction_type", "tool_call")

logging.info("Adding sub-agent: %s (%s) with URL: %s", agent_name, interaction_type, config["url"])
agent = RemoteA2aAgent(name=agent_name, agent_card=config["url"])
if interaction_type == "tool_call":
tools.append(AgentTool(agent=agent))
else:
sub_agents.append(agent)

return sub_agents, tools


def parse_tools(tools_config: str) -> list[ToolUnion]:
"""
Get tools from JSON string.
Format: {"tool_name": {"url": "http://tool_url"}, ...}

:return: A list of McpToolset tools
"""

try:
tools_map = json.loads(tools_config)
except json.JSONDecodeError as e:
raise ValueError("Warning: Invalid JSON in AGENT_TOOLS environment variable: " + tools_config, e)

tools: list[ToolUnion] = []
for name, config in tools_map.items():
if "url" not in config:
raise ValueError(f"Missing 'url' for tool '{name}': " + str(config))

logging.info("Adding tool: %s with URL: %s", name, config["url"])
tools.append(
McpToolset(
connection_params=StreamableHTTPConnectionParams(
url=config["url"],
),
)
)

return tools
166 changes: 142 additions & 24 deletions adk/tests/test_a2a_starlette.py
Original file line number Diff line number Diff line change
@@ -1,38 +1,156 @@
import uuid
from typing import Any

import pytest
from agenticlayer.agent_to_a2a import to_a2a
from google.adk.agents.base_agent import BaseAgent
from starlette.applications import Starlette
from agenticlayer.config import parse_sub_agents, parse_tools
from google.adk.agents.llm_agent import LlmAgent
from google.adk.models.lite_llm import LiteLlm
from starlette.testclient import TestClient


class TestA2AStarlette:
"""Test suite for the a2a_starlette module."""
def create_mock_agent_card(
agent_name: str,
base_url: str,
skills: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Helper function to create a valid agent card response."""
return {
"name": agent_name,
"description": f"Mock agent {agent_name}",
"url": base_url,
"version": "1.0.0",
"capabilities": {},
"skills": skills or [],
"default_input_modes": ["text/plain"],
"default_output_modes": ["text/plain"],
"supports_authenticated_extended_card": False,
}


@pytest.fixture
def test_agent(self) -> BaseAgent:
"""Create a test agent for testing."""
return BaseAgent(name="test_agent")
def create_send_message_request(
message_text: str = "Hello, agent!",
) -> dict[str, Any]:
"""Helper function to create a valid A2A send message request."""
message_id = str(uuid.uuid4())
context_id = str(uuid.uuid4())
return {
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": {
"message": {
"role": "user",
"parts": [{"kind": "text", "text": message_text}],
"messageId": message_id,
"contextId": context_id,
},
"metadata": {},
},
}

@pytest.fixture
def starlette_app(self, test_agent: BaseAgent) -> Starlette:
"""Create a Starlette app with the test agent."""
return to_a2a(test_agent)

@pytest.fixture
def client(self, starlette_app: Starlette) -> TestClient:
"""Create a test client."""
return TestClient(starlette_app)
def create_agent(
name: str = "test_agent",
sub_agents_config: str = "{}",
tools_config: str = "{}",
) -> LlmAgent:
sub_agents, agent_tools = parse_sub_agents(sub_agents_config)
mcp_tools = parse_tools(tools_config)
tools = [*agent_tools, *mcp_tools]
return LlmAgent(
name=name,
model=LiteLlm(model="gemini/gemini-2.5-flash"),
description="Test agent",
instruction="You are a test agent.",
sub_agents=sub_agents,
tools=tools,
)

def test_agent_card_endpoint(self, starlette_app: Starlette, client: TestClient) -> None:

class TestA2AStarlette:
@pytest.mark.asyncio
async def test_agent_card(self) -> None:
"""Test that the agent card is available at /.well-known/agent-card.json"""

# Try the standard agent card endpoint
# Given:
agent = create_agent()
app = await to_a2a(agent)
client = TestClient(app)

# When: Requesting the agent card endpoint
response = client.get("/.well-known/agent-card.json")

if response.status_code == 200:
# Great! We found the agent card
data = response.json()
assert isinstance(data, dict), "Agent card should return a JSON object"
# Then: Agent card is returned
assert response.status_code == 200
data = response.json()
assert isinstance(data, dict), "Agent card should return a JSON object"
assert data.get("name") == agent.name
assert data.get("description") == agent.description

@pytest.mark.asyncio
async def test_agent_rpc_send_message(self) -> None:
"""Test that the RPC url is working for send message."""

# Given:
agent = create_agent()
app = await to_a2a(agent)
client = TestClient(app)

# When: Sending an A2A RPC request
rpc_response = client.post("", json=create_send_message_request())

# Then: RPC response is returned
assert rpc_response.status_code == 200
rpc_data = rpc_response.json()
assert rpc_data.get("jsonrpc") == "2.0"
assert rpc_data.get("id") == 1

@pytest.mark.asyncio
async def test_sub_agents(self) -> None:
"""Test that sub-agents are parsed and integrated correctly."""

# When: Creating an agent with sub-agents
sub_agents_config = """{
"sub_agent_1": {
"url": "http://sub-agent-1.local/.well-known/agent-card.json",
"interaction_type": "transfer"
},
"sub_agent_2": {
"url": "http://sub-agent-2.local/.well-known/agent-card.json",
"interaction_type": "tool_call"
}
}"""
agent = create_agent(sub_agents_config=sub_agents_config)

# Then: Verify sub-agents and tools are parsed correctly
assert len(agent.sub_agents) == 1, "There should be 1 sub-agent for transfer interaction type"
assert len(agent.tools) == 1, "There should be 1 agent tool for tool_call interaction type"

# When: Requesting the agent card endpoint
app = await to_a2a(agent)
client = TestClient(app)
response = client.get("/.well-known/agent-card.json")

# Then: Agent card is returned
assert response.status_code == 200

@pytest.mark.asyncio
async def test_tools(self) -> None:
"""Test that tools are parsed and integrated correctly."""

# When: Creating an agent with tools
tools_config = """{
"tool_1": {
"url": "http://tool-1.local/mcp"
},
"tool_2": {
"url": "http://tool-2.local/mcp"
}
}"""
tools = parse_tools(tools_config)

# Then: Verify McpToolsets are created correctly
assert len(tools) == 2, "There should be 2 McpToolset tools"

# Verify it contains expected agent card fields
assert len(data) > 0, "Agent card should not be empty"
# Note: Further integration tests would require mocking MCP tool behavior
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ dev = [
"pytest>=9.0.1,<10",
"pytest-cov>=7,<8",
"types-protobuf>=6.30.2.20250822",
"pytest-asyncio>=1.3.0",
]


Expand All @@ -38,7 +39,7 @@ build-backend = "hatchling.build"

[tool.pytest.ini_options]
minversion = "7.0"
testpaths = "test"
testpaths = "adk/tests"
pythonpath = ["."]
addopts = """\
--strict-config \
Expand Down
Loading