Chapter 28

Building an agent from scratch

Agents from scratch, the engineering chapter of Part IX (Agents). From the 80/20 framing of agent engineering (LLM is 20%; everything else is 80%) through tool design principles, tool implementation patterns, schemas and structured calls (function calling, MCP), error handling and recovery (retries, circuit breakers, fallback tools), observability and trace debugging, and agent scaffolding (system prompts, tool descriptions, few-shot examples). The chapter that turns conceptual agent knowledge into working production code.

Chapter 27 covered what an agent is: an LLM acting as a controller in a loop with an environment. ReAct as the foundational pattern, AutoGPT as the cautionary tale, memory and patterns and the 20252025 stack. All conceptual. This chapter is engineering, how to take those concepts and assemble a working agent out of real code that survives real traffic. Production agents are 80%80\% engineering, 20%20\% LLM. The 20%20\% is the model and the basic loop. The 80%80\% is everything in this chapter: tools, schemas, error handling, observability, scaffolding.

The work breaks into six concerns. Tool design, atomic, single-responsibility, clear-contract functions the LLM can call effectively. Tool implementation: sync vs async, timeouts, output truncation, sandboxing for anything dangerous. Schemas, JSON Schema, OpenAI function calling, Anthropic MCP; how the LLM knows what tools exist and how to call them. Error handling, retries, circuit breakers, fallback tools, surfacing errors to the LLM. Observability, structured logging, distributed tracing, debugging from traces. Scaffolding, system prompts, tool descriptions, few-shot examples, output validation, cost budgets.

Tools should be designed so the LLM can use them effectively, not so you feel clever. A minimal ReAct agent is fifty lines; a production agent is 5005005,0005{,}000. The difference is everything in this chapter. By the end, you’ll have the operational toolkit to build agents that hold up in production.

From concept to code

The 80/20

Production agents are 80%80\% engineering on top of 20%20\% LLM. The 20%20\% is the model itself, the prompt, and the basic loop, all covered in Ch 27. The 80%80\% is everything else: tools, schemas, error handling, observability, scaffolding. Most engineers leaving a conceptual chapter on agents intuit that the LLM is the hard part. Ch 28 wants to correct that intuition. The LLM is the easy part, pick a frontier model, write a competent prompt, run a ReAct loop. The engineering around it is where the months of work live.

What goes into a real agent

The composition is short to state and long to build.

production agent  =  ReAct loop  +  tools  +  schemas  +  error handling  +  observability  +  scaffolding\text{production agent} \;=\; \text{ReAct loop} \;+\; \text{tools} \;+\; \text{schemas} \;+\; \text{error handling} \;+\; \text{observability} \;+\; \text{scaffolding}

(28.real-agent)

Each addend requires deliberate design. Skip one and the agent is fragile. Skip two and it never gets to production. The empirical anchors as of early 20252025: a minimal ReAct agent runs in ~5050 lines of Python; a production agent runs 5005005,0005{,}000. Production agents typically expose 555050 well-designed tools, not hundreds. Per-turn latency sits in the 111010 second range; total task latency for non-trivial tasks is 1010300300 seconds. Per-task cost runs \0.01$10dependingonmodelandcomplexity.Thereliabilitybarforproductiondeploymentis  depending on model and complexity. The reliability bar for production deployment is ~95%$+ task completion, anything lower and operators stop trusting the agent.

Engineering agents well means engineering with empathy for the LLM. Tools should be designed so the LLM can use them effectively. Schemas should be obvious to anyone reading them, including the model. Errors should be informative enough that the agent can plan its next move. Traces should be readable by humans debugging at 33 AM. The agent is a system; this chapter is its system design.

Tool design principles

Six principles

A tool is a function the agent can call. Designing good tools is the most important non-LLM skill in agent engineering, and the place engineers most consistently underinvest.

Single responsibility. Each tool does one thing. Avoid grab-bag utilities like a file_operations tool that reads, writes, lists, copies, and chmods. Split it: read_file, write_file, list_directory, copy_file. The LLM picks tools by name, clear names beat clever abstractions.

Atomic operations. Tools should complete fully or fail cleanly. Avoid partial-completion semantics that the LLM has to reason about. A tool that writes half a file before erroring forces the agent to model the world in finer detail than it can reliably handle.

