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

Dispatch, result envelopes and failure handling

A tool registry and dispatcher turn a model's tool call into a checked function call rather than a blind one; a result envelope reports success or failure the same way every time; and timeouts, retries, idempotency and cancellation are one connected design decision about what to do when a call doesn't return cleanly — illustrated with two failures captured live against this course's own local runtime.

Lesson · 20–30 minutes · Text-first

By the end, you can

  • Explain what a tool registry and a dispatcher each do, and why a dispatcher should never call a handler function directly by name from model output (LA-8).
  • Design a result envelope that reports success or failure in the same shape every time, rather than returning a bare value or raising an uncaught exception (LA-8).
  • Choose a timeout, retry and idempotency policy for a given tool, and explain why a persistent failure should not be retried the same way as a transient one (LA-8).

Before you start

This is Lesson 2 of 4 in Tool use and the agent loop. It assumes Lesson 1's schema and round-trip vocabulary — a tool definition, a `tool_calls`/`tool_use` request, and a result sent back on whichever runtime's wire shape you're using. This lesson is about what happens between those two points: the code that turns a model's request into an actual function call, reports back what happened, and decides what to do when a call doesn't return cleanly the first time. The two failures this lesson quotes were captured live against a local Ollama server while writing this lesson — your own numbers, if you reproduce them, may differ in timing but should show the same shape of failure.

A registry and a dispatcher: never call a handler by name from model output

A tool call arrives as data — a string naming a tool, and a value (object or string, per Lesson 1) naming its arguments. The tempting shortcut is to use that string directly: look up a Python function with a matching name and call it. Resist that shortcut. A **tool registry** is a fixed, code-defined mapping from a tool's name to its schema and its handler function — written once, by you, before any request arrives — and a **dispatcher** is the one piece of code that looks a call up in that registry and runs it. The registry is what makes "the model asked for a tool that doesn't exist" a normal, checked case (the name simply isn't a key) rather than a crash from calling an undefined function, and it is what keeps the set of things a model can ever cause your program to run limited to exactly the handlers you wrote and named on purpose.

``` TOOL_REGISTRY = { "add": {"schema": ..., # the JSON Schema from Lesson 1 "handler": tool_add}, "word_count": {"schema": ..., "handler": tool_word_count}, } ```

