Skip to main content

Online edition for BLOGE 0.9.8-RC1 · facts verified 2026-09-15 · 中文

Chapter 14 — Multi-Turn Sessions

Promise: By the end of this chapter you will know when to move from a one-shot graph to a long-lived session, how to model ONCE and ROUND phases, and how to keep a multi-turn interaction recoverable instead of rewriting the same suspend/resume glue over and over.


Learning Goals

  1. Distinguish a graph that suspends once from a session that spans multiple signals, phase boundaries, or both.
  2. Model a session as ordered phase steps with ONCE and ROUND behavior.
  3. Use ctx.round.input, yield_on, until, and then transitions to shape a real conversation.
  4. Configure idle timeout, history retention, ownership, and recovery without losing track of the runtime model.
  5. Decide when session is the right abstraction — and when a plain graph from Chapter 12 or Chapter 13 is still simpler.

Prerequisites

Source Examples

FileWhat it shows
ch13/customer-service-session.blogeCanonical session DSL: greeting → triage rounds → solve → wrap-up
ch13/interactive-review-session.blogeA smaller collect → review rounds → finalize flow used in the guided rewrite
CustomerServiceSessionExample.javaFluent Java API equivalent with PhaseBuilder.once(...) and PhaseBuilder.round(...)
bloge-session-ext/README.mdIn-memory runtime, manual restore boundary, and nested-state-machine note
docs/session-phase-round-specification.mdExact DSL and runtime contract for session / phase / round behavior

Why This Matters

In Chapter 12 you learned how one graph can pause and later resume. That solves a lot of real work: wait for approval, wait for payment, wait for an external event.

But some workflows are not just “one graph plus one wait”. They are conversations:

  • greet the user
  • ask for more detail
  • wait for the reply
  • ask again if needed
  • hand off or close the interaction

The same behavior can be encoded with await, branches, and context plumbing inside one graph. The trade-off is that conversation identity, round boundaries, and recovery state remain implicit in graph structure, making them harder to inspect and explain.

A session gives that conversation a first-class runtime model. Instead of forcing every turn into one giant DAG, you say:

  • these steps happen once
  • this step repeats round by round
  • these signals belong to the same business interaction
  • this whole interaction should survive restart, timeout, and resume

Read the Conversation Before the Session Model

A customer says the parcel is marked delivered but missing. The assistant reads facts, asks one narrowing question, receives the answer, and hands the case to a human path. That four-turn exchange is the problem the Session must retain—not an abstract desire to "support chat".

Diagram: four-turn customer-service transcript

Translate each conversational fact once

Conversation factSession ownerWhy it is retained
Ticket and customer identitySession contextStable across every round
Current customer messagectx.round.input.userMessageBelongs only to this round
Assistant answer and donerespond.outputDetermines yield and exit
action=handoffPhase transition conditionMoves ownership from triage to solve
Final resolutionwrap-up outputStable terminal result for the caller

Turn 2 yields after respond, so the caller can display the question while the session remains in triage. Turn 3 becomes the next round input. Turn 4 returns done=true and action=handoff, so the phase transitions to solve; it does not silently mutate a ticket from inside the language model response.

Now the vocabulary has a reason to exist: a phase owns a stage of the conversation, a round owns one repeated exchange, yield_on marks the observable pause, and until says when repetition ends.


Mental Model

Think of a session as a container for multiple graph executions.

PieceResponsibility
SessionGraphImmutable definition of the whole interaction
phaseA named step in the interaction lifecycle
ONCE phaseExecute the embedded graph one time, then transition
ROUND phaseExecute once per external signal payload, suspending between rounds
SessionExecutorStarts, signals, restores caller-provided state, and terminates in-memory sessions
DurableSessionManagerCoordinates checkpoint stores, definition lookup, leases, and restart recovery when durability is required

Two phase types do almost all the work:

Phase typeBest forRuntime behavior
ONCEgreeting, enrichment, finalize, wrap-upRun the graph once, write outputs, resolve then
ROUNDclarify-until-done, iterative review, menu loopsWait for a signal, run the round graph, evaluate until, either suspend again or transition

The key shift is this: a graph gives you dependency order inside one execution, while a session gives you conversation order across many executions.

Diagram: 14-multi-turn-sessions figure 1


First Working Example

The companion customerServiceSession example is the smallest realistic shape to copy:

session customerServiceSession {
idle_timeout = 5m
timeout_action = "cs_session_timeout_policy"
max_rounds = 20
max_history = 50

phase greeting {
node greet : CsSessionGreeter {
input {
sessionId = ctx.sessionId
}
}
then -> triage
}

phase triage {
max_rounds = 5
yield_on = [respond]
round {
node respond : CsSessionResponder {
input {
userMessage = ctx.round.input.userMessage
}
}
}
until respond.output.done == true
then {
respond.output.action == "handoff" -> solve
otherwise -> wrapUp
}
}
}

Read it in plain English:

  1. Start the interaction with a one-time greeting phase
  2. Enter a repeating triage phase
  3. Each signal payload becomes the next ctx.round.input
  4. Stay in triage until the responder says done == true
  5. Hand off to a solver or wrap up immediately

That is the whole value proposition of session: the business interaction stays readable even though it spans multiple user turns.


Break It Apart

ONCE and ROUND phases solve different problems

Use an ONCE phase when the step has a single answer:

  • greet the customer
  • load the current account
  • produce the final summary

Use a ROUND phase when the step can legitimately repeat:

  • ask clarifying questions until the issue is understood
  • review a document until the reviewer stops requesting revisions
  • collect IVR menu input until the caller reaches a resolved path

The runtime behavior is different enough that the DSL makes the distinction explicit instead of hoping readers infer it from branches.

What a round can see

Inside a ROUND phase the runtime injects extra bindings on top of normal GraphContext data:

  • ctx.round.input — the payload that resumed the current round
  • ctx.<phaseId>.output — outputs from earlier completed phases
  • ctx.session.namespace / ctx.session.ownerId — identity metadata when present

That means you usually combine three data sources in one round:

  • initial request context such as ctx.sessionId
  • accumulated prior phase outputs such as ctx.collect.output.*
  • the newest user reply through ctx.round.input.*

Session controls and defaults

PropertyScopeDefaultWhy you care
idle_timeoutsession30mMaximum idle time allowed between signals
timeout_actionsessionnonePolicy reference resolved by TimeoutPolicyProvider
max_roundssession100Global cap across all round phases
max_roundsROUND phase1Cap for that specific phase — set this explicitly for real conversations
max_historysession0Retained history entries; 0 means unlimited, not disabled
yield_onROUND phaseterminal nodesWhich node outputs are returned after each round
on_round_failureROUND phaseterminate_sessionHow the runtime reacts when a round fails
max_visitsphaseunboundedHard cap on how many times the runtime may re-enter this phase via then rules
thenphasenoneUnconditional or rule-based transition to the next phase

Two defaults surprise people a lot:

  • max_history = 0 means keep everything
  • a ROUND phase without its own max_rounds loops only once

Preventing transition loops with max_visits

max_rounds caps how many times a ROUND phase iterates internally. max_visits caps how many times the runtime is allowed to enter any phase via a then rule from elsewhere. Without it, two phases that route to each other can ping-pong indefinitely:

phase classify : ROUND { ... then "needs-more-info" -> gatherInfo }
phase gatherInfo : ROUND { max_visits = 5 then "complete" -> classify }

When max_visits is exceeded, the runtime emits PHASE_VISIT_LIMIT_EXCEEDED and throws SessionPhaseVisitExceededException. The executor then marks the session failed through its normal error-termination path. Set max_visits on every phase that can be re-entered.

Starting, signalling, and restoring in-memory state

The lifecycle API is intentionally small:

SessionExecutor executor = SessionExecutor.builder(engine)
.accessGuard(new OwnerOnlySessionAccessGuard()) // default
.build();

SessionHandle handle = executor.start(
sessionGraph,
new GraphContext(Map.of("sessionId", "SESSION-1001")),
SessionIdentity.of("support", "user-123")
);

executor.signal(handle.sessionId(), Map.of("userMessage", "I need a refund"), "user-123");