Clear contracts. Every tool has five parts: a name in verb-noun form (read_file, search_web, send_email); a one-to-three-sentence description; parameters with types and per-parameter descriptions; a return value with predictable structure; and documented error modes. Skip any of the five and the LLM has to guess.

Idempotency where possible. Calling twice has the same effect as calling once. This is critical for retry logic. Reads are naturally idempotent; writes often aren’t. When you can’t make a tool idempotent, make the non-idempotency visible, an explicit force=True parameter or a confirmation step.

Bounded outputs. Tools should return bounded amounts of data. A search_web that returns 100100 results blows up the context window in a single call. Limit, paginate, summarize. The right output size is “enough for the LLM to make the next decision, not more.”

Informative errors. Errors should tell the agent what went wrong AND what to try next. “Connection failed” is bad; “Connection failed: timeout after 30s; retry or try a different tool” is good. The LLM uses the error text to plan recovery; vague errors lead to vague recoveries.

Design anti-patterns

Four patterns that recur in production audits and need to be designed out. Kitchen-sink tools, one tool with twenty parameters and a dozen modes; impossible to describe; impossible for the LLM to use correctly. Magic parameters: parameter names that don’t tell the LLM what they do (flag, opts, mode); the description is the only thing the model sees. Silent failures: tools that return empty strings or null on error instead of an explicit failure signal; the agent thinks the call succeeded and acts on garbage. Unbounded outputs: tools that dump entire databases into context; one call exhausts the window.

Implementing tools

Synchronous tools

The simplest implementation: a synchronous Python function that takes parameters, does its work, and returns a result. Pros: easy to write, test, and debug. Cons: blocks the agent loop; tool calls can’t run in parallel. Synchronous tools are the right default for development and for tools whose latency is small relative to LLM latency.

Async, timeouts, and truncation

For I/O-bound tools, anything that waits on a network or disk, async unlocks parallel execution. The cost is operational complexity: the agent loop has to be async-aware, debugging is harder, and error semantics get subtler. Use async when parallelism actually pays for itself; stay sync otherwise.

Timeouts on every tool, always. Infinite hangs are an anti-pattern, they kill the agent loop without producing a useful error. Every network call gets a hard timeout, usually 553030 seconds depending on the operation. Output truncation, always. Cap the size of every tool’s return value; if the underlying call produces more, truncate and tell the LLM how much was hidden. Side-effect tools with confirmation, anything that mutates state (send email, delete file, run code, charge a card) should require explicit confirmation or run sandboxed. The LLM might call them by mistake, and the cost of a mistaken side effect can be much higher than a missing answer.

Tool registries

A registry maps tool names to functions, schemas, and descriptions. Single source of truth for what tools exist. A registry pattern means you define a tool once and the OpenAI schema, the Anthropic schema, the execution wrapper, and the error handling all read from the same place.

Tool schemas and structured calls

The schema problem

The LLM does not see Python types. It sees text. Schemas tell the LLM how to call tools in a structured, parseable way. Two dominant approaches exist in 20252025: function calling (OpenAI’s API, now widely adopted across providers) and MCP (Anthropic’s open Model Context Protocol). Both produce structured JSON tool calls the developer parses and executes.

Function calling vs MCP

OpenAI function calling works as follows. The developer declares each tool with a JSON Schema giving its name, description, and parameters. The LLM’s response contains a tool_calls array with the tool name and argument JSON. The developer parses the arguments, executes the function, and feeds the result back as a tool message in the next turn. A minimal schema looks like:

{
  "type": "function",
  "function": {
    "name": "get_weather",
    "description": "Return current weather for a city.",
    "parameters": {
      "type": "object",
      "properties": {
        "city": { "type": "string", "description": "City name (e.g. 'Tokyo')" }
      },
      "required": ["city"]
    }
  }
}

Anthropic tool use is conceptually identical (schemas plus structured tool-use blocks), with explicit parallel tool calls, reasoning tokens, and slightly different message formatting. Model Context Protocol (MCP) sits a layer above function calling. MCP servers expose tools; MCP clients (LLM applications) discover and invoke them. The protocol standardizes tool discovery, authentication, schemas, and error semantics across providers. Tools become portable between LLM applications without per-application integration work.

