Assistant turn (with tool_calls) — the model says "to answer this, I need to call get_weather(Tokyo)".
Tool turn — your code executes the function and adds {"role": "tool", "content": "..."} to the conversation.
Assistant turn (final) — the model uses the tool result to answer.
It's a loop, not a single round
The model may call tools again after seeing tool results. You loop: send messages → if response has tool_calls, execute them and append → otherwise return the answer. There's no fixed maximum, but in practice 5–10 iterations covers almost everything; cap the loop to prevent runaways.
Append, don't replace
The conversation is the full message history. Each iteration appends: assistant's tool_calls turn, then the tool's result turn. The model needs the whole history to reason correctly on the next turn. Replacing the assistant turn loses the model's plan.
Code
Production-grade tool loop·python
import httpx, json, glob
OLLAMA = "http://localhost:11434/api/chat"
def get_weather(city: str, unit: str = "celsius") -> str:
# Stub — replace with a real API call
return json.dumps({"city": city, "temp": 22, "unit": unit, "condition": "sunny"})
def search_files(pattern: str, directory: str = ".") -> str:
matches = glob.glob(f"{directory}/{pattern}")
return json.dumps({"files": matches[:10], "count": len(matches)})
REGISTRY = {"get_weather": get_weather, "search_files": search_files}
def chat_with_tools(model: str, user_message: str, tools: list,
max_iters: int = 8) -> str:
messages = [{"role": "user", "content": user_message}]
for _ in range(max_iters):
resp = httpx.post(OLLAMA, json={
"model": model, "messages": messages,
"tools": tools, "stream": False,
}, timeout=120.0).json()
msg = resp["message"]
messages.append(msg)
if not msg.get("tool_calls"):
return msg["content"]
for call in msg["tool_calls"]:
name = call["function"]["name"]
args = call["function"]["arguments"]
try:
result = REGISTRY[name](**args) if name in REGISTRY \
else json.dumps({"error": f"Unknown tool: {name}"})
except Exception as e:
result = json.dumps({"error": str(e)})
messages.append({"role": "tool", "content": result})
return f"<<max iterations ({max_iters}) reached>>"
# Usage — needs both tools in sequence
print(chat_with_tools(
"qwen2.5:7b",
"Find all .py files under '.' and tell me the weather in Seoul.",
tools=[
{"type": "function", "function": {
"name": "get_weather",
"description": "Current weather for a city.",
"parameters": {"type": "object",
"properties": {"city": {"type": "string"},
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}},
"required": ["city"]}}},
{"type": "function", "function": {
"name": "search_files",
"description": "Search files by glob pattern under a directory.",
"parameters": {"type": "object",
"properties": {"pattern": {"type": "string"},
"directory": {"type": "string"}},
"required": ["pattern"]}}},
],
))
Implement chat_with_tools(model, user_message, tools, max_iters). Test with three prompts: one needing zero tool calls, one needing one, one needing two in sequence. Log iteration count for each.
Progress
Progress is local-only — sign in to sync across devices.