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

65 min1/7 in module

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: return

ReAct (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

  1. Chat model — hosted API or local OpenAI-compatible server.
  2. Tool registry — name → schema → Python callable.
  3. Loop driver — your code, not the model, controls iteration.
  4. Message log — append assistant tool calls and tool role results (format varies by provider).
  5. 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/:

  1. Loop with ≥1 toy tool and final-answer stop (tool or plain assistant message policy — document choice).
  2. Hosted or local chat model with tool calling support.
  3. Trajectories to runs/*.jsonl.
  4. 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
Project checklist0/3 done

ShipAI delivery model is: