Courses / GenAI and Agents Fast Track

Session 5 of 8

Agents from scratch: the loop, tools and memory

Overview

Chapter 5 — Agents from Scratch: The Loop, Tools and Memory

Course: GenAI and Agents Fast Track Prerequisites: basic Python (functions, dictionaries, lists, loops); the idea of a model call as “messages in, one reply out”; the idea that a model has a limited context window; the idea that a fixed chain of prompts is a workflow and an agent is a system where the model chooses the next step.

What the reader can do after this unit:

  • Write the agent loop in plain Python, without a framework, so that a model requests a tool, the program runs it, and the result goes back until the model gives a final answer
  • Define a tool with a name, a description, a parameter schema and a function, and return its errors as text the model can act on
  • Keep a conversation in a memory that trims old messages, and end each run with a step limit, an error exit or a request for a person’s approval

Core question this unit answers: How does an agent decide what to do next, act on it, remember what happened, and know when to stop?

Connections:

  • Builds on: calling a model with messages and getting a reply, and the test “who chooses the next step”
  • Leads into: splitting work between several agents, and offering tools through a standard connector (Chapter 6)

Notes

Student Notes — Chapter 5: Agents from Scratch: The Loop, Tools and Memory

GenAI and Agents Fast Track · Chapter 5

Before you start

You need Python 3 and a text editor. The project and the loop code use Python’s standard library, and the model is replaced by a small scripted stand-in, so they need nothing installed, no account and no key. The one real-model example near the end also needs pip install openai and an endpoint. Connecting a real model is shown once, near the end of the loop section, and the project treats it as an extra.

Two ideas from earlier chapters, in a few words so this chapter stands on its own. A model call takes a list of messages (each with a role such as system, user or assistant) and returns one reply message. And a program can describe a function to the model, so that instead of an answer the reply is a request: “please call this function with these arguments”. This chapter builds on that second idea and turns it into a loop.

Summary

An agent is a program in which the model, not your code, chooses the next step. In this chapter you build one by hand in plain Python, with no framework, so that nothing about it is hidden. You write the loop that sends the conversation to the model, runs the tool the model asks for, appends the result and asks again until the model answers. You define tools as a name, a description, a parameter schema and a function, and you return their errors as text the model can read. You keep the conversation in a memory that trims old messages so the context window does not fill up, and you give each run a way to end: a final answer, a step limit, an error, or a pause for a person’s approval.

Key concepts

Term Definition
Agent loop The repeated cycle in which the program sends the messages to the model, runs any tool the model asks for, adds the result to the messages and asks again, until the model replies without asking for a tool.
Step One trip round the loop: one call to the model, plus the tool runs it asked for.
Tool A function the model may ask the program to run, described to the model by a name, a description and a schema for its inputs.
Parameter schema A JSON description of the inputs a tool accepts: their names, their types and which are required. A list of allowed values (an enum) limits an input to choices you name.
Tool call The model’s request to run a tool: the tool’s name and its arguments as JSON text. The model asks; the program runs it.
Tool result The text the program sends back to the model after running a tool, including an error message when the run failed.
Memory Whatever the program chooses to send to the model on the next call. For an agent it is the list of messages so far, plus any notes the program keeps for it.
Trimming Dropping or shortening older messages so the memory fits in the context window.
Step limit A maximum number of model calls in one run, after which the loop stops even if the model has not finished.
Approval A person’s confirmation, required before the program runs a tool that changes something outside the program.
Scripted model A stand-in function that returns replies prepared in advance, so the loop can be tested without a real model.

Explanations

1. Workflow or agent: who chooses the next step?

Take one question: “Which branch had more visits in March, and by how many?” The notes are a handful of short documents, one per branch. Here are two programs that answer it.

Program A does the same thing each time: search the notes for “visits”, read the first two results, subtract one number from the other, and ask the model to word the answer. It works for this question. Ask it “Which branch is open longer?” and it still searches for “visits”, reads the wrong notes and subtracts numbers that mean nothing.

