lash/turns

Turn outcomes, input shape, the semantic event stream, sink semantics, usage channels, and RLM finish.

Turn Outcomes

TurnReport.outcome is one of three categories: Finished, AgentFrameSwitch, Stopped. Branch on category; inspect the variant only when handling specific stop reasons.

TurnOutcome shape
pub enum TurnOutcome {
    Finished(TurnFinish),
    AgentFrameSwitch { frame_key: FrameKey, task: String },
    Stopped(TurnStop),
}

pub enum TurnFinish {
    AssistantMessage { text: String },
    FinalValue { value: serde_json::Value },
    ToolValue { tool_name: String, value: serde_json::Value },
}

Finished: clean terminal value. AssistantMessage is the prose default for both modes; the runtime commit materializes the transcript message exactly once; there is no second "final message" stream event after AssistantProseDelta. FinalValue / ToolValue come from RLM finish or a tool-authored terminal; both arrive on the stream too.

AgentFrameSwitch: a protocol plugin or tool switched to a fresh AgentFrame inside the same session. Runtime callers normally use the facade, which keeps driving the same session until a terminal outcome is reached.

Stopped: turn aborted before a terminal value. Ten TurnStop variants:

VariantClassCause
CancelledHost-drivenAn exact-turn cooperative cancellation request won the durable gate.
InvalidInputHost-drivenInput normalization rejected the TurnInput (e.g. missing attachment, malformed image ref).
IncompleteProviderThe LLM hit its output limit mid-message without finishing.
ProviderErrorProviderThe provider returned an error, a content-filter refusal, or a context-overflow terminal reason.
MaxTurnsRuntimeA per-turn bound ran out. Either the protocol turn loop exhausted a required TurnBudget::Bounded(n) at iteration n and the final forced-reply turn finished without a terminal, or the turn exhausted its NoProgressBudget — consecutive model calls that committed no successful execution. A no_progress_budget protocol diagnostic on the turn distinguishes the second cause; TurnBudget::Unbounded rules out only the first.
ToolFailureRuntimeA tool returned ToolOutcome::err(...) and turn assembly flagged the turn as failed.
PluginAbortRuntimeA plugin's turn-preparation or checkpoint hook returned an abort directive.
RuntimeErrorFatalInternal runtime failure or a strict-termination policy with no Done event. Treat as a bug or environmental fault.
SubmittedError { value }RLMAn RLM program ended with submit_error(...); value is the model-authored payload.
ToolError { tool_name, value }RLMThe RLM protocol signalled a tool-authored error terminal; value is the payload.

Host-driven stops are recoverable next request. Provider stops usually want a different model or shorter context. Runtime stops indicate a configuration or authoring problem. RuntimeError is the only category where identical-input retry is unlikely to help.

Session-lane conflicts and CAS backstop conflicts are not Stopped variants; they surface as Err from run()/stream(), no partial commit. See Persistence → Session Lane And CAS.

match result.outcome {
    TurnOutcome::Finished(finish) => persist_terminal(finish)?,
    TurnOutcome::AgentFrameSwitch { frame_key, .. } => record_frame_boundary(frame_key)?,
    TurnOutcome::Stopped(stop) => match stop {
        TurnStop::Cancelled { .. } | TurnStop::InvalidInput => report_user_visible(stop),
        TurnStop::ProviderError | TurnStop::Incomplete => offer_retry(stop),
        // Either bound: the turn budget, or the no-progress budget when
        // consecutive attempts committed no successful execution.
        TurnStop::MaxTurns => review_turn_bounds(),
        other => record_for_diagnosis(other),
    },
}

Input Boundary

TurnInput is text plus explicit attachment sources. Filesystem syntax is a host concern.

Text

TurnInput::text(...) or explicit InputItem::Text. For @path-style refs, resolve in the host and inline the marker text you want the model to see.

Attachments

InputItem::Attachment carries inline bytes, a Lash-owned stored ref, an external URL, or a provider-scoped file id. Inline bytes normalize to stored refs before durable effects; borrowed sources pass through.

Turn Admission

A turn is admitted before it is driven. TurnBuilder::run and stream_to commit the input as a pending turn input on any store-backed session, then claim and drive it, so a direct turn and a queued one enter through the same durable ingress.

The handle names the admission

