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 modelONCEandROUNDphases, and how to keep a multi-turn interaction recoverable instead of rewriting the same suspend/resume glue over and over.
Learning Goals
- Distinguish a graph that suspends once from a
sessionthat spans multiple signals, phase boundaries, or both. - Model a
sessionas orderedphasesteps withONCEandROUNDbehavior. - Use
ctx.round.input,yield_on,until, andthentransitions to shape a real conversation. - Configure idle timeout, history retention, ownership, and recovery without losing track of the runtime model.
- Decide when
sessionis the right abstraction — and when a plain graph from Chapter 12 or Chapter 13 is still simpler.
Prerequisites
- Chapter 12 — Waiting for the World — you need the suspend/signal mental model first
- Chapter 13 — Durable Execution — sessions build on the same checkpoint and recovery foundation
- Chapter 7 — Designing Good Operators — multi-turn flows stay readable only when operators are small and explicit
Source Examples
| File | What it shows |
|---|---|
ch13/customer-service-session.bloge | Canonical session DSL: greeting → triage rounds → solve → wrap-up |
ch13/interactive-review-session.bloge | A smaller collect → review rounds → finalize flow used in the guided rewrite |
CustomerServiceSessionExample.java | Fluent Java API equivalent with PhaseBuilder.once(...) and PhaseBuilder.round(...) |
bloge-session-ext/README.md | In-memory runtime, manual restore boundary, and nested-state-machine note |
docs/session-phase-round-specification.md | Exact 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".
Translate each conversational fact once
| Conversation fact | Session owner | Why it is retained |
|---|---|---|
| Ticket and customer identity | Session context | Stable across every round |
| Current customer message | ctx.round.input.userMessage | Belongs only to this round |
Assistant answer and done | respond.output | Determines yield and exit |
action=handoff | Phase transition condition | Moves ownership from triage to solve |
| Final resolution | wrap-up output | Stable 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.
| Piece | Responsibility |
|---|---|
SessionGraph | Immutable definition of the whole interaction |
phase | A named step in the interaction lifecycle |
ONCE phase | Execute the embedded graph one time, then transition |
ROUND phase | Execute once per external signal payload, suspending between rounds |
SessionExecutor | Starts, signals, restores caller-provided state, and terminates in-memory sessions |
DurableSessionManager | Coordinates checkpoint stores, definition lookup, leases, and restart recovery when durability is required |
Two phase types do almost all the work:
| Phase type | Best for | Runtime behavior |
|---|---|---|
ONCE | greeting, enrichment, finalize, wrap-up | Run the graph once, write outputs, resolve then |
ROUND | clarify-until-done, iterative review, menu loops | Wait 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.
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:
- Start the interaction with a one-time greeting phase
- Enter a repeating triage phase
- Each signal payload becomes the next
ctx.round.input - Stay in triage until the responder says
done == true - 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 roundctx.<phaseId>.output— outputs from earlier completed phasesctx.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
| Property | Scope | Default | Why you care |
|---|---|---|---|
idle_timeout | session | 30m | Maximum idle time allowed between signals |
timeout_action | session | none | Policy reference resolved by TimeoutPolicyProvider |
max_rounds | session | 100 | Global cap across all round phases |
max_rounds | ROUND phase | 1 | Cap for that specific phase — set this explicitly for real conversations |
max_history | session | 0 | Retained history entries; 0 means unlimited, not disabled |
yield_on | ROUND phase | terminal nodes | Which node outputs are returned after each round |
on_round_failure | ROUND phase | terminate_session | How the runtime reacts when a round fails |
max_visits | phase | unbounded | Hard cap on how many times the runtime may re-enter this phase via then rules |
then | phase | none | Unconditional or rule-based transition to the next phase |
Two defaults surprise people a lot:
max_history = 0means keep everything- a
ROUNDphase without its ownmax_roundsloops only once
Preventing transition loops with
max_visits
max_roundscaps how many times aROUNDphase iterates internally.max_visitscaps how many times the runtime is allowed to enter any phase via athenrule 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_visitsis exceeded, the runtime emitsPHASE_VISIT_LIMIT_EXCEEDEDand throwsSessionPhaseVisitExceededException. The executor then marks the session failed through its normal error-termination path. Setmax_visitson 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:
- Keep the one-time setup as an
ONCEphase.collectgathers the initial review context once. - Move the repeated human step into a
ROUNDphase.reviewexecutes once per reviewer response. - Use
untilto express the exit rule directly. The loop ends when the decision is no longerneeds_revision. - Finish in a final
ONCEphase.finalizewrites 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
-
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.)
-
What is the difference between session-level
max_roundsand phase-levelmax_rounds?(The session-level value is a global cap across all round phases; the phase-level value limits that one
ROUNDphase, and defaults to1.) -
What does
ctx.round.inputcontain?(The payload that resumed the current round.)
-
What does
max_history = 0mean?(Unlimited retained history, not “disable history”.)
-
What must the caller supply to
SessionExecutor.restore(...)?(The
SessionGraph, reconstructedSessionState, and correspondingGraphContext; persistence and lease recovery remain outside the pure executor.) -
What makes
sessiondifferent from a graph with oneawaitnode?(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.
- Start from a three-step interaction: greet, clarify, resolve.
- Move
greetandresolveintoONCEphases. - Put the clarification step in a
ROUNDphase. - Configure:
- session
idle_timeout = 10m - session
max_rounds = 12 - phase
max_rounds = 4 yield_onso the caller can display the latest bot reply after every round
- session
- 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:
| Strategy | When to use |
|---|---|
| Fail fast (default) | During development — forces you to notice definition changes immediately |
| Discard and restart | When old session state is expendable — catch the exception, start a fresh session |
| Migrate | When 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
sessionmodels a multi-turn interaction. ONCEphases handle single-pass work;ROUNDphases handle repeated signal-driven work.ctx.round.inputis 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
sessionwhen 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.
Reference Links
bloge-session-ext/README.md— in-memory session runtime and restore boundarydocs/session-phase-round-specification.md— full session / phase / round contractCustomerServiceSessionExample.java— Java builder examplecustomer-service-session.bloge— chapter-facing DSL exampleinteractive-review-session.bloge— guided rewrite example
Coding Agent: Open the versioned task guide.