Skip to content
Zarif Automates

Run a Bounded Tool-Calling Loop in Python

ZarifZarif
|Published |Updated

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. It uses the Messages API's tool_use and tool_result shapes, documented in Anthropic's 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:

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:

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:

{
    "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:

{
  "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:

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

FixtureObserved outcome
Read, propose, final textTwo successful tools; loop finishes; proposal not applied
Ticket outside allowed scopeError result record_unavailable, then final text
Tool named send_emailError result unknown_tool, then final text
Extra authorized argumentError result invalid_fields, then final text
Proposal for version 2Error result stale_or_invalid_version, then final text
Three fresh read requestsThird dispatch blocked by tool budget
Repeated call IDSecond dispatch blocked as duplicate
max_tokens stopIncomplete response; no tool dispatched
Two calls in one responseUnsupported batch; neither dispatched
Content that is not a block listInvalid 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:

{
  "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:

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 to expose a capability over a real protocol connection.

Zarif

Zarif

Zarif builds AI agents and automation workflows and writes about what holds up in production: the sources worth following, the roles the AI era is creating, and agent workflows you can inspect end to end.