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.
Turn outcomes, input shape, the semantic event stream, sink semantics, usage channels, and RLM finish.
TurnReport.outcome is one of three categories: Finished, AgentFrameSwitch, Stopped. Branch on category; inspect the variant only when handling specific stop reasons.
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:
| Variant | Class | Cause |
|---|---|---|
Cancelled | Host-driven | An exact-turn cooperative cancellation request won the durable gate. |
InvalidInput | Host-driven | Input normalization rejected the TurnInput (e.g. missing attachment, malformed image ref). |
Incomplete | Provider | The LLM hit its output limit mid-message without finishing. |
ProviderError | Provider | The provider returned an error, a content-filter refusal, or a context-overflow terminal reason. |
MaxTurns | Runtime | A 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. |
ToolFailure | Runtime | A tool returned ToolOutcome::err(...) and turn assembly flagged the turn as failed. |
PluginAbort | Runtime | A plugin's turn-preparation or checkpoint hook returned an abort directive. |
RuntimeError | Fatal | Internal runtime failure or a strict-termination policy with no Done event. Treat as a bug or environmental fault. |
SubmittedError { value } | RLM | An RLM program ended with submit_error(...); value is the model-authored payload. |
ToolError { tool_name, value } | RLM | The 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),
},
}
TurnInput is text plus explicit attachment sources. Filesystem syntax is a host concern.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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()),
}
}
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.
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.
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 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?;
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();
Four channels, finest to coarsest:
TraceSinkTurnEvent::Usage / TurnEvent::ChildUsageChildUsage carries session_id and source for grouping child traffic.TurnReport.usage / TurnReport.children_usage(source, model) child breakdown. TurnReport::total_usage() sums both.session.usage_report() → SessionUsageReportsource × 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.
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.
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?;
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?;