Multi-Tool AI Agent

Ma Mahalakshmi V Updated 19 Sep 2026
8 min read ·Lesson 218 of 224

Project 9: Build a Multi-Tool AI Agent

This project builds a general-purpose personal assistant agent that combines the Agents SDK from Unit 11 with the built-in tools covered in Unit 9: web search for current information, a custom function tool for a personal task list, and file search over the user's own notes. The scenario deliberately differs from Project 4's single-domain support agent and from Unit 11's own multi-agent support-desk capstone — this is one agent with several unrelated capabilities, chosen freely per request, rather than several agents each owning one domain.

Scope and Design Decisions

The assistant handles requests like "what's the weather-related news for my trip next week," "add a task to follow up with the dentist," and "what did I write about the Q3 budget in my notes" — three genuinely different capabilities inside one conversational agent. Three decisions define the shape of this project:

  1. One agent, several tools, not several agents. Unit 11's capstone shows multi-agent handoff for a support desk where each agent owns a distinct domain of expertise. Here, the tools are unrelated capabilities rather than domains of expertise, and a single agent choosing among them is the better fit — there is no specialized reasoning that justifies splitting "search the web" from "manage a task list" into separate agents.
  2. The custom task-list tool is a real function tool with local persistence. Unlike web search and file search, which are built-in tools OpenAI hosts, the task list is the project's own data and needs an explicit function tool backed by real storage.
  3. Tool selection is left to the agent's own reasoning, not a manual router. This is the central difference from Project 4: rather than the application code deciding which backend function to call, the agent decides which of its available tools is relevant to the current request.

Setting Up the Agent With Mixed Tool Types

from agents import Agent, Runner, function_tool, WebSearchTool, FileSearchTool
import json
import os

TASKS_FILE = "tasks.json"

def _load_tasks() -> list[dict]:
    if not os.path.exists(TASKS_FILE):
        return []
    with open(TASKS_FILE) as f:
        return json.load(f)

def _save_tasks(tasks: list[dict]) -> None:
    with open(TASKS_FILE, "w") as f:
        json.dump(tasks, f, indent=2)

@function_tool
def add_task(description: str, due_date: str | None = None) -> str:
    """Add a personal task to the user's task list."""
    tasks = _load_tasks()
    tasks.append({"id": len(tasks) + 1, "description": description, "due_date": due_date, "done": False})
    _save_tasks(tasks)
    return f"Added task #{len(tasks)}: {description}"

@function_tool
def list_tasks(include_done: bool = False) -> str:
    """List the user's current personal tasks."""
    tasks = _load_tasks()
    visible = tasks if include_done else [t for t in tasks if not t["done"]]
    if not visible:
        return "No tasks found."
    return "\n".join(f"#{t['id']}: {t['description']} (due: {t['due_date'] or 'none'})" for t in visible)

personal_assistant = Agent(
    name="Personal Assistant",
    instructions=(
        "You are a general-purpose personal assistant. Use web search for "
        "questions about current events or external information. Use file "
        "search for questions about the user's own notes. Use the task tools "
        "to manage the user's personal task list. Choose the right tool based "
        "on what the request actually needs; don't guess an answer you could "
        "look up."
    ),
    tools=[
        WebSearchTool(),
        FileSearchTool(vector_store_ids=["vs_user_notes_placeholder"]),
        add_task,
        list_tasks,
    ],
    model="gpt-5.6-terra",
)

This is the structural core of the project: one Agent with four tools of two fundamentally different kinds. WebSearchTool and FileSearchTool are hosted tools — OpenAI runs the actual search behind the scenes, and the SDK just declares that the agent may use them, exactly as in Unit 9. add_task and list_tasks are ordinary Python functions turned into tools with the @function_tool decorator, and the SDK inspects each function's signature and docstring to build the tool schema the model sees, the same mechanism from Unit 8 but wired through the Agents SDK's own registration path rather than a hand-built tool list.

The system instructions explicitly describe when to use which tool category. This matters more here than in Project 4's narrow domain, because the model genuinely has to disambiguate between three unrelated capabilities on every request, and a vague instruction set increases the odds of it defaulting to guessing an answer from its own knowledge instead of reaching for the tool that would actually get it right.

Note: FileSearchTool and WebSearchTool constructor parameters and the exact function_tool decorator behavior are part of the Agents SDK surface from Unit 11 and can change between SDK releases; verify current parameter names against the installed SDK version before deploying.

Running the Agent

def ask_assistant(message: str) -> str:
    result = Runner.run_sync(personal_assistant, message)
    return result.final_output

if __name__ == "__main__":
    print(ask_assistant("Add a task: renew passport, due next month"))
    print(ask_assistant("What tasks do I have open right now?"))
    print(ask_assistant("What's the latest on the topic I asked about last time?"))

Runner.run_sync hides the underlying loop of model call, tool call, tool result, and repeat until the model produces a final answer — the same mechanics as the manual loop built in Project 4, but managed by the SDK because the branching across three unrelated tool categories is exactly the kind of orchestration complexity the Agents SDK exists to absorb. This is the practical distinction worth internalizing between Project 4 and this project: a manual loop stays legible and auditable for a small, fixed, side-effect-heavy tool set; the Agents SDK's managed loop earns its abstraction once the tool set is broad enough that hand-rolling the branching logic would mostly be reimplementing what the SDK already does well.

Adding Task Completion and a Safety Boundary

@function_tool
def complete_task(task_id: int) -> str:
    """Mark a personal task as done by its numeric ID."""
    tasks = _load_tasks()
    for task in tasks:
        if task["id"] == task_id:
            task["done"] = True
            _save_tasks(tasks)
            return f"Task #{task_id} marked as done."
    return f"No task found with ID {task_id}."

DANGEROUS_REQUEST_MARKERS = ["delete all", "wipe", "erase everything"]

def guard_destructive_requests(message: str) -> str | None:
    lowered = message.lower()
    if any(marker in lowered for marker in DANGEROUS_REQUEST_MARKERS):
        return (
            "This request looks like it would delete data broadly. No "
            "bulk-delete tool is available; specify individual task IDs instead."
        )
    return None

def ask_assistant_safely(message: str) -> str:
    warning = guard_destructive_requests(message)
    if warning:
        return warning
    return ask_assistant(message)

complete_task is added as a fourth function tool, following the same pattern as add_task. guard_destructive_requests is a narrow, deliberate safety measure worth noting rather than a general content filter: because no bulk-delete tool exists in this agent's tool set at all, this guard is really just a fast, cheap way to give the user a clear message instead of letting the agent spend a full reasoning turn discovering it has no way to fulfill a bulk-delete request. It is a usability improvement, not a security control — the actual safety property here comes from never having defined a destructive bulk tool in the first place, which is the same principle from Project 4: the tool surface itself is the primary safety boundary.

Testing Tool Selection Logic in Isolation

def test_add_task_persists_with_correct_fields(tmp_tasks_file):
    global TASKS_FILE
    TASKS_FILE = tmp_tasks_file
    result = add_task(description="Call the plumber", due_date="2026-09-20")
    assert "Added task #1" in result
    tasks = _load_tasks()
    assert tasks[0]["description"] == "Call the plumber"
    assert tasks[0]["done"] is False
    print("PASS: add_task persists a well-formed task record")

def test_complete_task_marks_correct_task_done(tmp_tasks_file):
    global TASKS_FILE
    TASKS_FILE = tmp_tasks_file
    _save_tasks([{"id": 1, "description": "Test task", "due_date": None, "done": False}])
    result = complete_task(task_id=1)
    assert "marked as done" in result
    assert _load_tasks()[0]["done"] is True
    print("PASS: complete_task updates the matching task's done flag")

def test_guard_blocks_bulk_delete_phrasing():
    warning = guard_destructive_requests("please delete all my tasks")
    assert warning is not None
    assert "bulk-delete" in warning
    print("PASS: bulk-delete phrasing is caught before reaching the agent")

import tempfile
_tmp_path = tempfile.mktemp(suffix=".json")
test_add_task_persists_with_correct_fields(_tmp_path)
test_complete_task_marks_correct_task_done(_tmp_path)
test_guard_blocks_bulk_delete_phrasing()
os.remove(_tmp_path)

These tests call the underlying add_task and complete_task functions directly — the @function_tool decorator wraps them for the agent's use but does not prevent calling the original function like ordinary Python, which is exactly what makes function tools straightforward to unit test without ever invoking the agent or the model. guard_destructive_requests is tested purely as string-matching logic, entirely independent of the agent, matching the pattern used throughout this course of testing business logic separately from model behavior.

Extending This Project

Add a calendar-integration tool so the assistant can check for scheduling conflicts before adding a task with a due date, and add persistent per-user storage (replacing the flat JSON file) so the same agent can safely serve multiple distinct users without their task lists ever mixing.

