Agents, Tools & Function Calling
Let a model take actions. The reason–act loop, tool schemas, and the failure modes that make agents hard in production.
A plain LLM call returns text. An agent can take actions: search a database, call an API, write a file, schedule a job, or ask another service for state. The model still predicts tokens, but now some tokens are structured tool requests that software executes.
That extra power changes the failure mode. A bad answer is annoying. A bad tool call can refund the wrong order, leak data, loop forever, or spend money until a quota kills it.
The mental model
An agent is a loop around the model:
- Send the model the task, state, and available tool schemas.
- The model either returns a final answer or requests a tool call.
- Your code validates and executes the tool call.
- Tool results are appended to the conversation.
- The loop repeats until a termination condition fires.
while True:
response = model(messages=messages, tools=tool_schemas)
if response.final_answer:
return response.final_answer
call = response.tool_call
result = dispatch_tool(call.name, call.arguments)
messages.append({"role": "tool", "name": call.name, "content": result})
The model does not directly call your database. It emits a structured request. Your orchestrator owns validation, authorization, execution, retries, and stopping.
The user wants the temperature difference between Paris and Tokyo. I don't know either — I need the weather tool.
- goal: ΔT(Paris, Tokyo)
Tool schemas are API contracts
A tool schema tells the model what actions exist and what arguments are valid. The schema should be narrow enough that a valid call is safe to execute.
tool = {
"name": "lookup_order",
"description": "Return order status for the authenticated user's order.",
"parameters": {
"type": "object",
"properties": {
"order_id": {"type": "string", "description": "Public order ID"}
},
"required": ["order_id"],
"additionalProperties": False,
},
}
Names and descriptions matter. run_sql invites broad, risky behavior. lookup_order_status gives the model a clear, bounded affordance.
How the model consumes tool results
Tool results are just more context. Keep them compact, explicit, and shaped for the next decision.
Bad tool result:
OK
Better tool result:
{"order_id":"1842","status":"delivered","delivered_at":"2026-07-08","refundable":true}
The better result gives the model the facts it needs without another call. It also lets you redact fields before they enter the context window.
Orchestration choices
Agents range from simple single-tool loops to workflow engines:
- One-shot function calling — model chooses a tool once; your code returns a final response.
- ReAct loop — model alternates reasoning and acting until done.
- Planner/executor — one step decomposes work; another executes bounded tasks.
- Graph orchestration — deterministic nodes decide which model or tool runs next.
The more freedom the model gets, the more guardrails the orchestrator needs.
Failure modes
Production agents fail in recognizable ways:
- Loops — the model keeps calling tools without making progress.
- Hallucinated arguments — IDs, dates, or enum values appear from nowhere.
- Tool overuse — the model calls expensive tools when cached state was enough.
- No termination — the loop lacks max steps, deadline, or success criteria.
- Unsafe actions — a tool can mutate state without confirmation or policy checks.
- Context bloat — every tool result is appended forever until cost and latency explode.
Pattern recognition
Variations
Worked problems
Design a tool schema
A support assistant may check refund eligibility for the authenticated user’s order. It must not issue refunds; it can only report whether the order appears eligible. Design a tool schema.
Approach. Name the tool for the exact read-only action, require only the needed argument, forbid extra properties, and leave user identity outside the model-provided arguments.
Show solution
refund_tool = {
"name": "check_refund_eligibility",
"description": (
"Check whether an order owned by the authenticated user is eligible "
"for refund. This tool does not create a refund."
),
"parameters": {
"type": "object",
"properties": {
"order_id": {
"type": "string",
"description": "The public order ID shown to the user.",
}
},
"required": ["order_id"],
"additionalProperties": False,
},
}Do not include user_id in the schema; derive it from the authenticated session. Do not expose issue_refund unless the product has confirmation, policy checks, and audit logging.
Complexity. O(1). The safety comes from reducing the valid action surface.
Write the dispatch loop
Write a small Python orchestrator that calls a model, dispatches requested tools, and returns the final answer. Assume the model client returns dictionaries with either {"type": "final", "content": ...} or {"type": "tool_call", ...}.
Approach. Keep a tool registry, validate tool names, execute ordinary Python functions, and append tool results back into the message list.
Show solution
import json
class ToolError(Exception):
pass
def lookup_order(args):
order_id = args["order_id"]
return {"order_id": order_id, "status": "delivered", "refundable": True}
TOOLS = {"lookup_order": lookup_order}
def run_agent(model, user_message, max_steps=6):
messages = [{"role": "user", "content": user_message}]
for _ in range(max_steps):
event = model(messages=messages, tools=list(TOOLS))
if event["type"] == "final":
return event["content"]
if event["type"] != "tool_call":
raise ToolError(f"unknown event type: {event['type']}")
name = event["name"]
if name not in TOOLS:
raise ToolError(f"unknown tool: {name}")
args = event.get("arguments", {})
result = TOOLS[name](args)
messages.append({
"role": "tool",
"name": name,
"content": json.dumps(result),
})
raise ToolError("agent reached step limit")This is intentionally small. Production code adds JSON-schema validation, authorization, tracing, retries for safe reads, and idempotency keys for writes.
Complexity. O(s · T) where s is the number of agent steps and T is the cost of the called tools. The loop must have a hard step limit.
Stop an infinite loop
An agent keeps calling web_search with slightly different queries and never answers. Add guards that stop repeated calls and enforce a budget.
Approach. Track step count, repeated tool signatures, elapsed time, and total estimated cost. Stop with a clear failure instead of hoping the model terminates.
Show solution
import json
import time
class BudgetExceeded(Exception):
pass
def guarded_agent(model, messages, tools, max_steps=8, max_seconds=20, max_cost=0.05):
start = time.monotonic()
seen_calls = set()
cost = 0.0
for step in range(max_steps):
if time.monotonic() - start > max_seconds:
raise BudgetExceeded("deadline exceeded")
if cost > max_cost:
raise BudgetExceeded("cost budget exceeded")
event = model(messages=messages, tools=tools)
cost += event.get("estimated_cost", 0.0)
if event["type"] == "final":
return event["content"]
signature = (event["name"], json.dumps(event.get("arguments", {}), sort_keys=True))
if signature in seen_calls:
raise BudgetExceeded(f"repeated tool call: {signature}")
seen_calls.add(signature)
result = dispatch(event["name"], event.get("arguments", {}))
messages.append({"role": "tool", "name": event["name"], "content": result})
raise BudgetExceeded("step limit exceeded")The guard turns an unbounded model loop into a bounded workflow. You can also add “no progress” checks, per-tool limits, and human escalation for high-value tasks.
Complexity. O(s) guard bookkeeping for s steps, excluding tool execution. The main win is operational: worst-case cost becomes predictable.