Skip to content

feat: add AgentAsTool#1932

Open
notowen333 wants to merge 4 commits intostrands-agents:mainfrom
notowen333:agent-as-tool
Open

feat: add AgentAsTool#1932
notowen333 wants to merge 4 commits intostrands-agents:mainfrom
notowen333:agent-as-tool

Conversation

@notowen333
Copy link

@notowen333 notowen333 commented Mar 18, 2026

Description

Will close #1002.

This PR adds the AgentAsTool class as well as a convenience method on the Agent class to return an instance of it.

The AgentAsTool class just calls stream_async on the underlying agent and formats the response as a ToolResultEvent.

The added integration test confirms that an AgentAsTool was invoked and that agent-as-tool used a tool of its own.

Related Issues

#1002

Documentation PR

strands-agents/docs#686

Type of Change

New feature

Testing

  • Added unit tests

  • Added a minimal integ test

  • [ x] I ran hatch run prepare

Checklist

  • [ x ] I have read the CONTRIBUTING document
  • [ x ] I have added any necessary tests that prove my fix is effective or my feature works
  • [ x ] I have updated the documentation accordingly
  • [ x ] I have added an appropriate example to the documentation to outline the feature, or no new docs are needed
  • [ x ] My changes generate no new warnings
  • [ x ] Any dependent changes have been merged and published

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@codecov
Copy link

codecov bot commented Mar 18, 2026

Codecov Report

❌ Patch coverage is 94.62366% with 5 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/strands/agent/_agent_as_tool.py 93.50% 3 Missing and 2 partials ⚠️

📢 Thoughts on this report? Let us know!