Program B gives the model three tools (search the notes, read one note, do arithmetic) and the question, and lets the model decide: search first, then read, then calculate, then answer. For the opening-hours question it searches for something else and skips the arithmetic.

In Program A your code chose the steps. In Program B the model chose them. That difference is the whole definition. A workflow is a fixed chain of steps chosen by the programmer, with or without model calls inside it. An agent is a system where the model chooses the next step and which tool to use. One widely read engineering article puts it as workflows being orchestrated through predefined code paths and agents as systems where the model directs its own process and tool use. Two pictures: a recipe card lists the steps in order (a workflow), while a cook told “make dinner from what is in the fridge” decides as they go (an agent). A bus follows a fixed route; a taxi goes where the driver judges best.

Neither is better in general. The same article says agents suit open-ended problems where it is hard to predict the number of steps and you cannot hard-code a fixed path, and it adds that their autonomy brings higher costs and the potential for compounding errors. It advises starting with the simplest design that works. If you can write the steps down in advance, write a workflow. This chapter is about the case where you cannot.

2. The agent loop in plain Python

Here is what one run of Program B looks like, written as a transcript. The tools are search_notes, read_note and calculate.

  1. You to the model: the question, plus a description of the three tools.
  2. Model replies: not an answer, but a request: call search_notes with keyword “visits”.
  3. Your program runs the search and gets two note titles. It sends them back as a tool result.
  4. Model replies: call read_note with id n1. The program runs it: “March visits: 1240”.
  5. Model replies: call read_note with id n2. The program runs it: “March visits: 980”.
  6. Model replies: call calculate with 1240, 980 and the operation subtract. The program runs it: 260.
  7. Model replies: no tool request this time, just text: “Eastside had 260 more visits in March.” The run ends.

The model did not run anything. It wrote requests, and the program ran them and reported back. Picture a manager who writes memos and an assistant who runs the errands and brings back what they find: the manager reads each result and writes the next memo, until the manager writes the final report instead of another memo. A second picture: a cook tasting a sauce, deciding what to add, adding it and tasting again until it is right.

This cycle is the agent loop. The same engineering article describes agents as typically just models using tools based on feedback from their environment, in a loop, and says it is crucial for them to gain “ground truth” from the environment at each step, such as tool call results. One provider’s tool-calling documentation describes the same five steps: send a request with the tools the model could call, receive a tool call, run code on your side with the input from the tool call, send a second request with the tool output, and receive a final response or more tool calls.

In plain Python the loop is short. This is chapters/05-agent-loop/app/agent/loop.py. The model is one function, model_fn, that takes the messages and the tool descriptions and returns one reply message as a dictionary. Lines marked with a “stop” comment belong to the last section of the chapter; read past them on a first pass.

def tool_message(call, result):
    return {"role": "tool", "tool_call_id": call["id"], "content": result}


def run_agent(task, model_fn, toolbox, memory, max_steps=6, approve=None):
    memory.add({"role": "user", "content": task})
    for step in range(1, max_steps + 1):
        try:
            reply = model_fn(memory.messages(), toolbox.schemas())
        except Exception as exc:                                  # stop: error
            return {"answer": None, "steps": step, "stopped": "error", "detail": str(exc)}
        memory.add(reply)
        calls = reply.get("tool_calls") or []
        if not calls:                                             # stop: final answer
            return {"answer": reply["content"], "steps": step, "stopped": "final"}
        for call in calls:
            name = call["function"]["name"]
            if toolbox.needs_approval(name) and not (approve and approve(name)):
                memory.add(tool_message(call, "error: a person has not approved this action"))
                return {"answer": None, "steps": step, "stopped": "needs_person"}   # stop: person
            result = toolbox.run(name, call["function"]["arguments"])
            memory.add(tool_message(call, result))
    return {"answer": None, "steps": max_steps, "stopped": "step_limit"}   # stop: step limit

