AnyLearn
All lessons
AIadvanced

Designing a Production Agent Harness

Move beyond toy ReAct loops. Learn how to build a production-grade agent harness with a robust control loop, tool registry, schema validation, retry logic, token budgets, abort signals, and a persistent journal that survives crashes.

Updated · AI-authored, review-gated · how lessons are made

Not signed in: your progress and quiz score won't be saved.
Progress1 / 11

What makes a toy ReAct loop dangerous in production

The canonical ReAct loop is 15 lines: prompt the model, parse Thought/Action/Observation, call the tool, repeat. It works in demos. It fails in production because it assumes:

  • The model always returns valid JSON for tool calls.
  • Tools never time out, return errors, or exceed rate limits.
  • The conversation can grow forever without hitting the context window.
  • A crash loses no state — you can just restart.
  • There is no way to cancel a runaway agent mid-flight.

None of those assumptions hold at scale. A production harness is what you build to make each assumption explicitly false: schema validation gates every tool call, retries handle transient errors, a token budget caps context growth, an abort channel stops the loop from the outside, and a journal lets you resume after a crash. Each of those mechanisms is a seam where bugs hide — and where most teams skip the work.

Full lesson text

All 11 steps on one page, for reading, reference, and search.

Show

1. What makes a toy ReAct loop dangerous in production

The canonical ReAct loop is 15 lines: prompt the model, parse Thought/Action/Observation, call the tool, repeat. It works in demos. It fails in production because it assumes:

  • The model always returns valid JSON for tool calls.
  • Tools never time out, return errors, or exceed rate limits.
  • The conversation can grow forever without hitting the context window.
  • A crash loses no state — you can just restart.
  • There is no way to cancel a runaway agent mid-flight.

None of those assumptions hold at scale. A production harness is what you build to make each assumption explicitly false: schema validation gates every tool call, retries handle transient errors, a token budget caps context growth, an abort channel stops the loop from the outside, and a journal lets you resume after a crash. Each of those mechanisms is a seam where bugs hide — and where most teams skip the work.

2. The control loop at a glance

flowchart TD
  A["User request"] --> B["Build prompt + journal context"]
  B --> C["LLM call (with token budget)"]
  C --> D["Parse + validate response"]
  D --> E["Tool call requested?"]
  E -- "yes" --> F["Lookup tool in registry"]
  F --> G["Validate args against schema"]
  G --> H["Execute tool (with timeout)"]
  H --> I["Retry on transient error?"]
  I -- "yes" --> H
  I -- "no / success" --> J["Append to journal"]
  J --> K["Abort signal set?"]
  K -- "yes" --> L["Graceful shutdown"]
  K -- "no" --> B
  E -- "no (final answer)" --> M["Return to caller"]

3. The control loop: one iteration, spelled out

Each turn of the loop does exactly four things in order:

  1. Assemble context — reconstruct the messages array from the journal (not from in-memory state, which evaporates on crash).
  2. Call the model — pass a max_tokens cap and (if the API supports it) a system-level token budget header.
  3. Dispatch — if the model returns a tool call, run it; if it returns a final answer, yield it to the caller and stop.
  4. Record — write every LLM response and every tool result to the journal before moving on.

The loop must be a while True with an explicit break condition — not recursion. Recursion blows the call stack for long agent runs and makes it much harder to inject an abort check. Keep the loop body a single function so you can unit-test each step in isolation.

4. Tool registry: the single source of truth

A registry maps a string name to a callable and its JSON Schema descriptor. Register tools at startup, not inline.

from dataclasses import dataclass, field
from typing import Callable, Any

@dataclass
class ToolDef:
    name: str
    description: str
    parameters: dict          # JSON Schema object
    fn: Callable[..., Any]
    timeout_s: float = 30.0
    max_retries: int = 3

class ToolRegistry:
    def __init__(self):
        self._tools: dict[str, ToolDef] = {}

    def register(self, tool: ToolDef) -> None:
        if tool.name in self._tools:
            raise ValueError(f"Duplicate tool: {tool.name}")
        self._tools[tool.name] = tool

    def get(self, name: str) -> ToolDef:
        if name not in self._tools:
            raise KeyError(f"Unknown tool: {name!r}")
        return self._tools[name]

    def as_api_tools(self) -> list[dict]:
        """Return the list the LLM API expects."""
        return [
            {"name": t.name, "description": t.description,
             "input_schema": t.parameters}
            for t in self._tools.values()
        ]

The as_api_tools() method means the schema lives in exactly one place — no drift between what the model sees and what you validate against.

5. Schema validation before the tool ever runs

Never trust the model's arguments blindly. Validate against the same JSON Schema you sent to the API, then coerce types if needed.

import jsonschema

def validate_args(tool: ToolDef, raw_args: dict) -> dict:
    try:
        jsonschema.validate(instance=raw_args, schema=tool.parameters)
    except jsonschema.ValidationError as exc:
        # Return an error observation instead of raising — the model
        # can self-correct on the next turn.
        raise ToolValidationError(
            f"Invalid args for {tool.name!r}: {exc.message}"
        ) from exc
    return raw_args

