Agent Loop & Runtime

Agent Loop & Runtime

In One Sentence

The Agent is the runtime driver that turns a SessionLog into a living conversation — it owns the Turn/Step dual loop, lifecycle status (idle/running), a dual-queue Inbox for pending work, cancellation semantics, and request-error recovery — all expressed as durable session events.

What the Agent Owns

The Agent sits between the SessionLog (durable truth) and the LLM/Tools (external services). It owns five runtime concerns:

ConcernQuestion AnsweredKey Types
Turn/Step LoopHow does a user request become LLM calls + tool execution?runTurn(), runStep()
Lifecycle StatusIs the Agent busy or quiescent?AgentStatus (idle/running), agent/status event
InboxWhat work is queued for the next turn/step?Inbox (next-turn + next-step queues)
CancellationHow is an active turn aborted cleanly?CancelCause (5 kinds), CancelOptions
Request RecoveryWhat happens when an LLM request fails?RequestErrorAction (retry/abort), RequestErrorWaterfall

AgentOptions: Immutable Configuration

Created once at construction time, never changes at runtime:

1type AgentOptions struct {
2    Provider         string        // provider route (e.g. "deepseek")
3    Model            string        // model id (e.g. "deepseek-chat")
4    ReasoningEffort  string        // adapter-owned reasoning level
5    MaxTokens        int           // max output tokens per request
6    DefaultTimeout   time.Duration // default run timeout (0 = none)
7}
1agent := NewAgent(id, log, sys, pipeline, adapter, AgentOptions{
2    Provider: "deepseek",
3    Model:    "deepseek-chat",
4    MaxTokens: 4096,
5})

Persona and system-prompt sections belong to pkg/sysprompt, not AgentOptions.

AgentStatus: Lifecycle State Machine

The Agent has exactly two observable states, emitted via agent/status events on every transition:

         turn/start (runTurn begins)
    ┌──────────────────────────────┐
    │                              ▼
  idle                          running
    ▲                              │
    └──────────────────────────────┘
         turn/end (runTurn returns)
StateMeaningWhen Entered
idleNo driver is activeInitial state; after every runTurn returns
runningA driver is actively processingAt the start of runTurn (before turn/start)
1// Query current status
2status := agent.Status()  // "idle" or "running"
3
4// Wait for quiescence (blocks until idle)
5err := agent.WhenIdle(ctx)

WhenIdle: Waiting for Quiescence

WhenIdle(ctx) resolves after the current whole-agent activity reaches quiescence. If already idle, it returns immediately. This is useful for:

  • Tests that need to assert final state
  • Graceful shutdown (wait for active turn to finish)
  • Batch processing (wait for one turn before starting next)
1agent.Run("do something")
2if err := agent.WhenIdle(context.Background()); err != nil {
3    // context cancelled
4}
5// agent is now idle, session log is complete

Inbox: Dual-Queue Pending Work

The Inbox is the Agent-owned projection of durable pending messages. It maintains two ordered lists:

QueuePurposeConsumed At
next-turnPrompts awaiting individual turnsTurn boundary (one per turn)
next-stepInput awaiting the next step boundaryStep boundary (all at once)

Why Two Queues?

A user request may arrive mid-turn (while the Agent is processing). Rather than interrupting the active turn, the message is queued:

  • next-step: consumed at the next step boundary — the LLM sees it in the very next step
  • next-turn: consumed only when the current turn ends — it becomes the next user request

This separation enables steering (mid-turn input) without violating turn boundaries.

Core Operations

 1inbox := agent.Inbox()
 2
 3// Check if there's pending work
 4inbox.HasPending()  // true if either queue non-empty
 5
 6// Push a message to next-step (steering input)
 7inbox.Push(log, session.InboxNextStep, UserMessage{Content: "also check tests"})
 8
 9// Push a message to next-turn (next user request)
10inbox.Push(log, session.InboxNextTurn, UserMessage{Content: "now do this"})
11
12// Claim the batch for a step (removes + returns next-step + optionally one next-turn)
13batch := inbox.Claim(log, session.InboxNextTurn, turnIdx)
14
15// Clear all pending work (on cancellation)
16inbox.Clear(log)