Read it as the transcript above. Each pass through the for loop is a step: ask the model, add its reply to the memory, and if the reply has no tool calls, that is the answer. Otherwise run each requested tool and add its result as a message with the role tool and the id of the call it answers. The reply message and the tool message follow the shape that one provider’s documentation shows for its chat interface: the reply carries a list of tool calls, each with an id and a function name, and the arguments arrive as JSON text.

To connect a real model, you write one more small function that wraps an OpenAI-compatible client. The three settings come from the environment, so no key sits in the code:

import os
from openai import OpenAI


def openai_model_fn(client, model):
    def call(messages, tools):
        response = client.chat.completions.create(model=model, messages=messages, tools=tools)
        return response.choices[0].message.model_dump(exclude_none=True)
    return call


client = OpenAI(base_url=os.environ["LLM_BASE_URL"], api_key=os.environ["LLM_API_KEY"])
model_fn = openai_model_fn(client, os.environ["LLM_MODEL"])

For tests, the stand-in is the same shape: a function that returns the next prepared reply. That is why the model is a parameter and not hard-wired: the loop can be tested in a moment, offline, and the same loop runs against a real model by swapping one function. Check the documentation of the endpoint you use: a provider may document some newer models as requiring a different style of endpoint for tool calling.

3. Tools: a name, a description, a schema and code

A first attempt at a tool, and what goes wrong. A developer registers a function like this:

name: notes
description: Does stuff with notes.
inputs: text (string)

The model, asked for the March visit figures, sometimes calls notes with the text “visits”, sometimes with a whole sentence, and once with a note id, because nothing says what the tool does or what text should hold. The tool is not broken; the description is. Now compare:

name: search_notes
description: Find notes whose title contains a keyword. Returns note ids and titles.
inputs: keyword (string, required): a word from the title, such as "visits"

Now the model can tell what it is for, what to pass and what it will get back. A tool is a function the model may ask your program to run, described to the model by three things: a name, a description, and a parameter schema, plus the code that does the work. The model sees the first three, not the code. Two pictures: the label on a drawer in a workshop (a good label tells a new colleague what is inside without opening it), and a menu in a restaurant (the dish name, a line about it and the choices on offer).

Anthropic’s engineering team suggests thinking of how you would describe the tool to a new hire, making implicit context explicit and naming inputs without ambiguity. One provider’s function-calling documentation lists the same habits: write clear function names and parameter descriptions, use enums and object structure to prevent invalid states, and pass the “intern test” (could a human use the function with just what you gave the model?). Here is a tool that follows them, for arithmetic. Instead of letting the model send a free-form expression to be evaluated, it offers a fixed list of operations:

box.register(
    "calculate",
    "Apply one operation to two numbers and return the result.",
    {"type": "object",
     "properties": {"a": {"type": "number"},
                    "b": {"type": "number"},
                    "operation": {"type": "string",
                                  "enum": ["add", "subtract", "multiply", "divide"]}},
     "required": ["a", "b", "operation"]},
    lambda a, b, operation: {"add": a + b, "subtract": a - b,
                             "multiply": a * b, "divide": a / b}[operation],
)

The enum is a guard: the model picks from four operations, so the program does not run text the model invented. The registry that holds tools is chapters/05-agent-loop/app/agent/tools.py:

import json


class Toolbox:
    def __init__(self):
        self._tools = {}

    def register(self, name, description, parameters, func, needs_approval=False):
        self._tools[name] = {"description": description, "parameters": parameters,
                             "func": func, "needs_approval": needs_approval}

    def schemas(self):
        return [{"type": "function",
                 "function": {"name": n, "description": t["description"],
                              "parameters": t["parameters"]}}
                for n, t in self._tools.items()]

    def needs_approval(self, name):
        return name in self._tools and self._tools[name]["needs_approval"]

    def run(self, name, arguments_json):
        if name not in self._tools:
            return f"error: no tool named {name}"
        try:
            args = json.loads(arguments_json)
            return str(self._tools[name]["func"](**args))
        except Exception as exc:
            return f"error: {exc}"

