How to Implement Tool Calling with max_turns for Multi-Turn Conversations in aisuite

You can limit tool invocations in aisuite multi-turn conversations by implementing a custom ToolPolicy that increments a counter stored in the RunContext, which evaluates before each tool execution in Tools.execute_tool or aexecute_tool.

aisuite provides a flexible tool-calling framework centered around the Tools manager in aisuite/utils/tools.py. When building agents that invoke external tools across multiple LLM turns, you need safeguards to prevent infinite loops or excessive API calls. This guide demonstrates how to implement a max_turns limit for tool calling that integrates with aisuite's existing policy and tracing infrastructure.

Three Approaches to Limiting Tool Turns

The aisuite library offers three viable patterns for enforcing a maximum number of tool calls in a single conversation:

  • Agent-level counter: Store max_tool_turns and a mutable tool_turn_counter directly in the agent's run context (aisuite/agents/context.py). Increment the counter each time Tools.execute_tool runs and abort when the limit is reached. This approach is simple and requires no additional policy objects.

  • Custom ToolPolicy: Implement a subclass of ToolPolicyDecision in aisuite/agents/policy.py. The policy receives a ToolPolicyContext that you can extend with a counter. When the counter exceeds the configured limit, return ToolPolicyDecision(allowed=False, reason="max-turns-exceeded"). This reuses aisuite's existing policy mechanism and works for both sync and async paths.

  • Wrapper function: Create a thin wrapper (e.g., execute_tool_limited) that accepts a max_turns argument, delegates to Tools.execute_tool, and raises RuntimeError if the internal counter surpasses the limit. This keeps the core Tools class untouched and is easy to plug into existing client code.

The following implementation uses the custom ToolPolicy approach (Option B) because aisuite already provides a clean hook for policy evaluation inside Tools._prepare_tool_call. This method automatically integrates with both synchronous and asynchronous execution paths and respects the tracing and event-recording logic that aisuite emits.

Step-by-Step Implementation

1. Extend the Run Context

First, add a counter and limit field to the context object that Tools._active_trace_context() returns. The RunContext class in aisuite/agents/context.py stores conversation state.


# aisuite/agents/context.py

from dataclasses import dataclass, field

@dataclass
class RunContext:
    trace_id: str | None = None
    agent_name: str | None = None
    run_name: str | None = None
    # Existing fields...

    max_tool_turns: int | None = None          # User-set limit

    tool_turn_counter: int = 0                 # Mutable state

The RunContext is the object returned by get_active_run_context(), which the Tools class references during execution.

2. Create the Max-Turns Policy

Implement a policy class that inspects the context, updates the counter, and decides whether to allow the call. This policy integrates with aisuite's existing policy evaluation in Tools._prepare_tool_call.


# aisuite/agents/policy.py

from aisuite.agents.context import RunContext
from aisuite.agents.policy import ToolPolicyDecision, ToolPolicyContext

class MaxTurnsPolicy:
    """Tool policy that stops tool calls after a configured number of turns."""

    def __call__(self, ctx: ToolPolicyContext) -> ToolPolicyDecision:
        # Access the active RunContext

        run_ctx: RunContext = ctx.run_context
        
        if run_ctx.max_tool_turns is None:
            # No limit configured → always allow

            return ToolPolicyDecision(allowed=True)

        # Increment the counter before the tool runs

        run_ctx.tool_turn_counter += 1
        if run_ctx.tool_turn_counter > run_ctx.max_tool_turns:
            return ToolPolicyDecision(
                allowed=False,
                reason=f"max-turns-exceeded ({run_ctx.max_tool_turns})",
            )
        return ToolPolicyDecision(allowed=True)

3. Configure the Tools Manager

When initializing your conversation, set the limit in the active context and pass the policy to the Tools manager. According to the source code in aisuite/utils/tools.py, the execute_tool method accepts a tool_policy parameter that it evaluates via _evaluate_tool_policy.

from aisuite.utils.tools import Tools
from aisuite.agents.policy import MaxTurnsPolicy
from aisuite.agents.context import get_active_run_context

# Initialize the run context for the current conversation

ctx = get_active_run_context()
ctx.max_tool_turns = 5          # Allow at most 5 tool calls

ctx.tool_turn_counter = 0

# Build the tool manager and attach the policy

tool_manager = Tools([my_tool_func, another_tool])
max_turns_policy = MaxTurnsPolicy()

# When processing a model response containing tool calls:

results, messages = tool_manager.execute_tool(
    tool_calls=model_output.tool_calls,
    tool_policy=max_turns_policy,          # Policy consulted for each call

)

The same pattern works for the async variant aexecute_tool without modification to the policy class.

4. Optional: Convenience Wrapper

If you prefer not to manipulate the context directly in your main logic, create a helper function:

