- Durable session graph
- Conversation records, tool events, protocol events, and plugin nodes live in one
SessionGraph per session. SessionReadView and ChronologicalProjection read it; persistence writes stay behind the runtime store.
- Per-turn atomic commit
RuntimeCommit writes graph delta, checkpoint blobs, usage deltas, and head revision in one atomic store commit authorized by the optimistic CAS on expected_head_revision plus any queued-work or turn-input claim ownership. The session execution lease provides advisory serialization, not a settlement fence. Partial turn = no commit.
- Typed plugin capabilities
- Leaf tools receive sealed
AttemptContext reads and return typed ToolIntents; owner-bound process bodies and authored orchestration receive separate typed contexts. Preparation still gets tool_catalog() and snapshot reads on ToolPrepareContext, never a general host handle.
- Sandboxed code load, ready
- RLM mode runs model-emitted Lashlang programs in a VM with no direct filesystem, OS-process, or network surface. Every effect crosses Lashlang's
ExecutionHost, and the linked host surface decides which resource and background-process abilities exist. Use it when the model should compose multiple tool calls per turn instead of one.
- Subagent capability boundaries
Capability::build_session_request(ctx) resolves a constrained SessionCreateRequest for the child against the parent policy. Hosts hide interactive-only tools from child surfaces through the factory's hidden-tool set (with_hidden_tools); at the maximum spawn depth the runtime hides spawn_agent itself.
- Semantic event stream
- Identity-bearing
TurnActivity items: assistant prose deltas, reasoning deltas, tool start/complete pairs with correlation ids, code-block start/complete, terminal FinalValue/ToolValue, per-call and rolling usage. Assistant prose deltas are live previews; the settled transcript comes from TurnReport / SessionReadView after the runtime commit.
- Session observation
ObservableSession::current_observation() returns the current SessionReadView plus an opaque SessionCursor. Persistent browser or worker transports should use subscribe_and_recover, which is a futures_util::Stream that replays buffered SessionObservationEvents, yields recoverable gap items, and resubscribes from gap.latest_cursor internally. resume_from_cursor is the one-shot poll alternative; subscribe_from_cursor is the lower-level primitive for hosts that want to handle resubscribe loops themselves. See Streaming and reconnect for browser folding and gap recovery.
- Tracing as a first-class sink
- Attach
TraceSink implementations for structured runtime records. JSONL tracing and the HTML viewer cover turns, tools, LLMs, prompts, stream deltas, and usage; OpenTelemetry export is optional. Lashlang execution graphs use a separate opt-in sink so foreground blocks, durable process runs, and subagent child work can be observed without mutating process-registry state. Use LashCore::processes() for host process commands and TraceLashlangGraphStore for optional trace-derived graph snapshots.
- Snapshot and restore seams
- Plugins, tool state, and the current AgentFrame's Lashlang VM each persist through versioned snapshot writers so a parked session resumes intact across process restarts.
Out of scope: tenancy, artifact lifecycle, procedure discovery, cross-session shared state. Those belong to the embedder.
Host-facing handles
LashCore
- Shared runtime factory for provider, model, mode presets, plugin stack, tool providers, effect host, runtime stores, tracing, and durable policies. It is cheap to clone and does not represent a conversation.
SessionBuilder
- Short-lived opener for one session id. It resolves
SessionSpec into a concrete SessionPolicy, chooses the mode, attaches or creates the store, reloads persisted state when present, and then yields a LashSession.
LashSession
- The stable host-owned work identity. Use it to start turns, resume leased turns, read committed state, queue inputs, cancel background work, and reach explicit admin controls.
continue_as does not replace this handle; spawn_agent is the operation that creates another session.
Turn-facing handles
TurnInput
- The user or host payload plus per-turn prompt/plugin context. It is data, not a running turn.
TurnBuilder
- An ephemeral run request created by
session.turn(input). It layers exact-turn cooperative control through a stable turn id and TurnWorkDriver (with process-local cancellation tokens retained for shutdown/provider plumbing; see turns · cancellation), an optional provider override, protocol turn options, prompt edits, typed plugin input, and optional durable handler effects, then drives the turn through run(), stream_to(...), or pull-style stream(). The session model is selected at construction.
TurnReport / TurnOutput
- The committed result.
TurnReport is the terminal outcome and usage; TurnOutput adds the collected semantic TurnActivity stream for app UIs and logs.
SessionObservation
- The reconnect surface for hosts that observe a whole session. It is session-level and cursor-based; turn activity APIs remain local conveniences for the currently running turn. Persistent hosts should stream
subscribe_and_recover and keep app product state separate from Lash observation rows. The host recipe lives in Streaming and reconnect.
| Internal identity | What owns it | Why hosts care |
Session |
lash-core runtime state, graph, plugin instances, tool state, usage ledger, queue state, and a derived current-frame pointer. |
This is the durable unit behind LashSession. The session id is what app databases and stores should keep. |
AgentFrame |
An immutable FrameOpen history node: effective policy, plugin options/source, tool access, subagent context, protocol turn options, and frame reason. Prior-frame relationships are derived from ancestry. |
continue_as appends a new frame in the same session with explicit task + seed. /compact appends a compaction frame seeded by an assistant summary. The facade keeps driving the same session until a real final result or stop. |
SessionGraph |
Durable nodes for inputs, assistant output, tool calls, protocol events, plugin nodes, and explicit FrameOpen boundaries. A node's frame is its nearest FrameOpen ancestor. |
Read views can show the full session history, while model projection normally reads only the current frame unless a projection explicitly widens scope. |
RuntimeSessionState |
Persisted session envelope: graph, derived current frame node id, policy, plugin/tool snapshots, usage, pending turn input, queued work, checkpoints, and head metadata. |
Hosts normally never construct it directly, but stores and advanced tools can persist or inspect it through the curated facade modules. |
Rule of thumb: keep app code on LashCore -> SessionBuilder -> LashSession -> TurnBuilder. Drop below the facade only for custom runtime hosts, persistence implementations, protocol plugins, or diagnostics that genuinely need internal state.
lash vs lash-core boundary
use std::sync::Arc;
use lash::{TurnEvent, TurnInput, plugins::runtime_plugin_stack, tools::*};
let store_factory = Arc::new(lash_sqlite_store::SqliteSessionStoreFactory::new(
data_dir.join("sessions"),
));
let artifact_store =
Arc::new(lash_sqlite_store::Store::open(&data_dir.join("artifacts.db")).await?);
let factory = lash::rlm::RlmProtocolPluginFactory::new(
lash::rlm::RlmProtocolPluginConfig::builder()
.instruction_limit(lash::rlm::InstructionBound::instructions(1_000_000))
.wall_clock(lash::rlm::WallClockBound::secs(30))
.memory_limit(lash::rlm::MemoryBound::mebibytes(64))
.build(),
artifact_store.clone(),
);
let core = lash::LashCore::rlm_builder(lash::TurnBudget::Unbounded, factory)
.provider(provider)
.model(
lash::ModelSpec::builder("anthropic/claude-sonnet-4.6")
.context_window_tokens(200_000)
.build()
.expect("valid model metadata"),
)
.plugins(runtime_plugin_stack())
.tools(Arc::new(AppTools) as Arc<dyn ToolProvider>)
.store_factory(store_factory)
.effect_host(Arc::new(lash::durability::InlineEffectHost::default()))
.attachment_store(Arc::new(lash::persistence::FileAttachmentStore::new(
data_dir.join("attachments"),
)))
.process_env_store(artifact_store)
// Start bounded; tune both limits for your backend's latency envelope.
.commit_budget(lash::CommitBudget::bounded(1024 * 1024, 512))
.queued_work_batching(lash::QueuedWorkBatchingConfig::new(1024))
.build(crate::example_process_owner())?;
let session = core.session("chat-123").open().await?;
let result = session
.turn(TurnInput::text("Use the app tools."))
.run()
.await?;
let assistant_text: String = result
.activities
.iter()
.filter_map(|activity| match &activity.event {
TurnEvent::AssistantProseDelta { text } => Some(text.as_ref()),
_ => None,
})
.collect();
println!("{assistant_text}");
LashCore
- Cloneable shared config for advanced custom protocol hosts. App code normally starts with
LashCore::standard_builder(turn_budget) or LashCore::rlm_builder(turn_budget, factory).
LashCore::standard_builder(turn_budget) / LashCore::rlm_builder(turn_budget, factory)
- Sugar entry points for the built-in protocols. Every builder requires the host to choose
TurnBudget::Bounded(n) or TurnBudget::Unbounded; there is no implicit session-semantic default. standard_builder(...) opens native provider-tool sessions; rlm_builder(...) opens Lashlang/RLM sessions from a host-configured RLM protocol factory.
CommitBudget
- Required host policy for one atomic runtime commit. Choose a bounded or unbounded byte limit and a bounded or unbounded graph-node limit; Lash installs no default.
CommitBudget::bounded(1024 * 1024, 512) is a recommended starting point only, and hosts should tune it for their backend latency envelope. The resolved value is carried to the shared validation path used by the facade and every backend. A typed budget rejection is terminal: increase the configured limit or submit a smaller commit instead of retrying the same operation unchanged.
SessionSpec
- Provider, model/variant, max context tokens, required turn budget, prompt layer, generation options. Root cores:
SessionSpec::new(). Children and subagents: SessionSpec::inherit(). .turn_budget(...) overrides the budget inherited from the root or parent policy; a serialized SessionPolicy without turn_budget is rejected instead of becoming silently unbounded. .generation(...) layers sampling intent — output token cap, temperature, seed — over what the session inherits, and every LLM call in the session carries the result, including subagent turns. The layering is per option: an option the spec leaves unset keeps its inherited value, so a subagent that only caps output tokens still runs at the temperature and seed its parent pinned. .replace_generation(...) uses exactly the options you pass and .clear_generation() expresses none, both discarding what would be inherited. On reopen an explicitly supplied session prompt wins and is committed on the next boundary; otherwise a present persisted session prompt wins, including an explicitly empty layer. The core prompt is a separate live render base that is never persisted, so a Host Application redeploy updates existing sessions. Legacy heads with no session-prompt field reconstruct the ordinary live session layer. Provider identity remains committed, while model, turn-budget, and generation policy are host-resolved again. A resident graph refresh never rolls back live prompt, model, or provider mutations that have not yet committed.
PluginStack
- Ordered plugin factory list.
LashCore::standard_builder(turn_budget) and LashCore::rlm_builder(turn_budget, factory) pre-seed their protocol plugin and the default runtime stack; generic LashCore::builder(turn_budget) requires an explicit protocol plugin. All paths still require explicit effect and attachment facets before build(); RLM also requires a Lashlang artifact store.
LashSession
- One conversation/task. Parked/resumable runtime, optional per-session store, exposes
turn(TurnInput), enqueue(TurnInput) (durable pending input for the next turn unless an active-turn ingress is specified), pending_turn_inputs() plus typed pending-input cancellation for user-input previews and edit reconciliation, queued_work() for admin inspection of non-user process wakes and session commands, await_queued_work_batch(batch_id) (resolve when a non-user queued-work batch you enqueued is drained or cancelled, by any handle or process, without claiming it yourself; bound it with tokio::time::timeout), read_view(), and admin().
TurnBuilder
- Per-turn config: cancellation, typed plugin input, RLM projected bindings.
.stream_to(&sink) for a push sink, .stream() for a pull-style futures_util::Stream, and .run() for a collected activity log. Durable handlers add .turn_id(...).effects(&controller).
Root defaults
use std::sync::Arc;
use lash::{SessionSpec, plugins::PluginFactory};
let root_spec = SessionSpec::new()
.turn_budget(lash::TurnBudget::Unbounded)
.provider_id(provider.kind())
.model(
lash::ModelSpec::builder("gpt-5.4")
.context_window_tokens(200_000)
.build()
.expect("valid model metadata"),
);
let factory = lash::rlm::RlmProtocolPluginFactory::new(
lash::rlm::RlmProtocolPluginConfig::builder()
.instruction_limit(lash::rlm::InstructionBound::instructions(1_000_000))
.wall_clock(lash::rlm::WallClockBound::secs(30))
.memory_limit(lash::rlm::MemoryBound::mebibytes(64))
.build(),
Arc::new(lash::persistence::InMemoryLashlangArtifactStore::new()),
);
let core = lash::LashCore::rlm_builder(lash::TurnBudget::Unbounded, factory)
.session_spec(root_spec)
.effect_host(Arc::new(lash::durability::InlineEffectHost::default()))
.attachment_store(Arc::new(lash::persistence::InMemoryAttachmentStore::new()))
.process_env_store(Arc::new(
lash::persistence::InMemoryProcessExecutionEnvStore::new(),
))
// Start bounded; tune both limits for your backend's latency envelope.
.commit_budget(lash::CommitBudget::bounded(1024 * 1024, 512))
.queued_work_batching(lash::QueuedWorkBatchingConfig::new(1024))
.configure_plugins(|plugins| {
plugins.push(Arc::new(AppPluginFactory) as Arc<dyn PluginFactory>);
})
.build(crate::example_process_owner())?;
Explicit stacks
use std::sync::Arc;
let plugins = runtime_plugin_stack().configure(|plugins| {
plugins.replace(Arc::new(CustomBudgetPlugin) as Arc<dyn PluginFactory>);
plugins.push(Arc::new(AppPluginFactory) as Arc<dyn PluginFactory>);
});
let factory = lash::rlm::RlmProtocolPluginFactory::new(
lash::rlm::RlmProtocolPluginConfig::builder()
.instruction_limit(lash::rlm::InstructionBound::instructions(1_000_000))
.wall_clock(lash::rlm::WallClockBound::secs(30))
.memory_limit(lash::rlm::MemoryBound::mebibytes(64))
.build(),
Arc::new(lash::persistence::InMemoryLashlangArtifactStore::new()),
);
let core = lash::LashCore::rlm_builder(lash::TurnBudget::Unbounded, factory)
.session_spec(root_spec)
.plugins(plugins)
.effect_host(Arc::new(lash::durability::InlineEffectHost::default()))
.attachment_store(Arc::new(lash::persistence::InMemoryAttachmentStore::new()))
.process_env_store(Arc::new(
lash::persistence::InMemoryProcessExecutionEnvStore::new(),
))
// Start bounded; tune both limits for your backend's latency envelope.
.commit_budget(lash::CommitBudget::bounded(1024 * 1024, 512))
.queued_work_batching(lash::QueuedWorkBatchingConfig::new(1024))
.build(crate::example_process_owner())?;
.plugin(...) appends; .plugins(...) replaces; .configure_plugins(...) mutates in place.
Collected result
let collected = session
.turn(TurnInput::text("Summarize this task."))
.run()
.await?;
let live_preview: String = collected
.activities
.iter()
.filter_map(|activity| match &activity.event {
TurnEvent::AssistantProseDelta { text } => Some(text.as_ref()),
_ => None,
})
.collect();
let settled_answer = collected.assistant_message().unwrap_or_default();
let total = collected.result.total_usage(); // parent + children
let parent_usage = collected.result.usage; // parent's own LLM tokens
let children = collected.result.children_usage; // per-(source, model) child entries
let outcome = collected.result.outcome;
Live stream
let ui_sink = Arc::new(AppEvents::new(tx));
let turn = session
.turn(TurnInput::text(user_text))
.stream_to(ui_sink.as_ref())
.await?;
persist(
turn.assistant_message().unwrap_or_default(),
turn.total_usage(),
)?;
Fold TurnActivity directly for live UI state. At turn completion, replace the current-turn display from TurnReport.state.read_view(); do not append a separate final assistant event. Use TurnActivity.correlation_id as the stable identity for multi-phase rows: start events insert, completion events update.