---
title: "Tool-Calling Agent"
description: "Tool-calling agents are the foundation of 'AI agent' roles now appearing across the industry — this is the entry point into agentic AI engineering specifically."
source: "https://confidentprep.com/paths/ai-engineering-fundamentals/tool-calling-agent/"
path: "AI Engineering Fundamentals"
repository: "https://github.com/confidentprep14-web/ai-engineering-projects/tree/main/tool-calling-agent"
token_estimate: 5426
---

# Tool-Calling Agent

## Overview

An agent that calls tools — search, save, and lookup functions — inside a loop that keeps
running until the model has a real answer or a hard iteration cap kicks in. This is the shift
from "an LLM that responds" to "an LLM that acts": every project after this one in the path
assumes you're comfortable with a model deciding, mid-conversation, that it needs to call
something before it can answer.

### What you're building

`tool-calling-agent` gives the model a small set of functions it can request — not run,
*request* — and wraps a single conversation in a loop that executes those requests, feeds the
results back, and lets the model reason again with that new information. The model never
touches a file system or a network call directly; it only ever produces a structured request
that your code decides whether to honor. That distinction — the model requests, your code
executes — is the one piece of mental model that makes every failure mode in this project make
sense.

### The agent loop

![Tool-calling agent loop: call the model with the tool list, branch on stop_reason, validate any requested tool name against an allowlist before executing it, inject the result, and repeat until end_turn or a max-iterations guard trips](/diagrams/tool-calling-agent-loop.svg)

*Every turn of the loop passes through the same two checks — is this a real, registered tool?
and has the loop run too many times? — and both exist specifically to stop the model's own
output from taking your code somewhere it shouldn't go.*

### Core concepts, three levels deep

#### 1. Tool definitions

- **Definition:** a JSON structure with a `name`, a `description`, and an `input_schema` — handed
  to the model alongside the conversation so it knows what it's allowed to ask for.
- **In this project:** the `description` field is doing the real work. It's the only place you
  tell the model *when* this tool applies and, just as importantly, when it doesn't — a vague
  description ("use this for information") gets the tool called for things it was never meant to
  handle.