TurnReport.acceptance carries the input_id the turn was admitted under. Persist it beside the product message to reconcile later; it is the same identity session.pending_turn_inputs() and the settled turn report expose. A store-less session has nowhere to record an acceptance and reports none.

The caller is the first driver, not the owner

Dropping the future does not cancel the turn: the input is already durable, so a peer worker can claim and finish it. Withdraw a turn by cancelling its pending input, never by dropping the future. Durable backends pay one extra store commit per direct turn for this.

Whatever is queued joins the turn

The claim takes the head of the pending next-turn queue exactly as a drain does, so inputs enqueued before this call are folded into the same turn rather than waiting for another one.

Resubmitting is a new turn

Direct ingress mints no idempotency key. If a worker dies after admitting a turn but before the caller sees a result, resubmitting the same text admits it a second time. A host that needs at-most-once submission names its own source_key through session.enqueue(...).id(...) instead.

Semantic Events

Identity-bearing stream. Prose and reasoning arrive as deltas. Tool and code activity carries a correlation_id. finish or tool-authored terminals arrive as FinalValue / ToolValue. Normal prose completion emits no terminal stream item.

Three event types, only one is for your app.

Apps consume TurnActivity: id, correlation_id, and a TurnEvent payload. Hosts do not subscribe to lower-level runtime/debug graph events. There is no app-facing final-message event enum contract. Sinks match only on TurnEvent.

Turn streams are not reconnect cursors.

Use TurnBuilder::stream_to, run, and pull-style stream for one running turn. Hosts that need browser reconnect, session cursors, NDJSON, SSE, or remote DTO framing should use Streaming and reconnect.

TurnActivity sink
use async_trait::async_trait;
use lash::sync::MutexExt;
use lash::{TurnActivity, TurnActivitySink, TurnEvent};

struct AppEvents {
    tx: AppUiTx,
    turn_state: std::sync::Mutex<TurnUiState>,
}

#[derive(Default)]
struct TurnUiState {
    reasoning: Option<UiRowId>,
    tools: std::collections::HashMap<String, UiRowId>,
    code: Option<UiRowId>,
}

#[async_trait]
impl TurnActivitySink for AppEvents {
    async fn emit(&self, activity: TurnActivity) {
        let correlation_id = activity.correlation_id.0.to_string();
        match activity.event {
            TurnEvent::AssistantProseDelta { text } => {
                append_live_text(text.to_string()).await;
            }
            TurnEvent::ReasoningDelta { text } => {
                let row = self.turn_state.lock_recover().reasoning.clone();
                let row = upsert_reasoning_row(row, text.to_string()).await;
                self.turn_state.lock_recover().reasoning = Some(row);
            }
            TurnEvent::ToolCallStarted { name, args, .. } => {
                let row = insert_tool_row(name, args).await;
                self.turn_state
                    .lock_recover()
                    .tools
                    .insert(correlation_id, row);
            }
            TurnEvent::ToolCallCompleted { name, output, .. } => {
                let row = self.turn_state.lock_recover().tools.remove(&correlation_id);
                update_or_insert_tool_row(row, name, output).await;
            }
            TurnEvent::CodeBlockStarted { language, code, .. } => {
                let row = insert_code_row(language, code).await;
                self.turn_state.lock_recover().code = Some(row);
            }
            TurnEvent::CodeBlockCompleted {
                language,
                output,
                error,
                success,
                ..
            } => {
                let row = self.turn_state.lock_recover().code.take();
                update_or_insert_code_row(row, language, output, error, success).await;
            }
            TurnEvent::FinalValue { value } => {
                append_live_text(render_terminal_value(&value)).await;
            }
            TurnEvent::ToolValue { tool_name, value } => {
                append_live_text(render_terminal_value(&value)).await;
                record_terminal_tool(tool_name).await;
            }
            TurnEvent::Usage {
                usage, cumulative, ..
            } => {
                update_usage(usage, cumulative).await;
            }
            TurnEvent::ChildUsage {
                source,
                usage,
                cumulative,
                ..
            } => {
                update_child_usage(source, usage, cumulative).await;
            }
            _ => {}
        }
    }
}

fn render_terminal_value(value: &serde_json::Value) -> String {
    match value {
        serde_json::Value::Null => String::new(),
        serde_json::Value::String(text) => text.clone(),
        other => serde_json::to_string_pretty(other).unwrap_or_else(|_| other.to_string()),
    }
}
Tool and code events carry their containment.

