Turn / Step Dual Loop

Turn / Step Dual Loop

In One Sentence

Every Agent conversation is structured as a nested dual loop: a Turn is one user request and its complete resolution, and inside each Turn, one or more Steps each represent one LLM call plus its tool execution — both loops carry strictly monotonic numbers and are enforced by session invariants.

Why Two Loops?

A single loop is not enough because a user request often requires multiple LLM calls before it is complete:

User: "List files, then count lines in each .go file"
  └─ Turn 0 starts
       ├─ Step 1: LLM decides to call `ls` → tool executes → result
       ├─ Step 2: LLM sees file list, decides to call `wc -l` on each → tool executes → result
       └─ Step 3: LLM synthesizes final answer (no more tool calls)
  └─ Turn 0 ends

The Turn boundary answers: “Is this user request resolved?” The Step boundary answers: “Did this LLM call produce tool calls that need another round?”

This separation is what enables:

  • Per-turn cancellation — abort one user request without losing session history
  • Per-step retry — re-run a failed LLM call without restarting the whole turn
  • Accurate token accounting — count tokens per step, sum per turn
  • Tool-call scoping — every tool/call must belong to an open Step, which must belong to an open Turn

The Four Event Markers

The dual loop is expressed entirely through four event types in the session log:

EventDataMeaning
turn/start{ turn: uint64 }A new Turn begins
turn/end{ turn: uint64, reason: TurnEndReason }The current Turn closes
step/start{ turn: uint64, step: uint64 }A new Step begins inside the current Turn
step/end{ turn: uint64, step: uint64 }The current Step closes

Plus a fifth auxiliary event:

EventDataMeaning
turn/stopping{ reason?: string }Turn is entering shutdown (listeners like goal-round-driver hook here)
 1// pkg/session/session.go
 2type TurnStartData struct {
 3    Turn uint64 `json:"turn"`
 4}
 5
 6type TurnEndData struct {
 7    Turn   uint64        `json:"turn"`
 8    Reason TurnEndReason `json:"reason"`
 9}
10
11type StepStartData struct {
12    Turn   uint64 `json:"turn"`
13    Step   uint64 `json:"step"`
14    StepSeq uint64 `json:"stepSeq,omitempty"` // legacy alias
15}
16
17type StepEndData struct {
18    Turn   uint64 `json:"turn"`
19    Step   uint64 `json:"step"`
20    StepSeq uint64 `json:"stepSeq,omitempty"` // legacy alias
21}

Strict Monotonic Numbering

This is the most important invariant — and the one most easily missed.

Turn numbering

  • Turns are numbered from 0
  • Each turn/start must carry turn == nextTurn
  • After turn/end, nextTurn increments by 1
turn/start {turn: 0}  → nextTurn was 0 ✓ → nextTurn stays 0 (turn open)
turn/end   {turn: 0}  → matches openTurn 0 ✓ → nextTurn becomes 1
turn/start {turn: 1}  → nextTurn was 1 ✓
turn/start {turn: 0}  → ✗ REJECTED: expected turn 1, got 0

Step numbering

  • Steps are numbered from 1 (not 0)
  • Each new Turn resets nextStep to 1
  • Each step/start must carry step == nextStep
  • After step/end, nextStep increments by 1
turn/start {turn: 0}  → nextStep reset to 1
step/start {turn:0, step:1}  → nextStep was 1 ✓ → nextStep becomes 2
step/end   {turn:0, step:1}  → matches openStep 1 ✓
step/start {turn:0, step:2}  → nextStep was 2 ✓
turn/end   {turn: 0}
turn/start {turn: 1}  → nextStep reset to 1 again
step/start {turn:1, step:1}  → nextStep was 1 ✓ (reset worked)

Why strict?

Strict monotonic numbering serves three purposes:

  1. Detect missing events — if turn/start {turn: 5} appears after turn/end {turn: 3}, you know turns 4 was lost (or the log is corrupted)
  2. Enable deterministic replay — given a sequence of events, you can always reconstruct the loop state without ambiguity
  3. Catch producer bugs — if the Agent forgets to close a Step before opening a new one, the numbering mismatch surfaces immediately

Nesting Rules

The dual loop has a strict nesting hierarchy:

