Local AI Agents · Tool use and the agent loop · Lesson 4 of 4

Lab: building a first tool loop

The module's lab — a complete, minimal Python script wiring a tool registry, dispatcher, result envelope, timeout-and-retry and a loop-iteration cap into a real call/result round trip against your own local Ollama server, using only read-only tools, closing the module with this course's own live-captured evidence of a small model failing schema validation in exactly the way Lesson 3 described.

Lesson · 30–45 minutes · Text-first

By the end, you can

  • Run a complete, minimal Python tool loop — registry, dispatcher, result envelope, timeout and retry — against a real local Ollama server (LA-8).
  • Extend a working tool loop with a new read-only tool by adding a schema, a handler and a registry entry, without changing the dispatcher (LA-8).
  • Observe and explain a real schema-validation failure produced by a local model's own tool call, connecting it to this module's boundary-validation lesson (LA-8).

Before you start

This is Lesson 4 of 4, the lab that closes Tool use and the agent loop. It assumes the module's first three lessons — the schema and round-trip shapes (Lesson 1), the registry, dispatcher and result envelope (Lesson 2), and the two validation boundaries (Lesson 3) — and puts all four pieces into one runnable script. You need Python 3 (any recent 3.x; this lab was written and run against 3.12 — the next section shows you how to check for it and where to get it if it's missing, and no prior Python knowledge is assumed) and a working local Ollama server — the same one the Running models locally module's setup lab installed. This lab reuses `llama3.2:1b`, the exact model that lab already pulled: Ollama's own library page lists `tools` as a supported capability for both its `1b` and `3b` tags, so no new model download is required if you completed that lab. No paid service, account, API key or internet access beyond your own local server is needed anywhere in this lab; the script uses only Python's standard library (`json`, `urllib`) — no `pip install` required.

Check you have Python, and that your server is up

This is the first lesson in this course that asks you to run a Python script, so check the tool itself before anything else — the same check-before-you-start discipline the Running models locally module's setup lab applied to Ollama. In a terminal (macOS/Linux) or PowerShell (Windows), run `python3 --version` (on Windows, `python --version` if `python3` isn't recognised). Any Python 3 version number printed means you're ready — this lab needs nothing newer than what current systems ship, and no knowledge of Python is assumed beyond copying the script below into a file and running it; the lesson explains what each piece does. macOS and most Linux distributions come with Python 3 already installed. If the command isn't found — most commonly on Windows — install it from the official source, python.org's own Downloads page, the same prefer-the-official-path rule this course used for installing Ollama itself.

Second check: confirm your local Ollama server is actually running before the script tries to reach it. Run `ollama ps` — any response at all (even an empty list of running models) means the server answered. If instead you get an error, start Ollama the way the setup lab left you (opening the Ollama app, or `ollama serve` on Linux) before continuing. This thirty-second check matters because of something this module's Dispatch, result envelopes and failure handling lesson showed you directly: a script calling a server that isn't there produces a connection-refused error that no retry will ever fix — so if you see `Connection refused` from the script later in this lab, the diagnosis is exactly that lesson's captured example, and the fix is starting the server, not editing the code.

The complete script

This is the whole lab, minimal and complete — every piece this module's first three lessons named, wired together and runnable as one file. Save it as `first_loop.py`:

