Session 28 gave us the agent loop and showed how fragile small agents are. Three ingredients make agents more useful in practice: a way to reason in the open so each step is grounded, a memory that survives past a single conversation, and a clean way for several agents to pass work to each other. This session builds all three with code that runs.
ReAct: think, act, observe, written down
ReAct (Reason + Act), from a 2022 paper by Shunyu Yao and colleagues, is the pattern behind most tool-using agents. The agent does not just answer. It writes out a cycle:
- Thought: what do I need next?
- Action: call a tool with specific arguments.
- Observation: the tool's result, inserted by your code.
...and repeats until it writes a Final Answer. The value is in the grounding: each thought is anchored in a real observation instead of the model's guesswork, and writing the thought down makes the whole run readable and debuggable. The pattern is just a prompt format plus a loop (always with a step limit):
REACT_PROMPT = """Answer the question using these steps, repeating as needed.
Thought: what do I need next?
Action: tool_name[input]
Observation: (the tool result will be inserted here)
... (repeat Thought / Action / Observation) ...
Final Answer: the answer"""Here is the loop from last session once more, this time reading the transcript as a piece of reasoning:
# the same loop as Session 28, with the Thought / Action / Observation lines printed
answer = run_agent("What is the temperature in Karachi multiplied by 2?", system=SYSTEM_V1)
print("\nfinal:", answer)[step 1]
Thought: I need to use the get_temperature tool to find the temperature in Karachi and then multiply it by 2.
Action: get_temperature[Karachi]
Observation: 34
[step 2]
Thought: Now I need to multiply the temperature in Karachi by 2.
Action: calculator[34 * 2]
Observation: 68
[step 3]
Final Answer: 68
final: 68"I need to use the get_temperature tool to find the temperature in Karachi and then multiply it by 2" is the model's plan, written down. The action, get_temperature[Karachi], returns 34. The next thought, "Now I need to multiply...", uses that real number. Then calculator[34 * 2] returns 68, and the final answer is written. A related idea, chain of thought (asking a model to reason step by step), improves reasoning on its own. ReAct adds actions and observations to it.
The ReAct paper, ReAct: Synergizing Reasoning and Acting in Language Models (Yao et al., 2022), showed that interleaving reasoning steps with tool use reduced hallucination on question answering compared with reasoning alone.Yao et al., 2022
Memory: an LLM starts every call blank
A language model has no memory by default. Every call starts fresh. "Memory" is something you build around it by deciding what information to store and what to put back into the prompt. It comes in two kinds.
Short-term memory: inside the context window
Short-term memory is what the agent holds during the current task: the conversation so far, recent tool results, the current state. It lives in the context window, so it is fast, but limited. When it grows too large you have to trim it, summarise older turns, or both. The choice matters, as this test shows. A twelve-turn chat contains an early fact ("my exam is on 14 September, I'm weak in Physics"), followed by unrelated questions. Then we ask a question that depends on the early fact, with three different memory strategies:
history = ["User: Hi, I'm Sara and I'm preparing for MDCAT.", "Assistant: Nice to meet you Sara! How can I help?",
"User: My exam is on 14 September.", "Assistant: Got it, 14 September.",
"User: I'm strong in Biology but weak in Physics.", "Assistant: Understood.",
"User: What is the unit of force?", "Assistant: The newton (N).",
"User: And the unit of energy?", "Assistant: The joule (J).",
"User: And power?", "Assistant: The watt (W)."]
tokens = lambda text: len(get_llm().tokenize(text.encode()))
question = "User: When is my exam, and which subject should I focus on?"
full = "\n".join(history)
last4 = "\n".join(history[-4:]) # strategy 1: keep only the most recent turns
summary = ask("Summarise the important facts about the user in two lines:\n" + full, max_tokens=60)
summarised = "Summary of earlier chat: " + summary + "\n" + last4 # strategy 2: summary plus the recent turns
for name, ctx in [("full history", full), ("last 4 turns only", last4), ("summary + last 4", summarised)]:
reply = ask(ctx + "\n" + question + "\nAssistant:", max_tokens=50)
print(f"{name:20s} context {tokens(ctx):4d} tokens | {reply[:100]}")
print("\nsummary it wrote:", summary)full history context 112 tokens | Your exam is on 14 September. You should focus on your strong subject, Biology, and improve your wea
last 4 turns only context 28 tokens | I'm sorry, but I don't have any specific exam date or subject to provide information about. Please l
summary + last 4 context 73 tokens | Your exam is on September 14th. Focus on improving your weak subject, Physics.
summary it wrote: User is preparing for MDCAT. Exam is on 14 September. Strong in Biology, weak in Physics. Units: Force - newton, Energy - joule, Power - watt.With the full history (112 tokens) the model answers correctly, mentioning the exam date. With only the last 4 turns (28 tokens) it has forgotten everything and says "I don't have any specific exam date". With a summary plus the last 4 turns (73 tokens) it again answers correctly: "Your exam is on September 14th. Focus on improving your weak subject, Physics." The summary kept the facts that mattered at about two thirds of the cost. That trade, a little quality lost in exchange for many tokens saved, is the heart of context management. Notice also that the summary itself kept a trivia detail (the units of force, energy and power), so summarising is not free of judgement either.
Long-term memory: survives across sessions
Long-term memory lives outside the model and persists between conversations: preferences, decisions, facts, documents. It is usually stored in a vector database (retrieved by meaning, as in RAG) or a structured store such as SQL. It is often divided into three kinds:
- Episodic: what happened in past interactions.
- Semantic: facts about the user or the domain.
- Procedural: how to do things, such as rules and instructions.
Plain markdown files as memory
Not all memory needs a database. A simple and increasingly popular approach is plain .md files that the agent reads at the start of a session and updates when it learns something. Some coding assistants work this way. The design that makes it efficient is an index file: a short table of contents with one line per memory file, so the agent loads the index first and opens only the files it needs.
- a preferences file (tone, style, how the user likes things done),
- a facts file (stable information about the person or project),
- topic or session notes (decisions, progress),
- and the index (
MEMORY.md) listing every file with a one-line description.
The index keeps token use low, and the memory is transparent (you can open and edit it), version-controllable (put it in Git) and portable. Here it is in code, with a made-up student:
import tempfile
from pathlib import Path
MEM = Path(tempfile.mkdtemp()) / "memory"; MEM.mkdir()
def remember(name, description, text):
(MEM / f"{name}.md").write_text(text)
with open(MEM / "MEMORY.md", "a") as index: # the index: one line per memory file
index.write(f"- [{name}]({name}.md): {description}\n")
remember("preferences", "how the student likes to study", "Prefers short videos over long notes. Studies best at night. Dislikes timed drills.")
remember("exam_facts", "exam dates and target", "Appearing in MDCAT on 14 September. Target: 150 out of 180. Weakest subject: Physics.")
remember("fee_notes", "fee payment history", "Paid the first two months. Third month due on the 5th. Prefers payment by bank transfer.")
remember("chat_log_aug", "old chat transcript", "Long transcript of August chats... " * 40)
index = (MEM / "MEMORY.md").read_text()
print("THE INDEX (this is all the agent reads first):")
print(index)
# new session: the agent has the index, picks the files it needs, and reads only those
question = "Plan tonight's study session for me."
choice = ask(f"Memory index:\n{index}\nWhich memory files are needed to answer: '{question}'? Reply with only the file names, comma separated.", max_tokens=30)
picked = [n for n in ("preferences", "exam_facts", "fee_notes", "chat_log_aug") if n in choice]
print("files it chose to open:", picked)
context = "\n".join((MEM / f"{n}.md").read_text() for n in picked)
print(f"words read: {len(context.split())} (reading every file would be {sum(len(p.read_text().split()) for p in MEM.glob('*.md'))})\n")
print("answer:", ask(f"What you know about this student:\n{context}\n\nTask: {question} Give a 3-line plan.", max_tokens=120))THE INDEX (this is all the agent reads first):
- [preferences](preferences.md): how the student likes to study
- [exam_facts](exam_facts.md): exam dates and target
- [fee_notes](fee_notes.md): fee payment history
- [chat_log_aug](chat_log_aug.md): old chat transcript
files it chose to open: ['preferences', 'exam_facts', 'fee_notes']
words read: 43 (reading every file would be 267)
answer: 1. Start with short videos on Physics to review concepts.
2. Allocate time for a short quiz on notes from the day.
3. End with a 30-minute break to relax and recharge.The agent starts with only the index: four lines. Given the task "Plan tonight's study session for me", it chose to open three files (preferences, exam facts, and, unnecessarily, fee notes) and skipped the long old chat log. It read 43 words instead of 267, about 16 percent of everything stored. Its answer is a sensible, personalised three-line plan that mentions Physics, the weak subject recorded in memory. Choosing one file too many is a normal imperfection of a small model, and a cheap one. Choosing none would be the dangerous failure.
Memory across turns with a checkpointer
In LangGraph (Session 27), short-term memory across turns is the checkpointer plus a thread_id: the same thread restores the saved state, including the message history. A different thread starts empty:
import re
from typing import Annotated, TypedDict
from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import END, START, StateGraph
from langgraph.graph.message import add_messages
class State(TypedDict):
messages: Annotated[list, add_messages] # new messages are APPENDED to the saved history
def respond(state: State) -> dict:
text = state["messages"][-1].content
history = " ".join(m.content for m in state["messages"][:-1])
if "what is my name" in text.lower():
found = re.search(r"my name is (\w+)", history, re.I)
reply = f"Your name is {found.group(1)}." if found else "I don't know your name yet."
else:
reply = "Nice to meet you!"
return {"messages": [("assistant", reply)]}
g = StateGraph(State)
g.add_node("respond", respond); g.add_edge(START, "respond"); g.add_edge("respond", END)
app = g.compile(checkpointer=MemorySaver())
def chat(text, thread):
out = app.invoke({"messages": [("user", text)]}, {"configurable": {"thread_id": thread}})
return out["messages"][-1].content
print("thread user-42:", chat("My name is Sara.", "user-42"))
print("thread user-42:", chat("What is my name?", "user-42"), " <- same thread, remembered")
print("thread user-99:", chat("What is my name?", "user-99"), " <- a different thread starts empty")
print("\nsaved messages in user-42:", len(app.get_state({"configurable": {"thread_id": "user-42"}}).values["messages"]))thread user-42: Nice to meet you!
thread user-42: Your name is Sara. <- same thread, remembered
thread user-99: I don't know your name yet. <- a different thread starts empty
saved messages in user-42: 4The first message in thread user-42 states a name. The second, in the same thread, asks for it and gets "Your name is Sara." In thread user-99 the same question gets "I don't know your name yet." That is the whole mechanism for per-user conversation memory, and it is also the access control: each user's thread is separate. After two turns the saved state holds 4 messages, two from the user and two replies. (In this demo a regular expression recalls the name, to keep the point about state clear and not about model quality.)
cfg = {"configurable": {"thread_id": "user-42"}}
app.invoke({"question": "My name is Sara."}, config=cfg)
app.invoke({"question": "What is my name?"}, config=cfg) # same thread = remembered stateAgent-to-agent communication
In a multi-agent system each agent is a node that does one job, and the system works only if nodes can pass information cleanly. There are three common ways:
- Shared state: every node reads and writes one common state object, and the next node picks up what the last one wrote. This is how LangGraph works.
- Message passing: agents send messages to each other like a conversation, as in AutoGen, "talking" until the task is solved.
- Handoff: one agent transfers control, with the context needed, to a more suitable agent. The OpenAI Agents SDK uses this.
Whichever you use, two habits make communication reliable: pass structured output (such as validated JSON) so the receiver does not have to guess from free text, and pass only the context needed, since sending the whole history to every agent wastes tokens and can confuse it.
Here is a three-agent pipeline using shared state. A researcher extracts facts from notes as JSON, and the output is validated against a schema before moving on. A writer turns those facts into two sentences. A reviewer applies a concrete rule:
import json
from typing import TypedDict
from langgraph.graph import END, START, StateGraph
from pydantic import BaseModel, ValidationError
class Facts(BaseModel): # the agreed "form" that one agent hands to the next
facts: list[str]
class State(TypedDict):
notes: str
facts: list
article: str
verdict: str
def researcher(s):
raw = ask("From these notes, extract exactly 3 short facts as JSON like {\"facts\": [\"...\", \"...\", \"...\"]}. JSON only.\nNotes: " + s["notes"], max_tokens=150)
raw = raw[raw.find("{"): raw.rfind("}") + 1]
try:
return {"facts": Facts(**json.loads(raw)).facts} # validated, so the next agent can trust the shape
except (ValidationError, json.JSONDecodeError, ValueError):
return {"facts": []}
def writer(s):
if not s["facts"]: return {"article": ""}
return {"article": ask("Write 2 friendly sentences for students using ONLY these facts:\n- " + "\n- ".join(s["facts"]), max_tokens=100)}
def reviewer(s):
"""A checkable rule, not a vibe: every number in the facts must appear in the article."""
numbers = {w.strip(".,") for f in s["facts"] for w in f.split() if any(c.isdigit() for c in w)}
missing = [n for n in numbers if n not in s["article"]]
return {"verdict": "approved" if s["article"] and not missing else f"needs another pass, missing {missing}"}
g = StateGraph(State)
for name, fn in [("researcher", researcher), ("writer", writer), ("reviewer", reviewer)]: g.add_node(name, fn)
g.add_edge(START, "researcher"); g.add_edge("researcher", "writer"); g.add_edge("writer", "reviewer"); g.add_edge("reviewer", END)
app = g.compile()
result = app.invoke({"notes": "MDCAT 2026 has 180 MCQs. The test is paper-based. Biology carries 81 questions. Negative marking is not used.",
"facts": [], "article": "", "verdict": ""})
print("researcher passed on:", result["facts"])
print("writer wrote :", result["article"])
print("reviewer says :", result["verdict"])researcher passed on: ['MDCAT 2026 has 180 MCQs.', 'The test is paper-based.', 'Biology carries 81 questions.']
writer wrote : MDCAT 2026 is a comprehensive exam with 180 multiple-choice questions, all of which are paper-based, and Biology makes up 81 of those questions.
reviewer says : approvedThe researcher passed on three clean facts (180 MCQs, paper-based, Biology carries 81). The writer used them to produce two sentences. The reviewer checked a rule that can be tested: every number in the facts must appear in the article, and approved it. My first reviewer used a vague word-overlap check and rejected a perfectly good article. That is a general warning: a reviewing agent is only as good as the criterion you give it, so prefer checks a program can verify (numbers present, JSON valid, tests pass) to "does this look good?".
Every extra agent adds tokens, delay and chances for a misunderstanding. A single well-prompted agent with good tools often beats a team of three. Add agents only when roles really differ, when work can run in parallel, or when one agent's job is to check another's.
A chatbot must remember a user's preferences across many separate days. Which kind of memory do you need?
Why is an index file useful in a markdown-based agent memory?
Try it yourself
- In
memory_files.py, add a memory file about the student's mock-test scores and ask for a study plan. Does the agent open it? - In
short_term_memory.py, make the history 30 turns long. At what point does summarising start to pay off in tokens? - In
checkpoint_memory.py, add a third user thread and have it ask "what is my name" before and after introducing itself. - In
agent_handoff.py, make the researcher fail (give it empty notes). What does the writer do, and how would you make the pipeline stop cleanly?
What comes next
The last session closes the loop on agents: self-correction, where an agent checks its own work against real feedback, planning, where it breaks a goal into steps, and custom LLM training, for when behaviour must change in the model itself. Session 30 also finishes the course.
Frequently asked questions
What is the ReAct pattern?
ReAct, short for Reason plus Act, is a pattern where an agent alternates between a thought (what do I need next), an action (calling a tool with arguments) and an observation (the tool result), repeating until it produces a final answer. It grounds each reasoning step in real results and makes the agent's behaviour easy to follow.
What is the difference between short-term and long-term memory in an agent?
Short-term memory is what the agent holds during the current task, mainly the conversation and recent tool results in the context window. Long-term memory persists across sessions in an external store such as files, a vector database or SQL, and is loaded back into the prompt when relevant.
How can I give an agent memory without a vector database?
Store memories as plain markdown files and keep a short index file listing each file with a one-line description. The agent reads the index at the start of a session and opens only the files it needs. The memory stays human-readable, editable and version-controllable.
How do agents communicate with each other?
Through shared state (all agents read and write one state object), message passing (agents send messages to each other) or handoff (one agent passes control and context to another). Structured outputs such as validated JSON and passing only the context needed make communication more reliable.
Are multi-agent systems always better than a single agent?
No. They add cost, delay and coordination errors. A single agent with good tools is often enough. Use several agents when roles are truly different, when work can run in parallel, or when one agent must verify another.
