Back to labLearn AIShovon Saha

chapter 06

Your First Agent

Memory, tools and the think/decide/act/observe loop.

Contents

1 / 8

A chat call (Chapter 5) answers one question. An agent is a chat call wrapped in a loop that can (a) remember the conversation and (b) take actions in the world - not just talk about them. This chapter builds one, using only LM Studio, step by step.

Before diving into code, it's worth asking: why does this distinction matter at all? A chatbot that just answers questions is genuinely useful, but it is fundamentally passive - it can only ever hand you words. The moment you want the model to check today's weather, query a database, send an email, or run a calculation it can't do reliably in its head (large arithmetic, for example), you need a way for it to say "do this for me" and get a real answer back. That request-response cycle, repeated until the task is done, is the entire definition of an agent. There is no extra magic beyond what you already know from Chapters 1–5: a loop, a list of messages, and some Python functions.


6.1 The agent loop - the one diagram that defines "agent"

        ┌───────────────────────────────────────────────────────┐
        │                                                         │
        ▼                                                         │
   ┌─────────┐     ┌──────────┐     ┌────────────┐     ┌──────────┐
   │  THINK   │────▶│  DECIDE   │────▶│    ACT      │────▶│ OBSERVE  │──┘
   │ (model    │     │ (reply, or│     │ (run a tool,│     │ (feed the │
   │  generates│     │  call a   │     │  e.g. a     │     │  tool's   │
   │  a response)│    │  tool?)   │     │  calculator)│     │  result   │
   └─────────┘     └──────────┘     └────────────┘     │  back in) │
                                                           └──────────┘
                       loop continues until the model decides
                          it has enough info to just answer

Everything below is one concrete implementation of this diagram.

Notice what is not in this diagram: there is no separate "agent brain" module, no special hidden state inside the model. Every "decision" the model makes is just it predicting the next tokens of text, exactly like Chapter 4 described. The only difference from a plain chatbot is that we've given the model a vocabulary for saying "please run this function for me" (the tool-call format), and we've written code on our side that watches for that vocabulary and actually executes something when it shows up. The "loop" is just a for or while in Python - nothing about it lives on OpenAI's or LM Studio's servers.

Common mistake: thinking the model can directly run tools itself. It cannot access your filesystem, network, or Python interpreter. It can only ever emit text that describes what it wants run. Your code is 100% responsible for actually doing it, checking the result, and deciding whether to trust it.

   WRONG mental model:                       CORRECT mental model:
   model ──▶ directly calls calculator()     model ──▶ emits text: "call calculator('2+2')"
                                              your code ──▶ parses that text ──▶ actually calls calculator()
                                              your code ──▶ feeds the result back to the model

6.2 Step 1: memory - a class that holds the growing messages list

Recall from Chapter 4.5: the model has no memory of its own. "Memory" is just you keeping a list and appending to it - exactly like self.history in Chapter 1.5.

This is worth dwelling on because it surprises a lot of newcomers: every single request to an LLM API is stateless. The server does not remember your previous message. If you want the model to "remember" that you told it your name three turns ago, you must literally re-send that earlier message, every single time, as part of the messages list. A Memory class exists purely to make that bookkeeping less error-prone - it is not talking to the model at all, it's just a well-organized Python list with some convenience methods glued on.

python · sandbox

Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.

   Memory.messages grows over time:
   [system]
   [system, user1]
   [system, user1, assistant1]
   [system, user1, assistant1(wants tool), tool_result1, assistant2(final answer)]

You can try this class on its own, with no network call at all, just to see the shape of the data it's building:

python · sandbox

Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.

Run that snippet and you'll see exactly the four-message history that would be sent on the next API call - this is the entire "memory" a model ever has: whatever you choose to put in this list.

Common mistake: forgetting to append the model's own reply back into memory before asking the next question. If you only call memory.add_user(...) every turn and never memory.add_assistant(...), the model never sees its own previous answers, and it will contradict itself or ask you to repeat information you already gave it.

Why store tool_calls on the assistant message at all? Because the model's request to call a tool is itself part of the conversation history - if you don't include it, the follow-up tool message (the result) has nothing to attach to, and most APIs will reject or misinterpret the exchange. Think of it as: "assistant said it wanted to call X" must appear in the transcript right before "here's what X returned," in the same order a human reading the log would expect.