SessionExecutor deliberately owns only the live, in-memory interaction. Its builder configures access control, listeners, timeout policies, and snapshot callbacks; it does not accept a durable store.

If an application has saved a session snapshot itself, it reconstructs the plain SessionState and context, then supplies both explicitly:

SessionHandle restored = executor.restore(
sessionGraph,
restoredState,
restoredContext
);

That method resumes caller-provided state; it does not discover checkpoints, claim a lease, or recover sessions after a restart. Those responsibilities belong to DurableSessionManager, introduced later in this chapter.

Ownership is part of the model

A session is meant to live long enough that who is allowed to signal it becomes important.

By default the runtime uses OwnerOnlySessionAccessGuard, so the caller who started the session matters on later signal(...), read, and terminate operations. That is a big difference between:

  • “a graph that happens to wait once” and
  • “a business interaction that might stay alive for hours or days”

The application must derive that caller id from its own authenticated request and supply it to start(...) and later operations. The similarly named Spring property below serves a different owner:

spring:
bloge:
session:
owner-id: ${HOSTNAME} # durable recovery/lease owner for this JVM

spring.bloge.session.owner-id is not a user-expression resolver. It names the JVM that claims durable recovery work; when omitted, the durable manager creates a random UUID. Authentication-to-SessionIdentity mapping remains an application boundary.

When not to use sessions

Do not reach for session automatically.

A plain graph is still the better tool when:

  • there is only one suspend/resume boundary
  • there are no meaningful phase names beyond node names
  • the workflow is best described as a single DAG plus durability

And if the main challenge is named lifecycle states with event-driven transitions, not conversation rounds, Chapter 15 is usually the better fit.


Common Trap

❌ Forgetting that a ROUND phase defaults to one round

This looks like it should keep looping:

phase triage {
round {
node respond : CsSessionResponder { }
}
until respond.output.done == true
}

But it will stop after the first round unless you also configure the phase-level cap:

phase triage {
max_rounds = 5
round {
node respond : CsSessionResponder { }
}
until respond.output.done == true
}

The session-level max_rounds = 20 is a global budget. It does not override the phase-level default of 1.


What Goes Wrong

A restarted in-memory executor cannot find the old session

After a service restart, creating a fresh SessionExecutor and signalling an old id fails because that executor has no live state for the session:

SessionExecutor executor = SessionExecutor.builder(engine).build();
executor.signal(sessionId, payload, "user-123"); // session is not active here

For caller-managed persistence, reconstruct SessionState and call restore(sessionGraph, state, context). For store-backed restart recovery, use DurableSessionManager with an execution store, checkpoint store, and definitionLookup. Keeping these two paths separate prevents an in-memory executor from pretending that it owns distributed recovery.


Guided Rewrite

Open interactive-review-session.bloge and compare it with the single-wait patterns from Chapter 12.

The rewrite strategy is:

  1. Keep the one-time setup as an ONCE phase. collect gathers the initial review context once.
  2. Move the repeated human step into a ROUND phase. review executes once per reviewer response.
  3. Use until to express the exit rule directly. The loop ends when the decision is no longer needs_revision.
  4. Finish in a final ONCE phase. finalize writes the summary exactly once.
session interactiveReview {
phase collect { ... then -> review }

phase review {
max_rounds = 3
yield_on = [reviewRound]
round {
node reviewRound : DecideReviewOperator { ... }
}
until reviewRound.output.decision != "needs_revision"
then -> finalize
}

phase finalize { ... }
}

The point of the rewrite is not “use more runtime features”.

Brain Check

  1. When should you stay with a plain graph instead of introducing session?

    (When there is only one meaningful suspend/resume boundary and no need for multi-phase conversation structure.)

  2. What is the difference between session-level max_rounds and phase-level max_rounds?

    (The session-level value is a global cap across all round phases; the phase-level value limits that one ROUND phase, and defaults to 1.)

  3. What does ctx.round.input contain?

    (The payload that resumed the current round.)

  4. What does max_history = 0 mean?

    (Unlimited retained history, not “disable history”.)

  5. What must the caller supply to SessionExecutor.restore(...)?

    (The SessionGraph, reconstructed SessionState, and corresponding GraphContext; persistence and lease recovery remain outside the pure executor.)

  6. What makes session different from a graph with one await node?

    (A session is explicitly multi-turn: it owns phase order, round boundaries, session-level timeout/history, access control, and restore semantics across multiple signals.)