Look at run. Whatever goes wrong (an unknown tool name, arguments that are not valid JSON, a missing note id), it returns the problem as text instead of crashing. The model reads that text on the next step and can try again. Anthropic’s tool-writing guidance says to make error responses clear and actionable instead of opaque error codes or tracebacks, so in your own tools, say what was wrong and what would work, for example “error: no note with id n9; ids are n1, n2, n3”. A search with no match should say “no notes found for ‘parking’” and not return an empty list that says nothing. Think of a form that rejects your entry with “Error 7”: you have no idea what to change, whereas a message naming the field and the allowed values gets fixed on the next try.

Two more design rules. First, keep a tool’s output short: the same guidance suggests pagination, filtering or truncation for responses that could use a lot of context, because the result goes into the memory and the window is finite. Second, offer few tools and give them little power. OWASP’s list of risks for model applications names excessive agency as a vulnerability and advises limiting the tools the agent may call to the minimum necessary, and limiting what each one can do. A research helper needs to read notes; it does not need to delete them.

4. Memory across turns: what to keep and what to drop

A user asks the helper, “Which branch had more visits in March?” and gets “Eastside”. The user then asks, “And for April?” The helper replies, “Which branches do you mean, and what would you like to know about April?” It has forgotten the first question, because the program sent the model just the new sentence.

That is how these models are called: one provider’s documentation says each text generation request is independent and stateless, and that a multi-turn conversation is built by providing the earlier messages as parameters of the next request. So the model has memory if you give it one. Think of a notebook the assistant carries into each meeting: if you leave it on the desk, each meeting starts from a blank page. A second picture: a whiteboard that is wiped between visits, where you copy the important lines back before the next one.

Memory, for an agent, is whatever the program puts into the messages it sends. The simplest memory is the whole list of messages so far, and that is what the loop above keeps. The trouble is size. Each step adds a reply and a tool result, so a long run fills the context window. In Chapter 1 you saw what happens to text that does not fit: picture a desk that holds seven folders, where adding an eighth slides the oldest off the edge and the answer is written without it. Memory is not retrieval: RAG (Chapters 3 and 4) fetches passages from documents and puts them in the prompt, while memory is the program’s own record of this run. Both end up as text in the context window, so both have to fit. Anthropic’s engineering writing on context calls context a finite resource and describes context rot: as the number of tokens in the window rises, the model’s ability to recall information from it falls.

What can you drop, and what do you keep? A worked example with invented sizes: the system message is 400 tokens, each other message is 600 tokens, and the window budget for one call is 5,000 tokens. After the system message, 5,000 - 400 = 4,600 tokens remain. At 600 per message that fits 4,600 / 600 = 7.67, so 7 whole messages, not 8. Keep the last 7 and drop the rest. A doctor keeps a one-page summary clipped to the front of a thick patient file for the same reason: the allergy is on the summary, so it is seen even when the older pages are left out. That summary plays the part of the notes in the table below.

Strategy What it does What you lose
Keep the last N messages Drops the oldest messages Early facts, such as the first question
Summarise old messages Replaces a long stretch with a short summary and carries on (Anthropic calls this compaction) Detail the summary leaves out
Keep notes outside the window The program records key facts and puts them back into the system message (Anthropic calls this structured note-taking) Nothing in the window, but the program must decide what to note
Clear old tool results Removes bulky results the agent has already used The raw data, if it is needed again

The code keeps the last few messages and a small dictionary of notes. This is chapters/05-agent-loop/app/agent/memory.py:

class Memory:
    def __init__(self, system_prompt, keep_last=8):
        self.system = {"role": "system", "content": system_prompt}
        self.turns = []
        self.notes = {}
        self.keep_last = keep_last

    def add(self, message):
        self.turns.append(message)

    def remember(self, key, value):
        self.notes[key] = value

    def messages(self):
        system = dict(self.system)
        if self.notes:
            facts = "; ".join(f"{k}: {v}" for k, v in self.notes.items())
            system["content"] += " Known facts: " + facts
        recent = self.turns[-self.keep_last:]
        while recent and recent[0]["role"] == "tool":
            recent = recent[1:]
        return [system] + recent

