Chapter 11: TurnLoop — Preemption, Abort, and Multi-Turn Lifecycle

In the previous chapter, we used adk.Runner to implement a complete A2UI Web application. It works fine, but try this scenario:

You ask the Agent a complex question. It starts calling tools, generating a long answer… but you suddenly realize you asked the wrong thing and want to switch to a different question.

In the previous chapter’s Runner mode, you can only wait for it to finish or refresh the page and lose everything.

This chapter introduces adk.TurnLoop, enabling two new user-facing capabilities for the Agent: Preemption and Abort.

Prerequisites

Same as Chapter 1: You need to configure an available ChatModel (OpenAI or Ark). See the “Prerequisites” section in Chapter 1 for details.

Running & Trying It Out

In the quickstart/chatwitheino directory, execute:

go run .

Open your browser to http://localhost:8080, then try the following:

Experience Preemption

  1. Send a question that triggers a long answer, e.g., “Explain all of Eino’s components in detail”
  2. While the Agent is still answering, send a new message directly, e.g., “Never mind, just tell me what ChatModel is”
  3. Observe: The old answer stops immediately, and the Agent begins answering the new question

Experience Abort

  1. Send a question
  2. While the Agent is answering, click the Abort button in the top-right corner
  3. Observe: The Agent stops immediately and produces no further output

Neither of these capabilities existed in the previous chapter’s Runner version. Below explains how they are implemented.

Code Locations

Why Runner Can’t Do This

In the previous chapter’s cmd/ch10, each /sessions/:id/chat request calls runner.Run(ctx, messages) once. Runner is a single-turn model — call once, execute once, finish. If a user sends another message while the Agent is executing, Runner has no “running loop” to receive it.

TurnLoop is a persistent multi-turn execution loop. It remains idle between turns, ready to receive new input via Push() and respond immediately. Because there is a continuously running loop, preemption and abort become possible — you can interrupt an ongoing turn or stop the entire loop.

CapabilityCh10 (Runner, single-turn)Ch11 (TurnLoop, multi-turn)
Streaming output
Approval / interrupt
Persistent cross-turn execution, real-time response to new input❌ Each Run() is independent✅ Push() anytime
Preempt an ongoing answer✅ Push(item, WithPreempt(...))
Abort Agent✅ loop.Stop(WithImmediate())
Flexible per-turn input construction❌ Business layer manually assembles✅ GenInput callback

TurnLoop’s Core Model

TurnLoop is a push-based event loop that manages Agent execution in units of turns. Unlike Runner’s “call once, execute once” model, TurnLoop runs continuously: after a turn ends, it enters idle wait; when a new item arrives, it immediately starts the next turn.

Push(item) → [queue] → GenInput(items) → Agent.Run() → OnAgentEvents(events)
                                ↑                              │
                                └──── idle wait / next turn ←──┘

Key concepts:

  • Item: The carrier of user input. This example defines it as ChatItem, which can carry user messages or approval decisions
  • GenInput: Builds Agent input from items in the queue (decides which items to consume and which to retain for the next turn)
  • OnAgentEvents: Receives the Agent’s output event stream, responsible for rendering and persistence
  • Push: Pushes a new item to the queue, with optional preemption options

One Session Corresponds to One TurnLoop

In this example’s Web scenario, each chat session corresponds to one TurnLoop instance. When a user sends their first message, the server creates a TurnLoop for that session and calls Run() to start it; subsequent messages are fed into the same loop via Push(). The loop remains idle between turns until the session is deleted or the user aborts.

This is TurnLoop’s most typical usage pattern: the loop’s lifecycle is bound to the user session. A long-running TurnLoop makes preemption and abort natural operations — because the “running loop” always exists, new input can be fed in at any time.

Normal Flow: idle → new message → answer → idle

The simplest scenario is the user asking questions sequentially, waiting for answers, then asking the next:

// When the user sends the first message, create and start TurnLoop
loop := adk.NewTurnLoop(cfg)
loop.Push(&ChatItem{Query: "hello"})
loop.Run(ctx)
// → GenInput builds input → Agent executes → OnAgentEvents streams output
// → Turn ends, TurnLoop enters idle wait