OpenAPI integration is the third leg. Many existing REST APIs auto-convert to function-calling schemas via mature tooling. An agent can use any documented REST API with minimal glue code, point a converter at the OpenAPI spec, get tool schemas out, drop them in the registry.

Tool schema builder

Interactive
Tool schema builder
6 tools · Python → OpenAI / Anthropic schemas · sample LLM call
Pick a tool:
get_weather
simple
def get_weather(city: str) -> dict:
"""Get current weather for a city.
 
Returns temperature in Celsius and conditions.
 
Args:
city: City name (e.g. "Tokyo", "Paris", "San Francisco")
"""
response = requests.get(
f"https://api.weather.com/{city}",
timeout=10,
)
response.raise_for_status()
return response.json()
{
  "type": "function",
  "function": {
    "name": "get_weather",
    "description": "Get current weather for a city. Returns temperature in Celsius and conditions.",
    "parameters": {
      "type": "object",
      "properties": {
        "city": {
          "type": "string",
          "description": "City name (e.g. \"Tokyo\", \"Paris\", \"San Francisco\")"
        }
      },
      "required": [
        "city"
      ]
    }
  }
}
Schema design note
The simplest pattern: one required string parameter. Notice that the LLM emits `arguments` as a JSON-encoded string; your code must parse it before calling the function. The description carries the examples: that's where the LLM picks up the right input format.
Cycle through the tools. Each Python function compiles to a schema the LLM reads to decide whether and how to call it. OpenAI and Anthropic schemas are nearly identical: minor key differences (function vs name), same underlying mechanism. The sample LLM call shows what the LLM actually emits: a structured tool_calls array with the function name and JSON-encoded arguments. Schema design IS contract design: descriptions, type constraints, enums, and required fields are what stops the LLM from passing malformed inputs. The most under-appreciated piece of agent engineering, now visible.

Six curated Python tool functions, each a different schema pattern: simple, optional params, side-effect, bounded output, restricted eval, complex shape. For each, see the Python source side by side with the OpenAI function-calling schema, the Anthropic tool-use schema, and a sample LLM tool-call response as JSON. Demonstrates the function → schema → invocation pipeline that every agent framework runs on. The schema is the contract between engineer and LLM, descriptions, type constraints, and required fields are what stops the LLM from passing malformed inputs.

Best practices

Five practices separate schemas the LLM uses correctly from schemas it doesn’t. Descriptions matter: the LLM picks tools by reading them; vague descriptions guarantee wrong choices. Parameter examples in descriptions: include sample values inline; the LLM mirrors them. Required vs optional: be explicit; ambiguity here triggers junk arguments. Enums for constrained values: if a parameter is one of five strings, declare it as an enum, not a free string. Object types for complex inputs: when an argument is structured, declare it as an object with its own schema, not a stringified JSON blob.

Error handling and recovery

The reality of tool errors

Production tool calls fail constantly. Network failures. Services unavailable. Rate limits. Invalid arguments because the LLM got it slightly wrong. Timeouts. Authentication failures. Permission denied. Every one of these happens regularly in any agent that handles real traffic, and a robust agent handles every one of them.

Six patterns

Retry with exponential backoff for transient failures. Wait progressively longer between attempts, 11 s, 22 s, 44 s, capped at a maximum delay. Categorize errors first: retry transient failures (network, rate-limit, timeout); don’t retry permanent ones (invalid arguments, 4xx4xx auth errors).

Circuit breakers for cascading failures. If a tool fails repeatedly, stop calling it for a cooldown period. Prevents one broken service from taking the whole agent down, an agent that keeps trying a dead endpoint just burns iterations and cost.

Fallback tools for redundancy. When one tool fails, try a backup, two web search providers, a cached lookup before a live API, a cheaper model when the expensive one rate-limits. Fallbacks need their own design (when does the fallback fire? what does the LLM see?), but for high-availability agents they’re standard.

Surface errors to the LLM, never swallow them. The agent uses error messages to plan recovery. Return errors as structured observations the model can read.

Bounded iteration. Even with perfect error handling, cap iterations. No agent should loop indefinitely searching for a non-existent solution.

Cost budgets. Cap total cost per task. If an agent burns \10$ in inference without progress, something is wrong and the system should stop it.

