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:
| Concern | Question Answered | Key Types |
|---|---|---|
| Turn/Step Loop | How does a user request become LLM calls + tool execution? | runTurn(), runStep() |
| Lifecycle Status | Is the Agent busy or quiescent? | AgentStatus (idle/running), agent/status event |
| Inbox | What work is queued for the next turn/step? | Inbox (next-turn + next-step queues) |
| Cancellation | How is an active turn aborted cleanly? | CancelCause (5 kinds), CancelOptions |
| Request Recovery | What 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)| State | Meaning | When Entered |
|---|---|---|
idle | No driver is active | Initial state; after every runTurn returns |
running | A driver is actively processing | At 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 completeInbox: Dual-Queue Pending Work
The Inbox is the Agent-owned projection of durable pending messages. It maintains two ordered lists:
| Queue | Purpose | Consumed At |
|---|---|---|
next-turn | Prompts awaiting individual turns | Turn boundary (one per turn) |
next-step | Input awaiting the next step boundary | Step 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):
| Cause | Meaning | Typical Trigger |
|---|---|---|
user | User主动取消 | User clicks “stop” |
parent | Parent agent cancels (subagent) | Parent turn ends |
hook | Hook rejection triggers cancel | Pre-step hook returns reject |
disposed | Agent is being disposed | agent.Dispose() |
legacy | Legacy/compatibility path | Old 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
- Records the cancel cause via
turn/stoppingevent - The active
runTurndetects cancellation (via context) and aborts - The turn closes with
reason: interrupted(orabortedfor explicit cancel) - By default, the Inbox is cleared (with
outcome: canceled) - 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}| Decision | Effect |
|---|---|
reject | Do not enter this step; turn may close early |
enter | Enter 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:
Selectupdates thecurrentfor the next step;Capturefreezes current intoassembledat the prompt-assembly boundary;Applyoverrides 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
| Concept | Go Implementation | Official TypeScript |
|---|---|---|
| Agent core loop | pkg/agent/agent.go — runTurn(), runStep() | packages/core/agent/src/index.ts |
| AgentOptions | pkg/agent/options.go — AgentOptions | packages/core/agent/src/runtime-types.ts — AgentOptions |
| AgentStatus | pkg/agent/options.go — AgentStatus; pkg/session/session.go — AgentStatusData | packages/core/agent/src/runtime-types.ts — AgentStatus |
| WhenIdle | pkg/agent/agent.go — WhenIdle() | packages/core/agent/src/runtime-types.ts — whenIdle() |
| Inbox | pkg/agent/inbox.go — Inbox | packages/core/agent/src/inbox.ts — Inbox |
| Inbox events | pkg/session/session.go — InboxSplicedData, InboxTarget | packages/core/agent/src/types.ts — agent/inbox/spliced |
| Cancellation | pkg/agent/cancel.go — CancelCause, RecordCancel | packages/core/agent/src/runtime-types.ts — cancel() |
| PreStepDecision | pkg/agent/options.go — PreStepDecision | packages/core/agent/src/runtime-types.ts — PreStepDecision |
| RequestError | pkg/agent/requesterror.go — RequestErrorWaterfall | packages/core/agent-loop/ — request-error waterfall |
| Initiator | pkg/agent/initiator.go — Initiator | packages/core/agent/src/index.ts — initiator tracking |
| ConsumedWork | pkg/agent/consumed.go — FoldConsumedWork | packages/core/agent/src/consumed-work.ts |
| Model selection | pkg/agent/model_selection.go — ModelSelectionRef | packages/core/agent/src/model-selection.ts |
| Delegation depth | pkg/subagent/depth.go — ChildDepth/CanDelegate | packages/subagent/subagent/src/depth.ts |
Next Steps
- Turn / Step Dual Loop — the nested loop structure in detail
- Sandbox & Controlled Execution — where tool side effects are confined
- Session Event Sourcing — the durable event log that powers everything