Dispatch, then, is a small, boring function: look the name up, check it exists, check its arguments against its schema (this module's next lesson goes deeper on that check), run the handler, and catch whatever the handler itself might raise. None of that logic changes per tool — it is exactly the same four steps whether the tool adds two numbers or reads a file, which is the point of writing it once as a dispatcher rather than once per tool.

A result envelope: never return a bare value

A tool call can end three different ways: it succeeds and produces a value, it fails in an expected way (bad arguments, a resource that doesn't exist), or it fails in an unexpected way (the handler itself raises). A dispatcher that returns a bare value on success and lets a failure raise an uncaught exception hands the model — and your loop — two completely different shapes to handle, and makes "did this actually work" a guessing game based on whether an exception happened to propagate cleanly.

A **result envelope** fixes that by giving every dispatch call the same return shape, success or failure alike:

``` {"ok": True, "value": 42, "error": None} {"ok": False, "value": None, "error": "argument 'a' must be a number, got str"} ```

The loop, and the model reading the result on its next turn, only ever has to check one field — `ok` — to know what happened, and `error` is always a plain, readable string rather than a stack trace. A handler that raises is caught inside the dispatcher and turned into the same failure shape, not allowed to crash the loop — and the same discipline applies to the validation step itself: every way a call can be malformed, including an argument name the tool's schema never declared, has to come back as an error envelope too, not as the validator's own uncaught exception. A dispatcher only earns the "never crashes the loop" property when both paths — running the handler and checking the call — hold it. This module's lab lesson ships a complete, runnable version of exactly this pattern.

Timeouts, retries, idempotency and cancellation are one decision, not four

These four words are usually taught as separate settings. They are more honestly one connected design question: *when a tool call doesn't come back cleanly, what should the loop do, and is doing that safe?*

**Timeout** answers "how long do we wait before giving up on this attempt." Python's own `urllib.request.urlopen()` documents its `timeout` parameter plainly: it "specifies a timeout in seconds for blocking operations like the connection attempt." Without one, a hung tool call — a server that accepted the connection but never replies — can stall the whole loop indefinitely, not just that one call.

**Retry** answers "should we try again after a failure, and how many times." Microsoft's Azure Architecture Center's Retry pattern guidance frames the real choice as three strategies, not a single on/off switch: "Cancel," when "the fault indicates that the failure isn't transient or is unlikely to be successful if repeated"; "Retry immediately," for a rare, one-off glitch; and "Retry after delay," for "the more commonplace connectivity or busy failures" where the far side needs a moment to recover. Retrying blindly, with no cap and no delay, is not a safety net — the same guidance warns that "an aggressive retry policy with minimal delay between attempts, and a large number of retries, could further degrade a busy service that's running close to or at capacity."

**Idempotency** answers "is it actually safe to retry this specific call." The same Azure guidance states the rule directly: "Consider whether the operation is idempotent. If so, it's inherently safe to retry. Otherwise, retries could cause the operation to be executed more than once, with unintended side effects." A read-only tool — this module's lab uses only read-only tools for exactly this reason — is idempotent by construction: calling `add(17, 25)` twice does nothing worse than compute 42 twice. A tool that appends a line to a file, sends a message, or charges a card is not idempotent by default, and Stripe's own API documentation shows the standard fix for that case: a client-generated idempotency key sent with the request, so that "if a connection error occurs, you can safely repeat the request without risk of creating a second object or performing the update twice" — the server recognizes the repeated key and returns the original result instead of running the action again.

**Cancellation** answers "how do we stop a loop that isn't converging," independent of any single call's own timeout. A minimal, honest version of this — and the one this module's lab uses — is simply a hard cap on loop iterations: after some fixed number of tool-call rounds without a final answer, the loop stops and reports that it stopped, rather than running indefinitely on a model that keeps calling tools without ever producing a reply.

Two failures, captured live, and what each one teaches

Testing this lesson's own dispatcher against a local Ollama server produced two genuinely different failures, useful precisely because they look similar and call for different fixes.

Setting the request timeout to one millisecond against a real, working local server produced this, verbatim: `TimeoutError: timed out`. This is exactly the case a timeout exists to catch — the server was fine, the timeout was simply set too low for a real model response. The correct fix here is a longer timeout, not a retry; retrying immediately would hit the same too-short deadline again.

Pointing the same call at a port nothing was listening on produced a different failure: `urlopen error [Errno 111] Connection refused` — logged once, retried once per this lesson's own one-retry policy, and failed identically on the retry, before the loop gave up with `RuntimeError: Ollama call failed after 2 attempt(s)`. This is the case the Azure guidance's "Cancel" strategy is for: nothing about retrying a request against a server that plainly isn't there was ever going to succeed. A wider retry budget would only have delayed reporting the same, unfixable failure — the honest lesson here is that a retry helps a transient fault and does nothing for a persistent one, and telling those two apart (a timeout that's simply too short vs. a server that isn't running) is the actual design judgment this section asks for, not a fixed retry count applied uniformly to every failure.

A third failure is worth naming even though this lesson's testing didn't trigger it: the server responds, but with something that isn't valid JSON — a proxy's HTML error page, a truncated reply from a crashed process. Parsing that response (Python's `json.loads`, for example) raises its own exception, `json.JSONDecodeError`, which is neither a timeout nor a connection error and so slips past a handler that only catches those two. The design rule is the same one this lesson's envelope section established: every distinct way a call can fail — including "the reply arrived but couldn't be parsed" — needs to be caught and reported in the same shape, or the one failure you didn't anticipate is the one that crashes the loop.

Accessibility notes

This lesson is text-first, with no images, audio, video or downloadable artifacts. All code and error-message examples appear as plain block or inline 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.

Practice

Design a policy for a flight-status lookup tool

A `flight_status` tool calls an external flight-tracking API over the network and returns a short status string ('on time', 'delayed 40 min', 'cancelled'). It is read-only. In testing, it occasionally takes 15-20 seconds to respond during the tracking provider's own peak load, and once returned a connection-refused error for about ninety seconds during an unrelated provider outage.

  1. Is flight_status idempotent? Explain your answer using this lesson's definition, and say what that means for whether it's safe to retry.
  2. Choose a timeout value for this tool, and justify it against the 15-20 second peak-load figure in the scenario — what would too short a timeout cause, using this lesson's own captured TimeoutError as the example?
  3. The provider outage lasted about ninety seconds. Would a single retry, immediately after a failure, have helped during that outage? What would this lesson's connection-refused example suggest instead?
  4. Design this tool's cancellation behavior: at what point should a loop calling flight_status give up and report a failure to the user, rather than retrying indefinitely?
  5. Write the result envelope this tool should return for the connection-refused case, using this lesson's envelope shape.
Compare with a bounded first version

flight_status is idempotent — calling it twice with the same flight just checks the status twice; it has no side effect on the flight or the provider's data, so retrying it carries none of the duplicate-effect risk this lesson describes for a non-idempotent tool, and it is safe to retry freely. A reasonable timeout is comfortably above the observed 15-20 second peak-load figure — something like 30 seconds — because a timeout shorter than the tool's own normal slow-case response time would produce exactly this lesson's captured TimeoutError on completely healthy, if slow, requests, which is a false failure, not a real one. A single immediate retry would not have helped during a ninety-second outage — the connection-refused example in this lesson shows that retrying immediately against a server that is genuinely down just reproduces the same failure; what would actually help is a retry after a real delay (on the order of the outage's own duration, not immediately) or, more honestly, reporting the failure and letting a human or a scheduled retry try again later rather than blocking the loop. Cancellation: after some small, fixed number of attempts (for example, one timeout-triggered retry plus one delayed retry) the loop should stop calling flight_status for this turn and report a clear failure, rather than looping indefinitely against a provider that may be down for longer than any reasonable wait justifies. Result envelope for the connection-refused case: {'ok': False, 'value': None, 'error': 'flight_status: connection refused after 2 attempts'} — a plain, readable failure string, not a raw exception.

Knowledge check

Try the idea

A tool appends a new line to a shared notes file each time it is called, and has no idempotency key mechanism. A network error occurs after the request was sent but before a response came back — it's unknown whether the append actually happened. What does this lesson's guidance suggest?
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. urllib.request — urlopen()Python Software Foundation. Documents the timeout parameter of urlopen() as a number of seconds for blocking operations such as the connection attempt.
  2. Retry pattern — Azure Architecture CenterMicrosoft Learn. Documents the cancel/retry-immediately/retry-after-delay strategy choice, the distinction between transient and long-lasting faults, and that an idempotent operation is inherently safe to retry while a non-idempotent one is not.
  3. Idempotent requestsStripe. Documents the idempotency-key pattern: a client-generated key lets a server recognize and safely respond to a retried request without repeating its effect.