Durability: agent/inbox/spliced Events

Every Inbox mutation is persisted as an agent/inbox/spliced event:

1type InboxSplicedData struct {
2    Target       InboxTarget // "next-turn" or "next-step"
3    Start        int         // splice position (-1 = append)
4    RemovedCount int         // number removed
5    Inserted     []string    // contents inserted
6    Outcome      string      // "canceled" when cleared by cancellation
7}

On Agent construction, the Inbox replays all historical spliced events to reconstruct its state. This means the Inbox is fully crash-recoverable.

Cancellation: Five Causes, One Mechanism

The Agent supports five cancellation causes (aligned with the official AgentCancelCause):

CauseMeaningTypical Trigger
userUser主动取消User clicks “stop”
parentParent agent cancels (subagent)Parent turn ends
hookHook rejection triggers cancelPre-step hook returns reject
disposedAgent is being disposedagent.Dispose()
legacyLegacy/compatibility pathOld cancellation code
1// Cancel the active turn
2agent.Cancel(CancelUser)
3
4// Cancel but preserve queued inbox items
5agent.Cancel(CancelUser)  // with CancelOptions{KeepInbox: true}

What Cancellation Does

  1. Records the cancel cause via turn/stopping event
  2. The active runTurn detects cancellation (via context) and aborts
  3. The turn closes with reason: interrupted (or aborted for explicit cancel)
  4. By default, the Inbox is cleared (with outcome: canceled)
  5. With KeepInbox: true, queued items survive for a later turn

PreStepDecision: Gatekeeping Step Entry

Before entering a proposed step, listeners can return a PreStepDecision:

1type PreStepDecision struct {
2    Kind               string        // "reject" or "enter"
3    Messages           []UserMessage // messages to carry in (enter only)
4    StartsRequestSeries bool         // start a new model-message series
5}
DecisionEffect
rejectDo not enter this step; turn may close early
enterEnter the step with the specified messages

This is the extension point for hooks, approval gates, and steering logic that need to inspect or modify input before the LLM sees it.

RequestError Recovery: Retry Waterfall

When an LLM request fails, the Agent runs a request-error waterfall to decide the action:

LLM request fails
    ↓
RequestErrorWaterfall (middleware chain)
    ├─ middleware 1: check error kind → retry?
    ├─ middleware 2: check retry count → abort?
    └─ ...
    ↓
TerminalRetryDecision (fallback)
    ├─ retryable error + under max → retry
    └─ otherwise → abort (turn closes with error)
 1type RequestErrorAction struct {
 2    Kind   RequestErrorActionKind // "retry" or "abort"
 3    Reason string                 // audit reason
 4}
 5
 6// Build a waterfall with custom middleware
 7chain := NewRequestErrorWaterfall(
 8    func(ctx context.Context, p *RequestErrorPayload, next func()) {
 9        if p.RetryableError && p.RetryCount < 3 {
10            p.Action = &RequestErrorAction{Kind: ActionRetry, Reason: "custom retry policy"}
11            return  // short-circuit
12        }
13        next()
14    },
15)
16
17// Resolve the final action
18action := ResolveRequestError(chain, payload)

Retryable Errors

Only overload and rate-limit errors are retryable (classified by llm.ClassifyLlmError). Other errors (auth, invalid request, etc.) immediately abort.

ConsumedWork: Account for Consumed Work

Reading turn/end alone conflates a turn cut short before its first step with the balanced no-op a rejection or empty claim produces. agent.FoldConsumedWork folds the log in one pass:

  • End: the latest turn that actually accounted for work — it entered a model step, or claimed input and then failed/was blocked;
  • DroppedUnrun: whether work was cancelled out of the inbox unrun before any turn could open.

accountsForClaim: of turns that claimed input but never reached a step, only completed does not account for it; blocked/aborted/error/interrupted are the input’s ending too.

Model Selection Dual Snapshot

