Multi-Tool AI Agent
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:
- 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.
- 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.
- 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:
FileSearchToolandWebSearchToolconstructor parameters and the exactfunction_tooldecorator 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_requestsimproves 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_tooldecorator does not prevent ordinary function calls, which makes business logic fully testable without invoking the agent runtime.