Durable Agent Control Flow

Agentic AI Systems advanced Building with AI ~35 min read

Overview

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.

Core Concepts

Agent Events as the Source of Truth

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.

Application-Owned Control Flow

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.

Stateless Model, Durable Session

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.

Key Concept: The Agent Loop Is a Reducer

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.

How It Works

Persist the Thread Before Every Boundary

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.

Classify Actions by Control Policy

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"

Rebuild Context from Durable State

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.

Practical Applications

Customer Operations Agents

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 and DevOps Agents

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 and Back-Office Workflows

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.

Implementation Patterns

Use Typed Events

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"
}

Separate Decision, Approval, and Execution

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.

Make Resume an API, Not a Special Case

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.

Performance Considerations

Common Pitfalls

Further Reading