Skip to content

feat: Add programmatic tool caller#387

Open
mkmeral wants to merge 15 commits intostrands-agents:mainfrom
mkmeral:feat/programmatic-tool-caller
Open

feat: Add programmatic tool caller#387
mkmeral wants to merge 15 commits intostrands-agents:mainfrom
mkmeral:feat/programmatic-tool-caller

Conversation

@mkmeral
Copy link
Contributor

@mkmeral mkmeral commented Feb 5, 2026

Description

Adds programmatic_tool_caller tool that enables code-based tool invocation. Agents can write Python code that calls other tools as async functions, reducing API round-trips and enabling complex orchestration patterns like loops, parallel execution, and chaining.

Note: Does not work on Windows.

Key Features

  • Async-first design: Tools exposed as await tool_name(...) - code runs in async context automatically
  • Only print() output returned: Tool results stay in code execution context, don't enter agent's context window unless explicitly printed
  • Pluggable executor: Custom Executor implementations for sandboxed environments (Docker, Lambda, etc.)
  • Security controls: Code validation for dangerous patterns, user confirmation (bypassable via BYPASS_TOOL_CONSENT), configurable allowed tools

Example Usage

from strands import Agent
from strands_tools import programmatic_tool_caller, calculator

agent = Agent(tools=[programmatic_tool_caller, calculator])

result = agent.tool.programmatic_tool_caller(
    code="""
# Simple tool call
result = await calculator(expression="2 + 2")
print(f"Result: {result}")

# Loop with tool calls
total = 0
for i in range(1, 6):
    square = await calculator(expression=f"{i} ** 2")
    total += int(square)
print(f"Sum of squares: {total}")

# Parallel execution
results = await asyncio.gather(
    calculator(expression="10 * 1"),
    calculator(expression="10 * 2"),
    calculator(expression="10 * 3"),
)
print(f"Parallel results: {results}")
"""
)

Environment Variables

Variable Description
BYPASS_TOOL_CONSENT Skip user confirmation if "true"
PROGRAMMATIC_TOOL_CALLER_ALLOWED_TOOLS Comma-separated list of tools to expose (default: all except self)

Custom Executors

from strands_tools.programmatic_tool_caller import programmatic_tool_caller, Executor

class DockerExecutor(Executor):
    def execute(self, code: str, namespace: dict) -> str:
        # Run in sandboxed container
        ...

programmatic_tool_caller.executor = DockerExecutor()

Related Issues

Type of Change

New Tool

Testing

  • Unit tests for executor, tool execution, validation, and allowed tools filtering
  • Integration tests with real Agent and tools
  • Tests cover async execution, loops, asyncio.gather, custom executors, user cancellation

Checklist

  • I have read the CONTRIBUTING document
  • I have added any necessary tests that prove my fix is effective or my feature works
  • I have updated the documentation accordingly (README.md)
  • I have added an appropriate example to the documentation to outline the feature
  • My changes generate no new warnings

Containerized Agent added 7 commits February 5, 2026 04:37
This tool enables programmatic/code-based tool invocation for Strands Agents,
inspired by Anthropic's Programmatic Tool Calling feature. It allows an agent
to write Python code that calls other tools as functions.

Key features:
- Tools exposed as callable methods via 'tools.<tool_name>(**kwargs)'
- Supports complex orchestration with loops, conditionals, data processing
- Captures stdout/stderr from executed code
- Records all tool calls for transparency
- Validates code for potentially dangerous patterns
- User confirmation required unless BYPASS_TOOL_CONSENT is set

Example usage:
  result = agent.tool.programmatic_tool_caller(
      code='''
      result = tools.calculator(expression="2 + 2")
      print(f"Result: {result}")
      '''
  )

The tool integrates with Strands' DecoratedFunctionTool pattern, calling
tools directly with keyword arguments and handling both string and dict
return values.

Includes comprehensive unit tests covering:
- ToolProxy functionality
- Code validation
- Tool execution
- Integration with real tools
- Edge cases and error handling
Changes:
- Use tool_context via @tool(context=True) instead of agent parameter
- Handle multiple content blocks in tool results (combine all text)
- Remove allowed_tools parameter (let agent decide which tools to use)
- Add comprehensive integration tests with real tools
- Fix test assertions and add more edge case coverage

Test coverage:
- 43 unit tests
- 10 integration tests
- All tests passing
- Add tool entry to the tools table
- Add usage example section with code sample
- Note that tool does not work on Windows (uses exec)
Major changes:
- Remove ToolProxy class, inject tools directly as functions
- Tools exposed as both async (tool_name) and sync (tool_name_sync)
- Only return print() output, not tool call summary or execution time
- Support async tool calls via asyncio

This aligns with Anthropic's design where:
- Tools are callable as async functions: await tool_name(...)
- Only print() output is captured and returned to agent
- Tool results stay in code execution context, don't enter agent messages
@mkmeral mkmeral changed the title Feat/programmatic tool caller feat: Add programmatic tool caller Feb 5, 2026
Containerized Agent added 2 commits February 5, 2026 06:10
- Remove sync functions, only expose async (await tool_name(...))
- Auto-wrap user code in async function - no boilerplate needed
- Support asyncio.gather() for parallel execution
- Simplified implementation and tests
Containerized Agent added 3 commits February 5, 2026 15:08
- Add Executor abstract base class for custom execution environments
- LocalAsyncExecutor as default (local exec with asyncio)
- Custom executors can be set via: programmatic_tool_caller.executor = MyExecutor()
- Add PROGRAMMATIC_TOOL_CALLER_ALLOWED_TOOLS env var to control exposed tools
- Tests for executor swapping and env var filtering
@mkmeral mkmeral marked this pull request as ready for review February 5, 2026 22:09
@mkmeral mkmeral force-pushed the feat/programmatic-tool-caller branch from be93226 to 717566e Compare February 6, 2026 17:43
Use agent.tool.<name>() instead of directly calling tool_impl() from registry.
This properly handles all tool types including MCP tools which are not directly
callable but work through the ToolExecutor._stream() mechanism.

- Changed _execute_tool to use getattr(agent.tool, tool_name)()
- Added record_direct_tool_call=False to prevent polluting message history
- Handle AttributeError for tool not found case
@mkmeral mkmeral force-pushed the feat/programmatic-tool-caller branch from 717566e to b273bd6 Compare February 6, 2026 18:43
@mkmeral mkmeral deployed to auto-approve February 6, 2026 18:43 — with GitHub Actions Active
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant