hands-on labIDE in your browserfree

Build an Agent From Scratch: The Tool Loop Without a Framework

Hand-roll a tool-using agent on the chat completions API and nothing else: parse the model's tool calls, dispatch them to plain Python functions, close the loop with correctly threaded tool results, add stop conditions and budgets, turn every failure into a tool result the model can read, put an approval guardrail in front of a side-effecting refund tool, control context with truncation and a trace, and finish with an evaluation run that scores the agent on a fixed question set.

time
80 min
checked steps
8
level
Intermediate
setup
None

Freeone of 170+ labs in Pro

free · no account needed to start

Lab cockpit80 min · 8 stepsSession running
6 / 8 steps passingControl the context and trace every step · step 7 of 8
agent.py▶ Run✓ Check
     
TerminalOutput
step 7 of 8, replayed: Control the context and trace every step. The code, the output and the check are the lab's own.

The job

Northwind Cloud's operations team wants an assistant that answers questions like "what would it cost to ship order NW-1041 to its destination" and "refund order NW-1007 the full price of its power unit". The answers need order lookups, arithmetic, unit conversion, shipping rates and, for refunds, a side effect that moves money. You build the agent that does this on the raw chat completions API: no framework, no orchestration library, about a hundred lines you control end to end. A scripted stand-in model replays real failures captured from a small model so every safeguard is tested without waiting for the failure to happen live.

8 steps, each checked when you finish it

A check runs your work at the end of every step. Hints and the full solution are there if you get stuck.

  1. 1

    One round trip: what the model actually sends back

    An agent is a loop around one API call.

    read this step below ↓

  2. 2

    The dispatcher: from a tool name to a running function

    The model returns a name and some arguments.

  3. 3

    Close the loop

    Send the conversation with the tools.

  4. 4

    Stop conditions: budgets for steps, tool calls and tokens

    The loop script in fake_client.py is a real transcript.

  5. 5

    Errors as information: nothing raises out of the loop

    Two more recordings.

  6. 6

    A guardrail in front of the tool that moves money

    Five of the six tools read or compute.

  7. 7

    Control the context and trace every step

    Every tool result is appended to the conversation and sent back on every following request, for the rest of the run.

  8. 8

    Evaluate the agent on a fixed question set

    Everything so far makes the agent robust.

The lab, step by step

The free lab’s own text, every step of it. Hints and solutions stay inside the lab.

Step 1: One round trip: what the model actually sends back

An agent is a loop around one API call. Before writing the loop, look at what that call returns when tools are attached, because everything the loop does is a reaction to that shape.

tools.py holds six plain Python functions for Northwind's operations team: lookup_order, list_orders, convert_units, calculator, shipping_quote and refund. Next to them, TOOL_SPECS describes the same functions to the model in the chat completions tool format: a name, a description, and a JSON schema for the parameters. The model never runs anything. It reads the descriptions, and when a question needs a tool it returns an assistant message whose content is empty and whose tool_calls list says which function to call and with what arguments.

Two details of that message decide how the rest of the lab is written. Each tool call carries an id, and the API will refuse the next request unless every id gets a matching tool message back. And function.arguments is a string containing JSON rather than a parsed object. The model produced it token by token, and on a bad day it is not valid JSON at all. Parsing it is the executor's job, later; extracting it is this step's.

do this

1. Run. agent.py --one-shot sends one completion with the tools and prints the raw assistant message: the finish reason is tool_calls, the content is None, and there is a call to lookup_order with the order id in its arguments string. Below it, parsed: is empty. That is the gap you close now.

2. Implement parse_tool_calls(message) in agent.py. Walk message.tool_calls (it is None on a plain text answer, so guard for that) and collect one dict per call: {"id": tc.id, "name": tc.function.name, "arguments": tc.function.arguments}. Keep arguments as the raw string. Return [] when there are no tool calls.

3. Run again and read oneshot.json. The parsed list now mirrors the raw calls. Note what the model did with the question: it asked for the order first, because the shipping quote needs a weight and a country it does not have yet. It cannot get them in this step, since nothing runs the tool and nothing sends the result back. That is the loop, and it is Step 3.

Why this matters in practice

Every framework's agent loop starts from this exact message. Knowing that the arguments are a string, that ids must be answered, and that a text answer means "no more tool calls" is enough to read any of them.

agent.py, the file you edit250 lines
"""agent.py: a tool-using agent on the chat completions API and nothing else.

    python3 agent.py "How much would it cost to ship order NW-1007 to its destination?"
    python3 agent.py --one-shot "..."          # one completion, print the raw assistant message
    python3 agent.py --questions q.jsonl --out runs.jsonl
    python3 agent.py --scripted loop            # replay a recorded transcript offline (see fake_client.py)

Options: --model, --max-steps, --max-tool-calls, --token-budget, --max-result-chars,
         --trace trace.jsonl, --auto-approve, --audit audit.jsonl
"""
import argparse
import json
import os
import sys
import time

import tools
from tools import TOOL_SPECS, SIDE_EFFECT_TOOLS

MODEL = os.environ.get("AGENT_MODEL", "meta/llama-3.3-70b-instruct")
BASE_URL = os.environ.get("AGENT_BASE_URL", "http://nim-proxy.labs.svc:8080/v1")
APPROVAL_LIMIT_USD = float(os.environ.get("AGENT_APPROVAL_LIMIT", "100"))

SYSTEM = (
    "You are Northwind Cloud's operations assistant. You have tools for order lookup, "
    "arithmetic, unit conversion, shipping quotes and refunds. Use a tool for every lookup, "
    "calculation, conversion and quote, including ones that look trivial: call calculator for "
    "any arithmetic and convert_units for any unit conversion; never compute or convert numbers "
    "yourself. "
    "When the tools have given you what you need, answer in one or two sentences with the "
    "final numbers. If a tool reports an error, say so plainly instead of inventing a value."
)


def make_client():
    from openai import OpenAI
    return OpenAI(base_url=BASE_URL, api_key="not-needed")


# ----------------------------------------------------------------- Step 1
def parse_tool_calls(message):
    """Extract the tool calls from an assistant message.

    Returns a list of {"id", "name", "arguments"} where arguments is the RAW
    JSON string the model produced (parsing it is the executor's job).
    An assistant message with no tool calls returns [].
    """
    # TODO (Step 01): walk message.tool_calls (it may be None) and collect
    #   {"id": tc.id, "name": tc.function.name, "arguments": tc.function.arguments}
    #   arguments stays the RAW string; return [] when there are no tool calls.
    return []


# ----------------------------------------------------------------- Step 2
def build_registry(specs, module=tools):
    """Map every tool name declared in specs to the Python function of that
    name in `module`. A declared tool with no implementation is a bug: raise."""
    return {}


def dispatch(registry, name, args):
    """Run one tool and return the JSON string that goes back to the model.
    An unknown tool name returns a JSON error rather than raising."""
    return json.dumps({"error": "dispatch arrives in Step 02"})


# ----------------------------------------------------------------- Step 6
def check_policy(agent, name, args):
    """Policy hook, called before any tool runs. Return None to allow the
    call, or a string explaining the denial; the string goes back to the
    model as the tool result. Every decision on a side-effecting tool is
    written to the audit log."""
    return None  # the policy hook arrives in Step 06


def run_tool(agent, name, args):
    """Policy first, then dispatch. Returns the tool message content."""
    denial = check_policy(agent, name, args)
    if denial:
        return json.dumps({"error": denial, "executed": False})
    return dispatch(agent.registry, name, args)


# ----------------------------------------------------------------- Step 5
def execute_call(agent, call):
    """Turn one parsed tool call into the content of its tool message.

    Never raises: arguments that are not a JSON object, an unknown tool,
    wrong parameter names and a tool that throws all become
    {"error": "..."} so the model can read what went wrong.
    """
    return run_tool(agent, call["name"], json.loads(call["arguments"]))


class Result:
    def __init__(self, question):
        self.question = question
        self.answer = None
        self.steps = 0            # completions requested
        self.tool_calls = []      # [{"name", "arguments", "result"}]
        self.tokens = 0
        self.stopped_by = None    # None, or "max_steps" / "max_tool_calls" / "token_budget" / "provider_error"
        self.duration_ms = 0.0
        self.messages = []

    def to_dict(self):
        return {"question": self.question, "answer": self.answer, "steps": self.steps,
                "tool_calls": self.tool_calls, "tokens": self.tokens, "stopped_by": self.stopped_by,
                "duration_ms": round(self.duration_ms, 1), "messages": self.messages}