Two details. remember lets the program store a fact such as March winner: Eastside that survives trimming, because it is rewritten into the system message on each call. And the last loop in messages drops a tool result left at the front of the window, because a result with no request before it makes the record incomplete: keep a tool request together with its results.

5. Stopping: step limits, errors and asking a person

Before the fix, the failure. A helper is asked for a figure that is not in the notes. Its search tool returns “no notes found”. The model, which has been told to find the answer, searches again with a slightly different keyword, then another, and after thirty steps the run is still going, each step adding to the memory and each call using time and, on a hosted service, possibly money. Nothing in the loop above told it to stop. Think of a taxi with the meter running and no destination, or a cook who keeps tasting a dish nobody has defined as finished.

A step limit is a maximum number of model calls in one run. The same article says a run normally ends when the task is done, and that teams also commonly add a cap on how many rounds it may take, so that control stays with them; it notes that autonomy means higher costs and the potential for compounding errors. In the code, the for step in range(1, max_steps + 1) line is the limit, and the last line returns step_limit when it is reached.

A run has four ways to end. The loop reports which one in the stopped field:

stopped What happened What the caller should do
final The model replied without a tool request Use the answer
step_limit The model was still asking for tools after the last allowed step Report that the task was not finished; inspect the steps taken
error The call to the model failed (for example, the network) Retry later or show the error; the tool errors are not here, they go back to the model as text
needs_person The model asked for a tool marked as needing approval, and nobody approved Ask a person; run again with approval if they agree

The fourth is a design choice about power. The same engineering article says agents can pause for human feedback at checkpoints or when they meet blockers, and OWASP’s advice on excessive agency is to require a person to approve high-impact actions before they are taken. In the code, a tool registered with needs_approval=True is not run unless the approve function says yes. A search is harmless. Deleting a note or sending an email is not, so those get a person in the loop, the way a clerk may look up any file but needs a manager’s signature before deleting one:

box.register("delete_note", "Delete one note by id.", {...}, delete_fn, needs_approval=True)

Notice that the loop appends an error result for the refused call before returning, so that call is not left without an answer in the memory.

To test the loop you do not need a model. In chapters/05-agent-loop/tests/test_loop.py a scripted model returns prepared replies:

import json


def want(call_id, name, **args):
    return {"role": "assistant", "content": None,
            "tool_calls": [{"id": call_id, "type": "function",
                            "function": {"name": name, "arguments": json.dumps(args)}}]}


def scripted(replies):
    it = iter(replies)
    return lambda messages, tools: next(it)


def test_stops_at_the_step_limit():
    box = Toolbox()
    box.register("ping", "Return the word pong.", {"type": "object", "properties": {}}, lambda: "pong")
    replies = [want(f"c{i}", "ping") for i in range(5)]
    result = run_agent("Keep pinging.", scripted(replies), box, Memory("Helper."), max_steps=3)
    assert result["stopped"] == "step_limit"
    assert result["steps"] == 3

The test asks for five pings but allows three steps, and checks that the loop stops at three. Tests for the tools (chapters/05-agent-loop/tests/test_tools.py) check that an unknown tool name and bad arguments come back as error: text.

Flashcards

