diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 5a0583f..93bf6cf 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -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 diff --git a/adk/agenticlayer/agent_to_a2a.py b/adk/agenticlayer/agent_to_a2a.py index 7cd7cc4..cde6f60 100644 --- a/adk/agenticlayer/agent_to_a2a.py +++ b/adk/agenticlayer/agent_to_a2a.py @@ -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 @@ -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 @@ -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. @@ -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 @@ -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) diff --git a/adk/agenticlayer/config.py b/adk/agenticlayer/config.py new file mode 100644 index 0000000..c80d63b --- /dev/null +++ b/adk/agenticlayer/config.py @@ -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 diff --git a/adk/tests/test_a2a_starlette.py b/adk/tests/test_a2a_starlette.py index 183b595..0e5dad1 100644 --- a/adk/tests/test_a2a_starlette.py +++ b/adk/tests/test_a2a_starlette.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index e5feecf..d179e6e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", ] @@ -38,7 +39,7 @@ build-backend = "hatchling.build" [tool.pytest.ini_options] minversion = "7.0" -testpaths = "test" +testpaths = "adk/tests" pythonpath = ["."] addopts = """\ --strict-config \ diff --git a/uv.lock b/uv.lock index fd6fef8..5c5c6b8 100644 --- a/uv.lock +++ b/uv.lock @@ -15,7 +15,7 @@ members = [ [[package]] name = "a2a-sdk" -version = "0.3.16" +version = "0.3.19" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-api-core" }, @@ -24,9 +24,9 @@ dependencies = [ { name = "protobuf" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/df/57/0c5605a956646c3a3fe0a6f0eb2eb1193718b01b5ef3fb7288b20684e67b/a2a_sdk-0.3.16.tar.gz", hash = "sha256:bc579091cfcf18341076379ea7efb361df0aca4822db05db7267d9d7f881e964", size = 228805, upload-time = "2025-11-21T13:34:48.842Z" } +sdist = { url = "https://files.pythonhosted.org/packages/66/74/db61ee9d2663b291a7eec03bbc7685bec72b1ceb113001350766c03f20de/a2a_sdk-0.3.19.tar.gz", hash = "sha256:ecf526d1d7781228d8680292f913bad1099ba3335a7f0ea6811543c2bd3e601d", size = 229184, upload-time = "2025-11-25T13:48:05.185Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/e9/2fb9871bb416ae34b3a8f3c08de4bccb0a9b3b1dc0cb9a48940b13c14601/a2a_sdk-0.3.16-py3-none-any.whl", hash = "sha256:e5e1d6f8208985ed42b488dde9721bfb9efdf94e903700bb6c53d599b1433e03", size = 141390, upload-time = "2025-11-21T13:34:47.332Z" }, + { url = "https://files.pythonhosted.org/packages/cd/cd/14c1242d171b9739770be35223f1cbc1fb0244ebea2c704f8ae0d9e6abf7/a2a_sdk-0.3.19-py3-none-any.whl", hash = "sha256:314123f84524259313ec0cd9826a34bae5de769dea44b8eb9a0eca79b8935772", size = 141519, upload-time = "2025-11-25T13:48:02.622Z" }, ] [[package]] @@ -43,6 +43,7 @@ dev = [ { name = "mypy", extra = ["reports"] }, { name = "pip-audit" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "ruff" }, { name = "types-protobuf" }, @@ -57,6 +58,7 @@ dev = [ { name = "mypy", extras = ["reports"], specifier = ">=1.12.0,<2" }, { name = "pip-audit", specifier = ">=2.5,<3" }, { name = "pytest", specifier = ">=9.0.1,<10" }, + { name = "pytest-asyncio", specifier = ">=1.3.0" }, { name = "pytest-cov", specifier = ">=7,<8" }, { name = "ruff", specifier = ">=0.11.2" }, { name = "types-protobuf", specifier = ">=6.30.2.20250822" }, @@ -2422,11 +2424,11 @@ wheels = [ [[package]] name = "packageurl-python" -version = "0.17.5" +version = "0.17.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3a/f0/de0ac00a4484c0d87b71e3d9985518278d89797fa725e90abd3453bccb42/packageurl_python-0.17.5.tar.gz", hash = "sha256:a7be3f3ba70d705f738ace9bf6124f31920245a49fa69d4b416da7037dd2de61", size = 43832, upload-time = "2025-08-06T14:08:20.235Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/d6/3b5a4e3cfaef7a53869a26ceb034d1ff5e5c27c814ce77260a96d50ab7bb/packageurl_python-0.17.6.tar.gz", hash = "sha256:1252ce3a102372ca6f86eb968e16f9014c4ba511c5c37d95a7f023e2ca6e5c25", size = 50618, upload-time = "2025-11-24T15:20:17.998Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/78/9dbb7d2ef240d20caf6f79c0f66866737c9d0959601fd783ff635d1d019d/packageurl_python-0.17.5-py3-none-any.whl", hash = "sha256:f0e55452ab37b5c192c443de1458e3f3b4d8ac27f747df6e8c48adeab081d321", size = 30544, upload-time = "2025-08-06T14:08:19.055Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2f/c7277b7615a93f51b5fbc1eacfc1b75e8103370e786fd8ce2abf6e5c04ab/packageurl_python-0.17.6-py3-none-any.whl", hash = "sha256:31a85c2717bc41dd818f3c62908685ff9eebcb68588213745b14a6ee9e7df7c9", size = 36776, upload-time = "2025-11-24T15:20:16.962Z" }, ] [[package]] @@ -2717,7 +2719,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.12.4" +version = "2.12.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -2725,9 +2727,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/ad/a17bc283d7d81837c061c49e3eaa27a45991759a1b7eae1031921c6bd924/pydantic-2.12.4.tar.gz", hash = "sha256:0f8cb9555000a4b5b617f66bfd2566264c4984b27589d3b845685983e8ea85ac", size = 821038, upload-time = "2025-11-05T10:50:08.59Z" } +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/82/2f/e68750da9b04856e2a7ec56fc6f034a5a79775e9b9a81882252789873798/pydantic-2.12.4-py3-none-any.whl", hash = "sha256:92d3d202a745d46f9be6df459ac5a064fdaa3c1c4cd8adcfa332ccf3c05f871e", size = 463400, upload-time = "2025-11-05T10:50:06.732Z" }, + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, ] [[package]] @@ -2863,6 +2865,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/8b/6300fb80f858cda1c51ffa17075df5d846757081d11ab4aa35cef9e6258b/pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad", size = 373668, upload-time = "2025-11-12T13:05:07.379Z" }, ] +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + [[package]] name = "pytest-cov" version = "7.0.0" @@ -2971,16 +2986,16 @@ wheels = [ [[package]] name = "referencing" -version = "0.37.0" +version = "0.36.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, { name = "rpds-py" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744, upload-time = "2025-01-25T08:48:16.138Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775, upload-time = "2025-01-25T08:48:14.241Z" }, ] [[package]]