Agent-era architecture · Practical technical guide · 18 October 2026

A migration method for agent systems that began as an autonomous tool loop: extract candidate states from recorded traces, make guards and budgets deterministic, let the model choose only among allowed transitions, and decide when the loop was fine as it was.

Two ways to run the same agent

Most agent systems start as a loop: send the conversation and the tool list to the model, run whatever tool it picks, append the result, repeat until the model stops or a step cap trips. It is a fine way to discover what an agent needs to do. It is a poor way to run it for long, because every guarantee lives in the prompt. The loop pattern and its modern costs are discussed in the ReAct pattern revisited.

The alternative is an explicit state machine: named states, each with the tools allowed in it, the states it may move to, the guard that must hold for each move, and budgets that end the run. The model still decides, but only among the transitions the current state allows. The question this article answers is not which is better in the abstract. It is how to get from a loop that works in demos to a machine that works in production, using the loop traces you already have, and how to tell when the loop was good enough.

What stays deterministic

ElementDecided byWhat the model contributes
The set of states and terminal statesCode, derived from traces and reviewed by peopleNothing
Tools allowed in each stateCode; the tool list sent to the model is filtered per stateNothing
Transition guards (a review passed, a lookup returned data)Code evaluating structured resultsNothing
Budgets: steps, tokens, wall clock, moneyCode, enforced by the runtimeNothing
Persistence and resumption of the current stateRuntimeNothing
Which allowed transition to take, and the tool argumentsThe modelInterpretation of the task and of unstructured results
Free text: summaries, drafts, explanationsThe modelEverything

The split is the same one that appears whenever an agent joins an existing system: anything expressible over structured data is enforced in code, and the model gets the decisions that genuinely need judgement. A rule in a prompt is advice; a guard in code is enforcement. The migration consists of moving rules from the first column to the second without losing the flexibility that made the loop useful.

Mining states from traces

Do not design the states on a whiteboard. Record the loop first: for every run, the sequence of tool calls, their structured outcomes and the final result. A few hundred runs are enough to see the shape. Label each step by tool name and outcome class, for example search_kb:hit or search_kb:empty, and count the transitions between labels. The structure that emerges is usually a small number of phases where the same tools recur with the same exit conditions. Each such phase is a candidate state. Transitions that appear in under one percent of runs are either bugs to remove or situations to escalate rather than states to design for.

Illustrative Python for mining recorded loop traces; the labels come from your tool names and structured outcome classes.

from collections import Counter, defaultdict

def transition_counts(traces):
    """traces: list of runs; each run is a list of (tool, outcome) tuples."""
    counts = defaultdict(Counter)
    for run in traces:
        labels = [f"{tool}:{outcome}" for tool, outcome in run] + ["END"]
        prev = "START"
        for label in labels:
            counts[prev][label] += 1
            prev = label
    return counts

def rare_transitions(counts, threshold=0.01):
    rare = []
    for src, nxt in counts.items():
        total = sum(nxt.values())
        for dst, n in nxt.items():
            if n / total < threshold:
                rare.append((src, dst, n, total))
    return sorted(rare, key=lambda r: r[2])

def repeated_tool_runs(traces, tool, limit=3):
    """Runs where one tool was called more than `limit` times in a row."""
    flagged = []
    for i, run in enumerate(traces):
        streak = 0
        for t, _outcome in run:
            streak = streak + 1 if t == tool else 0
            if streak > limit:
                flagged.append(i)
                break
    return flagged

The second function finds the rare edges, and the third finds the pathological loops, such as an agent that searches the knowledge base seven times. Those two outputs are the design input: common edges become allowed transitions, rare edges become escalations, and repeated runs become budgets.

Worked example: a support triage agent

A triage agent built as a loop had six tools: fetch_ticket, search_kb, lookup_account, draft_reply, policy_check and send_reply. Mining a synthetic set of 1,200 recorded runs, used here only as an illustration, gives a clear shape. Nearly every run starts with fetch_ticket. Most then call search_kb once or twice and lookup_account at most once. Drafting follows, then policy_check, then send_reply. The rare edges are the interesting part: 14 runs called search_kb five or more times, nine runs sent a reply without a policy check, and three runs drafted before fetching the ticket.

The machine that falls out has six states. Intake allows only fetch_ticket and exits when the ticket is loaded. Research allows search_kb and lookup_account, with a budget of three searches, and exits to Draft or Escalate. Draft allows draft_reply only. Review runs policy_check as code, not as a model choice, and exits to Send when it passes or back to Draft once. Send allows send_reply. Escalate is terminal and always reachable. The model chooses between Research again, Draft and Escalate while in Research, and nothing else. The nine replies sent without review cannot happen any more, because Send is only reachable from Review.