ToolCallStarted and ToolCallCompleted carry an optional graph_key — the enclosing code block, matching CodeBlockStarted.graph_key — and an optional parent_call_id, the id of the parent batch call. Group rows from these fields directly instead of inferring containment from arrival order. Every Option field on TurnEvent is skip-when-None: an absent graph_key, call_id, or error is omitted from the serialized event rather than sent as null. The full channel map and stability contract live in Reporting channels.

Use event identity, not duplicate detection.

Same correlation_id → same logical row. FinalValue / ToolValue signal a control-path terminal; they are not emitted for normal prose finishes, since that prose already streamed as AssistantProseDelta and the settled message comes from the read view.

Sink Semantics

TurnActivitySink::emit() is awaited by the runtime. A slow sink slows the turn. No buffering, no spawn-and-drop. Sinks own their own concurrency.

Conventional pattern: push onto an mpsc channel and return immediately; drain into UI state from a separate task. Sender::send only awaits when full; the channel bound is the backpressure dial:

use tokio::sync::mpsc;

struct ChannelSink {
    tx: mpsc::Sender<TurnActivity>,
}

#[async_trait::async_trait]
impl TurnActivitySink for ChannelSink {
    async fn emit(&self, activity: TurnActivity) {
        // send().await yields when the channel is full — the turn
        // will pause here if your UI consumer falls behind.
        let _ = self.tx.send(activity).await;
    }
}

Sink panics or Err-shaped activities do not abort the turn. Persistence and TurnOutput.activities are independent of the sink; a dropped sink loses no activity.

Cancellation

Cancellation is cooperative and first-class: a cancelled turn finishes with TurnOutcome::Stopped(TurnStop::Cancelled { evidence }), commits like any other turn, and leaves the session ready for the next turn. The evidence that settled the cancellation rides the variant, so a cancelled outcome always names the request that stopped it. In-flight provider streaming is aborted, not awaited.

Host-facing controls retain the stable turn id and call LashSession::request_turn_cancel, or LashSession::request_turn_cancel_with_disposition when undelivered active-turn input should be dropped instead of deferred to the next turn. Drop leaves that input cancelled for the host to handle rather than replaying it as next-turn ingress. Deployments can use TurnWorkDriver::request_cancel directly. The keyed-promise gate is exact-scope and replay-safe, and await_terminal attaches idempotently to the authoritative result. Restate deployments use RestateTurnDeployment and LashDurableWaitWorkflow, not the Admin API. Cancellation is cooperative: detached effects are not guaranteed to stop. Session and turn ids are routing identity, not authorization.

use lash::{
    TurnAddress, TurnCancelDisposition, TurnCancellationEvidence, TurnOutcome, TurnStop,
};

let turn_id = "incident-summary-42";
let stream = session
    .turn(TurnInput::text("Summarize the incident."))
    .turn_id(turn_id)
    .stream()?;

// An HTTP handler can retain only these routing ids. Authenticate and
// authorize the caller before forwarding them to Lash. The default inline
// driver is same-process; cross-process cancellation requires a durable
// engine deployment. "user" is this host's vocabulary; Lash carries it
// opaquely without assigning semantics. Choose Drop when undelivered input
// should return to the host instead of being deferred to the next turn.
let receipt = session
    .request_turn_cancel_with_disposition(
        turn_id,
        "stop-button-7",
        Some("user".to_string()),
        Some("operator pressed Stop".to_string()),
        TurnCancelDisposition::Drop,
    )
    .await?;
let _receipt = receipt;

let result = stream.finish().await?;
// The evidence that settled the cancellation rides the outcome, so a
// cancelled turn always names the request that stopped it. Match on the
// variant, or read it with `TurnOutcome::cancellation`.
if let TurnOutcome::Stopped(TurnStop::Cancelled { evidence }) = &result.outcome {
    assert_eq!(evidence.request_id, "stop-button-7");
    assert_eq!(TurnOutcome::cancellation(&result.outcome), Some(evidence));
}
// Lash mints its own evidence when no host request explains the stop, so
// the request id is namespaced rather than absent.
assert_eq!(
    TurnCancellationEvidence::internal(turn_id).request_id,
    format!("internal:{turn_id}")
);