Common Mistakes

  • Splitting unrelated capabilities into separate agents when one agent with several tools would do. Multi-agent handoff earns its complexity when different domains genuinely need different specialized instructions or reasoning styles; for a personal assistant's grab-bag of unrelated tools, one agent with clear tool-selection instructions is simpler and just as effective.
  • Under-specifying when to use which tool in the system instructions. With three or more unrelated tool categories, vague instructions increase the chance the model guesses an answer instead of using an available tool that could get it right.
  • Confusing a usability guard with an actual security boundary. A string-matching check like guard_destructive_requests improves the user experience but provides no real protection; genuine safety comes from what tools are made available to the agent in the first place.

Best Practices

  • Choose one agent with multiple tools over multiple specialized agents when the tools represent unrelated capabilities rather than domains of expertise. Reserve multi-agent handoff for cases where different agents genuinely need different reasoning specializations.
  • Write explicit, tool-by-tool guidance in the system instructions when an agent has several unrelated capabilities. This is the primary lever for reliable tool selection.
  • Test function tools by calling the underlying function directly. The @function_tool decorator does not prevent ordinary function calls, which makes business logic fully testable without invoking the agent runtime.

0 Comments

Reviewed before they appear

No comments yet.

OpenAI SDK
Introduction to the OpenAI SDK Setting Up Python Creating an API Key Your First Call — client.responses.create() and response.output_text Understanding Billing, Credits, and What a Request Costs Why Responses Replaced Chat Completions Anatomy of a Request: model, input, and instructions Anatomy of a Response: The Typed output Array, Not Just Text Roles: User, Assistant, and Developer/System Choosing a Model, and Reading the Models Page Instead of Memorizing Names Instructions vs. Input Writing Prompts That Get Consistent Results Few-Shot Examples Reasoning Models and the reasoning Parameter Debugging a Prompt That Misbehaves Why Streaming Matters for User Experience stream=True and Iterating Over Events Handling the Event Types You Actually Care About Background Mode for Long-Running Jobs Project — Add Live Streaming to Your Chatbot The Problem With Parsing Free Text JSON Schema and Strict Mode Pydantic Models With the SDK's Parse Helpers Handling Refusals and Validation Failures Project — A Resume-to-JSON Extractor Working With input_image input_file, PDFs, and the Files API Image Generation Speech-to-Text and Text-to-Speech Project: A PDF Question-Answering Script What Function Calling Is Defining a Tool Schema The Full Loop Multiple Tools Errors, Timeouts, and Untrusted Arguments Project: A Weather Assistant Web Search File Search and Vector Stores Code Interpreter Remote MCP Servers and Connectors Project: A Research Assistant What an Embedding Is, Without the Maths Generating and Storing Embeddings Similarity Search From Scratch Hosted Vector Stores vs. Rolling Your Own A Small RAG App Over a Folder of Notes Agents vs. a Single API Call — When You Need One pip install openai Giving Agents Tools Handoffs and Multi-Agent Triage Guardrails and Approvals Tracing and Observing What Your Agent Did A Multi-Agent Support Desk Error Codes and What Each One Means Retries, Timeouts, and Backoff Rate Limits and Spend Limits Prompt Caching and Cost Optimisation The Batch API for Bulk Work Async Clients and Concurrency Moderation and Safety Best Practices Designing the App Backend With FastAPI Streaming to a Simple Frontend Deploying and a Cost/Safety Checklist Why Web Search Is Useful for Current Information Using the Web Search Tool with the Responses API Configuring Search Behavior for Application Use Cases Understanding Citations and Source Attribution Where to Go Next Building a Research Assistant with Web Search Combining Web Search with Structured Outputs Handling Conflicting or Low-Quality Web Sources Reducing Unsupported Claims with Grounded Generation Testing Freshness-Sensitive AI Answers Production Considerations for Web-Grounded Applications Understanding File Search and Retrieval-Augmented Generation Creating and Organizing Vector Stores Uploading Documents for Retrieval Connecting Vector Stores to Responses API Requests Designing Document Metadata and Filtering Strategies Building a PDF Question-Answering Application Improving Retrieval Quality With Better Document Preparation Handling Missing Evidence and Retrieval Failures Combining File Search With Web Search Building a Production Knowledge-Base Assistant What the Code Interpreter Tool Is Designed For Running Python-Based Analysis Through the OpenAI SDK Uploading Datasets for Analysis Analyzing CSV and Spreadsheet Data Generating Charts and Data Summaries Handling Generated Files and Downloadable Artifacts Building a Data-Analysis Assistant Combining Code Execution with Structured Outputs Validating Generated Calculations and Results Security and Sandbox Considerations for Code Execution Understanding Multimodal Input with the OpenAI SDK Sending Images to a Model Image Analysis from URLs and Uploaded Files Extracting Text and Information from Screenshots Building an Image-Question-Answering Application Combining Image Input with Structured Output Analyzing Multiple Images in One Request Handling Image Quality and Input Limitations Designing Multimodal Prompts for Reliable Results Building a Practical Vision-Powered Python Application Understanding Speech-to-Text and Text-to-Speech Workflows Transcribing Audio with the OpenAI SDK Working with Uploaded Audio Files Handling Timestamps and Transcription Metadata Building a Meeting Transcription Workflow Generating Spoken Responses from Text Handling Long Audio and Processing Failures Combining Audio with Text and Tool Calling Building an End-to-End Python Voice Application What Embeddings Are and When to Use Them Generating Embeddings With the OpenAI API Preparing Text for Embedding Comparing Vectors With Cosine Similarity Building a Simple Semantic Search Engine in Python Storing Embeddings in a Database Metadata Filtering for Semantic Search Chunking Strategies for Better Retrieval Evaluating Semantic Search Quality Building a Document Similarity Application When Batch Processing Makes Sense Designing Large-Volume AI Processing Pipelines Using Asynchronous Python with the OpenAI SDK Running Concurrent Requests Safely Controlling Concurrency and Avoiding Rate Limits Tracking Batch Job Progress Handling Partial Failures in Bulk Workloads Retrying Failed Items Without Duplicating Successful Work Designing Resumable AI Processing Jobs Building a Production Batch-Processing Pipeline Batch Processing Makes Sense Large-Scale AI Processing Pipelines Async Python with OpenAI SDK Safe Concurrent Requests Concurrency & Rate Limits Batch Progress Tracking Partial Failure Handling Safe Retry Handling Resumable AI Jobs Production Batch Pipeline System–User Data Separation Reusable App Instructions Prompt Templates & Variables Extraction & Classification Prompts Summarization & Transformation Prompts Explicit Output Requirements Prompt Version Management Prompt Testing & Evaluation Reusable Python Prompt Library API Key Security Secure API Key Storage Secure Secret Management Prompt Injection Prevention Trusted vs. Untrusted Content Tool Argument Validation Sensitive Data Handling Secure Logging AI Action Authorization Production AI Security Checklist Why AI Applications Need Evaluation Beyond Unit Tests Unit Testing OpenAI SDK Integration Code Mocking API Responses in Python Tests Testing Structured Outputs Against Schemas Testing Tool-Calling Workflows Building a Small Evaluation Dataset Measuring Accuracy, Consistency, and Failure Rates Regression Testing Prompts and Model Changes Human Evaluation Versus Automated Evaluation Creating a Repeatable Evaluation Pipeline AI Request Monitoring Token Cost Management Usage Metrics Design Reducing Model Calls Prompt & Context Optimization Model Selection & Optimization AI Caching Strategies Interactive Latency Optimization Usage Dashboards & Budget Alerts Performance & Cost Checklist Every API Call Starts Fresh Fixing API Statelessness Server-Side Conversation Memory Limits of Response Chaining What We're Building Conversation Memory Challenges Preparing an OpenAI SDK Application for Deployment Environment-Specific Configuration for Development and Production Deploying a Python AI Service with Docker Container Health Checks and Startup Configuration Managing Secrets in Cloud Deployments Background Workers for Long-Running AI Tasks Queues and Asynchronous Job Architectures Scaling AI Workloads Horizontally Monitoring Production Incidents and Failures Production Deployment Checklist for OpenAI SDK Applications Reusable OpenAI Service Classes AI Client Dependency Injection Typed AI Responses Python Configuration Management AI Request Decorators Centralized AI Error Handling Clean SDK Abstractions Reusable OpenAI Utilities Internal AI Python Libraries SDK Integration Maintenance Production AI Chatbot Document Q&A System Web Research Assistant Customer Support Agent AI Data Analysis Assistant Image Analysis App Meeting Transcription & Summary Semantic Document Search Multi-Tool AI Agent Production OpenAI SDK App Why "It Looked Fine When I Tested It" Isn't Enough Timing Note Status Note Pre-Decision Status Note Current Availability Note
Ask about this post
AI Ask about this post

Ask questions about Multi-Tool AI Agent and get answers drawn from it.

Signed-in readers only.