The state machine in code

Illustrative python, checked for syntax; adapt names and storage to your system.

STATES = {
    "intake":   {"tools": ["fetch_ticket"], "next": ["research"]},
    "research": {"tools": ["search_kb", "lookup_account"], "next": ["research", "draft", "escalate"]},
    "draft":    {"tools": ["draft_reply"], "next": ["review"]},
    "review":   {"tools": [], "next": ["send", "draft"]},       # decided by code, not the model
    "send":     {"tools": ["send_reply"], "next": ["done"]},
    "escalate": {"tools": [], "next": []},
    "done":     {"tools": [], "next": []},
}
BUDGETS = {"max_steps": 12, "max_searches": 3, "max_review_rounds": 1}

class GuardFailed(Exception):
    pass

def guard(run, target):
    if target == "send" and not run.get("review_passed"):
        raise GuardFailed("send requires a passed review")
    if target == "research" and run["searches"] >= BUDGETS["max_searches"]:
        raise GuardFailed("search budget spent; choose draft or escalate")
    if target == "draft" and run["state"] == "review" and run["review_rounds"] >= BUDGETS["max_review_rounds"]:
        raise GuardFailed("review rounds spent; escalate")

def transition(run, target):
    """The model proposes `target`; code decides whether the move is allowed."""
    if target not in STATES[run["state"]]["next"]:
        raise GuardFailed(f"{run['state']} cannot move to {target}")
    guard(run, target)
    run["steps"] += 1
    if run["steps"] > BUDGETS["max_steps"]:
        target = "escalate"
    run["state"] = target
    return STATES[target]["tools"]           # the only tools the model sees next

The function returns the tool list for the next model call, which is how the machine constrains the model without arguing with it: tools that are not allowed are simply not offered. When a guard fails, the error text goes back to the model as the result of its proposed move, and the state does not change. Step budgets are enforced by code and route to a terminal state rather than to a prompt that asks the model to stop.

Guards, budgets and the escape hatch

Three design rules keep the machine honest. First, a guard checks structured facts only: a review result, a count, a flag set by code. Never implement a guard by asking the model whether it is done. Second, every state has a budget and every budget routes to a terminal state; the loop stopped when the model felt finished, the machine stops when a counter says so. Third, Escalate is reachable from every non-terminal state, and it carries the trace so the person who picks it up sees what was tried. An agent that cannot give up will find a way to misbehave instead.

Failure modes

  • States too fine. Twenty states with one tool each turn the model into a script interpreter. If a state has exactly one allowed move and no model decision, merge it or make it a plain function.
  • Guards duplicated in the prompt. When the prompt also says do not send before review, the two drift apart. Keep the prompt descriptive and the guard authoritative.
  • A loop hidden inside a state. A Research state with no search budget is the old loop with a new name. Every self-transition needs a counter.
  • Resuming into a stale state. A run resumed after an hour in Draft may face a changed ticket. Re-run the entry guard of the state on resume, as durable agent workflows recommends.
  • Transition drift after a model change. A new model version takes rare edges more or less often. Re-mine traces after every model upgrade and compare the transition matrix.

Migration steps for an existing loop

  1. Add structured outcome classes to every tool result and record full traces with run ids. Change nothing else.
  2. Mine the transitions and the repeated-tool runs. Review the candidate states with the people who own the process.
  3. Write the state table, guards and budgets in code. Run them in shadow mode: the loop keeps running, and the machine logs every transition it would have refused.
  4. Fix what shadow mode reveals: usually a missing state or a guard that is too strict. Expect two or three rounds.
  5. Enforce the machine: filter the tool list per state and route budget exhaustion to Escalate. Keep the shadow metrics as alerts.
  6. Delete prompt rules that are now guards, and re-mine after every model upgrade.

When an agent is unnecessary

Sometimes the mined machine has one path and the model never chooses anything. Then you have a workflow with a text generator at one step, and the right migration is to a plain pipeline that calls the model only for that step. Sometimes the only real decision is a classification at the start, such as which of four templates applies; a classifier plus a fixed workflow is cheaper and easier to evaluate than an agent. The explicit state machine makes this visible in a way the loop never did: when the transition matrix is a straight line, the agent was decoration. Keep the agent only where the matrix branches and the branch depends on unstructured input.

Mine states from recorded loop traces, enforce guards and budgets in code, offer the model only the tools of its current state, and keep an escalation state reachable from everywhere.