AI SWE Prep
0/10in AI/ML Engineering
AI/ML Engineering

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.

~16 minLesson 41 of 60
Your progressNot started

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:

  1. Send the model the task, state, and available tool schemas.
  2. The model either returns a final answer or requests a tool call.
  3. Your code validates and executes the tool call.
  4. Tool results are appended to the conversation.
  5. 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 reason → act → observe loopStep 1 / 7
Reason

The user wants the temperature difference between Paris and Tokyo. I don't know either — I need the weather tool.

scratchpad / context
  • goal: ΔT(Paris, Tokyo)
Reason: the model plans, deciding it needs an external tool rather than guessing.
The model only ever emits text; a tool call is just structured text the runtime intercepts, runs, and feeds back.

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

DrillEasy
  • Schema
  • Safety

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.

DrillMedium
  • Function calling
  • Python

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.

DrillMedium
  • Guardrails
  • Cost

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.

The classic pitfall

Your progressNot started