class Agent:
    def __init__(self, client, model=MODEL, specs=TOOL_SPECS, registry=None, max_steps=8,
                 max_tool_calls=20, token_budget=30000, max_result_chars=1500,
                 auto_approve=False, approval_limit=APPROVAL_LIMIT_USD, trace_path=None,
                 audit_path="audit.jsonl", retries=1, temperature=0):
        self.client = client
        self.model = model
        self.specs = specs
        self.registry = registry if registry is not None else build_registry(specs)
        self.max_steps = max_steps
        self.max_tool_calls = max_tool_calls
        self.token_budget = token_budget
        self.max_result_chars = max_result_chars
        self.auto_approve = auto_approve
        self.approval_limit = approval_limit
        self.trace_path = trace_path
        self.audit_path = audit_path
        self.retries = retries
        self.temperature = temperature

    # ------------------------------------------------------------- Step 3
    def run(self, question):
        """The loop. Send the conversation with the tools, read the tool calls,
        run them, append one tool message per call, repeat until the model
        answers in text. Three hooks are called from inside the loop and are
        filled in by later steps: over_budget (Step 4), call_model (Step 5),
        on_tool_result (Step 7)."""
        raise NotImplementedError("the loop arrives in Step 03")

    # ------------------------------------------------------------- Step 4
    def over_budget(self, result):
        """Return the name of the exhausted budget, or None. Checked before
        every completion, so a runaway model is cut off at the limit."""
        return None  # budgets arrive in Step 04

    # ------------------------------------------------------------- Step 5
    def call_model(self, messages):
        """One completion request with the tools attached. Returns the response,
        or None when the API failed on every attempt (an exception, or a
        response with no choices, which gateways return for provider errors)."""
        return self.client.chat.completions.create(model=self.model, messages=messages, tools=self.specs,
                                                   temperature=self.temperature, max_tokens=400)

    # ------------------------------------------------------------- Step 7
    def on_tool_result(self, result, call, content, elapsed_ms):
        """Runs on every tool result before it is appended to the conversation.
        Cuts oversized results down to max_result_chars with a marker the
        model can read, and writes one trace record per call."""
        return content  # truncation and tracing arrive in Step 07

    # ------------------------------------------------------------- given
    def trace(self, record):
        if self.trace_path:
            with open(self.trace_path, "a", encoding="utf-8") as f:
                f.write(json.dumps(record) + "\n")

    def audit(self, record):
        record = {"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), **record}
        with open(self.audit_path, "a", encoding="utf-8") as f:
            f.write(json.dumps(record) + "\n")

    def log(self, text):
        print(f"[agent] {text}", file=sys.stderr)


# ----------------------------------------------------------------- CLI
def one_shot(client, model, question):
    """Send ONE completion with the tools and print the raw assistant message."""
    messages = [{"role": "system", "content": SYSTEM}, {"role": "user", "content": question}]
    r = client.chat.completions.create(model=model, messages=messages, tools=TOOL_SPECS, temperature=0, max_tokens=300)
    m = r.choices[0].message
    print("finish_reason:", r.choices[0].finish_reason)
    print("content:", repr(m.content))
    print("tool_calls (raw):")
    for tc in (m.tool_calls or []):
        print(f"  id={tc.id} name={tc.function.name} arguments={tc.function.arguments!r}")
    calls = parse_tool_calls(m)
    print("parsed:", json.dumps(calls, indent=2))
    with open("oneshot.json", "w", encoding="utf-8") as f:
        json.dump({"question": question, "finish_reason": r.choices[0].finish_reason,
                   "content": m.content, "parsed": calls,
                   "raw_tool_calls": [{"id": tc.id, "name": tc.function.name, "arguments": tc.function.arguments} for tc in (m.tool_calls or [])]}, f, indent=2)
    return calls


def print_result(res):
    print(f"Q: {res.question}")
    for c in res.tool_calls:
        print(f"   -> {c['name']}({c['arguments']}) = {c['result'][:110]}")
    print(f"   steps={res.steps} tool_calls={len(res.tool_calls)} tokens={res.tokens} stopped_by={res.stopped_by} {res.duration_ms:.0f} ms")
    print(f"A: {res.answer}\n")


def main(argv):
    p = argparse.ArgumentParser()
    p.add_argument("question", nargs="?")
    p.add_argument("--one-shot", metavar="QUESTION")
    p.add_argument("--questions", metavar="FILE.jsonl")
    p.add_argument("--out", default="runs.jsonl")
    p.add_argument("--scripted", metavar="NAME", help="replay a script from fake_client.SCRIPTS")
    p.add_argument("--model", default=MODEL)
    p.add_argument("--max-steps", type=int, default=8)
    p.add_argument("--max-tool-calls", type=int, default=20)
    p.add_argument("--token-budget", type=int, default=30000)
    p.add_argument("--max-result-chars", type=int, default=1500)
    p.add_argument("--trace", metavar="FILE.jsonl")
    p.add_argument("--audit", default="audit.jsonl")
    p.add_argument("--auto-approve", action="store_true")
    a = p.parse_args(argv)

    if a.scripted:
        from fake_client import SCRIPTS, ScriptedClient
        script = SCRIPTS[a.scripted]
        client = ScriptedClient(script["turns"])
        questions = [script["question"]]
    else:
        client = make_client()
        if a.one_shot:
            one_shot(client, a.model, a.one_shot)
            return
        if a.questions:
            questions = [json.loads(l)["question"] for l in open(a.questions, encoding="utf-8") if l.strip()]
        elif a.question:
            questions = [a.question]
        else:
            p.error("give a question, --questions FILE, --one-shot QUESTION or --scripted NAME")

    agent = Agent(client, model=a.model, max_steps=a.max_steps, max_tool_calls=a.max_tool_calls,
                  token_budget=a.token_budget, max_result_chars=a.max_result_chars,
                  auto_approve=a.auto_approve, trace_path=a.trace, audit_path=a.audit)
    with open(a.out, "w", encoding="utf-8") as out:
        for q in questions:
            res = agent.run(q)
            print_result(res)
            out.write(json.dumps(res.to_dict()) + "\n")


if __name__ == "__main__":
    main(sys.argv[1:])

provided for you:fake_client.pyorders.jsonquestions.jsonltools.py

Step 2: The dispatcher: from a tool name to a running function

The model returns a name and some arguments. Something has to turn that into a function call, and that something is a dictionary. The lecture called it the dispatcher pattern: map every tool name to a Python function, look the name up, call the function with the arguments, and send back whatever it returned, as a JSON string, because a tool message's content is text.

Two design rules keep this boring, which is what you want from the part of the system that runs code the model asked for. The registry is derived from TOOL_SPECS, so a tool the model can see is always a tool you implemented, and a spec without an implementation fails loudly at startup rather than at the first call. And an unknown name at dispatch time is a result, in the same JSON shape as any other, because the model can read a result and correct itself, while a Python exception ends the conversation.

do this

1. Implement build_registry(specs, module) in agent.py. For each spec take spec["function"]["name"], fetch the attribute of that name from module (it defaults to tools), raise KeyError if it is missing or not callable, and map the name to the function. Return the dict.

2. Implement dispatch(registry, name, args). Look the name up. Unknown: return json.dumps({"error": f"unknown tool {name}"}). Known: return json.dumps(fn(**args)). The tools already return dicts and report their own failures as {"error": ...}, so a bad expression or an unsupported unit comes back as text the model can read.

3. Run. The registry lists six names, the conversions and the quote return JSON, the calculator rejects sum(1, 2) with a readable error, and teleport comes back as an unknown tool rather than a traceback.

Why this matters in practice

The dispatcher is the security boundary of an agent. The model can only reach what is in the registry, with the parameter shapes the schema allows. Keeping the registry derived from the specs means there is exactly one list to review when someone asks what the agent can do.

agent.py, the file you edit255 lines
"""agent.py: a tool-using agent on the chat completions API and nothing else.

    python3 agent.py "How much would it cost to ship order NW-1007 to its destination?"
    python3 agent.py --one-shot "..."          # one completion, print the raw assistant message
    python3 agent.py --questions q.jsonl --out runs.jsonl
    python3 agent.py --scripted loop            # replay a recorded transcript offline (see fake_client.py)

Options: --model, --max-steps, --max-tool-calls, --token-budget, --max-result-chars,
         --trace trace.jsonl, --auto-approve, --audit audit.jsonl
"""
import argparse
import json
import os
import sys
import time

import tools
from tools import TOOL_SPECS, SIDE_EFFECT_TOOLS

MODEL = os.environ.get("AGENT_MODEL", "meta/llama-3.3-70b-instruct")
BASE_URL = os.environ.get("AGENT_BASE_URL", "http://nim-proxy.labs.svc:8080/v1")
APPROVAL_LIMIT_USD = float(os.environ.get("AGENT_APPROVAL_LIMIT", "100"))

SYSTEM = (
    "You are Northwind Cloud's operations assistant. You have tools for order lookup, "
    "arithmetic, unit conversion, shipping quotes and refunds. Use a tool for every lookup, "
    "calculation, conversion and quote, including ones that look trivial: call calculator for "
    "any arithmetic and convert_units for any unit conversion; never compute or convert numbers "
    "yourself. "
    "When the tools have given you what you need, answer in one or two sentences with the "
    "final numbers. If a tool reports an error, say so plainly instead of inventing a value."
)


def make_client():
    from openai import OpenAI
    return OpenAI(base_url=BASE_URL, api_key="not-needed")


# ----------------------------------------------------------------- Step 1
def parse_tool_calls(message):
    """Extract the tool calls from an assistant message.

    Returns a list of {"id", "name", "arguments"} where arguments is the RAW
    JSON string the model produced (parsing it is the executor's job).
    An assistant message with no tool calls returns [].
    """
    calls = []
    for tc in (getattr(message, "tool_calls", None) or []):
        calls.append({"id": tc.id, "name": tc.function.name, "arguments": tc.function.arguments})
    return calls


# ----------------------------------------------------------------- Step 2
def build_registry(specs, module=tools):
    """Map every tool name declared in specs to the Python function of that
    name in `module`. A declared tool with no implementation is a bug: raise."""
    # TODO (Step 02): for each spec, name = spec["function"]["name"];
    #   fn = getattr(module, name, None); raise KeyError if it is not callable;
    #   registry[name] = fn. Return the registry.
    raise NotImplementedError("Step 02: implement build_registry()")


def dispatch(registry, name, args):
    """Run one tool and return the JSON string that goes back to the model.
    An unknown tool name returns a JSON error rather than raising."""
    # TODO (Step 02): fn = registry.get(name); unknown -> json.dumps({"error": f"unknown tool {name}"});
    #   otherwise return json.dumps(fn(**args)).
    raise NotImplementedError("Step 02: implement dispatch()")


# ----------------------------------------------------------------- Step 6
def check_policy(agent, name, args):
    """Policy hook, called before any tool runs. Return None to allow the
    call, or a string explaining the denial; the string goes back to the
    model as the tool result. Every decision on a side-effecting tool is
    written to the audit log."""
    return None  # the policy hook arrives in Step 06


def run_tool(agent, name, args):
    """Policy first, then dispatch. Returns the tool message content."""
    denial = check_policy(agent, name, args)
    if denial:
        return json.dumps({"error": denial, "executed": False})
    return dispatch(agent.registry, name, args)


# ----------------------------------------------------------------- Step 5
def execute_call(agent, call):
    """Turn one parsed tool call into the content of its tool message.

    Never raises: arguments that are not a JSON object, an unknown tool,
    wrong parameter names and a tool that throws all become
    {"error": "..."} so the model can read what went wrong.
    """
    return run_tool(agent, call["name"], json.loads(call["arguments"]))


class Result:
    def __init__(self, question):
        self.question = question
        self.answer = None
        self.steps = 0            # completions requested
        self.tool_calls = []      # [{"name", "arguments", "result"}]
        self.tokens = 0
        self.stopped_by = None    # None, or "max_steps" / "max_tool_calls" / "token_budget" / "provider_error"
        self.duration_ms = 0.0
        self.messages = []

    def to_dict(self):
        return {"question": self.question, "answer": self.answer, "steps": self.steps,
                "tool_calls": self.tool_calls, "tokens": self.tokens, "stopped_by": self.stopped_by,
                "duration_ms": round(self.duration_ms, 1), "messages": self.messages}


class Agent:
    def __init__(self, client, model=MODEL, specs=TOOL_SPECS, registry=None, max_steps=8,
                 max_tool_calls=20, token_budget=30000, max_result_chars=1500,
                 auto_approve=False, approval_limit=APPROVAL_LIMIT_USD, trace_path=None,
                 audit_path="audit.jsonl", retries=1, temperature=0):
        self.client = client
        self.model = model
        self.specs = specs
        self.registry = registry if registry is not None else build_registry(specs)
        self.max_steps = max_steps
        self.max_tool_calls = max_tool_calls
        self.token_budget = token_budget
        self.max_result_chars = max_result_chars
        self.auto_approve = auto_approve
        self.approval_limit = approval_limit
        self.trace_path = trace_path
        self.audit_path = audit_path
        self.retries = retries
        self.temperature = temperature

    # ------------------------------------------------------------- Step 3
    def run(self, question):
        """The loop. Send the conversation with the tools, read the tool calls,
        run them, append one tool message per call, repeat until the model
        answers in text. Three hooks are called from inside the loop and are
        filled in by later steps: over_budget (Step 4), call_model (Step 5),
        on_tool_result (Step 7)."""
        raise NotImplementedError("the loop arrives in Step 03")

    # ------------------------------------------------------------- Step 4
    def over_budget(self, result):
        """Return the name of the exhausted budget, or None. Checked before
        every completion, so a runaway model is cut off at the limit."""
        return None  # budgets arrive in Step 04

    # ------------------------------------------------------------- Step 5
    def call_model(self, messages):
        """One completion request with the tools attached. Returns the response,
        or None when the API failed on every attempt (an exception, or a
        response with no choices, which gateways return for provider errors)."""
        return self.client.chat.completions.create(model=self.model, messages=messages, tools=self.specs,
                                                   temperature=self.temperature, max_tokens=400)

    # ------------------------------------------------------------- Step 7
    def on_tool_result(self, result, call, content, elapsed_ms):
        """Runs on every tool result before it is appended to the conversation.
        Cuts oversized results down to max_result_chars with a marker the
        model can read, and writes one trace record per call."""
        return content  # truncation and tracing arrive in Step 07

    # ------------------------------------------------------------- given
    def trace(self, record):
        if self.trace_path:
            with open(self.trace_path, "a", encoding="utf-8") as f:
                f.write(json.dumps(record) + "\n")

    def audit(self, record):
        record = {"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), **record}
        with open(self.audit_path, "a", encoding="utf-8") as f:
            f.write(json.dumps(record) + "\n")

    def log(self, text):
        print(f"[agent] {text}", file=sys.stderr)


# ----------------------------------------------------------------- CLI
def one_shot(client, model, question):
    """Send ONE completion with the tools and print the raw assistant message."""
    messages = [{"role": "system", "content": SYSTEM}, {"role": "user", "content": question}]
    r = client.chat.completions.create(model=model, messages=messages, tools=TOOL_SPECS, temperature=0, max_tokens=300)
    m = r.choices[0].message
    print("finish_reason:", r.choices[0].finish_reason)
    print("content:", repr(m.content))
    print("tool_calls (raw):")
    for tc in (m.tool_calls or []):
        print(f"  id={tc.id} name={tc.function.name} arguments={tc.function.arguments!r}")
    calls = parse_tool_calls(m)
    print("parsed:", json.dumps(calls, indent=2))
    with open("oneshot.json", "w", encoding="utf-8") as f:
        json.dump({"question": question, "finish_reason": r.choices[0].finish_reason,
                   "content": m.content, "parsed": calls,
                   "raw_tool_calls": [{"id": tc.id, "name": tc.function.name, "arguments": tc.function.arguments} for tc in (m.tool_calls or [])]}, f, indent=2)
    return calls


def print_result(res):
    print(f"Q: {res.question}")
    for c in res.tool_calls:
        print(f"   -> {c['name']}({c['arguments']}) = {c['result'][:110]}")
    print(f"   steps={res.steps} tool_calls={len(res.tool_calls)} tokens={res.tokens} stopped_by={res.stopped_by} {res.duration_ms:.0f} ms")
    print(f"A: {res.answer}\n")


def main(argv):
    p = argparse.ArgumentParser()
    p.add_argument("question", nargs="?")
    p.add_argument("--one-shot", metavar="QUESTION")
    p.add_argument("--questions", metavar="FILE.jsonl")
    p.add_argument("--out", default="runs.jsonl")
    p.add_argument("--scripted", metavar="NAME", help="replay a script from fake_client.SCRIPTS")
    p.add_argument("--model", default=MODEL)
    p.add_argument("--max-steps", type=int, default=8)
    p.add_argument("--max-tool-calls", type=int, default=20)
    p.add_argument("--token-budget", type=int, default=30000)
    p.add_argument("--max-result-chars", type=int, default=1500)
    p.add_argument("--trace", metavar="FILE.jsonl")
    p.add_argument("--audit", default="audit.jsonl")
    p.add_argument("--auto-approve", action="store_true")
    a = p.parse_args(argv)

    if a.scripted:
        from fake_client import SCRIPTS, ScriptedClient
        script = SCRIPTS[a.scripted]
        client = ScriptedClient(script["turns"])
        questions = [script["question"]]
    else:
        client = make_client()
        if a.one_shot:
            one_shot(client, a.model, a.one_shot)
            return
        if a.questions:
            questions = [json.loads(l)["question"] for l in open(a.questions, encoding="utf-8") if l.strip()]
        elif a.question:
            questions = [a.question]
        else:
            p.error("give a question, --questions FILE, --one-shot QUESTION or --scripted NAME")

    agent = Agent(client, model=a.model, max_steps=a.max_steps, max_tool_calls=a.max_tool_calls,
                  token_budget=a.token_budget, max_result_chars=a.max_result_chars,
                  auto_approve=a.auto_approve, trace_path=a.trace, audit_path=a.audit)
    with open(a.out, "w", encoding="utf-8") as out:
        for q in questions:
            res = agent.run(q)
            print_result(res)
            out.write(json.dumps(res.to_dict()) + "\n")


if __name__ == "__main__":
    main(sys.argv[1:])

provided for you:fake_client.pyorders.jsonquestions.jsonltools.py

Step 3: Close the loop

Send the conversation with the tools. If the assistant message carries tool calls, run them, append the results, send the conversation again. If it carries text, that text is the answer. That is the entire agent loop, and every framework hides this same dozen lines behind a class.

The part people get wrong is the conversation itself. Before the tool results go in, the assistant message that asked for them has to be appended too, with its tool_calls intact, because the API validates that every tool message answers a call that appears in the transcript. Then one tool message per call, each carrying the call's tool_call_id. Miss the assistant turn, or reuse an id, and the next request is rejected.

You will build the loop against the scripted stand-in model first. fake_client.ScriptedClient replays a recorded transcript and enforces the same protocol rules the real API does, so a threading mistake fails immediately and offline. Then the same code runs against the live model.

do this

1. Implement Agent.run(question) in agent.py. Start with result = Result(question) and messages holding the system prompt and the user question. Loop:

  • stop = self.over_budget(result); if it returns a name, record it in result.stopped_by, set a short answer saying the run stopped, and break. (A hook. It returns None until Step 4.)
  • response = self.call_model(messages); if it is None, record "provider_error" the same way and break. (Also a hook; today it makes one completion call.)
  • Count the step, add response.usage.total_tokens to result.tokens (guard usage being None), take message = response.choices[0].message and calls = parse_tool_calls(message).
  • No calls: result.answer = message.content or "" and break.
  • Otherwise append message.model_dump(exclude_none=True), then for each call: content = execute_call(self, call), pass it through self.on_tool_result(result, call, content, elapsed_ms) (a hook that returns the content unchanged until Step 7), append {"role": "tool", "tool_call_id": call["id"], "content": content}, and record the call in result.tool_calls as {"name", "arguments", "result": content[:300]}.

After the loop, store messages on the result, set duration_ms, and return it.

2. Run. The scripted replay answers the weight question in four steps with three tool calls. Then the live model answers three questions: one needs a lookup and a conversion, one a lookup and a quote, one two parallel conversions in a single assistant message. Read the transcript in runs.jsonl: every tool message's tool_call_id matches an id in the assistant message before it.

Why this matters in practice

This is the loop LangChain, LangGraph and the vendor agent SDKs run. Having written it, you can read their source, and you can decide case by case whether their state machines earn their weight over these lines.

provided for you:fake_client.pyorders.jsonquestions.jsonltools.py

Step 4: Stop conditions: budgets for steps, tool calls and tokens

The loop script in fake_client.py is a real transcript. A small model was asked for the total of an order that does not exist. It called the calculator with a Python expression the calculator rejects, called the lookup that returned "unknown order id", read both errors, and did exactly the same thing again. Forty times. With no budget, the loop you wrote in Step 3 would have made forty completions and eighty tool calls for one question, and at production volume a runaway like this is the difference between a bill and an incident.

Three budgets cover the ways a run can go wrong. A step budget caps the number of completions. A tool-call budget catches a model that packs many calls into each step. A token budget catches a run whose steps are few but whose context is growing past what it should cost. All three are checked before every completion, so the cut-off happens before the money is spent, and the reason is recorded on the result so the caller can tell a budget stop from an answer.

do this

1. Implement Agent.over_budget(result). Return "max_steps" when result.steps >= self.max_steps, "max_tool_calls" when len(result.tool_calls) >= self.max_tool_calls, "token_budget" when result.tokens >= self.token_budget, otherwise None. The loop from Step 3 already calls it at the top of every iteration and records the name in result.stopped_by.

2. Run. The runaway is cut at six steps, at four tool calls, and at the token budget, each with stopped_by naming the budget and an answer that says the run did not finish. Try --max-steps 40 in the terminal to see what the unbudgeted run looks like, then think about what that costs with a real model at a few seconds per call.

3. Check a normal run is untouched. python3 agent.py --scripted happy finishes in four steps with stopped_by=None. Budgets are safety rails, and the defaults should be well above what a healthy run needs.

Why this matters in practice

Runaway loops are the most common way an agent costs more than it earns. The fix is cheap and mechanical, and the recorded reason turns a mystery timeout into a line in a dashboard: this many runs stopped on the step budget this week, and here are their questions.

provided for you:fake_client.pyorders.jsonquestions.jsonltools.py

Step 5: Errors as information: nothing raises out of the loop

Two more recordings. In malformed, the model's arguments string is two JSON objects glued together with fields from three different tools. It is not valid JSON, and json.loads raises. In provider_error, the gateway returns HTTP 200 with an error body and choices: null, which is what some gateways do when the upstream provider rejects a request. Run the Step 3 loop against either and it dies with a traceback, on a request that a retry would have completed.

The rule for an agent loop is that a failure inside a step becomes a result the model can read, and a failure of the model call itself is retried a bounded number of times and then reported as a stop reason. Nothing propagates out of run(). The model is often able to fix its own mistake when it is told what the mistake was: given "arguments were not a valid JSON object", the recorded model tried again with a clean call.

do this

1. Implement execute_call(agent, call). Parse call["arguments"] with json.loads; if that raises, or the result is not a dict, return json.dumps({"error": "arguments were not a valid JSON object: ..."}). An unknown tool name returns an unknown-tool error. Then call run_tool(agent, call["name"], args) inside try: a TypeError (wrong parameter names, a missing required argument) becomes {"error": "bad arguments for <name>: ..."}, and any other exception becomes {"error": "<name> failed: <type>: <message>"}. Every path returns a JSON string.

2. Implement Agent.call_model(messages). Try up to self.retries + 1 times: make the completion request with tools=self.specs, temperature=self.temperature and max_tokens=400. An exception, or a response whose choices is empty or None, means try again. A response with choices is returned. After the last attempt, log the last error with self.log(...) and return None; the loop turns that into stopped_by="provider_error".

3. Run. The malformed call comes back to the model as an error, the model retries correctly, and the run finishes. The one-off gateway error is absorbed by the retry. The gateway that fails every time stops the run with a recorded reason after two attempts rather than looping or crashing.

Why this matters in practice

An agent that raises on the first malformed tool call fails on the questions where the model was already struggling, which are the ones users remember. Feeding errors back costs one extra step and recovers most of them. Bounded retries on the transport keep a flaky minute from becoming a failed run, and the recorded stop reason keeps a dead upstream from becoming a silent one.

provided for you:fake_client.pyorders.jsonquestions.jsonltools.py

Step 6: A guardrail in front of the tool that moves money

Five of the six tools read or compute. refund writes a line to the refunds ledger, and in a real system that line is money leaving the company. The model decides when to call it, and the model decides the amount, from a question typed by whoever is talking to the agent. Ask it to refund "the full price of the power unit" and it will look the order up and request 640 dollars, correctly, on the strength of one sentence.

The guardrail belongs in your code, in front of the dispatcher, where the model cannot talk its way past it. The pattern has three parts. A policy function sees every call before it runs and can deny it. A denial goes back to the model as a tool result, so the model reports it honestly instead of claiming success. And every decision on a side-effecting tool is written to an audit log, allowed or denied, with the arguments, so the question "what did the agent do with money this week" has a file for an answer.

do this

1. Implement check_policy(agent, name, args). For refund, when agent.auto_approve is false and amount_usd is above agent.approval_limit (100 by default), build a reason string: the amount, the limit, that it needs human approval and was not executed. For any tool in SIDE_EFFECT_TOOLS, call agent.audit({...}) with the tool, the args, the decision ("denied" or "allowed"), the reason and the auto_approve flag. Return the reason, or None to allow. run_tool() already turns a reason into {"error": reason, "executed": false} and skips the dispatch.

2. Run. In the recorded script the model requests both refunds in one turn: the 640 is denied and the 25 goes through, the ledger holds one line, the audit log holds two. Then the live model handles the same two requests on its own, looking the order up to find the price, and reports the denial in its answer rather than pretending the refund happened.

3. Lift the limit deliberately. python3 agent.py --scripted refunds --auto-approve executes both, and the audit log records that auto_approve was on. Approval is a flag a human sets, never something the model can request.

Why this matters in practice

Excessive agency is the name the OWASP LLM list gives to this failure, and the fix is always the same: side effects gated by code, denials returned as results, and an audit trail. The same hook grows into an approval queue when a human needs to click yes.

provided for you:fake_client.pyorders.jsonquestions-refunds.jsonlquestions.jsonltools.py

Step 7: Control the context and trace every step

Every tool result is appended to the conversation and sent back on every following request, for the rest of the run. A tool that returns a whole table returns it into the context window, and the cost of that result is paid again at each step. list_orders on eight orders is a few hundred characters. On eight thousand orders it is the entire context, and the model's answer to "how many are packed" degrades or fails outright.

The agent has to cap what a single result can put into the context, and it has to tell the model that the cap was applied, so the model can ask a narrower question instead of reasoning over a silently cut table. And because a run is now several calls with budgets, retries and policy decisions, each tool call should leave a trace record: which step, which tool, what arguments, how big the result was, how long it took, and how many tokens the run had consumed by then. That file is what you open when a run looks wrong.

do this

1. Implement Agent.on_tool_result(result, call, content, elapsed_ms). When len(content) exceeds self.max_result_chars, keep the first max_result_chars characters and append ... [truncated N characters; ask a narrower question] with the number dropped. Then call self.trace({...}) with step, tool, arguments, result_chars (after truncation), elapsed_ms and tokens_so_far. Return the content. trace() is given: it appends a JSON line when a trace path is set and does nothing otherwise.

2. Run. With a 600-character cap the recorded list_orders result is cut, the marker tells the model why, and the model still answers from the part it received. The trace has one line per tool call.

3. Look at a live trace. In the terminal, run python3 agent.py --trace trace.jsonl "How much would it cost to ship order NW-1041 to its destination?" and read the file: three calls, their timings, and the token count climbing as the context grows. That growth is the number the cap protects.

Why this matters in practice

Context control is the agent version of pagination. Without it, one generous tool turns every later step into an expensive, less accurate call. The trace is the same idea as the spans in the observability lab, scoped to a single run, and it is the first thing a production agent needs when someone asks why an answer was wrong.

provided for you:fake_client.pyorders.jsonquestions-refunds.jsonlquestions.jsonltools.py

Step 8: Evaluate the agent on a fixed question set

Everything so far makes the agent robust. Nothing so far says whether it is right. A prompt edit, a new tool description, a cheaper model: each can change the answers, and reading a few transcripts by hand is how those changes get shipped on a feeling. An evaluation set is the alternative: a fixed list of questions with what a correct run looks like, scored the same way every time, with a pass rate a CI job can gate on.

eval.jsonl holds six cases. Each has an expectation with three parts. A list of contains_any groups: the answer must contain at least one alternative from every group, so 27.778 and 27.78 both count for the weight question. A list of tools that must have been called, because an answer that happens to be right without a lookup is a guess that will be wrong next time. And a max_steps ceiling, since a correct answer in nine steps is a cost regression waiting to be noticed. A run that stopped on a budget fails regardless.

do this

1. Implement score_case(result, expect) in eval.py. Build a list of reasons: one for each contains_any group with no alternative in the lower-cased answer, one for each expected tool absent from the run's tool calls, one when result.stopped_by is set, and one when result.steps exceeds max_steps. Return (len(reasons) == 0, reasons).

2. Run. The offline smoke test scores every case against one recorded transcript, so five of six fail, and the reasons say exactly why: the answer lacks the number, the quote tool was never called. That is the scorer working. Then the live run answers all six with the model and prints one line per case with its steps and tokens, the pass rate, and an exit code: 1 below the threshold, 0 above it. If a case fails live, read its reason: a model that converted units in its head instead of calling the tool is exactly the drift an evaluation exists to catch, and the run you make after tightening the system prompt is the fix being measured.

3. Change something and re-run. In the terminal, try a stricter --threshold 1.0, or edit the system prompt in agent.py to drop the sentence about never computing numbers yourself and run the evaluation again. The difference between the two reports is the review you would otherwise do by reading transcripts.

Why this matters in practice

An agent without an evaluation set changes behaviour every time anything upstream changes, and nobody knows until a user says so. Six cases is where every eval set starts; the shape (expected content, expected tools, a step ceiling, a threshold, an exit code) is the shape it keeps at six hundred. The AI Engineer course's evaluation module takes this further, into trajectories and judge models.

eval.py, the file you edit77 lines
"""eval.py: score the agent on a fixed question set.

    python3 eval.py eval.jsonl [--scripted] [--threshold 0.8] [--out eval-report.json]

Each case: {"question": ..., "expect": {"contains_any": [[alt, alt], ...],
"tools": [...], "max_steps": n}}. A case passes when every contains_any
group has at least one alternative in the answer (case-insensitive), every
listed tool was called at least once, the run finished on its own (no budget
stop) and used at most max_steps completions.
"""
import argparse
import json
import sys

from agent import Agent, make_client


# ----------------------------------------------------------------- Step 8
def score_case(result, expect):
    """Return (passed: bool, reasons: list[str]) for one run against its expectation."""
    # TODO (Step 08): reasons = []
    #   answer = (result.answer or "").lower()
    #   each group in expect["contains_any"]: no alternative found -> "answer lacks any of <group>"
    #   each tool in expect["tools"] not in {c["name"] for c in result.tool_calls} -> "tool <t> was not called"
    #   result.stopped_by -> "stopped by <reason>";  result.steps > expect["max_steps"] -> "took N steps, allowed M"
    #   return (not reasons), reasons
    raise NotImplementedError("Step 08: implement score_case()")


def evaluate(make_agent, cases):
    rows = []
    for case in cases:
        agent = make_agent(case)
        res = agent.run(case["question"])
        passed, reasons = score_case(res, case.get("expect", {}))
        rows.append({"question": case["question"], "passed": passed, "reasons": reasons,
                     "steps": res.steps, "tool_calls": [c["name"] for c in res.tool_calls],
                     "tokens": res.tokens, "stopped_by": res.stopped_by, "answer": res.answer})
    passed = sum(1 for r in rows if r["passed"])
    return {"cases": rows, "passed": passed, "total": len(rows),
            "pass_rate": round(passed / len(rows), 4) if rows else 0.0,
            "mean_steps": round(sum(r["steps"] for r in rows) / len(rows), 2) if rows else 0.0,
            "total_tokens": sum(r["tokens"] for r in rows)}


def main(argv):
    p = argparse.ArgumentParser()
    p.add_argument("cases", nargs="?", default="eval.jsonl")
    p.add_argument("--threshold", type=float, default=0.8)
    p.add_argument("--out", default="eval-report.json")
    p.add_argument("--scripted", action="store_true", help="use the 'happy' script for every case (offline smoke test)")
    a = p.parse_args(argv)
    cases = [json.loads(l) for l in open(a.cases, encoding="utf-8") if l.strip()]
    if a.scripted:
        from fake_client import SCRIPTS, ScriptedClient
        make_agent = lambda case: Agent(ScriptedClient(SCRIPTS["happy"]["turns"]))
    else:
        client = make_client()
        make_agent = lambda case: Agent(client)
    report = evaluate(make_agent, cases)
    print(f"{'result':7} {'steps':>5} {'tokens':>7}  question / reasons")
    for r in report["cases"]:
        print(f"{'PASS' if r['passed'] else 'FAIL':7} {r['steps']:>5} {r['tokens']:>7}  {r['question'][:70]}")
        for reason in r["reasons"]:
            print(f"{'':21}  - {reason}")
    print(f"\npassed {report['passed']}/{report['total']}  pass_rate={report['pass_rate']:.0%}  "
          f"mean_steps={report['mean_steps']}  tokens={report['total_tokens']}  threshold={a.threshold:.0%}")
    with open(a.out, "w", encoding="utf-8") as f:
        json.dump(report, f, indent=2)
    if report["pass_rate"] < a.threshold:
        print("EVAL FAILED: pass rate below threshold")
        sys.exit(1)
    print("EVAL PASSED")


if __name__ == "__main__":
    main(sys.argv[1:])

provided for you:agent.pyeval.jsonlfake_client.pyorders.jsonquestions-refunds.jsonlquestions.jsonltools.py

what you get

  • An editor and terminal in your browser
  • A check at the end of every step
  • Hints and the full solution
  • Nothing to install or configure

before you start

  • Python: functions, dicts, json, try/except, a while loop
  • The chat completions message format: system, user, assistant, tool roles
  • Read the module's tool-calling lecture first: the model emits intent, your code executes

Exam domains covered

Tool Calling and AgentsUsing LLMs via APIAgent Evaluation

Part of the AI agents and MCP track

Lab 1 of 26. Tool loops, ReAct, MCP servers and clients, multi-agent supervisors and agent evaluation.

Questions about this lab

Do I need LangChain, LangGraph or another framework for this lab?

No. The agent is written against the chat completions API with the OpenAI Python client and plain functions. Frameworks wrap the same loop; after this lab you can read theirs.

Which model does the lab use?

A hosted Llama 3.3 70B instruct model behind the lab's proxy for the live steps, and a scripted stand-in that replays recorded transcripts for the failure-handling steps, so those tests are deterministic and cost nothing.

What does the agent do with a tool that fails?

It returns the failure to the model as the tool result, in JSON, and lets the model decide whether to retry with different arguments or report the problem. Nothing raises out of the loop, and budgets stop it from retrying forever.

How is the refund tool kept safe?

A policy hook runs before any side-effecting tool. Refunds above a limit are denied and the denial goes back to the model as a tool result, every decision is written to an audit log, and an explicit approval flag is the only way to lift the limit.

Why build the agent loop yourself

Every agent framework hides the same forty lines: send the conversation with a tools array, read the tool calls off the assistant message, run the matching functions, append one tool message per call with its id, send the conversation back, and repeat until the model answers in text. Building that loop once by hand is the fastest way to understand what the frameworks do, to debug them when they misbehave, and to decide whether you need one at all. The lab goes past the happy path. A scripted model replays real failure transcripts captured from a small model: an endless loop on an unknown order, arguments that are not valid JSON, and an API response with no choices. You add stop conditions and budgets, turn every failure into information the model can act on, put an approval guardrail in front of the refund tool with an audit log, truncate oversized tool results, trace each step, and score the finished agent against a fixed question set with an exit code a CI job can use.