Design Patterns Lab
Hands-on: refactor a naive agent using registry, middleware, and checkpoint patterns
Overview
In Module 3, you built a research assistant agent that searched the web, read pages, and produced structured reports. It worked. It also had every problem we diagnosed in lesson 01 of this module: tools were hardcoded in a dictionary, logging was scattered print() statements, there was no cost tracking, no context management, and no way to resume after a crash. The architecture was right. The engineering was not there yet.
In this lab, you will progressively refactor that same research agent using the four design patterns you learned in lessons 02 through 06. Each refactoring step is independent -- you add one pattern at a time, and after each step the agent still runs correctly but is more maintainable, observable, and resilient.
By the end, you will have transformed a monolithic script into a well-structured agent system. And you will see exactly where the next limitation lies -- which sets the stage for Module 7.
Before and After: The Architecture
Let's start by looking at the transformation we are going to make. The left side is the Module 3 agent. The right side is what we will have at the end of this lab.
graph TD
subgraph Before["Module 3: Monolithic Agent"]
direction TB
B1["Tools defined as dicts<br/>in a global list"]
B2["Tool dispatch via<br/>if/elif or dict lookup"]
B3["print statements<br/>for logging"]
B4["No cost tracking"]
B5["Messages list grows<br/>unbounded"]
B6["Crash = start over"]
B1 --> B2 --> B3 --> B4 --> B5 --> B6
end
subgraph After["Module 6: Pattern-Based Agent"]
direction TB
A1["ToolRegistry<br/>auto-generates schemas"]
A2["Middleware pipeline<br/>logging + cost tracking"]
A3["ContextManager<br/>summarizes old messages"]
A4["CheckpointStore<br/>saves state each iteration"]
A1 --> A2 --> A3 --> A4
end
Before -->|"This lab"| After
style Before fill:#fed7d7,stroke:#fc8181
style After fill:#f0fff4,stroke:#68d391
style B1 fill:#fff5f5,stroke:#feb2b2
style B2 fill:#fff5f5,stroke:#feb2b2
style B3 fill:#fff5f5,stroke:#feb2b2
style B4 fill:#fff5f5,stroke:#feb2b2
style B5 fill:#fff5f5,stroke:#feb2b2
style B6 fill:#fff5f5,stroke:#feb2b2
style A1 fill:#f0fff4,stroke:#9ae6b4
style A2 fill:#f0fff4,stroke:#9ae6b4
style A3 fill:#f0fff4,stroke:#9ae6b4
style A4 fill:#f0fff4,stroke:#9ae6b4
We will apply each pattern in order, testing at each step. This is how real refactoring works -- small, verified changes, never a big-bang rewrite.
The Starting Point
Here is the Module 3 research agent, condensed to the parts we will refactor. If you completed the Module 3 lab, this is your code. If not, use this as the starting point.
"""Module 3 Research Agent -- the monolithic version."""
import json
import datetime
from typing import Any
import anthropic
# --- Tools defined inline ---
def search_web(query: str, max_results: int = 3) -> str:
"""Search the web for information on a topic."""
simulated = [
{
"title": f"Result for: {query}",
"url": f"https://example.com/{query.replace(' ', '-')[:40]}",
"snippet": f"Overview of {query}.",
}
for i in range(max_results)
]
return json.dumps({"results": simulated})
def read_webpage(url: str) -> str:
"""Fetch text content of a webpage."""
content = f"Content from {url}. Key findings about the topic..."
return json.dumps({"url": url, "content": content[:2000]})
def get_current_date() -> str:
"""Return today's date."""
today = datetime.date.today()
return json.dumps({"date": today.isoformat()})
def save_report(title: str, summary: str,
sections: list[dict], sources: list[str]) -> str:
"""Save a structured research report."""
filename = title.lower().replace(" ", "_")[:50] + ".md"
return json.dumps({"filename": filename, "char_count": 1200})
# --- Dispatch table ---
tool_functions: dict[str, Any] = {
"search_web": search_web,
"read_webpage": read_webpage,
"get_current_date": get_current_date,
"save_report": save_report,
}
# --- Tool schemas (hand-written, 80+ lines omitted for brevity) ---
tools = [...] # The full list from Module 3
# --- Agent loop ---
client = anthropic.Anthropic()
def run_agent(question: str) -> str:
messages = [{"role": "user", "content": question}]
for iteration in range(15):
print(f"--- Iteration {iteration + 1} ---") # Ad-hoc logging
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=4096,
system="You are a research assistant...",
tools=tools,
messages=messages,
)
if response.stop_reason == "end_turn":
for block in response.content:
if block.type == "text":
return block.text
if response.stop_reason == "tool_use":
messages.append({"role": "assistant", "content": response.content})
tool_results = []
for block in response.content:
if block.type == "tool_use":
print(f" Calling: {block.name}") # Ad-hoc logging
try:
result = tool_functions[block.name](**block.input)
except Exception as e:
result = json.dumps({"error": str(e)})
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": result,
})
messages.append({"role": "user", "content": tool_results})
return "Max iterations reached."
`} language="python" filename="research_agent_v0.py" />
Count the problems: tool schemas are hand-written and can drift from implementations, logging is `print()` sprinkled everywhere, there is no cost tracking, the `messages` list grows without bound, and if the process crashes at iteration 10 you lose everything.
## Step 1: Add the Tool Registry
The first refactoring replaces the hand-written tool schemas and dispatch table with a **Tool Registry**. From lesson 02, you know the registry uses Python's `inspect` module to auto-generate JSON schemas from function signatures and docstrings.
<CodeBlock code={`"""Step 1: Replace hand-written schemas with a ToolRegistry."""
import inspect
import json
from typing import Any, Callable, get_type_hints
class ToolRegistry:
"""Central registry for agent tools.
Register a function once. The registry auto-generates the
JSON schema, handles dispatch, and manages tool lifecycle.
"""
def __init__(self):
self._tools: dict[str, Callable] = {}
self._schemas: dict[str, dict] = {}
def register(self, func: Callable) -> Callable:
"""Register a tool function. Can be used as a decorator."""
name = func.__name__
self._tools[name] = func
self._schemas[name] = self._generate_schema(func)
return func
def execute(self, name: str, args: dict) -> str:
"""Execute a registered tool by name."""
if name not in self._tools:
return json.dumps({
"error": f"Unknown tool: {name}",
"available": list(self._tools.keys()),
})
try:
return self._tools[name](**args)
except TypeError as e:
return json.dumps({"error": f"Invalid arguments: {e}"})
except Exception as e:
return json.dumps({"error": f"Tool failed: {e}"})
def get_definitions(self) -> list[dict]:
"""Return tool definitions for the API call."""
return list(self._schemas.values())
def _generate_schema(self, func: Callable) -> dict:
"""Auto-generate a tool schema from function metadata."""
sig = inspect.signature(func)
hints = get_type_hints(func)
doc = inspect.getdoc(func) or ""
properties = {}
required = []
for param_name, param in sig.parameters.items():
prop: dict[str, Any] = {}
hint = hints.get(param_name)
# Map Python types to JSON Schema types
if hint == str:
prop["type"] = "string"
elif hint == int:
prop["type"] = "integer"
elif hint == bool:
prop["type"] = "boolean"
elif hint == float:
prop["type"] = "number"
else:
prop["type"] = "string" # safe fallback
if param.default is inspect.Parameter.empty:
required.append(param_name)
else:
prop["default"] = param.default
properties[param_name] = prop
return {
"name": func.__name__,
"description": doc,
"input_schema": {
"type": "object",
"properties": properties,
"required": required,
},
}
`} language="python" filename="tool_registry.py" />
Now registering tools is a one-line operation. No more hand-written schemas that can drift from the implementation.
<CodeBlock code={`"""Register the research agent's tools with the registry."""
import json
import datetime
registry = ToolRegistry()
@registry.register
def search_web(query: str, max_results: int = 3) -> str:
"""Search the web for information on a topic. Use as the
first step when researching a new question."""
simulated = [
{
"title": f"Result for: {query}",
"url": f"https://example.com/{query.replace(' ', '-')[:40]}",
"snippet": f"Overview of {query}.",
}
for i in range(max_results)
]
return json.dumps({"results": simulated})
@registry.register
def read_webpage(url: str) -> str:
"""Fetch and return the text content of a webpage. Use after
search_web to read promising results."""
content = f"Content from {url}. Key findings about the topic..."
return json.dumps({"url": url, "content": content[:2000]})
@registry.register
def get_current_date() -> str:
"""Get today's date for time-sensitive research queries."""
today = datetime.date.today()
return json.dumps({"date": today.isoformat()})
@registry.register
def save_report(title: str, summary: str,
sections: str, sources: str) -> str:
"""Save a structured research report when you have gathered
enough information to answer the question."""
filename = title.lower().replace(" ", "_")[:50] + ".md"
return json.dumps({"filename": filename, "char_count": 1200})
# The agent loop now uses the registry:
# tools=registry.get_definitions()
# result = registry.execute(block.name, block.input)
`} language="python" filename="register_tools.py" />
What changed: the 80+ lines of hand-written tool schemas are gone. The docstring *is* the description. The function signature *is* the schema. Adding a new tool means writing a function and adding `@registry.register`. Removing a tool means deleting the function. The dispatch table and schema list are always in sync because they are generated from the same source.
## Step 2: Add Middleware for Logging and Cost Tracking
The second refactoring replaces scattered `print()` statements with a **middleware pipeline**. From lesson 03, you know that middleware intercepts every tool call, letting you add cross-cutting behavior in one place.
<CodeBlock code={`"""Step 2: Add middleware pipeline for logging and cost tracking."""
import time
from dataclasses import dataclass, field
@dataclass
class AgentMetrics:
"""Tracks cost and usage across an agent run."""
total_input_tokens: int = 0
total_output_tokens: int = 0
tool_calls: int = 0
tool_errors: int = 0
tool_durations: list[float] = field(default_factory=list)
estimated_cost_usd: float = 0.0
def record_api_call(self, usage):
"""Record token usage from an API response."""
self.total_input_tokens += usage.input_tokens
self.total_output_tokens += usage.output_tokens
# Pricing for claude-sonnet-4-6 (per million tokens)
self.estimated_cost_usd += (
usage.input_tokens * 3.0 / 1_000_000
+ usage.output_tokens * 15.0 / 1_000_000
)
def summary(self) -> str:
avg_ms = (
sum(self.tool_durations) / len(self.tool_durations) * 1000
if self.tool_durations else 0
)
return (
f"Tokens: {self.total_input_tokens:,} in / "
f"{self.total_output_tokens:,} out | "
f"Tools: {self.tool_calls} calls, "
f"{self.tool_errors} errors, "
f"{avg_ms:.0f}ms avg | "
f"Cost: ${self.estimated_cost_usd:.4f}"
)
def logging_middleware(registry: ToolRegistry,
metrics: AgentMetrics):
"""Wrap the registry's execute method with logging."""
original_execute = registry.execute
def wrapped_execute(name: str, args: dict) -> str:
metrics.tool_calls += 1
print(f" [tool] {name}("
f"{json.dumps(args, default=str)[:80]}...)")
start = time.time()
result = original_execute(name, args)
duration = time.time() - start
metrics.tool_durations.append(duration)
# Check for errors in the result
try:
parsed = json.loads(result)
if "error" in parsed:
metrics.tool_errors += 1
print(f" [error] {parsed['error']}")
except (json.JSONDecodeError, TypeError):
pass
print(f" [done] {duration*1000:.0f}ms | "
f"result: {result[:100]}...")
return result
registry.execute = wrapped_execute
`} language="python" filename="middleware.py" />
Notice the pattern: the middleware wraps `registry.execute` without modifying the registry class itself. This is the **decorator pattern** applied at the method level. You can stack multiple middleware -- logging, cost tracking, rate limiting -- and each one is a standalone function.
Here is how the agent loop changes with middleware applied:
<CodeBlock code={`"""Agent loop with registry + middleware."""
import anthropic
client = anthropic.Anthropic()
MODEL = "claude-sonnet-4-6"
SYSTEM_PROMPT = """You are a research assistant. Search the web,
read sources, and produce a structured report.
Process: (1) get today's date, (2) search, (3) read 2-3 pages,
(4) refine search if needed, (5) save report, (6) summarize.
Rules: max 5 pages, max 12 tool calls, do not fabricate."""
def run_agent_v2(question: str) -> str:
"""Agent loop with ToolRegistry and middleware."""
# Set up registry and middleware
metrics = AgentMetrics()
logging_middleware(registry, metrics)
messages = [{"role": "user", "content": question}]
print(f"\\nResearch: {question}")
print("=" * 60)
for iteration in range(15):
print(f"\\n--- Iteration {iteration + 1} ---")
response = client.messages.create(
model=MODEL,
max_tokens=4096,
system=SYSTEM_PROMPT,
tools=registry.get_definitions(), # From registry
messages=messages,
)
metrics.record_api_call(response.usage)
if response.stop_reason == "end_turn":
print(f"\\n{metrics.summary()}") # Final metrics
for block in response.content:
if block.type == "text":
return block.text
if response.stop_reason == "tool_use":
messages.append({
"role": "assistant",
"content": response.content,
})
tool_results = []
for block in response.content:
if block.type == "tool_use":
# Middleware handles logging + metrics
result = registry.execute(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": result,
})
messages.append({"role": "user", "content": tool_results})
print(f"\\n{metrics.summary()}")
return "Max iterations reached."
`} language="python" filename="agent_v2.py" />
What changed: all logging now flows through one middleware function. The agent loop itself has zero `print()` statements for tool tracking. Adding cost tracking required zero changes to the agent loop -- we just added `metrics.record_api_call()` where we already had the response object. To add rate limiting, you would write another middleware function and apply it -- one line of setup, no loop changes.
## Step 3: Add Context Management
The third refactoring addresses a problem you cannot see in a short demo but that kills long-running agents: **unbounded context growth**. From lesson 04, you know that every iteration adds both an assistant message and tool results to the `messages` list. After enough iterations, you exceed the model's context window and the API call fails.
<CodeBlock code={`"""Step 3: Add context management to prevent window overflow."""
import anthropic
class ContextManager:
"""Manages the conversation history to stay within token limits.
Strategy: when the message list exceeds a threshold, summarize
older messages and replace them with a compact summary. Recent
messages are kept intact so the agent has full detail for its
current step.
"""
def __init__(self, client: anthropic.Anthropic,
model: str,
max_messages: int = 20,
keep_recent: int = 6):
self.client = client
self.model = model
self.max_messages = max_messages
self.keep_recent = keep_recent
def maybe_compress(self, messages: list[dict]) -> list[dict]:
"""Compress the message history if it exceeds the limit.
Returns a new message list with old messages summarized.
"""
if len(messages) <= self.max_messages:
return messages # No compression needed
# Split into old messages and recent messages
boundary = len(messages) - self.keep_recent
old_messages = messages[:boundary]
recent_messages = messages[boundary:]
# Summarize the old messages
summary = self._summarize(old_messages)
# Build new message list: summary + recent
compressed = [
{
"role": "user",
"content": (
f"[Context summary of previous {len(old_messages)} "
f"messages]\\n\\n{summary}\\n\\n"
"[End of summary. Continue from here.]"
),
},
{
"role": "assistant",
"content": "Understood. I will continue the research "
"using the context above.",
},
] + recent_messages
print(f" [context] Compressed {len(messages)} messages "
f"-> {len(compressed)} messages")
return compressed
def _summarize(self, messages: list[dict]) -> str:
"""Use the LLM to summarize older conversation history."""
# Build a text representation of the old messages
parts = []
for msg in messages:
role = msg["role"]
content = msg.get("content", "")
if isinstance(content, str):
parts.append(f"{role}: {content[:300]}")
elif isinstance(content, list):
for block in content:
if isinstance(block, dict):
if block.get("type") == "tool_result":
parts.append(
f"tool_result: "
f"{str(block.get('content', ''))[:200]}"
)
elif hasattr(block, "type"):
if block.type == "text":
parts.append(f"assistant: {block.text[:200]}")
elif block.type == "tool_use":
parts.append(
f"tool_call: {block.name}"
f"({json.dumps(block.input)[:100]})"
)
history_text = "\\n".join(parts)
response = self.client.messages.create(
model=self.model,
max_tokens=500,
messages=[{
"role": "user",
"content": (
"Summarize this agent conversation history. "
"Include: the research question, tools called, "
"key findings so far, and sources found. Be "
"concise but preserve all factual details.\\n\\n"
f"{history_text}"
),
}],
)
return response.content[0].text
`} language="python" filename="context_manager.py" />
Integrating this into the agent loop requires exactly two lines:
<CodeBlock code={`# In the agent loop, after creating the client:
context_mgr = ContextManager(client, MODEL, max_messages=20)
# Inside the loop, before each API call:
messages = context_mgr.maybe_compress(messages)
`} language="python" filename="integration_context.py" />
What changed: the `messages` list can no longer grow without bound. When it hits 20 messages, the older ones are summarized and replaced. The agent retains full detail for its recent actions and a compact summary of everything before. The summarization costs one extra API call, but that is far cheaper than failing because you exceeded the context window -- or worse, degrading response quality because the model is drowning in old, irrelevant tool results.
## Step 4: Add Checkpointing
The fourth and final refactoring makes the agent **resumable**. From lesson 06, you know that checkpointing saves the agent's state after each meaningful step, so a crash at iteration 10 does not mean starting over from iteration 1.
<CodeBlock code={`"""Step 4: Add checkpointing for crash recovery."""
import json
import os
import hashlib
from dataclasses import dataclass, asdict
@dataclass
class Checkpoint:
"""Snapshot of agent state at a given iteration."""
question: str
iteration: int
messages_json: str # Serialized message history
metrics_snapshot: dict
status: str # "running", "completed", "failed"
def save(self, directory: str = ".checkpoints") -> str:
"""Save checkpoint to disk."""
os.makedirs(directory, exist_ok=True)
# Deterministic filename from the question
q_hash = hashlib.sha256(
self.question.encode()
).hexdigest()[:12]
path = os.path.join(
directory,
f"research_{q_hash}_iter{self.iteration:03d}.json"
)
with open(path, "w") as f:
json.dump(asdict(self), f, indent=2, default=str)
print(f" [checkpoint] Saved iteration {self.iteration} "
f"-> {path}")
return path
@classmethod
def load_latest(cls, question: str,
directory: str = ".checkpoints"
) -> "Checkpoint | None":
"""Load the most recent checkpoint for a question."""
if not os.path.exists(directory):
return None
q_hash = hashlib.sha256(
question.encode()
).hexdigest()[:12]
prefix = f"research_{q_hash}_iter"
# Find all checkpoints for this question
candidates = sorted([
f for f in os.listdir(directory)
if f.startswith(prefix) and f.endswith(".json")
])
if not candidates:
return None
# Load the latest one
path = os.path.join(directory, candidates[-1])
with open(path) as f:
data = json.load(f)
print(f" [checkpoint] Resuming from iteration "
f"{data['iteration']} ({path})")
return cls(**data)
def serialize_messages(messages: list[dict]) -> str:
"""Serialize message history to JSON for checkpointing.
Handles Anthropic content blocks by converting them to dicts.
"""
serializable = []
for msg in messages:
content = msg.get("content", "")
if isinstance(content, list):
# Convert content blocks to serializable dicts
blocks = []
for block in content:
if hasattr(block, "type"):
# Anthropic ContentBlock object
if block.type == "text":
blocks.append({
"type": "text", "text": block.text
})
elif block.type == "tool_use":
blocks.append({
"type": "tool_use",
"id": block.id,
"name": block.name,
"input": block.input,
})
else:
blocks.append(block)
serializable.append({
"role": msg["role"], "content": blocks
})
else:
serializable.append(msg)
return json.dumps(serializable, default=str)
`} language="python" filename="checkpointing.py" />
## The Complete Refactored Agent
Here is the full agent loop with all four patterns applied. Compare its structure to the Module 3 version -- every cross-cutting concern has its own home.
<CodeBlock code={`"""Research Agent v3 -- fully refactored with design patterns.
Patterns applied:
1. ToolRegistry -- dynamic tool management
2. Middleware -- logging and cost tracking
3. ContextManager -- prevents context overflow
4. Checkpointing -- crash recovery
Usage:
python research_agent_v3.py
# If it crashes, run again -- it resumes from checkpoint.
"""
import json
import anthropic
# --- Configuration ---
MODEL = "claude-sonnet-4-6"
MAX_ITERATIONS = 15
SYSTEM_PROMPT = """You are a research assistant. Search the web,
read sources, and produce a structured report.
Process: (1) get date, (2) search, (3) read 2-3 pages,
(4) refine if needed, (5) save report, (6) summarize.
Rules: max 5 pages, max 12 tool calls, never fabricate."""
client = anthropic.Anthropic()
def run_agent_v3(question: str) -> str:
"""Production-ready agent loop with all four patterns."""
# --- Pattern 1: Tool Registry ---
# (tools registered via @registry.register above)
tool_defs = registry.get_definitions()
# --- Pattern 2: Middleware ---
metrics = AgentMetrics()
logging_middleware(registry, metrics)
# --- Pattern 3: Context Manager ---
context_mgr = ContextManager(
client, MODEL, max_messages=20, keep_recent=6
)
# --- Pattern 4: Checkpoint Recovery ---
checkpoint = Checkpoint.load_latest(question)
if checkpoint and checkpoint.status == "running":
messages = json.loads(checkpoint.messages_json)
start_iteration = checkpoint.iteration + 1
print(f"Resuming from iteration {start_iteration}")
else:
messages = [{"role": "user", "content": question}]
start_iteration = 1
print(f"\\nResearch: {question}")
print("=" * 60)
for iteration in range(start_iteration, MAX_ITERATIONS + 1):
print(f"\\n--- Iteration {iteration} ---")
# Context management: compress if needed
messages = context_mgr.maybe_compress(messages)
# LLM call
response = client.messages.create(
model=MODEL,
max_tokens=4096,
system=SYSTEM_PROMPT,
tools=tool_defs,
messages=messages,
)
metrics.record_api_call(response.usage)
# Terminal condition
if response.stop_reason == "end_turn":
Checkpoint(
question=question,
iteration=iteration,
messages_json=serialize_messages(messages),
metrics_snapshot={"summary": metrics.summary()},
status="completed",
).save()
print(f"\\n{metrics.summary()}")
for block in response.content:
if block.type == "text":
return block.text
# Tool execution through registry + middleware
if response.stop_reason == "tool_use":
messages.append({
"role": "assistant",
"content": response.content,
})
tool_results = []
for block in response.content:
if block.type == "tool_use":
result = registry.execute(
block.name, block.input
)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": result,
})
messages.append({
"role": "user", "content": tool_results
})
# Checkpoint after each iteration
Checkpoint(
question=question,
iteration=iteration,
messages_json=serialize_messages(messages),
metrics_snapshot={"summary": metrics.summary()},
status="running",
).save()
print(f"\\n{metrics.summary()}")
return "Max iterations reached."
if __name__ == "__main__":
answer = run_agent_v3(
"What are the main approaches to reducing "
"hallucinations in large language models?"
)
print(f"\\nFinal answer: {answer[:300]}...")
`} language="python" filename="research_agent_v3.py" />
## What Each Pattern Bought You
Let's trace how the refactored agent handles the same change requests that broke the monolithic version in lesson 01:
**"Add a new tool."** Write a function, add `@registry.register`. The schema is auto-generated. The middleware already intercepts it. The checkpoint serializer already handles it. One line of code, zero changes elsewhere.
**"Add logging to every tool call."** Already done. The logging middleware intercepts every call through `registry.execute`. To change the log format, edit one function.
**"Track costs."** Already done. The `AgentMetrics` dataclass accumulates token usage from every API call. The `metrics.summary()` prints totals at the end.
**"Handle long conversations."** Already done. The `ContextManager` compresses old messages before they overflow the context window. The agent never crashes from token limits.
**"Resume after a crash."** Already done. Run the same command again. The `Checkpoint.load_latest()` finds the last saved state and continues from there. No work is lost.
Each pattern solved exactly one concern. Together, they transformed a fragile script into a system that can evolve.
## The Interaction Between Patterns
These four patterns are not just independent additions -- they **reinforce each other**. Here is how they interact at runtime:
<MermaidDiagram chart={`sequenceDiagram
participant Loop as Agent Loop
participant Ctx as ContextManager
participant LLM as LLM API
participant MW as Middleware
participant Reg as ToolRegistry
participant Chk as Checkpoint
Note over Loop: Start iteration N
Loop->>Ctx: maybe_compress(messages)
Ctx-->>Loop: compressed messages
Loop->>LLM: messages.create(tools, messages)
LLM-->>Loop: response (tool_use)
Note over Loop: Record token usage in metrics
loop For each tool call
Loop->>MW: execute(name, args)
MW->>MW: Log call, start timer
MW->>Reg: original_execute(name, args)
Reg-->>MW: result
MW->>MW: Log result, record duration
MW-->>Loop: result
end
Loop->>Chk: save(iteration, messages, metrics)
Note over Loop: Next iteration
The sequence matters: context management happens before the LLM call (so the model sees a clean, right-sized history), middleware wraps around tool execution (so every call is logged and timed), and checkpointing happens after tool results are collected (so the saved state includes the latest results). This ordering is not accidental -- it follows the data flow of the agent loop.
What Is Still Missing
The refactored agent is well-structured, observable, and resilient. But run it twice with the same question and notice something: the second run starts from scratch. It does not remember that it already researched this topic yesterday. It cannot build on previous findings. It cannot learn from past mistakes.
This is not a pattern problem -- it is a memory problem. Our agent has:
- Working memory (the
messageslist) -- but it is scoped to a single run and gets summarized away - Checkpoint state (the
.checkpoints/directory) -- but that is for crash recovery, not knowledge retention - No long-term memory -- no way to store and retrieve knowledge across sessions
Consider what a memory-equipped research agent could do:
- "I researched LLM hallucinations last week. Let me check what I already know before searching again."
- "The last time I used that URL, the content was outdated. Let me try a different source."
- "The user asked about RAG three times this month. They probably care about this topic -- I should track new developments."
This is the gap between a well-engineered tool and a truly intelligent assistant. Design patterns gave us the structure. Memory gives us the continuity.
Key insight: Design patterns make your agent's code maintainable and resilient. Memory makes your agent's behavior intelligent across sessions. You need both.
Summary
You refactored the Module 3 research agent in four steps, each applying one design pattern from this module:
- Tool Registry replaced hand-written schemas and dispatch tables with auto-generated definitions from function metadata. Adding or removing a tool is now a one-line change.
- Middleware replaced scattered
print()statements with a centralized interception layer that handles logging and cost tracking. Cross-cutting concerns live in one place. - Context Management prevents the message list from growing unbounded by summarizing older messages when the conversation exceeds a threshold. Long-running agents no longer crash from context overflow.
- Checkpointing saves agent state after each iteration, enabling crash recovery by resuming from the last saved checkpoint. No work is lost.
These patterns are composable -- each one solves a single concern, and they reinforce each other when combined. The Tool Registry gives middleware a single interception point. The Context Manager keeps checkpoints small. The middleware metrics tell you whether context compression is being triggered too often.
The refactored agent is maintainable, observable, and resilient. But it forgets everything between sessions. In Module 7: Memory and Knowledge Management, you will give your agent the ability to store, retrieve, and reason over information across runs -- turning a well-structured tool into a system that learns.