Say the answer aloud before revealing it. Speaking is a different skill from reading; practise both.

  1. What is the test that separates a workflow from an agent?

    • Ask who chooses the next step. If the programmer’s code fixed the order in advance it is a workflow; if the model chooses the next step and which tool to use, it is an agent.
  2. Describe the agent loop in order.

    • The program sends the messages to the model. If the reply asks for a tool, the program runs it and adds the result to the messages, then asks again. When the reply has no tool request, that reply is the answer and the loop ends.
  3. Who actually runs a tool: the model or your program?

    • Your program. The model writes a request with the tool’s name and its arguments as JSON text; the program runs the function and sends the result back as a message.
  4. What does a model see about a tool?

    • Its name, its description and its parameter schema. The model does not see the code, so a vague description leads to wrong or inconsistent calls.
  5. Why does the tool box return errors as text instead of raising them?

    • The model reads the text on the next step and can fix its call. A clear message such as the ids that exist is more useful than a crash or a traceback.
  6. Why use an enum in a parameter schema?

    • It limits an input to choices you name, such as four operations, so the model cannot send free text for the program to run, and invalid calls become harder to make.
  7. Why does an agent need memory, and where does it come from?

    • Each request to the model is independent, so the model knows just what is sent. The program builds the memory by sending the earlier messages, and any notes, on each call.
  8. Name two ways to keep memory from overfilling the context window.

    • Keep just the last few messages, or summarise older ones into a short note. You can also write key facts to notes that the program puts back into the system message.
  9. What does a step limit protect against?

    • A loop that does not end because the model keeps asking for tools. Autonomy brings higher costs and compounding errors, so a maximum number of steps keeps control with you.
  10. When should a tool need a person’s approval?

    • When it changes something outside the program in a way that is hard to undo, such as deleting a note or sending an email. Reading and searching can run without it.

Model answer

The core question this unit answers: “How does an agent decide what to do next, act on it, remember what happened, and know when to stop?”

Five beats a good answer covers, in this order:

  1. Who chooses — the model chooses the next step, not the code; that is what makes it an agent and not a workflow
  2. Loop — send messages, run the tool the model asks for, append the result, repeat
  3. Tools — a name, a description, a schema and code; the program runs them and returns errors as text
  4. Memory — the model keeps nothing, so the program sends the messages, trimmed to fit
  5. Stop — a final answer, a step limit, an error, or a person’s approval

The full answer, said aloud:

(Who chooses) “An agent is a system where the model chooses the next step and which tool to use; if my code fixes the order, it is a workflow.

(Loop) I run it as a loop: send the messages to the model, and if it asks for a tool, run it, add the result to the messages and ask again, until it replies without a tool request.

(Tools) Each tool is a name, a description, a parameter schema and a function, and my program, not the model, runs it and returns any error as text the model can act on.

(Memory) The model remembers nothing between calls, so my program sends the earlier messages, trimmed to fit the context window, plus any notes it keeps.

(Stop) And each run needs a way to end: a final answer, a step limit, an error exit, or a pause for a person to approve a risky action.“

Skip any of these five and the answer is incomplete. An interviewer or reviewer who hears just “the model calls tools in a loop” will ask next who runs the tool, what happens when it fails, and how the loop ends. Practise until the five beats come out in order without notes.

Why this matters

On the job: a team that says “we built an agent” is usually describing this loop. Knowing where the steps, the memory and the stop conditions live lets you read, debug and review it, and ask the questions that matter: what can it do, what does it remember, and what ends a run.

In interviews and reviews: “build an agent loop on a whiteboard” and “what stops it from running forever?” are natural questions for an AI engineering role. The five-beat answer is built for them.

As a builder: Chapter 6 shows the same loop in a framework, and having written it once by hand makes that easier to read; it also makes your tests simpler because the model is just a function you can replace.

After this unit

Hands-on project. Three tasks: (1) write a toolbox with three tools, run them directly and show how errors come back as text; (2) write the loop and drive it with a scripted model through a short research question, then through a step limit; (3) write a memory that trims by a token budget and decide which tools need approval. The full brief is in the project file.

What’s next. Chapter 6 splits the work between several agents with their own roles, shows the same loop written as a graph of steps in a framework, and offers tools through a standard connector. Keep your toolbox, loop and memory: Chapter 6 starts from them.

Project

Hands-on Project — Chapter 5: Build the Research Helper’s Loop

Objective

