8000 Initial A2A server Integration (#218) · strands-agents/sdk-python@be57089 · GitHub
[go: up one dir, main page]

Skip to content

Commit be57089

Browse files
authored
Initial A2A server Integration (#218)
1 parent d8ce2d5 commit be57089

File tree

11 files changed

+599
-4
lines changed

11 files changed

+599
-4
lines changed

pyproject.toml

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,8 @@ format-fix = [
108108
]
109109
lint-check = [
110110
"ruff check",
111-
"mypy -p src"
111+
# excluding due to A2A and OTEL http exporter dependency conflict
112+
"mypy -p src --exclude src/strands/multiagent"
112113
]
113114
lint-fix = [
114115
"ruff check --fix"
@@ -137,17 +138,29 @@ features = ["dev", "docs", "anthropic", "litellm", "llamaapi", "ollama", "otel"]
137138
dev-mode = true
138139
features = ["dev", "docs", "anthropic", "litellm", "llamaapi", "ollama", "a2a"]
139140

141+
[tool.hatch.envs.a2a.scripts]
142+
run = [
143+
"pytest{env:HATCH_TEST_ARGS:} tests/multiagent/a2a {args}"
144+
]
145+
run-cov = [
146+
"pytest{env:HATCH_TEST_ARGS:} tests/multiagent/a2a --cov --cov-config=pyproject.toml {args}"
147+
]
148+
lint-check = [
149+
"ruff check",
150+
"mypy -p src/strands/multiagent/a2a"
151+
]
140152

141153
[[tool.hatch.envs.hatch-test.matrix]]
142154
python = ["3.13", "3.12", "3.11", "3.10"]
143155

144-
145156
[tool.hatch.envs.hatch-test.scripts]
146157
run = [
147-
"pytest{env:HATCH_TEST_ARGS:} {args}"
158+
# excluding due to A2A and OTEL http exporter dependency conflict
159+
"pytest{env:HATCH_TEST_ARGS:} {args} --ignore=tests/multiagent/a2a"
148160
]
149161
run-cov = [
150-
"pytest{env:HATCH_TEST_ARGS:} --cov --cov-config=pyproject.toml {args}"
162+
# excluding due to A2A and OTEL http exporter dependency conflict
163+
"pytest{env:HATCH_TEST_ARGS:} --cov --cov-config=pyproject.toml {args} --ignore=tests/multiagent/a2a"
151164
]
152165

153166
cov-combine = []
@@ -181,6 +194,10 @@ prepare = [
181194
"hatch fmt --formatter",
182195
"hatch test --all"
183196
]
197+
test-a2a = [
198+
# required to run manually due to A2A and OTEL http exporter dependency conflict
199+
"hatch -e a2a run run {args}"
200+
]
184201

185202
[tool.mypy]
186203
python_version = "3.10"

src/strands/agent/agent.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,9 @@ def __init__(
220220
record_direct_tool_call: bool = True,
221221
load_tools_from_directory: bool = True,
222222
trace_attributes: Optional[Mapping[str, AttributeValue]] = None,
223+
*,
224+
name: Optional[str] = None,
225+
description: Optional[str] = None,
223226
):
224227
"""Initialize the Agent with the specified configuration.
225228
@@ -252,6 +255,10 @@ def __init__(
252255
load_tools_from_directory: Whether to load and automatically reload tools in the `./tools/` directory.
253256
Defaults to True.
254257
trace_attributes: Custom trace attributes to apply to the agent's trace span.
258+
name: name of the Agent
259+
Defaults to None.
260+
description: description of what the Agent does
261+
Defaults to None.
255262
256263
Raises:
257264
ValueError: If max_parallel_tools is less than 1.
@@ -313,6 +320,8 @@ def __init__(
313320
self.tracer = get_tracer()
314321
self.trace_span: Optional[trace.Span] = None
315322
self.tool_caller = Agent.ToolCaller(self)
323+
self.name = name
324+
self.description = description
316325

317326
@property
318327
def tool(self) -> ToolCaller:

src/strands/multiagent/__init__.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""Multiagent capabilities for Strands Agents.
2+
3+
This module provides support for multiagent systems, including agent-to-agent (A2A)
4+
communication protocols and coordination mechanisms.
5+
6+
Submodules:
7+
a2a: Implementation of the Agent-to-Agent (A2A) protocol, which enables
8+
standardized communication between agents.
9+
"""
10+
11+
from . import a2a
12+
13+
__all__ = ["a2a"]
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
"""Agent-to-Agent (A2A) communication protocol implementation for Strands Agents.
2+
3+
This module provides classes and utilities for enabling Strands Agents to communicate
4+
with other agents using the Agent-to-Agent (A2A) protocol.
5+
6+
Docs: https://google-a2a.github.io/A2A/latest/
7+
8+
Classes:
9+
A2AAgent: A wrapper that adapts a Strands Agent to be A2A-compatible.
10+
"""
11+
12+
from .agent import A2AAgent
13+
14+
__all__ = ["A2AAgent"]

src/strands/multiagent/a2a/agent.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
"""A2A-compatible wrapper for Strands Agent.
2+
3+
This module provides the A2AAgent class, which adapts a Strands Agent to the A2A protocol,
4+
allowing it to be used in A2A-compatible systems.
5+
"""
6+
7+
import logging
8+
from typing import Any, Literal
9+
10+
import uvicorn
11+
from a2a.server.apps import A2AFastAPIApplication, A2AStarletteApplication
12+
from a2a.server.request_handlers import DefaultRequestHandler
13+
from a2a.server.tasks import InMemoryTaskStore
14+
from a2a.types import AgentCapabilities, AgentCard, AgentSkill
15+
from fastapi import FastAPI
16+
from starlette.applications import Starlette
17+
18+
from ...agent.agent import Agent as SAAgent
19+
from .executor import StrandsA2AExecutor
20+
21+
logger = logging.getLogger(__name__)
22+
23+
24+
class A2AAgent:
25+
"""A2A-compatible wrapper for Strands Agent."""
26+
27+
def __init__(
28+
self,
29+
agent: SAAgent,
30+
*,
31+
# AgentCard
32+
host: str = "0.0.0.0",
33+
port: int = 9000,
34+
version: str = "0.0.1",
35+
):
36+
"""Initialize an A2A-compatible agent from a Strands agent.
37+
38+
Args:
39+
agent: The Strands Agent to wrap with A2A compatibility.
40+
name: The name of the agent, used in the AgentCard.
41+
description: A description of the agent's capabilities, used in the AgentCard.
42+
host: The hostname or IP address to bind the A2A server to. Defaults to "0.0.0.0".
43+
port: The port to bind the A2A server to. Defaults to 9000.
44+
version: The version of the agent. Defaults to "0.0.1".
45+
"""
46+
self.host = host
47+
self.port = port
48+
self.http_url = f"http://{self.host}:{self.port}/"
49+
self.version = version
50+
self.strands_agent = agent
51+
self.name = self.strands_agent.name
52+
self.description = self.strands_agent.description
53+
# TODO: enable configurable capabilities and request handler
54+
self.capabilities = AgentCapabilities()
55+
self.request_handler = DefaultRequestHandler(
56+
agent_executor=StrandsA2AExecutor(self.strands_agent),
57+
task_store=InMemoryTaskStore(),
58+
)
59+
logger.info("Strands' integration with A2A is experimental. Be aware of frequent breaking changes.")
60+
61+
@property
62+
def public_agent_card(self) -> AgentCard:
63+
"""Get the public AgentCard for this agent.
64+
65+
The AgentCard contains metadata about the agent, including its name,
66+
description, URL, version, skills, and capabilities. This information
67+
is used by other agents and systems to discover and interact with this agent.
68+
69+
Returns:
70+
AgentCard: The public agent card containing metadata about this agent.
71+
72+
Raises:
73+
ValueError: If name or description is None or empty.
74+
"""
75+
if not self.name:
76+
raise ValueError("A2A agent name cannot be None or empty")
77+
if not self.description:
78+
raise ValueError("A2A agent description cannot be None or empty")
79+
80+
return AgentCard(
81+
name=self.name,
82+
description=self.description,
83+
url=self.http_url,
84+
version=self.version,
85+
skills=self.agent_skills,
86+
defaultInputModes=["text"],
87+
defaultOutputModes=["text"],
88+
capabilities=self.capabilities,
89+
)
90+
91+
@property
92+
def agent_skills(self) -> list[AgentSkill]:
93+
"""Get the list of skills this agent provides.
94+
95+
Skills represent specific capabilities that the agent can perform.
96+
Strands agent tools are adapted to A2A skills.
97+
98+
Returns:
99+
list[AgentSkill]: A list of skills this agent provides.
100+
"""
101+
# TODO: translate Strands tools (native & MCP) to skills
102+
return []
103+
104+
def to_starlette_app(self) -> Starlette:
105+
"""Create a Starlette application for serving this agent via HTTP.
106+
107+
This method creates a Starlette application that can be used to serve
108+
the agent via HTTP using the A2A protocol.
109+
110+
Returns:
111+
Starlette: A Starlette application configured to serve this agent.
112+
"""
113+
return A2AStarletteApplication(agent_card=self.public_agent_card, http_handler=self.request_handler).build()
114+
115+
def to_fastapi_app(self) -> FastAPI:
116+
"""Create a FastAPI application for serving this agent via HTTP.
117+
118+
This method creates a FastAPI application that can be used to serve
119+
the agent via HTTP using the A2A protocol.
120+
121+
Returns:
122+
FastAPI: A FastAPI application configured to serve this agent.
123+
"""
124+
return A2AFastAPIApplication(agent_card=self.public_agent_card, http_handler=self.request_handler).build()
125+
126+
def serve(self, app_type: Literal["fastapi", "starlette"] = "starlette", **kwargs: Any) -> None:
127+
"""Start the A2A server with the specified application type.
128+
129+
This method starts an HTTP server that exposes the agent via the A2A protocol.
130+
The server can be implemented using either FastAPI or Starlette, depending on
131+
the specified app_type.
132+
133+
Args:
134+
app_type: The type of application to serve, either "fastapi" or "starlette".
135+
Defaults to "starlette".
136+
**kwargs: Additional keyword arguments to pass to uvicorn.run.
137+
"""
138+
try:
139+
logger.info("Starting Strands A2A server...")
140+
if app_type == "fastapi":
141+
uvicorn.run(self.to_fastapi_app(), host=self.host, port=self.port, **kwargs)
142+
else:
143+
uvicorn.run(self.to_starlette_app(), host=self.host, port=self.port, **kwargs)
144+
except KeyboardInterrupt:
145+
logger.warning("Strands A2A server shutdown requested (KeyboardInterrupt).")
146+
except Exception:
147+
logger.exception("Strands A2A server encountered exception.")
148+
finally:
149+
logger.info("Strands A2A server has shutdown.")
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""Strands Agent executor for the A2A protocol.
2+
3+
This module provides the StrandsA2AExecutor class, which adapts a Strands Agent
4+
to be used as an executor in the A2A protocol. It handles the execution of agent
5+
requests and the conversion of Strands Agent responses to A2A events.
6+
"""
7+
8+
import logging
9+
10+
from a2a.server.agent_execution import AgentExecutor, RequestContext
11+
from a2a.server.events import EventQueue
12+
from a2a.types import UnsupportedOperationError
13+
from a2a.utils import new_agent_text_message
14+
from a2a.utils.errors import ServerError
15+
16+
from ...agen 10000 t.agent import Agent as SAAgent
17+
from ...agent.agent_result import AgentResult as SAAgentResult
18+
19+
log = logging.getLogger(__name__)
20+
21+
22+
class StrandsA2AExecutor(AgentExecutor):
23+
"""Executor that adapts a Strands Agent to the A2A protocol."""
24+
25+
def __init__(self, agent: SAAgent):
26+
"""Initialize a StrandsA2AExecutor.
27+
28+
Args:
29+
agent: The Strands Agent to adapt to the A2A protocol.
30+
"""
31+
self.agent = agent
32+
33+
async def execute(
34+
self,
35+
context: RequestContext,
36+
event_queue: EventQueue,
37+
) -> None:
38+
"""Execute a request using the Strands Agent and send the response as A2A events.
39+
40+
This method executes the user's input using the Strands Agent and converts
41+
the agent's response to A2A events, which are then sent to the event queue.
42+
43+
Args:
44+
context: The A2A request context, containing the user's input and other metadata.
45+
event_queue: The A2A event queue, used to send response events.
46+
"""
47+
result: SAAgentResult = self.agent(context.get_user_input())
48+
if result.message and "content" in result.message:
49+
for content_block in result.message["content"]:
50+
if "text" in content_block:
51+
await event_queue.enqueue_event(new_agent_text_message(content_block["text"]))
52+
53+
async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
54+
"""Cancel an ongoing execution.
55+
56+
This method is called when a request is cancelled. Currently, cancellation
57+
is not supported, so this method raises an UnsupportedOperationError.
58+
59+
Args:
60+
context: The A2A request context.
61+
event_queue: The A2A event queue.
62+
63+
Raises:
64+
ServerError: Always raised with an UnsupportedOperationError, as cancellation
65+
is not currently supported.
66+
"""
67+
raise ServerError(error=UnsupportedOperationError())

tests/multiagent/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Tests for the multiagent module."""

tests/multiagent/a2a/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Tests for the A2A module."""

tests/multiagent/a2a/conftest.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""Common fixtures for A2A module tests."""
2+
3+
from unittest.mock import AsyncMock, MagicMock
4+
5+
import pytest
6+
from a2a.server.agent_execution import RequestContext
7+
from a2a.server.events import EventQueue
8+
9+
from strands.agent.agent import Agent as SAAgent
10+
from strands.agent.agent_result import AgentResult as SAAgentResult
11+
12+
13+
@pytest.fixture
14+
def mock_strands_agent():
15+
"""Create a mock Strands Agent for testing."""
16+
agent = MagicMock(spec=SAAgent)
17+
agent.name = "Test Agent"
18+
agent.description = "A test agent for unit testing"
19+
20+
# Setup default response
21+
mock_result = MagicMock(spec=SAAgentResult)
22+
mock_result.message = {"content": [{"text": "Test response"}]}
23+
agent.return_value = mock_result
24+
25+
return agent
26+
27+
28+
@pytest.fixture
29+
def mock_request_context():
30+
"""Create a mock RequestContext for testing."""
31+
context = MagicMock(spec=RequestContext)
32+
context.get_user_input.return_value = "Test input"
33+
return context
34+
35+
36+
@pytest.fixture
37+
def mock_event_queue():
38+
"""Create a mock EventQueue for testing."""
39+
queue = MagicMock(spec=EventQueue)
40+
queue.enqueue_event = AsyncMock()
41+
return queue

0 commit comments

Comments
 (0)
0