Module 11 — Single-Agent Workflows¶
Time: 7–10 days · Depends on: 03–05, 07 · Next: Multi-agent
Learning objectives¶
By the end of this module you will be able to:
- Implement a plan–act–observe loop with hard stops
- Constrain the model to an allowlisted tool surface and structured decisions
- Add reflection and tool-error recovery without unbounded cost
- Make agents observable and testable (replayable step logs)
- Prefer understanding the loop before adopting LangGraph or similar frameworks
Why this matters (CS engineer)¶
Overnight, a “helpful research agent” leaves 400+ tool calls in the logs: same search query, same empty hits, same optimistic retry. No max_steps. No repeated-signature abort. Morning bill: four figures for zero tickets closed. The demo had a charming persona and a ReAct prompt. It did not have a state machine with circuit breakers. Personality does not terminate; code does.
An “agent” is not a personality. It is a state machine that repeatedly:
- Chooses an action (tool call, final answer, ask user)
- Executes that action in your runtime
- Observes the result
- Updates state until a terminal condition
If you skip the systems view, you get:
- Infinite tool loops
- Unbounded spend (Module 10)
- Non-reproducible failures (“it just did something weird”)
- Tools that escape the allowlist
Frameworks (LangGraph, etc.) are useful after you can write the loop, name the state, and test stop conditions. Otherwise you debug the framework instead of the policy.
Mental model¶
stateDiagram-v2
[*] --> Decide: goal
Decide --> ActTool: type=tool
Decide --> Final: type=final
Decide --> AskUser: type=ask_user
Decide --> Abort: bad JSON / unknown type
ActTool --> Observe: run allowlisted tool
Observe --> Decide: append scratchpad
Observe --> Abort: repeated signature
Final --> [*]
AskUser --> [*]
Abort --> [*]
Decide --> Abort: max_steps
State is explicit: goal, scratchpad, steps[], done, result, abort_reason.
Policy is the LLM (or rules) that emits the next decision.
Runtime is your code: tools, limits, logging.
Intuition lock
Sticky picture: An agent is a state machine, not a personality. max_steps is a circuit breaker. Tool allowlists are capability tokens — if the name isn’t in the bag, it doesn’t run, no matter how confident the model sounds.
Kill this idea: “Agents are autonomous coworkers; give them freedom and they’ll figure it out.” → Replace with: Bounded decide→act→observe with allowlisted tools, hard stops, structured decisions, and replayable logs.
Core tutorial¶
1. The core loop (no framework)¶
goal
→ plan (optional)
→ select action (tool | respond | ask_user)
→ observe result
→ update state
→ until done | max_steps | abort
Minimal decision protocol (JSON only):
{"type": "tool", "name": "search_notes", "args": {"q": "refund policy"}}
{"type": "final", "content": "The refund window is 30 days."}
{"type": "ask_user", "content": "Which order id?"}
Why JSON decisions?
- Parsable in tests
- Easy to log and replay
- Harder (not impossible) for the model to invent free-form shell commands
2. Course agent: src.agents.Agent¶
This repo implements a teaching agent with hard stops. Read it as a spec, not as production middleware.
import json
from src.agents import Agent
def llm(prompt: str) -> str:
# stub: real systems call a model with structured outputs
if "Scratchpad:" in prompt and "Tool" not in prompt.split("Scratchpad:")[-1]:
return json.dumps({"type": "tool", "name": "echo", "args": {"text": "hi"}})
return json.dumps({"type": "final", "content": "hi"})
agent = Agent(llm=llm, tools={"echo": lambda text: text}, max_steps=5)
state = agent.run("echo hi then finish")
assert state.result == "hi"
assert state.done
What Agent enforces for you:
| Control | Behavior |
|---|---|
max_steps |
Stops with abort_reason="max_steps" |
| Allowlisted tools | Unknown name → observation error string, not exception death |
| Repeated tool signature | Same name+args twice → abort repeated_tool_call |
| Bad JSON decision | Abort bad_decision |
| Tool exceptions | Surfaced as error: ... in the scratchpad |
Explainer
The scratchpad is working memory: a append-only log of tool observations the model sees on the next decide step. It is not a database. Cap what you append (the teaching agent truncates observations to 2000 chars) so one huge tool dump cannot blow the context window.
3. Plan–act–observe in more detail¶
Decide¶
Prompt ingredients:
- Goal (immutable)
- Scratchpad / recent observations
- Tool catalog (names, schemas, one-line descriptions)
- Output contract (JSON types)
- Remaining step budget (optional but helpful)
Act¶
Your runtime executes tools — never trust model text as code.
def run_tool(tools: dict, name: str, args: dict) -> str:
if name not in tools:
return f"error: unknown tool {name}"
# validate args against JSON Schema in production
try:
return str(tools[name](**args))
except Exception as e:
return f"error: {e}"
Observe¶
Write structured observations back:
Prefer structured tool returns (json.dumps) so later steps can parse — but still treat them as untrusted text in the prompt (Module 02).
4. Stop conditions (non-negotiable)¶
| Condition | Why |
|---|---|
max_steps |
Prevent infinite loops |
| Token / $ budget | Economic safety (Module 10) |
| Repeated tool signature | Detect thrashing |
| User gate | Irreversible or high-impact actions |
| Validation pass | Structured success criteria met |
| Wall-clock timeout | Hung tools |
# Product pattern beyond the teaching agent
if steps >= max_steps or spend >= budget or time.time() > deadline:
return abort("limit")
if tool_sig in seen:
return abort("thrash")
if needs_approval(tool_name):
return wait_for_human()
Think about it
Question: Your agent has max_steps=20 and no repeated-call detection. It calls search with the same query 20 times and fails. What two code changes fix the class of bug, not just this instance?
Reveal a strong answer
(1) Abort on repeated tool signature (name + canonicalized args) after 1–2 identical calls. (2) Lower max_steps for search-only goals and/or require the model to change the query after empty hits (encode “if empty, reformulate or final with I don’t know” in the decision prompt). Bonus: cache tool results so even retries are cheap and log the thrash for evals.5. Planning patterns¶
For multi-step goals, externalize the plan into state — do not leave it only inside free-form CoT.
# Plan as data
plan = [
{"id": 1, "step": "Find refund policy chunk", "status": "pending"},
{"id": 2, "step": "Find order date for user", "status": "pending"},
{"id": 3, "step": "Compare and answer", "status": "pending"},
]
Loop:
- Produce short checklist (JSON)
- Execute current pending item
- Mark complete / failed in state
- Re-plan only when blocked (cap re-plans)
Do not replan every step by default — that doubles cost and causes oscillation.
6. Reflection (bounded)¶
After a draft answer:
Critique the draft against the goal.
List missing evidence or contradictions.
If gaps are material, return {"type":"tool",...} else {"type":"final",...}.
Rules:
- Cap to 1–2 reflection passes
- Reflect only when stakes or uncertainty are high
- Never reflect in an unbounded while-loop
Reflection is a product choice, not a default for autocomplete.
Explainer
Reflection is another edge in the state machine, not a vibe of self-awareness. Encode it as: if validate(draft) fails and reflect_count < 2, append critique notes and decide again; else final or abort. Without a counter, “think harder” becomes an infinite loop with better prose. Use it where mistakes are expensive; skip it for autocomplete and classification.
7. Tool-use discipline¶
| Practice | Reason |
|---|---|
| Small tool surface | Fewer wrong picks |
| Typed args + schema validation | Fail closed on garbage |
| Idempotent tools when possible | Safe retries |
| Explicit errors in observations | Model can recover |
| Timeouts and size limits | Protect context and latency |
| No raw shell without sandbox | Security |
Tool design tip: one tool = one clear side effect. Avoid god-tools like do_anything(command: str).
Think about it
Question: Product wants one tool: run(cmd: str) so the agent can “do anything in the shell.” You push back with capability tokens. What’s the concrete redesign?
Reveal a strong answer
Split into **narrow allowlisted tools**: `read_file(path)`, `list_dir(path)`, `run_tests()`, `git_diff()` — each with path sandboxes, timeouts, and arg schemas. Destructive ones (`git_push`, `rm`) require human approval. The model still *proposes* which tool; the runtime maps names to fixed code paths. One free-form shell string is an unbounded capability token and an injection magnet.8. Recovery from tool failure¶
observe error → model may:
- retry with fixed args (different signature)
- switch tool
- ask_user
- final with partial answer / failure explanation
Your job:
- Make errors informative (
error: timeout after 5s, notError) - Stop identical retries
- Count failures toward max_steps (they already should)
# Observation the model can use
"error: unknown tool nope"
"error: missing required arg 'order_id'"
"error: rate limited; retry after 30s"
9. Observability and replay¶
Log per step:
| Field | Example |
|---|---|
| timestamp | ISO time |
| step index | 0..n |
| decision type | tool / final / ask_user |
| tool name + arg hash | search:sha256... |
| latency_ms | tool and model separately |
| tokens / $ | Module 10 |
| success / error | bool + code |
| scratchpad length | detect bloat |
If you cannot replay a failure from logs + stored decisions, you cannot improve the agent systematically.
def log_step(state, decision, obs_meta: dict) -> None:
print({
"goal": state.goal[:80],
"decision": decision,
"meta": obs_meta,
"steps": len(state.steps),
})
In production: OpenTelemetry spans, Langfuse/Phoenix traces, or your APM — same fields.
10. Testing agents¶
Unit-test the runtime, not the prose of a live model:
def test_max_steps():
def llm(_):
return json.dumps({"type": "tool", "name": "inc", "args": {"n_arg": 1}})
agent = Agent(llm=llm, tools={"inc": lambda n_arg=1: str(n_arg)}, max_steps=2)
state = agent.run("loop")
assert state.abort_reason == "max_steps"
Eval layer (Module 04) for stochastic success:
- Task success rate on a fixed suite
- Steps-to-success
- Tool error rate
- Budget violations == 0
Use a stub LLM that returns scripted JSON for CI; use a real model in a scheduled eval job.
11. When to reach for LangGraph (later)¶
Prefer a framework when you need:
- Complex branching graphs with shared state reducers
- Durable execution / human-in-the-loop checkpoints
- Team conventions around one orchestration library
Still keep:
- Explicit max steps / budgets
- Allowlisted tools
- Structured messages
- Tests for terminal conditions
Studying LangGraph after this module is ideal: you will map nodes onto decide/act/observe instead of cargo-culting demos. Named failure modes, sandboxes, and trajectory evals are modules 20–22; framework vs custom is 26.
Think about it
Question: Should “plan” be a free-text paragraph in the scratchpad or a structured list in agent state? Why?
Reveal a strong answer
Structured list in agent state. Free-text plans get rewritten, partially forgotten, and are hard to test (“did step 2 complete?”). A list of `{id, step, status}` lets the runtime mark progress, skip done work, and assert in tests that the agent does not re-execute completed steps. Use the LLM to *propose* plan JSON; keep the source of truth in code.12. What you can build with one agent¶
| Build | Tools | Hard stops |
|---|---|---|
| Research assistant | search, fetch_url, final | max_steps=8, domain allowlist |
| Repo refactor helper | read_file, patch, test | approval before write |
| Ticket triage | classify, lookup_customer, escalate | ask_user on low confidence |
Default to one agent + tools until a second role needs a crisp interface (Module 12).
Failure modes¶
| Symptom | Cause | Fix |
|---|---|---|
| Infinite loop | No max_steps / no thrash detect | Hard stops |
| Tool thrash | Same args retried | Signature abort + better errors |
| Context blow-up | Huge tool dumps | Truncate / summarize observations |
| Hallucinated tool args | Weak schema | Validate; return errors |
| Unreplayable bug | No step logs | Structured logging |
| $ explosion | Uncapped steps + big model | Budget + router (Module 10) |
| Silent wrong final | No success criteria | Validator before accept |
Lab¶
- Build an agent with tools:
search_notes,calculator, and final answer (typefinal). - Force a tool failure (unknown tool or bad args); ensure the agent recovers or aborts cleanly.
- Set
max_steps=5; unit-test that a noop looping tool policy terminates withmax_steps. - Add logging of each decision + observation meta.
- Optional: one reflection pass only when a
validate_answerfunction fails.
Use src.agents.Agent as the baseline; extend only if you need new abort reasons.
Quizzes¶
Quiz · +25 XP
What is the primary role of max_steps in a single-agent loop?
Quiz · +25 XP
In a safe agent architecture, who executes tools?
Quiz · +25 XP
When should you introduce LangGraph (or similar) in this course path?
Open source materials¶
| Resource | Use it for |
|---|---|
Course src/agents.py + tests/test_agents.py |
Reference loop and stop conditions |
| huggingface/agents-course | smolagents / modern agent patterns |
| humanlayer/12-factor-agents | Production control-flow principles |
| LangGraph docs | Study graphs after this module |
| DAIR.AI / prompting guide ReAct sections | Historical plan–act–observe framing |
Checkpoint¶
- Loop has a hard stop (
max_stepsor stronger) - Tools are allowlisted and executed only in your code
- Steps are logged for replay
- Repeated tool thrash is detected or impossible
- At least one unit test covers termination
Mark complete when your agent terminates under max_steps, allowlists tools, and you can replay a run from step logs.
Exercise¶
- Catalog: EX-11 — Agent loop
- Prove: The stub calls
addthenfinal; repeated identical tool calls abort in code. - Test:
pytest tests/test_agents.py -v