Build the three working parts of a single-agent research helper by hand: a toolbox whose errors come back as text, the agent loop driven by a scripted model, and a memory that trims by a token budget, then decide which tools need a person’s approval.

What you need: Python 3 and a text editor. Tasks 1, 2 and 3 use Python’s standard library and the figures in this brief: no account, no API key, and no paid service. The model is replaced by replies prepared in this brief, so the loop runs offline. An optional extension at the end of Task 2 connects a real model; it needs an endpoint and a key, and a hosted service may charge for its use, so skip it if you have neither. The notes, token counts and replies below are invented for the exercise.

You write the code yourself. This brief gives the facts and the checks; the finished programs and the expected printouts are not in it. Work through the tasks in order and check each output against its checks before moving on.


The notes used in the tasks

Six invented research notes, with their ids:

Id Title Text
n1 Eastside branch: visits March visits: 1240. April visits: 1310.
n2 Westside branch: visits March visits: 980. April visits: 1105.
n3 Eastside branch: opening days Open Monday to Saturday.
n4 Westside branch: opening days Open Monday to Friday.
n5 Eastside branch: staff Staff count: 14.
n6 Westside branch: staff Staff count: 9.

Task 1 — Build the toolbox

Create toolbox.py with a class Toolbox and three tools registered on it.

The class needs a way to register a tool (a name, a description, a parameter schema, a function, and a flag for whether it needs approval), a way to list the tools in the shape a model is sent (a list of dictionaries with "type": "function" and a "function" entry holding the name, description and parameters), and a run(name, arguments_json) method. The arguments arrive as JSON text.

The three tools:

  • search_notes: input keyword. Returns the ids and titles of notes whose title contains the keyword, ignoring case. When nothing matches it returns a message that names the keyword.
  • read_note: input note_id. Returns the text of one note. For an id that does not exist it returns a message that names the id and lists the ids that do exist.
  • calculate: inputs a, b and operation, where the operation is one of add, subtract, multiply and divide. The schema lists those four as an enum. A division by zero, or an operation outside the four, returns a message.

run returns a string and raises no exception: a failure comes back as a string that starts with error:. A message you return for a missing result, such as an empty search, is up to you.

Then call run directly on these eight requests and print each result:

  1. search_notes with keyword visits
  2. search_notes with keyword parking
  3. read_note with id n2
  4. read_note with id n9
  5. calculate with 1240, 980 and subtract
  6. calculate with 14, 0 and divide
  7. calculate with 2, 3 and power
  8. a tool named delete_everything, with empty arguments {}

Scope: three tools and eight requests. Stop once the checks below hold.

Checks:

  • The eight calls return strings and raise no exception.
  • Requests 4, 6, 7 and 8 return text that starts with error:.
  • Request 4’s message contains n9 and at least three of the existing ids.
  • The tool list has exactly 3 entries, each with a name, a description and parameters, and the operation enum in the calculate schema has exactly 4 values.

Output: toolbox.py and the eight printed results.


Task 2 — Write the loop and drive it with a scripted model

Create loop.py with a function run_agent(task, model_fn, toolbox, memory, max_steps, approve).

model_fn(messages, tools) returns one reply message as a dictionary with "role": "assistant", a "content" and, when it wants a tool, a "tool_calls" list; each call has an "id" and a "function" entry holding the "name" and the "arguments" as JSON text. memory is an object with add(message) and messages(); for this task write the simplest one, which keeps the full list of messages. approve is either None or a function that takes a tool name and returns true or false.

The function adds the task as a user message and then repeats up to max_steps times: call model_fn, add the reply to the memory, and if the reply asks for no tool, return its content as the answer. Otherwise run each requested tool through the toolbox and add each result as a message with "role": "tool" and the "tool_call_id" of the call it answers. It returns a dictionary with answer, steps and stopped. The stopped value is one of final, step_limit, error (the call to model_fn raised an exception) or needs_person (a tool registered as needing approval was requested and approve did not say yes; add an error result for that call before returning).