writer("Write about AI agents")
```
"""
if not name:

Choose a reason for hiding this comment

The 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 is None checks for clarity. Currently if not name would treat an empty string "" the same as None, which may not be the intended behavior:

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 - None means "use default" while an explicit empty string could be considered user error.

Yields:
ToolStreamEvent for intermediate events, then ToolResultEvent with the final response.
"""
prompt = tool_use["input"].get("input", "") if isinstance(tool_use["input"], dict) else tool_use["input"]

Choose a reason for hiding this comment

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

Issue: The input extraction logic handles two cases but could silently fail with unexpected input formats.

Suggestion: Consider adding explicit type handling or validation:

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)

@github-actions
Copy link

Issue: The original issue #1002 mentions a preserve_context: bool parameter to control whether previous conversation context is preserved between invocations.

Question: Is this parameter intentionally deferred to a follow-up PR, or should it be included in this initial implementation? The current implementation appears to preserve context by default (since it uses the same agent instance), but there's no way for users to reset context between calls.

This could be a breaking change if added later, so it would be good to clarify the intended behavior.

@github-actions
Copy link

Code Review Summary

Assessment: Request Changes

This PR introduces a well-designed AgentAsTool class and Agent.as_tool() convenience method. The implementation follows SDK patterns and includes comprehensive tests. However, documentation is required before merge.

Review Categories
  • Documentation: Missing documentation PR. This introduces new public API surface (AgentAsTool class, Agent.as_tool() method) and requires user documentation per project guidelines.
  • API Review: Consider adding needs-api-review label since this adds new public abstractions customers will use.
  • Design Clarification: The preserve_context parameter from [FEATURE] Agent to_tool method #1002 should be addressed - either as part of this PR or explicitly deferred.

The core implementation looks solid - just needs the documentation PR linked to unblock merge.

@notowen333
Copy link
Author

/strands review

@github-actions
Copy link

Code Review Summary - Updated

Assessment: Approve ✅

Thank you for addressing the feedback from the previous review! All blocking issues have been resolved:

Changes Addressed
  • Documentation PR: Now linked (feat: add section in user guide on new functionality for Agent.as_tool() docs#686)
  • preserve_context parameter: Implemented with proper input schema, context clearing logic, and comprehensive tests
  • Input type handling: Added explicit type checks with warning logs for unexpected input formats
  • Test coverage: Expanded from 213 to 352 lines with tests for all new functionality
Minor Notes (Non-blocking)
  • The discussion with @awsarron about class vs @tool approach is still open - waiting for direction on how to proceed
  • Coverage at 94.52% is acceptable; the 4 uncovered lines appear to be edge cases
  • Previous inline comments about empty string checks remain as suggestions but are not blocking

The implementation is well-designed, follows SDK patterns, and includes proper error handling and logging. Ready to merge once the awsarron discussion is resolved and any CI checks pass.

"type": "string",
"description": "The input to send to the agent tool.",
},
"preserve_context": {
Copy link
Member

Choose a reason for hiding this comment

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

This should be a class level field. I think most consumers want to decide at creation time whether it's a persistent tool (preserve_context=True) or new agent call on every invocation (preserve_context=False).

I also wonder if there's a way we can determine the best default - if not, maybe make it required

Copy link
Author

Choose a reason for hiding this comment

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

Swapped to class level field with default True. I think we should take the least destructive default

Copy link
Member

Choose a reason for hiding this comment

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

Our docs implementation seems to correspond to preserve_context=False: https://strandsagents.com/docs/user-guide/concepts/multi-agent/agents-as-tools/

I just realized, preserve_context=True might need some sort of integration with the parent agent's AgentState, as otherwise if an agent is persisted & rehydrated the context of the sub-agent isn't restored. Maybe that's alright, as it's a tool concern, bug given that it's a tool that we're vending, we should at least explore if an agent-as-tool should integrate with the parent's AgentState

self._tool_name,
tool_use_id,
)
messages.clear()
Copy link
Member

Choose a reason for hiding this comment

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

I don't think we want to clear the agent in this case; we want to restore it to the original state that it was at. The graph node does something similar I think.

In the future, we can use snapshots to simplify this.

AgentState is another one that will need to be restored

Copy link
Author

Choose a reason for hiding this comment

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

took the pattern from graph.py

Comment on lines +56 to +67
def test_init_sets_name(mock_agent):
tool = AgentAsTool(mock_agent, name="my_tool", description="desc")
assert tool.tool_name == "my_tool"


def test_init_sets_description(mock_agent):
tool = AgentAsTool(mock_agent, name="my_tool", description="custom desc")
assert tool._description == "custom desc"


def test_init_stores_agent_reference(mock_agent, tool):
assert tool.agent is mock_agent
Copy link
Member

Choose a reason for hiding this comment

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

Combine this into one test

Copy link
Author

Choose a reason for hiding this comment

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

fixed

Comment on lines +73 to +96
def test_tool_name(tool):
assert tool.tool_name == "test_agent"


def test_tool_type(tool):
assert tool.tool_type == "agent"


def test_tool_spec_name(tool):
assert tool.tool_spec["name"] == "test_agent"


def test_tool_spec_description(tool):
assert tool.tool_spec["description"] == "A test agent"


def test_tool_spec_input_schema(tool):
schema = tool.tool_spec["inputSchema"]["json"]
assert schema["type"] == "object"
assert "input" in schema["properties"]
assert schema["properties"]["input"]["type"] == "string"
assert "preserve_context" in schema["properties"]
assert schema["properties"]["preserve_context"]["type"] == "boolean"
assert schema["required"] == ["input"]
Copy link
Member

Choose a reason for hiding this comment

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

Do an assert on the entire spec at once

Copy link
Author

Choose a reason for hiding this comment

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

fixed

Comment on lines +100 to +102
props = tool.get_display_properties()
assert props["Agent"] == "test_agent"
assert props["Type"] == "agent"
Copy link
Member

Choose a reason for hiding this comment

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

Same here - assert on the entire object

Copy link
Author

Choose a reason for hiding this comment

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

fixed

if "result" in event:
result = event["result"]
else:
yield event
Copy link
Member

Choose a reason for hiding this comment

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

We will auto wrap this in a ToolStreamResult, but I think we actually want to do additional wrapping here - Maybe an AgentAsToolStreamEvent?

If we don't, the caller cannot tell the difference between "This is ToolStreamEvent that came from an tool wrapping an agent using a tool" vs "This is a ToolStreamEvent from a tool"

AgentAsToolStreamEvent would probably reference the AgentAsTool class

Copy link
Author

Choose a reason for hiding this comment

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

Added AgentAsToolStreamEvent class that is a subclass of ToolStreamEvent to differentiate

if not name:
name = self.name
if not description:
description = self.description or f"Use the {name} tool to invoke this agent as a tool"
Copy link
Member

Choose a reason for hiding this comment

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

We'll need a better description I think

invoke this agent as a tool

Doesn't really make sense to an agent. It's going to see a tool that says:

name: "researcher"
description: "Use the researcher tool to invoke this agent as a tool"

where this doesn't make sense.

Copy link
Author

Choose a reason for hiding this comment

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

swapped to Use the {name} agent as a tool by providing a natural language input


tool_use_id = tool_use["toolUseId"]

if not preserve_context:
Copy link
Member

Choose a reason for hiding this comment

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

We should throw if preserve_context=False and _agent != Agent; that would indicate A2A agent which for preserve_context=False doesn't make sense

Copy link
Author

Choose a reason for hiding this comment

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

put this check in the constructor

Copy link
Member

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

Copy link
Author

Choose a reason for hiding this comment

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

renamed

…e; yield AgentAsToolStreamEvents; small fixes
@notowen333
Copy link
Author

/strands review

@github-actions
Copy link

Code Review Summary - Round 3

Assessment: Approve ✅

Excellent work addressing all the feedback from @zastrowm! The implementation has been significantly improved.

Changes Addressed
Feedback Resolution
preserve_context should be class-level ✅ Moved to constructor parameter
Context restoration should restore to initial state ✅ Uses deepcopy of initial messages/state
Combine tests, assert on entire objects ✅ Tests consolidated
Add AgentAsToolStreamEvent ✅ Added to _events.py
Better default description ✅ "Use the {name} agent as a tool by providing a natural language input"
Throw if preserve_context=False with non-Agent ✅ TypeError in constructor
Rename to _agent_as_tool.py ✅ Renamed and exported at top level
Code Quality Highlights
  • State Management: Proper deepcopy of initial state mirrors GraphNode.reset_executor_state() pattern
  • Event Differentiation: AgentAsToolStreamEvent extends ToolStreamEvent for clear sub-agent identification
  • Type Safety: TypeError raised early in constructor for invalid preserve_context=False usage
  • Exports: AgentAsTool now properly exported from main strands module
  • Test Coverage: 429 lines of comprehensive tests including edge cases
Remaining Discussion (Non-blocking)

@zastrowm raised a consideration about preserve_context=True and integration with parent AgentState for persistence/rehydration. This is a valid point for future enhancement but doesn't block this PR.

The implementation is well-designed, follows SDK patterns, and addresses the original issue #1002 completely. Ready to merge! 🎉

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] Agent to_tool method

3 participants