Module 6 of 12 · Open Source Track
Multi-Tool Orchestration

One tool was M05. Now you run many tools together — in parallel, in chains, with the right one chosen, filtered by context, and recovered when they fail. Five concepts that turn a model into an agent.

Track 2: Tool Use ⏱ ~16 min read 6 / 12
Concept 1 of 5

Independent tools should run at once

When the model needs several tools whose calls don't depend on each other, it can request them all in one response — a list of tool_calls, each with its own id. Your code runs them concurrently.

The payoff is two-fold: one API round trip handles N tools instead of N round trips, and the wall-clock time drops to the slowest tool, not the sum of all of them. Independence is the whole condition — if tool B needs tool A's output, they can't be parallel.

Concept 1 of 5

The kitchen with one sous chef

BEFORE: Imagine a kitchen where a single sous chef does all prep — chops onions, then dices tomatoes, then minces garlic, one after another, while the head chef waits.

PAIN: Three ten-minute tasks take thirty minutes end to end, and every dish that needs those ingredients sits idle the whole time. Service grinds to a halt.

MAPPING: A smart head chef hands each task to a different sous chef so all three run at once — total time falls to the slowest single task (10 min), not the sum (30 min). Parallel tool calls do exactly this: the model emits several tool_calls, your code runs them together, wall-clock time equals the slowest tool.

Concept 1 of 5

One turn, many tools

  1. Model emits a batchIt returns several tool_calls in a single message, each with a unique id.
  2. Your code runs them concurrentlyFire all of them at once (a thread pool or a promise-all style gather) — the model never runs tools, you do.
  3. Collect every resultWait for all to finish; the batch is done when the slowest one returns.
  4. Append one result per toolAdd a tool message per call, matched by id, then send them all back next turn.
model: 3 calls search 200ms wiki 350ms papers 500ms 500ms total, not 1050ms
Concept 1 of 5

Pseudocode

calls = response.tool_calls   # a list, maybe 3

# run them all AT THE SAME TIME
results = run_concurrently(
  FOR c IN calls:
    execute_tool(c.name, c.args)
)

# append one tool message per call,
# matched back by its id
FOR (c, r) IN zip(calls, results):
  history.append({
    role: "tool",
    tool_call_id: c.id,
    content: r })
Concept 1 of 5

Misconceptions

"The model auto-parallelizes for me."
It only requests multiple calls. You still write the concurrent execution (thread pool / promise-all). Process them one by one and you lose the speedup entirely.
"Everything can be parallel."
Only tools with no data dependency. If B needs A's output, forcing them parallel runs B on empty input — wrong results.

When calls are independent, the model batches them and your code runs them concurrently — one round trip, time = slowest tool.

Parallel is a design choice you implement, not magic the model does for you.

Concept 2 of 5

Output of one feeds input of the next

Some tools must run in order because each one needs the previous one's result. Search returns URLs → fetch_page turns a URL into text → summarize turns text into bullets. The data transforms at every stage.

Each step is a separate API round trip: you return a result, the model reads it and decides the next call. This back-and-forth is the agentic loop, and it keeps going until finish_reason is "stop" — the model's signal that it's done and ready to answer.

Concept 2 of 5

The assembly line

BEFORE: Imagine building a car by dumping steel, rubber, and glass into a room and hoping a finished vehicle appears. With no defined order, nothing fits together.

PAIN: You can't fit a windshield before the frame is welded, or paint the body before it's assembled. Steps out of order waste materials and yield a broken result.

MAPPING: A sequential chain is an assembly line — Station A (search) produces URLs, Station B (fetch_page) turns URLs into page text, Station C (summarize) turns text into bullets. Each station transforms the part and passes it forward. Skip one or run them out of order and the next stage gets the wrong input, breaking the whole pipeline.

Concept 2 of 5

The agentic loop, step by step

  1. Model requests tool Ae.g. search("Mistral AI"); you run it and return the URLs.
  2. Model reads A, requests BIt uses a URL from A to call fetch_page(url); you return the page text.
  3. Model reads B, requests CIt passes that text to summarize(text); you return the bullets.
  4. Model composes the answerNo more tools needed → finish_reason: "stop" and it writes the final reply.
search →URLs → fetch_page →text → summarize →bullets
Concept 2 of 5

Pseudocode

