Welcome back to the series!
Where we left off: In Lesson 1, we built the absolute foundation of our agent: a simple while loop that calls the LLM and executes tools until the LLM decides to stop. In Lesson 2, we introduced a “Dispatch Map” to cleanly route multiple tools (like read_file and write_file) without turning our core loop into a messy chain of if/else statements.
Our agent now has hands to interact with the filesystem. But as we assign it harder tasks, a new problem emerges.
The Problem: The Agent Loses Track
When you give an LLM a multi-step task, it tends to lose track of its progress. It repeats work, skips steps, or just wanders off completely.
Long conversations make this worse. As tool results (like massive file reads or bash outputs) fill up the context window, the original system prompt and the user’s initial instructions fade away. If you ask the agent to do a 10-step refactoring, it might complete steps 1 through 3 perfectly, and then start wildly improvising because it literally forgot steps 4 through 10.
The Solution: Externalizing the Plan
The motto for this lesson is: “An agent without a plan drifts”.
Instead of letting the model plan silently in its internal “chain-of-thought,” we need to force it to write its plans down. We achieve this by building a TodoManager and giving the agent a todo tool.
This approach has three massive benefits:
- Users can see exactly what the agent intends to do before it does it.
- Developers can debug the agent’s behavior by inspecting the plan’s state.
- The agent itself can refer back to this visible plan in later turns, even when earlier context has scrolled away.
How It Works (In Code)
Step 1: The TodoManager and Guardrails
We create a class to store the to-do items and their statuses (pending, in_progress, completed). But we also add two critical constraints:
class TodoManager:
def update(self, items: list) -> str:
if len(items) > 20:
raise ValueError("Max 20 todos allowed")
validated, in_progress_count = [], 0
for item in items:
status = item.get("status", "pending")
if status == "in_progress":
in_progress_count += 1
validated.append({"id": item["id"], "text": item["text"], "status": status})
if in_progress_count > 1:
raise ValueError("Only one task can be in_progress at a time")
self.items = validated
return self.render()
Notice the guardrails:
- Max 20 items: This is a deliberate constraint against over-planning. Without a cap, models tend to decompose tasks into absurdly fine-grained, fragile 50-item plans.
- Only one
in_progressitem: If the model tries to start a second task, it must complete or abandon the current one first. LLMs handle multitasking poorly, so this sequential focus constraint improves output quality.
Step 2: Adding the Tool
Just like in Lesson 2, we add the todo tool to our TOOL_HANDLERS dictionary.
TOOL_HANDLERS = {
# ... previous tools ...
"todo": lambda **kw: TODO.update(kw["items"]),
}
Step 3: The Nag Reminder If the model gets distracted and goes 3 or more rounds without updating its to-do list, we inject a reminder directly into the tool results.
# Inside the agent_loop:
rounds_since_todo = 0 if used_todo else rounds_since_todo + 1
if rounds_since_todo >= 3:
results.insert(0, {"type": "text", "text": "<reminder>Update your todos.</reminder>"})
This “nag” creates accountability. It forces the agent to periodically pause, look at its checklist, and figure out what to do next.
The Key Insight
Constraints do not limit agents; they focus them. By forcing the agent to externalize its plan and restricting it to one active task at a time, we dramatically increase its reliability on long-running tasks.