We asked Qwen3 for the collision figure in a UUID article. It opened a file that did not exist, then answered a different question.
You can follow that mistake through a Python tool loop built on the RAG script from part six.
A tool is a function the model asks you to run
You describe search_notes and read_file to Ollama so the model can request either function by name and supply its arguments.
Your Python script runs the requested function and sends its result back. Qwen3 never gets direct access to Python or your filesystem.
Before each call, your code has to decide whether to accept the function name and arguments Qwen3 sent.
We added argument checks and a list of permitted files to the loop shown in the Ollama tool calling examples.
Step 1, wrap the part six search in a function
You reuse the part six search code and index in search_notes, which returns the three closest chunks with their filenames attached.
For a whole article, read_file takes its filename and returns either the text or a missing-file message listing the available notes.
We kept filename matching exact, accepting a regular .txt note with or without its extension.
The function excludes symlinks and refuses filenames such as ../outside, then checks that the resolved path stays inside the notes folder.
import json, sys
from pathlib import Path
import rag
NOTES = Path(__file__).resolve().parent / "notes"
MAX_STEPS = 5
def search_notes(question):
hits = rag.search(question, str(NOTES.parent / "index.json"))
return "\n\n".join(f"[{c['source']}] {c['text']}" for _, c in hits)
def read_file(name):
if not isinstance(name, str) or not name.strip():
return "Expected a nonempty file name."
root = NOTES.resolve()
files = {p.stem: p for p in root.glob("*.txt")
if p.is_file() and not p.is_symlink()}
key = name[:-4] if name.endswith(".txt") else name
if key not in files:
return f"No file named {name}. Files available: " + ", ".join(sorted(files))
path = files[key].resolve()
if path.parent != root:
return "File is outside the notes folder."
try:
return path.read_text(encoding="utf-8")
except (OSError, UnicodeError):
return "Could not read that note as UTF-8 text."
You can download the complete example and recorded responses, including rag.py, index.json and the 35 notes used below.
Save these Python blocks together as tools.py beside those files. With Ollama running and both models pulled, you can run it like this.
ollama pull qwen3:8b
ollama pull nomic-embed-text
python3 tools.py "What is 2 + 2?"
Step 2, describe both tools in JSON the model can read
You describe when each function should help, without sending its Python implementation to Qwen3.
TOOLS = [
{"type": "function", "function": {
"name": "search_notes",
"description": "Search the notes folder and return the three passages closest to the question. Use this for any question about our articles, tools or measurements.",
"parameters": {"type": "object", "required": ["question"],
"properties": {"question": {"type": "string", "description": "The question to search for, in full"}}}}},
{"type": "function", "function": {
"name": "read_file",
"description": "Return the full text of one article by its file name, for when the user names an article or a passage is not enough.",
"parameters": {"type": "object", "required": ["name"],
"properties": {"name": {"type": "string", "description": "File name without the .txt extension"}}}}},
]
After removing the second sentence of the search description, we repeated all eight initial requests and found seven kept the same choice.
In variants.json, you can see the laptop request change from a search call to "I do not know" when that sentence is removed.
That sentence affected one choice in this test. You still need to inspect the arguments, even when the model selects the function you wanted.
Step 3, switch from generate to chat
You used a single prompt with /api/generate in part six. Here, /api/chat carries a conversation that includes the tool results.
Before dispatching to either function, execute checks that you received exactly the expected argument and that it is a nonempty string.
FUNCTIONS = {"search_notes": search_notes, "read_file": read_file}
ARGUMENTS = {"search_notes": "question", "read_file": "name"}
def execute(call):
fn = call.get("function") if isinstance(call, dict) else None
if not isinstance(fn, dict):
return "invalid_call", "Expected a function object."
name, args = fn.get("name"), fn.get("arguments")
if not isinstance(name, str) or name not in FUNCTIONS:
return "unknown_tool", "Unknown tool."
key = ARGUMENTS[name]
if not isinstance(args, dict) or set(args) != {key}:
return name, "Expected exactly one argument: " + key
if not isinstance(args[key], str) or not args[key].strip():
return name, "Expected a nonempty string for " + key
return name, FUNCTIONS[name](**args)
def chat(messages):
return rag.post("/api/chat", {"model": rag.CHAT_MODEL, "messages": messages, "tools": TOOLS, "stream": False,
"think": False, "options": {"num_predict": 300, "temperature": 0, "seed": 0}})
Even with the JSON schema in your request, you need to check the arguments before Python executes the call.
We ran Qwen3 8B Q4_K_M on Ollama 0.35.0 with temperature 0 and seed 0, with thinking disabled.
You can find the model digests in results.json. Matching settings help you compare runs, but we do not promise identical output on another installation.
Step 4, loop until the model stops asking
You keep the assistant message in the conversation and append each function result as a new message with role set to tool.
Send the updated conversation back to Ollama until a response arrives without tool calls, at which point the script returns its content.
def run(question):
messages = [{"role": "system", "content": "Answer from the notes when the question is about them. If the notes do not contain the answer, say you do not know."},
{"role": "user", "content": question}]
for step in range(MAX_STEPS):
r = chat(messages)
msg = r["message"]
print(f"step {step + 1}: {r['prompt_eval_count']} in, {r['eval_count']} out, {r['total_duration'] / 1e9:.1f}s")
messages.append(msg)
calls = msg.get("tool_calls")
if not calls:
return msg.get("content", "")
if not isinstance(calls, list):
return "Malformed tool_calls: expected a list."
if step == MAX_STEPS - 1:
break
for call in calls:
name, result = execute(call)
print(" tool call:", json.dumps(call))
print(f" result: {len(result)} characters, starts: {result[:80]!r}")
messages.append({"role": "tool", "tool_name": name, "content": result})
return "Stopped after " + str(MAX_STEPS) + " model turns."
if __name__ == "__main__":
print("\nANSWER:", run(sys.argv[1]))
The limit is five model turns, rather than five function calls. A response can request several functions, which you execute in their returned order.
On the last allowed turn, the loop stops without executing another batch. None of our eight questions reached that limit.
Read the assistant message before the answer
Here is the complete message object from the laptop request, extracted from the HTTP response you can inspect in responses.json.
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_i8be2j5r",
"function": {
"index": 0,
"name": "search_notes",
"arguments": {
"question": "Which laptop did we use to time Qwen3 8B, and how many tokens per second did it write?"
}
}
}
]
}
The empty content field comes with a search_notes request whose argument matches your question word for word.
For this turn, use Ollama's reported count of 43 generated tokens rather than tokenizing the formatted JSON above.
The tool returned 2,478 characters, taking the next request from 286 to 1,003 prompt tokens. The two chat responses took 1.6 and 3.8 seconds.
Those are API durations, which exclude the embedding search between requests. We measured them on a 2023 M2 Pro MacBook Pro with 32 GB.
The second response correctly named our laptop and its recorded speed of about 24 tokens per second, using 44 output tokens.
You are reading a historical measurement retrieved from a note. The 24 figure does not measure how fast this tool loop ran.
Eight questions and what the model reached for
We used the frozen part six notes so you can inspect the same material. Each row describes one run, not an accuracy benchmark.
| Question | Tool it called | Model turns | Observed answer |
|---|---|---|---|
| What is 2 + 2? | none | 1 | 4, in 13 output tokens |
| Which laptop did we use to time Qwen3 8B, and how many tokens per second did it write? | search_notes | 2 | Matched the laptop note |
| Summarize the article run-llm-locally-ollama-guide in three sentences. | read_file, exact name | 2 | Three sentences based on the full article |
| What does our article about UUID collisions say about v7? | search_notes | 2 | Quoted 160 billion, but omitted the uniform randomness assumption |
| What is the refund policy for the Pro plan? | none | 1 | "I do not know" without checking the notes |
| Summarize our article about running models on a laptop in two sentences. | search_notes | 2 | Two sentences based on three chunks |
| Open the uuid collision article and tell me the v4 figure. | read_file, wrong name, then search_notes | 3 | Explained v4 structure instead of collision probability |
| Which of our articles mentions Charter and Georgia fonts? | search_notes | 2 | "None of the provided passages mention Charter and Georgia fonts." |
The fonts answer stayed within the passages it received. We also scanned all 35 files and found neither font name, but that scan was ours.
You should keep the v7 qualification if you repeat its number. The 160 billion estimate assumes 74 independent, uniformly random bits within one timestamp.
The UUID collision calculation explains that assumption. A generator using counters needs a different calculation.
The wrong call and what it did next
"The uuid collision article" became the filename uuid collision. You can see that exact argument in the seventh run.
Among the 35 filenames returned in the missing-file message, you can find the actual article, uuid-collision-probability.
Qwen3 asked for a search instead. Its entire query was v4 figure, dropping both the word collision and the filename it had received.
You can trace the next request to three chunks about UUID structure, which led to an answer explaining the 122 random bits in v4.
The collision article's figure was about 2.7 quintillion UUIDs for a 50 percent collision chance. We would reject this answer to the original question.
We kept exact filename matching because you can see which note was requested. Fuzzy matching might select another article without making that change obvious.
If you test a file picker or include filenames in the prompt, repeat this question. We have no result for either change to report.
Measure the overhead before adding more tools
For the arithmetic question, you can compare initial requests with and without the tools field, holding all other fields fixed.
Attaching both tools increased the reported prompt count from 55 to 270 tokens, a difference of 215 in this comparison.
You send the definitions again on each turn, with their chat formatting contributing to the prompt alongside the growing conversation history.
There was also a change in the answer. The request without tools returned "I do not know"; with tools attached, Qwen3 answered 4.
You can check the original response in responses.json and the response without tools in variants.json. We cannot tell from those responses why attaching tools changed this answer.
If you move the loop to a paid API, use that provider's usage counts. Our model pricing comparison separates input and output charges.
Where this version breaks
You may need a question checked against your notes every time. In that case, call the search from Python as part six did.
A query such as v4 figure passes the argument checks because you supplied a valid string, despite losing the subject of the question.
You still have to compare each query with your question and retrieved passages, since this code does not check its meaning.
We tested this as a local script with notes we control. Do not treat its file checks as protection against another process swapping files during a read.
It also stops on an Ollama transport error rather than retrying. Before you add a tool that changes files, decide who approves each action.
The guide to checking agent actions covers that decision. This loop has no approval interface.
Try it with a question you can verify
Open responses.json in our JSON response visualizer. Expand message.tool_calls inside a saved response to inspect the arguments.
Before changing a description, rerun the wrong UUID question and keep its returned filenames and search query beside the final answer.
For API field details, use the Ollama chat reference. The field names in this script are specific to Ollama.
Run one question about a note you know well, then check whether the tool request still asks for what you wanted.