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
- Send a question that triggers a long answer, e.g., “Explain all of Eino’s components in detail”
- While the Agent is still answering, send a new message directly, e.g., “Never mind, just tell me what ChatModel is”
- Observe: The old answer stops immediately, and the Agent begins answering the new question
Experience Abort
- Send a question
- While the Agent is answering, click the Abort button in the top-right corner
- 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
- Entry code: main.go
- Agent construction: agent.go
- TurnLoop server: server/server.go
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.
| Capability | Ch10 (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:
- Waits for the current tool call to complete (
AfterToolCallsmeans don’t interrupt an executing tool to avoid inconsistent state) - Cancels the current turn — OnAgentEvents’ context is cancelled, the old turn exits
- 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:
| Mode | Specific Behavior |
| AfterToolCalls | Waits for the currently executing tool calls to complete, then cancels the current turn and starts a new turn |
| AfterChatModel | Waits for the current LLM call to complete, then cancels the current turn and starts a new turn |
| AnySafePoint | Immediately 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/iterResultand thehandlerDonesignaling 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
| Mode | Specific 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)
| Callback | Invocation Timing | Responsibility |
| GenInput | When items exist in the queue | Select which items to consume, build Agent input (can decide which items to retain for the next turn) |
| PrepareAgent | After GenInput | Return the Agent instance for this turn, supports dynamic Agent configuration adjustment |
| OnAgentEvents | When Agent produces event stream | Consume events, render output, persist results — the core entry point for business-layer Agent output processing |
| GenResume | When resuming from checkpoint | Extract approval results from newly Pushed items, constructResumeParams, automating approval recovery |
| Store + CheckpointID | — | Enable 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:
- When Agent execution reaches an approval interrupt, TurnLoop automatically saves execution state to
Store(keyed byCheckpointID) - After the user makes an approval decision, the business layer creates a new TurnLoop (using the same
CheckpointID) and Pushes the approval item - When the new TurnLoop
Run()s, it detects the checkpoint exists and automatically callsGenResume(instead ofGenInput) to obtain recovery parameters - 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.