Production agents are not just prompts wrapped in a while loop. They are long-running software systems that call tools, wait for humans, recover from process restarts, and need enough state to explain what happened. The 12-factor agents guide argues that reliable agent software should expose and own the control flow instead of hiding it behind a framework loop. That means treating every model response, tool call, approval request, and tool result as part of a durable execution record.
A durable agent control loop makes the model stateless and the application stateful. On each turn, the application reconstructs a purpose-built context from persisted events, asks the model for the next structured step, validates that step, executes or pauses it, appends the result, and saves the new state before continuing. The model does not secretly remember progress; the event history and business database do.
This pattern is especially important when an agent can perform side effects: creating tickets, deploying code, issuing refunds, modifying customer records, or contacting people. The control plane must be able to pause before risky actions, resume after external events, retry idempotently, compact context, and show operators a readable trace. Without that control plane, teams either restrict agents to low-value read-only tasks or accept unacceptable operational risk.
Instead of storing a mutable “current step” field separate from a transcript, store an append-only sequence of events. Events can include user requests, model decisions, tool calls, tool results, approval requests, human responses, retries, errors, and final answers. The current execution state is derived from those events, which makes debugging, replay, forking, and recovery simpler.
The LLM can choose an intent, but deterministic code decides what that intent means. A tool call that reads public data may run immediately. A tool call that changes production state may create an approval event and stop the loop. A request for clarification may send a message to a user and wait for a webhook. This division keeps the agent flexible while preserving explicit safety boundaries.
Each model call should be reproducible from durable state plus the current prompt-building policy. If a worker crashes after saving an approval request, another worker should resume from the same event log without asking the model to rediscover the entire task. Long-running agent systems should not depend on in-memory sleeps, process-local retry counters, or hidden framework state.
A durable agent loop can be modeled as next_state = reduce(events). The LLM proposes the next event, but the application validates, persists, and interprets that event. This makes the model a decision component inside a larger state machine rather than the owner of the state machine.
The loop alternates between model decisions and deterministic effects. Before crossing a boundary that might block, fail, or perform a side effect, persist an event that records the intended action. After the action completes, persist the observed result. That pair of events lets the runtime decide whether to retry, resume, or ask a human to inspect the pending action.
async def run_agent(thread_id: str) -> None:
thread = await db.load_thread(thread_id)
while True:
prompt = build_prompt_from_events(thread.events)
decision = await llm_next_step(prompt)
decision = validate_decision(decision)
await db.append_event(thread_id, {
"type": "model_decision",
"decision": decision,
})
if decision["intent"] == "done":
await db.append_event(thread_id, {
"type": "final_answer",
"text": decision["answer"],
})
return
if requires_approval(decision):
await db.append_event(thread_id, {
"type": "approval_requested",
"tool_call": decision,
})
await notify_reviewer(thread_id, decision)
return
result = await execute_tool_idempotently(decision)
await db.append_event(thread_id, {
"type": "tool_result",
"tool_call_id": decision["id"],
"result": result,
})
thread.events.append({"type": "model_decision", "decision": decision})
thread.events.append({"type": "tool_result", "tool_call_id": decision["id"], "result": result})
The important detail is not the language or framework. The important detail is that the persisted events bracket every meaningful transition. If the process dies after sending the approval notification, the thread still contains the approval request. If it dies after executing a tool but before recording the result, the idempotency key attached to the tool call prevents duplicate side effects.
A single agent loop can handle multiple execution modes by classifying each structured decision. Synchronous read-only calls can continue the loop immediately. Long-running jobs can append a waiting event and resume from a callback. High-risk writes can require human approval before invocation. Clarification requests can suspend until the user replies.
def classify(decision):
if decision["intent"] in {"search_docs", "fetch_open_issues"}:
return "run_now"
if decision["intent"] in {"deploy_backend", "refund_customer", "delete_record"}:
return "approve_before_run"
if decision["intent"] in {"request_clarification", "wait_for_training_job"}:
return "wait_for_external_event"
return "reject_unknown_intent"
Durable state does not mean stuffing every event into every prompt. The event log is the complete record; the prompt is a projection. Prompt builders can summarize old events, include only active constraints, elide noisy tool payloads, and attach pointers to large artifacts. Because the projection is deterministic and versioned, teams can debug why the model saw a particular context at a particular turn.
Support and success agents often mix low-risk retrieval with high-risk actions. Looking up an order can run immediately, while issuing a refund or changing an account tier may require approval. Durable control flow lets the same session move between automated steps and human checkpoints without losing conversation history.
Coding agents can run tests, inspect logs, and edit files autonomously, but deployment, secret rotation, and production configuration changes should be gated. Persisting each proposed command, diff, test result, and reviewer decision creates an audit trail that supports rollback and post-incident analysis.
Research agents may wait on slow crawls, third-party APIs, human-provided documents, or scheduled refreshes. Back-office agents may span hours or days while documents are reviewed. Durable events allow these workflows to pause without keeping a worker alive and to resume with a compact, purpose-built context.
Define a small event schema instead of logging arbitrary strings. Typed events make it possible to validate state transitions, render operator UIs, compute metrics, and write migrations. Include correlation identifiers for tool calls and idempotency keys for effects that might be retried.
{
"thread_id": "thr_123",
"event_id": "evt_009",
"type": "tool_call_requested",
"tool_call_id": "call_004",
"intent": "create_issue",
"arguments": {
"repo": "example/api",
"title": "Investigate failed webhook retries"
},
"policy": "approve_before_run",
"idempotency_key": "thr_123:call_004"
}
Do not let a model-generated tool call immediately perform irreversible work. First persist the requested call, then run policy, then either execute it or append an approval request. This ordering gives security and compliance systems a pre-execution control point rather than a post-hoc log.
Resumption should be the normal way an agent continues after any asynchronous boundary. A webhook, cron tick, human approval, or background job completion should append an event and enqueue the same runner. This keeps the loop simple: load state, reduce events, build context, ask for the next step, and persist the transition.