LOOP:
  reply = model.chat(history, tools)

  IF reply.finish_reason == "stop":
    RETURN reply.text      # chain done

  # else it wants a tool — run it,
  # feed the result back, loop again
  FOR call IN reply.tool_calls:
    out = execute_tool(call.name, call.args)
    history.append({ role: "tool",
      tool_call_id: call.id, content: out })
Concept 2 of 5

Misconceptions

"Sequential is always worse than parallel."
Not when B depends on A. Fetching a page needs a URL that search must return first — sequential is the correct shape, not a slow one.
"Long chains are free."
Every round trip resends the whole growing history. A 5-step chain is 5 calls with ever-larger message arrays — watch token cost (see M08 for context management).

A chain runs the agentic loop until finish_reason is "stop", each step transforming data for the next.

Parallelize independent tools; chain dependent ones. Real agents mix both.

Concept 3 of 5

Descriptions are the new prompts

The model picks a tool mostly by reading its name and description. A vague "searches stuff" leaves it guessing; a precise "Search the web, returns top 5 results, use for current events" points it straight at the right tool.

Four signals drive the choice: the name + description (biggest), the parameter schema, the user request, and the conversation context. Investing in clear descriptions is as high-leverage as writing a good system prompt.

Concept 3 of 5

The hardware store with unlabeled bins

BEFORE: Imagine a hardware store where every tool sits in an unmarked cardboard box — no labels, just numbered bins. You need to drive a tiny screw into a circuit board but can't tell which bin holds the right screwdriver.

PAIN: You grab random boxes, try tools that don't fit, strip the screw, and burn an hour on a two-minute job — or grab a power drill and destroy the board.

MAPPING: That's a model reading "searches stuff". A clear label — "Phillips-head #2, for small electronics" — maps to a good description: "Search the web by query, returns top 5 results, use for current events or lookups." The clearer the label, the more accurate the pick.

Concept 3 of 5

The four selection signals

  1. Name & descriptionThe primary signal. State what it does, what it returns, and when to use it.
  2. Parameter schemaClear property names, types, and required fields help the model build correct arguments.
  3. User requestThe model maps intent ("What's the weather?") to the tool whose capability matches.
  4. Conversation contextJust got search results back? It's more likely to pick fetch_page next than send_email.
Concept 3 of 5

How many tools is too many?

# selection accuracy drops as
# tool count climbs:

3 tools  → ~97%   SWEET SPOT
5 tools  → ~95%   still good
8 tools  → ~88%   drifting
12 tools → ~76%   degrading
18 tools → ~61%   danger zone

RULE: keep 4–5 tools per agent.
# more tools ≠ more capable.
# split extras into subagents.

A smaller local model (Mistral 7B) is even more sensitive to tool-count bloat than a frontier model.

Concept 3 of 5

Misconceptions

"More tools = more capable agent."
The opposite past 5–6 tools. More descriptions to weigh, more overlap and ambiguity, more tokens. Four focused tools beat eighteen scattered ones almost every time.
"The model always picks the right tool."
It's good, not infallible. Ambiguous descriptions, overlapping capabilities, and misleading param names all cause misselection — which is why description engineering matters.

The model chooses by name + description first; keep 4–5 sharp tools, not a bloated toolbox.

Tool descriptions are the single highest-leverage lever for agent reliability.

Concept 4 of 5

Send only the tools this task needs

You don't have to ship all 20 tools with every call. Dynamic registration builds a different tools array per request, filtered to what the current task actually needs.

Three wins: cost (every tool definition is 200–500 input tokens, sent on every call), accuracy (fewer options → better picks), and security via least privilege (a research query should never even see delete_user).

Concept 4 of 5

The surgical instrument tray

BEFORE: Imagine a surgeon walking in to find every instrument the hospital owns on the tray — orthopedic saws, dental drills, eye lasers, plus the cardiac tools they actually need. Hundreds of items, all within reach.

PAIN: The surgeon wastes time scanning past irrelevant tools, risks grabbing the wrong one under pressure, and the correct scalpel is buried under gear for a different specialty.

MAPPING: Dynamic registration is the nurse who curates the tray — only cardiac instruments out for a heart surgery. Instead of sending all 20 tools, you filter the tools array to the task. Fewer tools means the model scans less, picks better, and burns fewer tokens.

Concept 4 of 5