Anti-patterns that recur. Swallowing errors silently, the agent never sees them and never recovers. Retrying non-retryable errors, invalid arguments fail forever no matter how patiently you wait. Catching too broadly, except Exception hides bugs as readily as it hides transients. Infinite retry without backoff or cap, a denial-of-service against your own infrastructure.

Observability: tracing and debugging

The trace problem

When an agent fails in production, the trace is the only diagnostic. Without good traces, debugging is impossible. A failed agent run can involve dozens of LLM calls and tool invocations spread across minutes; reading raw logs to reconstruct what happened is impractical, and “rerun and see” is unreliable because most failures are non-deterministic.

A good trace shows every LLM call (model name, prompt tokens, completion tokens, latency, cost), every tool call (name, arguments, result, latency, success/failure), the thought-action-observation sequence, errors and recoveries, and cost-and-time accumulating per task. All of that, structured, queryable, and visualizable.

Spans and structured logs

The standard pattern, borrowed from distributed systems: trace IDs unique per task; spans as nested timed operations (LLM call → tool call → result processing); parent-child relationships so each span knows where it sits in the tree; attributes as structured metadata per span. Tools like OpenTelemetry standardize the data model; LangSmith, Helicone, and Braintrust render it.

Structured logging as a baseline. Every event is JSON, not free-text, {event: tool_call, task_id, iteration, tool, arguments, latency_ms, success}. Structured logs are queryable; free-text isn’t.

Agent trace inspector

Interactive
Agent trace inspector
4 traces · flame-graph-style nested spans · click for detail
Pick a trace:
CLEAN SUCCESS
clean
"What's the weather in Tokyo, and what date is it today?"
⏱️2.29stotal
💰$0.019cost
📊1,515tokens
completed
0ms1145ms2290ms
agent_turn 1
950ms
llm_call
720ms
parse
2ms
get_weather
195ms
agent_turn 2
840ms
llm_call
680ms
parse
1ms
get_date
140ms
agent_turn 3
480ms
llm_call
460ms
final_answer
5ms
Click a span above to see its full attributes.
  • Total task time: ~2.3 seconds, dominated by LLM latency (1.9s of 2.3s)
  • Tool calls are fast (140-200ms each); the bottleneck is the model, not the tools
  • Cost: ~$0.019 total for 3 LLM calls, manageable for most production use cases
  • Three iterations match the 3 thought-action-observation triples in the chapter's opener
Scenario note
A clean 3-turn ReAct trace: two tool calls (weather, date) followed by a final answer. No retries, no errors, modest cost. This is what production agents look like on the happy path.
This is what production observability looks like. Each agent task is a tree of spans (LLM calls, tool calls, parses, retries, final answers), each with timing, attributes (model, tokens, cost), and status. Clean traces tell you nothing; they're the baseline. The interesting traces are the failure modes: transient retries (engineering working as designed), hallucinated tool calls (the LLM recovers via structured errors), cost-blown runaways (context bloat caught by hard caps). Without traces, every agent failure is mysterious; with them, the cause is visible in seconds. Production tools like LangSmith, Helicone, and Braintrust render this view at scale.

Four agent traces visualized as flame-graph-style nested spans: clean success, transient-failure retries, hallucinated tool call, cost-blown runaway. Each span shows timing, attributes (model, tokens, cost), and status. Click any span for full detail. Mirrors what production tools (LangSmith, Helicone, Braintrust) render at scale. Demonstrates that observability isn't optional, it's the only way to debug production agent failures.

Debugging from traces

The mapping from failure mode to trace signature is short and worth memorizing. Hung agent: look for spans without end times. Wrong answer: look at the thought-action sequence for missteps. Cost spike: look for excessive iterations or oversized contexts. Slow response: look for slow tools or model-side latency. Most production debugging is pattern-matching from this list.

Scaffolding the agent

What goes into scaffolding