- **Practical consequence:** write the description like you're briefing a new teammate who can
  only read that one sentence — state the trigger condition and the exclusion in the same breath,
  the way the reference tool definitions in this project do ("use this when X... do NOT use this
  for Y").

#### 2. The agent loop (ReAct cycle)

- **Definition:** a control structure — observe, reason, act, observe again — that repeats until
  a stop condition ends it. The model reasons over the current context, optionally emits a tool
  call, your code executes it and injects the result as the next "observation," and the cycle
  continues.
- **In this project:** every user message can trigger zero, one, or several trips around this
  loop before a final text answer comes back. The loop's state is just the growing message
  history — there's no separate memory store.
- **Practical consequence:** check `stop_reason` on every response. `"end_turn"` means the model
  is done and produced text — return it. `"tool_use"` means it wants something executed —
  dispatch it and loop again. Missing this branch is the single most common way to ship a loop
  that never terminates on its own.

#### 3. Hallucinated tool calls and stop conditions

- **Definition:** a hallucinated tool call is a `tool_use` block naming a function that was never
  actually registered with the model — the model predicted a plausible-looking request, not a
  real one. A stop condition is whatever explicit rule ends the loop: `end_turn`, a max-iteration
  cap, or a user-issued "done."
- **In this project:** nothing stops the model from emitting a tool name it invented, and nothing
  stops a loop from running forever except a rule your code enforces — neither failure mode is
  prevented by the model "behaving well" on average.
- **Practical consequence:** validate every requested tool name against an explicit allowlist
  before executing anything, and enforce a hard `MAX_ITERATIONS` ceiling independent of whatever
  the model decides to do. Both checks live in your code, not in the prompt.

#### 4. Tool output poisoning

- **Definition:** a security failure where content returned by a tool contains instructions, and
  the model follows them because it treats tool results as trusted context, the same way it
  treats a system prompt.
- **In this project:** any tool that can return content you didn't author yourself — a fetched web
  page, a file, an external API response — is a place an attacker can smuggle instructions
  directly into the model's next reasoning step.
- **Practical consequence:** treat tool output as untrusted input, not as a system-level
  instruction. Cap its length before injecting it, and don't assume "it came back from my own
  tool" means it's safe just because the *call* was legitimate — the *content* still needs to be
  handled like user-controlled data.

### Decision rules

| If... | Then... |
|---|---|
| A tool's name doesn't match anything you actually registered | Reject it and return an error `tool_result` — never execute a name you don't recognize |
| The model keeps calling tools past a reasonable number of turns | Enforce a hard `MAX_ITERATIONS` cap independent of the model's own behavior |
| A tool returns content from outside your control (a web page, a file, a search result) | Treat it as untrusted — cap its size and don't let it silently override prior instructions |
| All the information the model needs already fits in the prompt, with no external action required | Skip the loop — a single-shot prompt is simpler, cheaper, and has fewer failure surfaces |

### Common mistakes

- **Writing a vague tool description** ("use this for information") instead of stating exactly
  when the tool applies and when it doesn't — the model will call it in cases you didn't intend.
- **Trusting a tool name without validating it** — a `tool_use` block naming an unregistered
  function is a hallucinated call, not a real request; execute only what's on your allowlist.
- **Building a loop with no iteration ceiling** — a missing or broken stop condition doesn't
  fail loudly, it just runs (and bills) until something external kills the process.
- **Reaching for an agent loop by default** — if a single prompt with the right context in it can
  already answer the question, a loop only adds latency, cost, and new places to fail.

### Key concepts at a glance

| Concept | One-line definition | Why it matters for `tool-calling-agent` |
|---|---|---|
| Tool definition | Name + description + input_schema handed to the model | The description is what actually drives correct tool selection |
| Agent loop (ReAct) | Observe → reason → act → observe, repeated until a stop condition | The control structure the whole project runs inside |
| Hallucinated tool call | A `tool_use` block naming a function that was never registered | Why every tool name must be checked against an allowlist before executing |
| Tool output poisoning | Untrusted content in a tool result treated as trusted instructions | Why tool output needs the same skepticism as any other user-controlled input |

## What to do

**Path:** 1  
**Position:** 6 of 12  
**Difficulty:** 🟢 Local — one API key, no Docker, no cloud  
**Estimated time:** 4–5 hours  
**AWS cost:** None  

---

### Agent Pickup Instructions

**This spec is self-contained. Build this project without reading any other spec file.**

```bash
mkdir -p path-1/p1-06-tool-calling-agent
cd path-1/p1-06-tool-calling-agent

pytest tests/ -v
python src/main.py "What is 15% of 847 plus the current UTC hour?"
python src/main.py "Give me a one-sentence summary of the Wikipedia article about Python"
python src/main.py "What day of the week will January 1st 2030 fall on?"
```

**Done when:**
- [ ] `pytest tests/ -v` → minimum 5 tests, all green
- [ ] Calculator handles arithmetic without using `eval()`
- [ ] Wikipedia tool makes a real HTTP call (not stubbed in main code)
- [ ] Max iterations guard fires gracefully (no infinite loop possible)
- [ ] Tool errors are injected into context, not raised as exceptions
- [ ] Reasoning trace printed: every tool call and result visible in stdout
- [ ] Final answer includes tools used and iterations taken
- [ ] No file in `src/` exceeds 200 lines

---

### What this project is

An agent that uses tools — calculator, current datetime, and Wikipedia — to answer questions it cannot answer from training data alone. The LLM decides which tool to call, what arguments to pass, and when it has enough information to give a final answer. Unlike the fixed pipeline in Project 5, the sequence here is dynamic. This is the agentic loop pattern that underpins every AI agent in production: LLM reasons → calls tool → gets result → reasons again.

---

### What the learner achieves

"I built a tool-calling agent with a registry pattern, max-iterations guard, and tool-error recovery — the agent handles tool failures by injecting the error into context and retrying, rather than crashing."

---

### Folder structure

```
p1-06-tool-calling-agent/
├── README.md
├── GUIDE.md
├── .env.example
├── requirements.txt
├── src/
│   ├── main.py         ← CLI entry point
│   ├── llm.py          ← provider-agnostic LLM wrapper
│   ├── tools.py        ← tool implementations + registry
│   └── agent.py        ← agentic loop
└── tests/
    └── test_tools.py
```

---

### .env.example

```bash
LLM_PROVIDER=anthropic
LLM_API_KEY=
LLM_MODEL=

# Maximum agentic loop iterations before forced exit
MAX_ITERATIONS=8

# Wikipedia fetch timeout in seconds
WIKIPEDIA_TIMEOUT_SECONDS=5
```

---

### requirements.txt

```
anthropic==0.40.0
openai==1.58.0
ollama==0.4.4
requests==2.32.3
python-dotenv==1.0.1
```

---

### src/ — what to implement

#### src/llm.py

**`get_completion(messages: list[dict], system: str = "", tools: list[dict] = None) -> dict`**
- Returns `{"content": str, "tool_call": dict | None}`
- `tool_call` is `{"tool_name": str, "arguments": dict}` if the LLM decided to use a tool, else `None`
- `anthropic`: use `client.messages.create(tools=tools_schema, ...)`. Parse response: if `stop_reason == "tool_use"`, extract tool name and input from the tool use block. Otherwise return text content.
- `openai`: use `client.chat.completions.create(tools=tools_schema, tool_choice="auto")`. If `finish_reason == "tool_calls"`, extract from `response.choices[0].message.tool_calls[0]`.
- `ollama`: implement tool call detection via JSON parsing of model output (ollama models may not support native tool calls — parse `{"tool": "name", "args": {...}}` from model response as fallback)
- Include a helper `format_tools_for_provider(tools: list[dict], provider: str) -> list[dict]` that converts the internal tool schema to provider-specific format

#### src/tools.py

**Tool registry pattern:**

```python
TOOL_REGISTRY: dict[str, dict] = {}

def register_tool(name: str, description: str, parameters: dict):
    """Decorator that registers a function as a callable tool."""
    def decorator(func):
        TOOL_REGISTRY[name] = {
            "function": func,
            "description": description,
            "parameters": parameters,   # JSON Schema for arguments
        }
        return func
    return decorator

def get_tool_schemas() -> list[dict]:
    """Return tool definitions in OpenAI-compatible format."""
    ...

def call_tool(tool_name: str, arguments: dict) -> str:
    """Execute a registered tool and return result as string."""
    if tool_name not in TOOL_REGISTRY:
        return f"Error: tool '{tool_name}' does not exist. Available: {list(TOOL_REGISTRY.keys())}"
    try:
        return str(TOOL_REGISTRY[tool_name]["function"](**arguments))
    except Exception as e:
        return f"Tool error: {e}"
```

**Tool 1 — `calculator`:**
```python
@register_tool(
    name="calculator",
    description="Evaluate an arithmetic expression. Supports +, -, *, /, ** (power), % (modulo), and parentheses.",
    parameters={"type": "object", "properties": {"expression": {"type": "string"}}, "required": ["expression"]}
)
def calculator(expression: str) -> str:
    ...
```
- Parse using Python `ast.parse()` and a safe evaluator — NOT `eval()`
- Allow only: `ast.Num`, `ast.BinOp`, `ast.UnaryOp`, and the operators `+, -, *, /, **, %`
- Reject any expression containing names/calls/attributes: return `"Error: only arithmetic is allowed"`
- Return the numeric result as a string

**Tool 2 — `get_datetime`:**
```python
@register_tool(
    name="get_datetime",
    description="Get the current UTC date and time.",
    parameters={"type": "object", "properties": {"timezone": {"type": "string", "description": "IANA timezone name, e.g. UTC, America/New_York"}}, "required": []}
)
def get_datetime(timezone: str = "UTC") -> str:
    ...
```
- Use `datetime.datetime.now(datetime.timezone.utc)` for UTC
- Return ISO 8601 format: `2025-06-16T14:32:00Z`
- If timezone not "UTC": return UTC with a note "timezone conversion not implemented — returning UTC"

**Tool 3 — `wikipedia_search`:**
```python
@register_tool(
    name="wikipedia_search",
    description="Fetch a summary of a Wikipedia article by title.",
    parameters={"type": "object", "properties": {"title": {"type": "string"}}, "required": ["title"]}
)
def wikipedia_search(title: str) -> str:
    ...
```
- Call `https://en.wikipedia.org/api/rest_v1/page/summary/{urllib.parse.quote(title)}`
- On 200: return `response.json()["extract"]` (first 500 chars max)
- On 404: return `f"No Wikipedia article found for '{title}'"`
- On timeout: return `"Wikipedia request timed out"`

#### src/agent.py

**`AgentResult` dataclass:**
```python
@dataclass
class AgentResult:
    question: str
    final_answer: str
    tools_used: list[str]
    iterations: int
    hit_max_iterations: bool
    trace: list[dict]   # each entry: {"iteration": N, "type": "tool_call"|"answer", "tool": str, "input": str, "output": str}
```

**`run_agent(question: str, max_iterations: int = None) -> AgentResult`**

System prompt:
```
You are a helpful assistant with access to tools. To use a tool, respond with a tool call.
When you have enough information to answer, give your final answer directly.
Always show your reasoning before calling a tool.
```

Loop:
1. Start with `messages = [{"role": "user", "content": question}]`
2. Call `llm.get_completion(messages, system=system_prompt, tools=get_tool_schemas())`
3. If `tool_call` is not None:
   - Print: `[Iter {N}] 🔧 Calling {tool_name}({arguments})`
   - Execute: `result = tools.call_tool(tool_name, arguments)`
   - Print: `         → {result[:100]}` (truncate long results)
   - Append to messages: `{"role": "assistant", "content": f"I'll use {tool_name}"}` and `{"role": "user", "content": f"Tool result: {result}"}`
   - Add to trace
4. If `tool_call` is None: this is the final answer — break loop
5. If `iterations >= max_iterations`: break with `hit_max_iterations=True`, append "Reached max iterations. Best answer with current info:" to final message
6. Return `AgentResult`

#### src/main.py

CLI: `python src/main.py "<question>" [--max-iter N]`

1. Print the question
2. Call `run_agent(question)`
3. Print reasoning trace (already printed during loop via agent.py)
4. Print final answer
5. Print: `\n📊 Tools used: {tools_used} | Iterations: {N}{" (max reached)" if hit_max else ""}`

---

### tests/ — what to test

**File:** `tests/test_tools.py`

**Test 1 — calculator evaluates correct result:**
`calculator("2 + 2 * 3")` → assert result == "8".

**Test 2 — calculator blocks eval injection:**
`calculator("__import__('os').system('echo bad')")` → assert "Error" in result.

**Test 3 — calculator blocks name references:**
`calculator("pi * 2")` → assert "Error" in result (pi is a name, not a literal).

**Test 4 — wikipedia_search handles 404:**
Mock `requests.get` to return status 404. Assert result contains "No Wikipedia article found".

**Test 5 — call_tool returns error string for unknown tool:**
`call_tool("nonexistent_tool", {})` → assert "Error" in result and no exception raised.

**Test 6 — get_datetime returns ISO format:**
`get_datetime()` → assert result starts with "20" (year) and contains "T".

---

### README.md content

```markdown
# Tool-Calling Agent

An agent that uses tools — calculator, datetime, and Wikipedia — to answer questions
it can't answer from training alone, with a reasoning trace and max-iterations guard.

## Setup

```bash
cd p1-06-tool-calling-agent
cp .env.example .env
pip install -r requirements.txt
```

## Run

```bash
python src/main.py "What is 15% of 847 plus the current UTC hour?"
python src/main.py "Give me a one-sentence summary of the Wikipedia article about FAISS"
python src/main.py "What is 2 to the power of 32?" --max-iter 3
```

Expected output:
```
Question: What is 15% of 847 plus the current UTC hour?

[Iter 1] 🔧 Calling calculator({"expression": "847 * 0.15"})
         → 127.05
[Iter 2] 🔧 Calling get_datetime({})
         → 2025-06-16T14:00:00Z
[Iter 3] 🔧 Calling calculator({"expression": "127.05 + 14"})
         → 141.05

Answer: 15% of 847 is 127.05. The current UTC hour is 14. The total is 141.05.

📊 Tools used: ['calculator', 'get_datetime', 'calculator'] | Iterations: 3
```

## Tests

```bash
pytest tests/ -v
```

Tests verify calculator safety, Wikipedia error handling, and unknown tool recovery.
No API key required.

## What to try next

- Add a `read_file` tool that reads a local file and returns its contents
- Add a `web_search` tool using the Brave or Serper API
- Set MAX_ITERATIONS=2 and ask a complex question — watch the graceful exit
```

---

### GUIDE.md content

```markdown
# Build guide: Tool-Calling Agent

## What you're building and why it matters

LLMs have a training cutoff and no access to live data. An agent with tools extends
the model's capabilities: it can fetch current stock prices, query a database, run
code, or call any API. The agentic loop — reason, act, observe, reason again — is
the architecture behind GitHub Copilot, customer support bots, and coding assistants.
Understanding it at the implementation level (not just as a library abstraction)
means you can debug it when it loops, control it when it hallucinates tool arguments,
and extend it when you need a new capability.

## The decision that matters in this build

**Tool error handling: raise or inject?** When a tool fails — Wikipedia times out,
the calculator gets a bad expression — you have two choices: raise an exception
(halt the agent) or inject the error into context (let the LLM decide what to do next).
Always inject. A production agent that crashes on a tool failure is unusable. Inject
the error message: "Tool wikipedia_search failed: timeout". The LLM will usually
try a different tool or rephrase the query. The `call_tool()` function in this project
never raises — it always returns a string, even if that string is an error message.

## What will break

**The LLM will call tools in unexpected ways.** You designed `get_datetime` with no
required arguments, but the LLM might call it with `{"format": "ISO"}` — an argument
that doesn't exist. Your tool should handle unexpected kwargs gracefully (log and ignore
them) rather than raising a TypeError that crashes the agent.

**Max iterations is not a safety net for bad prompts.** A poorly written system prompt
can cause the LLM to call tools in circles — calculator calls leading to more calculator
calls. Set MAX_ITERATIONS conservatively (5–8) and log a clear warning when it fires.
If it fires often, your system prompt needs work.

## How to talk about this in an interview

"I built an agent with a tool registry pattern — adding a new tool is one decorator,
not a change to the agent loop. The key design decision was treating tool errors as
context, not exceptions: the agent sees 'Tool X failed: reason' and can recover.
I also implemented a max-iterations guard with a graceful partial-answer exit, which
matters in production where infinite loops cause both cost and latency issues."
```

---

### Failure modes to handle

| Failure | Where | How to handle |
|---------|-------|---------------|
| Unknown tool called | `tools.call_tool()` | Return error string, never raise |
| Tool raises exception | `tools.call_tool()` | Catch all exceptions, return `f"Tool error: {e}"` |
| Calculator eval injection | `tools.calculator()` | AST whitelist check, return "Error: only arithmetic allowed" |
| Wikipedia timeout | `tools.wikipedia_search()` | Return timeout string |
| Max iterations reached | `agent.run_agent()` | Break loop, set `hit_max_iterations=True`, return best partial answer |
| LLM returns no tool call and no text | `agent.run_agent()` | Treat as final answer with empty string, log warning |

---

### The metric this project measures

**Iterations per question** and **tools used** — printed at end of every run.
Format: `📊 Tools used: ['calculator', 'get_datetime'] | Iterations: 2`
Target: 90% of questions answered in ≤5 iterations. If a question consistently hits MAX_ITERATIONS,
the system prompt or tool descriptions need improvement.


### Model source currency

- Anthropic model list: https://docs.anthropic.com/en/docs/about-claude/models/overview
- OpenAI model list: https://platform.openai.com/docs/models
- verifiedOn: 2026-08-07
- `LLM_MODEL` is required and has no implicit default; choose a supported model for the configured provider at setup time.

## Source code

A reference implementation of Tool-Calling Agent — README, guide, source and tests — is at https://github.com/confidentprep14-web/ai-engineering-projects/tree/main/tool-calling-agent.