Filter before every call

  1. Keep a full registryAll tools live in one place, tagged by category (data, comms, admin).
  2. Classify the requestDecide what the current task needs — e.g. "research" needs data tools, not admin ones.
  3. Build a filtered arraySelect only the matching tools into the tools array for this call.
  4. Send the subsetThe model only ever sees the curated set — fewer tokens, sharper selection, least privilege.
registry (8) search fetch query summarize email slack delete perms → filter:data sent (4) search fetch query summarize
Concept 4 of 5

Pseudocode

registry = {
  "data":  [search, fetch, query, summarize],
  "comms": [send_email, send_slack],
  "admin": [delete_user, modify_perms],
}

FUNCTION tools_for(request, user):
  needed = classify(request)     # e.g. "data"
  tools  = registry[needed]
  IF NOT user.is_admin:
    drop admin tools             # least privilege
  RETURN tools

model.chat(history, tools_for(req, user))
Concept 4 of 5

Misconceptions

"Dynamic registration is premature optimization."
For a 3-tool agent, sure. For a 15–20 tool production agent it's essential — 20 tools is 4,000–10,000 wasted tokens per call, plus the accuracy hit.
"Filtering is only about saving tokens."
It's also security. Least privilege means a regular user's request never even sees admin tools like delete_user — the model can't call what it can't see.

Build the tools array per request from a registry — cheaper, more accurate, and safer by least privilege.

The model can only call tools you actually send it. Curate the tray.

Concept 5 of 5

When a tool fails, don't crash — report

The more tools you chain, the higher the odds one breaks — a 404, a timeout, a permission error. The rule: never crash the loop. Return the failure as a tool result with a descriptive error message.

That tells the model "this tool failed, here's why," so it can try an alternative, ask the user, or work with partial results. For tools that keep failing, add a circuit breaker that disables them after N failures so you stop burning tokens on a dead endpoint.

Concept 5 of 5

The relay race with no backup plan

BEFORE: Imagine a relay team with no contingency — four runners, one baton, and if anyone trips the whole team is disqualified. No substitutes, no recovery protocol.

PAIN: The second runner twists an ankle at the handoff. With no plan the baton drops, the team freezes, and they forfeit a race they were winning. One failure cascades into total failure.

MAPPING: Multi-tool workflows carry the same risk — if fetch_page 404s, does the whole agent crash? Error handling is the backup plan: return a structured error so the model can pick a new runner (switch to web_search), retry transient failures with backoff, and circuit-break tools that keep failing.

Concept 5 of 5

Error ≠ empty result

  1. Wrap each toolCatch its exceptions; never let one broken tool kill the whole loop.
  2. Return a structured errorSend {"error": "404 Not Found"} as the tool result — "I couldn't even check."
  3. Keep error apart from emptyAn empty {"results": []} means "I checked, found nothing" — a totally different signal.
  4. Let the model adaptGiven a clear error, it often picks an alternative tool or path on its own.
❌ fetch_page 404 💡 try web_search → ✅ continues
Concept 5 of 5

Retry, break, or degrade?

IF failure is transient
   (timeout, rate limit):
  → retry with backoff (1s,2s,4s),
     max 3 tries

IF a tool fails N times in a row:
  → open the circuit breaker,
     drop it from the tools array

IF the tool is simply down
   and an alternative exists:
  → return the error, let the
     model switch tools

IF some data came back:
  → degrade gracefully —
     partial > nothing

Retries live within a call; circuit breakers act across calls. Use both together.

Concept 5 of 5

Misconceptions

"If a tool fails, just return an empty result."
Dangerous. Empty means "nothing found"; an error means "couldn't check." The model decides very differently — confusing them creates silent, hard-to-debug failures.
"Retry 100 times and it'll work."
If an endpoint is down, it's down. Use backoff with a max of ~3 retries for transient errors, and a circuit breaker for persistent ones.

Return failures as structured errors, distinct from empty results; retry transient, circuit-break persistent, degrade gracefully.

A robust agent expects failure and hands the model enough info to route around it.

Test the core insight

One question per concept. Tap to reveal the answer.

Open the full module on desktop

The desktop version has you build a research-assistant agent against local Mistral/Ollama — parallel search, a sequential fetch-and-summarize chain, a ToolRegistry for dynamic filtering, and circuit breakers for error recovery — in Python and Node.

Open M06 on Desktop → Parallel · chains · selection · dynamic registry · error handling · ~60 min

Module 6 of 12 · Open Source Track · Tool Use