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

> Learn to implement tool calling with max_turns for multi-turn conversations in aisuite. Limit tool invocations using a custom ToolPolicy and RunContext.

- Repository: [Andrew Ng/aisuite](https://github.com/andrewyng/aisuite)
- Tags: how-to-guide
- Published: 2026-07-27

---

**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`](https://github.com/andrewyng/aisuite/blob/main/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`](https://github.com/andrewyng/aisuite/blob/main/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`](https://github.com/andrewyng/aisuite/blob/main/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`](https://github.com/andrewyng/aisuite/blob/main/aisuite/agents/context.py) stores conversation state.

```python

# 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`.

```python

# 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`](https://github.com/andrewyng/aisuite/blob/main/aisuite/utils/tools.py), the `execute_tool` method accepts a `tool_policy` parameter that it evaluates via `_evaluate_tool_policy`.

```python
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:

```python
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

```python

# 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)

```

```python

# 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

```python

# 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`](https://github.com/andrewyng/aisuite/blob/main/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`](https://github.com/andrewyng/aisuite/blob/main/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`](https://github.com/andrewyng/aisuite/blob/main/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`](https://github.com/andrewyng/aisuite/blob/main/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`](https://github.com/andrewyng/aisuite/blob/main/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`](https://github.com/andrewyng/aisuite/blob/main/aisuite/utils/tools.py)). This preserves observability while adding your custom limits.