Scaffolding is everything that’s not the loop or the tools, the supporting structure that the user never sees but that makes the agent work. Seven components earn their keep in production. System prompt: sets the agent’s role, behavior, and constraints. The most important single piece of agent code. Tool descriptions: read by the LLM at every call; write them as if for a colleague who has to do the work. Few-shot examples, examples of correct agent behavior embedded in the system prompt. The most-impactful single intervention for many tasks: two or three good examples often outperform paragraphs of instructions. Output validation: after the LLM produces output, validate it; reject and retry on invalid output. Memory layer: what state does the agent preserve across turns and sessions? (Ch 27 §5.) Conversation management: for chat-style agents, the layer that maintains user identity, session state, and conversation history. Cost and rate limits: hard caps that prevent runaway spending.

A sample production system prompt, drawn down to the irreducible:

You are a customer-support agent for [Company]. Your goals:
1. Answer user questions accurately using the tools provided.
2. If you can't help, escalate to a human via the `escalate` tool.
3. Be concise and friendly.

You have access to these tools:
- search_kb(query): search the knowledge base
- check_order(order_id): look up order status
- escalate(reason): hand off to a human agent

Guidelines:
- Never invent information; if unsure, use search_kb or escalate.
- Confirm sensitive actions before taking them.
- Keep responses under 200 words.

Output format: ReAct (Thought/Action/Observation).

Production patterns

Five patterns recur across production agents. System-prompt versioning, prompts evolve; version them like code and record which version produced each task. Dynamic few-shot retrieval, for complex tasks, retrieve relevant examples per task instead of hard-coding the same ones (Ch 22’s RAG patterns applied to the prompt itself). Output validation with retry: reject malformed JSON; ask the LLM to try again with the error as context. Sanitized logging: redact PII before traces leave the application. Sandboxing dangerous tools, Docker, VMs, or restricted environments for anything that runs code or modifies external state.

The anti-patterns are mirror images. Massive system prompts (>2,0002{,}000 tokens) the LLM can’t reliably follow. Vague tool descriptions that force the model to guess. No few-shot examples when the task is unusual enough to need them. Skipping validation because the LLM “usually gets it right”, usually isn’t always, and the failures are silent.

Exercises

Four exercises that lock in the engineering toolkit. Each is a self-contained problem with a starting template; hints are collapsed by default; try the problem first.

The exercises trace the chapter’s arc: build a tool registry (Ex 1) → implement a retry pattern (Ex 2) → add structured tracing (Ex 3) → tie everything together in a complete agent harness (Ex 4). After these, the reader has all the components of a production agent.

Exercise 1 (easy): Tool registry with execute()

Build a tool registry where tools are registered by name, schemas are generated from metadata, and execution is wrapped in structured error handling.

Hint

The pattern:

  1. A Tool class holds: name, description, parameters schema, function
  2. A register decorator adds tools to a global registry
  3. Each tool’s execute(arguments) method calls the function and wraps any exception in a structured {'status': 'error', 'error_type': ..., 'message': ...} response
  4. On success: {'status': 'ok', 'result': ...}

The point is that the LLM never sees a Python exception, it sees a structured observation it can reason about.

Solution

The exception boundary lives entirely in execute(): call the function, wrap success as {'status': 'ok', ...}, catch any exception and wrap it as {'status': 'error', ...} instead of letting it propagate.

def execute(self, arguments: dict) -> dict:
    try:
        result = self.function(**arguments)
        return {'status': 'ok', 'result': result}
    except Exception as e:
        return {
            'status': 'error',
            'error_type': type(e).__name__,
            'message': str(e),
        }

Running the three test calls gives:

get_weather(Tokyo): {'status': 'ok', 'result': {'city': 'Tokyo', 'temp_c': 18, 'conditions': 'partly cloudy'}}
calculate(1/0):     {'status': 'error', 'error_type': 'ZeroDivisionError', 'message': 'division by zero'}
get_weather(town):  {'status': 'error', 'error_type': 'TypeError', 'message': "get_weather() got an unexpected keyword argument 'town'"}

The third call is the important one: calling with the wrong argument name raises a TypeError inside self.function(**arguments), and execute() catches it like any other exception, so a malformed tool call never crashes the agent loop.

Exercise 2 (medium): Retry with exponential backoff

Implement a retry helper that re-executes a flaky function with exponential backoff, returning a structured (result, error) tuple after success or exhaustion.

Hint

The pattern:

  1. Attempt up to max_retries times
  2. On exception, sleep for base_wait * 2^attempt (exponential backoff)
  3. Return (result, None) on success, (None, "error message") on exhaustion
  4. The structured tuple lets the caller decide what to do, no exceptions raised

