From 072532531b8ea90eba78c0604af93f61cfbb19b8 Mon Sep 17 00:00:00 2001 From: Jorge Moreno Date: Thu, 30 Jul 2026 11:16:36 -0700 Subject: [PATCH 1/9] a2a --- configs/example_a2a_agents.json | 45 ++ examples/a2a_average_mcp_server.py | 111 ++++ examples/a2a_google_adk_agent.py | 336 +++++++++++ examples/a2a_langchain_agent.py | 317 ++++++++++ examples/a2a_table_mcp_server.py | 89 +++ .../agent_cards/google_adk_agent_card.json | 26 + .../agent_cards/langchain_agent_card.json | 26 + pyproject.toml | 2 + src/mada/core/a2a_client.py | 123 ++++ src/mada/core/config/__init__.py | 10 + src/mada/core/config/a2a.py | 135 +++++ src/mada/core/config/app.py | 11 + .../orchestration/agent_as_tool_strategy.py | 22 +- src/mada/core/orchestration/base_strategy.py | 4 +- src/mada/core/orchestrator.py | 156 ++++- src/mada/interfaces/__init__.py | 1 + src/mada/interfaces/a2a/__init__.py | 4 + src/mada/interfaces/a2a/main.py | 560 ++++++++++++++++++ src/mada/interfaces/cli/main.py | 4 +- src/mada/interfaces/gradio/main.py | 2 + .../interfaces/gradio/mcp_client_wrapper.py | 6 +- src/mada/interfaces/openai_api/main.py | 4 +- src/mada/main.py | 77 ++- tests/unit/core/test_config.py | 74 +++ tests/unit/test_entrypoints.py | 238 +++++++- 25 files changed, 2372 insertions(+), 11 deletions(-) create mode 100644 configs/example_a2a_agents.json create mode 100644 examples/a2a_average_mcp_server.py create mode 100644 examples/a2a_google_adk_agent.py create mode 100644 examples/a2a_langchain_agent.py create mode 100644 examples/a2a_table_mcp_server.py create mode 100644 examples/agent_cards/google_adk_agent_card.json create mode 100644 examples/agent_cards/langchain_agent_card.json create mode 100644 src/mada/core/a2a_client.py create mode 100644 src/mada/core/config/a2a.py create mode 100644 src/mada/interfaces/a2a/__init__.py create mode 100644 src/mada/interfaces/a2a/main.py diff --git a/configs/example_a2a_agents.json b/configs/example_a2a_agents.json new file mode 100644 index 0000000..9bed276 --- /dev/null +++ b/configs/example_a2a_agents.json @@ -0,0 +1,45 @@ +{ + "model": { + "provider": "livai", + "model": "gpt-5.4", + "api_key": "${API_KEY}", + "base_url": "${API_BASE_URL:-https://livai-api.llnl.gov/v1}" + }, + "agents": [ + { + "agent_name": "LocalCoordinatorAgent", + "description": "Coordinates local reasoning and delegates to remote A2A agents when useful.", + "domain": "coordination", + "mcp_servers": [], + "instructions": "You are LocalCoordinatorAgent, a MADA specialist that handles local reasoning and helps the planning agent decide when to use remote A2A agents." + }, + { + "agent_name": "LocalCritiqueAgent", + "description": "Reviews proposed answers, identifies gaps, and recommends improvements.", + "domain": "critique", + "mcp_servers": [], + "instructions": "You are LocalCritiqueAgent, a rigorous reviewer. Identify flaws, risky assumptions, missing context, and concrete improvements." + } + ], + "a2a_agents": { + "LangChainAgent": { + "url": "http://localhost:9111/", + "card_url": "http://localhost:9111/.well-known/agent-card.json" + }, + "GoogleADKAgent": { + "url": "http://localhost:9112/", + "card_url": "http://localhost:9112/.well-known/agent-card.json" + } + }, + "orchestration": { + "mode": "agent-as-tool", + "participants": ["LocalCoordinatorAgent", "LocalCritiqueAgent"] + }, + "interface": { + "title": "MADA A2A Agent Orchestrator", + "description": "Coordinate local MADA agents with remote A2A agents", + "chat_placeholder": "Ask MADA to solve a task or delegate to a remote A2A agent...", + "port": 7862, + "share": false + } +} diff --git a/examples/a2a_average_mcp_server.py b/examples/a2a_average_mcp_server.py new file mode 100644 index 0000000..bc8dfb5 --- /dev/null +++ b/examples/a2a_average_mcp_server.py @@ -0,0 +1,111 @@ +# Copyright 2026, Lawrence Livermore National Security, LLC and MADA contributors +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +""" +CSV column-average MCP server used by the Google ADK A2A example agent. + +Run: + python examples/a2a_average_mcp_server.py --port 9102 + +Install optional dependencies first: + pip install fastmcp +""" + +from __future__ import annotations + +import argparse +import csv +from io import StringIO + +from fastmcp import FastMCP + + +SAMPLE_CSV = """experiment,temperature_c,pressure_kpa +alpha,21.2,101.3 +beta,24.8,99.8 +gamma,19.6,103.1 +delta,22.4,100.6 +""" + + +def create_server() -> FastMCP: + mcp = FastMCP(name="A2A Column Average MCP Server") + + @mcp.tool() + def calculate_column_averages(columns: str = "all") -> str: + """ + Calculate averages for numeric columns in a built-in CSV table. + """ + rows = _read_sample_rows() + numeric_columns = _numeric_columns(rows) + + if columns.strip().lower() != "all": + requested = [ + column.strip() + for column in columns.split(",") + if column.strip() in numeric_columns + ] + if not requested: + return ( + "No requested numeric columns were found. " + f"Available numeric columns: {', '.join(numeric_columns)}." + ) + numeric_columns = requested + + lines = ["Column averages from the sample experiment table:"] + for column in numeric_columns: + values = [float(row[column]) for row in rows] + value = sum(values) / len(values) + lines.append(f"- {column}: {value:.2f}") + return "\n".join(lines) + + return mcp + + +def _read_sample_rows() -> list[dict[str, str]]: + return list(csv.DictReader(StringIO(SAMPLE_CSV))) + + +def _numeric_columns(rows: list[dict[str, str]]) -> list[str]: + if not rows: + return [] + columns = [] + for column in rows[0]: + try: + for row in rows: + float(row[column]) + except ValueError: + continue + columns.append(column) + return columns + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Run the A2A column-average MCP server" + ) + parser.add_argument("--host", default="0.0.0.0", help="Host interface to bind") + parser.add_argument("--port", type=int, default=9102, help="Port to bind") + parser.add_argument( + "--transport", + choices=["stdio", "streamable-http"], + default="streamable-http", + help="MCP transport", + ) + args = parser.parse_args() + + server = create_server() + if args.transport == "stdio": + server.run(transport="stdio") + return + + server.run( + transport="streamable-http", + host=args.host, + port=args.port, + stateless_http=True, + ) + + +if __name__ == "__main__": + main() diff --git a/examples/a2a_google_adk_agent.py b/examples/a2a_google_adk_agent.py new file mode 100644 index 0000000..03254ff --- /dev/null +++ b/examples/a2a_google_adk_agent.py @@ -0,0 +1,336 @@ +# Copyright 2026, Lawrence Livermore National Security, LLC and MADA contributors +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +""" +Simple Google ADK-backed A2A agent for MADA. + +Run: + python examples/a2a_average_mcp_server.py --port 9102 + python examples/a2a_google_adk_agent.py --port 9002 + python examples/a2a_google_adk_agent.py --port 9002 --model gemini-2.5-pro + +Install optional dependencies first: + pip install google-adk fastapi uvicorn fastmcp + +By default this example reads: + MADA_MODEL or GOOGLE_MODEL + +MADA config: + { + "a2a_agents": { + "GoogleADKAgent": { + "url": "http://localhost:9002/", + "description": "Simple Google ADK remote agent" + } + } + } + +Smoke test prompt from MADA: + What are the average values for the sample table columns? +""" + +from __future__ import annotations + +import argparse +import json +import os +import time +import uuid +from pathlib import Path +from typing import Any + +from fastapi import FastAPI, HTTPException + +try: + from google.adk.agents import Agent + from google.adk.runners import Runner + from google.adk.sessions import InMemorySessionService + from google.genai import types +except ImportError: # pragma: no cover - example dependency guard + Agent = None + Runner = None + InMemorySessionService = None + types = None + + +DEFAULT_MODEL = "gemini-2.5-flash" +DEFAULT_MCP_URL = "http://localhost:9102/mcp" +DEFAULT_AVERAGE_COLUMNS = "all" +DEFAULT_AGENT_CARD_PATH = ( + Path(__file__).parent / "agent_cards" / "google_adk_agent_card.json" +) +APP_NAME = "mada_google_adk_a2a_agent" + + +def should_run_average_tool(task: str) -> bool: + lowered = task.lower() + return ( + "average" in lowered + or "mean" in lowered + or "columns" in lowered + or "column" in lowered + or "numeric" in lowered + ) + + +def stringify_mcp_result(result: Any) -> str: + if result is None: + return "" + content = getattr(result, "content", None) + if content is not None: + return stringify_mcp_result(content) + if isinstance(result, list): + parts = [] + for item in result: + text = getattr(item, "text", None) + parts.append(str(text if text is not None else item)) + return "\n".join(parts) + return str(result) + + +class MCPExampleToolClient: + def __init__(self, url: str) -> None: + self.url = url + + async def calculate_column_averages( + self, columns: str = DEFAULT_AVERAGE_COLUMNS + ) -> str: + try: + from fastmcp import Client + except ImportError as exc: # pragma: no cover - example dependency guard + raise RuntimeError( + "This example requires fastmcp for MCP tool calls. Install it with " + "`pip install fastmcp`." + ) from exc + + async with Client(self.url) as client: + try: + result = await client.call_tool( + "calculate_column_averages", + arguments={"columns": columns}, + timeout=30, + ) + except Exception as exc: + message = f"Average MCP tool call failed: {type(exc).__name__}: {exc}" + print(message, flush=True) + return message + text = stringify_mcp_result(result) + print(f"Average MCP tool result: {text}", flush=True) + return text + + +def extract_message_text(params: dict[str, Any]) -> str: + message = params.get("message", params) + if isinstance(message, str): + return message + if not isinstance(message, dict): + return "" + + parts = message.get("parts") + if not isinstance(parts, list): + return str(message.get("text", "") or "") + + text_parts = [] + for part in parts: + if not isinstance(part, dict): + continue + if part.get("kind") == "text" or part.get("type") == "text": + text = part.get("text") + if text: + text_parts.append(str(text)) + return "\n".join(text_parts) + + +def build_task(task_id: str, context_id: str, text: str) -> dict[str, Any]: + message_id = f"msg-{uuid.uuid4().hex}" + now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + message = { + "kind": "message", + "messageId": message_id, + "role": "agent", + "parts": [{"kind": "text", "text": text}], + "taskId": task_id, + "contextId": context_id, + } + return { + "kind": "task", + "id": task_id, + "contextId": context_id, + "status": {"state": "completed", "timestamp": now, "message": message}, + "artifacts": [ + { + "artifactId": f"artifact-{uuid.uuid4().hex}", + "name": "response", + "parts": [{"kind": "text", "text": text}], + } + ], + } + + +def ids_from_params(params: dict[str, Any]) -> tuple[str, str]: + message = params.get("message") + task_id = params.get("id") or params.get("taskId") + context_id = params.get("contextId") + if isinstance(message, dict): + task_id = task_id or message.get("taskId") + context_id = context_id or message.get("contextId") + return str(task_id or f"task-{uuid.uuid4().hex}"), str( + context_id or f"context-{uuid.uuid4().hex}" + ) + + +class GoogleADKA2AAgent: + def __init__(self, model: str, mcp_url: str = DEFAULT_MCP_URL) -> None: + self.model = model + self.mcp_tools = MCPExampleToolClient(mcp_url) + self._session_service = None + self._runner = None + + def _require_adk(self) -> None: + if Agent is None or Runner is None or InMemorySessionService is None: + raise RuntimeError( + "This example requires Google ADK. Install it with " + "`pip install google-adk`." + ) + + @property + def runner(self): + self._require_adk() + if self._runner is None: + agent = Agent( + name="GoogleADKAgent", + model=self.model, + description="Simple Google ADK remote agent callable from MADA.", + instruction=( + "You are a concise remote specialist called by MADA. " + "Complete the delegated task and return only the useful result." + ), + ) + self._session_service = InMemorySessionService() + self._runner = Runner( + agent=agent, + app_name=APP_NAME, + session_service=self._session_service, + ) + return self._runner + + async def run(self, task: str) -> str: + if should_run_average_tool(task): + return await self.mcp_tools.calculate_column_averages() + + self._require_adk() + runner = self.runner + user_id = f"mada-user-{uuid.uuid4().hex}" + session_id = f"mada-session-{uuid.uuid4().hex}" + await self._session_service.create_session( + app_name=APP_NAME, + user_id=user_id, + session_id=session_id, + ) + + message = types.Content( + role="user", + parts=[types.Part(text=task)], + ) + + final_text = "" + async for event in runner.run_async( + user_id=user_id, + session_id=session_id, + new_message=message, + ): + if not event.is_final_response(): + continue + if event.content and event.content.parts: + final_text = "\n".join( + part.text + for part in event.content.parts + if getattr(part, "text", None) + ) + + return final_text + + +def create_app(agent: GoogleADKA2AAgent, public_url: str) -> FastAPI: + app = FastAPI(title="Example Google ADK A2A Agent") + + @app.get("/health") + async def health() -> dict[str, str]: + return {"status": "ok"} + + @app.get("/.well-known/agent-card.json") + async def agent_card() -> dict[str, Any]: + card = json.loads(DEFAULT_AGENT_CARD_PATH.read_text(encoding="utf-8")) + card["url"] = public_url + return card + + @app.post("/") + @app.post("/a2a") + async def handle_rpc(body: dict[str, Any]): + request_id = body.get("id") + if body.get("method") != "message/send": + return { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32601, "message": "Only message/send is supported"}, + } + + params = body.get("params") or {} + if not isinstance(params, dict): + return { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32602, "message": "'params' must be an object"}, + } + + task = extract_message_text(params).strip() + if not task: + return { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32602, "message": "Missing text message"}, + } + + try: + text = await agent.run(task) + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + task_id, context_id = ids_from_params(params) + return { + "jsonrpc": "2.0", + "id": request_id, + "result": build_task(task_id, context_id, text), + } + + return app + + +def main() -> None: + parser = argparse.ArgumentParser(description="Run a simple Google ADK A2A agent") + parser.add_argument("--host", default="0.0.0.0", help="Host interface to bind") + parser.add_argument("--port", type=int, default=9002, help="Port to bind") + parser.add_argument( + "--model", + default=os.getenv("MADA_MODEL") or os.getenv("GOOGLE_MODEL") or DEFAULT_MODEL, + help=( + "Model to use. Defaults to MADA_MODEL, GOOGLE_MODEL, then gemini-2.5-flash." + ), + ) + parser.add_argument( + "--mcp-url", + default=os.getenv("A2A_AVERAGE_MCP_URL") or DEFAULT_MCP_URL, + help="Column-average MCP server URL.", + ) + parser.add_argument("--public-url", default=None) + args = parser.parse_args() + + import uvicorn + + public_url = args.public_url or f"http://localhost:{args.port}" + app = create_app(GoogleADKA2AAgent(args.model, args.mcp_url), public_url) + uvicorn.run(app, host=args.host, port=args.port) + + +if __name__ == "__main__": + main() diff --git a/examples/a2a_langchain_agent.py b/examples/a2a_langchain_agent.py new file mode 100644 index 0000000..b890cb6 --- /dev/null +++ b/examples/a2a_langchain_agent.py @@ -0,0 +1,317 @@ +# Copyright 2026, Lawrence Livermore National Security, LLC and MADA contributors +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +""" +Simple LangChain-backed A2A agent for MADA. + +Run: + python examples/a2a_table_mcp_server.py --port 9101 + python examples/a2a_langchain_agent.py --port 9001 + python examples/a2a_langchain_agent.py --port 9001 --model gpt-5 + +Install optional dependencies first: + pip install langchain-openai fastapi uvicorn fastmcp + +By default this example reads the same common environment variables used by +MADA example configs: + MADA_MODEL or OPENAI_MODEL + API_KEY or OPENAI_API_KEY + API_BASE_URL or OPENAI_BASE_URL + +MADA config: + { + "a2a_agents": { + "LangChainAgent": { + "url": "http://localhost:9001/", + "description": "Simple LangChain model-backed remote agent" + } + } + } + +Smoke test prompt from MADA: + Read the sample CSV table. +""" + +from __future__ import annotations + +import argparse +import json +import os +import time +import uuid +from pathlib import Path +from typing import Any + +from fastapi import FastAPI, HTTPException + +try: + from langchain_openai import ChatOpenAI +except ImportError: # pragma: no cover - example dependency guard + ChatOpenAI = None + + +DEFAULT_MODEL = "gpt-5" +DEFAULT_BASE_URL = "https://api.openai.com/v1" +DEFAULT_MCP_URL = "http://localhost:9101/mcp" +DEFAULT_ROW_LIMIT = 4 +DEFAULT_AGENT_CARD_PATH = ( + Path(__file__).parent / "agent_cards" / "langchain_agent_card.json" +) + + +def should_run_table_tool(task: str) -> bool: + lowered = task.lower() + return ( + "table" in lowered or "csv" in lowered or "read" in lowered or "rows" in lowered + ) + + +def stringify_mcp_result(result: Any) -> str: + if result is None: + return "" + content = getattr(result, "content", None) + if content is not None: + return stringify_mcp_result(content) + if isinstance(result, list): + parts = [] + for item in result: + text = getattr(item, "text", None) + parts.append(str(text if text is not None else item)) + return "\n".join(parts) + return str(result) + + +class MCPExampleToolClient: + def __init__(self, url: str) -> None: + self.url = url + + async def read_sample_table(self, row_limit: int = DEFAULT_ROW_LIMIT) -> str: + try: + from fastmcp import Client + except ImportError as exc: # pragma: no cover - example dependency guard + raise RuntimeError( + "This example requires fastmcp for MCP tool calls. Install it with " + "`pip install fastmcp`." + ) from exc + + async with Client(self.url) as client: + try: + result = await client.call_tool( + "read_sample_table", + arguments={"row_limit": row_limit}, + timeout=30, + ) + except Exception as exc: + message = f"Table MCP tool call failed: {type(exc).__name__}: {exc}" + print(message, flush=True) + return message + text = stringify_mcp_result(result) + print(f"Table MCP tool result: {text}", flush=True) + return text + + +def extract_message_text(params: dict[str, Any]) -> str: + message = params.get("message", params) + if isinstance(message, str): + return message + if not isinstance(message, dict): + return "" + + parts = message.get("parts") + if not isinstance(parts, list): + return str(message.get("text", "") or "") + + text_parts = [] + for part in parts: + if not isinstance(part, dict): + continue + if part.get("kind") == "text" or part.get("type") == "text": + text = part.get("text") + if text: + text_parts.append(str(text)) + return "\n".join(text_parts) + + +def build_task(task_id: str, context_id: str, text: str) -> dict[str, Any]: + message_id = f"msg-{uuid.uuid4().hex}" + now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + message = { + "kind": "message", + "messageId": message_id, + "role": "agent", + "parts": [{"kind": "text", "text": text}], + "taskId": task_id, + "contextId": context_id, + } + return { + "kind": "task", + "id": task_id, + "contextId": context_id, + "status": {"state": "completed", "timestamp": now, "message": message}, + "artifacts": [ + { + "artifactId": f"artifact-{uuid.uuid4().hex}", + "name": "response", + "parts": [{"kind": "text", "text": text}], + } + ], + } + + +def ids_from_params(params: dict[str, Any]) -> tuple[str, str]: + message = params.get("message") + task_id = params.get("id") or params.get("taskId") + context_id = params.get("contextId") + if isinstance(message, dict): + task_id = task_id or message.get("taskId") + context_id = context_id or message.get("contextId") + return str(task_id or f"task-{uuid.uuid4().hex}"), str( + context_id or f"context-{uuid.uuid4().hex}" + ) + + +class LangChainA2AAgent: + def __init__( + self, + model: str, + api_key: str | None = None, + base_url: str | None = None, + mcp_url: str = DEFAULT_MCP_URL, + ) -> None: + self.model = model + self.api_key = api_key + self.base_url = base_url + self.mcp_tools = MCPExampleToolClient(mcp_url) + self._llm = None + + @property + def llm(self): + if ChatOpenAI is None: + raise RuntimeError( + "This example requires langchain-openai. Install it with " + "`pip install langchain-openai`." + ) + if self._llm is None: + kwargs = {"model": self.model} + if self.api_key: + kwargs["api_key"] = self.api_key + if self.base_url: + kwargs["base_url"] = self.base_url + self._llm = ChatOpenAI(**kwargs) + return self._llm + + async def run(self, task: str) -> str: + if should_run_table_tool(task): + return await self.mcp_tools.read_sample_table() + + response = await self.llm.ainvoke( + [ + ( + "system", + "You are a concise remote specialist called by MADA. " + "Complete the delegated task and return only the useful result.", + ), + ("human", task), + ] + ) + return str(getattr(response, "content", response)) + + +def create_app(agent: LangChainA2AAgent, public_url: str) -> FastAPI: + app = FastAPI(title="Example LangChain A2A Agent") + + @app.get("/health") + async def health() -> dict[str, str]: + return {"status": "ok"} + + @app.get("/.well-known/agent-card.json") + async def agent_card() -> dict[str, Any]: + card = json.loads(DEFAULT_AGENT_CARD_PATH.read_text(encoding="utf-8")) + card["url"] = public_url + return card + + @app.post("/") + @app.post("/a2a") + async def handle_rpc(body: dict[str, Any]): + request_id = body.get("id") + if body.get("method") != "message/send": + return { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32601, "message": "Only message/send is supported"}, + } + + params = body.get("params") or {} + if not isinstance(params, dict): + return { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32602, "message": "'params' must be an object"}, + } + + task = extract_message_text(params).strip() + if not task: + return { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32602, "message": "Missing text message"}, + } + + try: + text = await agent.run(task) + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + task_id, context_id = ids_from_params(params) + return { + "jsonrpc": "2.0", + "id": request_id, + "result": build_task(task_id, context_id, text), + } + + return app + + +def main() -> None: + parser = argparse.ArgumentParser(description="Run a simple LangChain A2A agent") + parser.add_argument("--host", default="0.0.0.0", help="Host interface to bind") + parser.add_argument("--port", type=int, default=9001, help="Port to bind") + parser.add_argument( + "--model", + default=os.getenv("MADA_MODEL") or os.getenv("OPENAI_MODEL") or DEFAULT_MODEL, + help="Model to use. Defaults to MADA_MODEL, OPENAI_MODEL, then gpt-5.", + ) + parser.add_argument( + "--api-key", + default=os.getenv("API_KEY") or os.getenv("OPENAI_API_KEY"), + help="API key. Defaults to API_KEY or OPENAI_API_KEY.", + ) + parser.add_argument( + "--base-url", + default=( + os.getenv("API_BASE_URL") + or os.getenv("OPENAI_BASE_URL") + or DEFAULT_BASE_URL + ), + help="OpenAI-compatible base URL.", + ) + parser.add_argument( + "--mcp-url", + default=os.getenv("A2A_TABLE_MCP_URL") or DEFAULT_MCP_URL, + help="Table-reader MCP server URL.", + ) + parser.add_argument("--public-url", default=None) + args = parser.parse_args() + + import uvicorn + + public_url = args.public_url or f"http://localhost:{args.port}" + app = create_app( + LangChainA2AAgent(args.model, args.api_key, args.base_url, args.mcp_url), + public_url, + ) + uvicorn.run(app, host=args.host, port=args.port) + + +if __name__ == "__main__": + main() diff --git a/examples/a2a_table_mcp_server.py b/examples/a2a_table_mcp_server.py new file mode 100644 index 0000000..b10a520 --- /dev/null +++ b/examples/a2a_table_mcp_server.py @@ -0,0 +1,89 @@ +# Copyright 2026, Lawrence Livermore National Security, LLC and MADA contributors +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +""" +CSV table-reader MCP server used by the LangChain A2A example agent. + +Run: + python examples/a2a_table_mcp_server.py --port 9101 + +Install optional dependencies first: + pip install fastmcp +""" + +from __future__ import annotations + +import argparse +import csv +from io import StringIO + +from fastmcp import FastMCP + + +SAMPLE_CSV = """experiment,temperature_c,pressure_kpa +alpha,21.2,101.3 +beta,24.8,99.8 +gamma,19.6,103.1 +delta,22.4,100.6 +""" + + +def create_server() -> FastMCP: + mcp = FastMCP(name="A2A Table Reader MCP Server") + + @mcp.tool() + def read_sample_table(row_limit: int = 4) -> str: + """ + Read a small built-in CSV table and return it as text. + """ + rows = _read_sample_rows() + limit = max(1, min(row_limit, len(rows))) + selected_rows = rows[:limit] + headers = list(rows[0].keys()) + + widths = { + header: max(len(header), *(len(row[header]) for row in selected_rows)) + for header in headers + } + lines = ["Sample experiment table loaded from built-in CSV:"] + lines.append(" ".join(header.ljust(widths[header]) for header in headers)) + for row in selected_rows: + lines.append( + " ".join(row[header].ljust(widths[header]) for header in headers) + ) + return "\n".join(lines) + + return mcp + + +def _read_sample_rows() -> list[dict[str, str]]: + return list(csv.DictReader(StringIO(SAMPLE_CSV))) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Run the A2A table-reader MCP server") + parser.add_argument("--host", default="0.0.0.0", help="Host interface to bind") + parser.add_argument("--port", type=int, default=9101, help="Port to bind") + parser.add_argument( + "--transport", + choices=["stdio", "streamable-http"], + default="streamable-http", + help="MCP transport", + ) + args = parser.parse_args() + + server = create_server() + if args.transport == "stdio": + server.run(transport="stdio") + return + + server.run( + transport="streamable-http", + host=args.host, + port=args.port, + stateless_http=True, + ) + + +if __name__ == "__main__": + main() diff --git a/examples/agent_cards/google_adk_agent_card.json b/examples/agent_cards/google_adk_agent_card.json new file mode 100644 index 0000000..050bfd0 --- /dev/null +++ b/examples/agent_cards/google_adk_agent_card.json @@ -0,0 +1,26 @@ +{ + "protocolVersion": "0.3.0", + "name": "GoogleADKAgent", + "description": "Column average specialist that can compute averages for numeric columns in a small built-in CSV table by calling its MCP average tool.", + "url": "http://localhost:9002", + "version": "0.1.0", + "capabilities": { + "streaming": false + }, + "defaultInputModes": ["text/plain"], + "defaultOutputModes": ["text/plain"], + "skills": [ + { + "id": "column-average-tool", + "name": "CSV column averages", + "description": "Calculate averages for numeric columns in a built-in experiment CSV table.", + "tags": ["average", "mean", "columns", "csv", "mcp"] + }, + { + "id": "google-adk-chat", + "name": "Google ADK chat", + "description": "Answer follow-up questions about column averages and table summaries.", + "tags": ["average", "table", "chat", "summary"] + } + ] +} diff --git a/examples/agent_cards/langchain_agent_card.json b/examples/agent_cards/langchain_agent_card.json new file mode 100644 index 0000000..b3de4db --- /dev/null +++ b/examples/agent_cards/langchain_agent_card.json @@ -0,0 +1,26 @@ +{ + "protocolVersion": "0.3.0", + "name": "LangChainAgent", + "description": "Table reader specialist that can load and display a small built-in CSV table by calling its MCP table-reader tool.", + "url": "http://localhost:9001", + "version": "0.1.0", + "capabilities": { + "streaming": false + }, + "defaultInputModes": ["text/plain"], + "defaultOutputModes": ["text/plain"], + "skills": [ + { + "id": "table-reader-tool", + "name": "CSV table reader", + "description": "Read a small built-in experiment CSV table and return the rows as text.", + "tags": ["table", "csv", "rows", "mcp"] + }, + { + "id": "langchain-chat", + "name": "LangChain chat", + "description": "Answer follow-up questions about the table-reader output.", + "tags": ["table", "chat", "summary"] + } + ] +} diff --git a/pyproject.toml b/pyproject.toml index 959e8fd..1158800 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ dependencies = [ "agent-framework-orchestrations", "fastapi", "gradio>=6.2.0", + "httpx", "openai", "prompt_toolkit", "python-dotenv", @@ -78,6 +79,7 @@ mada = "mada.main:main" mada-gradio = "mada.interfaces.gradio.main:main" mada-cli = "mada.interfaces.cli.main:main" mada-openai-api = "mada.interfaces.openai_api.main:main" +mada-a2a = "mada.interfaces.a2a.main:main" [tool.setuptools.packages.find] where = ["src"] diff --git a/src/mada/core/a2a_client.py b/src/mada/core/a2a_client.py new file mode 100644 index 0000000..7afdf44 --- /dev/null +++ b/src/mada/core/a2a_client.py @@ -0,0 +1,123 @@ +# Copyright 2026, Lawrence Livermore National Security, LLC and MADA contributors +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +""" +Small A2A JSON-RPC client used by the MADA orchestrator. +""" + +from __future__ import annotations + +import uuid +from typing import Any + +import httpx + +from mada.core.config import RemoteA2AAgentConfig + + +class RemoteA2AClient: + """ + Minimal client for delegating a text task to a remote A2A agent. + """ + + def __init__(self, name: str, config: RemoteA2AAgentConfig) -> None: + self.name = name + self.config = config + headers = dict(config.headers) + if config.api_key: + headers["x-api-key"] = config.api_key + self._client = httpx.AsyncClient(headers=headers, timeout=config.timeout) + + async def send_message(self, task: str) -> str: + request_id = f"mada-{uuid.uuid4().hex}" + payload = { + "jsonrpc": "2.0", + "id": request_id, + "method": "message/send", + "params": { + "message": { + "kind": "message", + "messageId": f"msg-{uuid.uuid4().hex}", + "role": "user", + "parts": [{"kind": "text", "text": task}], + } + }, + } + + response = await self._client.post(self.config.url, json=payload) + response.raise_for_status() + data = response.json() + + if data.get("error"): + error = data["error"] + message = error.get("message") if isinstance(error, dict) else str(error) + raise RuntimeError(f"A2A agent {self.name} returned an error: {message}") + + return self._extract_text(data.get("result")) + + async def get_agent_card(self) -> dict[str, Any]: + """ + Fetch the remote agent card when the A2A server exposes one. + """ + if self.config.card_url: + try: + response = await self._client.get(self.config.card_url, timeout=5.0) + response.raise_for_status() + data = response.json() + except Exception: + return {} + return data if isinstance(data, dict) else {} + + base_url = self.config.url.rstrip("/") + if base_url.endswith("/a2a"): + base_url = base_url[: -len("/a2a")] + for path in ( + "/.well-known/agent-card.json", + "/.well-known/agent.json", + "/agent-card.json", + ): + try: + response = await self._client.get(f"{base_url}{path}", timeout=5.0) + if response.status_code == 404: + continue + response.raise_for_status() + data = response.json() + except Exception: + continue + if isinstance(data, dict): + return data + return {} + + async def aclose(self) -> None: + await self._client.aclose() + + def _extract_text(self, result: Any) -> str: + if result is None: + return "" + if isinstance(result, str): + return result + if not isinstance(result, dict): + return str(result) + + texts = [] + self._collect_text_parts(result, texts) + if texts: + return "\n".join(texts) + return str(result) + + def _collect_text_parts(self, value: Any, texts: list[str]) -> None: + if isinstance(value, dict): + parts = value.get("parts") + if isinstance(parts, list): + for part in parts: + if not isinstance(part, dict): + continue + if part.get("kind") == "text" or part.get("type") == "text": + text = part.get("text") + if text: + texts.append(str(text)) + for item in value.values(): + self._collect_text_parts(item, texts) + elif isinstance(value, list): + for item in value: + self._collect_text_parts(item, texts) diff --git a/src/mada/core/config/__init__.py b/src/mada/core/config/__init__.py index 1dd922e..354e40f 100644 --- a/src/mada/core/config/__init__.py +++ b/src/mada/core/config/__init__.py @@ -36,6 +36,12 @@ """ from mada.core.config.agents import AgentConfig +from mada.core.config.a2a import ( + A2AConfig, + RemoteA2AAgentConfig, + load_a2a_agents_config, + load_a2a_config, +) from mada.core.config.app import AppConfig, load_config_from_json from mada.core.config.database import ( DatabaseConfig, @@ -62,6 +68,7 @@ __all__ = [ "AgentConfig", + "A2AConfig", "AppConfig", "BaseModelConfig", "BedrockModelConfig", @@ -72,11 +79,14 @@ "OpenAIModelConfig", "OrchestrationConfig", "PostgreSQLConfig", + "RemoteA2AAgentConfig", "DEFAULT_ORCHESTRATION_MODE", "SUPPORTED_ORCHESTRATION_MODES", "SQLiteConfig", "expand_env_vars", "load_config_from_json", + "load_a2a_config", + "load_a2a_agents_config", "load_database_config", "load_model_config", "load_orchestration_config", diff --git a/src/mada/core/config/a2a.py b/src/mada/core/config/a2a.py new file mode 100644 index 0000000..e382f95 --- /dev/null +++ b/src/mada/core/config/a2a.py @@ -0,0 +1,135 @@ +# Copyright 2026, Lawrence Livermore National Security, LLC and MADA contributors +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +""" +A2A interface and remote agent configuration definitions. +""" + +from dataclasses import dataclass, field +from typing import Any + +from mada.core.config.utils import expand_env_vars + + +@dataclass +class A2AConfig: + """ + Configuration for the Agent-to-Agent HTTP interface. + + Attributes: + name: Public agent name advertised in the A2A agent card. + description: Public agent description advertised in the A2A agent card. + version: Public agent version advertised in the A2A agent card. + url: Externally reachable A2A endpoint URL. When omitted, the runtime + host and port are used to build a local URL for the agent card. + skills: Optional skill entries to expose in the A2A agent card. When + omitted, skills are derived from configured MADA agents. + """ + + name: str = "MADA" + description: str = "MADA multi-agent orchestration service" + version: str = "0.2.0" + url: str = "" + skills: list[dict[str, Any]] = field(default_factory=list) + + def __post_init__(self) -> None: + self.name = expand_env_vars(self.name or "").strip() or "MADA" + self.description = ( + expand_env_vars(self.description or "").strip() + or "MADA multi-agent orchestration service" + ) + self.version = expand_env_vars(self.version or "").strip() or "0.2.0" + self.url = expand_env_vars(self.url or "").strip() + + if self.skills is None: + self.skills = [] + if not isinstance(self.skills, list): + raise ValueError("'a2a.skills' must be a list") + for skill in self.skills: + if not isinstance(skill, dict): + raise ValueError("'a2a.skills' must contain only objects") + + +def load_a2a_config(config_dict: dict[str, Any] | None) -> A2AConfig: + """ + Load A2A configuration from a dictionary. + + Args: + config_dict: Serialized A2A settings, or `None`. + + Returns: + A validated A2A configuration object. + """ + if config_dict is None: + return A2AConfig() + + if not isinstance(config_dict, dict): + raise ValueError("'a2a' must be an object") + + return A2AConfig(**config_dict) + + +@dataclass +class RemoteA2AAgentConfig: + """ + Configuration for a remote A2A agent that MADA can delegate work to. + + Attributes: + url: JSON-RPC endpoint for the remote A2A agent. + card_url: Optional explicit URL for the remote A2A agent card. When + omitted, MADA discovers the card from standard paths derived from + `url`. + description: Optional fallback capability summary used only when the + remote agent card cannot be fetched. + timeout: HTTP timeout in seconds for calls to this remote agent. + api_key: Optional API key sent as `x-api-key`. + headers: Optional additional HTTP headers. + """ + + url: str + card_url: str = "" + description: str = "" + timeout: float = 180.0 + api_key: str = "" + headers: dict[str, str] = field(default_factory=dict) + + def __post_init__(self) -> None: + self.url = expand_env_vars(self.url or "").strip() + if not self.url: + raise ValueError("'a2a_agents..url' must not be empty") + + self.description = expand_env_vars(self.description or "").strip() + self.card_url = expand_env_vars(self.card_url or "").strip() + self.api_key = expand_env_vars(self.api_key or "").strip() + + if not isinstance(self.headers, dict): + raise ValueError("'a2a_agents..headers' must be an object") + + expanded_headers = {} + for key, value in self.headers.items(): + expanded_headers[str(key)] = expand_env_vars(str(value)) + self.headers = expanded_headers + + +def load_a2a_agents_config( + config_dict: dict[str, Any] | None, +) -> dict[str, RemoteA2AAgentConfig]: + """ + Load remote A2A agent definitions from a dictionary. + """ + if config_dict is None: + return {} + + if not isinstance(config_dict, dict): + raise ValueError("'a2a_agents' must be an object") + + agents = {} + for name, agent_config in config_dict.items(): + if not isinstance(agent_config, dict): + raise ValueError("'a2a_agents' values must be objects") + clean_name = str(name).strip() + if not clean_name: + raise ValueError("'a2a_agents' must not contain empty names") + agents[clean_name] = RemoteA2AAgentConfig(**agent_config) + + return agents diff --git a/src/mada/core/config/app.py b/src/mada/core/config/app.py index fbecabe..8947034 100644 --- a/src/mada/core/config/app.py +++ b/src/mada/core/config/app.py @@ -18,6 +18,12 @@ from typing import Any, Dict, List from mada.core.config.agents import AgentConfig +from mada.core.config.a2a import ( + A2AConfig, + RemoteA2AAgentConfig, + load_a2a_agents_config, + load_a2a_config, +) from mada.core.config.database import DatabaseConfig, load_database_config from mada.core.config.interface import InterfaceConfig from mada.core.config.mcp_servers import MCPServerConfig @@ -53,6 +59,8 @@ class AppConfig: mcp_servers: Dict[str, MCPServerConfig] = None # MCP server configurations interface: InterfaceConfig = None # Optional, used only by the Gradio app orchestration: OrchestrationConfig = field(default_factory=OrchestrationConfig) + a2a: A2AConfig = field(default_factory=A2AConfig) + a2a_agents: Dict[str, RemoteA2AAgentConfig] = field(default_factory=dict) @classmethod def from_dict(cls, config_dict: Dict[str, Any]) -> "AppConfig": @@ -101,6 +109,9 @@ def from_dict(cls, config_dict: Dict[str, Any]) -> "AppConfig": ) app_conf["orchestration"] = orchestration_cfg + app_conf["a2a"] = load_a2a_config(config_dict.get("a2a")) + app_conf["a2a_agents"] = load_a2a_agents_config(config_dict.get("a2a_agents")) + # Load MCP servers configuration (optional) python_exe = config_dict.get("python_executable", sys.executable) mcp_servers_entry = config_dict.get("mcp_servers") diff --git a/src/mada/core/orchestration/agent_as_tool_strategy.py b/src/mada/core/orchestration/agent_as_tool_strategy.py index 149f790..5ae8cb4 100644 --- a/src/mada/core/orchestration/agent_as_tool_strategy.py +++ b/src/mada/core/orchestration/agent_as_tool_strategy.py @@ -12,7 +12,7 @@ from agent_framework import MCPStdioTool -from mada.core.config import AgentConfig, MCPServerConfig +from mada.core.config import AgentConfig, MCPServerConfig, RemoteA2AAgentConfig from mada.core.orchestration.base_strategy import BaseOrchestrationStrategy if TYPE_CHECKING: @@ -242,6 +242,7 @@ def _build_status( ( "Connection Successful: Orchestrator initialized with " f"{orchestrator._mcp_tool_count} MCP Servers and " + f"{len(orchestrator.a2a_agents)} remote A2A agents and " f"{len(orchestrator.specialist_agents) + 1} agents" ) ] @@ -263,11 +264,27 @@ def _build_status( return "\n".join(status_parts) + def _remote_a2a_tool_labels(self, orchestrator: "MADAOrchestrator") -> List[str]: + """ + Build user-facing labels for remote A2A agents. + """ + labels = [] + for agent_name, agent_config in orchestrator.a2a_agents.items(): + card = orchestrator._a2a_agent_cards.get(agent_name, {}) + description = ( + card.get("description") + or agent_config.description + or f"Remote A2A agent at {agent_config.url}" + ) + labels.append(f"A2A: {agent_name} - {description}") + return labels + async def initialize( self, orchestrator: "MADAOrchestrator", agent_configs: List[AgentConfig], mcp_servers: Dict[str, MCPServerConfig] | None = None, + a2a_agents: Dict[str, RemoteA2AAgentConfig] | None = None, ) -> Tuple[str, List[str]]: """ Initialize the agent-as-tool orchestration flow end to end. @@ -280,9 +297,12 @@ async def initialize( orchestrator._mcp_tool_count = 0 participant_configs = orchestrator.resolve_participant_configs(agent_configs) orchestrator.mcp_servers = mcp_servers or {} + orchestrator.a2a_agents = a2a_agents or {} + await orchestrator._load_remote_a2a_agent_cards() all_tools, failed_servers, failed_agents = await self._initialize_participants( orchestrator, participant_configs ) + all_tools.extend(self._remote_a2a_tool_labels(orchestrator)) active_participant_configs = self._resolve_active_participant_configs( orchestrator, participant_configs ) diff --git a/src/mada/core/orchestration/base_strategy.py b/src/mada/core/orchestration/base_strategy.py index 60c1477..51c5199 100644 --- a/src/mada/core/orchestration/base_strategy.py +++ b/src/mada/core/orchestration/base_strategy.py @@ -8,7 +8,7 @@ from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Dict, List, Tuple -from mada.core.config import AgentConfig, MCPServerConfig +from mada.core.config import AgentConfig, MCPServerConfig, RemoteA2AAgentConfig if TYPE_CHECKING: from mada.core.orchestrator import MADAOrchestrator @@ -31,6 +31,7 @@ async def initialize( orchestrator: "MADAOrchestrator", agent_configs: List[AgentConfig], mcp_servers: Dict[str, MCPServerConfig] | None = None, + a2a_agents: Dict[str, RemoteA2AAgentConfig] | None = None, ) -> Tuple[str, List[str]]: """ Initialize the orchestrator for the strategy's orchestration mode. @@ -39,6 +40,7 @@ async def initialize( orchestrator: Orchestrator instance being configured. agent_configs: Agent definitions available to the strategy. mcp_servers: Named MCP server definitions available to the strategy. + a2a_agents: Named remote A2A agents available to the strategy. Returns: A user-facing status message and a flat list of connected tool names. diff --git a/src/mada/core/orchestrator.py b/src/mada/core/orchestrator.py index ad2d183..c22d138 100644 --- a/src/mada/core/orchestrator.py +++ b/src/mada/core/orchestrator.py @@ -12,6 +12,7 @@ import asyncio import copy import logging +import re import traceback from types import TracebackType from typing import Any, AsyncGenerator, Dict, List, Optional, Tuple, Type @@ -19,16 +20,25 @@ import httpx import httpcore -from agent_framework import Agent, AgentSession, MCPStdioTool, MCPStreamableHTTPTool +from agent_framework import ( + Agent, + AgentSession, + FunctionInvocationContext, + FunctionTool, + MCPStdioTool, + MCPStreamableHTTPTool, +) from agent_framework.exceptions import ToolException from mada.core.background_tasks import BackgroundTaskManager +from mada.core.a2a_client import RemoteA2AClient from mada.core.config import ( AgentConfig, DatabaseConfig, ModelConfig, MCPServerConfig, OrchestrationConfig, + RemoteA2AAgentConfig, ) from mada.core.coordinator import MCPAgentManager from mada.core.database import ChatSessionManager @@ -89,12 +99,15 @@ def __init__( self.specialist_agents = [] self.planning_agent = None self.mcp_servers = {} + self.a2a_agents = {} + self._a2a_agent_cards: Dict[str, Dict[str, Any]] = {} self.session = None self._session_lock = asyncio.Lock() self._next_turn_id = 1 self._next_turn_commit_id = 1 self._completed_turns: Dict[int, Dict[str, Any]] = {} self._mcp_tools_by_server: Dict[str, Any] = {} + self._a2a_clients_by_agent: Dict[str, RemoteA2AClient] = {} self._agent_descriptions = {} self._mcp_tool_count = 0 self.orchestration = orchestration_config or OrchestrationConfig() @@ -111,6 +124,18 @@ def __init__( # Authentication bearer token self.bearer_token = bearer_token + @staticmethod + def _tool_name(value: str) -> str: + """ + Convert a configured remote agent name into a Python tool function name. + """ + normalized = re.sub(r"[^0-9a-zA-Z_]+", "_", value.strip()).strip("_").lower() + if not normalized: + normalized = "remote_a2a_agent" + if normalized[0].isdigit(): + normalized = f"agent_{normalized}" + return normalized + def _build_orchestration_strategy(self, mode: str) -> BaseOrchestrationStrategy: """ Select the internal orchestration strategy for the configured mode. @@ -503,6 +528,7 @@ def _create_planning_agent( Planning agent instance with specialist agents exposed as tools. """ team_description = self._generate_team_description(participant_configs) + remote_a2a_description = self._generate_remote_a2a_description() # Convert each specialist agent to a tool using as_tool() agent_tools = [] @@ -518,6 +544,8 @@ def _create_planning_agent( ) agent_tools.append(agent_tool) + agent_tools.extend(self._create_remote_a2a_agent_tools()) + # Try to get a user defined planning agent config planning_cfg = self._get_planning_agent_config(agent_configs) @@ -545,8 +573,12 @@ def _create_planning_agent( Your specialist agents (available as tools): {team_description} +Remote A2A agents (available as tools): +{remote_a2a_description} + Guidelines: - Delegate to specialist agents when the request matches their expertise +- Delegate to remote A2A agents when their descriptions match the request - Answer directly only for questions about the system itself - Avoid infinite loops between agents - After receiving results, synthesize and respond to the user @@ -569,6 +601,73 @@ def _create_planning_agent( return planning_agent + def _create_remote_a2a_agent_tools(self) -> List[Any]: + """ + Create planner tools that delegate tasks to configured remote A2A agents. + """ + tools = [] + for agent_name, agent_config in self.a2a_agents.items(): + client = self._a2a_clients_by_agent.get(agent_name) + if client is None: + client = RemoteA2AClient(agent_name, agent_config) + self._a2a_clients_by_agent[agent_name] = client + + description = ( + agent_config.description + or f"Remote A2A agent available at {agent_config.url}" + ) + tool_name = f"call_{self._tool_name(agent_name)}" + tools.append( + self._build_remote_a2a_agent_tool( + tool_name, + agent_name, + description, + client, + ) + ) + + return tools + + def _build_remote_a2a_agent_tool( + self, + tool_name: str, + agent_name: str, + description: str, + client: RemoteA2AClient, + ) -> FunctionTool: + """ + Build one remote A2A planner tool with a clean public signature. + """ + input_schema = { + "type": "object", + "properties": { + "task": { + "type": "string", + "description": "The task to delegate to this remote A2A agent", + } + }, + "required": ["task"], + "additionalProperties": False, + } + + async def call_remote_a2a_agent( + ctx: FunctionInvocationContext, **kwargs: Any + ) -> str: + task = str(kwargs.get("task", "")).strip() + if not task: + raise ValueError("Missing task for remote A2A agent") + return await client.send_message(task) + + return FunctionTool( + name=tool_name, + description=( + f"Delegate a task to remote A2A agent {agent_name}. {description}" + ), + func=call_remote_a2a_agent, + input_model=input_schema, + approval_mode="never_require", + ) + def _generate_team_description(self, agent_configs: List[AgentConfig]) -> str: """ Generate formatted team member descriptions. @@ -588,10 +687,57 @@ def _generate_team_description(self, agent_configs: List[AgentConfig]) -> str: return " (no specialist agents configured)" return "\n".join(lines) + def _generate_remote_a2a_description(self) -> str: + """ + Generate formatted remote A2A agent descriptions. + """ + lines = [] + for agent_name, agent_config in self.a2a_agents.items(): + card = self._a2a_agent_cards.get(agent_name, {}) + description = ( + agent_config.description + or card.get("description") + or f"Remote A2A agent at {agent_config.url}" + ) + skills = card.get("skills") + if isinstance(skills, list) and skills: + skill_lines = [] + for skill in skills: + if not isinstance(skill, dict): + continue + skill_name = skill.get("name") or skill.get("id") or "skill" + skill_description = skill.get("description") or "" + skill_lines.append(f"{skill_name}: {skill_description}".strip()) + if skill_lines: + description = f"{description} Skills: {'; '.join(skill_lines)}" + lines.append(f" {agent_name}: {description}") + if not lines: + return " (no remote A2A agents configured)" + return "\n".join(lines) + + async def _load_remote_a2a_agent_cards(self) -> None: + """ + Best-effort fetch of remote A2A agent cards for planner routing context. + """ + self._a2a_agent_cards.clear() + for agent_name, agent_config in self.a2a_agents.items(): + client = self._a2a_clients_by_agent.get(agent_name) + if client is None: + client = RemoteA2AClient(agent_name, agent_config) + self._a2a_clients_by_agent[agent_name] = client + try: + card = await client.get_agent_card() + except Exception as exc: + LOG.debug(f"Could not fetch A2A agent card for {agent_name}: {exc}") + continue + if card: + self._a2a_agent_cards[agent_name] = card + async def initialize_orchestrator( self, agent_configs: List[AgentConfig], mcp_servers: Dict[str, MCPServerConfig] = None, + a2a_agents: Dict[str, RemoteA2AAgentConfig] = None, ) -> Tuple[str, List[str]]: """ Initialize the orchestrator with the given agent configurations. @@ -599,6 +745,7 @@ async def initialize_orchestrator( Args: agent_configs: List of agent configurations to set up. mcp_servers: Dictionary of MCP server configurations. + a2a_agents: Dictionary of remote A2A agent configurations. Returns: Tuple containing: @@ -609,6 +756,7 @@ async def initialize_orchestrator( orchestrator=self, agent_configs=agent_configs, mcp_servers=mcp_servers, + a2a_agents=a2a_agents, ) def _stringify_openai_content(self, content: Any) -> str: @@ -1175,6 +1323,9 @@ async def cleanup(self) -> None: self._next_turn_id = 1 self._next_turn_commit_id = 1 self._mcp_tools_by_server.clear() + for client in self._a2a_clients_by_agent.values(): + await client.aclose() + self._a2a_clients_by_agent.clear() self._agent_descriptions.clear() LOG.info("Orchestrator cleanup completed") except BaseExceptionGroup as eg: @@ -1182,7 +1333,8 @@ async def cleanup(self) -> None: # This is expected when MCP servers failed to connect - their async generators # will raise errors during cleanup, which we can safely suppress LOG.debug( - f"Async cleanup errors suppressed ({len(eg.exceptions)} errors) - this is expected for failed MCP connections" + "Async cleanup errors suppressed " + f"({len(eg.exceptions)} errors) - this is expected for failed MCP connections" ) except RuntimeError as e: # Suppress "Attempted to exit cancel scope in different task" errors diff --git a/src/mada/interfaces/__init__.py b/src/mada/interfaces/__init__.py index 6bf335b..bc1f999 100644 --- a/src/mada/interfaces/__init__.py +++ b/src/mada/interfaces/__init__.py @@ -7,4 +7,5 @@ This module provides different interfaces for interacting with the multi-agent orchestrator: - CLI interface for command-line interaction - Gradio interface for browser-based interaction +- A2A interface for agent-to-agent HTTP interaction """ diff --git a/src/mada/interfaces/a2a/__init__.py b/src/mada/interfaces/a2a/__init__.py new file mode 100644 index 0000000..af304a2 --- /dev/null +++ b/src/mada/interfaces/a2a/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026, Lawrence Livermore National Security, LLC and MADA contributors +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +"""A2A HTTP interface for MADA.""" diff --git a/src/mada/interfaces/a2a/main.py b/src/mada/interfaces/a2a/main.py new file mode 100644 index 0000000..0d7c169 --- /dev/null +++ b/src/mada/interfaces/a2a/main.py @@ -0,0 +1,560 @@ +# Copyright 2026, Lawrence Livermore National Security, LLC and MADA contributors +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +""" +Agent-to-Agent HTTP interface for MADA Orchestrator. + +This module exposes the configured MADA planning agent as an A2A-compatible +JSON-RPC service. The MADA agent card is available under the standard +`/.well-known/agent-card.json` path. +""" + +from __future__ import annotations + +import asyncio +import json +import re +import secrets +import sys +import time +import uuid +from contextlib import asynccontextmanager +from typing import TYPE_CHECKING, Any, AsyncGenerator, Dict, Optional + +import click + +try: + from fastapi import FastAPI, Header, HTTPException + from fastapi.responses import JSONResponse, StreamingResponse +except ( + ImportError +) as exc: # pragma: no cover - exercised only in missing dependency environments + FastAPI = None + Header = None + HTTPException = None + JSONResponse = None + StreamingResponse = None + FASTAPI_IMPORT_ERROR = exc +else: + FASTAPI_IMPORT_ERROR = None + +try: + import uvicorn +except ( + ImportError +) as exc: # pragma: no cover - exercised only in missing dependency environments + uvicorn = None + UVICORN_IMPORT_ERROR = exc +else: + UVICORN_IMPORT_ERROR = None + +from mada.core import load_config_from_json +from mada.core.config import A2AConfig, AppConfig, OrchestrationConfig + +if TYPE_CHECKING: + from mada.core.orchestrator import MADAOrchestrator + + +def _get_orchestration_config(config: AppConfig) -> OrchestrationConfig: + return getattr(config, "orchestration", None) or OrchestrationConfig() + + +def _get_a2a_config(config: AppConfig) -> A2AConfig: + return getattr(config, "a2a", None) or A2AConfig() + + +class A2AStartupError(RuntimeError): + """Raised when the orchestrator cannot be initialized for A2A requests.""" + + +def _format_startup_error_message(exc: BaseException) -> str: + details = str(exc).strip() or exc.__class__.__name__ + lowered = details.lower() + + if "connect" in lowered or "connection" in lowered or "cancellederror" in lowered: + return ( + "MADA could not connect to one or more MCP servers. " + "Check the MCP server processes and the URLs/commands in your config. " + f"Details: {details}" + ) + + return f"MADA failed to initialize the configured agent team. Details: {details}" + + +def _require_fastapi() -> None: + if FASTAPI_IMPORT_ERROR is not None or uvicorn is None: + missing = [] + if FASTAPI_IMPORT_ERROR is not None: + missing.append("fastapi") + if UVICORN_IMPORT_ERROR is not None: + missing.append("uvicorn") + packages = ", ".join(missing) or "fastapi, uvicorn" + raise RuntimeError( + f"A2A mode requires {packages}. Install the project dependencies again." + ) + + +def _slugify(value: str) -> str: + slug = re.sub(r"[^a-zA-Z0-9_-]+", "-", value.strip()).strip("-").lower() + return slug or "mada-agent" + + +class MADAA2AService: + """ + Manage the shared orchestrator instance used by the A2A API. + """ + + def __init__( + self, + config: AppConfig, + public_url: str, + api_key: Optional[str] = None, + bearer_token: Optional[str] = None, + ) -> None: + self.config = config + self.a2a_config = _get_a2a_config(config) + self.public_url = self.a2a_config.url or public_url + self.api_key = api_key + self.bearer_token = bearer_token + self.orchestrator: Optional[MADAOrchestrator] = None + self._startup_lock = asyncio.Lock() + + async def startup(self) -> None: + if self.orchestrator is not None: + return + + from mada.core.orchestrator import MADAOrchestrator + + orchestrator = None + try: + orchestrator = MADAOrchestrator( + model_config=self.config.model, + database_config=self.config.database, + orchestration_config=_get_orchestration_config(self.config), + bearer_token=self.bearer_token, + ) + await orchestrator.__aenter__() + await orchestrator.initialize_orchestrator( + self.config.agents, + self.config.mcp_servers, + getattr(self.config, "a2a_agents", {}), + ) + self.orchestrator = orchestrator + except BaseException as exc: + if orchestrator is not None: + await orchestrator.__aexit__(None, None, None) + if isinstance(exc, (KeyboardInterrupt, SystemExit)): + raise + raise A2AStartupError(_format_startup_error_message(exc)) from exc + + async def ensure_started(self) -> None: + if self.orchestrator is not None: + return + + async with self._startup_lock: + if self.orchestrator is None: + await self.startup() + + async def shutdown(self) -> None: + if self.orchestrator is None: + return + await self.orchestrator.__aexit__(None, None, None) + self.orchestrator = None + + def validate_api_key( + self, authorization: Optional[str], x_api_key: Optional[str] + ) -> None: + if not self.api_key: + return + + provided_key = x_api_key + if authorization and authorization.lower().startswith("bearer "): + provided_key = authorization[7:].strip() + + if not secrets.compare_digest(provided_key or "", self.api_key): + raise HTTPException(status_code=401, detail="Invalid API key") + + def build_agent_card(self) -> Dict[str, Any]: + return { + "protocolVersion": "0.3.0", + "name": self.a2a_config.name, + "description": self.a2a_config.description, + "url": self.public_url, + "version": self.a2a_config.version, + "capabilities": {"streaming": True}, + "defaultInputModes": ["text/plain"], + "defaultOutputModes": ["text/plain"], + "skills": self._build_skills(), + "supportsAuthenticatedExtendedCard": bool(self.api_key), + } + + def _build_skills(self) -> list[dict[str, Any]]: + if self.a2a_config.skills: + return self.a2a_config.skills + + skills = [] + for agent in self.config.agents: + if getattr(agent, "agent_name", "") == "PlanningAgent": + continue + name = getattr(agent, "agent_name", "") or "MADA Agent" + description = getattr(agent, "description", "") or name + skills.append( + { + "id": _slugify(name), + "name": name, + "description": description, + "tags": [getattr(agent, "domain", "") or "mada"], + } + ) + + if skills: + return skills + + return [ + { + "id": "mada-orchestration", + "name": "MADA orchestration", + "description": self.a2a_config.description, + "tags": ["mada"], + } + ] + + async def collect_response(self, message: str) -> str: + if self.orchestrator is None: + raise RuntimeError("Orchestrator not initialized") + return await self.orchestrator.collect_message_response( + message, + isolated_session=True, + ) + + async def stream_response(self, message: str) -> AsyncGenerator[str, None]: + if self.orchestrator is None: + raise RuntimeError("Orchestrator not initialized") + + async for chunk in self.orchestrator.process_message( + message, + isolated_session=True, + ): + yield chunk + + +def _json_rpc_error( + request_id: Any, + code: int, + message: str, + status_code: int = 200, +) -> JSONResponse: + return JSONResponse( + { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": code, "message": message}, + }, + status_code=status_code, + ) + + +def _extract_message_text(params: Dict[str, Any]) -> str: + message = params.get("message", params) + if isinstance(message, str): + return message + + if not isinstance(message, dict): + return "" + + parts = message.get("parts") + if not isinstance(parts, list): + return str(message.get("text", "") or "") + + text_parts = [] + for part in parts: + if not isinstance(part, dict): + continue + if part.get("kind") == "text" or part.get("type") == "text": + text = part.get("text") + if text: + text_parts.append(str(text)) + + return "\n".join(text_parts) + + +def _build_task( + task_id: str, + context_id: str, + text: str, + state: str = "completed", +) -> Dict[str, Any]: + message_id = f"msg-{uuid.uuid4().hex}" + now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + response_message = { + "kind": "message", + "messageId": message_id, + "role": "agent", + "parts": [{"kind": "text", "text": text}], + "taskId": task_id, + "contextId": context_id, + } + return { + "kind": "task", + "id": task_id, + "contextId": context_id, + "status": { + "state": state, + "timestamp": now, + "message": response_message, + }, + "artifacts": [ + { + "artifactId": f"artifact-{uuid.uuid4().hex}", + "name": "response", + "parts": [{"kind": "text", "text": text}], + } + ], + } + + +def _ids_from_params(params: Dict[str, Any]) -> tuple[str, str]: + message = params.get("message") + task_id = params.get("id") or params.get("taskId") + context_id = params.get("contextId") + + if isinstance(message, dict): + task_id = task_id or message.get("taskId") + context_id = context_id or message.get("contextId") + + return str(task_id or f"task-{uuid.uuid4().hex}"), str( + context_id or f"context-{uuid.uuid4().hex}" + ) + + +def create_a2a_app( + config: AppConfig, + public_url: str, + api_key: Optional[str] = None, + bearer_token: Optional[str] = None, +) -> FastAPI: + """ + Build and return a FastAPI app backed by the configured MADA orchestrator. + """ + _require_fastapi() + service = MADAA2AService( + config=config, + public_url=public_url, + api_key=api_key, + bearer_token=bearer_token, + ) + + @asynccontextmanager + async def lifespan(app: FastAPI): + app.state.mada_a2a_service = service + try: + yield + finally: + await service.shutdown() + + app = FastAPI(title="MADA A2A API", lifespan=lifespan) + + @app.get("/health") + async def health() -> Dict[str, str]: + return { + "status": "ok", + "orchestrator_initialized": "true" + if service.orchestrator is not None + else "false", + } + + async def get_agent_card() -> Dict[str, Any]: + return service.build_agent_card() + + app.get("/.well-known/agent-card.json")(get_agent_card) + app.get("/.well-known/agent.json")(get_agent_card) + app.get("/agent-card.json")(get_agent_card) + + async def handle_rpc( + body: Dict[str, Any], + authorization: Optional[str] = Header(default=None), + x_api_key: Optional[str] = Header(default=None), + ): + service.validate_api_key(authorization, x_api_key) + + request_id = body.get("id") + method = body.get("method") + params = body.get("params") or {} + if not isinstance(params, dict): + return _json_rpc_error(request_id, -32602, "'params' must be an object") + + if method not in {"message/send", "message/stream"}: + return _json_rpc_error(request_id, -32601, f"Unsupported method: {method}") + + message_text = _extract_message_text(params).strip() + if not message_text: + return _json_rpc_error( + request_id, + -32602, + "A2A request must include a text message part", + ) + + try: + await service.ensure_started() + except A2AStartupError as exc: + configured_servers = ( + ", ".join((service.config.mcp_servers or {}).keys()) or "none" + ) + print( + "No MCP servers connected; returning 503 for A2A request. " + f"Configured MCP servers: {configured_servers}", + file=sys.stderr, + flush=True, + ) + raise HTTPException(status_code=503, detail=str(exc)) from exc + + task_id, context_id = _ids_from_params(params) + + if method == "message/send": + content = await service.collect_response(message_text) + return { + "jsonrpc": "2.0", + "id": request_id, + "result": _build_task(task_id, context_id, content), + } + + async def event_stream() -> AsyncGenerator[str, None]: + collected = [] + async for chunk in service.stream_response(message_text): + collected.append(chunk) + task = _build_task( + task_id, + context_id, + "".join(collected), + state="working", + ) + payload = {"jsonrpc": "2.0", "id": request_id, "result": task} + yield f"data: {json.dumps(payload)}\n\n" + + final = { + "jsonrpc": "2.0", + "id": request_id, + "result": _build_task( + task_id, + context_id, + "".join(collected), + state="completed", + ), + } + yield f"data: {json.dumps(final)}\n\n" + + return StreamingResponse(event_stream(), media_type="text/event-stream") + + app.post("/")(handle_rpc) + app.post("/a2a")(handle_rpc) + + return app + + +def run_a2a( + config: AppConfig, + host: str, + port: int, + public_url: Optional[str] = None, + api_key: Optional[str] = None, + bearer_token: Optional[str] = None, +) -> None: + """ + Launch the A2A FastAPI server. + """ + _require_fastapi() + card_url = public_url or f"http://{host}:{port}" + app = create_a2a_app( + config=config, + public_url=card_url, + api_key=api_key, + bearer_token=bearer_token, + ) + uvicorn.run(app, host=host, port=port) + + +def a2a_entrypoint( + host: str, + port: int, + public_url: Optional[str], + api_key: Optional[str], + bearer_token: Optional[str], + config_file: str, +) -> None: + """ + Load config and start the A2A API server. + """ + try: + print(f"Loading configuration from {config_file}") + config = load_config_from_json(config_file) + card_url = public_url or f"http://{host}:{port}" + print(f"Serving A2A API on {card_url}") + run_a2a( + config=config, + host=host, + port=port, + public_url=public_url, + api_key=api_key, + bearer_token=bearer_token, + ) + except Exception as e: + print(f"Error launching A2A interface: {e}") + sys.exit(1) + + +@click.command( + name="mada-a2a", + context_settings={ + "help_option_names": ["-h", "--help"], + }, +) +@click.option( + "--host", + type=str, + default="0.0.0.0", + show_default=True, + help="Host interface to bind.", +) +@click.option( + "-p", + "--port", + type=int, + default=8000, + show_default=True, + help="Port for the A2A API.", +) +@click.option( + "--public-url", + type=str, + default=None, + help="Externally reachable URL to publish in the A2A agent card.", +) +@click.option( + "--api-key", + type=str, + default=None, + help="Optional API key that incoming requests must provide.", +) +@click.option( + "--bearer-token", + type=str, + default=None, + help="Optional bearer token forwarded to streamable HTTP MCP servers as X-Token.", +) +@click.argument("config_file", type=str) +def main( + host: str, + port: int, + public_url: Optional[str], + api_key: Optional[str], + bearer_token: Optional[str], + config_file: str, +) -> None: + """ + Run MADA Orchestrator as an A2A agent. + """ + a2a_entrypoint(host, port, public_url, api_key, bearer_token, config_file) + + +if __name__ == "__main__": + main() diff --git a/src/mada/interfaces/cli/main.py b/src/mada/interfaces/cli/main.py index 560068c..2908764 100644 --- a/src/mada/interfaces/cli/main.py +++ b/src/mada/interfaces/cli/main.py @@ -267,7 +267,9 @@ async def run(self): print("\nInitializing agents and MCP servers...") try: status, tools = await orchestrator.initialize_orchestrator( - self.config.agents, self.config.mcp_servers + self.config.agents, + self.config.mcp_servers, + getattr(self.config, "a2a_agents", {}), ) print(f"Status: {status}") print(f"Orchestration mode: {self.orchestration_config.mode}") diff --git a/src/mada/interfaces/gradio/main.py b/src/mada/interfaces/gradio/main.py index ccdd4db..069e3d6 100644 --- a/src/mada/interfaces/gradio/main.py +++ b/src/mada/interfaces/gradio/main.py @@ -80,6 +80,7 @@ def run_gradio(config: AppConfig): agents=config.agents, database_config=config.database, mcp_servers=config.mcp_servers, + a2a_agents=getattr(config, "a2a_agents", {}), orchestration_config=_get_orchestration_config(config), ) gradio_interface = MADAMultiAgentGradioInterface( @@ -122,6 +123,7 @@ def create_gradio_app(config_path: str) -> gr.Blocks: agents=config.agents, database_config=config.database, mcp_servers=config.mcp_servers, + a2a_agents=getattr(config, "a2a_agents", {}), orchestration_config=_get_orchestration_config(config), ) gradio_interface = MADAMultiAgentGradioInterface( diff --git a/src/mada/interfaces/gradio/mcp_client_wrapper.py b/src/mada/interfaces/gradio/mcp_client_wrapper.py index 05a7a01..ea4e009 100644 --- a/src/mada/interfaces/gradio/mcp_client_wrapper.py +++ b/src/mada/interfaces/gradio/mcp_client_wrapper.py @@ -20,6 +20,7 @@ MCPServerConfig, ModelConfig, OrchestrationConfig, + RemoteA2AAgentConfig, ) from mada.core.database import ChatSessionManager from mada.core.orchestrator import MADAOrchestrator @@ -42,6 +43,7 @@ def __init__( agents: List[AgentConfig], database_config: DatabaseConfig, mcp_servers: MCPServerConfig = None, + a2a_agents: Dict[str, RemoteA2AAgentConfig] = None, orchestration_config: OrchestrationConfig = None, blocking: bool = False, ): @@ -61,6 +63,7 @@ def __init__( self.orchestrator = None self.initialized = False self.mcp_servers = mcp_servers or {} + self.a2a_agents = a2a_agents or {} self.orchestration_config = orchestration_config or OrchestrationConfig() self.session_manager = ChatSessionManager(database_config) self.session_bearer_token = None # Store session bearer token @@ -114,7 +117,8 @@ async def connect_servers( ) status_msg, tools = await self.orchestrator.initialize_orchestrator( agent_configs=self.agents, # Use provided agents - mcp_servers=self.mcp_servers, # Placeholder for MCP server config, replace with real config when available + mcp_servers=self.mcp_servers, + a2a_agents=self.a2a_agents, ) LOG.info("Orchestrator initialization complete!") status_msg = ( diff --git a/src/mada/interfaces/openai_api/main.py b/src/mada/interfaces/openai_api/main.py index 19db826..6422026 100644 --- a/src/mada/interfaces/openai_api/main.py +++ b/src/mada/interfaces/openai_api/main.py @@ -194,7 +194,9 @@ async def startup(self) -> None: ) await orchestrator.__aenter__() await orchestrator.initialize_orchestrator( - self.config.agents, self.config.mcp_servers + self.config.agents, + self.config.mcp_servers, + getattr(self.config, "a2a_agents", {}), ) self.orchestrator = orchestrator except BaseException as exc: diff --git a/src/mada/main.py b/src/mada/main.py index b7d4198..bbaefc0 100644 --- a/src/mada/main.py +++ b/src/mada/main.py @@ -156,6 +156,75 @@ def openai_api_cmd( openai_api_cmd.main(args=args, standalone_mode=False) +def _run_a2a_from_args(args: list[str]): + """ + Run the A2A API mode for MADA. + + Args: + args: command line arguments. + """ + from mada.interfaces.a2a.main import a2a_entrypoint + + @click.command( + context_settings={ + "help_option_names": ["-h", "--help"], + }, + ) + @click.option( + "--host", + type=str, + default="0.0.0.0", + show_default=True, + help="Host interface to bind.", + ) + @click.option( + "-p", + "--port", + type=int, + default=8000, + show_default=True, + help="Port for the A2A API.", + ) + @click.option( + "--public-url", + type=str, + default=None, + help="Externally reachable URL to publish in the A2A agent card.", + ) + @click.option( + "--api-key", + type=str, + default=None, + help="Optional API key that incoming requests must provide.", + ) + @click.option( + "--bearer-token", + type=str, + default=None, + help="Optional bearer token forwarded to streamable HTTP MCP servers as X-Token.", + ) + @click.argument( + "config_file", + type=str, + ) + def a2a_cmd( + host: str, + port: int, + public_url: str | None, + api_key: str | None, + bearer_token: str | None, + config_file: str, + ) -> None: + """ + Run MADA in A2A API mode. + + CONFIG_FILE is the path to the MADA configuration file. + """ + a2a_entrypoint(host, port, public_url, api_key, bearer_token, config_file) + + a2a_cmd.main(args=args, standalone_mode=False) + + @click.command( context_settings={ "help_option_names": ["-h", "--help"], @@ -165,14 +234,14 @@ def openai_api_cmd( ) @click.argument( "mode", - type=click.Choice(["gradio", "cli", "openai-api"], case_sensitive=False), + type=click.Choice(["gradio", "cli", "openai-api", "a2a"], case_sensitive=False), ) @click.pass_context def main(ctx: click.Context, mode: str) -> None: """ Run MADA. - MODE is one of 'gradio', 'cli', or 'openai-api' and will determine the interface. + MODE is one of 'gradio', 'cli', 'openai-api', or 'a2a' and will determine the interface. Examples: @@ -181,6 +250,8 @@ def main(ctx: click.Context, mode: str) -> None: mada cli config.json mada openai-api --port 8000 config.json + + mada a2a --port 8000 config.json """ mode = mode.lower() @@ -193,6 +264,8 @@ def main(ctx: click.Context, mode: str) -> None: _run_cli_from_args(remaining) elif mode == "openai-api": _run_openai_api_from_args(remaining) + elif mode == "a2a": + _run_a2a_from_args(remaining) else: # Protected by click.Choice, here just in case raise click.ClickException(f"Unsupported mode: {mode}") diff --git a/tests/unit/core/test_config.py b/tests/unit/core/test_config.py index b6b447e..69edaa4 100644 --- a/tests/unit/core/test_config.py +++ b/tests/unit/core/test_config.py @@ -4,9 +4,13 @@ import pytest from mada.core.config import ( + A2AConfig, DEFAULT_ORCHESTRATION_MODE, PostgreSQLConfig, + RemoteA2AAgentConfig, SQLiteConfig, + load_a2a_agents_config, + load_a2a_config, load_orchestration_config, ) @@ -96,3 +100,73 @@ def test_load_orchestration_config_defaults_for_empty_object(self): def test_load_orchestration_config_rejects_non_object_blocks(self, invalid_value): with pytest.raises(ValueError, match="'orchestration' must be an object"): load_orchestration_config(invalid_value) + + +@pytest.mark.unit +class TestA2AConfig: + def test_load_a2a_config_defaults_when_omitted(self): + config = load_a2a_config(None) + + assert config.name == "MADA" + assert config.description == "MADA multi-agent orchestration service" + assert config.skills == [] + + def test_load_a2a_config_accepts_metadata(self): + config = load_a2a_config( + { + "name": "MADA A2A", + "description": "Agent card description", + "version": "1.2.3", + "url": "https://mada.example/a2a", + "skills": [{"id": "workflow", "name": "Workflow"}], + } + ) + + assert config == A2AConfig( + name="MADA A2A", + description="Agent card description", + version="1.2.3", + url="https://mada.example/a2a", + skills=[{"id": "workflow", "name": "Workflow"}], + ) + + @pytest.mark.parametrize("invalid_value", [False, []]) + def test_load_a2a_config_rejects_non_object_blocks(self, invalid_value): + with pytest.raises(ValueError, match="'a2a' must be an object"): + load_a2a_config(invalid_value) + + +@pytest.mark.unit +class TestRemoteA2AAgentConfig: + def test_load_a2a_agents_config_defaults_when_omitted(self): + assert load_a2a_agents_config(None) == {} + + def test_load_a2a_agents_config_accepts_remote_agents(self): + config = load_a2a_agents_config( + { + "optimizer": { + "url": "https://optimizer.example/a2a", + "card_url": "https://optimizer.example/.well-known/agent-card.json", + "description": "Remote optimizer", + "timeout": 30, + "api_key": "secret", + "headers": {"x-trace": "enabled"}, + } + } + ) + + assert config == { + "optimizer": RemoteA2AAgentConfig( + url="https://optimizer.example/a2a", + card_url="https://optimizer.example/.well-known/agent-card.json", + description="Remote optimizer", + timeout=30, + api_key="secret", + headers={"x-trace": "enabled"}, + ) + } + + @pytest.mark.parametrize("invalid_value", [False, []]) + def test_load_a2a_agents_config_rejects_non_object_blocks(self, invalid_value): + with pytest.raises(ValueError, match="'a2a_agents' must be an object"): + load_a2a_agents_config(invalid_value) diff --git a/tests/unit/test_entrypoints.py b/tests/unit/test_entrypoints.py index b22d3d4..7b52d3d 100644 --- a/tests/unit/test_entrypoints.py +++ b/tests/unit/test_entrypoints.py @@ -18,11 +18,18 @@ from click.testing import CliRunner from mada.core.config import ( + A2AConfig, MCPServerConfig, OpenAIModelConfig, OrchestrationConfig, SQLiteConfig, ) +from mada.interfaces.a2a.main import ( + MADAA2AService, + a2a_entrypoint, + create_a2a_app, +) +from mada.interfaces.a2a.main import main as a2a_main from mada.interfaces.cli.main import MADACLIInterface, async_main from mada.interfaces.cli.main import main as cli_main from mada.interfaces.gradio.main import ( @@ -40,6 +47,7 @@ main as openai_api_main, ) from mada.main import ( + _run_a2a_from_args, _run_cli_from_args, _run_gradio_from_args, _run_openai_api_from_args, @@ -73,6 +81,8 @@ def __init__(self, interface=None, database=None): self.mcp_servers = {"s1": MCPServerConfig(transport="stdio")} self.database = database self.orchestration = OrchestrationConfig() + self.a2a = A2AConfig() + self.a2a_agents = {} @pytest.fixture @@ -157,6 +167,16 @@ def test_main_dispatches_to_openai_api(self, runner): ["--port", "8000", "config.json"] ) + def test_main_dispatches_to_a2a(self, runner): + """ + Test that the main entry point correctly dispatches to the A2A API + interface when the 'a2a' mode is specified. + """ + with patch("mada.main._run_a2a_from_args") as mock_run_a2a: + result = runner.invoke(main, ["a2a", "--port", "8000", "config.json"]) + assert result.exit_code == 0 + mock_run_a2a.assert_called_once_with(["--port", "8000", "config.json"]) + class TestRunGradioFromArgs: def test_run_gradio_from_args_calls_entrypoint(self): """ @@ -232,6 +252,43 @@ def test_run_openai_api_from_args_uses_defaults(self): "0.0.0.0", 8000, "mada-team", None, None, "config.json" ) + class TestRunA2AFromArgs: + def test_run_a2a_from_args_calls_entrypoint(self): + """ + Test that the helper function `_run_a2a_from_args` calls the A2A + entry point with the correct arguments. + """ + with patch("mada.interfaces.a2a.main.a2a_entrypoint") as mock_entry: + _run_a2a_from_args( + [ + "--host", + "127.0.0.1", + "--port", + "8000", + "--public-url", + "https://mada.example/a2a", + "config.json", + ] + ) + mock_entry.assert_called_once_with( + "127.0.0.1", + 8000, + "https://mada.example/a2a", + None, + None, + "config.json", + ) + + def test_run_a2a_from_args_uses_defaults(self): + """ + Test `_run_a2a_from_args` when optional flags are not provided. + """ + with patch("mada.interfaces.a2a.main.a2a_entrypoint") as mock_entry: + _run_a2a_from_args(["config.json"]) + mock_entry.assert_called_once_with( + "0.0.0.0", 8000, None, None, None, "config.json" + ) + @pytest.mark.unit class TestMADAGradioCmd: @@ -424,6 +481,7 @@ def test_run_gradio_launches_interface_with_defaults( agents=["a1", "a2"], database_config=db_config, mcp_servers={"s1": MCPServerConfig(transport="stdio")}, + a2a_agents={}, orchestration_config=OrchestrationConfig(), ) mock_iface_cls.assert_called_once() @@ -809,8 +867,184 @@ def test_chat_completions_streams_sse_chunks( ) body = response.text - assert response.status_code == 200 - assert "data: [DONE]" in body + assert response.status_code == 200 + assert "data: [DONE]" in body + + +@pytest.mark.unit +class TestMADAA2ACmd: + class TestA2AMain: + def test_main_calls_a2a_entrypoint_with_args(self, runner): + """ + Test that the A2A main function calls the entry point with the + correct CLI arguments. + """ + with patch("mada.interfaces.a2a.main.a2a_entrypoint") as mock_entrypoint: + result = runner.invoke( + a2a_main, + [ + "--host", + "127.0.0.1", + "--port", + "9000", + "--public-url", + "https://mada.example/a2a", + "config.json", + ], + ) + + assert result.exit_code == 0 + mock_entrypoint.assert_called_once_with( + "127.0.0.1", + 9000, + "https://mada.example/a2a", + None, + None, + "config.json", + ) + + def test_main_works_with_only_config_file(self, runner): + """ + Test that the A2A main function uses default values when only the + configuration file is provided. + """ + with patch("mada.interfaces.a2a.main.a2a_entrypoint") as mock_entrypoint: + result = runner.invoke(a2a_main, ["config.json"]) + + assert result.exit_code == 0 + mock_entrypoint.assert_called_once_with( + "0.0.0.0", 8000, None, None, None, "config.json" + ) + + class TestA2AEntrypoint: + def test_a2a_entrypoint_happy_path_uses_config_and_runs_server( + self, create_dummy_config: Callable + ): + """ + Test that the A2A entry point loads the config and launches the API + server. + """ + config = create_dummy_config() + + with ( + patch( + "mada.interfaces.a2a.main.load_config_from_json", + return_value=config, + ) as mock_load, + patch("mada.interfaces.a2a.main.run_a2a") as mock_run, + patch("mada.interfaces.a2a.main.sys.exit") as mock_exit, + ): + a2a_entrypoint( + host="127.0.0.1", + port=8000, + public_url="https://mada.example/a2a", + api_key="secret", + bearer_token="token", + config_file="config.json", + ) + + mock_load.assert_called_once_with("config.json") + mock_run.assert_called_once_with( + config=config, + host="127.0.0.1", + port=8000, + public_url="https://mada.example/a2a", + api_key="secret", + bearer_token="token", + ) + mock_exit.assert_not_called() + + def test_a2a_entrypoint_exits_with_code_1_on_exception(self): + """ + Test that the A2A entry point exits with code 1 when an unexpected + exception occurs. + """ + with ( + patch("mada.interfaces.a2a.main.load_config_from_json") as mock_load, + patch("mada.interfaces.a2a.main.sys.exit") as mock_exit, + ): + mock_load.side_effect = RuntimeError("Bad config") + + a2a_entrypoint( + host="127.0.0.1", + port=8000, + public_url=None, + api_key=None, + bearer_token=None, + config_file="config.json", + ) + + mock_exit.assert_called_once_with(1) + + @pytest.mark.skipif( + TestClient is None, reason="fastapi test client is not installed" + ) + class TestCreateA2AApp: + def test_agent_card_endpoint_returns_configured_metadata( + self, create_dummy_config: Callable + ): + """ + Test that the standard agent card endpoint returns A2A metadata. + """ + config = create_dummy_config() + config.a2a = A2AConfig( + name="MADA Test", + description="Test A2A agent", + version="9.9.9", + ) + + with patch.object(MADAA2AService, "shutdown", new=AsyncMock()): + app = create_a2a_app(config, public_url="https://mada.example/a2a") + with TestClient(app) as client: + response = client.get("/.well-known/agent-card.json") + + assert response.status_code == 200 + payload = response.json() + assert payload["name"] == "MADA Test" + assert payload["description"] == "Test A2A agent" + assert payload["url"] == "https://mada.example/a2a" + assert payload["capabilities"]["streaming"] is True + + def test_message_send_returns_a2a_task(self, create_dummy_config: Callable): + """ + Test that JSON-RPC `message/send` returns a completed A2A task. + """ + config = create_dummy_config() + + with ( + patch.object(MADAA2AService, "ensure_started", new=AsyncMock()), + patch.object(MADAA2AService, "shutdown", new=AsyncMock()), + patch.object( + MADAA2AService, + "collect_response", + new=AsyncMock(return_value="hello from mada"), + ), + ): + app = create_a2a_app(config, public_url="https://mada.example/a2a") + with TestClient(app) as client: + response = client.post( + "/", + json={ + "jsonrpc": "2.0", + "id": "req-1", + "method": "message/send", + "params": { + "message": { + "role": "user", + "parts": [{"kind": "text", "text": "hello"}], + } + }, + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["id"] == "req-1" + assert payload["result"]["status"]["state"] == "completed" + assert ( + payload["result"]["status"]["message"]["parts"][0]["text"] + == "hello from mada" + ) @pytest.mark.unit From fb736a34f91d429c9e925a3275191fbc53b95777 Mon Sep 17 00:00:00 2001 From: Jorge Moreno Date: Thu, 30 Jul 2026 11:51:36 -0700 Subject: [PATCH 2/9] self a2a card --- .../agent_cards/mada_orchestrator_card.json | 32 +++++++++++++++ configs/example_a2a_agents.json | 5 +++ src/mada/core/config/a2a.py | 13 ++++-- src/mada/core/config/app.py | 16 +++++++- src/mada/interfaces/a2a/main.py | 27 ++++++++++++ tests/unit/core/test_config.py | 4 +- tests/unit/test_entrypoints.py | 41 +++++++++++++++++++ 7 files changed, 131 insertions(+), 7 deletions(-) create mode 100644 configs/agent_cards/mada_orchestrator_card.json diff --git a/configs/agent_cards/mada_orchestrator_card.json b/configs/agent_cards/mada_orchestrator_card.json new file mode 100644 index 0000000..50b59ec --- /dev/null +++ b/configs/agent_cards/mada_orchestrator_card.json @@ -0,0 +1,32 @@ +{ + "protocolVersion": "0.3.0", + "name": "MADAOrchestrator", + "description": "MADA multi-agent orchestrator that coordinates local reasoning agents and delegates to remote A2A agents when their capabilities match the task.", + "url": "http://localhost:9120", + "version": "0.2.0", + "capabilities": { + "streaming": true + }, + "defaultInputModes": ["text/plain"], + "defaultOutputModes": ["text/plain"], + "skills": [ + { + "id": "mada-orchestration", + "name": "MADA orchestration", + "description": "Coordinate configured local MADA agents and remote A2A agents to answer delegated tasks.", + "tags": ["mada", "orchestration", "multi-agent", "a2a"] + }, + { + "id": "csv-table-routing", + "name": "CSV table routing", + "description": "Route CSV table-reading and column-average requests to the appropriate remote A2A specialist.", + "tags": ["csv", "table", "average", "routing"] + }, + { + "id": "answer-review", + "name": "Answer review", + "description": "Use local critique behavior to identify gaps, risky assumptions, and concrete improvements.", + "tags": ["review", "critique", "reasoning"] + } + ] +} diff --git a/configs/example_a2a_agents.json b/configs/example_a2a_agents.json index 9bed276..324b781 100644 --- a/configs/example_a2a_agents.json +++ b/configs/example_a2a_agents.json @@ -31,6 +31,11 @@ "card_url": "http://localhost:9112/.well-known/agent-card.json" } }, + "a2a_self": { + "card_path": "agent_cards/mada_orchestrator_card.json", + "url": "http://localhost:9120", + "version": "0.2.0" + }, "orchestration": { "mode": "agent-as-tool", "participants": ["LocalCoordinatorAgent", "LocalCritiqueAgent"] diff --git a/src/mada/core/config/a2a.py b/src/mada/core/config/a2a.py index e382f95..ec0e59c 100644 --- a/src/mada/core/config/a2a.py +++ b/src/mada/core/config/a2a.py @@ -22,6 +22,9 @@ class A2AConfig: version: Public agent version advertised in the A2A agent card. url: Externally reachable A2A endpoint URL. When omitted, the runtime host and port are used to build a local URL for the agent card. + card_path: Optional path to a standalone A2A agent card JSON file. + When provided, the A2A interface serves this card and overrides its + `url` field with the runtime public URL. skills: Optional skill entries to expose in the A2A agent card. When omitted, skills are derived from configured MADA agents. """ @@ -30,6 +33,7 @@ class A2AConfig: description: str = "MADA multi-agent orchestration service" version: str = "0.2.0" url: str = "" + card_path: str = "" skills: list[dict[str, Any]] = field(default_factory=list) def __post_init__(self) -> None: @@ -40,19 +44,20 @@ def __post_init__(self) -> None: ) self.version = expand_env_vars(self.version or "").strip() or "0.2.0" self.url = expand_env_vars(self.url or "").strip() + self.card_path = expand_env_vars(self.card_path or "").strip() if self.skills is None: self.skills = [] if not isinstance(self.skills, list): - raise ValueError("'a2a.skills' must be a list") + raise ValueError("'a2a_self.skills' must be a list") for skill in self.skills: if not isinstance(skill, dict): - raise ValueError("'a2a.skills' must contain only objects") + raise ValueError("'a2a_self.skills' must contain only objects") def load_a2a_config(config_dict: dict[str, Any] | None) -> A2AConfig: """ - Load A2A configuration from a dictionary. + Load self A2A configuration from a dictionary. Args: config_dict: Serialized A2A settings, or `None`. @@ -64,7 +69,7 @@ def load_a2a_config(config_dict: dict[str, Any] | None) -> A2AConfig: return A2AConfig() if not isinstance(config_dict, dict): - raise ValueError("'a2a' must be an object") + raise ValueError("'a2a_self' must be an object") return A2AConfig(**config_dict) diff --git a/src/mada/core/config/app.py b/src/mada/core/config/app.py index 8947034..ddf19cd 100644 --- a/src/mada/core/config/app.py +++ b/src/mada/core/config/app.py @@ -15,6 +15,7 @@ import logging import sys from dataclasses import dataclass, field +from pathlib import Path from typing import Any, Dict, List from mada.core.config.agents import AgentConfig @@ -109,7 +110,7 @@ def from_dict(cls, config_dict: Dict[str, Any]) -> "AppConfig": ) app_conf["orchestration"] = orchestration_cfg - app_conf["a2a"] = load_a2a_config(config_dict.get("a2a")) + app_conf["a2a"] = load_a2a_config(config_dict.get("a2a_self")) app_conf["a2a_agents"] = load_a2a_agents_config(config_dict.get("a2a_agents")) # Load MCP servers configuration (optional) @@ -142,7 +143,18 @@ def load_config_from_json(path: str) -> AppConfig: Returns: AppConfig: The parsed application configuration object. """ - with open(path, "r") as f: + config_path = Path(path) + with open(config_path, "r") as f: config_dict = json.load(f) + a2a_config = config_dict.get("a2a_self") + if isinstance(a2a_config, dict): + card_path = a2a_config.get("card_path") + if card_path: + resolved_card_path = Path(str(card_path).strip()) + if not resolved_card_path.is_absolute(): + a2a_config["card_path"] = str( + (config_path.parent / resolved_card_path).resolve() + ) + return AppConfig.from_dict(config_dict) diff --git a/src/mada/interfaces/a2a/main.py b/src/mada/interfaces/a2a/main.py index 0d7c169..049c76d 100644 --- a/src/mada/interfaces/a2a/main.py +++ b/src/mada/interfaces/a2a/main.py @@ -19,6 +19,7 @@ import time import uuid from contextlib import asynccontextmanager +from pathlib import Path from typing import TYPE_CHECKING, Any, AsyncGenerator, Dict, Optional import click @@ -175,6 +176,16 @@ def validate_api_key( raise HTTPException(status_code=401, detail="Invalid API key") def build_agent_card(self) -> Dict[str, Any]: + if self.a2a_config.card_path: + card = self._load_agent_card_file() + card["url"] = self.public_url + card.setdefault("protocolVersion", "0.3.0") + card.setdefault("capabilities", {"streaming": True}) + card.setdefault("defaultInputModes", ["text/plain"]) + card.setdefault("defaultOutputModes", ["text/plain"]) + card["supportsAuthenticatedExtendedCard"] = bool(self.api_key) + return card + return { "protocolVersion": "0.3.0", "name": self.a2a_config.name, @@ -188,6 +199,22 @@ def build_agent_card(self) -> Dict[str, Any]: "supportsAuthenticatedExtendedCard": bool(self.api_key), } + def _load_agent_card_file(self) -> Dict[str, Any]: + card_path = Path(self.a2a_config.card_path) + try: + with card_path.open("r", encoding="utf-8") as card_file: + card = json.load(card_file) + except OSError as exc: + raise RuntimeError(f"Could not read A2A agent card: {card_path}") from exc + except json.JSONDecodeError as exc: + raise RuntimeError( + f"A2A agent card is not valid JSON: {card_path}" + ) from exc + + if not isinstance(card, dict): + raise RuntimeError(f"A2A agent card must be a JSON object: {card_path}") + return card + def _build_skills(self) -> list[dict[str, Any]]: if self.a2a_config.skills: return self.a2a_config.skills diff --git a/tests/unit/core/test_config.py b/tests/unit/core/test_config.py index 69edaa4..86b142d 100644 --- a/tests/unit/core/test_config.py +++ b/tests/unit/core/test_config.py @@ -118,6 +118,7 @@ def test_load_a2a_config_accepts_metadata(self): "description": "Agent card description", "version": "1.2.3", "url": "https://mada.example/a2a", + "card_path": "/tmp/mada-card.json", "skills": [{"id": "workflow", "name": "Workflow"}], } ) @@ -127,12 +128,13 @@ def test_load_a2a_config_accepts_metadata(self): description="Agent card description", version="1.2.3", url="https://mada.example/a2a", + card_path="/tmp/mada-card.json", skills=[{"id": "workflow", "name": "Workflow"}], ) @pytest.mark.parametrize("invalid_value", [False, []]) def test_load_a2a_config_rejects_non_object_blocks(self, invalid_value): - with pytest.raises(ValueError, match="'a2a' must be an object"): + with pytest.raises(ValueError, match="'a2a_self' must be an object"): load_a2a_config(invalid_value) diff --git a/tests/unit/test_entrypoints.py b/tests/unit/test_entrypoints.py index 7b52d3d..e15172b 100644 --- a/tests/unit/test_entrypoints.py +++ b/tests/unit/test_entrypoints.py @@ -9,6 +9,7 @@ - mada/interface/gradio/main.py -> The `mada-gradio` command. """ +import json from contextlib import nullcontext from pathlib import Path from typing import Callable @@ -1005,6 +1006,46 @@ def test_agent_card_endpoint_returns_configured_metadata( assert payload["url"] == "https://mada.example/a2a" assert payload["capabilities"]["streaming"] is True + def test_agent_card_endpoint_can_serve_card_file( + self, create_dummy_config: Callable, tmp_path: Path + ): + """ + Test that the standard agent card endpoint can load a standalone card. + """ + card_path = tmp_path / "agent-card.json" + card_path.write_text( + json.dumps( + { + "protocolVersion": "0.3.0", + "name": "FileBackedMADA", + "description": "Loaded from a card file", + "url": "http://placeholder", + "version": "1.0.0", + "skills": [ + { + "id": "file-backed", + "name": "File backed card", + "description": "Served from JSON", + "tags": ["a2a"], + } + ], + } + ), + encoding="utf-8", + ) + config = create_dummy_config() + config.a2a = A2AConfig(card_path=str(card_path)) + + service = MADAA2AService( + config=config, public_url="https://mada.example/a2a" + ) + payload = service.build_agent_card() + + assert payload["name"] == "FileBackedMADA" + assert payload["description"] == "Loaded from a card file" + assert payload["url"] == "https://mada.example/a2a" + assert payload["supportsAuthenticatedExtendedCard"] is False + def test_message_send_returns_a2a_task(self, create_dummy_config: Callable): """ Test that JSON-RPC `message/send` returns a completed A2A task. From ccbb72ed559779c7327111967cd7ccd6fbd69dfd Mon Sep 17 00:00:00 2001 From: Jorge Moreno Date: Thu, 30 Jul 2026 13:48:17 -0700 Subject: [PATCH 3/9] docs --- docs/user_guide/configuration.md | 88 ++++++++++++++++++++++++++++++++ tests/unit/test_entrypoints.py | 2 +- 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/docs/user_guide/configuration.md b/docs/user_guide/configuration.md index a0a7f02..9b2c53c 100644 --- a/docs/user_guide/configuration.md +++ b/docs/user_guide/configuration.md @@ -11,6 +11,7 @@ Additionally, there are optional configuration options: - [Database Configuration](#optional-database-configuration) - [Gradio Interface Configuration](#optional-gradio-interface-configuration) - [Orchestration Configuration](#optional-orchestration-configuration) +- [A2A Configuration](#optional-a2a-configuration) ## Agent Configuration @@ -222,6 +223,93 @@ it is the only supported mode. If `participants` is omitted, MADA includes every configured agent except `PlanningAgent`. +## (Optional) A2A Configuration + +MADA can participate in Agent-to-Agent (A2A) workflows in two directions: + +- `a2a_agents` lists remote A2A agents that MADA can call as tools. +- `a2a_self` describes MADA's own A2A identity when you run MADA with `mada-a2a`. + +These settings do not replace CLI or Gradio. CLI and Gradio are interactive +interfaces for users. A2A mode starts an HTTP service so other A2A agents can +discover MADA and delegate tasks to it. + +### Remote A2A Agents + +Use `a2a_agents` when the MADA orchestrator should delegate work to other A2A +agents. Each configured remote agent is exposed to the planning agent as a tool, +using the remote agent card for routing context when available. + +#### Fields + +| Field Name | Description | Required? | Default | +| ------------- | --------------------------------------------------------------------------- | --------- | ------- | +| `url` | JSON-RPC endpoint for the remote A2A agent. | Yes | N/A | +| `card_url` | Explicit URL for the remote agent card. If omitted, MADA tries standard A2A card paths derived from `url`. | No | None | +| `description` | Fallback description used only if the remote agent card cannot be fetched. | No | None | +| `timeout` | HTTP timeout in seconds for calls to the remote agent. | No | `180` | +| `api_key` | Optional API key sent as `x-api-key`. | No | None | +| `headers` | Additional HTTP headers to send to the remote agent. | No | `{}` | + +#### Example + +```json +"a2a_agents": { + "LangChainAgent": { + "url": "http://localhost:9111/", + "card_url": "http://localhost:9111/.well-known/agent-card.json" + }, + "GoogleADKAgent": { + "url": "http://localhost:9112/", + "card_url": "http://localhost:9112/.well-known/agent-card.json" + } +} +``` + +### MADA's A2A Agent Card + +Use `a2a_self` when you want MADA itself to be discoverable by other A2A agents. +This block is used by `mada-a2a` and `mada a2a`; it is not used by CLI or Gradio +mode. + +The `card_path` value points to a standalone A2A agent card JSON file. Relative +paths are resolved relative to the configuration file. When the card is served, +MADA overwrites the card's `url` field with the runtime public URL from +`a2a_self.url` or `--public-url`. + +#### Fields + +| Field Name | Description | Required? | Default | +| ----------- | --------------------------------------------------------------------------- | --------- | ------- | +| `card_path` | Path to MADA's standalone A2A agent card JSON file. | No | None | +| `url` | Public URL advertised in the served agent card. | No | Runtime host and port | +| `version` | Version used by the generated card fallback when no `card_path` is supplied. | No | `0.2.0` | +| `name` | Name used by the generated card fallback when no `card_path` is supplied. | No | `MADA` | +| `description` | Description used by the generated card fallback when no `card_path` is supplied. | No | `MADA multi-agent orchestration service` | +| `skills` | Skills used by the generated card fallback when no `card_path` is supplied. | No | Derived from configured agents | + +#### Example + +```json +"a2a_self": { + "card_path": "agent_cards/mada_orchestrator_card.json", + "url": "http://localhost:9120", + "version": "0.2.0" +} +``` + +Launch MADA as an A2A service with: + +```bash +mada-a2a --port 9120 configs/example_a2a_agents.json +``` + +Other A2A agents can then discover MADA at: + +```text +http://localhost:9120/.well-known/agent-card.json +``` + ## (Optional) Database Configuration If you want to customize your database settings, you can set this in the configuration file. There are two database options: diff --git a/tests/unit/test_entrypoints.py b/tests/unit/test_entrypoints.py index e15172b..0f29f26 100644 --- a/tests/unit/test_entrypoints.py +++ b/tests/unit/test_entrypoints.py @@ -1246,7 +1246,7 @@ async def test_cli_interface_run_quit_immediately( await cli.run() orchestrator_mock.initialize_orchestrator.assert_awaited_once_with( - config.agents, config.mcp_servers + config.agents, config.mcp_servers, config.a2a_agents ) orchestrator_mock.background_tasks.run_query.assert_not_called() From c2ac55d270dc24e3d1d11735f881242517dbd75e Mon Sep 17 00:00:00 2001 From: Jorge Moreno Date: Thu, 30 Jul 2026 14:48:13 -0700 Subject: [PATCH 4/9] changelog --- CHANGELOG.md | 1 + examples/a2a_average_mcp_server.py | 1 + examples/a2a_google_adk_agent.py | 4 +--- examples/a2a_langchain_agent.py | 4 +--- examples/a2a_table_mcp_server.py | 1 + src/mada/interfaces/gradio/interface.py | 5 ++++- .../interfaces/gradio/mcp_client_wrapper.py | 9 ++++++-- src/mada/interfaces/gradio/utils.py | 21 +++++++++++++++++-- 8 files changed, 35 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e900f0e..b128b5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ - workflows for publishing develop and stable versions of documentation - Orchestration configuration layer and behavior selection through a mode-specific strategy, preserves existing CLI, Gradio, and OpenAI API interfaces. - `orchestration.py` , `orchestrator.py` large updates to support the new pattern selection layer +- Adds A2A capabilities, enabling the agent to connect with other agents and be accessed by them through A2A. ### Changed - re-architected the test suite into unit/integration/e2e tests diff --git a/examples/a2a_average_mcp_server.py b/examples/a2a_average_mcp_server.py index bc8dfb5..b0c1d27 100644 --- a/examples/a2a_average_mcp_server.py +++ b/examples/a2a_average_mcp_server.py @@ -104,6 +104,7 @@ def main() -> None: host=args.host, port=args.port, stateless_http=True, + uvicorn_config={"access_log": False}, ) diff --git a/examples/a2a_google_adk_agent.py b/examples/a2a_google_adk_agent.py index 03254ff..67f36bd 100644 --- a/examples/a2a_google_adk_agent.py +++ b/examples/a2a_google_adk_agent.py @@ -112,10 +112,8 @@ async def calculate_column_averages( ) except Exception as exc: message = f"Average MCP tool call failed: {type(exc).__name__}: {exc}" - print(message, flush=True) return message text = stringify_mcp_result(result) - print(f"Average MCP tool result: {text}", flush=True) return text @@ -329,7 +327,7 @@ def main() -> None: public_url = args.public_url or f"http://localhost:{args.port}" app = create_app(GoogleADKA2AAgent(args.model, args.mcp_url), public_url) - uvicorn.run(app, host=args.host, port=args.port) + uvicorn.run(app, host=args.host, port=args.port, access_log=False) if __name__ == "__main__": diff --git a/examples/a2a_langchain_agent.py b/examples/a2a_langchain_agent.py index b890cb6..1e44a73 100644 --- a/examples/a2a_langchain_agent.py +++ b/examples/a2a_langchain_agent.py @@ -103,10 +103,8 @@ async def read_sample_table(self, row_limit: int = DEFAULT_ROW_LIMIT) -> str: ) except Exception as exc: message = f"Table MCP tool call failed: {type(exc).__name__}: {exc}" - print(message, flush=True) return message text = stringify_mcp_result(result) - print(f"Table MCP tool result: {text}", flush=True) return text @@ -310,7 +308,7 @@ def main() -> None: LangChainA2AAgent(args.model, args.api_key, args.base_url, args.mcp_url), public_url, ) - uvicorn.run(app, host=args.host, port=args.port) + uvicorn.run(app, host=args.host, port=args.port, access_log=False) if __name__ == "__main__": diff --git a/examples/a2a_table_mcp_server.py b/examples/a2a_table_mcp_server.py index b10a520..559552a 100644 --- a/examples/a2a_table_mcp_server.py +++ b/examples/a2a_table_mcp_server.py @@ -82,6 +82,7 @@ def main() -> None: host=args.host, port=args.port, stateless_http=True, + uvicorn_config={"access_log": False}, ) diff --git a/src/mada/interfaces/gradio/interface.py b/src/mada/interfaces/gradio/interface.py index bc394aa..6078853 100644 --- a/src/mada/interfaces/gradio/interface.py +++ b/src/mada/interfaces/gradio/interface.py @@ -213,7 +213,10 @@ def create_interface(self) -> gr.Blocks: with gr.Column(scale=4): # MCP Server connection section with self.create_accordion(): - agent_table = create_agent_table(self.agents) + agent_table = create_agent_table( + self.agents, + a2a_agents=getattr(self.client, "a2a_agents", {}), + ) connect_button = gr.Button( "Connect to MCP Servers", variant="primary" diff --git a/src/mada/interfaces/gradio/mcp_client_wrapper.py b/src/mada/interfaces/gradio/mcp_client_wrapper.py index ea4e009..dabd526 100644 --- a/src/mada/interfaces/gradio/mcp_client_wrapper.py +++ b/src/mada/interfaces/gradio/mcp_client_wrapper.py @@ -143,7 +143,9 @@ async def connect_servers( table_agent_dict = agent_dict self.initialized = True return gr.Button(status_msg, elem_id="green_btn"), create_agent_table( - table_agents, table_agent_dict + table_agents, + table_agent_dict, + self.a2a_agents, ) except BaseExceptionGroup as eg: @@ -154,7 +156,10 @@ async def connect_servers( error_msg = f"Failed to connect to MCP servers: {e}" LOG.error(error_msg) LOG.error("Full traceback:", exc_info=True) - return gr.Button(error_msg, variant="stop"), create_agent_table(self.agents) + return gr.Button(error_msg, variant="stop"), create_agent_table( + self.agents, + a2a_agents=self.a2a_agents, + ) def list_sessions(self) -> List[str]: """ diff --git a/src/mada/interfaces/gradio/utils.py b/src/mada/interfaces/gradio/utils.py index 94eba66..e0314b2 100644 --- a/src/mada/interfaces/gradio/utils.py +++ b/src/mada/interfaces/gradio/utils.py @@ -2,16 +2,17 @@ # SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception import math -from typing import Any, List +from typing import Any, Dict, List import gradio as gr -from mada.core.config import AgentConfig +from mada.core.config import AgentConfig, RemoteA2AAgentConfig def create_agent_table( agents: List[AgentConfig], agent_dict: dict | None = None, + a2a_agents: Dict[str, RemoteA2AAgentConfig] | None = None, ) -> gr.Dataframe: """ Create a Gradio Dataframe component for agent configuration. @@ -21,6 +22,8 @@ def create_agent_table( agent_dict: An optional mapping from agent name to MCP server tool data produced by `cycle_through_tools` and is used to populate the MCP Server Tools column. + a2a_agents: Optional mapping of remote A2A agent names to configuration + values. These agents are displayed as A2A rows in the same table. Returns: The configured Gradio Dataframe component representing agents @@ -29,6 +32,7 @@ def create_agent_table( # Headers for the agent table headers = [ "Agent Name", + "Agent Type", "Description", "Domain", "MCP Servers", @@ -41,6 +45,7 @@ def create_agent_table( for agent in agents: row = [ agent.agent_name, + "local", agent.description, getattr(agent, "domain", ""), ", ".join(agent.mcp_servers) if agent.mcp_servers else "", @@ -51,6 +56,18 @@ def create_agent_table( ] table_rows.append(row) + for agent_name, agent_config in (a2a_agents or {}).items(): + row = [ + agent_name, + "a2a", + agent_config.description or f"Remote A2A agent at {agent_config.url}", + "a2a", + "", + f"A2A endpoint: {agent_config.url}", + "", + ] + table_rows.append(row) + column_count = len(headers) return gr.Dataframe( From 95caca9b6782480ab3f0f10586c7f4baaea985ac Mon Sep 17 00:00:00 2001 From: Jorge Moreno Date: Thu, 30 Jul 2026 14:58:31 -0700 Subject: [PATCH 5/9] updating docstrings --- docs/user_guide/configuration.md | 11 +++++++++-- src/mada/core/a2a_client.py | 5 +++++ src/mada/core/config/a2a.py | 4 ++++ src/mada/interfaces/a2a/main.py | 5 +++++ 4 files changed, 23 insertions(+), 2 deletions(-) diff --git a/docs/user_guide/configuration.md b/docs/user_guide/configuration.md index 9b2c53c..cb255b3 100644 --- a/docs/user_guide/configuration.md +++ b/docs/user_guide/configuration.md @@ -227,13 +227,20 @@ If `participants` is omitted, MADA includes every configured agent except MADA can participate in Agent-to-Agent (A2A) workflows in two directions: -- `a2a_agents` lists remote A2A agents that MADA can call as tools. -- `a2a_self` describes MADA's own A2A identity when you run MADA with `mada-a2a`. +- `a2a_agents` is the client-side configuration. It lists remote A2A agents + that MADA can call as tools from the orchestrator. +- `a2a_self` is the server-side configuration. It describes MADA's own A2A + identity when you run MADA with `mada-a2a` so other agents can discover and + call MADA. These settings do not replace CLI or Gradio. CLI and Gradio are interactive interfaces for users. A2A mode starts an HTTP service so other A2A agents can discover MADA and delegate tasks to it. +In code, the same split is reflected by the modules: `mada.core.a2a_client` +handles outbound calls from MADA to remote A2A agents, while +`mada.interfaces.a2a.main` exposes MADA itself as an inbound A2A service. + ### Remote A2A Agents Use `a2a_agents` when the MADA orchestrator should delegate work to other A2A diff --git a/src/mada/core/a2a_client.py b/src/mada/core/a2a_client.py index 7afdf44..f300ab6 100644 --- a/src/mada/core/a2a_client.py +++ b/src/mada/core/a2a_client.py @@ -3,6 +3,11 @@ """ Small A2A JSON-RPC client used by the MADA orchestrator. + +This is the client-side A2A helper: the orchestrator uses it to call remote +A2A agents configured under `a2a_agents`. The server-side interface that +exposes MADA itself as an A2A agent lives in `mada.interfaces.a2a.main` and +uses the `a2a_self` configuration block. """ from __future__ import annotations diff --git a/src/mada/core/config/a2a.py b/src/mada/core/config/a2a.py index ec0e59c..3c5f293 100644 --- a/src/mada/core/config/a2a.py +++ b/src/mada/core/config/a2a.py @@ -3,6 +3,10 @@ """ A2A interface and remote agent configuration definitions. + +`A2AConfig` models MADA's own A2A identity for `a2a_self` when MADA is run as +an A2A server. `RemoteA2AAgentConfig` models remote agents under `a2a_agents` +that the orchestrator can call as tools. """ from dataclasses import dataclass, field diff --git a/src/mada/interfaces/a2a/main.py b/src/mada/interfaces/a2a/main.py index 049c76d..44e4478 100644 --- a/src/mada/interfaces/a2a/main.py +++ b/src/mada/interfaces/a2a/main.py @@ -7,6 +7,11 @@ This module exposes the configured MADA planning agent as an A2A-compatible JSON-RPC service. The MADA agent card is available under the standard `/.well-known/agent-card.json` path. + +This is the server-side A2A entry point: use it when another A2A client or +agent needs to discover MADA and send work to MADA. The client-side support +for MADA calling other A2A agents lives in `mada.core.a2a_client` and is wired +through the `a2a_agents` configuration block. """ from __future__ import annotations From 6679b7256ed6701b665b24912ed27d85b257da70 Mon Sep 17 00:00:00 2001 From: Jorge Moreno Date: Thu, 30 Jul 2026 15:06:12 -0700 Subject: [PATCH 6/9] adding docstrings --- src/mada/core/a2a_client.py | 15 +++++++ src/mada/core/config/a2a.py | 6 +++ src/mada/interfaces/a2a/main.py | 72 +++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+) diff --git a/src/mada/core/a2a_client.py b/src/mada/core/a2a_client.py index f300ab6..2bbf679 100644 --- a/src/mada/core/a2a_client.py +++ b/src/mada/core/a2a_client.py @@ -26,6 +26,9 @@ class RemoteA2AClient: """ def __init__(self, name: str, config: RemoteA2AAgentConfig) -> None: + """ + Initialize an HTTP client for a configured remote A2A agent. + """ self.name = name self.config = config headers = dict(config.headers) @@ -34,6 +37,9 @@ def __init__(self, name: str, config: RemoteA2AAgentConfig) -> None: self._client = httpx.AsyncClient(headers=headers, timeout=config.timeout) async def send_message(self, task: str) -> str: + """ + Send a text task to the remote A2A agent and return its text response. + """ request_id = f"mada-{uuid.uuid4().hex}" payload = { "jsonrpc": "2.0", @@ -94,9 +100,15 @@ async def get_agent_card(self) -> dict[str, Any]: return {} async def aclose(self) -> None: + """ + Close the underlying async HTTP client. + """ await self._client.aclose() def _extract_text(self, result: Any) -> str: + """ + Extract human-readable text from an A2A JSON-RPC result payload. + """ if result is None: return "" if isinstance(result, str): @@ -111,6 +123,9 @@ def _extract_text(self, result: Any) -> str: return str(result) def _collect_text_parts(self, value: Any, texts: list[str]) -> None: + """ + Recursively collect text parts from an A2A result structure. + """ if isinstance(value, dict): parts = value.get("parts") if isinstance(parts, list): diff --git a/src/mada/core/config/a2a.py b/src/mada/core/config/a2a.py index 3c5f293..8b754b9 100644 --- a/src/mada/core/config/a2a.py +++ b/src/mada/core/config/a2a.py @@ -41,6 +41,9 @@ class A2AConfig: skills: list[dict[str, Any]] = field(default_factory=list) def __post_init__(self) -> None: + """ + Normalize fields and validate generated-card skill entries. + """ self.name = expand_env_vars(self.name or "").strip() or "MADA" self.description = ( expand_env_vars(self.description or "").strip() @@ -103,6 +106,9 @@ class RemoteA2AAgentConfig: headers: dict[str, str] = field(default_factory=dict) def __post_init__(self) -> None: + """ + Normalize fields and validate required remote-agent settings. + """ self.url = expand_env_vars(self.url or "").strip() if not self.url: raise ValueError("'a2a_agents..url' must not be empty") diff --git a/src/mada/interfaces/a2a/main.py b/src/mada/interfaces/a2a/main.py index 44e4478..a40c91f 100644 --- a/src/mada/interfaces/a2a/main.py +++ b/src/mada/interfaces/a2a/main.py @@ -62,10 +62,16 @@ def _get_orchestration_config(config: AppConfig) -> OrchestrationConfig: + """ + Return the configured orchestration settings or the default configuration. + """ return getattr(config, "orchestration", None) or OrchestrationConfig() def _get_a2a_config(config: AppConfig) -> A2AConfig: + """ + Return the configured self A2A settings or the default configuration. + """ return getattr(config, "a2a", None) or A2AConfig() @@ -74,6 +80,9 @@ class A2AStartupError(RuntimeError): def _format_startup_error_message(exc: BaseException) -> str: + """ + Convert orchestrator startup failures into user-facing A2A error text. + """ details = str(exc).strip() or exc.__class__.__name__ lowered = details.lower() @@ -88,6 +97,9 @@ def _format_startup_error_message(exc: BaseException) -> str: def _require_fastapi() -> None: + """ + Raise a clear error when A2A server dependencies are unavailable. + """ if FASTAPI_IMPORT_ERROR is not None or uvicorn is None: missing = [] if FASTAPI_IMPORT_ERROR is not None: @@ -101,6 +113,9 @@ def _require_fastapi() -> None: def _slugify(value: str) -> str: + """ + Convert an agent name into a stable A2A skill identifier. + """ slug = re.sub(r"[^a-zA-Z0-9_-]+", "-", value.strip()).strip("-").lower() return slug or "mada-agent" @@ -117,6 +132,9 @@ def __init__( api_key: Optional[str] = None, bearer_token: Optional[str] = None, ) -> None: + """ + Initialize the service wrapper for one shared orchestrator instance. + """ self.config = config self.a2a_config = _get_a2a_config(config) self.public_url = self.a2a_config.url or public_url @@ -126,6 +144,9 @@ def __init__( self._startup_lock = asyncio.Lock() async def startup(self) -> None: + """ + Start and initialize the orchestrator used to serve A2A requests. + """ if self.orchestrator is not None: return @@ -154,6 +175,9 @@ async def startup(self) -> None: raise A2AStartupError(_format_startup_error_message(exc)) from exc async def ensure_started(self) -> None: + """ + Lazily start the orchestrator once across concurrent requests. + """ if self.orchestrator is not None: return @@ -162,6 +186,9 @@ async def ensure_started(self) -> None: await self.startup() async def shutdown(self) -> None: + """ + Shut down the shared orchestrator if it has been initialized. + """ if self.orchestrator is None: return await self.orchestrator.__aexit__(None, None, None) @@ -170,6 +197,9 @@ async def shutdown(self) -> None: def validate_api_key( self, authorization: Optional[str], x_api_key: Optional[str] ) -> None: + """ + Validate the configured API key against request headers. + """ if not self.api_key: return @@ -181,6 +211,9 @@ def validate_api_key( raise HTTPException(status_code=401, detail="Invalid API key") def build_agent_card(self) -> Dict[str, Any]: + """ + Build the public A2A agent card for this MADA service. + """ if self.a2a_config.card_path: card = self._load_agent_card_file() card["url"] = self.public_url @@ -205,6 +238,9 @@ def build_agent_card(self) -> Dict[str, Any]: } def _load_agent_card_file(self) -> Dict[str, Any]: + """ + Load and validate a standalone A2A agent card JSON file. + """ card_path = Path(self.a2a_config.card_path) try: with card_path.open("r", encoding="utf-8") as card_file: @@ -221,6 +257,9 @@ def _load_agent_card_file(self) -> Dict[str, Any]: return card def _build_skills(self) -> list[dict[str, Any]]: + """ + Build A2A skill entries from configuration or configured MADA agents. + """ if self.a2a_config.skills: return self.a2a_config.skills @@ -252,6 +291,9 @@ def _build_skills(self) -> list[dict[str, Any]]: ] async def collect_response(self, message: str) -> str: + """ + Collect a complete orchestrator response for a single A2A message. + """ if self.orchestrator is None: raise RuntimeError("Orchestrator not initialized") return await self.orchestrator.collect_message_response( @@ -260,6 +302,9 @@ async def collect_response(self, message: str) -> str: ) async def stream_response(self, message: str) -> AsyncGenerator[str, None]: + """ + Stream orchestrator response chunks for a single A2A message. + """ if self.orchestrator is None: raise RuntimeError("Orchestrator not initialized") @@ -276,6 +321,9 @@ def _json_rpc_error( message: str, status_code: int = 200, ) -> JSONResponse: + """ + Build a JSON-RPC error response with the requested HTTP status. + """ return JSONResponse( { "jsonrpc": "2.0", @@ -287,6 +335,9 @@ def _json_rpc_error( def _extract_message_text(params: Dict[str, Any]) -> str: + """ + Extract text content from supported A2A message parameter shapes. + """ message = params.get("message", params) if isinstance(message, str): return message @@ -316,6 +367,9 @@ def _build_task( text: str, state: str = "completed", ) -> Dict[str, Any]: + """ + Build an A2A task object containing a text response artifact. + """ message_id = f"msg-{uuid.uuid4().hex}" now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) response_message = { @@ -346,6 +400,9 @@ def _build_task( def _ids_from_params(params: Dict[str, Any]) -> tuple[str, str]: + """ + Resolve task and context IDs from request params or create new IDs. + """ message = params.get("message") task_id = params.get("id") or params.get("taskId") context_id = params.get("contextId") @@ -378,6 +435,9 @@ def create_a2a_app( @asynccontextmanager async def lifespan(app: FastAPI): + """ + Attach the A2A service to app state and clean it up on shutdown. + """ app.state.mada_a2a_service = service try: yield @@ -388,6 +448,9 @@ async def lifespan(app: FastAPI): @app.get("/health") async def health() -> Dict[str, str]: + """ + Report whether the A2A process is running and initialized. + """ return { "status": "ok", "orchestrator_initialized": "true" @@ -396,6 +459,9 @@ async def health() -> Dict[str, str]: } async def get_agent_card() -> Dict[str, Any]: + """ + Return the MADA A2A agent card for discovery endpoints. + """ return service.build_agent_card() app.get("/.well-known/agent-card.json")(get_agent_card) @@ -407,6 +473,9 @@ async def handle_rpc( authorization: Optional[str] = Header(default=None), x_api_key: Optional[str] = Header(default=None), ): + """ + Handle A2A JSON-RPC message requests. + """ service.validate_api_key(authorization, x_api_key) request_id = body.get("id") @@ -451,6 +520,9 @@ async def handle_rpc( } async def event_stream() -> AsyncGenerator[str, None]: + """ + Yield server-sent events for streaming A2A responses. + """ collected = [] async for chunk in service.stream_response(message_text): collected.append(chunk) From 17bc549682e05fc1916507c13badc4041bd15a08 Mon Sep 17 00:00:00 2001 From: Jorge Moreno Date: Thu, 30 Jul 2026 16:22:49 -0700 Subject: [PATCH 7/9] updating sample agents --- docs/user_guide/configuration.md | 16 +++ examples/a2a_average_mcp_server.py | 35 ++--- examples/a2a_example_config.py | 64 +++++++++ examples/a2a_google_adk_agent.py | 203 +++++++++++++---------------- examples/a2a_langchain_agent.py | 193 +++++++++++---------------- examples/a2a_table_mcp_server.py | 12 +- pyproject.toml | 12 +- 7 files changed, 264 insertions(+), 271 deletions(-) create mode 100644 examples/a2a_example_config.py diff --git a/docs/user_guide/configuration.md b/docs/user_guide/configuration.md index cb255b3..85dfa8d 100644 --- a/docs/user_guide/configuration.md +++ b/docs/user_guide/configuration.md @@ -273,6 +273,22 @@ using the remote agent card for routing context when available. } ``` +The example A2A agents read the same MADA config by default so they use the +same `model` block as the orchestrator. Install their optional dependencies and +launch them with the config path: + +```bash +pip install -e ".[a2a-examples]" +python examples/a2a_table_mcp_server.py --port 9101 +python examples/a2a_average_mcp_server.py --port 9102 +python examples/a2a_langchain_agent.py --port 9111 --config configs/example_a2a_agents.json --mcp-url http://localhost:9101/mcp +python examples/a2a_google_adk_agent.py --port 9112 --config configs/example_a2a_agents.json --mcp-url http://localhost:9102/mcp +``` + +Use each example agent's `--model`, `--api-key`, and `--base-url` flags when +you want that remote agent to use a different model endpoint from MADA. The +Google ADK example also accepts `--provider`. + ### MADA's A2A Agent Card Use `a2a_self` when you want MADA itself to be discoverable by other A2A agents. diff --git a/examples/a2a_average_mcp_server.py b/examples/a2a_average_mcp_server.py index b0c1d27..4cdc3b9 100644 --- a/examples/a2a_average_mcp_server.py +++ b/examples/a2a_average_mcp_server.py @@ -3,12 +3,6 @@ """ CSV column-average MCP server used by the Google ADK A2A example agent. - -Run: - python examples/a2a_average_mcp_server.py --port 9102 - -Install optional dependencies first: - pip install fastmcp """ from __future__ import annotations @@ -36,8 +30,15 @@ def calculate_column_averages(columns: str = "all") -> str: """ Calculate averages for numeric columns in a built-in CSV table. """ - rows = _read_sample_rows() - numeric_columns = _numeric_columns(rows) + rows = list(csv.DictReader(StringIO(SAMPLE_CSV))) + numeric_columns = [] + for column in rows[0]: + try: + for row in rows: + float(row[column]) + except ValueError: + continue + numeric_columns.append(column) if columns.strip().lower() != "all": requested = [ @@ -62,24 +63,6 @@ def calculate_column_averages(columns: str = "all") -> str: return mcp -def _read_sample_rows() -> list[dict[str, str]]: - return list(csv.DictReader(StringIO(SAMPLE_CSV))) - - -def _numeric_columns(rows: list[dict[str, str]]) -> list[str]: - if not rows: - return [] - columns = [] - for column in rows[0]: - try: - for row in rows: - float(row[column]) - except ValueError: - continue - columns.append(column) - return columns - - def main() -> None: parser = argparse.ArgumentParser( description="Run the A2A column-average MCP server" diff --git a/examples/a2a_example_config.py b/examples/a2a_example_config.py new file mode 100644 index 0000000..12b6a29 --- /dev/null +++ b/examples/a2a_example_config.py @@ -0,0 +1,64 @@ +# Copyright 2026, Lawrence Livermore National Security, LLC and MADA contributors +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +"""Shared configuration helpers for the example A2A agents.""" + +from __future__ import annotations + +import json +import os +import re +from pathlib import Path + + +DEFAULT_CONFIG_PATH = ( + Path(__file__).parent.parent / "configs" / "example_a2a_agents.json" +) + + +def expand_env_vars(value: str | None) -> str | None: + """ + Expand `${VAR}` and `${VAR:-default}` placeholders in config values. + """ + if value is None: + return value + + def replace_env_var(match): + var_expr = match.group(1) + if ":-" in var_expr: + var_name, default_value = var_expr.split(":-", 1) + return os.getenv(var_name, default_value) + return os.getenv(var_expr, match.group(0)) + + return re.sub(r"\$\{([^}]+)\}", replace_env_var, value) + + +def load_model_settings(config_path: str | None) -> dict[str, str]: + """ + Load model settings from a MADA config JSON file. + """ + if not config_path: + return {} + + path = Path(config_path).expanduser() + if not path.exists(): + return {} + + with path.open("r", encoding="utf-8") as config_file: + config = json.load(config_file) + + model_config = config.get("model", {}) + if not isinstance(model_config, dict): + return {} + + settings = {} + for key in ("provider", "model", "api_key", "base_url"): + value = model_config.get(key) + if isinstance(value, str): + settings[key] = expand_env_vars(value) or "" + + api_key = settings.get("api_key") + if api_key and Path(api_key).expanduser().exists(): + settings["api_key"] = Path(api_key).expanduser().read_text().strip() + + return settings diff --git a/examples/a2a_google_adk_agent.py b/examples/a2a_google_adk_agent.py index 67f36bd..9a185d7 100644 --- a/examples/a2a_google_adk_agent.py +++ b/examples/a2a_google_adk_agent.py @@ -1,33 +1,7 @@ # Copyright 2026, Lawrence Livermore National Security, LLC and MADA contributors # SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception -""" -Simple Google ADK-backed A2A agent for MADA. - -Run: - python examples/a2a_average_mcp_server.py --port 9102 - python examples/a2a_google_adk_agent.py --port 9002 - python examples/a2a_google_adk_agent.py --port 9002 --model gemini-2.5-pro - -Install optional dependencies first: - pip install google-adk fastapi uvicorn fastmcp - -By default this example reads: - MADA_MODEL or GOOGLE_MODEL - -MADA config: - { - "a2a_agents": { - "GoogleADKAgent": { - "url": "http://localhost:9002/", - "description": "Simple Google ADK remote agent" - } - } - } - -Smoke test prompt from MADA: - What are the average values for the sample table columns? -""" +"""Simple Google ADK-backed A2A agent for MADA.""" from __future__ import annotations @@ -39,84 +13,23 @@ from pathlib import Path from typing import Any +from a2a_example_config import DEFAULT_CONFIG_PATH, load_model_settings from fastapi import FastAPI, HTTPException - -try: - from google.adk.agents import Agent - from google.adk.runners import Runner - from google.adk.sessions import InMemorySessionService - from google.genai import types -except ImportError: # pragma: no cover - example dependency guard - Agent = None - Runner = None - InMemorySessionService = None - types = None +from google.adk.agents import Agent +from google.adk.models.lite_llm import LiteLlm +from google.adk.runners import Runner +from google.adk.sessions import InMemorySessionService +from google.adk.tools.mcp_tool import McpToolset, StreamableHTTPConnectionParams +from google.genai import types -DEFAULT_MODEL = "gemini-2.5-flash" DEFAULT_MCP_URL = "http://localhost:9102/mcp" -DEFAULT_AVERAGE_COLUMNS = "all" DEFAULT_AGENT_CARD_PATH = ( Path(__file__).parent / "agent_cards" / "google_adk_agent_card.json" ) APP_NAME = "mada_google_adk_a2a_agent" -def should_run_average_tool(task: str) -> bool: - lowered = task.lower() - return ( - "average" in lowered - or "mean" in lowered - or "columns" in lowered - or "column" in lowered - or "numeric" in lowered - ) - - -def stringify_mcp_result(result: Any) -> str: - if result is None: - return "" - content = getattr(result, "content", None) - if content is not None: - return stringify_mcp_result(content) - if isinstance(result, list): - parts = [] - for item in result: - text = getattr(item, "text", None) - parts.append(str(text if text is not None else item)) - return "\n".join(parts) - return str(result) - - -class MCPExampleToolClient: - def __init__(self, url: str) -> None: - self.url = url - - async def calculate_column_averages( - self, columns: str = DEFAULT_AVERAGE_COLUMNS - ) -> str: - try: - from fastmcp import Client - except ImportError as exc: # pragma: no cover - example dependency guard - raise RuntimeError( - "This example requires fastmcp for MCP tool calls. Install it with " - "`pip install fastmcp`." - ) from exc - - async with Client(self.url) as client: - try: - result = await client.call_tool( - "calculate_column_averages", - arguments={"columns": columns}, - timeout=30, - ) - except Exception as exc: - message = f"Average MCP tool call failed: {type(exc).__name__}: {exc}" - return message - text = stringify_mcp_result(result) - return text - - def extract_message_text(params: dict[str, Any]) -> str: message = params.get("message", params) if isinstance(message, str): @@ -178,31 +91,41 @@ def ids_from_params(params: dict[str, Any]) -> tuple[str, str]: class GoogleADKA2AAgent: - def __init__(self, model: str, mcp_url: str = DEFAULT_MCP_URL) -> None: + def __init__( + self, + provider: str, + model: str, + api_key: str | None = None, + base_url: str | None = None, + mcp_url: str = DEFAULT_MCP_URL, + ) -> None: + self.provider = provider self.model = model - self.mcp_tools = MCPExampleToolClient(mcp_url) + self.api_key = api_key + self.base_url = base_url + self.mcp_url = mcp_url self._session_service = None self._runner = None - def _require_adk(self) -> None: - if Agent is None or Runner is None or InMemorySessionService is None: - raise RuntimeError( - "This example requires Google ADK. Install it with " - "`pip install google-adk`." - ) - @property def runner(self): - self._require_adk() if self._runner is None: agent = Agent( name="GoogleADKAgent", - model=self.model, + model=self._build_adk_model(), description="Simple Google ADK remote agent callable from MADA.", instruction=( "You are a concise remote specialist called by MADA. " - "Complete the delegated task and return only the useful result." + "Complete the delegated task and return only the useful result. " + "Use your available MCP tools when they are relevant." ), + tools=[ + McpToolset( + connection_params=StreamableHTTPConnectionParams( + url=self.mcp_url, + ) + ) + ], ) self._session_service = InMemorySessionService() self._runner = Runner( @@ -212,11 +135,22 @@ def runner(self): ) return self._runner + def _build_adk_model(self): + provider = self.provider.lower() + if provider in {"openai", "livai"}: + if self.api_key: + os.environ["OPENAI_API_KEY"] = self.api_key + if self.base_url: + os.environ["OPENAI_API_BASE"] = self.base_url + os.environ["OPENAI_BASE_URL"] = self.base_url + return LiteLlm(model=f"openai/{self.model}") + + return self.model + async def run(self, task: str) -> str: - if should_run_average_tool(task): - return await self.mcp_tools.calculate_column_averages() + return await self._run_adk_agent(task) - self._require_adk() + async def _run_adk_agent(self, prompt: str) -> str: runner = self.runner user_id = f"mada-user-{uuid.uuid4().hex}" session_id = f"mada-session-{uuid.uuid4().hex}" @@ -228,7 +162,7 @@ async def run(self, task: str) -> str: message = types.Content( role="user", - parts=[types.Part(text=task)], + parts=[types.Part(text=prompt)], ) final_text = "" @@ -308,12 +242,30 @@ def main() -> None: parser = argparse.ArgumentParser(description="Run a simple Google ADK A2A agent") parser.add_argument("--host", default="0.0.0.0", help="Host interface to bind") parser.add_argument("--port", type=int, default=9002, help="Port to bind") + parser.add_argument( + "--config", + default=os.getenv("MADA_CONFIG") or str(DEFAULT_CONFIG_PATH), + help="MADA config JSON to read default model settings from.", + ) + parser.add_argument( + "--provider", + default=None, + help="Provider override. Defaults to the MADA config provider.", + ) parser.add_argument( "--model", - default=os.getenv("MADA_MODEL") or os.getenv("GOOGLE_MODEL") or DEFAULT_MODEL, - help=( - "Model to use. Defaults to MADA_MODEL, GOOGLE_MODEL, then gemini-2.5-flash." - ), + default=None, + help="Model override. Defaults to the MADA config model.", + ) + parser.add_argument( + "--api-key", + default=None, + help="API key override. Defaults to the MADA config api_key.", + ) + parser.add_argument( + "--base-url", + default=None, + help="Base URL override for OpenAI-compatible ADK models.", ) parser.add_argument( "--mcp-url", @@ -325,8 +277,27 @@ def main() -> None: import uvicorn + model_settings = load_model_settings(args.config) + provider = args.provider or model_settings.get("provider") + model = args.model or model_settings.get("model") + api_key = args.api_key or model_settings.get("api_key") + base_url = args.base_url or model_settings.get("base_url") + if not provider or not model: + raise RuntimeError( + "Google ADK A2A example requires provider and model from the MADA " + "config or explicit --provider/--model overrides." + ) + if provider.lower() in {"openai", "livai"} and (not api_key or not base_url): + raise RuntimeError( + "OpenAI-compatible ADK model providers require api_key and base_url " + "from the MADA config or explicit --api-key/--base-url overrides." + ) + public_url = args.public_url or f"http://localhost:{args.port}" - app = create_app(GoogleADKA2AAgent(args.model, args.mcp_url), public_url) + app = create_app( + GoogleADKA2AAgent(provider, model, api_key, base_url, args.mcp_url), + public_url, + ) uvicorn.run(app, host=args.host, port=args.port, access_log=False) diff --git a/examples/a2a_langchain_agent.py b/examples/a2a_langchain_agent.py index 1e44a73..288f2c8 100644 --- a/examples/a2a_langchain_agent.py +++ b/examples/a2a_langchain_agent.py @@ -1,36 +1,7 @@ # Copyright 2026, Lawrence Livermore National Security, LLC and MADA contributors # SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception -""" -Simple LangChain-backed A2A agent for MADA. - -Run: - python examples/a2a_table_mcp_server.py --port 9101 - python examples/a2a_langchain_agent.py --port 9001 - python examples/a2a_langchain_agent.py --port 9001 --model gpt-5 - -Install optional dependencies first: - pip install langchain-openai fastapi uvicorn fastmcp - -By default this example reads the same common environment variables used by -MADA example configs: - MADA_MODEL or OPENAI_MODEL - API_KEY or OPENAI_API_KEY - API_BASE_URL or OPENAI_BASE_URL - -MADA config: - { - "a2a_agents": { - "LangChainAgent": { - "url": "http://localhost:9001/", - "description": "Simple LangChain model-backed remote agent" - } - } - } - -Smoke test prompt from MADA: - Read the sample CSV table. -""" +"""Simple LangChain-backed A2A agent for MADA.""" from __future__ import annotations @@ -42,72 +13,19 @@ from pathlib import Path from typing import Any +from a2a_example_config import DEFAULT_CONFIG_PATH, load_model_settings from fastapi import FastAPI, HTTPException +from langchain_mcp_adapters.client import MultiServerMCPClient +from langchain_core.messages import ToolMessage +from langchain_openai import ChatOpenAI -try: - from langchain_openai import ChatOpenAI -except ImportError: # pragma: no cover - example dependency guard - ChatOpenAI = None - -DEFAULT_MODEL = "gpt-5" -DEFAULT_BASE_URL = "https://api.openai.com/v1" DEFAULT_MCP_URL = "http://localhost:9101/mcp" -DEFAULT_ROW_LIMIT = 4 DEFAULT_AGENT_CARD_PATH = ( Path(__file__).parent / "agent_cards" / "langchain_agent_card.json" ) -def should_run_table_tool(task: str) -> bool: - lowered = task.lower() - return ( - "table" in lowered or "csv" in lowered or "read" in lowered or "rows" in lowered - ) - - -def stringify_mcp_result(result: Any) -> str: - if result is None: - return "" - content = getattr(result, "content", None) - if content is not None: - return stringify_mcp_result(content) - if isinstance(result, list): - parts = [] - for item in result: - text = getattr(item, "text", None) - parts.append(str(text if text is not None else item)) - return "\n".join(parts) - return str(result) - - -class MCPExampleToolClient: - def __init__(self, url: str) -> None: - self.url = url - - async def read_sample_table(self, row_limit: int = DEFAULT_ROW_LIMIT) -> str: - try: - from fastmcp import Client - except ImportError as exc: # pragma: no cover - example dependency guard - raise RuntimeError( - "This example requires fastmcp for MCP tool calls. Install it with " - "`pip install fastmcp`." - ) from exc - - async with Client(self.url) as client: - try: - result = await client.call_tool( - "read_sample_table", - arguments={"row_limit": row_limit}, - timeout=30, - ) - except Exception as exc: - message = f"Table MCP tool call failed: {type(exc).__name__}: {exc}" - return message - text = stringify_mcp_result(result) - return text - - def extract_message_text(params: dict[str, Any]) -> str: message = params.get("message", params) if isinstance(message, str): @@ -179,16 +97,12 @@ def __init__( self.model = model self.api_key = api_key self.base_url = base_url - self.mcp_tools = MCPExampleToolClient(mcp_url) + self.mcp_url = mcp_url self._llm = None + self._tools = None @property def llm(self): - if ChatOpenAI is None: - raise RuntimeError( - "This example requires langchain-openai. Install it with " - "`pip install langchain-openai`." - ) if self._llm is None: kwargs = {"model": self.model} if self.api_key: @@ -199,21 +113,55 @@ def llm(self): return self._llm async def run(self, task: str) -> str: - if should_run_table_tool(task): - return await self.mcp_tools.read_sample_table() - - response = await self.llm.ainvoke( - [ - ( - "system", - "You are a concise remote specialist called by MADA. " - "Complete the delegated task and return only the useful result.", - ), - ("human", task), - ] - ) + return await self._run_langchain_agent(task) + + async def _run_langchain_agent(self, prompt: str) -> str: + tools = await self._get_tools() + tools_by_name = {tool.name: tool for tool in tools} + messages = [ + ( + "system", + "You are a concise remote specialist called by MADA. " + "Complete the delegated task and return only the useful result. " + "Use your available MCP tools when they are relevant.", + ), + ("human", prompt), + ] + + response = await self.llm.bind_tools(tools).ainvoke(messages) + tool_calls = getattr(response, "tool_calls", []) or [] + if not tool_calls: + return str(getattr(response, "content", response)) + + messages.append(response) + for tool_call in tool_calls: + tool = tools_by_name.get(tool_call.get("name")) + if tool is None: + continue + result = await tool.ainvoke(tool_call.get("args") or {}) + messages.append( + ToolMessage( + content=str(result), + tool_call_id=tool_call.get("id") or f"tool-{uuid.uuid4().hex}", + ) + ) + + response = await self.llm.ainvoke(messages) return str(getattr(response, "content", response)) + async def _get_tools(self) -> list[Any]: + if self._tools is None: + client = MultiServerMCPClient( + { + "example": { + "transport": "streamable_http", + "url": self.mcp_url, + } + } + ) + self._tools = await client.get_tools() + return self._tools + def create_app(agent: LangChainA2AAgent, public_url: str) -> FastAPI: app = FastAPI(title="Example LangChain A2A Agent") @@ -274,24 +222,25 @@ def main() -> None: parser = argparse.ArgumentParser(description="Run a simple LangChain A2A agent") parser.add_argument("--host", default="0.0.0.0", help="Host interface to bind") parser.add_argument("--port", type=int, default=9001, help="Port to bind") + parser.add_argument( + "--config", + default=os.getenv("MADA_CONFIG") or str(DEFAULT_CONFIG_PATH), + help="MADA config JSON to read default model settings from.", + ) parser.add_argument( "--model", - default=os.getenv("MADA_MODEL") or os.getenv("OPENAI_MODEL") or DEFAULT_MODEL, - help="Model to use. Defaults to MADA_MODEL, OPENAI_MODEL, then gpt-5.", + default=None, + help="Model override. Defaults to the MADA config model.", ) parser.add_argument( "--api-key", - default=os.getenv("API_KEY") or os.getenv("OPENAI_API_KEY"), - help="API key. Defaults to API_KEY or OPENAI_API_KEY.", + default=None, + help="API key override. Defaults to the MADA config api_key.", ) parser.add_argument( "--base-url", - default=( - os.getenv("API_BASE_URL") - or os.getenv("OPENAI_BASE_URL") - or DEFAULT_BASE_URL - ), - help="OpenAI-compatible base URL.", + default=None, + help="OpenAI-compatible base URL override. Defaults to the MADA config base_url.", ) parser.add_argument( "--mcp-url", @@ -303,9 +252,19 @@ def main() -> None: import uvicorn + model_settings = load_model_settings(args.config) + model = args.model or model_settings.get("model") + api_key = args.api_key or model_settings.get("api_key") + base_url = args.base_url or model_settings.get("base_url") + if not model or not api_key or not base_url: + raise RuntimeError( + "LangChain A2A example requires model, api_key, and base_url from " + "the MADA config or explicit --model/--api-key/--base-url overrides." + ) + public_url = args.public_url or f"http://localhost:{args.port}" app = create_app( - LangChainA2AAgent(args.model, args.api_key, args.base_url, args.mcp_url), + LangChainA2AAgent(model, api_key, base_url, args.mcp_url), public_url, ) uvicorn.run(app, host=args.host, port=args.port, access_log=False) diff --git a/examples/a2a_table_mcp_server.py b/examples/a2a_table_mcp_server.py index 559552a..88a2479 100644 --- a/examples/a2a_table_mcp_server.py +++ b/examples/a2a_table_mcp_server.py @@ -3,12 +3,6 @@ """ CSV table-reader MCP server used by the LangChain A2A example agent. - -Run: - python examples/a2a_table_mcp_server.py --port 9101 - -Install optional dependencies first: - pip install fastmcp """ from __future__ import annotations @@ -36,7 +30,7 @@ def read_sample_table(row_limit: int = 4) -> str: """ Read a small built-in CSV table and return it as text. """ - rows = _read_sample_rows() + rows = list(csv.DictReader(StringIO(SAMPLE_CSV))) limit = max(1, min(row_limit, len(rows))) selected_rows = rows[:limit] headers = list(rows[0].keys()) @@ -56,10 +50,6 @@ def read_sample_table(row_limit: int = 4) -> str: return mcp -def _read_sample_rows() -> list[dict[str, str]]: - return list(csv.DictReader(StringIO(SAMPLE_CSV))) - - def main() -> None: parser = argparse.ArgumentParser(description="Run the A2A table-reader MCP server") parser.add_argument("--host", default="0.0.0.0", help="Host interface to bind") diff --git a/pyproject.toml b/pyproject.toml index 1158800..09e8ee0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,8 +71,18 @@ docs = [ "mike", ] +# Example A2A agent requirements +a2a-examples = [ + "fastmcp", + "google-adk", + "langchain-core", + "langchain-mcp-adapters", + "langchain-openai", + "litellm", +] + # All requirements -all = ["mada[tests, docs]"] +all = ["mada[tests, docs, a2a-examples]"] [project.scripts] mada = "mada.main:main" From 1878a300510cdb571a62debf2651d25390593e0d Mon Sep 17 00:00:00 2001 From: Jorge Moreno Date: Mon, 3 Aug 2026 15:25:19 -0700 Subject: [PATCH 8/9] github comments --- .../agent_cards/mada_orchestrator_card.json | 2 +- configs/example_a2a_agents.json | 25 ++-- docs/user_guide/configuration.md | 38 +++--- examples/{ => a2a}/a2a_average_mcp_server.py | 1 + .../a2a_example_utils.py} | 2 +- examples/{ => a2a}/a2a_google_adk_agent.py | 74 ++---------- examples/{ => a2a}/a2a_langchain_agent.py | 74 ++---------- examples/{ => a2a}/a2a_table_mcp_server.py | 1 + .../agent_cards/google_adk_agent_card.json | 2 +- .../agent_cards/langchain_agent_card.json | 2 +- pyproject.toml | 1 + pytest.ini | 1 + src/mada/core/a2a_client.py | 4 +- src/mada/core/config/a2a.py | 41 ++++--- src/mada/core/config/app.py | 45 ++++--- src/mada/interfaces/a2a/main.py | 24 ++-- tests/unit/core/test_config.py | 110 +++++++++++++++++- tests/unit/test_entrypoints.py | 4 +- 18 files changed, 236 insertions(+), 215 deletions(-) rename examples/{ => a2a}/a2a_average_mcp_server.py (97%) rename examples/{a2a_example_config.py => a2a/a2a_example_utils.py} (95%) rename examples/{ => a2a}/a2a_google_adk_agent.py (78%) rename examples/{ => a2a}/a2a_langchain_agent.py (75%) rename examples/{ => a2a}/a2a_table_mcp_server.py (97%) rename examples/{ => a2a}/agent_cards/google_adk_agent_card.json (96%) rename examples/{ => a2a}/agent_cards/langchain_agent_card.json (96%) diff --git a/configs/agent_cards/mada_orchestrator_card.json b/configs/agent_cards/mada_orchestrator_card.json index 50b59ec..683c81b 100644 --- a/configs/agent_cards/mada_orchestrator_card.json +++ b/configs/agent_cards/mada_orchestrator_card.json @@ -1,5 +1,5 @@ { - "protocolVersion": "0.3.0", + "protocolVersion": "1.0.0", "name": "MADAOrchestrator", "description": "MADA multi-agent orchestrator that coordinates local reasoning agents and delegates to remote A2A agents when their capabilities match the task.", "url": "http://localhost:9120", diff --git a/configs/example_a2a_agents.json b/configs/example_a2a_agents.json index 324b781..942fb2a 100644 --- a/configs/example_a2a_agents.json +++ b/configs/example_a2a_agents.json @@ -21,21 +21,22 @@ "instructions": "You are LocalCritiqueAgent, a rigorous reviewer. Identify flaws, risky assumptions, missing context, and concrete improvements." } ], - "a2a_agents": { - "LangChainAgent": { - "url": "http://localhost:9111/", - "card_url": "http://localhost:9111/.well-known/agent-card.json" + "a2a": { + "agents": { + "LangChainAgent": { + "url": "http://localhost:9111/", + "card_url": "http://localhost:9111/.well-known/agent-card.json" + }, + "GoogleADKAgent": { + "url": "http://localhost:9112/", + "card_url": "http://localhost:9112/.well-known/agent-card.json" + } }, - "GoogleADKAgent": { - "url": "http://localhost:9112/", - "card_url": "http://localhost:9112/.well-known/agent-card.json" + "self": { + "card_path": "agent_cards/mada_orchestrator_card.json", + "url": "http://localhost:9120" } }, - "a2a_self": { - "card_path": "agent_cards/mada_orchestrator_card.json", - "url": "http://localhost:9120", - "version": "0.2.0" - }, "orchestration": { "mode": "agent-as-tool", "participants": ["LocalCoordinatorAgent", "LocalCritiqueAgent"] diff --git a/docs/user_guide/configuration.md b/docs/user_guide/configuration.md index 85dfa8d..4917026 100644 --- a/docs/user_guide/configuration.md +++ b/docs/user_guide/configuration.md @@ -227,9 +227,9 @@ If `participants` is omitted, MADA includes every configured agent except MADA can participate in Agent-to-Agent (A2A) workflows in two directions: -- `a2a_agents` is the client-side configuration. It lists remote A2A agents +- `a2a.agents` is the client-side configuration. It lists remote A2A agents that MADA can call as tools from the orchestrator. -- `a2a_self` is the server-side configuration. It describes MADA's own A2A +- `a2a.self` is the server-side configuration. It describes MADA's own A2A identity when you run MADA with `mada-a2a` so other agents can discover and call MADA. @@ -243,7 +243,7 @@ handles outbound calls from MADA to remote A2A agents, while ### Remote A2A Agents -Use `a2a_agents` when the MADA orchestrator should delegate work to other A2A +Use `a2a.agents` when the MADA orchestrator should delegate work to other A2A agents. Each configured remote agent is exposed to the planning agent as a tool, using the remote agent card for routing context when available. @@ -261,15 +261,17 @@ using the remote agent card for routing context when available. #### Example ```json -"a2a_agents": { +"a2a": { + "agents": { "LangChainAgent": { - "url": "http://localhost:9111/", - "card_url": "http://localhost:9111/.well-known/agent-card.json" + "url": "http://localhost:9111/", + "card_url": "http://localhost:9111/.well-known/agent-card.json" }, "GoogleADKAgent": { - "url": "http://localhost:9112/", - "card_url": "http://localhost:9112/.well-known/agent-card.json" + "url": "http://localhost:9112/", + "card_url": "http://localhost:9112/.well-known/agent-card.json" } + } } ``` @@ -279,10 +281,10 @@ launch them with the config path: ```bash pip install -e ".[a2a-examples]" -python examples/a2a_table_mcp_server.py --port 9101 -python examples/a2a_average_mcp_server.py --port 9102 -python examples/a2a_langchain_agent.py --port 9111 --config configs/example_a2a_agents.json --mcp-url http://localhost:9101/mcp -python examples/a2a_google_adk_agent.py --port 9112 --config configs/example_a2a_agents.json --mcp-url http://localhost:9102/mcp +python examples/a2a/a2a_table_mcp_server.py --port 9101 +python examples/a2a/a2a_average_mcp_server.py --port 9102 +python examples/a2a/a2a_langchain_agent.py --port 9111 --config configs/example_a2a_agents.json --mcp-url http://localhost:9101/mcp +python examples/a2a/a2a_google_adk_agent.py --port 9112 --config configs/example_a2a_agents.json --mcp-url http://localhost:9102/mcp ``` Use each example agent's `--model`, `--api-key`, and `--base-url` flags when @@ -291,14 +293,14 @@ Google ADK example also accepts `--provider`. ### MADA's A2A Agent Card -Use `a2a_self` when you want MADA itself to be discoverable by other A2A agents. +Use `a2a.self` when you want MADA itself to be discoverable by other A2A agents. This block is used by `mada-a2a` and `mada a2a`; it is not used by CLI or Gradio -mode. +mode. These are commands within this repo and not actual MADA repos like `mada-tools`. The `card_path` value points to a standalone A2A agent card JSON file. Relative paths are resolved relative to the configuration file. When the card is served, MADA overwrites the card's `url` field with the runtime public URL from -`a2a_self.url` or `--public-url`. +`a2a.self.url` or `--public-url`, and advertises A2A protocol `1.0.0`. #### Fields @@ -306,7 +308,6 @@ MADA overwrites the card's `url` field with the runtime public URL from | ----------- | --------------------------------------------------------------------------- | --------- | ------- | | `card_path` | Path to MADA's standalone A2A agent card JSON file. | No | None | | `url` | Public URL advertised in the served agent card. | No | Runtime host and port | -| `version` | Version used by the generated card fallback when no `card_path` is supplied. | No | `0.2.0` | | `name` | Name used by the generated card fallback when no `card_path` is supplied. | No | `MADA` | | `description` | Description used by the generated card fallback when no `card_path` is supplied. | No | `MADA multi-agent orchestration service` | | `skills` | Skills used by the generated card fallback when no `card_path` is supplied. | No | Derived from configured agents | @@ -314,10 +315,11 @@ MADA overwrites the card's `url` field with the runtime public URL from #### Example ```json -"a2a_self": { +"a2a": { + "self": { "card_path": "agent_cards/mada_orchestrator_card.json", "url": "http://localhost:9120", - "version": "0.2.0" + } } ``` diff --git a/examples/a2a_average_mcp_server.py b/examples/a2a/a2a_average_mcp_server.py similarity index 97% rename from examples/a2a_average_mcp_server.py rename to examples/a2a/a2a_average_mcp_server.py index 4cdc3b9..f1267f9 100644 --- a/examples/a2a_average_mcp_server.py +++ b/examples/a2a/a2a_average_mcp_server.py @@ -3,6 +3,7 @@ """ CSV column-average MCP server used by the Google ADK A2A example agent. +This is separate from MADA as this only used for A2A examples. """ from __future__ import annotations diff --git a/examples/a2a_example_config.py b/examples/a2a/a2a_example_utils.py similarity index 95% rename from examples/a2a_example_config.py rename to examples/a2a/a2a_example_utils.py index 12b6a29..f68e8c0 100644 --- a/examples/a2a_example_config.py +++ b/examples/a2a/a2a_example_utils.py @@ -12,7 +12,7 @@ DEFAULT_CONFIG_PATH = ( - Path(__file__).parent.parent / "configs" / "example_a2a_agents.json" + Path(__file__).parent.parent.parent / "configs" / "example_a2a_agents.json" ) diff --git a/examples/a2a_google_adk_agent.py b/examples/a2a/a2a_google_adk_agent.py similarity index 78% rename from examples/a2a_google_adk_agent.py rename to examples/a2a/a2a_google_adk_agent.py index 9a185d7..7976237 100644 --- a/examples/a2a_google_adk_agent.py +++ b/examples/a2a/a2a_google_adk_agent.py @@ -8,12 +8,11 @@ import argparse import json import os -import time import uuid from pathlib import Path from typing import Any -from a2a_example_config import DEFAULT_CONFIG_PATH, load_model_settings +from a2a_example_utils import DEFAULT_CONFIG_PATH, load_model_settings from fastapi import FastAPI, HTTPException from google.adk.agents import Agent from google.adk.models.lite_llm import LiteLlm @@ -21,6 +20,11 @@ from google.adk.sessions import InMemorySessionService from google.adk.tools.mcp_tool import McpToolset, StreamableHTTPConnectionParams from google.genai import types +from mada.interfaces.a2a.main import ( + _build_task, + _extract_message_text, + _ids_from_params, +) DEFAULT_MCP_URL = "http://localhost:9102/mcp" @@ -30,66 +34,6 @@ APP_NAME = "mada_google_adk_a2a_agent" -def extract_message_text(params: dict[str, Any]) -> str: - message = params.get("message", params) - if isinstance(message, str): - return message - if not isinstance(message, dict): - return "" - - parts = message.get("parts") - if not isinstance(parts, list): - return str(message.get("text", "") or "") - - text_parts = [] - for part in parts: - if not isinstance(part, dict): - continue - if part.get("kind") == "text" or part.get("type") == "text": - text = part.get("text") - if text: - text_parts.append(str(text)) - return "\n".join(text_parts) - - -def build_task(task_id: str, context_id: str, text: str) -> dict[str, Any]: - message_id = f"msg-{uuid.uuid4().hex}" - now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) - message = { - "kind": "message", - "messageId": message_id, - "role": "agent", - "parts": [{"kind": "text", "text": text}], - "taskId": task_id, - "contextId": context_id, - } - return { - "kind": "task", - "id": task_id, - "contextId": context_id, - "status": {"state": "completed", "timestamp": now, "message": message}, - "artifacts": [ - { - "artifactId": f"artifact-{uuid.uuid4().hex}", - "name": "response", - "parts": [{"kind": "text", "text": text}], - } - ], - } - - -def ids_from_params(params: dict[str, Any]) -> tuple[str, str]: - message = params.get("message") - task_id = params.get("id") or params.get("taskId") - context_id = params.get("contextId") - if isinstance(message, dict): - task_id = task_id or message.get("taskId") - context_id = context_id or message.get("contextId") - return str(task_id or f"task-{uuid.uuid4().hex}"), str( - context_id or f"context-{uuid.uuid4().hex}" - ) - - class GoogleADKA2AAgent: def __init__( self, @@ -215,7 +159,7 @@ async def handle_rpc(body: dict[str, Any]): "error": {"code": -32602, "message": "'params' must be an object"}, } - task = extract_message_text(params).strip() + task = _extract_message_text(params).strip() if not task: return { "jsonrpc": "2.0", @@ -228,11 +172,11 @@ async def handle_rpc(body: dict[str, Any]): except Exception as exc: raise HTTPException(status_code=500, detail=str(exc)) from exc - task_id, context_id = ids_from_params(params) + task_id, context_id = _ids_from_params(params) return { "jsonrpc": "2.0", "id": request_id, - "result": build_task(task_id, context_id, text), + "result": _build_task(task_id, context_id, text), } return app diff --git a/examples/a2a_langchain_agent.py b/examples/a2a/a2a_langchain_agent.py similarity index 75% rename from examples/a2a_langchain_agent.py rename to examples/a2a/a2a_langchain_agent.py index 288f2c8..78148bc 100644 --- a/examples/a2a_langchain_agent.py +++ b/examples/a2a/a2a_langchain_agent.py @@ -8,16 +8,20 @@ import argparse import json import os -import time import uuid from pathlib import Path from typing import Any -from a2a_example_config import DEFAULT_CONFIG_PATH, load_model_settings +from a2a_example_utils import DEFAULT_CONFIG_PATH, load_model_settings from fastapi import FastAPI, HTTPException from langchain_mcp_adapters.client import MultiServerMCPClient from langchain_core.messages import ToolMessage from langchain_openai import ChatOpenAI +from mada.interfaces.a2a.main import ( + _build_task, + _extract_message_text, + _ids_from_params, +) DEFAULT_MCP_URL = "http://localhost:9101/mcp" @@ -26,66 +30,6 @@ ) -def extract_message_text(params: dict[str, Any]) -> str: - message = params.get("message", params) - if isinstance(message, str): - return message - if not isinstance(message, dict): - return "" - - parts = message.get("parts") - if not isinstance(parts, list): - return str(message.get("text", "") or "") - - text_parts = [] - for part in parts: - if not isinstance(part, dict): - continue - if part.get("kind") == "text" or part.get("type") == "text": - text = part.get("text") - if text: - text_parts.append(str(text)) - return "\n".join(text_parts) - - -def build_task(task_id: str, context_id: str, text: str) -> dict[str, Any]: - message_id = f"msg-{uuid.uuid4().hex}" - now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) - message = { - "kind": "message", - "messageId": message_id, - "role": "agent", - "parts": [{"kind": "text", "text": text}], - "taskId": task_id, - "contextId": context_id, - } - return { - "kind": "task", - "id": task_id, - "contextId": context_id, - "status": {"state": "completed", "timestamp": now, "message": message}, - "artifacts": [ - { - "artifactId": f"artifact-{uuid.uuid4().hex}", - "name": "response", - "parts": [{"kind": "text", "text": text}], - } - ], - } - - -def ids_from_params(params: dict[str, Any]) -> tuple[str, str]: - message = params.get("message") - task_id = params.get("id") or params.get("taskId") - context_id = params.get("contextId") - if isinstance(message, dict): - task_id = task_id or message.get("taskId") - context_id = context_id or message.get("contextId") - return str(task_id or f"task-{uuid.uuid4().hex}"), str( - context_id or f"context-{uuid.uuid4().hex}" - ) - - class LangChainA2AAgent: def __init__( self, @@ -195,7 +139,7 @@ async def handle_rpc(body: dict[str, Any]): "error": {"code": -32602, "message": "'params' must be an object"}, } - task = extract_message_text(params).strip() + task = _extract_message_text(params).strip() if not task: return { "jsonrpc": "2.0", @@ -208,11 +152,11 @@ async def handle_rpc(body: dict[str, Any]): except Exception as exc: raise HTTPException(status_code=500, detail=str(exc)) from exc - task_id, context_id = ids_from_params(params) + task_id, context_id = _ids_from_params(params) return { "jsonrpc": "2.0", "id": request_id, - "result": build_task(task_id, context_id, text), + "result": _build_task(task_id, context_id, text), } return app diff --git a/examples/a2a_table_mcp_server.py b/examples/a2a/a2a_table_mcp_server.py similarity index 97% rename from examples/a2a_table_mcp_server.py rename to examples/a2a/a2a_table_mcp_server.py index 88a2479..dbd2603 100644 --- a/examples/a2a_table_mcp_server.py +++ b/examples/a2a/a2a_table_mcp_server.py @@ -3,6 +3,7 @@ """ CSV table-reader MCP server used by the LangChain A2A example agent. +This is separate from MADA as this only used for A2A examples. """ from __future__ import annotations diff --git a/examples/agent_cards/google_adk_agent_card.json b/examples/a2a/agent_cards/google_adk_agent_card.json similarity index 96% rename from examples/agent_cards/google_adk_agent_card.json rename to examples/a2a/agent_cards/google_adk_agent_card.json index 050bfd0..8b1307b 100644 --- a/examples/agent_cards/google_adk_agent_card.json +++ b/examples/a2a/agent_cards/google_adk_agent_card.json @@ -1,5 +1,5 @@ { - "protocolVersion": "0.3.0", + "protocolVersion": "1.0.0", "name": "GoogleADKAgent", "description": "Column average specialist that can compute averages for numeric columns in a small built-in CSV table by calling its MCP average tool.", "url": "http://localhost:9002", diff --git a/examples/agent_cards/langchain_agent_card.json b/examples/a2a/agent_cards/langchain_agent_card.json similarity index 96% rename from examples/agent_cards/langchain_agent_card.json rename to examples/a2a/agent_cards/langchain_agent_card.json index b3de4db..85d2812 100644 --- a/examples/agent_cards/langchain_agent_card.json +++ b/examples/a2a/agent_cards/langchain_agent_card.json @@ -1,5 +1,5 @@ { - "protocolVersion": "0.3.0", + "protocolVersion": "1.0.0", "name": "LangChainAgent", "description": "Table reader specialist that can load and display a small built-in CSV table by calling its MCP table-reader tool.", "url": "http://localhost:9001", diff --git a/pyproject.toml b/pyproject.toml index 09e8ee0..28121a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ requires-python = ">=3.11" readme = "README.md" dependencies = [ # Can't use basic `agent-framework` install since hyperlight breaks install on HPCs + "a2a-sdk==1.0.0", "agent-framework-core>=1.0.1", "agent-framework-a2a", "agent-framework-ag-ui", diff --git a/pytest.ini b/pytest.ini index 3cb45ea..2817b44 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,4 +1,5 @@ [pytest] +pythonpath = src markers = unit: marks tests as unit tests integration: marks tests as integration tests diff --git a/src/mada/core/a2a_client.py b/src/mada/core/a2a_client.py index 2bbf679..6a814c3 100644 --- a/src/mada/core/a2a_client.py +++ b/src/mada/core/a2a_client.py @@ -5,9 +5,9 @@ Small A2A JSON-RPC client used by the MADA orchestrator. This is the client-side A2A helper: the orchestrator uses it to call remote -A2A agents configured under `a2a_agents`. The server-side interface that +A2A agents configured under `a2a.agents`. The server-side interface that exposes MADA itself as an A2A agent lives in `mada.interfaces.a2a.main` and -uses the `a2a_self` configuration block. +uses the `a2a.self` configuration block. """ from __future__ import annotations diff --git a/src/mada/core/config/a2a.py b/src/mada/core/config/a2a.py index 8b754b9..8de09fa 100644 --- a/src/mada/core/config/a2a.py +++ b/src/mada/core/config/a2a.py @@ -4,12 +4,13 @@ """ A2A interface and remote agent configuration definitions. -`A2AConfig` models MADA's own A2A identity for `a2a_self` when MADA is run as -an A2A server. `RemoteA2AAgentConfig` models remote agents under `a2a_agents` +`A2AConfig` models MADA's own A2A identity for `a2a.self` when MADA is run as +an A2A server. `RemoteA2AAgentConfig` models remote agents under `a2a.agents` that the orchestrator can call as tools. """ -from dataclasses import dataclass, field +from dataclasses import InitVar, dataclass, field +from pathlib import Path from typing import Any from mada.core.config.utils import expand_env_vars @@ -39,8 +40,9 @@ class A2AConfig: url: str = "" card_path: str = "" skills: list[dict[str, Any]] = field(default_factory=list) + card_path_base: InitVar[str | Path | None] = None - def __post_init__(self) -> None: + def __post_init__(self, card_path_base: str | Path | None) -> None: """ Normalize fields and validate generated-card skill entries. """ @@ -52,17 +54,26 @@ def __post_init__(self) -> None: self.version = expand_env_vars(self.version or "").strip() or "0.2.0" self.url = expand_env_vars(self.url or "").strip() self.card_path = expand_env_vars(self.card_path or "").strip() + if self.card_path and card_path_base: + resolved_card_path = Path(self.card_path) + if not resolved_card_path.is_absolute(): + self.card_path = str( + (Path(card_path_base) / resolved_card_path).resolve() + ) if self.skills is None: self.skills = [] if not isinstance(self.skills, list): - raise ValueError("'a2a_self.skills' must be a list") + raise ValueError("'a2a.self.skills' must be a list") for skill in self.skills: if not isinstance(skill, dict): - raise ValueError("'a2a_self.skills' must contain only objects") + raise ValueError("'a2a.self.skills' must contain only objects") -def load_a2a_config(config_dict: dict[str, Any] | None) -> A2AConfig: +def load_a2a_config( + config_dict: dict[str, Any] | None, + card_path_base: str | Path | None = None, +) -> A2AConfig: """ Load self A2A configuration from a dictionary. @@ -73,12 +84,12 @@ def load_a2a_config(config_dict: dict[str, Any] | None) -> A2AConfig: A validated A2A configuration object. """ if config_dict is None: - return A2AConfig() + return A2AConfig(card_path_base=card_path_base) if not isinstance(config_dict, dict): - raise ValueError("'a2a_self' must be an object") + raise ValueError("'a2a.self' must be an object") - return A2AConfig(**config_dict) + return A2AConfig(**config_dict, card_path_base=card_path_base) @dataclass @@ -111,14 +122,14 @@ def __post_init__(self) -> None: """ self.url = expand_env_vars(self.url or "").strip() if not self.url: - raise ValueError("'a2a_agents..url' must not be empty") + raise ValueError("'a2a.agents..url' must not be empty") self.description = expand_env_vars(self.description or "").strip() self.card_url = expand_env_vars(self.card_url or "").strip() self.api_key = expand_env_vars(self.api_key or "").strip() if not isinstance(self.headers, dict): - raise ValueError("'a2a_agents..headers' must be an object") + raise ValueError("'a2a.agents..headers' must be an object") expanded_headers = {} for key, value in self.headers.items(): @@ -136,15 +147,15 @@ def load_a2a_agents_config( return {} if not isinstance(config_dict, dict): - raise ValueError("'a2a_agents' must be an object") + raise ValueError("'a2a.agents' must be an object") agents = {} for name, agent_config in config_dict.items(): if not isinstance(agent_config, dict): - raise ValueError("'a2a_agents' values must be objects") + raise ValueError("'a2a.agents' values must be objects") clean_name = str(name).strip() if not clean_name: - raise ValueError("'a2a_agents' must not contain empty names") + raise ValueError("'a2a.agents' must not contain empty names") agents[clean_name] = RemoteA2AAgentConfig(**agent_config) return agents diff --git a/src/mada/core/config/app.py b/src/mada/core/config/app.py index ddf19cd..98fd74d 100644 --- a/src/mada/core/config/app.py +++ b/src/mada/core/config/app.py @@ -64,7 +64,11 @@ class AppConfig: a2a_agents: Dict[str, RemoteA2AAgentConfig] = field(default_factory=dict) @classmethod - def from_dict(cls, config_dict: Dict[str, Any]) -> "AppConfig": + def from_dict( + cls, + config_dict: Dict[str, Any], + a2a_card_path_base: str | Path | None = None, + ) -> "AppConfig": """ Create an AppConfig instance from a dictionary. @@ -110,8 +114,12 @@ def from_dict(cls, config_dict: Dict[str, Any]) -> "AppConfig": ) app_conf["orchestration"] = orchestration_cfg - app_conf["a2a"] = load_a2a_config(config_dict.get("a2a_self")) - app_conf["a2a_agents"] = load_a2a_agents_config(config_dict.get("a2a_agents")) + a2a_self_config, a2a_agents_config = _get_a2a_config_blocks(config_dict) + app_conf["a2a"] = load_a2a_config( + a2a_self_config, + card_path_base=a2a_card_path_base, + ) + app_conf["a2a_agents"] = load_a2a_agents_config(a2a_agents_config) # Load MCP servers configuration (optional) python_exe = config_dict.get("python_executable", sys.executable) @@ -133,6 +141,25 @@ def from_dict(cls, config_dict: Dict[str, Any]) -> "AppConfig": return cls(**app_conf) +def _get_a2a_config_blocks( + config_dict: Dict[str, Any], +) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: + """ + Return server-side and remote-agent A2A blocks from nested config. + """ + if "a2a_self" in config_dict or "a2a_agents" in config_dict: + raise ValueError("Use 'a2a.self' and 'a2a.agents' for A2A configuration") + + a2a_section = config_dict.get("a2a") + if a2a_section is None: + return None, None + + if not isinstance(a2a_section, dict): + raise ValueError("'a2a' must be an object") + + return a2a_section.get("self"), a2a_section.get("agents") + + def load_config_from_json(path: str) -> AppConfig: """ Load application configuration from a JSON file. @@ -147,14 +174,4 @@ def load_config_from_json(path: str) -> AppConfig: with open(config_path, "r") as f: config_dict = json.load(f) - a2a_config = config_dict.get("a2a_self") - if isinstance(a2a_config, dict): - card_path = a2a_config.get("card_path") - if card_path: - resolved_card_path = Path(str(card_path).strip()) - if not resolved_card_path.is_absolute(): - a2a_config["card_path"] = str( - (config_path.parent / resolved_card_path).resolve() - ) - - return AppConfig.from_dict(config_dict) + return AppConfig.from_dict(config_dict, a2a_card_path_base=config_path.parent) diff --git a/src/mada/interfaces/a2a/main.py b/src/mada/interfaces/a2a/main.py index a40c91f..540e604 100644 --- a/src/mada/interfaces/a2a/main.py +++ b/src/mada/interfaces/a2a/main.py @@ -11,7 +11,7 @@ This is the server-side A2A entry point: use it when another A2A client or agent needs to discover MADA and send work to MADA. The client-side support for MADA calling other A2A agents lives in `mada.core.a2a_client` and is wired -through the `a2a_agents` configuration block. +through the `a2a.agents` configuration block. """ from __future__ import annotations @@ -61,18 +61,7 @@ from mada.core.orchestrator import MADAOrchestrator -def _get_orchestration_config(config: AppConfig) -> OrchestrationConfig: - """ - Return the configured orchestration settings or the default configuration. - """ - return getattr(config, "orchestration", None) or OrchestrationConfig() - - -def _get_a2a_config(config: AppConfig) -> A2AConfig: - """ - Return the configured self A2A settings or the default configuration. - """ - return getattr(config, "a2a", None) or A2AConfig() +A2A_PROTOCOL_VERSION = "1.0.0" class A2AStartupError(RuntimeError): @@ -136,7 +125,7 @@ def __init__( Initialize the service wrapper for one shared orchestrator instance. """ self.config = config - self.a2a_config = _get_a2a_config(config) + self.a2a_config = getattr(config, "a2a", None) or A2AConfig() self.public_url = self.a2a_config.url or public_url self.api_key = api_key self.bearer_token = bearer_token @@ -157,7 +146,8 @@ async def startup(self) -> None: orchestrator = MADAOrchestrator( model_config=self.config.model, database_config=self.config.database, - orchestration_config=_get_orchestration_config(self.config), + orchestration_config=getattr(self.config, "orchestration", None) + or OrchestrationConfig(), bearer_token=self.bearer_token, ) await orchestrator.__aenter__() @@ -217,7 +207,7 @@ def build_agent_card(self) -> Dict[str, Any]: if self.a2a_config.card_path: card = self._load_agent_card_file() card["url"] = self.public_url - card.setdefault("protocolVersion", "0.3.0") + card["protocolVersion"] = A2A_PROTOCOL_VERSION card.setdefault("capabilities", {"streaming": True}) card.setdefault("defaultInputModes", ["text/plain"]) card.setdefault("defaultOutputModes", ["text/plain"]) @@ -225,7 +215,7 @@ def build_agent_card(self) -> Dict[str, Any]: return card return { - "protocolVersion": "0.3.0", + "protocolVersion": A2A_PROTOCOL_VERSION, "name": self.a2a_config.name, "description": self.a2a_config.description, "url": self.public_url, diff --git a/tests/unit/core/test_config.py b/tests/unit/core/test_config.py index 86b142d..8b492e3 100644 --- a/tests/unit/core/test_config.py +++ b/tests/unit/core/test_config.py @@ -1,14 +1,19 @@ # Copyright 2026, Lawrence Livermore National Security, LLC and MADA contributors # SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +import json +from pathlib import Path + import pytest from mada.core.config import ( A2AConfig, + AppConfig, DEFAULT_ORCHESTRATION_MODE, PostgreSQLConfig, RemoteA2AAgentConfig, SQLiteConfig, + load_config_from_json, load_a2a_agents_config, load_a2a_config, load_orchestration_config, @@ -134,9 +139,19 @@ def test_load_a2a_config_accepts_metadata(self): @pytest.mark.parametrize("invalid_value", [False, []]) def test_load_a2a_config_rejects_non_object_blocks(self, invalid_value): - with pytest.raises(ValueError, match="'a2a_self' must be an object"): + with pytest.raises(ValueError, match="'a2a.self' must be an object"): load_a2a_config(invalid_value) + def test_load_a2a_config_resolves_relative_card_path(self, tmp_path: Path): + config = load_a2a_config( + {"card_path": "agent_cards/mada-card.json"}, + card_path_base=tmp_path, + ) + + assert config.card_path == str( + (tmp_path / "agent_cards/mada-card.json").resolve() + ) + @pytest.mark.unit class TestRemoteA2AAgentConfig: @@ -170,5 +185,96 @@ def test_load_a2a_agents_config_accepts_remote_agents(self): @pytest.mark.parametrize("invalid_value", [False, []]) def test_load_a2a_agents_config_rejects_non_object_blocks(self, invalid_value): - with pytest.raises(ValueError, match="'a2a_agents' must be an object"): + with pytest.raises(ValueError, match="'a2a.agents' must be an object"): load_a2a_agents_config(invalid_value) + + +@pytest.mark.unit +class TestAppA2AConfig: + def _base_config(self): + return { + "model": { + "provider": "openai", + "model": "gpt-test", + "api_key": "test-key", + "base_url": "https://llm.example/v1", + }, + "agents": [ + { + "agent_name": "WorkerAgent", + "description": "Does work", + "domain": "test", + "mcp_servers": [], + "instructions": "Help with tests.", + } + ], + } + + def test_from_dict_accepts_nested_a2a_config(self): + config_dict = self._base_config() + config_dict["a2a"] = { + "agents": { + "ursa": { + "url": "https://ursa.example/a2a", + "card_url": "https://ursa.example/.well-known/agent-card.json", + } + }, + "self": { + "name": "MADA A2A", + "url": "https://mada.example/a2a", + }, + } + + config = AppConfig.from_dict(config_dict) + + assert config.a2a.name == "MADA A2A" + assert config.a2a.url == "https://mada.example/a2a" + assert config.a2a_agents == { + "ursa": RemoteA2AAgentConfig( + url="https://ursa.example/a2a", + card_url="https://ursa.example/.well-known/agent-card.json", + ) + } + + def test_load_config_from_json_resolves_nested_a2a_self_card_path( + self, tmp_path: Path + ): + config_dict = self._base_config() + config_dict["a2a"] = { + "self": { + "card_path": "agent_cards/mada-card.json", + } + } + config_path = tmp_path / "mada.json" + config_path.write_text(json.dumps(config_dict), encoding="utf-8") + + config = load_config_from_json(str(config_path)) + + assert config.a2a.card_path == str( + (tmp_path / "agent_cards/mada-card.json").resolve() + ) + + def test_from_dict_rejects_non_object_nested_a2a_config(self): + config_dict = self._base_config() + config_dict["a2a"] = [] + + with pytest.raises(ValueError, match="'a2a' must be an object"): + AppConfig.from_dict(config_dict) + + def test_from_dict_rejects_legacy_top_level_a2a_keys(self): + config_dict = self._base_config() + config_dict["a2a_self"] = { + "name": "Legacy", + "url": "https://legacy.example/a2a", + } + config_dict["a2a_agents"] = { + "legacy": { + "url": "https://legacy-agent.example/a2a", + } + } + + with pytest.raises( + ValueError, + match="Use 'a2a.self' and 'a2a.agents' for A2A configuration", + ): + AppConfig.from_dict(config_dict) diff --git a/tests/unit/test_entrypoints.py b/tests/unit/test_entrypoints.py index 0f29f26..4864132 100644 --- a/tests/unit/test_entrypoints.py +++ b/tests/unit/test_entrypoints.py @@ -1001,6 +1001,7 @@ def test_agent_card_endpoint_returns_configured_metadata( assert response.status_code == 200 payload = response.json() + assert payload["protocolVersion"] == "1.0.0" assert payload["name"] == "MADA Test" assert payload["description"] == "Test A2A agent" assert payload["url"] == "https://mada.example/a2a" @@ -1016,7 +1017,7 @@ def test_agent_card_endpoint_can_serve_card_file( card_path.write_text( json.dumps( { - "protocolVersion": "0.3.0", + "protocolVersion": "1.0.0", "name": "FileBackedMADA", "description": "Loaded from a card file", "url": "http://placeholder", @@ -1041,6 +1042,7 @@ def test_agent_card_endpoint_can_serve_card_file( ) payload = service.build_agent_card() + assert payload["protocolVersion"] == "1.0.0" assert payload["name"] == "FileBackedMADA" assert payload["description"] == "Loaded from a card file" assert payload["url"] == "https://mada.example/a2a" From 46415d94c16bd6f806fbe5e49c50fbd19febb093 Mon Sep 17 00:00:00 2001 From: Jorge Moreno Date: Tue, 4 Aug 2026 16:12:58 -0700 Subject: [PATCH 9/9] using agenta2a --- .../agent_cards/mada_orchestrator_card.json | 9 +- docs/user_guide/configuration.md | 12 +- examples/a2a/a2a_example_utils.py | 49 +++ examples/a2a/a2a_google_adk_agent.py | 99 ++--- examples/a2a/a2a_langchain_agent.py | 96 ++--- .../agent_cards/google_adk_agent_card.json | 9 +- .../a2a/agent_cards/langchain_agent_card.json | 9 +- pyproject.toml | 3 +- src/mada/core/a2a_client.py | 147 ++++--- src/mada/core/config/a2a.py | 4 - .../orchestration/agent_as_tool_strategy.py | 9 +- src/mada/core/orchestrator.py | 59 ++- src/mada/interfaces/a2a/main.py | 401 +++++++----------- src/mada/interfaces/cli/main.py | 7 +- src/mada/interfaces/gradio/utils.py | 2 +- tests/unit/core/test_config.py | 2 - tests/unit/test_entrypoints.py | 39 +- 17 files changed, 464 insertions(+), 492 deletions(-) diff --git a/configs/agent_cards/mada_orchestrator_card.json b/configs/agent_cards/mada_orchestrator_card.json index 683c81b..428fbb7 100644 --- a/configs/agent_cards/mada_orchestrator_card.json +++ b/configs/agent_cards/mada_orchestrator_card.json @@ -1,9 +1,14 @@ { - "protocolVersion": "1.0.0", "name": "MADAOrchestrator", "description": "MADA multi-agent orchestrator that coordinates local reasoning agents and delegates to remote A2A agents when their capabilities match the task.", - "url": "http://localhost:9120", "version": "0.2.0", + "supportedInterfaces": [ + { + "url": "http://localhost:9120", + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0" + } + ], "capabilities": { "streaming": true }, diff --git a/docs/user_guide/configuration.md b/docs/user_guide/configuration.md index 4917026..e5dee43 100644 --- a/docs/user_guide/configuration.md +++ b/docs/user_guide/configuration.md @@ -245,7 +245,8 @@ handles outbound calls from MADA to remote A2A agents, while Use `a2a.agents` when the MADA orchestrator should delegate work to other A2A agents. Each configured remote agent is exposed to the planning agent as a tool, -using the remote agent card for routing context when available. +using the remote agent card for routing context. MADA fails startup if a +configured remote A2A agent card cannot be fetched. #### Fields @@ -253,7 +254,6 @@ using the remote agent card for routing context when available. | ------------- | --------------------------------------------------------------------------- | --------- | ------- | | `url` | JSON-RPC endpoint for the remote A2A agent. | Yes | N/A | | `card_url` | Explicit URL for the remote agent card. If omitted, MADA tries standard A2A card paths derived from `url`. | No | None | -| `description` | Fallback description used only if the remote agent card cannot be fetched. | No | None | | `timeout` | HTTP timeout in seconds for calls to the remote agent. | No | `180` | | `api_key` | Optional API key sent as `x-api-key`. | No | None | | `headers` | Additional HTTP headers to send to the remote agent. | No | `{}` | @@ -275,9 +275,11 @@ using the remote agent card for routing context when available. } ``` -The example A2A agents read the same MADA config by default so they use the -same `model` block as the orchestrator. Install their optional dependencies and -launch them with the config path: +The example MCP servers are used inside the remote A2A agents, not as local +MADA MCP servers. The MADA orchestrator should report `0 MCP Servers` and `2 +remote A2A agents` for this config. The remote agent card endpoints must be +reachable so MADA can discover each remote agent's skills. Install optional +dependencies and launch the MCP servers and A2A agents with the config path: ```bash pip install -e ".[a2a-examples]" diff --git a/examples/a2a/a2a_example_utils.py b/examples/a2a/a2a_example_utils.py index f68e8c0..858f427 100644 --- a/examples/a2a/a2a_example_utils.py +++ b/examples/a2a/a2a_example_utils.py @@ -10,6 +10,18 @@ import re from pathlib import Path +from a2a.server.agent_execution import AgentExecutor +from a2a.server.request_handlers import DefaultRequestHandler +from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes +from a2a.server.tasks import InMemoryTaskStore +from a2a.types import AgentCard +from a2a.utils.constants import PROTOCOL_VERSION_1_0, TransportProtocol +from google.protobuf.json_format import ParseDict +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import JSONResponse +from starlette.routing import Route + DEFAULT_CONFIG_PATH = ( Path(__file__).parent.parent.parent / "configs" / "example_a2a_agents.json" @@ -62,3 +74,40 @@ def load_model_settings(config_path: str | None) -> dict[str, str]: settings["api_key"] = Path(api_key).expanduser().read_text().strip() return settings + + +def create_a2a_example_app( + agent_executor: AgentExecutor, + agent_card_path: Path, + public_url: str, +) -> Starlette: + """ + Build the common Starlette A2A app used by the example agents. + """ + + async def health(request: Request) -> JSONResponse: + return JSONResponse({"status": "ok"}) + + card = json.loads(agent_card_path.read_text(encoding="utf-8")) + card["supportedInterfaces"] = [ + { + "url": public_url, + "protocolBinding": TransportProtocol.JSONRPC.value, + "protocolVersion": PROTOCOL_VERSION_1_0, + } + ] + public_agent_card = ParseDict(card, AgentCard()) + request_handler = DefaultRequestHandler( + agent_executor=agent_executor, + task_store=InMemoryTaskStore(), + agent_card=public_agent_card, + ) + + return Starlette( + routes=[ + Route("/health", health, methods=["GET"]), + *create_agent_card_routes(public_agent_card), + *create_jsonrpc_routes(request_handler, "/"), + *create_jsonrpc_routes(request_handler, "/a2a"), + ] + ) diff --git a/examples/a2a/a2a_google_adk_agent.py b/examples/a2a/a2a_google_adk_agent.py index 7976237..704f8bc 100644 --- a/examples/a2a/a2a_google_adk_agent.py +++ b/examples/a2a/a2a_google_adk_agent.py @@ -6,25 +6,25 @@ from __future__ import annotations import argparse -import json import os import uuid from pathlib import Path -from typing import Any -from a2a_example_utils import DEFAULT_CONFIG_PATH, load_model_settings -from fastapi import FastAPI, HTTPException +from a2a_example_utils import ( + DEFAULT_CONFIG_PATH, + create_a2a_example_app, + load_model_settings, +) +from a2a.server.agent_execution import AgentExecutor, RequestContext +from a2a.server.events import EventQueue +from a2a.helpers import new_text_message from google.adk.agents import Agent from google.adk.models.lite_llm import LiteLlm from google.adk.runners import Runner from google.adk.sessions import InMemorySessionService from google.adk.tools.mcp_tool import McpToolset, StreamableHTTPConnectionParams from google.genai import types -from mada.interfaces.a2a.main import ( - _build_task, - _extract_message_text, - _ids_from_params, -) +from starlette.applications import Starlette DEFAULT_MCP_URL = "http://localhost:9102/mcp" @@ -92,9 +92,6 @@ def _build_adk_model(self): return self.model async def run(self, task: str) -> str: - return await self._run_adk_agent(task) - - async def _run_adk_agent(self, prompt: str) -> str: runner = self.runner user_id = f"mada-user-{uuid.uuid4().hex}" session_id = f"mada-session-{uuid.uuid4().hex}" @@ -106,7 +103,7 @@ async def _run_adk_agent(self, prompt: str) -> str: message = types.Content( role="user", - parts=[types.Part(text=prompt)], + parts=[types.Part(text=task)], ) final_text = "" @@ -127,59 +124,31 @@ async def _run_adk_agent(self, prompt: str) -> str: return final_text -def create_app(agent: GoogleADKA2AAgent, public_url: str) -> FastAPI: - app = FastAPI(title="Example Google ADK A2A Agent") - - @app.get("/health") - async def health() -> dict[str, str]: - return {"status": "ok"} - - @app.get("/.well-known/agent-card.json") - async def agent_card() -> dict[str, Any]: - card = json.loads(DEFAULT_AGENT_CARD_PATH.read_text(encoding="utf-8")) - card["url"] = public_url - return card - - @app.post("/") - @app.post("/a2a") - async def handle_rpc(body: dict[str, Any]): - request_id = body.get("id") - if body.get("method") != "message/send": - return { - "jsonrpc": "2.0", - "id": request_id, - "error": {"code": -32601, "message": "Only message/send is supported"}, - } - - params = body.get("params") or {} - if not isinstance(params, dict): - return { - "jsonrpc": "2.0", - "id": request_id, - "error": {"code": -32602, "message": "'params' must be an object"}, - } - - task = _extract_message_text(params).strip() +class GoogleADKA2AExecutor(AgentExecutor): + """ + A2A SDK executor that delegates requests to the Google ADK example agent. + """ + + def __init__(self, agent: GoogleADKA2AAgent) -> None: + self.agent = agent + + async def execute(self, context: RequestContext, event_queue: EventQueue) -> None: + task = context.get_user_input().strip() if not task: - return { - "jsonrpc": "2.0", - "id": request_id, - "error": {"code": -32602, "message": "Missing text message"}, - } - - try: - text = await agent.run(task) - except Exception as exc: - raise HTTPException(status_code=500, detail=str(exc)) from exc - - task_id, context_id = _ids_from_params(params) - return { - "jsonrpc": "2.0", - "id": request_id, - "result": _build_task(task_id, context_id, text), - } - - return app + raise ValueError("A2A request must include a text message part") + text = await self.agent.run(task) + await event_queue.enqueue_event(new_text_message(text)) + + async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None: + raise RuntimeError("A2A task cancellation is not supported") + + +def create_app(agent: GoogleADKA2AAgent, public_url: str) -> Starlette: + return create_a2a_example_app( + GoogleADKA2AExecutor(agent), + DEFAULT_AGENT_CARD_PATH, + public_url, + ) def main() -> None: diff --git a/examples/a2a/a2a_langchain_agent.py b/examples/a2a/a2a_langchain_agent.py index 78148bc..b417d96 100644 --- a/examples/a2a/a2a_langchain_agent.py +++ b/examples/a2a/a2a_langchain_agent.py @@ -6,22 +6,23 @@ from __future__ import annotations import argparse -import json import os import uuid from pathlib import Path from typing import Any -from a2a_example_utils import DEFAULT_CONFIG_PATH, load_model_settings -from fastapi import FastAPI, HTTPException +from a2a_example_utils import ( + DEFAULT_CONFIG_PATH, + create_a2a_example_app, + load_model_settings, +) +from a2a.server.agent_execution import AgentExecutor, RequestContext +from a2a.server.events import EventQueue +from a2a.helpers import new_text_message from langchain_mcp_adapters.client import MultiServerMCPClient from langchain_core.messages import ToolMessage from langchain_openai import ChatOpenAI -from mada.interfaces.a2a.main import ( - _build_task, - _extract_message_text, - _ids_from_params, -) +from starlette.applications import Starlette DEFAULT_MCP_URL = "http://localhost:9101/mcp" @@ -57,9 +58,6 @@ def llm(self): return self._llm async def run(self, task: str) -> str: - return await self._run_langchain_agent(task) - - async def _run_langchain_agent(self, prompt: str) -> str: tools = await self._get_tools() tools_by_name = {tool.name: tool for tool in tools} messages = [ @@ -69,7 +67,7 @@ async def _run_langchain_agent(self, prompt: str) -> str: "Complete the delegated task and return only the useful result. " "Use your available MCP tools when they are relevant.", ), - ("human", prompt), + ("human", task), ] response = await self.llm.bind_tools(tools).ainvoke(messages) @@ -107,59 +105,31 @@ async def _get_tools(self) -> list[Any]: return self._tools -def create_app(agent: LangChainA2AAgent, public_url: str) -> FastAPI: - app = FastAPI(title="Example LangChain A2A Agent") - - @app.get("/health") - async def health() -> dict[str, str]: - return {"status": "ok"} - - @app.get("/.well-known/agent-card.json") - async def agent_card() -> dict[str, Any]: - card = json.loads(DEFAULT_AGENT_CARD_PATH.read_text(encoding="utf-8")) - card["url"] = public_url - return card - - @app.post("/") - @app.post("/a2a") - async def handle_rpc(body: dict[str, Any]): - request_id = body.get("id") - if body.get("method") != "message/send": - return { - "jsonrpc": "2.0", - "id": request_id, - "error": {"code": -32601, "message": "Only message/send is supported"}, - } - - params = body.get("params") or {} - if not isinstance(params, dict): - return { - "jsonrpc": "2.0", - "id": request_id, - "error": {"code": -32602, "message": "'params' must be an object"}, - } - - task = _extract_message_text(params).strip() +class LangChainA2AExecutor(AgentExecutor): + """ + A2A SDK executor that delegates requests to the LangChain example agent. + """ + + def __init__(self, agent: LangChainA2AAgent) -> None: + self.agent = agent + + async def execute(self, context: RequestContext, event_queue: EventQueue) -> None: + task = context.get_user_input().strip() if not task: - return { - "jsonrpc": "2.0", - "id": request_id, - "error": {"code": -32602, "message": "Missing text message"}, - } + raise ValueError("A2A request must include a text message part") + text = await self.agent.run(task) + await event_queue.enqueue_event(new_text_message(text)) - try: - text = await agent.run(task) - except Exception as exc: - raise HTTPException(status_code=500, detail=str(exc)) from exc + async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None: + raise RuntimeError("A2A task cancellation is not supported") - task_id, context_id = _ids_from_params(params) - return { - "jsonrpc": "2.0", - "id": request_id, - "result": _build_task(task_id, context_id, text), - } - return app +def create_app(agent: LangChainA2AAgent, public_url: str) -> Starlette: + return create_a2a_example_app( + LangChainA2AExecutor(agent), + DEFAULT_AGENT_CARD_PATH, + public_url, + ) def main() -> None: @@ -184,7 +154,9 @@ def main() -> None: parser.add_argument( "--base-url", default=None, - help="OpenAI-compatible base URL override. Defaults to the MADA config base_url.", + help=( + "OpenAI-compatible base URL override. Defaults to the MADA config base_url." + ), ) parser.add_argument( "--mcp-url", diff --git a/examples/a2a/agent_cards/google_adk_agent_card.json b/examples/a2a/agent_cards/google_adk_agent_card.json index 8b1307b..18ea872 100644 --- a/examples/a2a/agent_cards/google_adk_agent_card.json +++ b/examples/a2a/agent_cards/google_adk_agent_card.json @@ -1,9 +1,14 @@ { - "protocolVersion": "1.0.0", "name": "GoogleADKAgent", "description": "Column average specialist that can compute averages for numeric columns in a small built-in CSV table by calling its MCP average tool.", - "url": "http://localhost:9002", "version": "0.1.0", + "supportedInterfaces": [ + { + "url": "http://localhost:9002", + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0" + } + ], "capabilities": { "streaming": false }, diff --git a/examples/a2a/agent_cards/langchain_agent_card.json b/examples/a2a/agent_cards/langchain_agent_card.json index 85d2812..93d8c18 100644 --- a/examples/a2a/agent_cards/langchain_agent_card.json +++ b/examples/a2a/agent_cards/langchain_agent_card.json @@ -1,9 +1,14 @@ { - "protocolVersion": "1.0.0", "name": "LangChainAgent", "description": "Table reader specialist that can load and display a small built-in CSV table by calling its MCP table-reader tool.", - "url": "http://localhost:9001", "version": "0.1.0", + "supportedInterfaces": [ + { + "url": "http://localhost:9001", + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0" + } + ], "capabilities": { "streaming": false }, diff --git a/pyproject.toml b/pyproject.toml index 28121a2..3ca6ee5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,8 @@ requires-python = ">=3.11" readme = "README.md" dependencies = [ # Can't use basic `agent-framework` install since hyperlight breaks install on HPCs - "a2a-sdk==1.0.0", + "a2a-sdk>=1.1.0", + "grpcio", "agent-framework-core>=1.0.1", "agent-framework-a2a", "agent-framework-ag-ui", diff --git a/src/mada/core/a2a_client.py b/src/mada/core/a2a_client.py index 6a814c3..17565eb 100644 --- a/src/mada/core/a2a_client.py +++ b/src/mada/core/a2a_client.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception """ -Small A2A JSON-RPC client used by the MADA orchestrator. +Small A2A client used by the MADA orchestrator. This is the client-side A2A helper: the orchestrator uses it to call remote A2A agents configured under `a2a.agents`. The server-side interface that @@ -12,10 +12,15 @@ from __future__ import annotations -import uuid from typing import Any import httpx +from a2a.client import A2ACardResolver +from a2a.server.request_handlers.response_helpers import agent_card_to_dict +from a2a.types import AgentCard +from a2a.utils.constants import PROTOCOL_VERSION_1_0, TransportProtocol +from agent_framework.a2a import A2AAgent +from google.protobuf.json_format import ParseDict from mada.core.config import RemoteA2AAgentConfig @@ -31,73 +36,66 @@ def __init__(self, name: str, config: RemoteA2AAgentConfig) -> None: """ self.name = name self.config = config - headers = dict(config.headers) + self._headers = dict(config.headers) if config.api_key: - headers["x-api-key"] = config.api_key - self._client = httpx.AsyncClient(headers=headers, timeout=config.timeout) + self._headers["x-api-key"] = config.api_key + self._client = httpx.AsyncClient(headers=self._headers, timeout=config.timeout) + self._agent_card: AgentCard | None = None async def send_message(self, task: str) -> str: """ Send a text task to the remote A2A agent and return its text response. """ - request_id = f"mada-{uuid.uuid4().hex}" - payload = { - "jsonrpc": "2.0", - "id": request_id, - "method": "message/send", - "params": { - "message": { - "kind": "message", - "messageId": f"msg-{uuid.uuid4().hex}", - "role": "user", - "parts": [{"kind": "text", "text": task}], - } - }, - } - - response = await self._client.post(self.config.url, json=payload) - response.raise_for_status() - data = response.json() - - if data.get("error"): - error = data["error"] - message = error.get("message") if isinstance(error, dict) else str(error) - raise RuntimeError(f"A2A agent {self.name} returned an error: {message}") - - return self._extract_text(data.get("result")) + if self._agent_card is None: + await self.get_agent_card() + agent_card = self._agent_card + if agent_card is None: + raise RuntimeError(f"A2A agent card was not loaded for {self.name}") + agent = A2AAgent( + name=agent_card.name or self.name, + url=self.config.url, + agent_card=agent_card, + http_client=self._client, + ) + response = await agent.run(task) + return self._extract_text(response) async def get_agent_card(self) -> dict[str, Any]: """ Fetch the remote agent card when the A2A server exposes one. """ - if self.config.card_url: - try: + if self._agent_card is None: + if self.config.card_url: response = await self._client.get(self.config.card_url, timeout=5.0) response.raise_for_status() data = response.json() - except Exception: - return {} - return data if isinstance(data, dict) else {} - - base_url = self.config.url.rstrip("/") - if base_url.endswith("/a2a"): - base_url = base_url[: -len("/a2a")] - for path in ( - "/.well-known/agent-card.json", - "/.well-known/agent.json", - "/agent-card.json", - ): - try: - response = await self._client.get(f"{base_url}{path}", timeout=5.0) - if response.status_code == 404: - continue - response.raise_for_status() - data = response.json() - except Exception: - continue - if isinstance(data, dict): - return data - return {} + if not isinstance(data, dict): + raise RuntimeError( + "A2A agent card response must be a JSON object: " + f"{self.config.card_url}" + ) + self._agent_card = ParseDict(data, AgentCard()) + else: + base_url = self.config.url.rstrip("/") + if base_url.endswith("/a2a"): + base_url = base_url[: -len("/a2a")] + resolver = A2ACardResolver( + httpx_client=self._client, + base_url=base_url, + ) + self._agent_card = await resolver.get_agent_card() + + if not any( + interface.protocol_binding == TransportProtocol.JSONRPC.value + and interface.protocol_version == PROTOCOL_VERSION_1_0 + for interface in self._agent_card.supported_interfaces + ): + raise RuntimeError( + f"A2A agent {self.name} must advertise a JSONRPC " + f"{PROTOCOL_VERSION_1_0} supported interface." + ) + + return self._to_dict(self._agent_card) async def aclose(self) -> None: """ @@ -107,25 +105,45 @@ async def aclose(self) -> None: def _extract_text(self, result: Any) -> str: """ - Extract human-readable text from an A2A JSON-RPC result payload. + Extract human-readable text from an A2A response payload. """ if result is None: return "" if isinstance(result, str): return result - if not isinstance(result, dict): - return str(result) texts = [] + self._collect_agent_framework_text(result, texts) + if texts: + return "\n".join(texts) self._collect_text_parts(result, texts) if texts: return "\n".join(texts) return str(result) + def _collect_agent_framework_text(self, value: Any, texts: list[str]) -> None: + """ + Collect common Agent Framework text fields from response objects. + """ + for attr in ("messages", "contents"): + items = getattr(value, attr, None) + if isinstance(items, list): + for item in items: + self._collect_agent_framework_text(item, texts) + + text = getattr(value, "text", None) + if text: + texts.append(str(text)) + def _collect_text_parts(self, value: Any, texts: list[str]) -> None: """ Recursively collect text parts from an A2A result structure. """ + if not isinstance(value, (dict, list)): + value = self._to_dict(value) + if not value: + return + if isinstance(value, dict): parts = value.get("parts") if isinstance(parts, list): @@ -141,3 +159,16 @@ def _collect_text_parts(self, value: Any, texts: list[str]) -> None: elif isinstance(value, list): for item in value: self._collect_text_parts(item, texts) + + def _to_dict(self, value: Any) -> dict[str, Any]: + """ + Convert A2A SDK and Pydantic objects to plain dictionaries. + """ + if isinstance(value, dict): + return value + if isinstance(value, AgentCard): + return agent_card_to_dict(value) + data = value.dict(by_alias=True) + if not isinstance(data, dict): + raise RuntimeError("A2A SDK object did not serialize to a dictionary") + return data diff --git a/src/mada/core/config/a2a.py b/src/mada/core/config/a2a.py index 8de09fa..9537853 100644 --- a/src/mada/core/config/a2a.py +++ b/src/mada/core/config/a2a.py @@ -102,8 +102,6 @@ class RemoteA2AAgentConfig: card_url: Optional explicit URL for the remote A2A agent card. When omitted, MADA discovers the card from standard paths derived from `url`. - description: Optional fallback capability summary used only when the - remote agent card cannot be fetched. timeout: HTTP timeout in seconds for calls to this remote agent. api_key: Optional API key sent as `x-api-key`. headers: Optional additional HTTP headers. @@ -111,7 +109,6 @@ class RemoteA2AAgentConfig: url: str card_url: str = "" - description: str = "" timeout: float = 180.0 api_key: str = "" headers: dict[str, str] = field(default_factory=dict) @@ -124,7 +121,6 @@ def __post_init__(self) -> None: if not self.url: raise ValueError("'a2a.agents..url' must not be empty") - self.description = expand_env_vars(self.description or "").strip() self.card_url = expand_env_vars(self.card_url or "").strip() self.api_key = expand_env_vars(self.api_key or "").strip() diff --git a/src/mada/core/orchestration/agent_as_tool_strategy.py b/src/mada/core/orchestration/agent_as_tool_strategy.py index 5ae8cb4..2c7f293 100644 --- a/src/mada/core/orchestration/agent_as_tool_strategy.py +++ b/src/mada/core/orchestration/agent_as_tool_strategy.py @@ -269,14 +269,9 @@ def _remote_a2a_tool_labels(self, orchestrator: "MADAOrchestrator") -> List[str] Build user-facing labels for remote A2A agents. """ labels = [] - for agent_name, agent_config in orchestrator.a2a_agents.items(): + for agent_name in orchestrator.a2a_agents: card = orchestrator._a2a_agent_cards.get(agent_name, {}) - description = ( - card.get("description") - or agent_config.description - or f"Remote A2A agent at {agent_config.url}" - ) - labels.append(f"A2A: {agent_name} - {description}") + labels.append(f"A2A: {agent_name} - {card['description']}") return labels async def initialize( diff --git a/src/mada/core/orchestrator.py b/src/mada/core/orchestrator.py index c22d138..da478a4 100644 --- a/src/mada/core/orchestrator.py +++ b/src/mada/core/orchestrator.py @@ -612,9 +612,10 @@ def _create_remote_a2a_agent_tools(self) -> List[Any]: client = RemoteA2AClient(agent_name, agent_config) self._a2a_clients_by_agent[agent_name] = client - description = ( - agent_config.description - or f"Remote A2A agent available at {agent_config.url}" + card = self._a2a_agent_cards.get(agent_name, {}) + description = self._remote_a2a_description( + card["description"], + card, ) tool_name = f"call_{self._tool_name(agent_name)}" tools.append( @@ -694,30 +695,37 @@ def _generate_remote_a2a_description(self) -> str: lines = [] for agent_name, agent_config in self.a2a_agents.items(): card = self._a2a_agent_cards.get(agent_name, {}) - description = ( - agent_config.description - or card.get("description") - or f"Remote A2A agent at {agent_config.url}" + description = self._remote_a2a_description( + card["description"], + card, ) - skills = card.get("skills") - if isinstance(skills, list) and skills: - skill_lines = [] - for skill in skills: - if not isinstance(skill, dict): - continue - skill_name = skill.get("name") or skill.get("id") or "skill" - skill_description = skill.get("description") or "" - skill_lines.append(f"{skill_name}: {skill_description}".strip()) - if skill_lines: - description = f"{description} Skills: {'; '.join(skill_lines)}" lines.append(f" {agent_name}: {description}") if not lines: return " (no remote A2A agents configured)" return "\n".join(lines) + def _remote_a2a_description(self, description: str, card: dict[str, Any]) -> str: + """ + Add agent-card skill summaries to a remote A2A description. + """ + skills = card.get("skills") + if not isinstance(skills, list) or not skills: + return description + + skill_lines = [] + for skill in skills: + if not isinstance(skill, dict): + continue + skill_name = skill.get("name") or skill.get("id") or "skill" + skill_description = skill.get("description") or "" + skill_lines.append(f"{skill_name}: {skill_description}".strip()) + if not skill_lines: + return description + return f"{description} Skills: {'; '.join(skill_lines)}" + async def _load_remote_a2a_agent_cards(self) -> None: """ - Best-effort fetch of remote A2A agent cards for planner routing context. + Fetch remote A2A agent cards for planner routing context. """ self._a2a_agent_cards.clear() for agent_name, agent_config in self.a2a_agents.items(): @@ -728,10 +736,15 @@ async def _load_remote_a2a_agent_cards(self) -> None: try: card = await client.get_agent_card() except Exception as exc: - LOG.debug(f"Could not fetch A2A agent card for {agent_name}: {exc}") - continue - if card: - self._a2a_agent_cards[agent_name] = card + raise RuntimeError( + f"Could not fetch A2A agent card for {agent_name}: {exc}" + ) from exc + if not card: + raise RuntimeError( + f"Could not fetch A2A agent card for {agent_name}: " + f"{agent_config.card_url or agent_config.url}" + ) + self._a2a_agent_cards[agent_name] = card async def initialize_orchestrator( self, diff --git a/src/mada/interfaces/a2a/main.py b/src/mada/interfaces/a2a/main.py index 540e604..fcdb6bf 100644 --- a/src/mada/interfaces/a2a/main.py +++ b/src/mada/interfaces/a2a/main.py @@ -5,7 +5,7 @@ Agent-to-Agent HTTP interface for MADA Orchestrator. This module exposes the configured MADA planning agent as an A2A-compatible -JSON-RPC service. The MADA agent card is available under the standard +service. The MADA agent card is available under the standard `/.well-known/agent-card.json` path. This is the server-side A2A entry point: use it when another A2A client or @@ -17,42 +17,32 @@ from __future__ import annotations import asyncio +import inspect import json import re import secrets import sys -import time -import uuid from contextlib import asynccontextmanager from pathlib import Path from typing import TYPE_CHECKING, Any, AsyncGenerator, Dict, Optional import click - -try: - from fastapi import FastAPI, Header, HTTPException - from fastapi.responses import JSONResponse, StreamingResponse -except ( - ImportError -) as exc: # pragma: no cover - exercised only in missing dependency environments - FastAPI = None - Header = None - HTTPException = None - JSONResponse = None - StreamingResponse = None - FASTAPI_IMPORT_ERROR = exc -else: - FASTAPI_IMPORT_ERROR = None - -try: - import uvicorn -except ( - ImportError -) as exc: # pragma: no cover - exercised only in missing dependency environments - uvicorn = None - UVICORN_IMPORT_ERROR = exc -else: - UVICORN_IMPORT_ERROR = None +import uvicorn +from a2a.server.agent_execution import AgentExecutor, RequestContext +from a2a.server.events import EventQueue +from a2a.server.request_handlers import DefaultRequestHandler +from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes +from a2a.server.tasks import InMemoryTaskStore +from a2a.helpers import new_text_message +from a2a.types import AgentCard +from a2a.utils.constants import PROTOCOL_VERSION_1_0, TransportProtocol +from google.protobuf.json_format import ParseDict +from starlette.applications import Starlette +from starlette.datastructures import Headers +from starlette.exceptions import HTTPException +from starlette.requests import Request +from starlette.responses import JSONResponse +from starlette.routing import Route from mada.core import load_config_from_json from mada.core.config import A2AConfig, AppConfig, OrchestrationConfig @@ -61,13 +51,42 @@ from mada.core.orchestrator import MADAOrchestrator -A2A_PROTOCOL_VERSION = "1.0.0" - - class A2AStartupError(RuntimeError): """Raised when the orchestrator cannot be initialized for A2A requests.""" +class A2AAuthMiddleware: + """ + Validate API keys for state-changing A2A requests. + """ + + def __init__(self, app: Any, service: "MADAA2AService") -> None: + self.app = app + self.service = service + + async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None: + if ( + scope.get("type") == "http" + and scope.get("method", "").upper() != "GET" + and scope.get("path") in {"/", "/a2a"} + ): + headers = Headers(scope=scope) + try: + self.service.validate_api_key( + headers.get("authorization"), + headers.get("x-api-key"), + ) + except HTTPException as exc: + response = JSONResponse( + {"detail": exc.detail}, + status_code=exc.status_code, + ) + await response(scope, receive, send) + return + + await self.app(scope, receive, send) + + def _format_startup_error_message(exc: BaseException) -> str: """ Convert orchestrator startup failures into user-facing A2A error text. @@ -85,30 +104,6 @@ def _format_startup_error_message(exc: BaseException) -> str: return f"MADA failed to initialize the configured agent team. Details: {details}" -def _require_fastapi() -> None: - """ - Raise a clear error when A2A server dependencies are unavailable. - """ - if FASTAPI_IMPORT_ERROR is not None or uvicorn is None: - missing = [] - if FASTAPI_IMPORT_ERROR is not None: - missing.append("fastapi") - if UVICORN_IMPORT_ERROR is not None: - missing.append("uvicorn") - packages = ", ".join(missing) or "fastapi, uvicorn" - raise RuntimeError( - f"A2A mode requires {packages}. Install the project dependencies again." - ) - - -def _slugify(value: str) -> str: - """ - Convert an agent name into a stable A2A skill identifier. - """ - slug = re.sub(r"[^a-zA-Z0-9_-]+", "-", value.strip()).strip("-").lower() - return slug or "mada-agent" - - class MADAA2AService: """ Manage the shared orchestrator instance used by the A2A API. @@ -206,25 +201,33 @@ def build_agent_card(self) -> Dict[str, Any]: """ if self.a2a_config.card_path: card = self._load_agent_card_file() - card["url"] = self.public_url - card["protocolVersion"] = A2A_PROTOCOL_VERSION + card["supportedInterfaces"] = [ + { + "url": self.public_url, + "protocolBinding": TransportProtocol.JSONRPC.value, + "protocolVersion": PROTOCOL_VERSION_1_0, + } + ] card.setdefault("capabilities", {"streaming": True}) card.setdefault("defaultInputModes", ["text/plain"]) card.setdefault("defaultOutputModes", ["text/plain"]) - card["supportsAuthenticatedExtendedCard"] = bool(self.api_key) return card return { - "protocolVersion": A2A_PROTOCOL_VERSION, "name": self.a2a_config.name, "description": self.a2a_config.description, - "url": self.public_url, "version": self.a2a_config.version, + "supportedInterfaces": [ + { + "url": self.public_url, + "protocolBinding": TransportProtocol.JSONRPC.value, + "protocolVersion": PROTOCOL_VERSION_1_0, + } + ], "capabilities": {"streaming": True}, "defaultInputModes": ["text/plain"], "defaultOutputModes": ["text/plain"], "skills": self._build_skills(), - "supportsAuthenticatedExtendedCard": bool(self.api_key), } def _load_agent_card_file(self) -> Dict[str, Any]: @@ -259,9 +262,13 @@ def _build_skills(self) -> list[dict[str, Any]]: continue name = getattr(agent, "agent_name", "") or "MADA Agent" description = getattr(agent, "description", "") or name + skill_id = ( + re.sub(r"[^a-zA-Z0-9_-]+", "-", name.strip()).strip("-").lower() + or "mada-agent" + ) skills.append( { - "id": _slugify(name), + "id": skill_id, "name": name, "description": description, "tags": [getattr(agent, "domain", "") or "mada"], @@ -305,33 +312,28 @@ async def stream_response(self, message: str) -> AsyncGenerator[str, None]: yield chunk -def _json_rpc_error( - request_id: Any, - code: int, - message: str, - status_code: int = 200, -) -> JSONResponse: +def _extract_message_text(value: Any) -> str: """ - Build a JSON-RPC error response with the requested HTTP status. + Extract text content from supported A2A message-like shapes. """ - return JSONResponse( - { - "jsonrpc": "2.0", - "id": request_id, - "error": {"code": code, "message": message}, - }, - status_code=status_code, - ) - - -def _extract_message_text(params: Dict[str, Any]) -> str: - """ - Extract text content from supported A2A message parameter shapes. - """ - message = params.get("message", params) + if isinstance(value, str): + return value + + get_user_input = getattr(value, "get_user_input", None) + if callable(get_user_input): + text = get_user_input() + return str(text or "") + + if not isinstance(value, dict): + model_dump = getattr(value, "model_dump", None) + if callable(model_dump): + value = model_dump() + else: + return str(getattr(value, "text", "") or "") + + message = value.get("message", value) if isinstance(message, str): return message - if not isinstance(message, dict): return "" @@ -351,59 +353,65 @@ def _extract_message_text(params: Dict[str, Any]) -> str: return "\n".join(text_parts) -def _build_task( - task_id: str, - context_id: str, - text: str, - state: str = "completed", -) -> Dict[str, Any]: +async def _enqueue_event(event_queue: Any, event: Any) -> None: """ - Build an A2A task object containing a text response artifact. + Enqueue an SDK event across minor EventQueue API differences. """ - message_id = f"msg-{uuid.uuid4().hex}" - now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) - response_message = { - "kind": "message", - "messageId": message_id, - "role": "agent", - "parts": [{"kind": "text", "text": text}], - "taskId": task_id, - "contextId": context_id, - } - return { - "kind": "task", - "id": task_id, - "contextId": context_id, - "status": { - "state": state, - "timestamp": now, - "message": response_message, - }, - "artifacts": [ - { - "artifactId": f"artifact-{uuid.uuid4().hex}", - "name": "response", - "parts": [{"kind": "text", "text": text}], - } - ], - } + enqueue = getattr(event_queue, "enqueue_event", None) + if callable(enqueue): + result = enqueue(event) + if inspect.isawaitable(result): + await result + return + + put = getattr(event_queue, "put", None) + if callable(put): + result = put(event) + if inspect.isawaitable(result): + await result + return + raise RuntimeError("Unsupported A2A event queue implementation") -def _ids_from_params(params: Dict[str, Any]) -> tuple[str, str]: + +class MADAA2AExecutor(AgentExecutor): """ - Resolve task and context IDs from request params or create new IDs. + A2A SDK executor adapter for the shared MADA orchestrator service. """ - message = params.get("message") - task_id = params.get("id") or params.get("taskId") - context_id = params.get("contextId") - if isinstance(message, dict): - task_id = task_id or message.get("taskId") - context_id = context_id or message.get("contextId") + def __init__(self, service: MADAA2AService) -> None: + self.service = service - return str(task_id or f"task-{uuid.uuid4().hex}"), str( - context_id or f"context-{uuid.uuid4().hex}" - ) + async def execute(self, context: RequestContext, event_queue: EventQueue) -> None: + """ + Execute one A2A request through the MADA orchestrator. + """ + try: + await self.service.ensure_started() + except A2AStartupError as exc: + configured_servers = ( + ", ".join((self.service.config.mcp_servers or {}).keys()) or "none" + ) + print( + "No MCP servers connected; returning A2A startup error. " + f"Configured MCP servers: {configured_servers}", + file=sys.stderr, + flush=True, + ) + raise RuntimeError(str(exc)) from exc + + message_text = _extract_message_text(context).strip() + if not message_text: + raise ValueError("A2A request must include a text message part") + + content = await self.service.collect_response(message_text) + await _enqueue_event(event_queue, new_text_message(content)) + + async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None: + """ + Report that MADA does not support cancelling in-flight orchestrator work. + """ + raise RuntimeError("A2A task cancellation is not supported") def create_a2a_app( @@ -411,11 +419,10 @@ def create_a2a_app( public_url: str, api_key: Optional[str] = None, bearer_token: Optional[str] = None, -) -> FastAPI: +) -> Starlette: """ - Build and return a FastAPI app backed by the configured MADA orchestrator. + Build and return an A2A SDK app backed by the configured MADA orchestrator. """ - _require_fastapi() service = MADAA2AService( config=config, public_url=public_url, @@ -424,7 +431,7 @@ def create_a2a_app( ) @asynccontextmanager - async def lifespan(app: FastAPI): + async def lifespan(app: Starlette): """ Attach the A2A service to app state and clean it up on shutdown. """ @@ -434,113 +441,34 @@ async def lifespan(app: FastAPI): finally: await service.shutdown() - app = FastAPI(title="MADA A2A API", lifespan=lifespan) - - @app.get("/health") - async def health() -> Dict[str, str]: + async def health(request: Request) -> JSONResponse: """ Report whether the A2A process is running and initialized. """ - return { - "status": "ok", - "orchestrator_initialized": "true" - if service.orchestrator is not None - else "false", - } - - async def get_agent_card() -> Dict[str, Any]: - """ - Return the MADA A2A agent card for discovery endpoints. - """ - return service.build_agent_card() - - app.get("/.well-known/agent-card.json")(get_agent_card) - app.get("/.well-known/agent.json")(get_agent_card) - app.get("/agent-card.json")(get_agent_card) - - async def handle_rpc( - body: Dict[str, Any], - authorization: Optional[str] = Header(default=None), - x_api_key: Optional[str] = Header(default=None), - ): - """ - Handle A2A JSON-RPC message requests. - """ - service.validate_api_key(authorization, x_api_key) - - request_id = body.get("id") - method = body.get("method") - params = body.get("params") or {} - if not isinstance(params, dict): - return _json_rpc_error(request_id, -32602, "'params' must be an object") - - if method not in {"message/send", "message/stream"}: - return _json_rpc_error(request_id, -32601, f"Unsupported method: {method}") - - message_text = _extract_message_text(params).strip() - if not message_text: - return _json_rpc_error( - request_id, - -32602, - "A2A request must include a text message part", - ) - - try: - await service.ensure_started() - except A2AStartupError as exc: - configured_servers = ( - ", ".join((service.config.mcp_servers or {}).keys()) or "none" - ) - print( - "No MCP servers connected; returning 503 for A2A request. " - f"Configured MCP servers: {configured_servers}", - file=sys.stderr, - flush=True, - ) - raise HTTPException(status_code=503, detail=str(exc)) from exc - - task_id, context_id = _ids_from_params(params) - - if method == "message/send": - content = await service.collect_response(message_text) - return { - "jsonrpc": "2.0", - "id": request_id, - "result": _build_task(task_id, context_id, content), - } - - async def event_stream() -> AsyncGenerator[str, None]: - """ - Yield server-sent events for streaming A2A responses. - """ - collected = [] - async for chunk in service.stream_response(message_text): - collected.append(chunk) - task = _build_task( - task_id, - context_id, - "".join(collected), - state="working", - ) - payload = {"jsonrpc": "2.0", "id": request_id, "result": task} - yield f"data: {json.dumps(payload)}\n\n" - - final = { - "jsonrpc": "2.0", - "id": request_id, - "result": _build_task( - task_id, - context_id, - "".join(collected), - state="completed", - ), + return JSONResponse( + { + "status": "ok", + "orchestrator_initialized": "true" + if service.orchestrator is not None + else "false", } - yield f"data: {json.dumps(final)}\n\n" + ) - return StreamingResponse(event_stream(), media_type="text/event-stream") + public_agent_card = ParseDict(service.build_agent_card(), AgentCard()) + request_handler = DefaultRequestHandler( + agent_executor=MADAA2AExecutor(service), + task_store=InMemoryTaskStore(), + agent_card=public_agent_card, + ) - app.post("/")(handle_rpc) - app.post("/a2a")(handle_rpc) + routes = [ + Route("/health", health, methods=["GET"]), + *create_agent_card_routes(public_agent_card), + *create_jsonrpc_routes(request_handler, "/"), + *create_jsonrpc_routes(request_handler, "/a2a"), + ] + app = Starlette(routes=routes, lifespan=lifespan) + app.add_middleware(A2AAuthMiddleware, service=service) return app @@ -554,9 +482,8 @@ def run_a2a( bearer_token: Optional[str] = None, ) -> None: """ - Launch the A2A FastAPI server. + Launch the A2A server. """ - _require_fastapi() card_url = public_url or f"http://{host}:{port}" app = create_a2a_app( config=config, diff --git a/src/mada/interfaces/cli/main.py b/src/mada/interfaces/cli/main.py index 2908764..1c340e0 100644 --- a/src/mada/interfaces/cli/main.py +++ b/src/mada/interfaces/cli/main.py @@ -291,11 +291,8 @@ async def run(self): f"\nWARNING: {len(eg.exceptions)} initialization error(s) occurred. Continuing with available agents..." ) except Exception as e: - print(f"\nWARNING: Initialization error: {e}") - print("Continuing with available agents...") - import traceback - - traceback.print_exc() + print(f"\nERROR: Initialization failed: {e}") + return print("\nChat with the agents (type 'quit' to exit)") print("-" * 50) diff --git a/src/mada/interfaces/gradio/utils.py b/src/mada/interfaces/gradio/utils.py index e0314b2..b6180ef 100644 --- a/src/mada/interfaces/gradio/utils.py +++ b/src/mada/interfaces/gradio/utils.py @@ -60,7 +60,7 @@ def create_agent_table( row = [ agent_name, "a2a", - agent_config.description or f"Remote A2A agent at {agent_config.url}", + "", "a2a", "", f"A2A endpoint: {agent_config.url}", diff --git a/tests/unit/core/test_config.py b/tests/unit/core/test_config.py index 8b492e3..22c1793 100644 --- a/tests/unit/core/test_config.py +++ b/tests/unit/core/test_config.py @@ -164,7 +164,6 @@ def test_load_a2a_agents_config_accepts_remote_agents(self): "optimizer": { "url": "https://optimizer.example/a2a", "card_url": "https://optimizer.example/.well-known/agent-card.json", - "description": "Remote optimizer", "timeout": 30, "api_key": "secret", "headers": {"x-trace": "enabled"}, @@ -176,7 +175,6 @@ def test_load_a2a_agents_config_accepts_remote_agents(self): "optimizer": RemoteA2AAgentConfig( url="https://optimizer.example/a2a", card_url="https://optimizer.example/.well-known/agent-card.json", - description="Remote optimizer", timeout=30, api_key="secret", headers={"x-trace": "enabled"}, diff --git a/tests/unit/test_entrypoints.py b/tests/unit/test_entrypoints.py index 4864132..7b21571 100644 --- a/tests/unit/test_entrypoints.py +++ b/tests/unit/test_entrypoints.py @@ -54,6 +54,7 @@ _run_openai_api_from_args, main, ) +from a2a.utils.constants import PROTOCOL_VERSION_1_0, VERSION_HEADER try: from fastapi.testclient import TestClient @@ -1001,10 +1002,12 @@ def test_agent_card_endpoint_returns_configured_metadata( assert response.status_code == 200 payload = response.json() - assert payload["protocolVersion"] == "1.0.0" assert payload["name"] == "MADA Test" assert payload["description"] == "Test A2A agent" - assert payload["url"] == "https://mada.example/a2a" + assert payload["supportedInterfaces"][0]["url"] == ( + "https://mada.example/a2a" + ) + assert payload["supportedInterfaces"][0]["protocolVersion"] == "1.0" assert payload["capabilities"]["streaming"] is True def test_agent_card_endpoint_can_serve_card_file( @@ -1017,11 +1020,16 @@ def test_agent_card_endpoint_can_serve_card_file( card_path.write_text( json.dumps( { - "protocolVersion": "1.0.0", "name": "FileBackedMADA", "description": "Loaded from a card file", - "url": "http://placeholder", "version": "1.0.0", + "supportedInterfaces": [ + { + "url": "http://placeholder", + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0", + } + ], "skills": [ { "id": "file-backed", @@ -1042,15 +1050,16 @@ def test_agent_card_endpoint_can_serve_card_file( ) payload = service.build_agent_card() - assert payload["protocolVersion"] == "1.0.0" assert payload["name"] == "FileBackedMADA" assert payload["description"] == "Loaded from a card file" - assert payload["url"] == "https://mada.example/a2a" - assert payload["supportsAuthenticatedExtendedCard"] is False + assert payload["supportedInterfaces"][0]["url"] == ( + "https://mada.example/a2a" + ) + assert payload["supportedInterfaces"][0]["protocolVersion"] == "1.0" def test_message_send_returns_a2a_task(self, create_dummy_config: Callable): """ - Test that JSON-RPC `message/send` returns a completed A2A task. + Test that JSON-RPC `SendMessage` returns a completed A2A task. """ config = create_dummy_config() @@ -1067,14 +1076,16 @@ def test_message_send_returns_a2a_task(self, create_dummy_config: Callable): with TestClient(app) as client: response = client.post( "/", + headers={VERSION_HEADER: PROTOCOL_VERSION_1_0}, json={ "jsonrpc": "2.0", "id": "req-1", - "method": "message/send", + "method": "SendMessage", "params": { "message": { - "role": "user", - "parts": [{"kind": "text", "text": "hello"}], + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"text": "hello"}], } }, }, @@ -1083,11 +1094,7 @@ def test_message_send_returns_a2a_task(self, create_dummy_config: Callable): assert response.status_code == 200 payload = response.json() assert payload["id"] == "req-1" - assert payload["result"]["status"]["state"] == "completed" - assert ( - payload["result"]["status"]["message"]["parts"][0]["text"] - == "hello from mada" - ) + assert payload["result"]["message"]["parts"][0]["text"] == "hello from mada" @pytest.mark.unit