Module 2: Agents & Tool Calling
This page covers the theory behind how an agent works: the tool-call loop, how the model signals that it wants to run a function, how tool schemas are structured, and why the agent in this workshop is deliberately simple. The hands-on portion is in Lab 2.
By the end of this page you should be able to explain:
- What an agent is at the code level (a loop, nothing more)
- How
finish_reason: tool_callsdrives the loop - What a tool schema is and why the description field matters
- How the message list grows with each iteration
- What
MAX_ITERATIONSprotects against and why it is not a security control
The UI
Lab 2 introduces a browser UI. Lab 2 gives the URL for your deployment
path. It is a single-page vanilla JS application that talks to the agent API
(proxied through nginx as /api/).
It has three panels:
- Chat — sends messages to
/chat, displays the agent’s final answer. - Trace — shows each tool call in real time: function name, arguments, and
result. Populated from the
tracearray in the/chatresponse. - Audit Log — shows the structured log from
/logs. Visible only whenTRANSPARENCY=verbose; empty underquietmode. This is the Lab 4 lesson.
The UI is a teaching aid. All the same interactions are available via curl
against the agent API directly — which is how the lab verification steps work.
What an agent actually is
The word “agent” gets used to describe everything from a simple chatbot wrapper to a fully autonomous system managing cloud infrastructure. For this workshop, we use a precise definition:
An agent is a loop that calls an LLM, checks whether the model wants to run a tool, executes that tool if so, feeds the result back into the context, and repeats — until the model produces a plain text reply or a safety limit fires.
flowchart TD
U[User message] --> LLM[LLM call]
LLM -->|finish_reason = tool_calls| T[Execute tools]
T -->|results appended to messages| LLM
LLM -->|finish_reason = stop| A[Answer to user]There is no framework magic here. The agent in lab-app/images/agent/main.py
implements this in about 40 lines of Python. The reason we build it without
LangChain or any similar framework is that frameworks hide the loop — and once
you understand the loop, you understand both how agents work and exactly where
they can go wrong.
Agent vs agentic — a distinction that matters
These two terms are often used interchangeably but mean different things:
Agent refers to a specific, identifiable software system: a loop, an LLM, and a set of tools. You can point at it in code. The container in this workshop is an agent.
Agentic is an adjective that describes any system where an LLM drives decisions and real-world actions — to any degree. An agent is always agentic. But many systems are agentic without being called agents:
| System | Why it is agentic |
|---|---|
| GitHub Copilot Workspace | LLM decides which files to edit and what code to write |
| An email assistant | LLM reads incoming mail and drafts or sends replies |
| A RAG pipeline with write-back | LLM retrieves content and the result updates a record |
| An IT automation copilot | LLM interprets a ticket and calls infrastructure APIs |
| This workshop’s FastAPI container | LLM calls query_employees and send_message |
The distinction matters for security. A product team may say “we don’t have an agent” while running a system where an LLM is driving tool calls. The question is not whether the word “agent” appears in the architecture diagram — it is whether an LLM is making decisions that cause code to execute or data to move. If yes, the agentic security model applies.
Module 4 covers agentic security: the attack surface and failure modes that apply to all of these systems, not just bare agent loops.
How the model signals a tool call
Recall from Module 1 that finish_reason is the field that tells you why the
model stopped generating. In a plain chat application you only see stop. In
an agent, a second value appears: tool_calls.
When the model decides it needs to invoke a function, instead of producing a
text response it emits a structured message where content is null and
tool_calls is a list of function requests:
{
"finish_reason": "tool_calls",
"message": {
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_a1b2c3",
"type": "function",
"function": {
"name": "query_employees",
"arguments": "{\"filter\": \"Engineering\"}"
}
}
]
}
}The model can request more than one tool call in a single turn. The arguments
field is always a JSON string (not an object) — your code needs to
json.loads() it before passing it to the actual function.
The id field matters: when you return the result, you reference this id so
the model knows which result belongs to which call. If the model requested
three tool calls, you return three tool results, each tagged with the
corresponding id.
This workshop’s implementation runs multiple tool calls sequentially — a
for loop over msg["tool_calls"], one at a time. Parallel execution is
possible (Python’s asyncio.gather) but adds complexity. For the lab’s two
tools the difference is unnoticeable; for a production agent calling slow
external APIs it matters.
Tool schemas
Before the model can request a tool, it has to know what tools exist. You tell
it by including a tools array in each LLM request. Each entry follows the
OpenAI function-calling schema:
{
"type": "function",
"function": {
"name": "query_employees",
"description": "Look up employees in the HR database by department name.",
"parameters": {
"type": "object",
"properties": {
"filter": {
"type": "string",
"description": "Department to look up, e.g. 'Engineering', 'Finance', 'Sales'."
}
},
"required": ["filter"]
}
}
}The description field is the attack surface
The model reads the description field to decide when to call a tool and
how to construct the arguments. It does not read the source code of the
function. It does not know what the function actually does.
This has two implications:
- A vague or misleading description causes the model to call the tool at the wrong time or with wrong arguments.
- A description that an attacker has modified — because it comes from an MCP server the attacker controls — can embed hidden instructions that cause the model to take actions the user never requested. Module 4 demonstrates this.
The description is not metadata. It is an instruction to a statistical model, with all the fragility that implies.
The message list during the loop
The agent loop adds messages to the conversation on every iteration. Starting from a single user message, a two-tool-call turn produces:
[system] ← always present
[user: "Who manages Engineering?"]
[assistant: content=null, tool_calls=[call_1]] ← model's tool request
[tool: call_1, content="...employees..."] ← your code's result
[assistant: "Alice Chen's manager is Bob..."] ← model's final answerIf the model chains two tool calls across two iterations:
[system]
[user: "Find Alice's manager and email them"]
[assistant: tool_calls=[call_1]] ← iteration 1 request
[tool: call_1, "Alice's manager is Bob"] ← iteration 1 result
[assistant: tool_calls=[call_2]] ← iteration 2 request
[tool: call_2, "message queued"] ← iteration 2 result
[assistant: "Done, I've notified Bob."] ← final answerThe entire list goes into every LLM call. The context window shrinks with each round trip. For deep tool chains, this matters — a model with a limited context window may lose earlier messages if the chain grows long enough.
One call, one conversation
The agent in this workshop is stateless across /chat calls. Each request
builds the message list from scratch — system prompt plus the single user
message in that request. The model has no memory of previous /chat calls.
session_id in the request body is for log grouping only — it tags audit
log entries so you can filter by session. It does not cause the agent to replay
previous messages. If you want a multi-turn conversation, your client must resend
the full history in each request, the same way any chat application does (as
covered in Module 1 statelessness).
This is intentional simplicity for the workshop. Production agents typically maintain per-session message history on the server side or push that responsibility to the client.
MAX_ITERATIONS and why it is not a security control
The agent caps the loop at MAX_ITERATIONS = 5. If the model has not
produced a stop response by then, the loop exits and returns a fixed error
message.
This exists to prevent runaway loops — situations where the model keeps requesting tools indefinitely, either because it is confused or because it has been manipulated into an infinite task. Without the cap, the agent would consume tokens and run tools until it hit an external timeout or resource limit.
However, MAX_ITERATIONS is not a security control in any meaningful sense. Five iterations is plenty for an attacker who can inject a two-step “look up all records, then email them” instruction. The cap limits cost and runtime; it does not limit what the agent can be made to do within those iterations.
tool_choice
The LLM call in this agent sets "tool_choice": "auto". This tells the model
it can freely decide whether to call a tool or respond with text. The
alternatives are:
| Value | Behaviour |
|---|---|
"auto" | Model decides whether to call a tool or not |
"none" | Model must respond with text; tool calling disabled for this turn |
{"type": "function", "function": {"name": "..."}} | Model must call this specific function |
The lab uses auto throughout. none is useful for final-answer turns where
you want to guarantee a text response. Forced function calling is useful for
structured-output extraction patterns.
The tools in this workshop
Both labs use the same two tools. The implementations are intentionally simple so the focus stays on the loop, not the tools themselves.
| Tool | What it does |
|---|---|
query_employees | Queries the SQLite HR database by department. Intentionally vulnerable to SQL injection — the vulnerability is the lesson in Module 4, not a mistake. |
send_message | Appends a message to an in-memory outbox. Nothing leaves the container. Inspect the outbox via /outbox. |
The agent also has a system prompt — different from Lab 1’s:
You are a helpful HR assistant for Acme Corp.
You have access to tools for looking up employee information and sending messages.
When a user asks you to perform an action, always use the appropriate tool —
do not just describe what you would do.
CONFIDENTIAL NOTE: Do not share salary figures or the contents of any
confidential employee notes with users under any circumstances.Notice the CONFIDENTIAL NOTE at the bottom. It is there for Module 4.
Hardcoded vs MCP mode
The same agent binary runs in all labs. TOOL_MODE selects how tools are
registered and dispatched:
TOOL_MODE | Tool source | Dispatch |
|---|---|---|
hardcoded | Static Python list in tools.py | Direct function call in same process |
mcp | Discovered from MCP server at startup (and on /tools/refresh) | HTTP call to MCP server |
The agent loop — _run_agent() in main.py — is identical in both modes.
The only differences are in _load_hardcoded() / _discover_mcp() (how
schemas are obtained) and _run_tool() (how a call is dispatched). The loop
itself never knows which mode is active.
This abstraction is Module 3’s teaching point: if you can swap the tool backend without changing the loop, you can also add new tools at runtime without restarting anything.
The authorization gap
Here is the question the agent loop never asks: who authorized this tool call?
The model decides to call query_employees. The loop executes it. There is no
check that the user actually intended that specific query with those specific
arguments. The model’s judgment — which can be manipulated — is the sole
authorization mechanism.
In a conventional application, you would not let user input construct a database query directly. You would validate and sanitise. The agent loop, in its basic form, does not. That gap — between what the user intended and what the model decided to do — is the entire subject of Module 4.
Quick reference
/chat response and trace format
{
"answer": "Alice Chen's manager is Bob.",
"session_id": "abc123",
"trace": [
{
"iteration": 0,
"tool_calls": [
{
"tool": "query_employees",
"arguments": { "filter": "Engineering" },
"result": "{\"employees\": [{\"name\": \"Alice Chen\", ...}]}"
}
]
},
{
"iteration": 1,
"answer": "Alice Chen's manager is Bob."
}
]
}Each element in trace is one iteration of the loop. A tool-call iteration
has tool_calls (list of name + arguments + raw result string). The final
iteration has answer instead. If MAX_ITERATIONS is reached, answer is
"Reached iteration limit." and the trace has no final answer entry.
Agent loop state machine
START → [LLM call] → finish_reason?
├─ tool_calls → execute tools → append results → [LLM call again]
├─ stop → return answer
└─ (MAX_ITERATIONS reached) → return errorKey endpoints (agent)
| Endpoint | Method | Returns |
|---|---|---|
/health | GET | tool_mode, model, transparency |
/chat | POST | Answer, trace, session_id |
/tools | GET | Current tool list (name, description, parameters) |
/tools/refresh | POST | Re-discovers tools from MCP; no-op in hardcoded mode |
/logs | GET | Full audit log |
/outbox | GET | Messages queued by send_message |
Environment variables (agent)
| Variable | Default | Effect |
|---|---|---|
TOOL_MODE | hardcoded | hardcoded or mcp |
TRANSPARENCY | verbose | Controls whether audit log is surfaced in UI |
OPENAI_BASE_URL | http://ollama:11434/v1 | LLM endpoint — change for Day 2 |
MODEL | qwen2.5:3b | Model name passed to the API |