Real production also adds: error categorization (retryable vs not), circuit breakers, jitter (small random offset to avoid thundering-herd retries).

Solution

Loop up to max_retries, return immediately on success, sleep with exponential backoff between attempts (but not after the last one), and return a structured error once the budget is exhausted.

def with_retry(fn, *args, max_retries=3, base_wait=0.001, **kwargs):
    for attempt in range(max_retries):
        try:
            result = fn(*args, **kwargs)
            return (result, None)
        except Exception as e:
            wait = base_wait * (2 ** attempt)
            print(f"  Attempt {attempt + 1} failed: {e}. Waiting {wait*1000:.0f}ms...")
            if attempt < max_retries - 1:
                time.sleep(wait)
    return (None, f"Failed after {max_retries} attempts")

With random.seed(42) and the given flaky_search, the four calls resolve as: call 1 succeeds immediately, call 2 fails all 3 attempts (waiting 1ms, 2ms, 4ms) and returns "Failed after 3 attempts", calls 3 and 4 each succeed on the first attempt. The if attempt < max_retries - 1 guard matters: without it, the helper sleeps one extra time after the final failed attempt, delaying an error the caller already has enough information to act on.

Exercise 3 (medium): Structured tracing layer

Add a Tracer class that records spans (timed operations) with attributes. Spans nest via Python’s context-manager protocol. Use it to trace a mock agent loop.

Hint

The pattern:

  1. Tracer keeps a list of span records and a stack of currently-open spans
  2. tracer.span(name, **attrs) returns a context manager that:
    • on enter: pushes a new span onto the stack with start time + attrs
    • on exit: pops, records end time and status
  3. Each span records: id, parent_id, name, start, duration, attributes, status
  4. report() prints the tree with indentation

Real production uses OpenTelemetry, but the conceptual model is identical.

Solution

span() is a context manager: build the record on enter (with the current stack top as parent_id), push it, then on exit stamp status/duration and pop. The try/except/finally makes error spans and normal spans share the same bookkeeping.

@contextmanager
def span(self, name: str, **attrs):
    span_id = str(uuid.uuid4())[:8]
    parent_id = self.span_stack[-1] if self.span_stack else None
    record = {
        'span_id': span_id,
        'parent_id': parent_id,
        'task_id': self.task_id,
        'name': name,
        'start': time.time(),
        'attributes': attrs,
    }
    self.spans.append(record)
    self.span_stack.append(span_id)
    try:
        yield record
        record['status'] = 'ok'
    except Exception as e:
        record['status'] = 'error'
        record['error'] = str(e)
        raise
    finally:
        record['end'] = time.time()
        record['duration_ms'] = (record['end'] - record['start']) * 1000
        self.span_stack.pop()

Running the two-turn mock trace produces nested output like:

=== Trace for task <uuid> ===
[OK] agent_turn           (78.4ms)  iteration=1
  [OK] llm_call             (48.2ms)  model=claude-sonnet-4 tokens_in=320 tokens_out=95
  [OK] tool_call            (30.0ms)  tool=get_weather city=Tokyo
[OK] agent_turn           (53.4ms)  iteration=2
  [OK] llm_call             (38.3ms)  model=claude-sonnet-4 tokens_in=455 tokens_out=60
  [OK] tool_call            (15.0ms)  tool=get_date

(exact millisecond values vary run to run). llm_call and tool_call indent one level under agent_turn because parent_id points at whatever span is on top of the stack when they open, which report()’s parent-chain walk turns into indentation.

Exercise 4 (hard): Complete agent harness

Tie everything together: build a complete agent harness that uses a tool registry (Ex 1), retry helper (Ex 2), and tracer (Ex 3) to execute a multi-turn ReAct task.

Hint

The harness:

  1. Agent(tools, tracer, max_iterations) holds the components
  2. run(task) executes the ReAct loop with tracing
  3. Each iteration: traced agent_turn span; traced llm_call; parse; traced tool_call (with retry)
  4. Returns the final answer (or error) when the loop ends

The point is that all the pieces compose: the tool registry executes safely; retry handles flakiness; the tracer records everything; the agent loop drives the iteration.

For the LLM, you’ll mock responses, focus on the orchestration, not the model.

