Why Long-Running Agents Need a State Machine: Leader, Worker and Verifier Roles
Four ways a single agent loop fails on long tasks, and how a Leader, Worker and Verifier team driven by a deterministic engine fixes them, plus when to skip it.
This article is published in English.
Sources: MiniMax Agent Team Best Practice: An AI Agent Is a While Loop. A Reliable One Is a State Machine.
Going from chatbot to agent takes a while loop, not a research breakthrough. That's true, and it's also where most explanations stop and most production pain begins. The more useful question is why that loop quietly falls apart on tasks that run longer than ten minutes, and what structure repairs it. MiniMax has published two candid references on exactly this: the open-source Mini-Agent tutorial, which gives the baseline loop, and an engineering account of their Agent Team, covering what broke at scale and the runtime built in response. Both serve as the backbone here, and the pattern carries over to any stack.
One loop, start to finish
Strip an agent down and four parts remain: a model, a running list of messages, a registry of tools and a loop around them. The Mini-Agent reference works like so. Each pass first checks whether the history is close to the context ceiling and, if it is, condenses it. It then sends messages and tool definitions to the model. If the model asks for no tools, the loop returns. Otherwise it runs the requested tools, adds their output to the messages and repeats. Here's a full version for any OpenAI-compatible endpoint, with two real tools and the summarizing hook:
# naive_loop.py -- a single-agent tool loop,
# Mini-Agent style: summarize if needed ->
# llm.generate(messages, tools) -> execute
# tool_calls -> append results -> repeat.
import json
import subprocess
from openai import OpenAI
client = OpenAI()
MODEL = "gpt-4o-mini"
MAX_HISTORY = 40 # crude context budget
def read_file(path: str) -> str:
with open(path) as f:
return f.read()[:4000]
def run_cmd(cmd: str) -> str:
p = subprocess.run(
cmd, shell=True, timeout=30,
capture_output=True, text=True)
out = p.stdout + p.stderr
return f"exit={p.returncode}\n{out[-2000:]}"
TOOLS = {"read_file": read_file,
"run_cmd": run_cmd}
def schema(name, desc, arg):
return {
"type": "function",
"function": {
"name": name,
"description": desc,
"parameters": {
"type": "object",
"properties": {
arg: {"type": "string"},
},
"required": [arg],
},
},
}
SCHEMAS = [
schema("read_file",
"Read a local text file.", "path"),
schema("run_cmd",
"Run a shell command.", "cmd"),
]
def summarize(msgs):
# Mini-Agent compresses old turns with an
# LLM summary call when the context nears
# its limit; hard truncation is the v0.
if len(msgs) <= MAX_HISTORY:
return msgs
return [msgs[0]] + msgs[-MAX_HISTORY:]
def run(task: str, max_steps: int = 20):
msgs = [{"role": "user", "content": task}]
for _ in range(max_steps):
msgs = summarize(msgs)
r = client.chat.completions.create(
model=MODEL,
messages=msgs,
tools=SCHEMAS,
)
msg = r.choices[0].message
msgs.append(msg)
if not msg.tool_calls:
return msg.content # model says done
for call in msg.tool_calls:
fn = TOOLS[call.function.name]
args = json.loads(
call.function.arguments)
try:
out = fn(**args)
except Exception as e:
out = f"tool error: {e}"
msgs.append({
"role": "tool",
"tool_call_id": call.id,
"content": out,
})
return "hit max_steps -- gave up"
if __name__ == "__main__":
print(run("Count the .py files here, then "
"summarize naive_loop.py."))
The loop has two exits, and both carry weight. Success is the model's own judgment, expressed as no further tool calls. Safety is a dumb counter, the step limit. That asymmetry, where the model alone decides when the work is finished, is the very thing that gives way first.
Where a single loop gives out on long jobs
Run the same loop on a two-hour job instead of a two-minute one and the failures start. The MiniMax team catalogued the ones they kept hitting. None are bugs. They're properties of a single context, so a larger window or a better prompt cures none of them.
Context anxiety comes first: the agent stops when nobody asked it to. Seven subtasks in, it finishes three and says it has done 1, 2 and 3 and asks whether to continue. Models have fuzzy judgment about when a long task may end, and training that judgment directly is extremely expensive in money and algorithmic effort. A plain loop hands the stop decision to its least reliable part.
Degradation is next: the agent gets dumber as the context grows. At minute five it's sharp. At minute sixty-five it behaves like a distracted intern. One drifted step, such as a research task sliding into marketing copy, taints everything after it, because each later generation conditions on the polluted history.
Third, nothing checks the work, since the judge is also the contestant. A lone agent that produces the final result grades itself inside the very context that produced its mistakes. That isn't dishonesty. It's inspecting a scene the model itself just built.
Fourth is silence. Chat-style interfaces make users expect an acknowledgment within seconds, even for an hour-long job, but a loop buried inside a model call can't say it got the request and will report back. Per the MiniMax account, the question "why isn't my agent replying" drew more user feedback than anything else.
The fix: three roles and a deterministic engine
MiniMax answers with structure. Three kinds of role are coordinated by ordinary deterministic code, not by a clever prompt, and that code is called the Team Engine. One task lifecycle is one Session, and the engine pushes every Session through three states, producing, verifying and done, waking the producer again whenever verification fails.
- The Leader breaks the user's request into a structured plan, chooses which pieces run side by side, replies to the user within seconds while the rest proceeds out of sight, and finally merges the results.
- Workers take on subtasks, each in a separate context equipped with its own tools, memory and required output format. That separation matters because a Worker only ever sees one subtask, never two hours of accumulated drift.
- Verifiers convert "it's finished" into "it can be delivered." The relationship is intentionally adversarial. A Worker completing starts the Verifier, and a Verifier rejecting restarts the Worker, like R&D versus QA, with quality coming out of iteration rounds and not out of a human micromanaging.
The engine's core is small enough to write out. This version needs no API key, since stub roles stand in for LLM calls and can be swapped without touching the state machine:
# team_engine.py -- the loop promoted to a
# state machine. One task lifecycle is one
# Session; deterministic code owns every
# transition: producing -> verifying -> done.
from dataclasses import dataclass, field
PRODUCING = "producing"
VERIFYING = "verifying"
DONE = "done"
FAILED = "failed"
@dataclass
class Session:
goal: str
state: str = PRODUCING
attempts: int = 0
artifact: str = ""
reviews: list = field(default_factory=list)
def run_session(s, worker, verifier,
max_retries: int = 3):
while s.state not in (DONE, FAILED):
if s.state == PRODUCING:
s.attempts += 1
s.artifact = worker(
s.goal, s.reviews)
s.state = VERIFYING
elif s.state == VERIFYING:
ok, report = verifier(
s.goal, s.artifact)
s.reviews.append(report)
if ok:
s.state = DONE
elif s.attempts >= max_retries:
s.state = FAILED
else:
# Reject: wake the worker with
# the review log in context.
s.state = PRODUCING
return s
def run_team(goals, worker, verifier):
# Leader's job: split the goal, run the
# sessions (a thread pool in real use),
# then merge N artifacts into 1 result.
done, failed = [], []
for g in goals:
s = run_session(Session(g),
worker, verifier)
if s.state == DONE:
done.append(s.artifact)
else:
failed.append(s.goal)
return done, failed
if __name__ == "__main__":
# Stub roles so the engine runs anywhere;
# swap in LLM calls for the real thing.
def worker(goal, reviews):
v = len(reviews) + 1
return f"draft v{v}: {goal}"
def verifier(goal, artifact):
ok = "v2" in artifact
return ok, f"review {artifact!r}: {ok}"
done, failed = run_team(
["intro", "benchmarks", "faq"],
worker, verifier)
print("delivered:", done)
print("failed:", failed)
Run it and each Session's first version is rejected, the Worker wakes with the review log in its context, and the second version passes. Set it beside the naive loop and a few differences stand out.
- Stopping: the single loop ends when the model stops emitting tool calls (fuzzy) or hits the step cap, while the engine uses explicit done and failed states with a retry budget held in code.
- Verification: in the loop, the same context that produced the work checks it, whereas the team uses an adversarial Verifier in a separate context, grounded in commands.
- Parallelism: the loop is one sequential context, and the team runs Workers in parallel Sessions with the Leader merging many into one.
- Persistence: the loop keeps a message list until summarization, and the team keeps Session state, review records, artifacts and per-agent memory.
The Leader also isn't confined to one-shot calls that spawn a sub-task and return text. In MiniMax's design it reads the engine's live status and can push extra prompts into a Session that is mid-producing or mid-verifying, so there's both proactive push and on-demand query. That also handles the silence problem: the Leader acknowledges within seconds, and the user then gets reports at checkpoints such as started, blocked, decision needed and done.
Grounding the Verifier
The self-grading flaw goes away only when the verdict originates outside every model. For code, MiniMax surrounds execution with a harness. Work lives on branches, runs in sandboxes, edits are diffs, tests can be repeated, reviews leave records and failures can be replayed. The sentence that matters most: a stop condition has to be bound to something deterministic and observable from outside, such as tests, lint and the build, and never to the model's feeling of being done.
# grounded_verifier.py -- evidence over vibes.
# The verdict comes from exit codes and logs,
# never from the model's self-assessment.
import subprocess
import sys
PY = sys.executable
# pytest exit codes: 0 = all passed,
# 1 = failures, 5 = no tests collected
# (acceptable for a docs-only change).
PYTEST_OK = (0, 5)
CHECKS = [
("diff", ["git", "diff", "--stat"], (0,)),
("build", [PY, "-m", "compileall",
"-q", "."], (0,)),
("tests", [PY, "-m", "pytest", "-q",
"--maxfail", "1"], PYTEST_OK),
]
def run_check(name, cmd, ok_codes, cwd):
p = subprocess.run(
cmd, cwd=cwd,
capture_output=True, text=True)
tail = (p.stdout + p.stderr)[-2000:]
return {
"check": name,
"cmd": " ".join(cmd),
"exit_code": p.returncode,
"ok": p.returncode in ok_codes,
"output_tail": tail,
}
def verify(repo: str):
evidence = []
for name, cmd, ok_codes in CHECKS:
e = run_check(name, cmd, ok_codes, repo)
evidence.append(e)
if not e["ok"]:
# Reject with logs attached: the
# worker retries against real
# errors, not "please improve".
return False, evidence
return True, evidence
if __name__ == "__main__":
ok, ev = verify(".")
for e in ev:
print(f"{e['check']:>6} exit "
f"{e['exit_code']} ok={e['ok']}")
print("verdict:", "pass" if ok else "fail")
The evidence list doubles as the review record, part of a replayable, accountable trace that separates something deliverable from something merely plausible. MiniMax divides the reviewing side further. A Tester produces outside evidence by running the tests, compressing failure logs and adding a minimal reproduction. A Reviewer asks a different question, not whether it passed but whether the code should change this way, looking at abstraction boundaries, new dependencies, widened permissions and secrets leaking into logs. Reviewers can run in parallel with specialties such as general, security and domain.
What a team costs, and when to skip it
A team isn't free parallelism. Three costs show up as soon as agents collaborate, and a bigger context window removes none of them.
Handoff cost: the same information has to be reorganized between agents, so research pages become a writer's brief, which becomes input for a format check. The remedy is to pass artifacts, meaning readable files handed over by path with short summaries, instead of raw context dumps.
Sharing cost: every globally shared token is paid for by every worker on every round. A workable layout keeps memory private to each agent, lets agents nudge one another through CLI interrupts, and offers one shared whiteboard that agents read only when they need it.
Aggregation cost is the third. Collecting ten partial drafts is simple. Folding them into a single artifact with matching facts, citations and tone is the Leader's toughest task, and it calls for a merge step, not extra headcount.
Consensus has a price as well. Free-form debate between agents can consume several times the tokens of isolated self-correction and still not improve accuracy. The value lies in structure: bounded sessions, explicit artifacts and a verifier allowed to reject.
Skip the team when the task is small, local and cheap to check, because one loop costs less. Use one when the work divides cleanly, mistakes are expensive and an independent check can yield evidence the model can't talk itself past. A good test: add a role only if it covers a failure mode no other role covers, otherwise the collaboration overhead buys nothing.
What to build first
Start with the smallest state machine you can trust. Each session gets a goal, an artifact, a review log and a retry budget, and every transition is spelled out. Put the verifier behind a harness whose answers come from tests, lint, builds or similar mechanical checks. Parallel workers come later: parallelism is an optimization, whereas correctness boundaries are architecture. Leave the Leader's merge step visible, keep handoff artifacts open to inspection and log why each session ended.
What comes out isn't an autonomous mind. It's a controlled workflow: the models suggest, the tools do the running, and ordinary code rules on whether the job is finished. Build the plain loop by hand once before adopting a framework. Expect long tasks to break it structurally through anxiety, drift, self-grading and silence, which no amount of prompting fixes. Treat multi-agent as a runtime with a control plane of state, events, permissions, observability and recoverable artifacts, not as role-play in a prompt. And budget for handoff, sharing and aggregation before spinning up a team, and never spin one up for a typo.