When validation fails, feed the error back as an observation rather than crashing the loop. The model usually self-corrects within one turn. If it fails N times in a row on the same tool call, that is a hard abort — the model is stuck.

Heads up: JSON Schema additionalProperties: false is your friend. Without it, a model can hallucinate extra fields and the schema passes anyway.

6. Retry logic: transient vs. permanent errors

Not all tool errors are equal. Classify them before deciding whether to retry:

Error classExamplesAction
Transient429, 503, timeout, network blipExponential backoff, up to max_retries
Recoverable400 bad request, schema mismatchReturn observation, let model self-correct
FatalAuth failure (401/403), tool not foundAbort immediately
import asyncio, httpx

async def call_with_retry(tool: ToolDef, args: dict) -> str:
    delay = 1.0
    for attempt in range(tool.max_retries + 1):
        try:
            return await asyncio.wait_for(
                tool.fn(**args), timeout=tool.timeout_s
            )
        except asyncio.TimeoutError:
            obs = f"Tool timed out after {tool.timeout_s}s"
        except httpx.HTTPStatusError as e:
            if e.response.status_code in (401, 403):
                raise  # fatal
            obs = f"HTTP {e.response.status_code}: {e.response.text[:200]}"
        if attempt < tool.max_retries:
            await asyncio.sleep(delay)
            delay = min(delay * 2, 60)
    return obs  # last error becomes the observation

The key insight: retry at the tool execution layer, not at the LLM layer. Retrying the LLM after a tool error wastes tokens and context.

7. Token budgets: keeping the context window sane

Context windows are large but not infinite, and large contexts are slow and expensive. A token budget enforces two limits:

Input budget — the maximum tokens you'll send to the model per turn. When the journal grows past this, you need a compaction strategy (summarize older turns, drop raw tool outputs, keep only the last N observations).

Output budget — set max_tokens on every API call. Never omit it; some models will happily emit 8k tokens when a tool call needs 50.

TOKEN_BUDGET = 80_000   # leave 48k headroom in a 128k window

def trim_messages(messages: list[dict], tokenizer) -> list[dict]:
    total = sum(len(tokenizer.encode(m["content"])) for m in messages)
    while total > TOKEN_BUDGET and len(messages) > 2:
        # Drop oldest tool result first (they're the least load-bearing)
        for i, m in enumerate(messages):
            if m["role"] == "tool":
                removed = messages.pop(i)
                total -= len(tokenizer.encode(removed["content"]))
                break
    return messages

Anthropic's API also supports a budget_tokens field in the thinking block — use it for reasoning models to cap chain-of-thought spend separately from the answer.

8. Abort signals: stopping the agent from the outside

A runaway agent can burn through API budget or take destructive actions if left unchecked. You need a way to stop it from outside the loop — from a UI cancel button, a deadline timer, or a watchdog process.

The cleanest pattern is an asyncio.Event (or a threading.Event) checked at the top of every iteration:

async def run_agent(
    task: str,
    registry: ToolRegistry,
    journal: Journal,
    abort: asyncio.Event,
) -> str:
    while not abort.is_set():
        messages = journal.to_messages()
        response = await llm_call(messages, registry.as_api_tools())
        journal.append_llm(response)

        if response.stop_reason == "end_turn":
            return response.content[-1].text

        for tool_use in response.tool_use_blocks():
            if abort.is_set():
                journal.append_tool(tool_use.id, "[aborted by caller]")
                return "[agent aborted]"
            result = await call_with_retry(
                registry.get(tool_use.name), tool_use.input
            )
            journal.append_tool(tool_use.id, result)

    return "[agent aborted]"

Never check the abort signal only at the top of the outer loop — check it before each individual tool call too, since a single tool can block for seconds.

9. The persistent journal: surviving crashes

If the process dies mid-run, all in-memory state is gone. A journal persists every event to disk (or a database) so you can resume — or at least reconstruct what happened.

import json, pathlib
from datetime import datetime, timezone

class Journal:
    def __init__(self, path: pathlib.Path):
        self.path = path
        self._entries: list[dict] = []
        if path.exists():
            self._entries = json.loads(path.read_text())

    def _flush(self):
        self.path.write_text(json.dumps(self._entries, indent=2))

    def append_llm(self, response) -> None:
        self._entries.append({"ts": _now(), "role": "assistant",
                               "raw": response.model_dump()})
        self._flush()

    def append_tool(self, tool_use_id: str, result: str) -> None:
        self._entries.append({"ts": _now(), "role": "tool",
                               "tool_use_id": tool_use_id, "content": result})
        self._flush()

    def to_messages(self) -> list[dict]:
        """Reconstruct the messages list the LLM API expects."""
        ...

def _now() -> str:
    return datetime.now(timezone.utc).isoformat()