A model can switch at runtime, but the prompt-assembly surface and request-routing surface must not use different models. agent.ModelSelectionRef:

  • Select updates the current for the next step;
  • Capture freezes current into assembled at the prompt-assembly boundary;
  • Apply overrides provider/model/effort with the assembled snapshot when sending.

A concurrent switch thus takes effect only on a later step; when the captured selection carries no reasoning effort, Apply clears the inherited effort to restore the selected model’s default.

Delegation Depth

A recursion budget prevents unbounded nesting. In the subagent package: a top-level agent is depth 0, ChildDepth adds one per level; ResolveDepth takes the max of the persisted header and runtime depth (runtime can only deepen); CanDelegate(depth, maxDepth) gates further spawning.

Interaction with Other Subsystems

SessionLog

The Agent is the primary writer to the SessionLog. Every runtime transition — turn/start, step/start, agent/request, tool/call, agent/status, agent/inbox/spliced — is a durable event. The Agent never mutates state directly; it appends events and lets projections derive state.

LLM Adapter

The Agent calls the adapter’s Stream() method inside runStep(). The adapter is selected based on AgentOptions.Provider. Request errors flow into the RequestErrorWaterfall.

Tool Pipeline

Tool calls discovered in the LLM response are executed through the 4-stage pipeline (resolve → approve → sandbox → execute). The pipeline is injected at construction time.

Sandbox

The Sandbox decides where tool side effects can happen. The Agent doesn’t interact with the Sandbox directly — it’s the Tool Pipeline’s responsibility.

Approval

Approval decides whether a tool can run. Like the Sandbox, this is handled inside the Tool Pipeline, not by the Agent directly.

Benefits and Costs

Benefits

  • Event-sourced runtime — every state transition is durable and replayable
  • Clean cancellation — turn boundaries give natural abort points; inbox can be preserved or cleared
  • Steering without interruption — next-step queue lets users inject input mid-turn
  • Status observability — idle/running is a first-class event, not a derived guess
  • Composable error recovery — waterfall middleware lets plugins customize retry logic

Costs

  • Two queues to reason about — next-turn vs next-step is subtle; misuse can cause messages to be delayed or lost
  • Status is coarse — only idle/running; no “waiting for tool” or “streaming” sub-states
  • Cancellation is cooperative — the Agent must poll context; a stuck tool call may not cancel promptly
  • Inbox replay overhead — constructing an Agent replays all spliced events; very long sessions have O(n) construction cost

Source Reference

ConceptGo ImplementationOfficial TypeScript
Agent core looppkg/agent/agent.go — runTurn(), runStep()packages/core/agent/src/index.ts
AgentOptionspkg/agent/options.go — AgentOptionspackages/core/agent/src/runtime-types.ts — AgentOptions
AgentStatuspkg/agent/options.go — AgentStatus; pkg/session/session.go — AgentStatusDatapackages/core/agent/src/runtime-types.ts — AgentStatus
WhenIdlepkg/agent/agent.go — WhenIdle()packages/core/agent/src/runtime-types.ts — whenIdle()
Inboxpkg/agent/inbox.go — Inboxpackages/core/agent/src/inbox.ts — Inbox
Inbox eventspkg/session/session.go — InboxSplicedData, InboxTargetpackages/core/agent/src/types.ts — agent/inbox/spliced
Cancellationpkg/agent/cancel.go — CancelCause, RecordCancelpackages/core/agent/src/runtime-types.ts — cancel()
PreStepDecisionpkg/agent/options.go — PreStepDecisionpackages/core/agent/src/runtime-types.ts — PreStepDecision
RequestErrorpkg/agent/requesterror.go — RequestErrorWaterfallpackages/core/agent-loop/ — request-error waterfall
Initiatorpkg/agent/initiator.go — Initiatorpackages/core/agent/src/index.ts — initiator tracking
ConsumedWorkpkg/agent/consumed.go — FoldConsumedWorkpackages/core/agent/src/consumed-work.ts
Model selectionpkg/agent/model_selection.go — ModelSelectionRefpackages/core/agent/src/model-selection.ts
Delegation depthpkg/subagent/depth.go — ChildDepth/CanDelegatepackages/subagent/subagent/src/depth.ts

Next Steps