```python """Minimal first agent loop against local Ollama — read-only tools only."""

import json import urllib.error import urllib.request

OLLAMA_URL = "http://localhost:11434/api/chat" MODEL = "llama3.2:1b" REQUEST_TIMEOUT_SECONDS = 30 MAX_RETRIES = 1 MAX_LOOP_ITERATIONS = 4 # stands in for a cancellation/stop budget

# --- Tool registry: schema + handler pairs, both read-only -----------------

def tool_add(args): return args["a"] + args["b"]

def tool_word_count(args): return len(args["text"].split())

TOOL_REGISTRY = { "add": { "schema": { "type": "function", "function": { "name": "add", "description": "Add two numbers together.", "parameters": { "type": "object", "properties": { "a": {"type": "number", "description": "First addend."}, "b": {"type": "number", "description": "Second addend."}, }, "required": ["a", "b"], "additionalProperties": False, }, }, }, "handler": tool_add, }, "word_count": { "schema": { "type": "function", "function": { "name": "word_count", "description": "Count the words in a piece of text.", "parameters": { "type": "object", "properties": { "text": {"type": "string", "description": "The text to count words in."}, }, "required": ["text"], "additionalProperties": False, }, }, }, "handler": tool_word_count, }, }

# --- Boundary validation: check args against the schema before dispatch ----

def validate_args(tool_name, args): schema = TOOL_REGISTRY[tool_name]["schema"]["function"]["parameters"] required = schema.get("required", []) missing = [key for key in required if key not in args] if missing: return f"missing required argument(s): {', '.join(missing)}" allowed = set(schema.get("properties", {}).keys()) extra = [key for key in args if key not in allowed] if extra and schema.get("additionalProperties") is False: return f"unexpected argument(s): {', '.join(extra)}" for key, value in args.items(): if key not in schema["properties"]: # Reached when a schema omits additionalProperties: false and the # model supplies an undeclared key — still a clear validation # failure, never an uncaught KeyError. return f"argument '{key}' is not declared in this tool's schema" expected_type = schema["properties"][key]["type"] if expected_type == "number" and not isinstance(value, (int, float)): return f"argument '{key}' must be a number, got {type(value).__name__}" if expected_type == "string" and not isinstance(value, str): return f"argument '{key}' must be a string, got {type(value).__name__}" return None # no validation error

# --- Result envelope: every dispatch returns the same shape ----------------

def make_envelope(ok, value=None, error=None): return {"ok": ok, "value": value, "error": error}

def dispatch(tool_name, args): if tool_name not in TOOL_REGISTRY: return make_envelope(False, error=f"unknown tool: {tool_name}") validation_error = validate_args(tool_name, args) if validation_error is not None: return make_envelope(False, error=validation_error) try: result = TOOL_REGISTRY[tool_name]["handler"](args) except Exception as exc: # a misbehaving handler is still an envelope, not a crash return make_envelope(False, error=f"tool raised: {exc}") return make_envelope(True, value=result)

# --- One HTTP call to Ollama, with a timeout and one retry -----------------

def call_ollama(messages, tools): payload = json.dumps( {"model": MODEL, "messages": messages, "tools": tools, "stream": False} ).encode("utf-8") request = urllib.request.Request( OLLAMA_URL, data=payload, headers={"Content-Type": "application/json"} ) last_error = None for attempt in range(MAX_RETRIES + 1): try: with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT_SECONDS) as response: return json.loads(response.read()) except (urllib.error.URLError, TimeoutError) as exc: last_error = exc if attempt < MAX_RETRIES: print(f" (attempt {attempt + 1} failed: {exc}; retrying)") raise RuntimeError(f"Ollama call failed after {MAX_RETRIES + 1} attempt(s): {last_error}")

# --- The loop itself ---------------------------------------------------

def run_loop(user_message): tools = [entry["schema"] for entry in TOOL_REGISTRY.values()] messages = [{"role": "user", "content": user_message}]

for iteration in range(MAX_LOOP_ITERATIONS): response = call_ollama(messages, tools) message = response["message"] messages.append(message)

tool_calls = message.get("tool_calls") if not tool_calls: return message.get("content", "")

for call in tool_calls: name = call["function"]["name"] args = call["function"]["arguments"] envelope = dispatch(name, args) print(f" dispatch({name}, {args}) -> {envelope}") messages.append( { "role": "tool", "content": json.dumps(envelope), "tool_name": name, } )

return "(stopped: hit MAX_LOOP_ITERATIONS without a final answer)"

if __name__ == "__main__": for prompt in [ "What is 17 plus 25? Use the add tool.", "How many words are in the sentence 'the quick brown fox jumps over the lazy dog'? Use the word_count tool.", ]: print(f"User: {prompt}") answer = run_loop(prompt) print(f"Model: {answer}\n") ```

