Build real AI agents
LLM vs agent; loops; stop conditions
Define an agent as an LLM plus a loop over tools/state with stop conditions
Learning objectives
- Define an agent as an LLM plus a loop over tools/state with stop conditions
- Implement a hand-rolled ReAct-style loop without a heavy framework
- Log a trajectory you can replay when debugging
LLM ≠ agent
A language model maps context → next tokens. One call, one completion. An agent wraps the model in a loop that observes state, chooses actions (often tool calls), executes them, feeds results back, and repeats until stop conditions fire.
while not stopped:
model decides: tool_call OR final_answer
if tool_call: execute, append result to messages
if final_answer: returnReAct (Reason + Act) interleaves natural-language reasoning with tool invocations — "I need the population of France → call search → 67M → answer user." Frameworks (LangGraph, smolagents, CrewAI) package this pattern; this course requires hand-rolling first so you know what frameworks hide.
Callout — frameworks second: If you cannot implement max_steps and trajectory logging yourself, LangGraph debug sessions will feel like magic tricks.
Minimum agent components
- Chat model — hosted API or local OpenAI-compatible server.
- Tool registry — name → schema → Python callable.
- Loop driver — your code, not the model, controls iteration.
- Message log — append assistant tool calls and tool role results (format varies by provider).
- Stop policy — explicit termination rules.
The model proposes; your driver disposes. Never let the model run shell commands without your executor reviewing schema and bounds.
ReAct loop sketch
MAX_STEPS = 8
messages = [system, user_query]
for step in range(MAX_STEPS):
response = llm.chat(messages, tools=tool_schemas)
if response.tool_calls:
for call in response.tool_calls:
result = execute_tool(call.name, call.arguments)
messages.append(tool_result_message(call.id, result))
log_trajectory(step, response, result)
continue
if response.final_text:
return response.final_text
# fallback: nudge model to act or finish
raise MaxStepsExceeded()Adapt message shapes to OpenAI tools vs Anthropic tool_use — keep driver logic identical.
Stop conditions matter more than clever prompts
Unbounded loops cause infinite search spam, retry storms, and surprise bills. Minimum production set:
| Condition | Purpose |
|---|---|
max_steps |
Hard cap on iterations |
final_answer tool or tag |
Explicit completion signal |
| Tool error budget | Stop after N consecutive tool failures |
| Timeout wall clock | Kill runaway latency |
| Token/cost budget | Financial circuit breaker |
Optional (lesson 7.5): human approval before mutating tools.
Demonstrate max_steps deliberately: prompt that triggers repeated useless tool calls; log graceful failure message.
Callout — stop is not failure: Hitting max_steps with a clear "could not complete" response beats hallucinating an answer.
Trajectory logging
Write JSONL per run:
{"step": 1, "thought": "...", "tool": "calculator", "args": {"expr": "2+2"}, "result": "4", "latency_ms": 120}
{"step": 2, "final": "The answer is 4."}Store under m7/react_min/runs/. Trajectories power lesson 7.6 replay debugging and later eval harnesses.
Include: model name, prompt version, timestamps, full raw assistant message (redact secrets).
Toy tools for learning
Start with deterministic tools:
- Calculator — safe eval of arithmetic AST, not raw
eval(). - Dictionary lookup — static JSON map.
- get_current_time — returns ISO timestamp (timezone explicit).
Avoid network tools until schema validation lesson (7.2). Keeps failures interpretable.
Industry preview
Production agents (Shopify Sidekick-class, Uber agent platforms) differ from demos via identity, gateway guardrails, eval loops, and durable execution — covered in industry labs and later modules. Your hand-rolled loop is the kernel those systems wrap.
Parsing tool calls reliably
When native tool APIs unavailable, structured output fallback:
Reply with JSON: {"action": "tool"|"final", "name": "...", "args": {...}, "answer": "..."}Validate with Pydantic before execute — never eval() user-facing model JSON.
For providers with parallel tool calls, your driver should handle multiple calls in one assistant turn sequentially or concurrently based on side-effect rules (read-only parallel OK; mutating sequential default).
Observability from day one
Emit structured events:
{"event": "tool_start", "name": "calculator", "step": 2}
{"event": "tool_end", "name": "calculator", "ok": true, "ms": 4}These become spans in production tracing — start habit now in JSONL trajectories.
Cost and token budgeting per run
Agents multiply LLM calls. Track per run:
- Total input/output tokens across steps
- Tool count by name
- Estimated USD if using hosted API
Set max_cost_usd=0.50 circuit breaker for dev demos — prevents overnight runaway loops left attached to cron.
Compare token use reactive vs planner on same task suite — planners often use more upfront tokens for plan JSON but fewer wasted tool calls.
Common beginner mistakes
- Putting loop inside prompt ("repeat until done") — model does not control loop.
- No logging — impossible to debug wrong tool args.
- Parsing tool calls from free text with regex — use native tool APIs when available.
- Same max_steps for read vs write workflows — tune separately later.
Engineering problem (staff framing)
An agent is an LLM inside a controlled loop with tools and stop conditions — not a longer prompt.
Diagram — ReAct control loop
flowchart TD
Start([User goal]) --> Think[Model: reason / act]
Think -->|tool_call| Exec[Execute tool]
Exec --> Obs[Append observation]
Obs --> Think
Think -->|final| Done([Answer])
Think -->|max_steps| Fail([Stop / escalate])
Precise definitions & mental model
ReAct, trajectory, stop policy, driver vs model authority.
Tradeoffs — when to use what
Single-shot LLM (cheap) vs agent (capable, cost/risk ↑).
Failure modes (interview + on-call)
No max_steps; tool loops; unlogged trajectories.
Production & OSS practices
Hard budgets (steps/tokens/time); structured trace export; idempotent tools.
Interview cue card
Implement stop conditions for a web-research agent that must not exceed $0.50/request.
Deep dive (FAANG / OSS bar)
Control authority
The driver owns: timeouts, budgets, schema validation, which tools exist, and when to stop. The model owns: proposals. Inverting that (model freely executes shell) is how you get security incidents.
Trajectory schema (minimum)
{
"request_id": "...",
"steps": [
{"type": "llm", "tool_calls": [], "latency_ms": 0, "tokens": {}},
{"type": "tool", "name": "search", "ok": true, "latency_ms": 0}
],
"stop_reason": "final|max_steps|budget|error"
}Store this even in the hand-rolled lab — it becomes your Build real AI agents / Evals, guardrails, safety / Deploy, cost, latency, observability spine.
Diagram — stop policy decision
flowchart TD
Step --> C1{steps > max?}
C1 -->|yes| Stop1[stop max_steps]
C1 -->|no| C2{tool errors > N?}
C2 -->|yes| Stop2[stop tool_budget]
C2 -->|no| C3{final?}
C3 -->|yes| Done
C3 -->|no| Step
Micro-project: Hand-rolled ReAct
In m7/react_min/:
- Loop with ≥1 toy tool and final-answer stop (tool or plain assistant message policy — document choice).
- Hosted or local chat model with tool calling support.
- Trajectories to
runs/*.jsonl. - README: successful tool run + max_steps exhaustion example.
No framework dependency for this lesson.
Checklist
- Trajectory logs readable and complete
- max_steps enforced with user-visible outcome
- Tool execution outside model weights
- README shows successful and bounded-failure runs
ShipAI delivery model is: