chapter 06
Your First Agent
Memory, tools and the think/decide/act/observe loop.
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 answerEverything 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 model6.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.
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:
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 nevermemory.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.
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
calculatorwraps its entire execution intry / exceptand 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, nevereval()untrusted or model-generated strings without heavy sandboxing - Chapter 10 builds a full isolatedSandboxedEnvironmentfor 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:
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 existCommon mistake: the tool's JSON
namemust exactly match the key inAVAILABLE_FUNCTIONS. A typo like"calculater"in one place and"calculator"in the other produces aKeyErrorat 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
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
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 answerThis 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.
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 atool_callslist has more entries than matchingtoolresult 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:
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:
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_formatequally well. Smaller local models can still occasionally produce malformed JSON even when asked nicely - always wrap thejson.loads(...)call in atry/exceptand 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_agentneed amax_stepslimit? Describe a realistic scenario where a loop without one could run indefinitely. - Extend
TOOLSandAVAILABLE_FUNCTIONSwith a second tool, e.g. aword_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.messagesand in what order, before the model is asked to respond again. - Why is
response_formatwith a JSON schema more reliable for downstream code than asking the model to "please reply in JSON" as plain instructions in the prompt?