# Run a Bounded Tool-Calling Loop in Python

> Run a Python tool-calling loop with schemas, argument checks, matched results, turn limits and ten deterministic success and failure fixtures.

- Source: https://www.zarifautomates.com/blog/how-to-build-an-ai-agent-with-tool-use-and-function-calling
- Published: 2026-09-17
- Updated: 2026-09-19
- Pillar: Agents & AI Engineering
- Tags: agent-course, tool-use, practical-guide
- Author: Zarif

---

A model can request a function call. Your application decides whether to run it, supplies the result, and decides how much longer the conversation may continue. Keeping those responsibilities separate makes the loop easier to understand and test.

This lesson builds that loop around two small tools: read a synthetic ticket and propose a label. The Python handlers execute for real. A scripted assistant supplies predictable responses so you can inspect ten cases without an API key. No ticket is changed and no message is sent.

[Download the complete tool loop](/downloads/agent-course/tool_loop.py). It uses the Messages API's `tool_use` and `tool_result` shapes, documented in [Anthropic's tool-use overview](https://platform.claude.com/docs/en/agents-and-tools/tool-use/overview). The local script does not contact Anthropic or test Claude's choice of tools.

## 1. Run it and inspect the trace

Use Python 3.10 or newer, save the download as `tool_loop.py`, and run:

```bash
python3 tool_loop.py
```

Use `python` if that is your Python 3 command. No dependencies are required. The September 17, 2026 fixture run passed all ten cases and confirmed that the ticket remained unchanged.

The first case takes three assistant turns:

```text
Turn 1: get_ticket(DEMO-42) → ticket text and version 3
Turn 2: propose_label(DEMO-42, billing, 3) → applied: false
Turn 3: final text → loop finishes
```

The output includes the call ID, tool name, error flag and result for every executed call. Read that trace alongside the final text. A sentence saying a task succeeded is not a substitute for a confirmed tool result.

## 2. Describe one capability at a time

The script passes two tool definitions to its assistant interface. The first is:

```python
{
    "name": "get_ticket",
    "description": "Read the permitted synthetic ticket before proposing a label.",
    "input_schema": {
        "type": "object",
        "properties": {"ticket_id": {"type": "string"}},
        "required": ["ticket_id"],
        "additionalProperties": False,
    },
}
```

The second tool, `propose_label`, requires `ticket_id`, one of three allowed labels, and an integer `expected_version`. Its result always includes `applied: false`.

The schema describes the expected shape, but the handler checks it again. In the downloaded `execute` function, an extra `authorized` field is rejected instead of being treated as proof of permission. The allowed ticket set comes from the caller of `run_loop`, outside the assistant's arguments.

For this local exercise that set contains only `DEMO-42`. A real application must derive its scope from an authenticated caller and current policy. Do not let the model supply its own owner ID or an access-control decision.

## 3. Validate, execute and return a matching result

The scripted assistant's first response looks like this:

```json
{
  "stop_reason": "tool_use",
  "content": [{
    "type": "tool_use",
    "id": "call-1",
    "name": "get_ticket",
    "input": {"ticket_id":"DEMO-42"}
  }]
}
```

The loop checks the response shape and requires exactly one tool call for that turn. It verifies a nonempty, previously unseen call ID and checks the call budget before dispatching to `execute`.

The handler uses a fixed allowlist of tool names. It never evaluates a string as code or looks up an arbitrary Python function. Unknown names, incorrect fields, unavailable records and stale versions become a `ToolRejected` result.

After dispatch, the loop retains the original assistant response and appends a user message containing the matching result:

```python
messages.extend([
    {"role": "assistant", "content": blocks},
    {"role": "user", "content": [{
        "type": "tool_result",
        "tool_use_id": call_id,
        "content": json.dumps(result),
        "is_error": is_error,
    }]},
])
```

The next assistant turn receives that history. The fixture assistant checks that the preceding response is preserved and the result references the right call ID. A result for `call-2` must not be attached to `call-1`.

## 4. Keep the stop conditions in code

The default budgets allow at most **three assistant turns** and **two tool dispatches**. A rejected tool request still consumes a dispatch: changing invalid arguments repeatedly does not buy an unlimited loop.

An `end_turn` response must contain text and no tool calls. An output-limit stop returns `incomplete_response`. The loop does not pretend that partial output completed the task. Duplicate call IDs stop the loop before another dispatch.

This example also rejects parallel tool batches. That is a deliberate scope limit, not a claim that the provider cannot return multiple calls. If you add parallelism, preserve a result for every call ID and define how dependencies and partial failures work before running operations together.

The budgets control counts. They do not establish a wall-clock or spending limit for a real provider connection. An adapter that calls an API also needs its own request timeout, token limits, error handling and permitted account budget.

## 5. Compare the ten observed cases

| Fixture | Observed outcome |
| --- | --- |
| Read, propose, final text | Two successful tools; loop finishes; proposal not applied |
| Ticket outside allowed scope | Error result `record_unavailable`, then final text |
| Tool named `send_email` | Error result `unknown_tool`, then final text |
| Extra `authorized` argument | Error result `invalid_fields`, then final text |
| Proposal for version 2 | Error result `stale_or_invalid_version`, then final text |
| Three fresh read requests | Third dispatch blocked by tool budget |
| Repeated call ID | Second dispatch blocked as duplicate |
| `max_tokens` stop | Incomplete response; no tool dispatched |
| Two calls in one response | Unsupported batch; neither dispatched |
| Content that is not a block list | Invalid response; no tool dispatched |

“Loop finishes” means the conversation reached a valid final-text response. It does not mean every tool succeeded. The denied-record case finishes after returning an error, and the trace keeps that distinction visible.

The exact successful proposal in the first case is:

```json
{
  "ticket_id": "DEMO-42",
  "label": "billing",
  "expected_version": 3,
  "applied": false
}
```

The script checks that the original ticket still has label `other` afterward. A proposal needs a separate approval and execution path before it can change a record.

## 6. Change the budget and inspect the consequence

For a focused exercise, call the existing functions from a second file in the same directory:

```python
from tool_loop import ScriptedAssistant, run_loop, tool

assistant = ScriptedAssistant([
    tool("demo-call", "get_ticket", {"ticket_id": "DEMO-42"}),
])
print(run_loop(assistant, max_turns=1))
```

The expected state is `turn_budget_exhausted`, with one successful read in the trace. The loop has a tool result but no remaining assistant turn to turn it into final text. Decide whether your application should show that partial result, request another authorized run, or stop with an explanation.

Then change the stale-version fixture from version 2 to boolean `true` in JSON, or `True` in Python. It should still return `stale_or_invalid_version`. The handler requires an actual integer as well as the current value.

To connect a live provider later, implement the `model(messages, tools)` interface using its current API and return the documented response dictionary. Keep the local fixtures as regression tests. Provider responses add failure modes these scripted cases do not cover, so record that new test scope separately.

Your deliverable is the ten-case output and a trace explaining one rejected call. Continue to the [MCP server and client lesson](/blog/how-to-build-an-ai-agent-using-mcp-model-context-protocol) to expose a capability over a real protocol connection.

## Related Guides

- [Agent Design Patterns: Run Four Control Flows and Their Failures](/blog/ai-agent-design-patterns-production-systems)
- [Plan and Execute: Build a Bounded Ticket Workflow](/blog/how-to-build-ai-agent-that-plans-and-executes-tasks)
- [Choose an Agent Starter: Inspect Three Repositories and Test One](/blog/best-ai-agent-template-libraries-and-starters)

## Continue the course

Lesson 4 of 17.

Previous lesson: [Three Prompt Contracts You Can Check with Evaluation Fixtures](https://www.zarifautomates.com/blog/prompt-contracts-with-evaluation-fixtures.md).

Next lesson: [Build an MCP Server and Client for a Ticket-Label Tool](https://www.zarifautomates.com/blog/how-to-build-an-ai-agent-using-mcp-model-context-protocol.md).

[Browse available lessons](https://www.zarifautomates.com/blog/pillar/agents-and-ai-engineering#agent-course-heading).
