A minimal, correct implementation of the Anthropic agentic tool-use loop.
Most implementations get at least one of these wrong. This one doesn't.
importanthropicfromtoolloopimportToolLoopdefsearch(query: str) ->str:
"""Search the knowledge base and return matching passages."""
... # your real implementationdeflookup(ticker: str) ->str:
"""Return the current price for a stock ticker."""
...
loop=ToolLoop(client=anthropic.Anthropic(), model="claude-opus-4-8", tools=[search, lookup])
result=loop.run([{"role": "user", "content": "Compare AAPL and MSFT based on recent news."}])
print(result.final_text())Tools are plain Python functions. Schemas are auto-generated from type annotations and docstrings — no JSON by hand.
When the model returns two tool_use blocks in a single response, a naive loop calls them one at a time:
# common — unnecessarily slowforblockintool_blocks:
result=call_tool(block)ToolLoop dispatches them concurrently with ThreadPoolExecutor:
# correct — runs in parallelwithThreadPoolExecutor(max_workers=len(tool_blocks)) asex:
futures= {ex.submit(call_tool, b): b.idforbintool_blocks}If the model calls a slow external API and a fast local function in the same turn, both finish in the time of the slowest one.
A tool that raises should return an error to the model so it can recover — not propagate an exception to the caller:
# broken — one bad tool call kills the entire agentresult=my_tool(**block.input)
# correct — model sees the error and can retry or recovertry:
out=my_tool(**block.input)
returnstr(out), FalseexceptExceptionasexc:
returnf"{type(exc).__name__}: {exc}", True# is_error=TrueThe assistant message must include all content blocks — both text and tool_use — not just the text:
# broken — omits the tool_use blocks, API rejects itmessages.append({"role": "assistant", "content": response_text})
# correct — full content listmessages.append({"role": "assistant", "content": response.content})The tool_result user turn must use the exact tool_use_id from the corresponding block. Mismatches produce silent hallucinations.
A response can contain tool_use blocks but have stop_reason = "max_tokens" (output was truncated mid-tool-call). Treating this as a normal tool invocation sends a malformed request:
# broken — ignores stop_reasonifany(b.type=="tool_use"forbinresponse.content):
dispatch_tools(...)
# correct — only dispatch on explicit tool_use stopifresponse.stop_reason=="tool_use":
dispatch_tools(...)pip install -e .Requires Python ≥ 3.10 and the anthropic SDK.
importanthropicfromtoolloopimportToolLoopdefget_weather(city: str) ->str:
"""Return current weather for a city."""returnf"Sunny, 72°F in {city}"loop=ToolLoop(
client=anthropic.Anthropic(),
model="claude-opus-4-8",
tools=[get_weather],
)
result=loop.run([{"role": "user", "content": "What's the weather in Chicago?"}])
print(result.final_text())result=loop.run(
messages=[{"role": "user", "content": "..."}],
system="You are a precise assistant. Always use tools for external data.",
)print(result.to_markdown())
# Step 0 stop_reason=tool_use# - `get_weather({"city": "Chicago"})` → [ok] Sunny, 72°F in Chicago# *tokens: 312 in / 48 out*## Step 1 stop_reason=end_turn# > It's sunny and 72°F in Chicago right now.# *tokens: 374 in / 21 out*## Total tokens — input: 686 output: 69 cache_read: 0 cache_write: 0forstepinresult.trace:
print(step["iteration"], step["stop_reason"], step["tool_calls"])fromtoolloopimportfn_to_schemaimportjsondefcalculate(expression: str, precision: int=6) ->str:
"""Evaluate a mathematical expression to the given decimal precision."""returnstr(round(eval(expression), precision))
print(json.dumps(fn_to_schema(calculate), indent=2)){
"name": "calculate",
"description": "Evaluate a mathematical expression to the given decimal precision.",
"input_schema": {
"type": "object",
"properties": {
"expression": {"type": "string"},
"precision": {"type": "integer"}
},
"required": ["expression"]
}
}Parameters with defaults are not required. Optional[X] annotations map correctly. Unannotated parameters default to "string".
| Parameter | Default | Description |
|---|---|---|
client | — | anthropic.Anthropic() instance |
model | — | Model string, e.g. "claude-opus-4-8" |
tools | — | List of Python callables |
max_iterations | 10 | Hard stop — prevents infinite loops |
parallel | True | Dispatch multiple tool calls concurrently |
max_tokens | 4096 | Per-call output token budget |
| Field | Type | Description |
|---|---|---|
messages | list | Full conversation history including tool turns |
trace | list | Per-iteration dict with tool calls, results, tokens |
tokens | dict | Cumulative input, output, cache_read, cache_write |
stop_reason | str | Final stop reason from the last model call |
final_text() | method | Last text block the model emitted |
to_markdown() | method | Human-readable trace for debugging |
export ANTHROPIC_API_KEY=sk-...
python examples/calculator.py
python examples/filesystem_agent.pycalculator.py sends a multi-part math question, triggering parallel tool dispatch across three independent computations. filesystem_agent.py demonstrates error recovery: if a file doesn't exist, the error is returned as a tool_result with is_error=True so the model can adjust rather than crashing.
pip install -e ".[dev]"
pytest -vAll tests mock the Anthropic client — no API key needed.