Run a Bounded Tool-Calling Loop in Python
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
| 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:
{
"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.