Every piece maps directly back to this module's first three lessons: `TOOL_REGISTRY` is Lesson 2's registry; `validate_args` is Lesson 3's boundary-one check; `make_envelope`/`dispatch` is Lesson 2's result envelope; `call_ollama`'s `timeout=` argument and its one-retry loop are Lesson 2's timeout-and-retry policy, using the exact `urlopen()` timeout parameter Python's own docs describe; and `MAX_LOOP_ITERATIONS` is Lesson 2's cancellation stand-in. The tool round trip itself is Ollama's own documented shape from Lesson 1: a `tools` field on the request, a `tool_calls` field on the model's reply, and a `role: "tool"` message carrying the result back — the same shape Ollama's own tool-support documentation shows read from `response['message']['tool_calls']` in Python.

Run it, and what to expect

Run `python3 first_loop.py`. You should see two exchanges print: a request to add 17 and 25, and a request to count the words in a short sentence, each followed by a `dispatch(...)` line showing exactly what the dispatcher received and returned, and finally the model's own reply.

Here is what this lab's own repeated runs actually produced, worth stating plainly before you run it yourself: on `llama3.2:1b`, the `add` request consistently — every run captured while writing this lesson — arrived with **string-typed** arguments, `{'a': '17', 'b': '25'}`, not numbers, despite the schema declaring `type: number` for both. `validate_args` caught this every time and returned an error envelope; the model's own recovery varied run to run — sometimes computing the answer itself in plain text, sometimes apologising and retrying — but the dispatcher itself never crashed and never silently ran a handler on the wrong argument type. This is Lesson 3's boundary-one validation working exactly as designed, on a real, unscripted model output, not a contrived example. The `word_count` request came back clean (a correct string argument, a correct count of 9) in the large majority of runs; on rare occasions a small local model can still garble a longer string argument outright, which is one more reason Lesson 3's second boundary — checking a tool's own result before trusting it — matters even for a tool that never fails a type check.

Your own run may differ in the specifics — a different local model, a different prompt phrasing, or plain sampling variation can all change exactly what a model sends. What should stay consistent is the shape of the behavior: a real, small local model is not fully reliable about honoring a schema's declared types, and a dispatcher that validates before dispatch turns that unreliability into a clean, expected error envelope instead of a crash or a silent wrong answer.

Extend it: add a third read-only tool

Add one more tool, `char_count`, that counts the characters in a piece of text (not words). It needs: a handler function, a schema entry in `TOOL_REGISTRY`, and nothing else — the dispatcher, the validator and the loop do not change, which is the point of having built them once, generically, in this module's earlier lessons rather than per tool.

```python def tool_char_count(args): return len(args["text"])

TOOL_REGISTRY["char_count"] = { "schema": { "type": "function", "function": { "name": "char_count", "description": "Count the characters in a piece of text.", "parameters": { "type": "object", "properties": { "text": {"type": "string", "description": "The text to count characters in."}, }, "required": ["text"], "additionalProperties": False, }, }, }, "handler": tool_char_count, } ```

Add this before the `if __name__ == "__main__":` block, then add a third prompt — something like `"How many characters are in the word 'agent'? Use the char_count tool."` — to the list at the bottom, and run the script again. Two practical notes for a first Python edit: Python is indentation-sensitive — the leading spaces on each line are part of the syntax, so paste the block exactly as shown, keeping its indentation intact — and edit the file in a plain-text editor (Notepad on Windows, TextEdit in plain-text mode on macOS, or any code editor), not a word processor, which can silently replace quotes and spacing with characters Python rejects.

Accessibility notes

This lesson is text-first, with no images, audio, video or downloadable artifacts. All code appears as plain block text, readable and copyable by assistive technology and keyboard-only users without relying on a screenshot. The practice exercise's model answer sits behind a native disclosure control that is reachable and operable by keyboard and correctly announced by screen readers. The knowledge check uses native radio-button inputs with a visible question and options, and posts its result to a live status region so assistive technology announces the outcome without a page reload. This lab's own commands are run in the learner's own terminal application, outside this page — learners using a screen reader with their terminal should follow their own assistive-technology setup for that application, which this lesson does not attempt to instruct.

Practice

Run, extend and observe — the lab itself

