The agent loop
For AI Agents: the Markdown version of this page is at https://ankole.agentbull.com/en-US/docs/agent-loop/index.md. The documentation index is at https://ankole.agentbull.com/en-US/llms.txt.
A turn is a unit of work that spans two runtimes: the Elixir control plane schedules and fences it, and the Bun worker runs the agent loop inside it. This page documents the boundary between the two — what the control-plane TurnLifecycle owns, what the worker’s runAgentLoop owns, and how they communicate across RuntimeFabric. It builds on the Actor Runtime and Agent Computer Worker pages; this is the turn-level detail between them.
The decisive property, stated up front: the control plane owns the turn’s identity and commit; the worker owns the turn’s execution. The worker decides when the loop ends; the control plane decides whether the turn’s result is durable. A turn that the worker reports as finished is not durable until the control plane commits it.
The control-plane side: TurnLifecycle
Ankole.SignalsGateway.ActorRuntime.TurnLifecycle owns what happens around the loop, not inside it. Its responsibilities:
| Responsibility | What it does |
|---|---|
| Lease management | an activation holds a lease (activation_progress_lease_seconds = 2100s, with a 120s grace); the watchdog fails an expired activation so its event can be retried |
| Turn start | creates the ActorSessionActivation with a fresh epoch, assigns the worker, delivers the turn envelope over RuntimeFabric |
| Turn error handling | handle_turn_error/2 receives the worker’s error report, classifies it, and decides retry vs dead-letter |
| Turn commit | records the turn’s outcome as durable truth when the worker reports success |
| Activation expiry | fail_activation_if_expired/2 catches a stuck or crashed turn whose lease ran out |
The turn error retry budget lives here, not in the worker: at most 5 deliveries (@worker_turn_error_dead_letter_attempts). A transient Worker failure waits with exponential backoff between 5 and 120 seconds. A provider capacity failure (an upstream 429, a 5xx, or an exhausted credential pool) waits on its own ladder of 30 seconds, then 2, 5, and 12.5 minutes, so the automatic retry never schedules more than 20 minutes of backoff in total. Each failed attempt bumps the epoch, so a late reply from the failed attempt cannot match a later retry.
The control plane does not decide what the model says, what tools the agent calls, or how many iterations the loop runs. Those are the worker’s.
The worker side: runAgentLoop
runAgentLoop in app/agent_computer/src/core/agent-loop.ts is the four-step loop the worker runs inside a turn:
- Call the model through a turn-scoped OpenAI Responses adapter (AIGateway’s stateful transport).
- Execute function calls locally — if the response carries function-call items, the worker runs the tools.
- Record the outputs through AIGateway, which stores them as function-call-output messages.
- Continue from the journal anchor until the response returns no more function-call items.
The worker owns loop termination and its local iteration budget. Two outcomes:
loop_finished— the model returned with no further tool calls. The turn ends naturally.iteration_exhausted— the worker hit its iteration limit. The model is nudged to synthesize a final response rather than call more tools (MODEL_ITERATION_LIMIT_SYNTHESIS_TEXT), and the turn ends with that synthesis.
The worker also owns three recovery nudges: an empty-after-tools nudge (the model executed tools but returned an empty response), a tool-error recovery hint, and the iteration-limit synthesis. These are worker-side because they are about what the model does next, not about whether the turn is durable.
What the worker does NOT own
The agent-loop moduledoc is explicit: the worker does not own history expansion, compaction, continuation anchors, or durable response state. Those remain in AIGateway. The worker:
- does not decide how much history the model sees (AIGateway’s stateful Responses owns that, including compaction);
- does not store the conversation (AIGateway does);
- does not decide whether the turn’s side effects are committed (the control plane does).
This is the split that makes a worker replaceable: the worker runs the loop, AIGateway owns the transcript, the control plane owns the commit.
How they communicate
| Direction | What crosses the boundary |
|---|---|
| Control plane → worker | a TurnStart envelope (actor identity, turn ref, the event to process) |
| Worker → control plane | progress envelopes (checkpoints and activity summaries), an actor_turn.abort RPC for a failure, or an actor_turn.complete or actor_turn.noop RPC for completion |
| Worker → AIGateway | model calls, function-call outputs (these do not go through the control plane) |
Every worker message carries the ActorTurnRef (activation_uid, actor_epoch, actor_event_id). The control plane checks it against the current activation; a message whose ref no longer matches is rejected as stale. This is the Actor Runtime triple fence, seen from the turn level.
The retry boundary
When a turn fails, the control plane decides retry, not the worker. The worker reports the error; handle_turn_error classifies it:
- Infrastructure (a Codex runtime that is busy, a runtime exception, a steer that could not be delivered) — the interruption is not the task failing, so it does not consume the failure budget.
- Provider capacity (a retryable upstream
429or5xx, or an exhausted credential pool) — the event staysopen, the epoch bumps, and the runtime re-delivers on the capacity ladder above. The Worker does not repeat the model call locally for this class. A credential pool whose recovery time lies past every remaining wait dead-letters at once. - Execution (every other retryable failure, such as a Worker transport failure or a timeout) — the event stays
open, the epoch bumps, the runtime re-delivers after the exponential backoff, and the attempt is charged against the budget. - Dead-letter — after 5 deliveries, or when the failure is not retryable, the event moves to
dead_letterand the turn stops retrying. The user receives a notice that the automatic retry stopped, and an operator inspects and resolves it.
The worker does not retry on its own. It reports the error and the control plane owns the retry decision, because the control plane is what can re-establish the activation fence.
What this guide is not
It is not a model-prompting guide — the loop’s shape is mechanical (call, execute, record, continue), and the model’s behavior within it is the persona’s concern. It is not a transport guide — RuntimeFabric carries the envelopes, and that is the Kernel page’s scope. And it is not a substitute for the Actor Runtime page; the activation fence is the context the turn lifecycle operates inside.
Next steps
- For the activation fence and the actor model, read Actor Runtime.
- For the worker that runs the loop, read Agent Computer Worker.
- For the stateful Responses transport the loop calls, read AIGateway.
- For compaction (which the worker does not own), read Context compression.