turn/start
  ├─ (user/message)
  ├─ step/start
  │    ├─ (agent/request)
  │    ├─ (assistant/chunk...)
  │    ├─ (tool/call)
  │    ├─ (tool/result)
  │    └─ (assistant/message)
  ├─ step/end
  ├─ step/start  (optional: another LLM round)
  ├─ step/end
  └─ (turn/stopping)
turn/end

Enforced invariants

RuleViolationError
turn/start requires no open TurnTwo turn/start without turn/endturn/start while turn already open
turn/end requires an open Turnturn/end without turn/startturn/end without open turn/start
turn/end requires no open Stepturn/end while Step is openturn/end while step still open
step/start requires an open Turnstep/start outside Turnstep/start without open turn
step/start requires no open StepTwo step/start without step/endstep/start while step already open
step/end requires an open Stepstep/end without step/startstep/end without open step/start
tool/call requires an open Steptool/call outside Steptool/call without open step
Turn numbers strictly increaseturn/start {turn: 0} after turn/end {turn: 0}turn/start expected turn 1, got 0
Step numbers strictly increasestep/start {step: 1} after step/end {step: 1}step/start expected step 2, got 1
Step’s turn matches open turnstep/start {turn: 5} while open turn is 0step/start in turn 5 but open turn is 0

All of these are enforced in SessionLog.applyState() — the single write path. No event can bypass these checks.

TurnEndReason: The Complete Vocabulary

A Turn can close for six reasons:

ReasonMeaningTypical Trigger
completedTurn resolved normallyAgent produced final answer with no tool calls
interruptedTurn was interrupted mid-executionContext cancellation, user abort
abortedTurn was deliberately abortedAgent.Cancel() with a cancel cause
blockedTurn was blocked by a safety gateApproval denied, sandbox escalation rejected
errorTurn failed due to an internal errorLLM API error, tool execution panic
max-tokensTurn hit the token limitLLM returned finish_reason: length
 1// pkg/session/session.go
 2const (
 3    ReasonCompleted   TurnEndReason = "completed"
 4    ReasonFinished    TurnEndReason = "finished"    // legacy alias for completed
 5    ReasonInterrupted TurnEndReason = "interrupted"
 6    ReasonAborted     TurnEndReason = "aborted"
 7    ReasonBlocked     TurnEndReason = "blocked"
 8    ReasonError       TurnEndReason = "error"
 9    ReasonMaxTokens   TurnEndReason = "max-tokens"
10)

Note: finished is retained as a legacy alias for backward compatibility. New code should use completed.

State Projection: Querying the Loop State

The SessionLog maintains the full loop state internally and exposes read-only query methods:

 1// Next turn number (starts at 0, increments after each turn/end)
 2func (sl *SessionLog) NextTurn() uint64
 3
 4// Next step number in current turn (starts at 1, resets on each turn/start)
 5func (sl *SessionLog) NextStep() uint64
 6
 7// Currently open turn (0, false) if no turn is open
 8func (sl *SessionLog) OpenTurn() (uint64, bool)
 9
10// Currently open step (0, false) if no step is open
11func (sl *SessionLog) OpenStep() (uint64, bool)

How the Agent uses them

The Agent queries NextTurn() before creating a turn/start event, ensuring the number is always correct:

 1// pkg/agent/agent.go — runTurn
 2func (a *Agent) runTurn(req *turnReq) {
 3    turnIdx := a.log.NextTurn()  // query current number
 4
 5    if _, err := a.log.Append(session.TurnStartData{Turn: turnIdx}); err != nil {
 6        return
 7    }
 8    // ... steps ...
 9    a.log.Append(session.TurnEndData{Turn: turnIdx, Reason: session.ReasonFinished})
10}

This pattern — query before append — is the canonical way to ensure strict monotonic numbering without maintaining a separate counter in the Agent.

Streaming Block Assembly

Model output arrives as a chunk stream. llm.BlockAssembler is the single canonical assembler: it merges consecutive text/reasoning deltas, fixes tool-call chunks into tool-use blocks, preserves stream order, and yields the assistant message via Message(). Size is estimated by tokenmeter.EstimateMessage at fixed density (4 chars/token); crossing the budget triggers compaction — an assemble → estimate → compact loop.