// User sends second message (loop is idle at this point)
loop.Push(&ChatItem{Query: "explain Eino's architecture"})
// → TurnLoop wakes up, starts new turn: GenInput → Agent → OnAgentEvents → idle

This flow is no different from the previous chapter’s Runner in terms of user experience — the difference is that TurnLoop’s loop persists and doesn’t need to be recreated each time. Once a user sends a new message while the Agent is still answering, we enter the “preemption” scenario below.

How Preemption Works

When a user sends a new message while the Agent is answering, the business layer only needs one line of code to trigger preemption:

loop.Push(item, adk.WithPreempt[*ChatItem, M](adk.AfterToolCalls))

After TurnLoop receives this instruction:

  1. Waits for the current tool call to complete (AfterToolCalls means don’t interrupt an executing tool to avoid inconsistent state)
  2. Cancels the current turn — OnAgentEvents’ context is cancelled, the old turn exits
  3. Takes the new item from the queue, builds input via GenInput, starts a new turn

Preemption mode allows choosing different safe points based on business needs:

ModeSpecific Behavior
AfterToolCallsWaits for the currently executing tool calls to complete, then cancels the current turn and starts a new turn
AfterChatModelWaits for the current LLM call to complete, then cancels the current turn and starts a new turn
AnySafePointImmediately cancels the current turn at any safe point (e.g., between tool calls, between model calls) and starts a new turn

In this example, TurnLoop runs in a separate goroutine while the HTTP handler needs to write the event stream to the SSE response. The two coordinate via channels (see iterEnvelope/iterResult and the handlerDone signaling mechanism in server/server.go). These are HTTP adaptation layer details, not part of the TurnLoop API itself.

How Abort Works

Abort is simpler — directly stop the entire TurnLoop:

loop.Stop(adk.WithImmediate())  // Cancel immediately without waiting for current turn
loop.Wait()                     // Wait for complete exit

Three Modes of Stop

ModeSpecific Behavior
loop.Stop()Turn-boundary exit: waits for the current turn to complete before exiting
loop.Stop(WithImmediate())Immediate exit: cancels the current turn's context
loop.Stop(WithGraceful())Safe-point exit: exits at the next safe point (e.g., between tool calls)

TurnLoop Configuration

When creating a TurnLoop, specify callbacks and options via TurnLoopConfig:

cfg := adk.TurnLoopConfig[*ChatItem, M]{
    // GenInput: Called at the start of each turn, decides "what the Agent sees this turn"
    // Selects items from the queue to build Agent input, returns Consumed (processed this turn) and Remaining (kept for later turns)
    GenInput: func(ctx context.Context, loop *adk.TurnLoop[*ChatItem, M], items []*ChatItem) (*adk.GenInputResult[*ChatItem, M], error) {
        // ...build AgentInput, persist user messages...
    },

    // PrepareAgent: Called once per turn, returns the Agent to use for this turn
    // This example returns the same Agent, but you can dynamically select different Agents based on items
    PrepareAgent: func(ctx context.Context, loop *adk.TurnLoop[*ChatItem, M], consumed []*ChatItem) (adk.TypedAgent[M], error) {
        return agent, nil
    },

    // OnAgentEvents: Receives the Agent's event stream, responsible for rendering output and persisting intermediate messages
    // This example transfers the event stream to the HTTP handler via channel for SSE output
    OnAgentEvents: func(ctx context.Context, tc *adk.TurnContext[*ChatItem, M], events *adk.AsyncIterator[*adk.TypedAgentEvent[M]]) error {
        // ...pass events to HTTP handler, wait for consumption to complete...
    },

    // The following three fields are for declarative checkpoint (approval recovery), detailed in the next section
    GenResume:    makeGenResume(),
    Store:        checkpointStore,
    CheckpointID: sessionID,
}

loop := adk.NewTurnLoop(cfg)
CallbackInvocation TimingResponsibility
GenInputWhen items exist in the queueSelect which items to consume, build Agent input (can decide which items to retain for the next turn)
PrepareAgentAfter GenInputReturn the Agent instance for this turn, supports dynamic Agent configuration adjustment
OnAgentEventsWhen Agent produces event streamConsume events, render output, persist results — the core entry point for business-layer Agent output processing
GenResumeWhen resuming from checkpointExtract approval results from newly Pushed items, construct
ResumeParams
, automating approval recovery
Store + CheckpointIDEnable declarative checkpoint; TurnLoop automatically handles saving and restoring execution state

For complete callback implementations, see server/server.go.

Declarative Checkpoint: Automating Approval Recovery

In Chapter 7 (Runner mode), approval recovery required the business layer to manually call runner.ResumeWithParams() and determine whether “this is a normal execution or a recovery execution.” TurnLoop provides a more concise approach — declare Store and CheckpointID in the configuration (see previous section), and TurnLoop automatically handles saving and restoration:

  1. When Agent execution reaches an approval interrupt, TurnLoop automatically saves execution state to Store (keyed by CheckpointID)
  2. After the user makes an approval decision, the business layer creates a new TurnLoop (using the same CheckpointID) and Pushes the approval item
  3. When the new TurnLoop Run()s, it detects the checkpoint exists and automatically calls GenResume (instead of GenInput) to obtain recovery parameters
  4. The Agent resumes execution from the interrupt point

GenResume’s responsibility is to extract approval results from newly Pushed items and construct ResumeParams:

GenResume: func(ctx context.Context, loop *adk.TurnLoop[*ChatItem, M],
    canceledItems, unhandledItems, newItems []*ChatItem,
) (*adk.GenResumeResult[*ChatItem, M], error) {
    // newItems contains the item Pushed during approval recovery
    item := newItems[0]
    return &adk.GenResumeResult[*ChatItem, M]{
        ResumeParams: &adk.ResumeParams{
            InterruptID: item.InterruptID,
            ApprovalResult: item.ApprovalResult,
        },
    }, nil
}

Compared to Runner’s ResumeWithParams(), declarative checkpoint frees the business layer from managing the “normal execution vs. recovery execution” branching — TurnLoop automatically chooses between GenInput and GenResume based on whether a checkpoint exists.

Chapter Summary

  • TurnLoop is a persistent multi-turn execution loop whose lifecycle is bound to the user session
  • Normal flow: Push(item) → GenInput → Agent → OnAgentEvents → idle → wait for next Push
  • Preemption: Push(item, WithPreempt(AfterToolCalls)) — one line of code cancels the current turn and starts a new one
  • Abort: loop.Stop(WithImmediate()) — one line of code terminates the entire loop
  • Declarative checkpoint: Configure Store + CheckpointID, and TurnLoop automatically handles interrupt saving and restoration
  • For specific callback implementations, see server/server.go

Series Conclusion: Complete Agent Application Skeleton

By this chapter, we’ve used a runnable Agent to connect Eino’s core capabilities:

  • Runtime: Runner / TurnLoop drives execution, supporting streaming output, preemption, and abort
  • Tool layer: Filesystem / Shell and other Tool capabilities integrated, tool errors handled safely
  • Middleware: Pluggable middleware/handlers for cross-cutting capabilities like error handling, retry, and approval
  • Observability: callbacks/trace capabilities connecting key paths for debugging and production observability
  • Human-AI collaboration: interrupt/resume + checkpoint supporting approval, parameter completion, branch selection, and other interactive flows
  • Deterministic orchestration: compose (graph/chain/workflow) organizes complex business flows into maintainable, reusable execution graphs
  • Business delivery: A2UI protocol presents Agent capabilities to users as streaming UI
  • Execution control: TurnLoop provides preemption, abort, and multi-turn lifecycle management, adapting to complex interaction needs in real business scenarios

You can progressively replace/extend any component on this skeleton: model, tools, storage, workflows, frontend rendering protocol — without starting from scratch.