6.3 Step 2: give the model tools it can ask to use

A tool to an LLM is just: a name, a description, and a schema describing its arguments - in plain JSON. The model never executes anything; it only ever replies "please run get_weather with {"city": "Cairo"}" - your Python code does the actual running.

   TOOL DEFINITION (JSON, sent WITH every request)      ACTUAL PYTHON FUNCTION (your code)
   {                                                     def calculator(expression):
     "name": "calculator",                                    return eval(expression, {}, {})
     "description": "Evaluate a math expression",
     "parameters": {
       "type": "object",
       "properties": {
         "expression": {"type": "string"}
       },
       "required": ["expression"]
     }
   }

The description field matters more than it looks. The model decides whether and when to call a tool purely by reading its name and description, the same way you'd decide whether to call a function by reading its docstring. A vague description ("does stuff with numbers") leads to the model calling it at the wrong times, or never calling it at all. A precise one ("Evaluate a basic arithmetic expression like '2 + 2 * 3'; supports +, -, *, /, and parentheses") gives the model enough signal to use it correctly and only when appropriate.

python · sandbox

Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.

The Tool Return Contract - Never Crash the Loop: Notice that calculator wraps its entire execution in try / except and returns a string like "error: division by zero" instead of raising an exception. This is one of the most critical rules in agent engineering: a tool must never raise an unhandled exception into the agent loop. When a tool returns an error string, that string is fed back to the model as an observation. The model can then read the error, understand what went wrong, and try an alternative approach (self-correction). If a tool raises an unhandled exception, your entire Python program crashes mid-conversation and the agent loses its state.

Safety note: eval() is used here only to keep the introductory example short. In real code, never eval() untrusted or model-generated strings without heavy sandboxing - Chapter 10 builds a full isolated SandboxedEnvironment for safe command and Python execution.

Try the function standalone before wiring it into any model call - it's just a Python function and it should be testable exactly like any other:

python · sandbox

Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.

Notice the pattern: AVAILABLE_FUNCTIONS is a plain dict mapping the name the model will use (a string) to the actual callable. This lookup table is what lets your loop go from "the model said to call 'calculator'" to "actually call the Python function calculator" without a chain of if name == "calculator": ... elif name == "get_weather": .... Adding a second tool later means adding one entry to TOOLS and one entry to AVAILABLE_FUNCTIONS - nothing else in the loop needs to change. This is the same "don't hardcode, look it up" instinct behind using a dict instead of a long if/elif chain anywhere else in your code.

   MULTIPLE TOOLS, SAME PATTERN:

   TOOLS = [calculator_schema, weather_schema, search_schema]
   AVAILABLE_FUNCTIONS = {
       "calculator": calculator,
       "get_weather": get_weather,
       "web_search": web_search,
   }

   loop code stays IDENTICAL regardless of how many tools exist

Common mistake: the tool's JSON name must exactly match the key in AVAILABLE_FUNCTIONS. A typo like "calculater" in one place and "calculator" in the other produces a KeyError at runtime that has nothing to do with the model - always double check these two spellings match character-for-character.


6.4 Step 3: the full agent loop, wired to LM Studio

python · sandbox

Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.

python · sandbox

Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.

Trace of what happens on the wire for that example:

1. Send:    [system, user("137*42, then +15")]
             + the calculator tool definition
   Model replies: "I need to calculate 137*42 first" + tool_call{calculator, "137*42"}

2. Your code runs calculator("137*42") -> "5754"
   Send back:  [..., assistant(wants tool), tool_result("5754")]
   Model replies: tool_call{calculator, "5754+15"}

3. Your code runs calculator("5754+15") -> "5769"
   Send back:  [..., tool_result("5769")]
   Model replies (no tool call this time): "137 * 42 + 15 = 5769"

4. Loop sees no tool_calls -> returns final answer

This is the exact loop from 6.1, now running for real: think → decide → act → observe → repeat, until the model is satisfied and just answers.