Register one more tool for the last run: delete_note, with input note_id, flagged as needing approval, which returns deleted when it runs.

Write a helper that builds a model from a list of prepared replies, handing them out one per call. Then make four runs and print the returned dictionary of each, with the number of messages in the memory (not counting any system message).

  • Run A: task “Which branch had more April visits, and by how many?” with max_steps 6 and these replies: a call to search_notes with keyword visits; a call to read_note with n1; a call to read_note with n2; a call to calculate with 1310, 1105 and subtract; then the text “Eastside, by 205.”
  • Run B: the same replies, with max_steps 3.
  • Run C: task “Remove the Eastside staff note.” with max_steps 6 and these replies: a call to search_notes with keyword staff; a call to delete_note with n5; then the text “Done.” Run it with approve set to None.
  • Run D: a model that raises an exception with the message network down when called.

Scope: one function, one extra tool, four runs. Stop once the checks below hold.

Checks:

  • Run A returns stopped of final and an answer of exactly the prepared text.
  • Run B returns stopped of step_limit and no answer, and its steps equals its max_steps.
  • Run C returns stopped of needs_person, and the note n5 is still readable afterwards.
  • Run D returns stopped of error and a detail that contains network down.
  • In the four runs, each tool message in the memory comes straight after an assistant message holding a call with the same id.

Optional extension (needs an endpoint and a key; may cost money): write a small function that wraps an OpenAI-compatible client into the model_fn shape, reading LLM_BASE_URL, LLM_API_KEY and LLM_MODEL from the environment, and run Run A’s task against it with your toolbox. Print the stopped and steps values. The number of steps may differ from Run A, because the model chooses.

Output: loop.py, the four printed dictionaries with their message counts, and (if you did it) the extension’s printout.


Task 3 — Trim memory by a budget and decide on approvals

Part A: the memory. Create memory.py with a class Memory that takes a system prompt and has: add(message); remember(key, value), which stores a fact and adds it to the system message on each call; and messages(budget), which returns the system message followed by the newest messages that fit. Each message carries a "tokens" number, and the system message is 400 tokens. Take messages from the newest backwards, stop at the first one that would push the total over the budget, and then, if the first kept message is a tool result, drop it, so that the record does not start on a result without its request.

The history to load, oldest first (an invented conversation; the number is the token size):

Position Role Tokens
1 user 120
2 assistant (asks for a tool) 80
3 tool result 450
4 assistant (asks for a tool) 80
5 tool result 300
6 assistant (asks for a tool) 80
7 tool result 520
8 assistant 150
9 user 90
10 assistant (asks for a tool) 80
11 tool result 610
12 assistant 200

Call messages with budgets of 1,300, 1,500 and 2,100 tokens (the budget includes the system message) and print, for each, the positions kept and their token total. Then call remember("March winner", "Eastside") and print the system message.

Part B: approvals. Here are six tools a research helper might be given:

  • search_notes: finds notes by title.
  • read_note: returns one note’s text.
  • calculate: does arithmetic on two numbers.
  • delete_note: removes a note permanently.
  • rename_note: changes a note’s title; renaming it back restores the old title.
  • send_summary: emails a summary of the notes to a list of people.

Write a file approvals.md that lists each tool as needing approval or not, with one sentence of reason. Each reason must say whether the tool changes something outside the program and whether the change can be undone.

Scope: three budgets, one remembered fact and six tools. Stop there.

Checks:

  • The three printed lists of kept positions are in increasing order and each starts at a position that is not a tool result.
  • No printed token total, with the system message included, is above its budget.
  • The system message printout contains the remembered fact.
  • approvals.md has six rows, each with a decision and a reason that answers both questions in Part B.

Output: memory.py with its printed results, and approvals.md.

Keep toolbox.py, loop.py and memory.py. Chapter 6 starts from the single agent you built here, then adds more agents and a standard way to offer tools.

TechEazy Consulting training material — not affiliated with or endorsed by OpenAI, Anthropic or any other company named in this course.