This lesson's practice is the lab, run for real on your own machine: run the provided script against your own local Ollama server, record what actually happened, then add the char_count tool above and confirm it works without touching any code beyond the tool itself.

  1. Run `python3 first_loop.py` as provided. For the add request, record the exact arguments your dispatch(...) line shows — were they numbers or strings? Did validate_args catch a problem, and if so, how did the model's reply respond to that failure?
  2. Run it again (or a few more times). Is the add request's argument-type behavior consistent across your runs, matching this lesson's own captured finding, or does your model/setup behave differently? Either answer is useful data — record what you actually saw.
  3. Add the char_count tool exactly as shown, add a matching prompt, and run the script a third time. Confirm the new tool dispatches correctly and that you did not need to change dispatch, validate_args or run_loop to add it — what does that confirm about how this module's registry-and-dispatcher design is supposed to scale to new tools?
  4. Temporarily break something on purpose: call `add` with only one argument (edit the prompt to ask something like 'call the add tool with just the number 5 and nothing else') or set REQUEST_TIMEOUT_SECONDS to a very small value like 0.01 and run again. Record the exact error message the script produces, and name which of this module's lessons (Lesson 2 or Lesson 3) that specific failure belongs to.
  5. Looking back across this whole module, write two or three sentences connecting what you just watched happen (a real model sending the wrong argument type) to Lesson 3's claim that a validated call still isn't the same as an approved one — would a JSON Schema type check alone have been enough to safely let this loop perform a write action instead of a read?
Compare with a bounded first version

There is no single correct captured value here — the point of this exercise, like this module's own captured evidence, is that you ran it yourself and can describe what actually happened. What to expect, based on this lesson's own runs: the add call's arguments are likely to arrive as strings ({'a': '17', 'b': '25'}) rather than numbers, validate_args should report something like: argument 'a' must be a number, got str — and the model's reply should still land on the right numeric answer even though the tool call itself failed, because a capable-enough model can compute simple arithmetic on its own once told the tool call didn't work. Consistency across your own runs may or may not match this lesson's own finding exactly — either result is legitimate evidence, since model behavior is not fully deterministic even at a fixed configuration (the model underneath module's Sampling, determinism and structured output lesson covers exactly why). Adding char_count without touching dispatch, validate_args or run_loop confirms the registry-and-dispatcher split from Lesson 2 does its job: those three functions are written generically against 'whatever is in the registry,' not against any specific tool by name, so a new tool is purely additive. A one-argument add call is likely to trigger one of validate_args' failure branches, but which one is the model's choice, not yours: it may omit b entirely (the missing-required-argument check) or invent a filler value like an empty string for b (the type check — a string where a number belongs), and live testing of this lesson observed exactly that second variant; either branch is legitimate evidence, per this exercise's own no-single-correct-value rule, since both show the validator converting a malformed call into a clean error envelope. An artificially tiny timeout should reproduce this module's own captured TimeoutError (Lesson 2's timeout policy) — these are different lessons' territory even though both show up as an error in the same script. Connecting back: watching a real model send the wrong argument type demonstrates concretely why Lesson 3 treats a validated call as necessary but not sufficient — schema validation caught a shape problem here, but nothing about a passing type check on a hypothetical write tool's arguments would confirm the write is the correct or authorized action to take; that gap is exactly why a write tool needs the approval gate on top of validation, not instead of it.

Knowledge check

Try the idea

After adding the char_count tool exactly as this lesson describes, which functions in first_loop.py needed to change to support it, beyond writing the new tool_char_count handler and its registry entry?
Low-stakes practice only. This does not score, block progress or create a learner record.

Sources and limits

This lesson synthesises the sources below into a practical learning model. It is not a security standard, legal advice or a guarantee that any particular agent design is safe.

  1. ollama/ollama docs/api.mdOllama (GitHub). Documents the tools request field, the tool_calls response field, and the role: tool result message this lab's script sends and receives.
  2. llama3.2Ollama. Lists tools as a supported capability for the llama3.2 1b and 3b tags, the model this lab reuses from the Running models locally module's setup lab.
  3. Tool supportOllama (blog). Documents providing tools via the tools field and reading a model's request from response['message']['tool_calls'], the same round trip this lab's script implements directly against the HTTP API.
  4. urllib.request — urlopen()Python Software Foundation. Documents the timeout parameter this lab's script passes to urlopen() for each request to the local server.