From ad7b3d9a1e8ea69f44d5de0f390b11b07b0dab9a Mon Sep 17 00:00:00 2001 From: jer Date: Fri, 13 Jun 2025 05:47:11 +0000 Subject: [PATCH 01/23] wip: a2a --- a2a_client.py | 96 ++++++++++++++++++++++++++ a2a_server.py | 40 +++++++++++ pyproject.toml | 6 +- src/strands/multiagent/__init__.py | 9 +++ src/strands/multiagent/a2a/__init__.py | 5 ++ src/strands/multiagent/a2a/agent.py | 66 ++++++++++++++++++ src/strands/multiagent/a2a/executor.py | 36 ++++++++++ 7 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 a2a_client.py create mode 100644 a2a_server.py create mode 100644 src/strands/multiagent/__init__.py create mode 100644 src/strands/multiagent/a2a/__init__.py create mode 100644 src/strands/multiagent/a2a/agent.py create mode 100644 src/strands/multiagent/a2a/executor.py diff --git a/a2a_client.py b/a2a_client.py new file mode 100644 index 000000000..fbc26b762 --- /dev/null +++ b/a2a_client.py @@ -0,0 +1,96 @@ +import logging +from typing import Any +from uuid import uuid4 + +import httpx +from a2a.client import A2ACardResolver, A2AClient +from a2a.types import ( + AgentCard, + MessageSendParams, + SendMessageRequest, + SendStreamingMessageRequest, +) + + +async def main() -> None: + PUBLIC_AGENT_CARD_PATH = "/.well-known/agent.json" + EXTENDED_AGENT_CARD_PATH = "/agent/authenticatedExtendedCard" + + logging.basicConfig(level=logging.INFO) + logger = logging.getLogger(__name__) + + base_url = "http://localhost:9000" + + async with httpx.AsyncClient() as httpx_client: + # Initialize A2ACardResolver + resolver = A2ACardResolver( + httpx_client=httpx_client, + base_url=base_url, + ) + + # Fetch Public Agent Card and Initialize Client + final_agent_card_to_use: AgentCard | None = None + + try: + logger.info(f"Attempting to fetch public agent card from: {base_url}{PUBLIC_AGENT_CARD_PATH}") + _public_card = await resolver.get_agent_card() # Fetches from default public path + logger.info("Successfully fetched public agent card:") + logger.info(_public_card.model_dump_json(indent=2, exclude_none=True)) + final_agent_card_to_use = _public_card + logger.info("\nUsing PUBLIC agent card for client initialization (default).") + + if _public_card.supportsAuthenticatedExtendedCard: + try: + logger.info( + f"\nPublic card supports authenticated extended card. Attempting to fetch from: {base_url}{EXTENDED_AGENT_CARD_PATH}" + ) + auth_headers_dict = {"Authorization": "Bearer dummy-token-for-extended-card"} + _extended_card = await resolver.get_agent_card( + relative_card_path=EXTENDED_AGENT_CARD_PATH, + http_kwargs={"headers": auth_headers_dict}, + ) + logger.info("Successfully fetched authenticated extended agent card:") + logger.info(_extended_card.model_dump_json(indent=2, exclude_none=True)) + final_agent_card_to_use = _extended_card # Update to use the extended card + logger.info("\nUsing AUTHENTICATED EXTENDED agent card for client initialization.") + except Exception as e_extended: + logger.warning( + f"Failed to fetch extended agent card: {e_extended}. Will proceed with public card.", + exc_info=True, + ) + elif _public_card: # supportsAuthenticatedExtendedCard is False or None + logger.info("\nPublic card does not indicate support for an extended card. Using public card.") + + except Exception as e: + logger.exception(f"Critical error fetching public agent card: {e}", exc_info=True) + raise RuntimeError("Failed to fetch the public agent card. Cannot continue.") from e + + client = A2AClient(httpx_client=httpx_client, agent_card=final_agent_card_to_use) + logger.info("A2AClient initialized.") + + send_message_payload: dict[str, Any] = { + "message": { + "role": "user", + "parts": [{"kind": "text", "text": "how much is 10 USD in INR?"}], + "messageId": uuid4().hex, + }, + } + request = SendMessageRequest(id=str(uuid4()), params=MessageSendParams(**send_message_payload)) + + response = await client.send_message(request) + print(response.model_dump(mode="json", exclude_none=True)) + + streaming_request = SendStreamingMessageRequest( + id=str(uuid4()), params=MessageSendParams(**send_message_payload) + ) + + stream_response = client.send_message_streaming(streaming_request) + + async for chunk in stream_response: + print(chunk.model_dump(mode="json", exclude_none=True)) + + +if __name__ == "__main__": + import asyncio + + asyncio.run(main()) diff --git a/a2a_server.py b/a2a_server.py new file mode 100644 index 000000000..ed5235ff6 --- /dev/null +++ b/a2a_server.py @@ -0,0 +1,40 @@ +from a2a.types import ( + AgentCapabilities, + AgentCard, + AgentSkill, +) +from strands import Agent +from strands.multiagent.a2a import A2AAgent + +agent = Agent(model="us.anthropic.claude-3-haiku-20240307-v1:0") + + +skill = AgentSkill( + id="hello_world", + name="Returns hello world", + description="just returns hello world", + tags=["hello world"], + examples=["hi", "hello world"], +) + +extended_skill = AgentSkill( + id="super_hello_world", + name="Returns a SUPER Hello World", + description="A more enthusiastic greeting, only for authenticated users.", + tags=["hello world", "super", "extended"], + examples=["super hi", "give me a super hello"], +) + +public_agent_card = AgentCard( + name="Hello World Agent", + description="Just a hello world agent", + url="http://0.0.0.0:9000/", + version="1.0.0", + defaultInputModes=["text"], + defaultOutputModes=["text"], + capabilities=AgentCapabilities(streaming=True), + skills=[skill], +) + +agent = A2AAgent(agent=agent, agent_card=public_agent_card, skills=[skill]) +agent.serve() diff --git a/pyproject.toml b/pyproject.toml index 56c1a40e1..13e1872c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,6 +85,7 @@ a2a = [ "httpx>=0.28.1", "fastapi>=0.115.12", "starlette>=0.46.2", + "protobuf==6.31.1", ] [tool.hatch.version] @@ -116,7 +117,7 @@ lint-fix = [ [tool.hatch.envs.hatch-test] features = ["anthropic", "litellm", "llamaapi", "ollama", "openai", "otel"] -extra-dependencies = [ +extra-dependencies = "moto>=5.1.0,<6.0.0", "pytest>=8.0.0,<9.0.0", "pytest-asyncio>=0.26.0,<0.27.0", @@ -206,6 +207,9 @@ ignore_missing_imports = true line-length = 120 include = ["examples/**/*.py", "src/**/*.py", "tests/**/*.py", "tests-integ/**/*.py"] +[tool.ruff.format] +docstring-code-format = true + [tool.ruff.lint] select = [ "B", # flake8-bugbear diff --git a/src/strands/multiagent/__init__.py b/src/strands/multiagent/__init__.py new file mode 100644 index 000000000..4c5c2c751 --- /dev/null +++ b/src/strands/multiagent/__init__.py @@ -0,0 +1,9 @@ +"""Multiagent capabilities for Strands Agents. + +This module provides support for multiagent systems, including agent-to-agent +communication protocols and coordination mechanisms. +""" + +from . import a2a + +__all__ = ["a2a"] diff --git a/src/strands/multiagent/a2a/__init__.py b/src/strands/multiagent/a2a/__init__.py new file mode 100644 index 000000000..0cb57fd09 --- /dev/null +++ b/src/strands/multiagent/a2a/__init__.py @@ -0,0 +1,5 @@ +"""A2A Module.""" + +from .agent import A2AAgent + +__all__ = ["A2AAgent"] diff --git a/src/strands/multiagent/a2a/agent.py b/src/strands/multiagent/a2a/agent.py new file mode 100644 index 000000000..c397627d8 --- /dev/null +++ b/src/strands/multiagent/a2a/agent.py @@ -0,0 +1,66 @@ +"""A2A-compatible wrapper for Strands Agent. + +This module provides the A2AAgent class, which adapts a Strands Agent to the A2A protocol, +allowing it to be used in A2A-compatible systems. +""" + +import logging + +import httpx +import uvicorn +from a2a.server.apps import A2AStarletteApplication +from a2a.server.request_handlers import DefaultRequestHandler +from a2a.server.tasks import InMemoryTaskStore +from a2a.types import ( + AgentCard, + AgentSkill, +) + +from ...agent.agent import Agent as SAAgent +from .executor import StrandsA2AExecutor + +logger = logging.getLogger(__name__) + + +class A2AAgent: + """A2A-compatible wrapper for Strands Agent. + + This class adapts a Strands Agent to the A2A protocol, allowing it to be used + in A2A-compatible systems. + """ + + def __init__( + self, + agent: SAAgent | None, + agent_card: AgentCard | None, + skills: list[AgentSkill] | None, + version: str = "1.0.0", + host: str = "localhost", + port: int = 9000, + ): + """Initialize an A2A-compatible agent from a Strands agent. + + TODO: add args + + """ + self.host = host + self.port = port + self.agent = agent + self.agent_card = agent_card + self.skills = skills + self.executor = StrandsA2AExecutor(self.agent) + self.httpx_client = httpx.AsyncClient() + self.request_handler = DefaultRequestHandler( + agent_executor=self.executor, + task_store=InMemoryTaskStore(), + # push_notifier=a2a_tasks.InMemoryPushNotifier(self.httpx_client), + ) + + def to_starlette(self): + """TODO.""" + starlette_server = A2AStarletteApplication(agent_card=self.agent_card, http_handler=self.request_handler) + return starlette_server.build() + + def serve(self): + """TODO.""" + uvicorn.run(self.to_starlette(), host=self.host, port=self.port) diff --git a/src/strands/multiagent/a2a/executor.py b/src/strands/multiagent/a2a/executor.py new file mode 100644 index 000000000..45d739754 --- /dev/null +++ b/src/strands/multiagent/a2a/executor.py @@ -0,0 +1,36 @@ +"""A2A Module.""" + +from a2a.server.agent_execution import AgentExecutor, RequestContext +from a2a.server.events import EventQueue +from a2a.utils import new_agent_text_message + +from ...agent.agent import Agent as SAAAgent + + +class HelloWorldAgent: + """Hello World Agent.""" + + async def invoke(self) -> str: + """Hello World Agent.""" + return "Hello World" + + +class StrandsA2AExecutor(AgentExecutor): + """Test AgentProxy Implementation.""" + + def __init__(self, agent: SAAAgent): + """Hello World Agent.""" + self.agent = agent + + async def execute( + self, + context: RequestContext, + event_queue: EventQueue, + ) -> None: + """Hello World Agent.""" + result = self.agent("tell me something about love") + await event_queue.enqueue_event(new_agent_text_message(result)) + + async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None: + """Hello World Agent.""" + raise Exception("cancel not supported") From 10c9bb1726aef45a38ebe36235310d1c7c1c0f5e Mon Sep 17 00:00:00 2001 From: jer Date: Fri, 13 Jun 2025 18:17:18 +0000 Subject: [PATCH 02/23] wip: cleanup and wire up sync agent --- a2a_client.py | 17 ++--- a2a_server.py | 47 ++++---------- pyproject.toml | 1 + src/strands/multiagent/a2a/agent.py | 89 ++++++++++++++++++-------- src/strands/multiagent/a2a/executor.py | 34 +++++----- 5 files changed, 105 insertions(+), 83 deletions(-) diff --git a/a2a_client.py b/a2a_client.py index fbc26b762..e9f8b50ad 100644 --- a/a2a_client.py +++ b/a2a_client.py @@ -8,7 +8,6 @@ AgentCard, MessageSendParams, SendMessageRequest, - SendStreamingMessageRequest, ) @@ -78,16 +77,18 @@ async def main() -> None: request = SendMessageRequest(id=str(uuid4()), params=MessageSendParams(**send_message_payload)) response = await client.send_message(request) + print("Sync Response", end="\n\n") print(response.model_dump(mode="json", exclude_none=True)) - streaming_request = SendStreamingMessageRequest( - id=str(uuid4()), params=MessageSendParams(**send_message_payload) - ) - - stream_response = client.send_message_streaming(streaming_request) + # streaming_request = SendStreamingMessageRequest( + # id=str(uuid4()), params=MessageSendParams(**send_message_payload) + # ) + # + # stream_response = client.send_message_streaming(streaming_request) - async for chunk in stream_response: - print(chunk.model_dump(mode="json", exclude_none=True)) + # print("Streaming Response", end="\n\n") + # async for chunk in stream_response: + # print(chunk.model_dump(mode="json", exclude_none=True)) if __name__ == "__main__": diff --git a/a2a_server.py b/a2a_server.py index ed5235ff6..4e55b3aeb 100644 --- a/a2a_server.py +++ b/a2a_server.py @@ -1,40 +1,19 @@ -from a2a.types import ( - AgentCapabilities, - AgentCard, - AgentSkill, -) +import logging +import sys + from strands import Agent from strands.multiagent.a2a import A2AAgent -agent = Agent(model="us.anthropic.claude-3-haiku-20240307-v1:0") - - -skill = AgentSkill( - id="hello_world", - name="Returns hello world", - description="just returns hello world", - tags=["hello world"], - examples=["hi", "hello world"], +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + handlers=[logging.StreamHandler(sys.stdout)], + force=True, ) -extended_skill = AgentSkill( - id="super_hello_world", - name="Returns a SUPER Hello World", - description="A more enthusiastic greeting, only for authenticated users.", - tags=["hello world", "super", "extended"], - examples=["super hi", "give me a super hello"], -) - -public_agent_card = AgentCard( - name="Hello World Agent", - description="Just a hello world agent", - url="http://0.0.0.0:9000/", - version="1.0.0", - defaultInputModes=["text"], - defaultOutputModes=["text"], - capabilities=AgentCapabilities(streaming=True), - skills=[skill], -) +# Log that we're starting +logging.info("Starting A2A server with root logger") -agent = A2AAgent(agent=agent, agent_card=public_agent_card, skills=[skill]) -agent.serve() +strands_agent = Agent(model="us.anthropic.claude-3-haiku-20240307-v1:0", callback_handler=None) +strands_a2a_agent = A2AAgent(agent=strands_agent, name="Hello World Agent", description="Just a hello world agent") +strands_a2a_agent.serve() diff --git a/pyproject.toml b/pyproject.toml index 13e1872c9..bfb2d5aa2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,6 +86,7 @@ a2a = [ "fastapi>=0.115.12", "starlette>=0.46.2", "protobuf==6.31.1", + "fastapi>=0.115.12", ] [tool.hatch.version] diff --git a/src/strands/multiagent/a2a/agent.py b/src/strands/multiagent/a2a/agent.py index c397627d8..bd2588c3f 100644 --- a/src/strands/multiagent/a2a/agent.py +++ b/src/strands/multiagent/a2a/agent.py @@ -5,21 +5,20 @@ """ import logging +from typing import Any, Literal -import httpx import uvicorn -from a2a.server.apps import A2AStarletteApplication +from a2a.server.apps import A2AFastAPIApplication, A2AStarletteApplication from a2a.server.request_handlers import DefaultRequestHandler from a2a.server.tasks import InMemoryTaskStore -from a2a.types import ( - AgentCard, - AgentSkill, -) +from a2a.types import AgentCapabilities, AgentCard, AgentSkill +from fastapi import FastAPI +from starlette.applications import Starlette from ...agent.agent import Agent as SAAgent from .executor import StrandsA2AExecutor -logger = logging.getLogger(__name__) +log = logging.getLogger(__name__) class A2AAgent: @@ -31,36 +30,76 @@ class A2AAgent: def __init__( self, - agent: SAAgent | None, - agent_card: AgentCard | None, - skills: list[AgentSkill] | None, - version: str = "1.0.0", + agent: SAAgent, + *, + # AgentCard + name: str, + description: str, host: str = "localhost", port: int = 9000, + version: str = "0.0.1", ): """Initialize an A2A-compatible agent from a Strands agent. TODO: add args """ + self.name = name + self.description = description self.host = host self.port = port - self.agent = agent - self.agent_card = agent_card - self.skills = skills - self.executor = StrandsA2AExecutor(self.agent) - self.httpx_client = httpx.AsyncClient() + self.http_url = f"http://{self.host}:{self.port}/" + self.version = version + self.strands_agent = agent + self.capabilities = AgentCapabilities() self.request_handler = DefaultRequestHandler( - agent_executor=self.executor, + agent_executor=StrandsA2AExecutor(self.strands_agent), task_store=InMemoryTaskStore(), - # push_notifier=a2a_tasks.InMemoryPushNotifier(self.httpx_client), ) - def to_starlette(self): - """TODO.""" - starlette_server = A2AStarletteApplication(agent_card=self.agent_card, http_handler=self.request_handler) - return starlette_server.build() + @property + def public_agent_card(self) -> AgentCard: + """AgentCard.""" + return AgentCard( + name=self.name, + description=self.description, + url=self.http_url, + version=self.version, + skills=self.agent_skills, + defaultInputModes=["text"], + defaultOutputModes=["text"], + capabilities=self.capabilities, + ) + + @property + def agent_skills(self) -> list[AgentSkill]: + """AgentSkills.""" + return [] + + def to_starlette_app(self) -> Starlette: + """Startlette app.""" + starlette_app = A2AStarletteApplication(agent_card=self.public_agent_card, http_handler=self.request_handler) + return starlette_app.build() - def serve(self): - """TODO.""" - uvicorn.run(self.to_starlette(), host=self.host, port=self.port) + def to_fastapi_app(self) -> FastAPI: + """FastAPI app.""" + fastapi_app = A2AFastAPIApplication(agent_card=self.public_agent_card, http_handler=self.request_handler) + return fastapi_app.build() + + def serve(self, app_type: Literal["fastapi", "starlette"] = "starlette", **kwargs: Any) -> None: + """Start the A2A server with the specified application type. + + Args: + app_type: The type of application to serve, either "fastapi" or "starlette". + **kwargs: Additional keyword arguments to pass to uvicorn.run. + """ + try: + log.info("Starting Strands agent A2A server...") + if app_type == "fastapi": + uvicorn.run(self.to_fastapi_app(), host=self.host, port=self.port, **kwargs) + else: + uvicorn.run(self.to_starlette_app(), host=self.host, port=self.port, **kwargs) + except KeyboardInterrupt: + log.warning("Server shutdown requested (KeyboardInterrupt).") + finally: + log.info("Strands agent A2A server has shutdown.") diff --git a/src/strands/multiagent/a2a/executor.py b/src/strands/multiagent/a2a/executor.py index 45d739754..e165a5f1c 100644 --- a/src/strands/multiagent/a2a/executor.py +++ b/src/strands/multiagent/a2a/executor.py @@ -1,25 +1,24 @@ """A2A Module.""" +import logging + from a2a.server.agent_execution import AgentExecutor, RequestContext from a2a.server.events import EventQueue +from a2a.types import UnsupportedOperationError from a2a.utils import new_agent_text_message +from a2a.utils.errors import ServerError -from ...agent.agent import Agent as SAAAgent - - -class HelloWorldAgent: - """Hello World Agent.""" +from ...agent.agent import Agent as SAAgent +from ...agent.agent_result import AgentResult as SAAgentResult - async def invoke(self) -> str: - """Hello World Agent.""" - return "Hello World" +log = logging.getLogger(__name__) class StrandsA2AExecutor(AgentExecutor): - """Test AgentProxy Implementation.""" + """StrandsA2AExecutor.""" - def __init__(self, agent: SAAAgent): - """Hello World Agent.""" + def __init__(self, agent: SAAgent): + """StrandsA2AExecutor constructor.""" self.agent = agent async def execute( @@ -27,10 +26,13 @@ async def execute( context: RequestContext, event_queue: EventQueue, ) -> None: - """Hello World Agent.""" - result = self.agent("tell me something about love") - await event_queue.enqueue_event(new_agent_text_message(result)) + """Execute.""" + result: SAAgentResult = self.agent(context.get_user_input()) + if result.message and "content" in result.message: + for content_block in result.message["content"]: + if "text" in content_block: + await event_queue.enqueue_event(new_agent_text_message(content_block["text"])) async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None: - """Hello World Agent.""" - raise Exception("cancel not supported") + """Cancel.""" + raise ServerError(error=UnsupportedOperationError()) From 5fe7f9a347f1903060e355d7e330240f7f1997a6 Mon Sep 17 00:00:00 2001 From: jer Date: Fri, 13 Jun 2025 18:35:22 +0000 Subject: [PATCH 03/23] wip: cleanup a2a client --- a2a_client.py | 63 ++++++++++----------------------------------------- 1 file changed, 12 insertions(+), 51 deletions(-) diff --git a/a2a_client.py b/a2a_client.py index e9f8b50ad..e8683aa3c 100644 --- a/a2a_client.py +++ b/a2a_client.py @@ -10,61 +10,33 @@ SendMessageRequest, ) +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) +PUBLIC_AGENT_CARD_PATH = "/.well-known/agent.json" +BASE_URL = "http://localhost:9000" -async def main() -> None: - PUBLIC_AGENT_CARD_PATH = "/.well-known/agent.json" - EXTENDED_AGENT_CARD_PATH = "/agent/authenticatedExtendedCard" - - logging.basicConfig(level=logging.INFO) - logger = logging.getLogger(__name__) - - base_url = "http://localhost:9000" +async def main() -> None: async with httpx.AsyncClient() as httpx_client: # Initialize A2ACardResolver resolver = A2ACardResolver( httpx_client=httpx_client, - base_url=base_url, + base_url=BASE_URL, ) # Fetch Public Agent Card and Initialize Client - final_agent_card_to_use: AgentCard | None = None + agent_card: AgentCard | None = None try: - logger.info(f"Attempting to fetch public agent card from: {base_url}{PUBLIC_AGENT_CARD_PATH}") - _public_card = await resolver.get_agent_card() # Fetches from default public path + logger.info("Attempting to fetch public agent card from: {} {}", BASE_URL, PUBLIC_AGENT_CARD_PATH) + agent_card = await resolver.get_agent_card() # Fetches from default public path logger.info("Successfully fetched public agent card:") - logger.info(_public_card.model_dump_json(indent=2, exclude_none=True)) - final_agent_card_to_use = _public_card - logger.info("\nUsing PUBLIC agent card for client initialization (default).") - - if _public_card.supportsAuthenticatedExtendedCard: - try: - logger.info( - f"\nPublic card supports authenticated extended card. Attempting to fetch from: {base_url}{EXTENDED_AGENT_CARD_PATH}" - ) - auth_headers_dict = {"Authorization": "Bearer dummy-token-for-extended-card"} - _extended_card = await resolver.get_agent_card( - relative_card_path=EXTENDED_AGENT_CARD_PATH, - http_kwargs={"headers": auth_headers_dict}, - ) - logger.info("Successfully fetched authenticated extended agent card:") - logger.info(_extended_card.model_dump_json(indent=2, exclude_none=True)) - final_agent_card_to_use = _extended_card # Update to use the extended card - logger.info("\nUsing AUTHENTICATED EXTENDED agent card for client initialization.") - except Exception as e_extended: - logger.warning( - f"Failed to fetch extended agent card: {e_extended}. Will proceed with public card.", - exc_info=True, - ) - elif _public_card: # supportsAuthenticatedExtendedCard is False or None - logger.info("\nPublic card does not indicate support for an extended card. Using public card.") - + logger.info(agent_card.model_dump_json(indent=2, exclude_none=True)) except Exception as e: - logger.exception(f"Critical error fetching public agent card: {e}", exc_info=True) + logger.exception("Critical error fetching public agent card") raise RuntimeError("Failed to fetch the public agent card. Cannot continue.") from e - client = A2AClient(httpx_client=httpx_client, agent_card=final_agent_card_to_use) + client = A2AClient(httpx_client=httpx_client, agent_card=agent_card) logger.info("A2AClient initialized.") send_message_payload: dict[str, Any] = { @@ -77,19 +49,8 @@ async def main() -> None: request = SendMessageRequest(id=str(uuid4()), params=MessageSendParams(**send_message_payload)) response = await client.send_message(request) - print("Sync Response", end="\n\n") print(response.model_dump(mode="json", exclude_none=True)) - # streaming_request = SendStreamingMessageRequest( - # id=str(uuid4()), params=MessageSendParams(**send_message_payload) - # ) - # - # stream_response = client.send_message_streaming(streaming_request) - - # print("Streaming Response", end="\n\n") - # async for chunk in stream_response: - # print(chunk.model_dump(mode="json", exclude_none=True)) - if __name__ == "__main__": import asyncio From 8ae3c397e107f789357773863abb4199207baceb Mon Sep 17 00:00:00 2001 From: jer Date: Fri, 13 Jun 2025 18:47:46 +0000 Subject: [PATCH 04/23] fix: otel dependency confilct with a2a --- pyproject.toml | 1 - src/strands/telemetry/tracer.py | 2 ++ 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index bfb2d5aa2..13e1872c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,7 +86,6 @@ a2a = [ "fastapi>=0.115.12", "starlette>=0.46.2", "protobuf==6.31.1", - "fastapi>=0.115.12", ] [tool.hatch.version] diff --git a/src/strands/telemetry/tracer.py b/src/strands/telemetry/tracer.py index e9a37a4aa..de9e243dc 100644 --- a/src/strands/telemetry/tracer.py +++ b/src/strands/telemetry/tracer.py @@ -195,6 +195,8 @@ def _initialize_tracer(self) -> None: # Add OTLP exporter if endpoint is provided if HAS_OTEL_EXPORTER_MODULE and self.otlp_endpoint and self.tracer_provider: try: + from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter + # Ensure endpoint has the right format endpoint = self.otlp_endpoint if not endpoint.endswith("/v1/traces") and not endpoint.endswith("/traces"): From b87b1edfafbf31473fa7976ed0ea6e800357ce0e Mon Sep 17 00:00:00 2001 From: jer Date: Fri, 13 Jun 2025 19:03:09 +0000 Subject: [PATCH 05/23] docs: multiagent + a2a --- src/strands/multiagent/__init__.py | 6 ++- src/strands/multiagent/a2a/__init__.py | 11 ++++- src/strands/multiagent/a2a/agent.py | 58 ++++++++++++++++++++------ src/strands/multiagent/a2a/executor.py | 39 ++++++++++++++--- 4 files changed, 95 insertions(+), 19 deletions(-) diff --git a/src/strands/multiagent/__init__.py b/src/strands/multiagent/__init__.py index 4c5c2c751..1cef1425c 100644 --- a/src/strands/multiagent/__init__.py +++ b/src/strands/multiagent/__init__.py @@ -1,7 +1,11 @@ """Multiagent capabilities for Strands Agents. -This module provides support for multiagent systems, including agent-to-agent +This module provides support for multiagent systems, including agent-to-agent (A2A) communication protocols and coordination mechanisms. + +Submodules: + a2a: Implementation of the Agent-to-Agent (A2A) protocol, which enables + standardized communication between agents. """ from . import a2a diff --git a/src/strands/multiagent/a2a/__init__.py b/src/strands/multiagent/a2a/__init__.py index 0cb57fd09..c54256187 100644 --- a/src/strands/multiagent/a2a/__init__.py +++ b/src/strands/multiagent/a2a/__init__.py @@ -1,4 +1,13 @@ -"""A2A Module.""" +"""Agent-to-Agent (A2A) communication protocol implementation for Strands Agents. + +This module provides classes and utilities for enabling Strands Agents to communicate +with other agents using the Agent-to-Agent (A2A) protocol. + +Docs: https://google-a2a.github.io/A2A/latest/ + +Classes: + A2AAgent: A wrapper that adapts a Strands Agent to be A2A-compatible. +""" from .agent import A2AAgent diff --git a/src/strands/multiagent/a2a/agent.py b/src/strands/multiagent/a2a/agent.py index bd2588c3f..56ba10160 100644 --- a/src/strands/multiagent/a2a/agent.py +++ b/src/strands/multiagent/a2a/agent.py @@ -22,17 +22,12 @@ class A2AAgent: - """A2A-compatible wrapper for Strands Agent. - - This class adapts a Strands Agent to the A2A protocol, allowing it to be used - in A2A-compatible systems. - """ + """A2A-compatible wrapper for Strands Agent.""" def __init__( self, agent: SAAgent, *, - # AgentCard name: str, description: str, host: str = "localhost", @@ -41,8 +36,13 @@ def __init__( ): """Initialize an A2A-compatible agent from a Strands agent. - TODO: add args - + Args: + agent: The Strands Agent to wrap with A2A compatibility. + name: The name of the agent, used in the AgentCard. + description: A description of the agent's capabilities, used in the AgentCard. + host: The hostname or IP address to bind the A2A server to. Defaults to "localhost". + port: The port to bind the A2A server to. Defaults to 9000. + version: The version of the agent. Defaults to "0.0.1". """ self.name = name self.description = description @@ -59,7 +59,15 @@ def __init__( @property def public_agent_card(self) -> AgentCard: - """AgentCard.""" + """Get the public AgentCard for this agent. + + The AgentCard contains metadata about the agent, including its name, + description, URL, version, skills, and capabilities. This information + is used by other agents and systems to discover and interact with this agent. + + Returns: + AgentCard: The public agent card containing metadata about this agent. + """ return AgentCard( name=self.name, description=self.description, @@ -73,24 +81,50 @@ def public_agent_card(self) -> AgentCard: @property def agent_skills(self) -> list[AgentSkill]: - """AgentSkills.""" + """Get the list of skills this agent provides. + + Skills represent specific capabilities that the agent can perform. + Strands agent tools are adapted to A2A skills. + + Returns: + list[AgentSkill]: A list of skills this agent provides. + """ return [] def to_starlette_app(self) -> Starlette: - """Startlette app.""" + """Create a Starlette application for serving this agent via HTTP. + + This method creates a Starlette application that can be used to serve + the agent via HTTP using the A2A protocol. + + Returns: + Starlette: A Starlette application configured to serve this agent. + """ starlette_app = A2AStarletteApplication(agent_card=self.public_agent_card, http_handler=self.request_handler) return starlette_app.build() def to_fastapi_app(self) -> FastAPI: - """FastAPI app.""" + """Create a FastAPI application for serving this agent via HTTP. + + This method creates a FastAPI application that can be used to serve + the agent via HTTP using the A2A protocol. + + Returns: + FastAPI: A FastAPI application configured to serve this agent. + """ fastapi_app = A2AFastAPIApplication(agent_card=self.public_agent_card, http_handler=self.request_handler) return fastapi_app.build() def serve(self, app_type: Literal["fastapi", "starlette"] = "starlette", **kwargs: Any) -> None: """Start the A2A server with the specified application type. + This method starts an HTTP server that exposes the agent via the A2A protocol. + The server can be implemented using either FastAPI or Starlette, depending on + the specified app_type. + Args: app_type: The type of application to serve, either "fastapi" or "starlette". + Defaults to "starlette". **kwargs: Additional keyword arguments to pass to uvicorn.run. """ try: diff --git a/src/strands/multiagent/a2a/executor.py b/src/strands/multiagent/a2a/executor.py index e165a5f1c..b7a7af091 100644 --- a/src/strands/multiagent/a2a/executor.py +++ b/src/strands/multiagent/a2a/executor.py @@ -1,4 +1,9 @@ -"""A2A Module.""" +"""Strands Agent executor for the A2A protocol. + +This module provides the StrandsA2AExecutor class, which adapts a Strands Agent +to be used as an executor in the A2A protocol. It handles the execution of agent +requests and the conversion of Strands Agent responses to A2A events. +""" import logging @@ -15,10 +20,14 @@ class StrandsA2AExecutor(AgentExecutor): - """StrandsA2AExecutor.""" + """Executor that adapts a Strands Agent to the A2A protocol.""" def __init__(self, agent: SAAgent): - """StrandsA2AExecutor constructor.""" + """Initialize a StrandsA2AExecutor. + + Args: + agent: The Strands Agent to adapt to the A2A protocol. + """ self.agent = agent async def execute( @@ -26,7 +35,15 @@ async def execute( context: RequestContext, event_queue: EventQueue, ) -> None: - """Execute.""" + """Execute a request using the Strands Agent and send the response as A2A events. + + This method executes the user's input using the Strands Agent and converts + the agent's response to A2A events, which are then sent to the event queue. + + Args: + context: The A2A request context, containing the user's input and other metadata. + event_queue: The A2A event queue, used to send response events. + """ result: SAAgentResult = self.agent(context.get_user_input()) if result.message and "content" in result.message: for content_block in result.message["content"]: @@ -34,5 +51,17 @@ async def execute( await event_queue.enqueue_event(new_agent_text_message(content_block["text"])) async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None: - """Cancel.""" + """Cancel an ongoing execution. + + This method is called when a request is cancelled. Currently, cancellation + is not supported, so this method raises an UnsupportedOperationError. + + Args: + context: The A2A request context. + event_queue: The A2A event queue. + + Raises: + ServerError: Always raised with an UnsupportedOperationError, as cancellation + is not currently supported. + """ raise ServerError(error=UnsupportedOperationError()) From 23c31f9079fa3de00ceceed474cf518e74a88417 Mon Sep 17 00:00:00 2001 From: jer Date: Fri, 13 Jun 2025 21:00:00 +0000 Subject: [PATCH 06/23] tests: unit tests --- pyproject.toml | 1 + tests/multiagent/__init__.py | 1 + tests/multiagent/a2a/__init__.py | 1 + tests/multiagent/a2a/conftest.py | 12 ++++ tests/multiagent/a2a/test_agent.py | 70 +++++++++++++++++++ tests/multiagent/a2a/test_executor.py | 99 +++++++++++++++++++++++++++ 6 files changed, 184 insertions(+) create mode 100644 tests/multiagent/__init__.py create mode 100644 tests/multiagent/a2a/__init__.py create mode 100644 tests/multiagent/a2a/conftest.py create mode 100644 tests/multiagent/a2a/test_agent.py create mode 100644 tests/multiagent/a2a/test_executor.py diff --git a/pyproject.toml b/pyproject.toml index 13e1872c9..462b672c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -232,6 +232,7 @@ testpaths = [ "tests" ] asyncio_default_fixture_loop_scope = "function" +norecursedirs = ["multiagent/a2a"] [tool.coverage.run] branch = true diff --git a/tests/multiagent/__init__.py b/tests/multiagent/__init__.py new file mode 100644 index 000000000..b43bae53d --- /dev/null +++ b/tests/multiagent/__init__.py @@ -0,0 +1 @@ +"""Tests for the multiagent module.""" diff --git a/tests/multiagent/a2a/__init__.py b/tests/multiagent/a2a/__init__.py new file mode 100644 index 000000000..ea8e5990b --- /dev/null +++ b/tests/multiagent/a2a/__init__.py @@ -0,0 +1 @@ +"""Tests for the A2A implementation.""" diff --git a/tests/multiagent/a2a/conftest.py b/tests/multiagent/a2a/conftest.py new file mode 100644 index 000000000..0500db93d --- /dev/null +++ b/tests/multiagent/a2a/conftest.py @@ -0,0 +1,12 @@ +"""Pytest configuration for A2A tests.""" + +from unittest.mock import patch + +import pytest + + +@pytest.fixture(autouse=True) +def mock_uvicorn(): + """Mock uvicorn.run to prevent actual server startup during tests.""" + with patch("uvicorn.run") as mock: + yield mock diff --git a/tests/multiagent/a2a/test_agent.py b/tests/multiagent/a2a/test_agent.py new file mode 100644 index 000000000..4580bc1bb --- /dev/null +++ b/tests/multiagent/a2a/test_agent.py @@ -0,0 +1,70 @@ +"""Tests for the A2AAgent class.""" + +import pytest +from a2a.types import AgentCard +from fastapi import FastAPI +from starlette.applications import Starlette + +from strands import Agent +from strands.multiagent.a2a import A2AAgent + + +@pytest.fixture +def strands_agent(): + """Create a Strands agent for testing.""" + return Agent() + + +@pytest.fixture +def a2a_agent(strands_agent): + """Create an A2A agent for testing.""" + return A2AAgent( + agent=strands_agent, + name="Test Agent", + description="A test agent", + host="localhost", + port=9000, + ) + + +def test_a2a_agent_initialization(a2a_agent, strands_agent): + """Test that the A2AAgent initializes correctly.""" + assert a2a_agent.name == "Test Agent" + assert a2a_agent.description == "A test agent" + assert a2a_agent.host == "localhost" + assert a2a_agent.port == 9000 + assert a2a_agent.http_url == "http://localhost:9000/" + assert a2a_agent.version == "0.0.1" + assert a2a_agent.strands_agent == strands_agent + + +def test_public_agent_card(a2a_agent): + """Test that the public agent card is created correctly.""" + card = a2a_agent.public_agent_card + assert isinstance(card, AgentCard) + assert card.name == "Test Agent" + assert card.description == "A test agent" + assert card.url == "http://localhost:9000/" + assert card.version == "0.0.1" + assert card.defaultInputModes == ["text"] + assert card.defaultOutputModes == ["text"] + assert len(card.skills) == 0 # No skills defined yet + + +def test_agent_skills(a2a_agent): + """Test that agent skills are returned correctly.""" + skills = a2a_agent.agent_skills + assert isinstance(skills, list) + assert len(skills) == 0 # No skills defined yet + + +def test_to_starlette_app(a2a_agent): + """Test that a Starlette app is created correctly.""" + app = a2a_agent.to_starlette_app() + assert isinstance(app, Starlette) + + +def test_to_fastapi_app(a2a_agent): + """Test that a FastAPI app is created correctly.""" + app = a2a_agent.to_fastapi_app() + assert isinstance(app, FastAPI) diff --git a/tests/multiagent/a2a/test_executor.py b/tests/multiagent/a2a/test_executor.py new file mode 100644 index 000000000..7e7c8ba50 --- /dev/null +++ b/tests/multiagent/a2a/test_executor.py @@ -0,0 +1,99 @@ +"""Tests for the StrandsA2AExecutor class.""" + +import pytest +from a2a.types import UnsupportedOperationError +from a2a.utils.errors import ServerError + +from strands.agent.agent_result import AgentResult +from strands.multiagent.a2a.executor import StrandsA2AExecutor +from strands.telemetry.metrics import EventLoopMetrics + + +class MockAgent: + """Mock Strands Agent for testing.""" + + def __init__(self, response_text="Test response"): + """Initialize the mock agent with a predefined response.""" + self.response_text = response_text + self.called_with = None + + def __call__(self, input_text): + """Mock the agent call method.""" + self.called_with = input_text + return AgentResult( + stop_reason="end_turn", + message={"content": [{"text": self.response_text}]}, + metrics=EventLoopMetrics(), + state={}, + ) + + +class MockEventQueue: + """Mock EventQueue for testing.""" + + def __init__(self): + """Initialize the mock event queue.""" + self.events = [] + + async def enqueue_event(self, event): + """Mock the enqueue_event method.""" + self.events.append(event) + return None + + +class MockRequestContext: + """Mock RequestContext for testing.""" + + def __init__(self, user_input="Test input"): + """Initialize the mock request context.""" + self.user_input = user_input + + def get_user_input(self): + """Mock the get_user_input method.""" + return self.user_input + + +@pytest.fixture +def mock_agent(): + """Create a mock Strands agent for testing.""" + return MockAgent() + + +@pytest.fixture +def executor(mock_agent): + """Create a StrandsA2AExecutor for testing.""" + return StrandsA2AExecutor(mock_agent) + + +@pytest.fixture +def event_queue(): + """Create a mock event queue for testing.""" + return MockEventQueue() + + +@pytest.fixture +def request_context(): + """Create a mock request context for testing.""" + return MockRequestContext() + + +@pytest.mark.asyncio +async def test_execute(executor, event_queue, request_context): + """Test that the execute method works correctly.""" + await executor.execute(request_context, event_queue) + + # Check that the agent was called with the correct input + assert executor.agent.called_with == "Test input" + + # Check that an event was enqueued (we can't check the content directly) + assert len(event_queue.events) == 1 + + +@pytest.mark.asyncio +async def test_cancel(executor, event_queue, request_context): + """Test that the cancel method raises the expected error.""" + with pytest.raises(ServerError) as excinfo: + await executor.cancel(request_context, event_queue) + + # Check that the error contains an UnsupportedOperationError + assert isinstance(excinfo.value.error, UnsupportedOperationError) From 25c331c9d53b4142bbf9607e8ad6803c94235d46 Mon Sep 17 00:00:00 2001 From: jer Date: Mon, 16 Jun 2025 14:41:36 +0000 Subject: [PATCH 07/23] build(a2a): add a2a deps and mitigate otel conflict --- pyproject.toml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 462b672c3..f7b3a57f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -207,9 +207,6 @@ ignore_missing_imports = true line-length = 120 include = ["examples/**/*.py", "src/**/*.py", "tests/**/*.py", "tests-integ/**/*.py"] -[tool.ruff.format] -docstring-code-format = true - [tool.ruff.lint] select = [ "B", # flake8-bugbear @@ -232,7 +229,6 @@ testpaths = [ "tests" ] asyncio_default_fixture_loop_scope = "function" -norecursedirs = ["multiagent/a2a"] [tool.coverage.run] branch = true From 82f251eb9402c88d2a65fcd9566a255039c4aaed Mon Sep 17 00:00:00 2001 From: jer Date: Mon, 16 Jun 2025 16:33:24 +0000 Subject: [PATCH 08/23] fix: proper imoprt erorr handling --- src/strands/telemetry/tracer.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/strands/telemetry/tracer.py b/src/strands/telemetry/tracer.py index de9e243dc..e9a37a4aa 100644 --- a/src/strands/telemetry/tracer.py +++ b/src/strands/telemetry/tracer.py @@ -195,8 +195,6 @@ def _initialize_tracer(self) -> None: # Add OTLP exporter if endpoint is provided if HAS_OTEL_EXPORTER_MODULE and self.otlp_endpoint and self.tracer_provider: try: - from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter - # Ensure endpoint has the right format endpoint = self.otlp_endpoint if not endpoint.endswith("/v1/traces") and not endpoint.endswith("/traces"): From f0d505ea3b740e287f6f79a11e275674112bbd41 Mon Sep 17 00:00:00 2001 From: jer Date: Tue, 17 Jun 2025 14:40:39 +0000 Subject: [PATCH 09/23] fix: ignore a2a tests due to dependency conflict --- pyproject.toml | 5 ++++- tests/multiagent/a2a/conftest.py | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f7b3a57f3..d3484c36e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -117,7 +117,7 @@ lint-fix = [ [tool.hatch.envs.hatch-test] features = ["anthropic", "litellm", "llamaapi", "ollama", "openai", "otel"] -extra-dependencies = +extra-dependencies = [ "moto>=5.1.0,<6.0.0", "pytest>=8.0.0,<9.0.0", "pytest-asyncio>=0.26.0,<0.27.0", @@ -229,6 +229,9 @@ testpaths = [ "tests" ] asyncio_default_fixture_loop_scope = "function" +norecursedirs = [ + "tests/multiagent/a2a" +] [tool.coverage.run] branch = true diff --git a/tests/multiagent/a2a/conftest.py b/tests/multiagent/a2a/conftest.py index 0500db93d..8f67a44ee 100644 --- a/tests/multiagent/a2a/conftest.py +++ b/tests/multiagent/a2a/conftest.py @@ -4,6 +4,9 @@ import pytest +# Mark all tests in this directory to be skipped by default +pytestmark = pytest.mark.skip(reason="a2a tests are excluded") + @pytest.fixture(autouse=True) def mock_uvicorn(): From 3ed915ba8571a61f90d1aefe75c29e4566c34b0f Mon Sep 17 00:00:00 2001 From: jer Date: Tue, 17 Jun 2025 14:53:15 +0000 Subject: [PATCH 10/23] fix: ignore a2a from static analysis --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index d3484c36e..21e14446a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -198,6 +198,7 @@ warn_no_return = true warn_unreachable = true follow_untyped_imports = true ignore_missing_imports = false +exclude = ["/multiagent/a2a"] [[tool.mypy.overrides]] module = "litellm" From a522a3fb1aad3d7ef967ba797f49dd3be22b4564 Mon Sep 17 00:00:00 2001 From: jer Date: Tue, 17 Jun 2025 21:03:39 +0000 Subject: [PATCH 11/23] chore: code cleanup + unit tests --- pyproject.toml | 16 +-- src/strands/agent/agent.py | 9 ++ src/strands/multiagent/a2a/agent.py | 33 +++-- tests/multiagent/a2a/__init__.py | 2 +- tests/multiagent/a2a/conftest.py | 44 +++++-- tests/multiagent/a2a/test_agent.py | 169 ++++++++++++++++++++------ tests/multiagent/a2a/test_executor.py | 141 +++++++++++---------- 7 files changed, 283 insertions(+), 131 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 21e14446a..1207c4eb8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -116,7 +116,7 @@ lint-fix = [ ] [tool.hatch.envs.hatch-test] -features = ["anthropic", "litellm", "llamaapi", "ollama", "openai", "otel"] +features = ["anthropic", "litellm", "llamaapi", "ollama", "openai", "a2a"] extra-dependencies = [ "moto>=5.1.0,<6.0.0", "pytest>=8.0.0,<9.0.0", @@ -134,15 +134,9 @@ extra-args = [ dev-mode = true features = ["dev", "docs", "anthropic", "litellm", "llamaapi", "ollama", "otel"] -[tool.hatch.envs.a2a] -dev-mode = true -features = ["dev", "docs", "anthropic", "litellm", "llamaapi", "ollama", "a2a"] - - [[tool.hatch.envs.hatch-test.matrix]] python = ["3.13", "3.12", "3.11", "3.10"] - [tool.hatch.envs.hatch-test.scripts] run = [ "pytest{env:HATCH_TEST_ARGS:} {args}" @@ -151,6 +145,10 @@ run-cov = [ "pytest{env:HATCH_TEST_ARGS:} --cov --cov-config=pyproject.toml {args}" ] +[tool.hatch.envs.a2a] +dev-mode = true +features = ["dev", "docs", "anthropic", "litellm", "llamaapi", "ollama", "a2a"] + cov-combine = [] cov-report = [] @@ -198,7 +196,6 @@ warn_no_return = true warn_unreachable = true follow_untyped_imports = true ignore_missing_imports = false -exclude = ["/multiagent/a2a"] [[tool.mypy.overrides]] module = "litellm" @@ -230,9 +227,6 @@ testpaths = [ "tests" ] asyncio_default_fixture_loop_scope = "function" -norecursedirs = [ - "tests/multiagent/a2a" -] [tool.coverage.run] branch = true diff --git a/src/strands/agent/agent.py b/src/strands/agent/agent.py index 56f5b92e7..714cca0e1 100644 --- a/src/strands/agent/agent.py +++ b/src/strands/agent/agent.py @@ -216,6 +216,9 @@ def __init__( record_direct_tool_call: bool = True, load_tools_from_directory: bool = True, trace_attributes: Optional[Mapping[str, AttributeValue]] = None, + *, + name: str | None = None, + description: str | None = None, ): """Initialize the Agent with the specified configuration. @@ -248,6 +251,10 @@ def __init__( load_tools_from_directory: Whether to load and automatically reload tools in the `./tools/` directory. Defaults to True. trace_attributes: Custom trace attributes to apply to the agent's trace span. + name: name of the Agent + Defaults to empty string. + description: description of what the Agent does + Defaults to empty string. Raises: ValueError: If max_parallel_tools is less than 1. @@ -310,6 +317,8 @@ def __init__( self.trace_span: Optional[trace.Span] = None self.tool_caller = Agent.ToolCaller(self) + self.name = name + self.description = description @property def tool(self) -> ToolCaller: diff --git a/src/strands/multiagent/a2a/agent.py b/src/strands/multiagent/a2a/agent.py index 56ba10160..e2933853d 100644 --- a/src/strands/multiagent/a2a/agent.py +++ b/src/strands/multiagent/a2a/agent.py @@ -28,9 +28,8 @@ def __init__( self, agent: SAAgent, *, - name: str, - description: str, - host: str = "localhost", + # AgentCard + host: str = "0.0.0", port: int = 9000, version: str = "0.0.1", ): @@ -44,13 +43,14 @@ def __init__( port: The port to bind the A2A server to. Defaults to 9000. version: The version of the agent. Defaults to "0.0.1". """ - self.name = name - self.description = description self.host = host self.port = port self.http_url = f"http://{self.host}:{self.port}/" self.version = version self.strands_agent = agent + self.name = self.strands_agent.name + self.description = self.strands_agent.description + # TODO: enable configurable capabilities and request handler self.capabilities = AgentCapabilities() self.request_handler = DefaultRequestHandler( agent_executor=StrandsA2AExecutor(self.strands_agent), @@ -67,7 +67,15 @@ def public_agent_card(self) -> AgentCard: Returns: AgentCard: The public agent card containing metadata about this agent. + + Raises: + ValueError: If name or description is None or empty. """ + if not self.name: + raise ValueError("A2A agent name cannot be None or empty") + if not self.description: + raise ValueError("A2A agent description cannot be None or empty") + return AgentCard( name=self.name, description=self.description, @@ -89,6 +97,7 @@ def agent_skills(self) -> list[AgentSkill]: Returns: list[AgentSkill]: A list of skills this agent provides. """ + # TODO: translate Strands tools (native & MCP) to skills return [] def to_starlette_app(self) -> Starlette: @@ -100,8 +109,7 @@ def to_starlette_app(self) -> Starlette: Returns: Starlette: A Starlette application configured to serve this agent. """ - starlette_app = A2AStarletteApplication(agent_card=self.public_agent_card, http_handler=self.request_handler) - return starlette_app.build() + return A2AStarletteApplication(agent_card=self.public_agent_card, http_handler=self.request_handler).build() def to_fastapi_app(self) -> FastAPI: """Create a FastAPI application for serving this agent via HTTP. @@ -112,8 +120,7 @@ def to_fastapi_app(self) -> FastAPI: Returns: FastAPI: A FastAPI application configured to serve this agent. """ - fastapi_app = A2AFastAPIApplication(agent_card=self.public_agent_card, http_handler=self.request_handler) - return fastapi_app.build() + return A2AFastAPIApplication(agent_card=self.public_agent_card, http_handler=self.request_handler).build() def serve(self, app_type: Literal["fastapi", "starlette"] = "starlette", **kwargs: Any) -> None: """Start the A2A server with the specified application type. @@ -128,12 +135,14 @@ def serve(self, app_type: Literal["fastapi", "starlette"] = "starlette", **kwarg **kwargs: Additional keyword arguments to pass to uvicorn.run. """ try: - log.info("Starting Strands agent A2A server...") + log.info("Starting Strands A2A server...") if app_type == "fastapi": uvicorn.run(self.to_fastapi_app(), host=self.host, port=self.port, **kwargs) else: uvicorn.run(self.to_starlette_app(), host=self.host, port=self.port, **kwargs) except KeyboardInterrupt: - log.warning("Server shutdown requested (KeyboardInterrupt).") + log.warning("Strands A2A server shutdown requested (KeyboardInterrupt).") + except Exception: + log.exception("Strands A2A server encountered exception.") finally: - log.info("Strands agent A2A server has shutdown.") + log.info("Strands A2A server has shutdown.") diff --git a/tests/multiagent/a2a/__init__.py b/tests/multiagent/a2a/__init__.py index ea8e5990b..eb5487d92 100644 --- a/tests/multiagent/a2a/__init__.py +++ b/tests/multiagent/a2a/__init__.py @@ -1 +1 @@ -"""Tests for the A2A implementation.""" +"""Tests for the A2A module.""" diff --git a/tests/multiagent/a2a/conftest.py b/tests/multiagent/a2a/conftest.py index 8f67a44ee..558a45948 100644 --- a/tests/multiagent/a2a/conftest.py +++ b/tests/multiagent/a2a/conftest.py @@ -1,15 +1,41 @@ -"""Pytest configuration for A2A tests.""" +"""Common fixtures for A2A module tests.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, MagicMock import pytest +from a2a.server.agent_execution import RequestContext +from a2a.server.events import EventQueue -# Mark all tests in this directory to be skipped by default -pytestmark = pytest.mark.skip(reason="a2a tests are excluded") +from strands.agent.agent import Agent as SAAgent +from strands.agent.agent_result import AgentResult as SAAgentResult -@pytest.fixture(autouse=True) -def mock_uvicorn(): - """Mock uvicorn.run to prevent actual server startup during tests.""" - with patch("uvicorn.run") as mock: - yield mock +@pytest.fixture +def mock_strands_agent(): + """Create a mock Strands Agent for testing.""" + agent = MagicMock(spec=SAAgent) + agent.name = "Test Agent" + agent.description = "A test agent for unit testing" + + # Setup default response + mock_result = MagicMock(spec=SAAgentResult) + mock_result.message = {"content": [{"text": "Test response"}]} + agent.return_value = mock_result + + return agent + + +@pytest.fixture +def mock_request_context(): + """Create a mock RequestContext for testing.""" + context = MagicMock(spec=RequestContext) + context.get_user_input.return_value = "Test input" + return context + + +@pytest.fixture +def mock_event_queue(): + """Create a mock EventQueue for testing.""" + queue = MagicMock(spec=EventQueue) + queue.enqueue_event = AsyncMock() + return queue diff --git a/tests/multiagent/a2a/test_agent.py b/tests/multiagent/a2a/test_agent.py index 4580bc1bb..5558c2af7 100644 --- a/tests/multiagent/a2a/test_agent.py +++ b/tests/multiagent/a2a/test_agent.py @@ -1,70 +1,165 @@ """Tests for the A2AAgent class.""" +from unittest.mock import patch + import pytest -from a2a.types import AgentCard +from a2a.types import AgentCapabilities, AgentCard from fastapi import FastAPI from starlette.applications import Starlette -from strands import Agent -from strands.multiagent.a2a import A2AAgent +from strands.multiagent.a2a.agent import A2AAgent + +def test_a2a_agent_initialization(mock_strands_agent): + """Test that A2AAgent initializes correctly with default values.""" + a2a_agent = A2AAgent(mock_strands_agent) -@pytest.fixture -def strands_agent(): - """Create a Strands agent for testing.""" - return Agent() + assert a2a_agent.strands_agent == mock_strands_agent + assert a2a_agent.name == "Test Agent" + assert a2a_agent.description == "A test agent for unit testing" + assert a2a_agent.host == "0.0.0" + assert a2a_agent.port == 9000 + assert a2a_agent.http_url == "http://0.0.0:9000/" + assert a2a_agent.version == "0.0.1" + assert isinstance(a2a_agent.capabilities, AgentCapabilities) -@pytest.fixture -def a2a_agent(strands_agent): - """Create an A2A agent for testing.""" - return A2AAgent( - agent=strands_agent, - name="Test Agent", - description="A test agent", - host="localhost", - port=9000, +def test_a2a_agent_initialization_with_custom_values(mock_strands_agent): + """Test that A2AAgent initializes correctly with custom values.""" + a2a_agent = A2AAgent( + mock_strands_agent, + host="127.0.0.1", + port=8080, + version="1.0.0", ) + assert a2a_agent.host == "127.0.0.1" + assert a2a_agent.port == 8080 + assert a2a_agent.http_url == "http://127.0.0.1:8080/" + assert a2a_agent.version == "1.0.0" -def test_a2a_agent_initialization(a2a_agent, strands_agent): - """Test that the A2AAgent initializes correctly.""" - assert a2a_agent.name == "Test Agent" - assert a2a_agent.description == "A test agent" - assert a2a_agent.host == "localhost" - assert a2a_agent.port == 9000 - assert a2a_agent.http_url == "http://localhost:9000/" - assert a2a_agent.version == "0.0.1" - assert a2a_agent.strands_agent == strands_agent +def test_public_agent_card(mock_strands_agent): + """Test that public_agent_card returns a valid AgentCard.""" + a2a_agent = A2AAgent(mock_strands_agent) -def test_public_agent_card(a2a_agent): - """Test that the public agent card is created correctly.""" card = a2a_agent.public_agent_card + assert isinstance(card, AgentCard) assert card.name == "Test Agent" - assert card.description == "A test agent" - assert card.url == "http://localhost:9000/" + assert card.description == "A test agent for unit testing" + assert card.url == "http://0.0.0:9000/" assert card.version == "0.0.1" assert card.defaultInputModes == ["text"] assert card.defaultOutputModes == ["text"] - assert len(card.skills) == 0 # No skills defined yet + assert card.skills == [] + assert card.capabilities == a2a_agent.capabilities + + +def test_public_agent_card_with_missing_name(mock_strands_agent): + """Test that public_agent_card raises ValueError when name is missing.""" + mock_strands_agent.name = "" + a2a_agent = A2AAgent(mock_strands_agent) + with pytest.raises(ValueError, match="A2A agent name cannot be None or empty"): + _ = a2a_agent.public_agent_card + + +def test_public_agent_card_with_missing_description(mock_strands_agent): + """Test that public_agent_card raises ValueError when description is missing.""" + mock_strands_agent.description = "" + a2a_agent = A2AAgent(mock_strands_agent) + + with pytest.raises(ValueError, match="A2A agent description cannot be None or empty"): + _ = a2a_agent.public_agent_card + + +def test_agent_skills(mock_strands_agent): + """Test that agent_skills returns an empty list (current implementation).""" + a2a_agent = A2AAgent(mock_strands_agent) -def test_agent_skills(a2a_agent): - """Test that agent skills are returned correctly.""" skills = a2a_agent.agent_skills + assert isinstance(skills, list) - assert len(skills) == 0 # No skills defined yet + assert len(skills) == 0 + +def test_to_starlette_app(mock_strands_agent): + """Test that to_starlette_app returns a Starlette application.""" + a2a_agent = A2AAgent(mock_strands_agent) -def test_to_starlette_app(a2a_agent): - """Test that a Starlette app is created correctly.""" app = a2a_agent.to_starlette_app() + assert isinstance(app, Starlette) -def test_to_fastapi_app(a2a_agent): - """Test that a FastAPI app is created correctly.""" +def test_to_fastapi_app(mock_strands_agent): + """Test that to_fastapi_app returns a FastAPI application.""" + a2a_agent = A2AAgent(mock_strands_agent) + app = a2a_agent.to_fastapi_app() + assert isinstance(app, FastAPI) + + +@patch("uvicorn.run") +def test_serve_with_starlette(mock_run, mock_strands_agent): + """Test that serve starts a Starlette server by default.""" + a2a_agent = A2AAgent(mock_strands_agent) + + a2a_agent.serve() + + mock_run.assert_called_once() + args, kwargs = mock_run.call_args + assert isinstance(args[0], Starlette) + assert kwargs["host"] == "0.0.0" + assert kwargs["port"] == 9000 + + +@patch("uvicorn.run") +def test_serve_with_fastapi(mock_run, mock_strands_agent): + """Test that serve starts a FastAPI server when specified.""" + a2a_agent = A2AAgent(mock_strands_agent) + + a2a_agent.serve(app_type="fastapi") + + mock_run.assert_called_once() + args, kwargs = mock_run.call_args + assert isinstance(args[0], FastAPI) + assert kwargs["host"] == "0.0.0" + assert kwargs["port"] == 9000 + + +@patch("uvicorn.run") +def test_serve_with_custom_kwargs(mock_run, mock_strands_agent): + """Test that serve passes additional kwargs to uvicorn.run.""" + a2a_agent = A2AAgent(mock_strands_agent) + + a2a_agent.serve(log_level="debug", reload=True) + + mock_run.assert_called_once() + _, kwargs = mock_run.call_args + assert kwargs["log_level"] == "debug" + assert kwargs["reload"] is True + + +@patch("uvicorn.run", side_effect=KeyboardInterrupt) +def test_serve_handles_keyboard_interrupt(mock_run, mock_strands_agent, caplog): + """Test that serve handles KeyboardInterrupt gracefully.""" + a2a_agent = A2AAgent(mock_strands_agent) + + a2a_agent.serve() + + assert "Strands A2A server shutdown requested (KeyboardInterrupt)" in caplog.text + assert "Strands A2A server has shutdown" in caplog.text + + +@patch("uvicorn.run", side_effect=Exception("Test exception")) +def test_serve_handles_general_exception(mock_run, mock_strands_agent, caplog): + """Test that serve handles general exceptions gracefully.""" + a2a_agent = A2AAgent(mock_strands_agent) + + a2a_agent.serve() + + assert "Strands A2A server encountered exception" in caplog.text + assert "Strands A2A server has shutdown" in caplog.text diff --git a/tests/multiagent/a2a/test_executor.py b/tests/multiagent/a2a/test_executor.py index 7e7c8ba50..2ac9bed91 100644 --- a/tests/multiagent/a2a/test_executor.py +++ b/tests/multiagent/a2a/test_executor.py @@ -1,99 +1,118 @@ """Tests for the StrandsA2AExecutor class.""" +from unittest.mock import MagicMock + import pytest from a2a.types import UnsupportedOperationError from a2a.utils.errors import ServerError -from strands.agent.agent_result import AgentResult +from strands.agent.agent_result import AgentResult as SAAgentResult from strands.multiagent.a2a.executor import StrandsA2AExecutor -from strands.telemetry.metrics import EventLoopMetrics -class MockAgent: - """Mock Strands Agent for testing.""" +def test_executor_initialization(mock_strands_agent): + """Test that StrandsA2AExecutor initializes correctly.""" + executor = StrandsA2AExecutor(mock_strands_agent) - def __init__(self, response_text="Test response"): - """Initialize the mock agent with a predefined response.""" - self.response_text = response_text - self.called_with = None + assert executor.agent == mock_strands_agent - def __call__(self, input_text): - """Mock the agent call method.""" - self.called_with = input_text - return AgentResult( - stop_reason="end_turn", - message={"content": [{"text": self.response_text}]}, - metrics=EventLoopMetrics(), - state={}, - ) +@pytest.mark.asyncio +async def test_execute_with_text_response(mock_strands_agent, mock_request_context, mock_event_queue): + """Test that execute processes text responses correctly.""" + # Setup mock agent response + mock_result = MagicMock(spec=SAAgentResult) + mock_result.message = {"content": [{"text": "Test response"}]} + mock_strands_agent.return_value = mock_result -class MockEventQueue: - """Mock EventQueue for testing.""" + # Create executor and call execute + executor = StrandsA2AExecutor(mock_strands_agent) + await executor.execute(mock_request_context, mock_event_queue) - def __init__(self): - """Initialize the mock event queue.""" - self.events = [] + # Verify agent was called with correct input + mock_strands_agent.assert_called_once_with("Test input") - async def enqueue_event(self, event): - """Mock the enqueue_event method.""" - self.events.append(event) - return None + # Verify event was enqueued + mock_event_queue.enqueue_event.assert_called_once() + args, _ = mock_event_queue.enqueue_event.call_args + event = args[0] + assert event.parts[0].root.text == "Test response" -class MockRequestContext: - """Mock RequestContext for testing.""" +@pytest.mark.asyncio +async def test_execute_with_multiple_text_blocks(mock_strands_agent, mock_request_context, mock_event_queue): + """Test that execute processes multiple text blocks correctly.""" + # Setup mock agent response with multiple text blocks + mock_result = MagicMock(spec=SAAgentResult) + mock_result.message = {"content": [{"text": "First response"}, {"text": "Second response"}]} + mock_strands_agent.return_value = mock_result - def __init__(self, user_input="Test input"): - """Initialize the mock request context.""" - self.user_input = user_input + # Create executor and call execute + executor = StrandsA2AExecutor(mock_strands_agent) + await executor.execute(mock_request_context, mock_event_queue) - def get_user_input(self): - """Mock the get_user_input method.""" - return self.user_input + # Verify agent was called with correct input + mock_strands_agent.assert_called_once_with("Test input") + # Verify events were enqueued + assert mock_event_queue.enqueue_event.call_count == 2 -@pytest.fixture -def mock_agent(): - """Create a mock Strands agent for testing.""" - return MockAgent() + # Check first event + args1, _ = mock_event_queue.enqueue_event.call_args_list[0] + event1 = args1[0] + assert event1.parts[0].root.text == "First response" + # Check second event + args2, _ = mock_event_queue.enqueue_event.call_args_list[1] + event2 = args2[0] + assert event2.parts[0].root.text == "Second response" -@pytest.fixture -def executor(mock_agent): - """Create a StrandsA2AExecutor for testing.""" - return StrandsA2AExecutor(mock_agent) +@pytest.mark.asyncio +async def test_execute_with_empty_response(mock_strands_agent, mock_request_context, mock_event_queue): + """Test that execute handles empty responses correctly.""" + # Setup mock agent response with empty content + mock_result = MagicMock(spec=SAAgentResult) + mock_result.message = {"content": []} + mock_strands_agent.return_value = mock_result -@pytest.fixture -def event_queue(): - """Create a mock event queue for testing.""" - return MockEventQueue() + # Create executor and call execute + executor = StrandsA2AExecutor(mock_strands_agent) + await executor.execute(mock_request_context, mock_event_queue) + # Verify agent was called with correct input + mock_strands_agent.assert_called_once_with("Test input") -@pytest.fixture -def request_context(): - """Create a mock request context for testing.""" - return MockRequestContext() + # Verify no events were enqueued + mock_event_queue.enqueue_event.assert_not_called() @pytest.mark.asyncio -async def test_execute(executor, event_queue, request_context): - """Test that the execute method works correctly.""" - await executor.execute(request_context, event_queue) +async def test_execute_with_no_message(mock_strands_agent, mock_request_context, mock_event_queue): + """Test that execute handles responses with no message correctly.""" + # Setup mock agent response with no message + mock_result = MagicMock(spec=SAAgentResult) + mock_result.message = None + mock_strands_agent.return_value = mock_result + + # Create executor and call execute + executor = StrandsA2AExecutor(mock_strands_agent) + await executor.execute(mock_request_context, mock_event_queue) - # Check that the agent was called with the correct input - assert executor.agent.called_with == "Test input" + # Verify agent was called with correct input + mock_strands_agent.assert_called_once_with("Test input") - # Check that an event was enqueued (we can't check the content directly) - assert len(event_queue.events) == 1 + # Verify no events were enqueued + mock_event_queue.enqueue_event.assert_not_called() @pytest.mark.asyncio -async def test_cancel(executor, event_queue, request_context): - """Test that the cancel method raises the expected error.""" +async def test_cancel_raises_unsupported_operation_error(mock_strands_agent, mock_request_context, mock_event_queue): + """Test that cancel raises UnsupportedOperationError.""" + executor = StrandsA2AExecutor(mock_strands_agent) + with pytest.raises(ServerError) as excinfo: - await executor.cancel(request_context, event_queue) + await executor.cancel(mock_request_context, mock_event_queue) - # Check that the error contains an UnsupportedOperationError + # Verify the error is a ServerError containing an UnsupportedOperationError assert isinstance(excinfo.value.error, UnsupportedOperationError) From 9d981f053bcb4684d75e4398d6456ea5b7a1754c Mon Sep 17 00:00:00 2001 From: jer Date: Tue, 17 Jun 2025 21:45:02 +0000 Subject: [PATCH 12/23] fix: static analysis --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 1207c4eb8..9dc57be27 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -93,7 +93,7 @@ a2a = [ source = "vcs" [tool.hatch.envs.hatch-static-analysis] -features = ["anthropic", "litellm", "llamaapi", "ollama", "openai", "otel"] +features = ["anthropic", "litellm", "llamaapi", "ollama", "openai", "a2a"] dependencies = [ "mypy>=1.15.0,<2.0.0", "ruff>=0.11.6,<0.12.0", From 8f2561ea0ef434236003409f1acb61847de73583 Mon Sep 17 00:00:00 2001 From: jer Date: Wed, 18 Jun 2025 16:13:46 +0000 Subject: [PATCH 13/23] build: address build errors --- pyproject.toml | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9dc57be27..0909440c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -109,14 +109,14 @@ format-fix = [ ] lint-check = [ "ruff check", - "mypy -p src" + "mypy -p src --exclude src/multiagent/a2a" ] lint-fix = [ "ruff check --fix" ] [tool.hatch.envs.hatch-test] -features = ["anthropic", "litellm", "llamaapi", "ollama", "openai", "a2a"] +features = ["anthropic", "litellm", "llamaapi", "ollama", "openai", "otel"] extra-dependencies = [ "moto>=5.1.0,<6.0.0", "pytest>=8.0.0,<9.0.0", @@ -139,16 +139,24 @@ python = ["3.13", "3.12", "3.11", "3.10"] [tool.hatch.envs.hatch-test.scripts] run = [ - "pytest{env:HATCH_TEST_ARGS:} {args}" + "pytest{env:HATCH_TEST_ARGS:} {args} --ignore=tests/multiagent/a2a" ] run-cov = [ - "pytest{env:HATCH_TEST_ARGS:} --cov --cov-config=pyproject.toml {args}" + "pytest{env:HATCH_TEST_ARGS:} --cov --cov-config=pyproject.toml {args} --ignore=tests/multiagent/a2a" ] [tool.hatch.envs.a2a] dev-mode = true features = ["dev", "docs", "anthropic", "litellm", "llamaapi", "ollama", "a2a"] +[tool.hatch.envs.a2a.scripts] +run = [ + "pytest{env:HATCH_TEST_ARGS:} tests/multiagent/a2a {args}" +] +run-cov = [ + "pytest{env:HATCH_TEST_ARGS:} tests/multiagent/a2a --cov --cov-config=pyproject.toml {args}" +] + cov-combine = [] cov-report = [] @@ -180,6 +188,9 @@ prepare = [ "hatch fmt --formatter", "hatch test --all" ] +test-a2a = [ + "hatch -e a2a run run {args}" +] [tool.mypy] python_version = "3.10" From f7c0f830d4f3503997352888c5df59534bbec5d7 Mon Sep 17 00:00:00 2001 From: jer Date: Wed, 18 Jun 2025 16:15:55 +0000 Subject: [PATCH 14/23] build: add otel deps to static analysis env --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0909440c9..6116e69cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -93,7 +93,7 @@ a2a = [ source = "vcs" [tool.hatch.envs.hatch-static-analysis] -features = ["anthropic", "litellm", "llamaapi", "ollama", "openai", "a2a"] +features = ["anthropic", "litellm", "llamaapi", "ollama", "openai", "otel"] dependencies = [ "mypy>=1.15.0,<2.0.0", "ruff>=0.11.6,<0.12.0", From 045897fdd301784eb70c0835a3888a4deb50e125 Mon Sep 17 00:00:00 2001 From: jer Date: Wed, 18 Jun 2025 16:24:18 +0000 Subject: [PATCH 15/23] build: add otel deps to static analysis env --- pyproject.toml | 23 +++++++++++------------ src/strands/agent/agent.py | 4 ++-- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6116e69cb..9a67fa115 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,7 +85,6 @@ a2a = [ "httpx>=0.28.1", "fastapi>=0.115.12", "starlette>=0.46.2", - "protobuf==6.31.1", ] [tool.hatch.version] @@ -134,17 +133,6 @@ extra-args = [ dev-mode = true features = ["dev", "docs", "anthropic", "litellm", "llamaapi", "ollama", "otel"] -[[tool.hatch.envs.hatch-test.matrix]] -python = ["3.13", "3.12", "3.11", "3.10"] - -[tool.hatch.envs.hatch-test.scripts] -run = [ - "pytest{env:HATCH_TEST_ARGS:} {args} --ignore=tests/multiagent/a2a" -] -run-cov = [ - "pytest{env:HATCH_TEST_ARGS:} --cov --cov-config=pyproject.toml {args} --ignore=tests/multiagent/a2a" -] - [tool.hatch.envs.a2a] dev-mode = true features = ["dev", "docs", "anthropic", "litellm", "llamaapi", "ollama", "a2a"] @@ -157,6 +145,17 @@ run-cov = [ "pytest{env:HATCH_TEST_ARGS:} tests/multiagent/a2a --cov --cov-config=pyproject.toml {args}" ] +[[tool.hatch.envs.hatch-test.matrix]] +python = ["3.13", "3.12", "3.11", "3.10"] + +[tool.hatch.envs.hatch-test.scripts] +run = [ + "pytest{env:HATCH_TEST_ARGS:} {args} --ignore=tests/multiagent/a2a" +] +run-cov = [ + "pytest{env:HATCH_TEST_ARGS:} --cov --cov-config=pyproject.toml {args} --ignore=tests/multiagent/a2a" +] + cov-combine = [] cov-report = [] diff --git a/src/strands/agent/agent.py b/src/strands/agent/agent.py index 714cca0e1..8ffcea151 100644 --- a/src/strands/agent/agent.py +++ b/src/strands/agent/agent.py @@ -252,9 +252,9 @@ def __init__( Defaults to True. trace_attributes: Custom trace attributes to apply to the agent's trace span. name: name of the Agent - Defaults to empty string. + Defaults to None. description: description of what the Agent does - Defaults to empty string. + Defaults to None. Raises: ValueError: If max_parallel_tools is less than 1. From 6f1543e5244cc8a56f44faa057b020030ca380b8 Mon Sep 17 00:00:00 2001 From: jer Date: Wed, 18 Jun 2025 16:27:47 +0000 Subject: [PATCH 16/23] fix: ignore a2a for mypy --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9a67fa115..46af22d6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -108,7 +108,7 @@ format-fix = [ ] lint-check = [ "ruff check", - "mypy -p src --exclude src/multiagent/a2a" + "mypy -p src --exclude src/strands/multiagent/a2a" ] lint-fix = [ "ruff check --fix" From 9a3f5e70ec26f4bb3b620b3c118dc8e1d026b614 Mon Sep 17 00:00:00 2001 From: jer Date: Wed, 18 Jun 2025 16:32:08 +0000 Subject: [PATCH 17/23] fix: add lint and static analysis for a2a --- pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 46af22d6a..d28dfc426 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -144,6 +144,10 @@ run = [ run-cov = [ "pytest{env:HATCH_TEST_ARGS:} tests/multiagent/a2a --cov --cov-config=pyproject.toml {args}" ] +lint-check = [ + "ruff check", + "mypy -p src/strands/multiagent/a2a" +] [[tool.hatch.envs.hatch-test.matrix]] python = ["3.13", "3.12", "3.11", "3.10"] From 67ccda048b11475e3aaf99ba5e2922d9886cfb60 Mon Sep 17 00:00:00 2001 From: jer Date: Wed, 18 Jun 2025 16:39:56 +0000 Subject: [PATCH 18/23] fix: mypy ignore --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d28dfc426..c13a56d2e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -108,7 +108,7 @@ format-fix = [ ] lint-check = [ "ruff check", - "mypy -p src --exclude src/strands/multiagent/a2a" + "mypy -p src --exclude 'src/strands/multiagent/a2a'" ] lint-fix = [ "ruff check --fix" From 84d1a780a001a305c9b30e5ba7244b14a1497fe2 Mon Sep 17 00:00:00 2001 From: jer Date: Wed, 18 Jun 2025 16:49:27 +0000 Subject: [PATCH 19/23] fix: mypy exclude --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c13a56d2e..ad73e0855 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -108,7 +108,7 @@ format-fix = [ ] lint-check = [ "ruff check", - "mypy -p src --exclude 'src/strands/multiagent/a2a'" + "mypy -p src --exclude /multiagent/a2a/.*" ] lint-fix = [ "ruff check --fix" From a727d14a105f7d9f257ca97ca70795a8e794fa96 Mon Sep 17 00:00:00 2001 From: jer Date: Wed, 18 Jun 2025 16:51:13 +0000 Subject: [PATCH 20/23] fix: mypy exclude --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ad73e0855..097839706 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -108,7 +108,7 @@ format-fix = [ ] lint-check = [ "ruff check", - "mypy -p src --exclude /multiagent/a2a/.*" + "mypy -p src --exclude src/strands/multiagent/a2a/.*" ] lint-fix = [ "ruff check --fix" From f3010543b8badf202e0f1b50026de394e567b61f Mon Sep 17 00:00:00 2001 From: jer Date: Wed, 18 Jun 2025 16:52:46 +0000 Subject: [PATCH 21/23] fix: mypy exclude --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 097839706..08536d79f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -108,7 +108,7 @@ format-fix = [ ] lint-check = [ "ruff check", - "mypy -p src --exclude src/strands/multiagent/a2a/.*" + "mypy -p src --exclude src/strands/multiagent" ] lint-fix = [ "ruff check --fix" From b930c09671d4712b14b656901c1119da5ced0e78 Mon Sep 17 00:00:00 2001 From: jer Date: Wed, 18 Jun 2025 16:58:02 +0000 Subject: [PATCH 22/23] chore: remove samples --- a2a_client.py | 58 --------------------------------------------------- a2a_server.py | 19 ----------------- 2 files changed, 77 deletions(-) delete mode 100644 a2a_client.py delete mode 100644 a2a_server.py diff --git a/a2a_client.py b/a2a_client.py deleted file mode 100644 index e8683aa3c..000000000 --- a/a2a_client.py +++ /dev/null @@ -1,58 +0,0 @@ -import logging -from typing import Any -from uuid import uuid4 - -import httpx -from a2a.client import A2ACardResolver, A2AClient -from a2a.types import ( - AgentCard, - MessageSendParams, - SendMessageRequest, -) - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) -PUBLIC_AGENT_CARD_PATH = "/.well-known/agent.json" -BASE_URL = "http://localhost:9000" - - -async def main() -> None: - async with httpx.AsyncClient() as httpx_client: - # Initialize A2ACardResolver - resolver = A2ACardResolver( - httpx_client=httpx_client, - base_url=BASE_URL, - ) - - # Fetch Public Agent Card and Initialize Client - agent_card: AgentCard | None = None - - try: - logger.info("Attempting to fetch public agent card from: {} {}", BASE_URL, PUBLIC_AGENT_CARD_PATH) - agent_card = await resolver.get_agent_card() # Fetches from default public path - logger.info("Successfully fetched public agent card:") - logger.info(agent_card.model_dump_json(indent=2, exclude_none=True)) - except Exception as e: - logger.exception("Critical error fetching public agent card") - raise RuntimeError("Failed to fetch the public agent card. Cannot continue.") from e - - client = A2AClient(httpx_client=httpx_client, agent_card=agent_card) - logger.info("A2AClient initialized.") - - send_message_payload: dict[str, Any] = { - "message": { - "role": "user", - "parts": [{"kind": "text", "text": "how much is 10 USD in INR?"}], - "messageId": uuid4().hex, - }, - } - request = SendMessageRequest(id=str(uuid4()), params=MessageSendParams(**send_message_payload)) - - response = await client.send_message(request) - print(response.model_dump(mode="json", exclude_none=True)) - - -if __name__ == "__main__": - import asyncio - - asyncio.run(main()) diff --git a/a2a_server.py b/a2a_server.py deleted file mode 100644 index 4e55b3aeb..000000000 --- a/a2a_server.py +++ /dev/null @@ -1,19 +0,0 @@ -import logging -import sys - -from strands import Agent -from strands.multiagent.a2a import A2AAgent - -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", - handlers=[logging.StreamHandler(sys.stdout)], - force=True, -) - -# Log that we're starting -logging.info("Starting A2A server with root logger") - -strands_agent = Agent(model="us.anthropic.claude-3-haiku-20240307-v1:0", callback_handler=None) -strands_a2a_agent = A2AAgent(agent=strands_agent, name="Hello World Agent", description="Just a hello world agent") -strands_a2a_agent.serve() From b6e08495deb75f599a28f856c41aca5c5ce5fdc0 Mon Sep 17 00:00:00 2001 From: jer Date: Thu, 19 Jun 2025 18:03:56 +0000 Subject: [PATCH 23/23] fix: address pr comments --- pyproject.toml | 4 ++++ src/strands/agent/agent.py | 4 ++-- src/strands/multiagent/a2a/agent.py | 15 ++++++++------- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 08536d79f..81e865c5f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -108,6 +108,7 @@ format-fix = [ ] lint-check = [ "ruff check", + # excluding due to A2A and OTEL http exporter dependency conflict "mypy -p src --exclude src/strands/multiagent" ] lint-fix = [ @@ -154,9 +155,11 @@ python = ["3.13", "3.12", "3.11", "3.10"] [tool.hatch.envs.hatch-test.scripts] run = [ + # excluding due to A2A and OTEL http exporter dependency conflict "pytest{env:HATCH_TEST_ARGS:} {args} --ignore=tests/multiagent/a2a" ] run-cov = [ + # excluding due to A2A and OTEL http exporter dependency conflict "pytest{env:HATCH_TEST_ARGS:} --cov --cov-config=pyproject.toml {args} --ignore=tests/multiagent/a2a" ] @@ -192,6 +195,7 @@ prepare = [ "hatch test --all" ] test-a2a = [ + # required to run manually due to A2A and OTEL http exporter dependency conflict "hatch -e a2a run run {args}" ] diff --git a/src/strands/agent/agent.py b/src/strands/agent/agent.py index 8ffcea151..8dcfbd0da 100644 --- a/src/strands/agent/agent.py +++ b/src/strands/agent/agent.py @@ -217,8 +217,8 @@ def __init__( load_tools_from_directory: bool = True, trace_attributes: Optional[Mapping[str, AttributeValue]] = None, *, - name: str | None = None, - description: str | None = None, + name: Optional[str] = None, + description: Optional[str] = None, ): """Initialize the Agent with the specified configuration. diff --git a/src/strands/multiagent/a2a/agent.py b/src/strands/multiagent/a2a/agent.py index e2933853d..4359100de 100644 --- a/src/strands/multiagent/a2a/agent.py +++ b/src/strands/multiagent/a2a/agent.py @@ -18,7 +18,7 @@ from ...agent.agent import Agent as SAAgent from .executor import StrandsA2AExecutor -log = logging.getLogger(__name__) +logger = logging.getLogger(__name__) class A2AAgent: @@ -29,7 +29,7 @@ def __init__( agent: SAAgent, *, # AgentCard - host: str = "0.0.0", + host: str = "0.0.0.0", port: int = 9000, version: str = "0.0.1", ): @@ -39,7 +39,7 @@ def __init__( agent: The Strands Agent to wrap with A2A compatibility. name: The name of the agent, used in the AgentCard. description: A description of the agent's capabilities, used in the AgentCard. - host: The hostname or IP address to bind the A2A server to. Defaults to "localhost". + host: The hostname or IP address to bind the A2A server to. Defaults to "0.0.0.0". port: The port to bind the A2A server to. Defaults to 9000. version: The version of the agent. Defaults to "0.0.1". """ @@ -56,6 +56,7 @@ def __init__( agent_executor=StrandsA2AExecutor(self.strands_agent), task_store=InMemoryTaskStore(), ) + logger.info("Strands' integration with A2A is experimental. Be aware of frequent breaking changes.") @property def public_agent_card(self) -> AgentCard: @@ -135,14 +136,14 @@ def serve(self, app_type: Literal["fastapi", "starlette"] = "starlette", **kwarg **kwargs: Additional keyword arguments to pass to uvicorn.run. """ try: - log.info("Starting Strands A2A server...") + logger.info("Starting Strands A2A server...") if app_type == "fastapi": uvicorn.run(self.to_fastapi_app(), host=self.host, port=self.port, **kwargs) else: uvicorn.run(self.to_starlette_app(), host=self.host, port=self.port, **kwargs) except KeyboardInterrupt: - log.warning("Strands A2A server shutdown requested (KeyboardInterrupt).") + logger.warning("Strands A2A server shutdown requested (KeyboardInterrupt).") except Exception: - log.exception("Strands A2A server encountered exception.") + logger.exception("Strands A2A server encountered exception.") finally: - log.info("Strands A2A server has shutdown.") + logger.info("Strands A2A server has shutdown.")