Skip to content

Tools & agents

run_tool_loop handles the complete tool-call cycle. It calls the model, runs the requested functions from dispatch, and repeats until the model returns a final reply with no further tool calls.

def get_weather(city: str) -> str:
    return f"It is 20°C in {city}"


tools = [
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "Current weather in a city",
            "parameters": {
                "type": "object",
                "properties": {"city": {"type": "string"}},
                "required": ["city"],
            },
        },
    }
]

broker = llmbroker.Broker()
reply = llmbroker.run_tool_loop(
    broker,
    [{"role": "user", "content": "What is the weather in Paris?"}],
    tools=tools,
    dispatch={"get_weather": get_weather},
)
print(reply.text, "— by", reply.llm_name)

The function returns the final model call in the same format as chat: its text, model name, and usage. Each earlier call has a separate journal row. The returned usage therefore covers only the final call; calculate totals for the whole loop from the journal.

With AsyncBroker, use await llmbroker.arun_tool_loop(...).

max_steps, which defaults to 8, limits the number of model calls. If the model requests another tool on the final step, the function raises llmbroker.ToolLoopLimitError. Increase max_steps or handle the exception if you want to retain the intermediate result.

Parameters passed to the broker

Additional parameters, including operation=, trace_id=, and wait=, are passed to every chat call in the loop. Set them as you would for a single chat call. Without operation=, every call uses the same general category, so quality ratings cannot distinguish between tasks:

reply = llmbroker.run_tool_loop(
    broker,
    messages,
    tools=tools,
    dispatch={"get_weather": get_weather},
    operation="weather-agent",
    trace_id=request_id,
)

Using one trace_id groups all calls in a loop in the journal. A rating recorded with that ID applies only to the most recent successful call. To rate another call in the same loop, keep its call_id. See Quality rating.

A specific model

The loop also accepts a direct client in place of the broker. Every call then goes to the one model you selected:

broker = llmbroker.Broker(direct=["opus"])
reply = llmbroker.run_tool_loop(
    broker.direct("opus"),
    [{"role": "user", "content": "What is the weather in Paris?"}],
    tools=tools,
    dispatch={"get_weather": get_weather},
    params={"temperature": 0},
)
print(reply.text, reply.usage)

The result is the final DirectResult, with text, tool_calls, and usage. It has no llm_name or call_id: you selected the model yourself, and a direct call writes no journal row. The loop does not try another model after an error; the exception reaches the application. Additional parameters are passed to the client's chat, so use timeout= and params= here instead of operation=, trace_id=, or wait=.

A catalog alias needs no parameters to take part in the loop: parameters that its model requires with tools come from the catalog and are sent in every round. Your params override them. See Direct model calls.

Tool error handling

Exceptions raised by functions in dispatch do not propagate to the caller. If a function fails, the model receives Tool <name> failed: <error> and may try again with different arguments. If dispatch does not contain the requested function, the model receives Unknown tool <name>. Successful function results are also converted with str(), so tools should return text or a serialized JSON string rather than an arbitrary object.

If a function converts an error into an ordinary result, the loop continues and uses one step. To make a tool failure stop the request, handle it in the function and return an unambiguous failure to the model, or implement the loop yourself. chat(messages, tools=...) returns tool_calls, after which the application can choose what to do.

Scoped calls

The first argument can be any object with a chat method that accepts tools=: a broker, a direct client, or a caller from broker.for_scope(user). With a scoped caller, every call in the loop uses that user's keys and is journaled under that scope. See Multi-user applications.