Crash Repair: Orphan Turns

If the process crashes mid-Turn, the persisted log will contain a turn/start without a matching turn/end. The persistence layer’s repairOrphanTurn function detects this and appends a synthetic turn/end {reason: interrupted}:

 1// pkg/persistence/jsonl.go — repairOrphanTurn
 2func repairOrphanTurn(events *[]session.SessionEvent) int {
 3    // Scan all events to determine if a turn is open at the end
 4    turnOpen := false
 5    var openTurn uint64
 6    for _, ev := range *events {
 7        switch ev.Type {
 8        case session.EventTurnStart:
 9            turnOpen = true
10            if td, ok := ev.Data.(session.TurnStartData); ok {
11                openTurn = td.Turn  // capture the turn number
12            }
13        case session.EventTurnEnd:
14            turnOpen = false
15        }
16    }
17    if !turnOpen {
18        return 0
19    }
20    // Append synthetic turn/end with the correct turn number
21    last := (*events)[len(*events)-1]
22    repaired := session.SessionEvent{
23        Seq:  last.Seq + 1,
24        Time: last.Time,
25        Type: session.EventTurnEnd,
26        Data: session.TurnEndData{Turn: openTurn, Reason: session.ReasonInterrupted},
27    }
28    *events = append(*events, repaired)
29    return 1
30}

The repair preserves the correct turn number — it doesn’t just append a bare turn/end. This ensures the repaired log still passes all numbering invariants.

Interaction with Other Subsystems

Tool Pipeline

Every tool/call and tool/result must occur inside an open Step. The tool pipeline does not manage Turn/Step itself — it relies on the Agent to have opened the appropriate Step before invoking tools.

Approval

Approval requests (approval/request, approval/decided) can occur inside a Step. The approval system does not enforce Turn/Step nesting — that’s the session invariant’s job.

Persistence

The JSONL backend persists all Turn/Step events verbatim. On load, it runs repairOrphanTurn to fix any crash-induced orphans. The shard/async backend (H02) batches events but preserves order and numbering.

Cancellation

Agent.Cancel(cause) records a cancel cause and the Turn closes with aborted (or interrupted if the cancellation comes from context). The cancel cause is extracted via ExtractCancelCause(events) by scanning for turn/stopping events with cancel markers.

Benefits and Costs

Benefits

  • Deterministic replay — any log can be replayed to reconstruct exact loop state
  • Early bug detection — numbering mismatches catch producer bugs at write time, not hours later
  • Clean cancellation boundaries — Turn boundaries give natural points to abort without corrupting state
  • Accurate metrics — per-turn and per-step token/latency accounting is trivial
  • Crash recoverability — orphan turns can be detected and repaired deterministically

Costs

  • Producer complexity — every event creator must know the correct turn/step number
  • No out-of-order writes — events must be appended in strict loop order; you can’t “backfill” a step later
  • Migration burden — changing the numbering scheme (e.g., step from 0 instead of 1) requires a full log migration
  • Testing surface — every test that creates Turn/Step events must use correct numbers or the invariant rejects them

Source Reference

ConceptGo ImplementationOfficial TypeScript
Event data structurespkg/session/session.go — TurnStartData, TurnEndData, StepStartData, StepEndDatapackages/core/session/src/types.ts — SessionEventMap
TurnEndReasonpkg/session/session.go — TurnEndReason const blockpackages/core/session/src/types.ts — TurnEndReasonMap
Pairing & nesting invariantspkg/session/session.go — applyState()packages/core/session/src/invariant.ts — validateEvent()
State counterspkg/session/session.go — sessionState.nextTurn/nextStep/openTurn/openSteppackages/core/session/src/invariant.ts — SessionTrace
Query methodspkg/session/session.go — NextTurn(), NextStep(), OpenTurn(), OpenStep()(not exposed in official; maintained internally)
Agent looppkg/agent/agent.go — runTurn(), runStep()packages/core/agent/src/ — agent loop
Crash repairpkg/persistence/jsonl.go — repairOrphanTurn()packages/core/session/src/repair.ts
Block assemblypkg/llm/assembler.go — BlockAssemblerpackages/llm/llm/src/assembler.ts

Next Steps