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.

Not signed in โ€” your progress and quiz score won't be saved.
Lesson 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 start โ€” run_id, turn, input_tokens (estimated)
  • Tool dispatch โ€” tool_name, args (redact secrets), attempt
  • Tool result โ€” tool_name, latency_ms, status (ok / error / timeout)
  • Abort โ€” reason, turn count at abort time
  • Final answer โ€” run_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

AI
beginner

Loop engineering basics: the agent control loop

How LLM agents actually run: the iterative prompt-action-observation loop, the ReAct shape, the smallest tool-calling loop in twelve lines, why you always stack three termination layers, what the model sees on iteration N, and when a single prompt is the better answer.

8 stepsยท~12 min
AI
advanced

Loop engineering: verification, orchestration, and anti-patterns

Make loops trustworthy. Adversarial verification panels, sub-agent orchestration, loop-until-dry for unbounded discovery, loop-until-budget for paid depth, multi-modal sweeps with diverse prompts, eval-driven cap selection, and the five anti-patterns โ€” silent caps, infinite plans, drift, premature termination, thrashing โ€” that ship to prod more than they should.

9 stepsยท~14 min
AI
intermediate

Loop engineering in production: state, errors, and budgets

Once an agent loop runs past a few iterations the failure surface shifts: state bloats, tool calls fail in three distinct ways, retries multiply if you stack them wrong, and unbounded budgets rack up four-figure bills overnight. The state, errors, and budgets you need to make the loop survive contact with reality.

8 stepsยท~12 min
Programming
advanced

SQLAlchemy 2.0 async ORM in production

SQLAlchemy 2.0 from the production angle. The engine/session/transaction layering, the typed declarative, the identity map, the N+1 query problem, async-only gotchas (no lazy loading), savepoint nesting, and the connection-pool knobs that decide whether the backend survives load.

8 stepsยท~12 min