// Attachment is idempotent and returns immediately after publication.
let terminal = core
    .turn_work_driver()
    .await_terminal_with_timeout(
        &session.turn_address(turn_id),
        std::time::Duration::from_secs(30),
    )
    .await?;

Restate deployment wiring

let deployment = lash_restate::RestateTurnDeployment::new(ingress_url);

// Configure the core with this host. Bind LashDurableWaitWorkflowImpl and
// LashDurableWaitIndexImpl on the Restate endpoint alongside turn handlers.
let effect_host = deployment.effect_host();

// This durable driver can live in a different web process from the turn
// owner. It uses LashDurableWaitWorkflow—not the Restate Admin API—and
// survives web-process restarts. Inline TurnWorkDriver is same-process.
let driver = deployment.turn_work_driver();
let terminal_attach = deployment.turn_attach();

Token Usage

Four channels, finest to coarsest:

TraceSink
Every provider call across every session. Billing, audit, off-line analysis.
TurnEvent::Usage / TurnEvent::ChildUsage
Live during a turn, one event per LLM iteration. ChildUsage carries session_id and source for grouping child traffic.
TurnReport.usage / TurnReport.children_usage
Per-turn snapshot. Parent-only and per-(source, model) child breakdown. TurnReport::total_usage() sums both.
session.usage_report()SessionUsageReport
Session aggregate by source × model. Dashboards and "session so far."

All usage surfaces use the same five canonical buckets: uncached input_tokens, total output_tokens, cache_read_input_tokens, cache_write_input_tokens, and reasoning_output_tokens. Reasoning is included in output. Source-label constants and re-exports: lash::usage.

RLM And Finish

Two RLM termination policies: FinishRequired validates finish against the configured schema and finishes as TurnFinish::FinalValue (also emitted on the stream). Natural allows a no-code prose answer that finishes as TurnFinish::AssistantMessage with no terminal stream event. For prompt construction, history, variables, and the execution loop, see the RLM protocol guide.

Per-turn RLM options

use lash::rlm::RlmTurnBuilderExt as _;

let finished = session
    .turn(TurnInput::text("Move on the board."))
    .require_finish()?
    .stream_to(&sink)
    .await?;

let natural = session
    .turn(TurnInput::text("Answer directly if no code is needed."))
    .allow_prose_or_finish()?
    .run()
    .await?;

Outcome shape

match result.outcome {
    TurnOutcome::Finished(TurnFinish::FinalValue { value }) => {
        // Same value already arrived as TurnEvent::FinalValue.
        persist_typed_value(value)?;
    }
    TurnOutcome::Finished(TurnFinish::AssistantMessage { text }) => persist_text(text)?,
    other => handle_other_outcome(other)?,
}

Use require_finish_schema(...) when the final value must match a JSON Schema. State TypeScript durably when the session opens, through SessionBuilder::plugin_option(RLM_PROTOCOL_PLUGIN_ID, RlmCreateExtras { dialect: Some(RlmDialect::Typescript), .. }); omission selects Lashlang. The same RlmCreateExtras::final_answer_format field sets the session's normal final-value presentation: root RLM sessions default to Markdown guidance, managed child sessions default to raw finish values, and schema-required turns ignore the presentation preference. Both are durable facts, applied as a guarded set-if-unset write (ADR 0066).

use lash::rlm::{
    RLM_PROTOCOL_PLUGIN_ID, RlmCreateExtras, RlmDialect, RlmFinalAnswerFormat,
    RlmTurnBuilderExt as _,
};

// Durable session facts are stated once through the plugin options seam and
// applied as a guarded set-if-unset write (ADR 0066).
let session = core
    .session("analysis")
    .plugin_option(
        RLM_PROTOCOL_PLUGIN_ID,
        RlmCreateExtras {
            dialect: Some(RlmDialect::Typescript),
            final_answer_format: Some(RlmFinalAnswerFormat::RawFinalValue),
            ..RlmCreateExtras::default()
        },
    )?
    .open()
    .await?;

let result = session
    .turn(TurnInput::text("Return a risk rating."))
    .require_finish_schema(serde_json::json!({
        "type": "object",
        "required": ["rating"],
        "properties": {
            "rating": { "type": "string" }
        },
        "additionalProperties": false
    }))?
    .run()
    .await?;
read on ·