Skip to content

feat(a2a): agents-as-tools #424

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/strands/tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
This module provides the core functionality for creating, managing, and executing tools through agents.
"""

from .agent_tool_wrapper import AgentToolWrapper
from .decorator import tool
from .structured_output import convert_pydantic_to_tool_spec
from .tools import InvalidToolUseNameException, PythonAgentTool, normalize_schema, normalize_tool_spec

__all__ = [
"AgentToolWrapper",
"tool",
"PythonAgentTool",
"InvalidToolUseNameException",
Expand Down
114 changes: 114 additions & 0 deletions src/strands/tools/agent_tool_wrapper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""Agent tool wrapper that enables using Agent objects as tools."""

from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
from ..agent.agent import Agent

from ..types.tools import AgentTool, ToolGenerator, ToolResult, ToolResultContent, ToolSpec, ToolUse


class AgentToolWrapper(AgentTool):
"""Wrapper that makes an Agent usable as a tool.

This class enables the agents-as-tools pattern by wrapping an Agent
and implementing the AgentTool interface. The wrapped agent can then
be used as a tool by other agents.
"""

def __init__(self, agent: "Agent"):
"""Initialize the agent tool wrapper.

Args:
agent: The Agent instance to wrap
"""
super().__init__()
self._agent = agent
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that that this ends up being a stateful tool. Unlike most other tools, once invoked this tool retains history (the agent messages), which means future invocations will keep the prior context. Similarly, agents as tools aren't really safe to share between agents because of the shared history and because of potential concurrency issues.

To that end, I wonder if additional options are needed for things like having a per-tool-invocation history or copying the agent as part of the tool so that it could be more easily shared but that also has it's own downsides.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related to session persistence, I'm also curious if this should be persisting it's tool state (the tool-agent's state) in the parent agent's state - otherwise we run into the problem where the parent agent cannot be persisted/hydrated correctly.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for sure, great points! i see you've been advocating for these since the start: #84 (comment). 😄

  1. i think supporting more configuration options is definitely worth investigating. do you view that as a blocker for this initial implementation or something that can be done in a followup task? given some of the intricacies of the current concurrency model and freshness of session persistence, i worry about not having enough bandwidth to implement the changes in time.
  2. just to make sure i understand correctly: the main limitation with the implementation is that the tool agent's state will not be persisted, thus it cannot be hydrated correctly. however, the parent agent will store interactions with the tool agent as tool calls/results in the messages array - which can be persisted/hydrated. so your concern is that this fragmentation in state (the parent agent having a history of interactions with the tool agent, and the tool agent not having any history) could hinder performance? it definitely can. my initial thought on this is that since the tool agent is itself an agent, it should be responsible for maintaining it's own state and can be persisted/hydrating. i think strand's notion of persistence is tied to an agent which is where i think the thoughts around adding the tool agent's state to the parent agent may be coming from. however, i think for these use multi agent use cases, where the role of each agent is more complex and stateful, the thing that ties all the components in the system together is more akin to a "sessionId" or "systemId" where each component of that "system" is responsible for it's own state, and they are tied together by an identifier which effectively functions as a foreign key.

but all in all, i'm more than happy to address any blocking items you have in this PR. otherwise, i will create followup tasks for these topics.

self._validate_agent()
self._name = agent.name
self._description = agent.description or ""

def _validate_agent(self) -> None:
"""Check if agent has the required attributes and they are properly set."""
if (
not hasattr(self._agent, "name")
or not hasattr(self._agent, "description")
or not self._agent.name
or self._agent.name == "Strands Agents" # Default agent name
or not self._agent.description
):
raise ValueError(
"Agent must have both 'name' and 'description' parameters "
"to be used as a tool. 'name' must not be default agent name: 'Strands Agents'. "
"Initialize the Agent with: "
"Agent(name='tool_name', description='tool_description', ...)"
)

@property
def tool_name(self) -> str:
"""The unique name of the tool used for identification and invocation."""
return self._name

@property
def tool_spec(self) -> ToolSpec:
"""Tool specification that describes its functionality and parameters."""
return ToolSpec(
name=self._name,
description=self._description,
inputSchema={
"type": "object",
"properties": {"prompt": {"type": "string", "description": "The prompt to send to the sub-agent"}},
"required": ["prompt"],
},
)

@property
def tool_type(self) -> str:
"""The type of the tool implementation."""
return "agent"

async def stream(self, tool_use: ToolUse, invocation_state: dict[str, Any], **kwargs: Any) -> ToolGenerator:
"""Stream tool events by delegating to the wrapped agent.

Args:
tool_use: The tool use request containing tool ID and parameters
invocation_state: Context for the tool invocation, including agent state
**kwargs: Additional keyword arguments for future extensibility

Yields:
Tool events with the last being the tool result
"""
try:
# Extract the prompt from tool input
prompt = tool_use["input"].get("prompt", "")

# Invoke the sub-agent
result = await self._agent.invoke_async(prompt)

# Convert agent response to tool result format
tool_result = ToolResult(
toolUseId=tool_use["toolUseId"], status="success", content=[ToolResultContent(text=str(result))]
)

yield tool_result

except Exception as e:
# Return error result
tool_result = ToolResult(
toolUseId=tool_use["toolUseId"],
status="error",
content=[ToolResultContent(text=f"Error executing '{self._name}': {str(e)}")],
)
yield tool_result

def get_display_properties(self) -> dict[str, str]:
"""Get properties to display in UI representations of this tool.

Returns:
Dictionary of property names and their string values
"""
return {
"Name": self.tool_name,
"Type": self.tool_type,
"Description": self._description,
}
14 changes: 14 additions & 0 deletions src/strands/tools/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from strands.tools.decorator import DecoratedFunctionTool

from ..types.tools import AgentTool, ToolSpec
from .agent_tool_wrapper import AgentToolWrapper
from .tools import PythonAgentTool, normalize_schema, normalize_tool_spec

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -97,6 +98,12 @@ def process_tools(self, tools: List[Any]) -> List[str]:
elif isinstance(tool, AgentTool):
self.register_tool(tool)
tool_names.append(tool.tool_name)

# Case 6: Agent as tools
elif self._is_agent_instance(tool):
agent_tool = AgentToolWrapper(tool)
self.register_tool(agent_tool)
tool_names.append(agent_tool.tool_name)
else:
logger.warning("tool=<%s> | unrecognized tool specification", tool)

Expand Down Expand Up @@ -598,3 +605,10 @@ def _scan_module_for_tools(self, module: Any) -> List[AgentTool]:
logger.warning("tool_name=<%s> | failed to create function tool | %s", name, e)

return tools

def _is_agent_instance(self, obj: Any) -> bool:
"""Check if an object is an Agent instance."""
# Use local import to avoid circular dependencies
from ..agent.agent import Agent

return isinstance(obj, Agent)
Loading
Loading