def execute_with_max_turns(tools: Tools, tool_calls, max_turns: int):
    ctx = get_active_run_context()
    ctx.max_tool_turns = max_turns
    ctx.tool_turn_counter = 0
    return tools.execute_tool(
        tool_calls,
        tool_policy=MaxTurnsPolicy(),
    )

Complete Code Examples

Synchronous Implementation


# policy.py

from aisuite.agents.context import RunContext, get_active_run_context
from aisuite.agents.policy import ToolPolicyDecision, ToolPolicyContext

class MaxTurnsPolicy:
    def __call__(self, ctx: ToolPolicyContext) -> ToolPolicyDecision:
        run_ctx = get_active_run_context()
        
        if run_ctx.max_tool_turns is None:
            return ToolPolicyDecision(allowed=True)
            
        run_ctx.tool_turn_counter += 1
        if run_ctx.tool_turn_counter > run_ctx.max_tool_turns:
            return ToolPolicyDecision(
                allowed=False,
                reason=f"max-turns-exceeded ({run_ctx.max_tool_turns})",
            )
        return ToolPolicyDecision(allowed=True)

# client_usage.py

from aisuite.utils.tools import Tools
from aisuite.agents.context import get_active_run_context
from policy import MaxTurnsPolicy

# Configure conversation context

run_ctx = get_active_run_context()
run_ctx.max_tool_turns = 3
run_ctx.tool_turn_counter = 0

# Initialize tools

tool_manager = Tools([search_web, calculate])
max_policy = MaxTurnsPolicy()

# Execute with limit

results, msgs = tool_manager.execute_tool(
    tool_calls=model_output.tool_calls,
    tool_policy=max_policy,
)

Async Implementation


# async_usage.py

async def process_with_limit(model_output, tools):
    run_ctx = get_active_run_context()
    run_ctx.max_tool_turns = 5
    run_ctx.tool_turn_counter = 0
    
    return await tools.aexecute_tool(
        tool_calls=model_output.tool_calls,
        tool_policy=MaxTurnsPolicy(),
    )

How This Works

The implementation leverages three key integration points in the aisuite codebase:

  1. Policy Evaluation Hook: In aisuite/utils/tools.py, the Tools._prepare_tool_call method invokes _evaluate_tool_policy before any tool runs. By supplying a custom policy, you create a pre-execution gate that can abort the call early while still recording a "tool.denied" event (see lines 510-527 in the source).

  2. Shared Run Context: The counter lives on the RunContext object returned by get_active_run_context(), which persists across the entire multi-turn conversation. This ensures the limit automatically applies to successive LLM replies without additional state management.

  3. Sync/Async Compatibility: Because aisuite's ToolPolicy interface is independent of the concrete tool implementation, the same policy class works for both execute_tool and aexecute_tool without code duplication.

When the limit is exceeded, the LLM receives a deterministic "tool call denied" response, enabling graceful fallback logic or user prompting to take over the conversation.

Summary

  • aisuite's tool-calling workflow centers on the Tools class in aisuite/utils/tools.py, which validates arguments and manages tool invocation.
  • You can enforce max_turns limits by implementing a custom ToolPolicy that maintains a counter in the RunContext from aisuite/agents/context.py.
  • The policy evaluates before each tool execution via the tool_policy parameter in execute_tool or aexecute_tool.
  • This approach works for both synchronous and asynchronous code paths and preserves aisuite's built-in tracing and event recording.
  • Store the turn counter in the run context to maintain state across multi-turn conversations automatically.

Frequently Asked Questions

What happens when the max_turns limit is reached?

When the counter exceeds the configured limit, the MaxTurnsPolicy returns ToolPolicyDecision(allowed=False, reason="max-turns-exceeded"). According to the implementation in aisuite/utils/tools.py, this triggers a "tool.denied" event in the tracing system, and the tool execution aborts before invoking the actual function. The conversation can then handle the denial gracefully, typically by returning control to the LLM or user.

Can I use max_turns with async tool execution?

Yes. Because the ToolPolicy interface is execution-model agnostic, the same MaxTurnsPolicy class works with both Tools.execute_tool (synchronous) and Tools.aexecute_tool (asynchronous). The policy evaluates in the same pre-execution phase regardless of which method you call, ensuring consistent behavior across sync and async code paths.

Where should I store the turn counter to ensure it persists across conversation turns?

Store the counter in the RunContext object from aisuite/agents/context.py. This context is retrieved via get_active_run_context() and remains active for the duration of the conversation. Because Tools._active_trace_context() accesses this same context object, your counter will automatically persist and increment across multiple LLM response cycles without requiring external state management.

How does this integrate with aisuite's existing tracing features?

The custom policy approach respects aisuite's tracing infrastructure because it works within the existing policy evaluation framework. When you deny a tool call via ToolPolicyDecision(allowed=False), aisuite records this as a "tool.denied" event in the trace (as seen in the event recording logic in aisuite/utils/tools.py). This preserves observability while adding your custom limits.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →