-
Notifications
You must be signed in to change notification settings - Fork 731
feat: add AgentAsTool #1932
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
base: main
Are you sure you want to change the base?
feat: add AgentAsTool #1932
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,237 @@ | ||
| """Agent-as-tool adapter. | ||
|
|
||
| This module provides the AgentAsTool class that wraps an Agent (or any AgentBase) as a tool | ||
| so it can be passed to another agent's tool list. | ||
| """ | ||
|
|
||
| import copy | ||
| import logging | ||
| from typing import Any | ||
|
|
||
| from typing_extensions import override | ||
|
|
||
| from ..agent.state import AgentState | ||
| from ..types._events import AgentAsToolStreamEvent, ToolResultEvent | ||
| from ..types.content import Messages | ||
| from ..types.tools import AgentTool, ToolGenerator, ToolSpec, ToolUse | ||
| from .base import AgentBase | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| class AgentAsTool(AgentTool): | ||
| """Adapter that exposes an Agent as a tool for use by other agents. | ||
|
|
||
| The tool accepts a single ``input`` string parameter, invokes the wrapped | ||
| agent, and returns the text response. | ||
|
|
||
| Example: | ||
| ```python | ||
| from strands import Agent | ||
| from strands.agent import AgentAsTool | ||
|
|
||
| researcher = Agent(name="researcher", description="Finds information") | ||
|
|
||
| # Use directly | ||
| tool = AgentAsTool(researcher, name="researcher", description="Finds information") | ||
|
|
||
| # Or via convenience method | ||
| tool = researcher.as_tool() | ||
|
|
||
| # Start each invocation with a fresh conversation | ||
| tool = researcher.as_tool(preserve_context=False) | ||
|
|
||
| writer = Agent(name="writer", tools=[tool]) | ||
| writer("Write about AI agents") | ||
| ``` | ||
| """ | ||
|
|
||
| def __init__( | ||
| self, | ||
| agent: AgentBase, | ||
| *, | ||
| name: str, | ||
| description: str, | ||
| preserve_context: bool = False, | ||
| ) -> None: | ||
| r"""Initialize the agent-as-tool adapter. | ||
|
|
||
| Args: | ||
| agent: The agent to wrap as a tool. | ||
| name: Tool name. Must match the pattern ``[a-zA-Z0-9_\\-]{1,64}``. | ||
| description: Tool description. | ||
| preserve_context: Whether to preserve the agent's conversation history across | ||
| invocations. When False, the agent's messages and state are reset to the | ||
| values they had at construction time before each call, ensuring every | ||
| invocation starts from the same baseline regardless of any external | ||
| interactions with the agent. Defaults to False. Only effective when the | ||
| wrapped agent exposes a mutable ``messages`` list and/or an ``AgentState`` | ||
| (e.g. ``strands.agent.Agent``). | ||
| """ | ||
| super().__init__() | ||
| self._agent = agent | ||
| self._tool_name = name | ||
| self._description = description | ||
| self._preserve_context = preserve_context | ||
|
|
||
| # When preserve_context=False, we snapshot the agent's initial state so we can | ||
| # restore it before each invocation. This mirrors GraphNode.reset_executor_state(). | ||
| # We require an Agent instance for this since AgentBase doesn't guarantee | ||
| # messages/state attributes. | ||
| self._initial_messages: Messages = [] | ||
| self._initial_state: AgentState = AgentState() | ||
|
|
||
| if not preserve_context: | ||
| from .agent import Agent | ||
|
|
||
| if not isinstance(agent, Agent): | ||
| raise TypeError(f"preserve_context=False requires an Agent instance, got {type(agent).__name__}") | ||
| self._initial_messages = copy.deepcopy(agent.messages) | ||
| self._initial_state = AgentState(agent.state.get()) | ||
|
|
||
| @property | ||
| def agent(self) -> AgentBase: | ||
| """The wrapped agent instance.""" | ||
| return self._agent | ||
|
|
||
| @property | ||
| def tool_name(self) -> str: | ||
| """Get the tool name.""" | ||
| return self._tool_name | ||
|
|
||
| @property | ||
| def tool_spec(self) -> ToolSpec: | ||
| """Get the tool specification.""" | ||
| return { | ||
| "name": self._tool_name, | ||
| "description": self._description, | ||
| "inputSchema": { | ||
| "json": { | ||
| "type": "object", | ||
| "properties": { | ||
| "input": { | ||
| "type": "string", | ||
| "description": "The input to send to the agent tool.", | ||
| }, | ||
| }, | ||
| "required": ["input"], | ||
| } | ||
| }, | ||
| } | ||
|
|
||
| @property | ||
| def tool_type(self) -> str: | ||
| """Get the tool type.""" | ||
| return "agent" | ||
|
|
||
| @override | ||
| async def stream(self, tool_use: ToolUse, invocation_state: dict[str, Any], **kwargs: Any) -> ToolGenerator: | ||
| """Invoke the wrapped agent via streaming and yield events. | ||
|
|
||
| Intermediate agent events are wrapped in AgentAsToolStreamEvent so the caller | ||
| can distinguish sub-agent progress from regular tool events. The final | ||
| AgentResult is yielded as a ToolResultEvent. | ||
|
|
||
| Args: | ||
| tool_use: The tool use request containing the input parameter. | ||
| invocation_state: Context for the tool invocation. | ||
| **kwargs: Additional keyword arguments. | ||
|
|
||
| Yields: | ||
| AgentAsToolStreamEvent for intermediate events, then ToolResultEvent with the final response. | ||
| """ | ||
| tool_input = tool_use["input"] | ||
| if isinstance(tool_input, dict): | ||
| prompt = tool_input.get("input", "") | ||
| elif isinstance(tool_input, str): | ||
| prompt = tool_input | ||
| else: | ||
| logger.warning("tool_name=<%s> | unexpected input type: %s", self._tool_name, type(tool_input)) | ||
| prompt = str(tool_input) | ||
|
|
||
| tool_use_id = tool_use["toolUseId"] | ||
|
|
||
| if not self._preserve_context: | ||
| self._reset_agent_state(tool_use_id) | ||
|
|
||
| logger.debug("tool_name=<%s>, tool_use_id=<%s> | invoking agent", self._tool_name, tool_use_id) | ||
|
|
||
| try: | ||
| result = None | ||
| async for event in self._agent.stream_async(prompt): | ||
| if "result" in event: | ||
| result = event["result"] | ||
| else: | ||
| yield AgentAsToolStreamEvent(tool_use, event, self) | ||
|
|
||
| if result is None: | ||
| yield ToolResultEvent( | ||
| { | ||
| "toolUseId": tool_use_id, | ||
| "status": "error", | ||
| "content": [{"text": "Agent did not produce a result"}], | ||
| } | ||
| ) | ||
| return | ||
|
|
||
| if result.structured_output: | ||
| yield ToolResultEvent( | ||
| { | ||
| "toolUseId": tool_use_id, | ||
| "status": "success", | ||
| "content": [{"json": result.structured_output.model_dump()}], | ||
| } | ||
| ) | ||
| else: | ||
| yield ToolResultEvent( | ||
| { | ||
| "toolUseId": tool_use_id, | ||
| "status": "success", | ||
| "content": [{"text": str(result)}], | ||
| } | ||
| ) | ||
|
|
||
| except Exception as e: | ||
| logger.warning( | ||
| "tool_name=<%s>, tool_use_id=<%s> | agent invocation failed: %s", | ||
| self._tool_name, | ||
| tool_use_id, | ||
| e, | ||
| ) | ||
| yield ToolResultEvent( | ||
| { | ||
| "toolUseId": tool_use_id, | ||
| "status": "error", | ||
| "content": [{"text": f"Agent error: {e}"}], | ||
| } | ||
| ) | ||
|
|
||
| def _reset_agent_state(self, tool_use_id: str) -> None: | ||
| """Reset the wrapped agent to its initial state. | ||
|
|
||
| Restores messages and state to the values captured at construction time. | ||
| This mirrors the pattern used by ``GraphNode.reset_executor_state()``. | ||
|
|
||
| Args: | ||
| tool_use_id: Tool use ID for logging context. | ||
| """ | ||
| from .agent import Agent | ||
|
|
||
| # isinstance narrows the type for mypy; __init__ guarantees this when preserve_context=False | ||
| if not isinstance(self._agent, Agent): | ||
| return | ||
|
|
||
| logger.debug( | ||
| "tool_name=<%s>, tool_use_id=<%s> | resetting agent to initial state", | ||
| self._tool_name, | ||
| tool_use_id, | ||
| ) | ||
| self._agent.messages = copy.deepcopy(self._initial_messages) | ||
| self._agent.state = AgentState(self._initial_state.get()) | ||
|
|
||
| @override | ||
| def get_display_properties(self) -> dict[str, str]: | ||
| """Get properties for UI display.""" | ||
| properties = super().get_display_properties() | ||
| properties["Agent"] = getattr(self._agent, "name", "unknown") | ||
| return properties |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -62,6 +62,7 @@ | |
| from ..types.content import ContentBlock, Message, Messages, SystemContentBlock | ||
| from ..types.exceptions import ConcurrencyException, ContextWindowOverflowException | ||
| from ..types.traces import AttributeValue | ||
| from ._agent_as_tool import AgentAsTool | ||
| from .agent_result import AgentResult | ||
| from .base import AgentBase | ||
| from .conversation_manager import ( | ||
|
|
@@ -612,6 +613,40 @@ async def structured_output_async(self, output_model: type[T], prompt: AgentInpu | |
| finally: | ||
| await self.hooks.invoke_callbacks_async(AfterInvocationEvent(agent=self, invocation_state={})) | ||
|
|
||
| def as_tool( | ||
| self, | ||
| name: str | None = None, | ||
| description: str | None = None, | ||
| preserve_context: bool = False, | ||
| ) -> AgentAsTool: | ||
| r"""Convert this agent into a tool for use by another agent. | ||
|
|
||
| Args: | ||
| name: Tool name. Must match the pattern ``[a-zA-Z0-9_\\-]{1,64}``. | ||
| Defaults to the agent's name. | ||
| description: Tool description. Defaults to the agent's description. | ||
| preserve_context: Whether to preserve the agent's conversation history across | ||
| invocations. When False, the agent's messages and state are reset to the | ||
| values they had at construction time before each call, ensuring every | ||
| invocation starts from the same baseline regardless of any external | ||
| interactions with the agent. Defaults to False. | ||
|
|
||
| Returns: | ||
| An AgentAsTool wrapping this agent. | ||
|
|
||
| Example: | ||
| ```python | ||
| researcher = Agent(name="researcher", description="Finds information") | ||
| writer = Agent(name="writer", tools=[researcher.as_tool()]) | ||
| writer("Write about AI agents") | ||
| ``` | ||
| """ | ||
| if not name: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Issue: Empty string check uses falsy evaluation which is inconsistent. Suggestion: Consider using explicit if name is None:
name = self.name
if description is None:
description = self.description or f"Use the {name} tool to invoke this agent as a tool"This makes the API contract clearer - |
||
| name = self.name | ||
| if not description: | ||
| description = self.description or f"Use the {name} agent as a tool by providing a natural language input" | ||
| return AgentAsTool(self, name=name, description=description, preserve_context=preserve_context) | ||
|
|
||
| def cleanup(self) -> None: | ||
| """Clean up resources used by the agent. | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's rename this as _agent_as_tool.py and just export at the top level
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
renamed