Look closely at max_steps. Why cap the loop at all? Because nothing structurally prevents a model from deciding to call a tool forever - a buggy prompt, a confusing tool description, or just an unlucky generation can send the model into a cycle where it keeps asking for the same tool over and over without ever concluding. max_steps is a simple, cheap safety valve: if the loop hasn't produced a final answer within a handful of iterations, give up gracefully instead of hanging (or burning API credits) indefinitely. In production systems this is often paired with a wall-clock timeout too, since a single "step" against a slow model can itself take a long time.

   WITHOUT a step limit:                      WITH a step limit:
   think -> tool -> think -> tool -> ...      think -> tool -> think -> tool -> ... -> STOP at step 5
   (could run forever on a bad prompt)        (bounded worst case, predictable cost)

Let's also trace what happens if the model asks for two tool calls in the same turn - this is common once you have multiple tools, e.g. "what's 5*5 and also what's the weather in Cairo?" The for tool_call in choice.tool_calls: loop already handles this correctly: it runs every requested tool and appends a separate tool message for each one, using each call's own tool_call_id so the model can tell which result answers which request.

python · sandbox

Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.

Running that shows the two tool messages sitting right after the single assistant message that requested both - that pairing by tool_call_id is exactly what lets the model, on its next turn, correctly say "55 is 25 and 66 is 36" instead of getting the two results confused.

Common mistake: appending tool results in the wrong order relative to their tool_call_ids, or forgetting to include a result for every requested call. Some servers will reject the next request entirely if a tool_calls list has more entries than matching tool result messages that follow it.


6.5 Structured output - forcing a predictable shape back

Sometimes you don't want free text, you want data you can immediately use in code (e.g. a dict with fixed keys). Many OpenAI-compatible servers, including LM Studio, support a response_format with a JSON schema for exactly this:

python · sandbox

Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.

   INPUT (free text)              TRANSFORM (model + schema constraint)     OUTPUT (typed dict)
   "Ada is 36 years old."   ──▶   forced to match {"name": str, "age": int} ──▶  {"name": "Ada", "age": 36}

This turns the model into a reliable data-extraction step in a larger pipeline - no more regex-parsing free-form replies.

Why does this matter so much in agent systems specifically? Because agents are often chained: the output of one call becomes the input of the next processing step, or gets written straight into a database, a spreadsheet, or another function's arguments. If that output is unpredictable free text ("The person's name is Ada and she's 36 years old, if I'm reading this right"), every downstream consumer of that text needs its own fragile parsing logic. response_format moves that burden onto the API itself: it constrains the model's output tokens so that what comes back is guaranteed to be valid JSON matching your schema (assuming the server supports the feature properly), which means json.loads(...) will not blow up, and the keys you expect will be there.

You can extend the same idea to lists and nested structures, which is common when extracting several records at once:

python · sandbox

Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.

That's the same building blocks (dicts nested inside dicts, a list for the repeating part) you learned in Chapter 1 - a JSON schema is just a very literal, structured way of writing down "here's the shape I expect," which both you and the model can read.

Common mistake: assuming every model or server supports response_format equally well. Smaller local models can still occasionally produce malformed JSON even when asked nicely - always wrap the json.loads(...) call in a try/except and have a fallback plan (retry once, or fall back to asking for plain text and parsing it yourself) rather than letting a malformed response crash your whole pipeline.


6.6 What you've built

An object (Memory) that remembers a growing conversation, a set of Python functions the model can request by name, and a loop that keeps calling the model until it's done acting and ready to answer. This is a real, working agent - running entirely on your own machine, for free. Chapter 7 makes this same agent able to run against any provider, not just LM Studio.


Check yourself

  • In your own words, explain why an LLM "calling a tool" is really just the model emitting a specially-formatted piece of text, and not the model directly executing code.
  • What would happen to the conversation history (and the model's next answer) if you forgot to call memory.add_assistant(...) after receiving a reply? Trace it out with a small example.
  • Why does run_agent need a max_steps limit? Describe a realistic scenario where a loop without one could run indefinitely.
  • Extend TOOLS and AVAILABLE_FUNCTIONS with a second tool, e.g. a word_count(text: str) function. What is the minimum set of changes required in the loop itself? (Hint: it should be zero.)
  • Suppose the model asks for two tool calls in a single turn. Walk through, step by step, how many messages get appended to memory.messages and in what order, before the model is asked to respond again.
  • Why is response_format with a JSON schema more reliable for downstream code than asking the model to "please reply in JSON" as plain instructions in the prompt?