Flush after every write — not in bulk. A crash between two writes is survivable; losing a batch is not. On high-throughput systems, replace the JSON file with an append-only SQLite table using WAL mode.

10. Observability: what you must log

A harness without observability is a black box. At minimum, emit structured log events (JSON lines to stdout works fine) for:

  • Turn startrun_id, turn, input_tokens (estimated)
  • Tool dispatchtool_name, args (redact secrets), attempt
  • Tool resulttool_name, latency_ms, status (ok / error / timeout)
  • Abort — reason, turn count at abort time
  • Final answerrun_id, total_turns, total_tokens, wall_time_ms

Avoid logging full LLM responses inline — they're huge and often contain PII. Log a response_id and store the raw response separately.

Heads up: if you use OpenTelemetry, wrap each turn as a span with run_id as the trace root. This gives you waterfall latency breakdowns across multi-tool turns for free.

11. Putting it together: the seams that matter

The gap between a toy loop and a production harness isn't a single feature — it's the accumulation of seams, each one a failure mode you've closed:

  • Registry closes the "tool not found" and schema-drift failure modes.
  • Validation closes the "model passed garbage args" failure mode.
  • Retry + classify closes the "transient error kills the run" failure mode.
  • Token budget closes the "context overflow kills the run" failure mode.
  • Abort signal closes the "runaway agent burns budget / takes bad action" failure mode.
  • Journal closes the "crash loses all progress" failure mode.

You do not need all of these on day one. Start with the journal (cheapest, highest leverage) and the schema validation (saves hours of debugging malformed tool calls). Add retries next. Token budgets and abort signals become urgent once your agents run tasks longer than 30 seconds.

The pattern generalizes across APIs — Anthropic tool use, OpenAI function calling, Google Gemini function declarations all share the same loop shape. The registry and journal are model-agnostic.

Check your understanding

The lesson ends with a 5-question quiz. Take it in the player above to see your score.

  1. You register a tool called `search_web` with `additionalProperties: false` in its JSON Schema. The model returns a tool call with an extra field `verbose: true` not in the schema. What should the harness do?
    • Pass the args to the tool unchanged — the model knows best.
    • Raise a fatal error and abort the agent run.
    • Return the validation error as a tool observation and let the model retry.
    • Strip the extra field and call the tool with the remaining args.
  2. An HTTP 429 (rate limit) error is returned by a tool's backend. Which retry strategy is correct?
    • Retry the LLM call with a shorter prompt to reduce load.
    • Retry the tool call with exponential backoff, up to max_retries.
    • Classify it as a fatal error and abort immediately.
    • Return the error to the user and stop the agent.
  3. Your agent journal stores events as a JSON file and flushes after every write. Why flush after every write rather than in bulk at the end of each turn?
    • JSON serialization is faster in small increments.
    • A crash between two writes in a turn loses only one event, not the entire turn.
    • Bulk writes cause file-locking issues on Linux.
    • The LLM API requires acknowledgment before the next turn.
  4. Where should the abort signal be checked in the control loop?
    • Only at the top of the outer while loop, before building the prompt.
    • Only after the LLM responds, before dispatching tool calls.
    • At the top of the outer loop AND before each individual tool call.
    • Only inside the tool implementation itself.
  5. Which of the following events provides the highest leverage if you can only add one observability data point to a production agent harness?
    • Full LLM response text for every turn.
    • Tool dispatch: tool name, args, attempt number, and latency.
    • The token count of the system prompt at startup.
    • The Python version and OS the harness is running on.

Related lessons

Programming
intermediate

Canary Releases: Deciding With Evidence Instead of Nerve

A canary release sends a slice of real traffic to a new version and asks whether it is healthy. This lesson covers what to measure, why comparing the canary against the current version beats comparing against history, the statistics problem that makes small canaries weak evidence, and how automated promotion and rollback turn a judgement call into a rule.

7 steps·~11 min
Programming
intermediate

Why Deploys Break Things, and the Strategies That Answer It

Deploying is the moment a working system is replaced by a different one while people are using it. This lesson covers what actually goes wrong at that moment, the research finding that shipping fast and shipping safely are not opposites, and the four deployment strategies as answers to one question: how many users meet a bad version before you find out.

7 steps·~11 min
Programming
intermediate

Making Rollback Possible: The Changes That Cannot Be Undone

Every deployment strategy assumes you can go back, and that assumption is the one most often false when it matters. This lesson covers what actually makes a rollback work, the database migration pattern that keeps schema changes reversible, the one-way doors that no amount of tooling can undo, and how to tell which kind of change you are about to ship.

7 steps·~11 min
AI
intermediate

Memory, Identity, and Seeing What the Agent Did

Three services decide whether an agent survives contact with production: what it remembers between sessions, whose authority it acts with when it calls your systems, and whether you can reconstruct what it did after the fact. This lesson covers AgentCore Memory, Identity and Observability, and the delegation problem that makes agent authentication genuinely different.

7 steps·~11 min