Stateful agents

July 28, 2026 · View on GitHub

Set Stateful = true to pin every worker task for an execution to one worker process (domain-based routing).

Why it exists

This is required when a WaitForMessageTool runs alongside local tools, so the worker that waits for messages is the same one that receives them. Without it, a message can be delivered to a process that isn't the one blocked on the wait.

Driving a stateful agent

Use StartAsync + SendMessageAsync:

var receive = WaitForMessageTool.Create(name: "wait_for_message",
    description: "Wait for the next external message, then return its content.");

var agent = new Agent("listener")
{
    Model    = "anthropic/claude-sonnet-4-6",
    Stateful = true,
    MaxTurns = 10_000,
    Tools    = [receive, .. ToolRegistry.FromInstance(new ActionTools())],
    Instructions = "Loop: wait_for_message, act on it, repeat until told to stop.",
};

await using var runtime = new AgentRuntime();
var handle = await runtime.StartAsync(agent, "Start listening.");

await runtime.SendMessageAsync(handle.ExecutionId, new { action = "generate-report" });
// ...
await handle.StopAsync();
var result = await handle.WaitAsync();

WaitForMessageTool requires conductor.workflow-message-queue.enabled=true on the server. A per-tool [Tool(Stateful = true)] flag (or ToolDef.Stateful) also marks the parent agent stateful.

Surviving process restarts

Reattach to a durable execution across process restarts:

var handle = await runtime.ResumeAsync(executionId, agent);

ResumeAsync re-registers the local workers as well as reattaching, which is what makes the run continue rather than merely being observable.

Liveness

Stateful runs route their tool tasks to this process's own worker via a per-run domain. If the process that started the run stops polling — crash, restart, network partition — the task sits unpolled forever and a blocking WaitAsync would hang.

AgentRuntime therefore attaches a background liveness monitor to every stateful run (unless CONDUCTOR_AGENT_LIVENESS_ENABLED=false). It polls the workflow's task list every LivenessCheckIntervalSeconds and flags a stall once a SCHEDULED/IN_PROGRESS task has gone unpolled past LivenessStallSeconds.

try
{
    var result = await handle.WaitAsync();
}
catch (WorkerStallException ex)
{
    // ex.TaskReferenceName / ex.ExecutionId — the worker handling this run's
    // domain may have died; the task itself is still SCHEDULED on the server.
}

WaitAsync is itself bounded — a 10-minute overall deadline, and it tolerates up to 3 consecutive transient GetStatus errors before giving up — so a stateful run can never hang forever even without a stall being flagged. The monitor is disposed automatically when the handle's wait completes.

Tuning knobs are in deploy-serve-run.md; the reliability rationale is in ../../reliability.md.