09. Multi-Tool Agents

Chapter 9 of 16 · 15 min

When an agent has access to multiple tools, it must decide which tool to use and in what order. This introduces the tool selection problem: the model must reason about tool capabilities and pick the right one for each sub-task.

Multi-tool routing

import ollama
from typing import List, Dict, Any

def multi_tool_agent(task: str, tools: List[Tool], max_turns: int = 10) -> str:
    tool_map = {t.name: t for t in tools}
    tool_schemas = [t.to_openai_schema() for t in tools]
    
    messages = [
        {"role": "system", "content": (
            "You have access to multiple tools. Choose the right tool for each step. "
            "You may call multiple tools if they are independent. "
            "When you have all the information needed, respond with your final answer."
        )},
        {"role": "user", "content": task}
    ]
    
    for turn in range(max_turns):
        response = ollama.chat(
            model="llama3.2",
            messages=messages,
            tools=tool_schemas
        )
        
        if not response.message.tool_calls:
            messages.append({"role": "assistant", "content": response.message.content})
            return response.message.content
        
        for call in response.message.tool_calls:
            fn = call.function
            if fn.name not in tool_map:
                result = f"Error: Unknown tool '{fn.name}'"
            else:
                result = tool_map[fn.name].invoke(**fn.arguments)
            
            messages.append({"role": "assistant", "content": "", "tool_calls": [call]})
            messages.append({"role": "tool", "tool_call_id": call.id, "content": str(result)})
    
    return "Max turns exceeded"

Parallel tool calls

Ollama supports parallel tool calls in a single response. When the model returns multiple tool_calls, execute them concurrently:

import concurrent.futures

if response.message.tool_calls:
    with concurrent.futures.ThreadPoolExecutor() as executor:
        futures = {
            executor.submit(tool_map[call.function.name].invoke, **call.function.arguments): call
            for call in response.message.tool_calls
            if call.function.name in tool_map
        }
        
        for future in concurrent.futures.as_completed(futures):
            call = futures[future]
            try:
                result = future.result()
            except Exception as e:
                result = f"Error: {e}"
            
            messages.append({"role": "tool", "tool_call_id": call.id, "content": str(result)})

Tool selection failures

Models sometimes call the wrong tool. A web_search tool cannot answer "calculate 15% of 200" because the model chose search instead of calculator. Mitigate this by:

  • Writing distinct, non-overlapping tool descriptions
  • Adding explicit lists of tool capabilities in the system prompt
  • Providing a retry mechanism that asks the model to reconsider
EXERCISE

Register three tools (web search, calculator, file reader) and test the agent with a compound query that requires all three. Log which tool was called at each step and verify the execution order makes sense.