Solution

The ReAct loop: mock the LLM call, parse thought/action, terminate on final_answer, otherwise dispatch to the matching tool and append the observation to history for the next turn. Everything is wrapped in traced spans.

def run(self, task):
    history = [f"Task: {task}"]
    for iteration in range(self.max_iterations):
        with self.tracer.span("agent_turn", iteration=iteration + 1):
            with self.tracer.span("llm_call", tokens=420):
                response = mock_llm("\n".join(history))
            thought, action = parse_react(response)
            history.extend([f"Thought: {thought}", f"Action: {action}"])
            if action.startswith('final_answer'):
                answer = re.search(r'final_answer\(["\'](.*)["\']\)', action)
                return answer.group(1) if answer else "Done"
            tool_name, tool_args = parse_action(action)
            if tool_name not in self.tools:
                history.append(f"Observation: Error - unknown tool {tool_name}")
                continue
            with self.tracer.span("tool_call", tool=tool_name):
                result = self.tools[tool_name].execute(tool_args)
            if result['status'] == 'error':
                history.append(f"Observation: Error - {result['message']}")
            else:
                history.append(f"Observation: {result['result']}")
    return "Max iterations reached."

With agent.run("What's the weather and the date today?"), mock_llm gates its branches on what’s already in the transcript: turn 1’s action text (get_weather(city="Tokyo")) is the first thing to mention Tokyo, so get_weather fires first; turn 2 sees 'Tokyo' in prompt and '2025-05-22' not in prompt and calls get_date; turn 3 sees the date and returns final_answer. The final answer is "Today in Tokyo: 18C, partly cloudy.", and the trace shows all three agent turns with both tools actually called (get_weather then get_date), not a hardcoded answer that skips retrieval. (Note the task string itself must not contain “Tokyo”: if it did, the first mock_llm check would see “Tokyo” already in the prompt on turn 1 and skip straight past get_weather.)

Part IX: chapter map

Chapter status

ChapterTopicStatus
Ch 27Agent foundationsdone
Ch 28 (this)Agents from scratchclosing here
Ch 29Multi-agent systemsnext
Ch 30Agent eval and frameworkscloses the curriculum

What’s left

Ch 29 (Multi-agent systems) covers the cases where a single-agent setup isn’t enough: orchestration patterns, agent-to-agent communication, frameworks (CrewAI, AutoGen, OpenAI Swarm). The composition arc, in code. Ch 30 (Agent eval and frameworks) brings Part VIII’s evaluation discipline back to bear on agent systems, how to quantitatively evaluate agents at scale (SWE-bench, GAIA, OSWorld), the production frameworks that wrap everything in this chapter, and the deployment patterns. Ch 30 closes the curriculum.

The trajectory is short to state. Ch 27 conceptualCh 28 engineering (you are here) → Ch 29 compositionCh 30 evaluation. Each chapter builds on the prior; each delivers a layer of the stack that the next one composes on top of. After Ch 30 the reader has the full picture, from numpy primitives in Ch 1 through transformer internals (Ch 4-6), pretraining (Ch 7-10), alternative architectures (Ch 11-12), post-training (Ch 13-16), inference (Ch 17-19), modern capabilities (Ch 20-23), safety and interpretability and evaluation (Ch 24-26), and finally production agent systems (Ch 27-30). Every layer covered, every layer with its own working code.

Production agents are 80%80\% engineering on top of 20%20\% LLM. This chapter covered the 80%80\%. Tool design, atomic, single-responsibility, clear-contract functions. Tool implementation, sync, async, timeouts, output bounds, sandboxing. Schemas, function calling, MCP, the structured-call pipeline. Error handling, retry, circuit breakers, fallback, surfacing errors to the LLM. Observability, structured logs, distributed traces, debugging from spans. Scaffolding, system prompts, tool descriptions, few-shot examples, output validation, cost budgets. All of it is engineering you’d recognize from any production system, applied with the LLM in mind.

Ch 29 composes multiple agents when a single agent doesn’t fit and orchestration starts to matter. Ch 30 brings Part VIII’s evaluation discipline back to bear on agent systems, and closes the curriculum. You’re now equipped to build a real agent. The next chapters compose them, evaluate them, and bring the whole arc home.