Lab

Goal: turn a one-shot support graph into a real multi-turn support session.

  1. Start from a three-step interaction: greet, clarify, resolve.
  2. Move greet and resolve into ONCE phases.
  3. Put the clarification step in a ROUND phase.
  4. Configure:
    • session idle_timeout = 10m
    • session max_rounds = 12
    • phase max_rounds = 4
    • yield_on so the caller can display the latest bot reply after every round
  5. Add a branch that hands off to a specialist when the round output marks action == "handoff".

Stretch: implement a custom timeout policy reference so an idle session sends a reminder before it terminates.


Durable Sessions

Everything above uses the pure in-memory SessionExecutor. In production, sessions that span hours or days need checkpoint persistence so they survive process restarts. The bloge-session-durable module provides DurableSessionManager for exactly this purpose.

Maven dependency

<dependency>
<groupId>com.leanowtech.bloge</groupId>
<artifactId>bloge-session-durable</artifactId>
</dependency>

Builder and usage

Map<String, SessionGraph> sessionRegistry = Map.of(
sessionGraph.name(), sessionGraph
);

DurableSessionManager durableSession = DurableSessionManager.builder()
.executionStore(executionStore)
.checkpointStore(checkpointStore)
.graphEngine(engine)
.definitionLookup(sessionRegistry::get)
.recoveryConfig(SessionRecoveryConfig.defaultConfig())
.build();

durableSession.startup();
// If this session is not active locally, signal() claims its durable execution,
// restores the checkpoint, and then delivers the next turn.
durableSession.signal(sessionId, newTurn);

DurableSessionManager builds a SessionExecutor with a durable snapshot callback. Session snapshots are written through the checkpoint store. When signal(...) cannot find the session locally, the manager claims its execution, reloads the latest checkpoint, and then delivers the signal.

Content hash and version detection

Each session graph definition has a contentHash — a SHA-256 digest of the session graph structure. This hash is stored inside every checkpoint. When DurableSessionManager loads a checkpoint, it compares the stored hash with the current definition's hash.

If the hashes don't match, the session definition has changed since the checkpoint was written.

SessionVersionMismatchException

When a hash mismatch is detected, DurableSessionManager throws SessionVersionMismatchException:

try {
durableSession.signal(sessionId, userTurn);
} catch (SessionVersionMismatchException e) {
// e.sessionId() — which session failed
// e.checkpointHash() — hash from the checkpoint
// e.currentHash() — hash of the current definition
}

Recovery strategies:

StrategyWhen to use
Fail fast (default)During development — forces you to notice definition changes immediately
Discard and restartWhen old session state is expendable — catch the exception, start a fresh session
MigrateWhen you need continuity — implement application-level migration that maps old phase state to the new definition

The choice depends on your domain. Short-lived support sessions can usually discard safely. Long-running onboarding flows may need migration logic.


Experiment acceptance card

  • Expected and observed: Four turns map to context, round input, yield, and transition.
  • Failure and recovery: Resume without session identity; recover the same identity and checkpoint.
  • Proof boundary: Proves Session lifecycle behavior, not conversation correctness.
  • Exercise contract: Four-turn transcript; change one input; deliver a phase table; stop when each owner and next transition are explainable.

Recap

  • A plain graph models one execution; a session models a multi-turn interaction.
  • ONCE phases handle single-pass work; ROUND phases handle repeated signal-driven work.
  • ctx.round.input is the bridge between an external signal and the graph executed inside the current round.
  • Session-level and phase-level limits are different; you usually need both.
  • The pure executor restores caller-provided state; store-backed restart recovery belongs to DurableSessionManager.
  • Use session when conversation structure matters more than a single DAG.

Next Step

In Chapter 15 — State Machines you will switch from conversational turns to named lifecycle states. Instead of repeating rounds until a phase is done, you will model explicit state transitions such as draft -> pendingReview -> processing -> completed.


Coding Agent: Open the versioned task guide.