Skip to content

Repository files navigation

CI

tool-loop

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.


What the naive loop gets wrong

1. Serial tool dispatch

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.

2. Exceptions crashing the loop

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=True

3. Malformed message history

The 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.

4. Ignoring stop_reason

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(...)

Install

pip install -e .

Requires Python ≥ 3.10 and the anthropic SDK.


Usage

Basic

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

With a system prompt

result=loop.run(
messages=[{"role": "user", "content": "..."}],
system="You are a precise assistant. Always use tools for external data.",
)

Inspecting the trace

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"])

Auto-schema generation

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".


ToolLoop options

ParameterDefaultDescription
clientanthropic.Anthropic() instance
modelModel string, e.g. "claude-opus-4-8"
toolsList of Python callables
max_iterations10Hard stop — prevents infinite loops
parallelTrueDispatch multiple tool calls concurrently
max_tokens4096Per-call output token budget

LoopResult

FieldTypeDescription
messageslistFull conversation history including tool turns
tracelistPer-iteration dict with tool calls, results, tokens
tokensdictCumulative input, output, cache_read, cache_write
stop_reasonstrFinal stop reason from the last model call
final_text()methodLast text block the model emitted
to_markdown()methodHuman-readable trace for debugging

Running examples

export ANTHROPIC_API_KEY=sk-...
python examples/calculator.py
python examples/filesystem_agent.py

calculator.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.


Tests

pip install -e ".[dev]"
pytest -v

All tests mock the Anthropic client — no API key needed.

About

Correct, parallel Claude tool-use loop with auto-schema generation from Python functions

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages