A bounded agent loop with validated arguments, visible tool results and failure limits.
01 / A tool call is a request, not an action
A chat model can propose a function name and arguments. Your application decides whether to execute that function, returns the result, and lets the model explain it. This loop is a practical foundation for a small local agent. The model does not gain automatic access to your files, browser or terminal just because it supports tool calling.
We will ask about fictional stock levels. A successful agent looks up both items and reports the returned counts. That is more informative than judging a friendly sentence such as “I checked the inventory” without any trace of a tool running.
02 / Run a deliberately limited example
Install Python 3 and Ollama, download qwen3:4b, and keep the Ollama service running. Download stock-agent.py below. It uses only the standard library and a hard-coded stock dictionary. It cannot purchase items, modify files or run shell commands. On Windows, py stock-agent.py may be the appropriate launcher.
The program allows one named tool, two item names, four total calls and four model rounds. The model receives a JSON schema, but Python validates the returned arguments again. A schema is a description to the model, not a replacement for runtime checks.
ollama pull qwen3:4b
python3 stock-agent.py03 / Check the trace, then the answer
The fixture contains cable: 12 and adapter: 0. Look for both verified tool-result lines before accepting the final explanation. The exact prose may vary; the two counts must not. If the model returns text without using any tool, the script deliberately fails instead of presenting that as a verified lookup.
Change one stock count and rerun. Then ask for an item outside the allowed list. The model may ask for clarification, or the program may reject an invalid call. Both are more useful than silently inventing a result. A model that reaches the round limit has not completed the task, even if it generated many tokens.
04 / Extend the boundary deliberately
For a real business, replace the dictionary with an authenticated, read-only inventory query. Keep authorization in application code and restrict the records a caller can see. Do not turn a model-provided string into SQL, a shell command or an arbitrary URL. Add human confirmation before tools that send, buy or delete.
Retrieved webpages and documents can contain misleading instructions. Keep them as untrusted evidence and enforce tool permissions outside the prompt. Log tool names, validation outcomes and durations while avoiding confidential content. These controls matter whether your model runs locally or in the cloud.
05 / Measure completed tasks
Record success rate, correct tool selection, valid arguments and end-to-end time. Then repeat with two and three simultaneous jobs calling the same model. A parallel tool request inside one conversation is not the same thing as several independent jobs sharing a server.
TokFire’s local agent fixture provides a repeatable measurement for its supported runtimes. Passing this stock exercise or that fixture does not establish that a model can safely browse arbitrary websites or operate an entire business. Expand the task set before expanding its permissions.
Complete example
Download Python exampleTeaching code with fictional data. Requires the local runtime and models described above; model outputs vary. These examples are not published benchmark results.
"""Bounded, read-only tool-calling exercise. Requires Ollama and qwen3:4b."""
import json
from urllib.request import Request, urlopen
INVENTORY = {"cable": 12, "adapter": 0} # Fictional teaching data.
TOOLS = [{"type": "function", "function": {
"name": "stock_count", "description": "Look up stock for one item.",
"parameters": {"type": "object", "properties": {
"item": {"type": "string", "enum": list(INVENTORY)}},
"required": ["item"], "additionalProperties": False}
}}]
def chat(messages):
request = Request("http://127.0.0.1:11434/api/chat", json.dumps({
"model": "qwen3:4b", "stream": False, "think": False,
"messages": messages, "tools": TOOLS,
"options": {"num_ctx": 4096, "num_predict": 256, "temperature": 0}
}).encode(), {"Content-Type": "application/json"})
with urlopen(request, timeout=120) as response:
return json.load(response)["message"]
def dispatch(function):
args = function.get("arguments", {})
if function.get("name") != "stock_count":
raise ValueError("Unknown tool")
if not isinstance(args, dict) or set(args) != {"item"}:
raise ValueError("Invalid arguments")
item = args["item"]
if not isinstance(item, str) or item not in INVENTORY:
raise ValueError("Unknown item")
return {"item": item, "count": INVENTORY[item]}
def run(question):
messages = [{"role": "system", "content": "Use stock_count for stock facts. "
"Do not invent inventory. This tool only reads data."},
{"role": "user", "content": question}]
calls_used = 0
for _ in range(4):
message = chat(messages)
messages.append(message)
calls = message.get("tool_calls", [])
if not calls:
if not calls_used:
raise RuntimeError("No tool used: the task is not verified")
print(message.get("content", ""))
return
if not isinstance(calls, list) or calls_used + len(calls) > 4:
raise RuntimeError("Tool-call limit exceeded")
for call in calls:
result = dispatch(call["function"])
calls_used += 1
print("Verified tool result:", json.dumps(result))
messages.append({"role": "tool", "tool_name": "stock_count",
"content": json.dumps(result)})
raise RuntimeError("Stopped: model did not finish within four rounds")
if __name__ == "__main__":
run("How many cables and adapters are in stock?")