Architecture chapter 03

data/flow

Turn data starts as host input. It becomes normalized messages and attachment refs, passes through plugin context transforms, drives a sans-IO effect loop, commits to the session graph, and finally projects into UI and export views.

Turn Lifecycle

The runtime owns host effects; TurnMachine owns protocol state. This keeps provider calls, tools, code execution, persistence, and streaming outside the pure state machine. Hosts can seed read-only host values into a turn's lashlang scope through RlmProjectedBindings: per-turn, apply them via TurnInput::rlm_project(bindings) from lash_mode_rlm::RlmTurnInputExt; per-session, install rlm_session_projection_extension(bindings) as a session-level mode extension (see lash-mode-rlm/src/projected_bindings.rs).

Turn effect loop
sequenceDiagram participant Host as CLI / app host participant Runtime as LashRuntime participant Plugins as PluginSession participant Machine as TurnMachine participant Provider as ProviderHandle participant Tools as Tool dispatch participant Lang as RLM lashlang runtime participant Store as RuntimePersistence Host->>Runtime: session.turn(TurnInput).stream(&sink) Runtime->>Runtime: normalize InputItem text/file/dir/image refs Runtime->>Plugins: prepare_turn + context transforms Runtime->>Machine: build_turn(ModePreamble, ToolSurface, PromptTemplate) loop poll effect Machine-->>Runtime: Effect alt LlmCall Runtime->>Provider: complete(LlmRequest) Provider-->>Runtime: stream events + LlmResponse Runtime->>Machine: Response::LlmComplete else ToolCalls Runtime->>Tools: native/mode/provider tools Tools-->>Runtime: ToolResult + ToolImage[] Runtime->>Plugins: ToolResultProjection BeforeState/Model/History Runtime->>Machine: Response::ToolResults else ExecCode Runtime->>Lang: execute lashlang (with ProjectedBindings) Lang-->>Runtime: ExecResponse + observations Runtime->>Machine: Response::ExecResult else Progress / Done Runtime->>Store: graph delta + checkpoint + usage end end Runtime->>Plugins: finalize_turn Runtime-->>Host: AssembledTurn (TurnOutcome) + stream events

Handoff Continuations

RLM's continue_as tool ends the current trajectory and queues a successor session with the packed task + seed as its first input. The low-level runtime exposes this as TurnOutcome::Handoff { session_id }. App hosts call TurnBuilder::stream, run, or collect_with; the lash facade drives handoff chains and returns the final turn result. The underlying runtime primitive is stream_turn_following_handoffs.

Handoff drive loop (runtime internal)
flowchart LR Host["host (CLI / bench / lash facade)"] --> Follow["runtime handoff follower"] Follow --> Turn["stream_turn"] Turn --> Outcome{"TurnOutcome"} Outcome -->|Finished / Stopped| Done["final turn result"] Outcome -->|Handoff{session_id}| Pending["pending_first_turn_inputs
(seeded by continue_as)"] Pending --> Successor["activate successor RLM session
fresh window, inherited turn_index"] Successor --> Turn

Tool Result Projection

ToolOutputBudgetPluginFactory registers projectors at three hook sites so the same raw output can be persisted in full while the model and rolled-up history see budgeted views.

HookEffect
BeforeStateLast chance to shape a result before it is committed to the durable session graph; only string-typed JSON payloads are truncated, structured values pass through.
BeforeModelTrims content right before it is shown to the LLM in the next request, including non-string payloads rendered for display.
BeforeHistorySame string-only truncation as BeforeState, applied as the result rolls into compacted history projections.

Limits are configured via ToolOutputBudgetConfig (ToolOutputBudgetMode::{Bytes, Tokens}; defaults: 16 KiB, 400 lines). RLM reuses this projector for its own print observations through project_observation_text.

Graph To UI Projection

Chronological rendering is a projection policy, not a storage policy. The graph remains durable and branchable; UI and exports choose readable order. lash-export walks an entire session tree: load_tree_from_paths assembles a LoadedSessionTree from the SQLite store and trace JSONL, and render_tree emits a multi-view HTML (lash-export/src/tree.rs, html.rs).

Read projection path
flowchart LR Store["Store
SQLite + blobs + session_head"] --> State["PersistedSessionState"] State --> Graph["SessionGraph
nodes + leaf_node_id"] Graph --> ReadModel["internal SessionReadModel cache
active_events, messages, tool_calls"] ReadModel --> View["SessionReadView"] View --> Chrono["ChronologicalProjection"] Chrono --> Timeline["lash-cli UiTimeline"] Chrono --> Export["lash-export HTML/JSON"] Chrono --> RlmHistory["RLM history projection"] Timeline --> Render["render_block_into
lash-tui Frame"]

Usage And Benchmark Flow

Token accounting is part of the runtime data flow. Parent turns, subagents, and direct LLM helper calls all report usage into the session ledger, and benchmark harnesses read deltas from that ledger.

Usage, trace, and CLBench path
flowchart TD Runner["Python CLBench adapter"] --> RustBench["bench-clbench-lash"] RustBench --> Runtime["LashRuntime in RLM mode"] Runtime --> Surface["restricted RLM tools
llm_query, spawn_agent(explore), continue_as, list_async_handles"] Surface --> Submit["submit JSON schema validation"] Runtime --> Usage["SessionUsageReport
ledger by source + model"] Usage --> Deltas["diff_usage_reports
benchmark cost deltas"] Runtime --> Trace["provider JSONL trace
LLM started/completed"] Runtime --> Store["SQLite session DB"] Trace --> Export["lash-export prompt snapshots
context %, cached %, token bars"] Store --> Export

Important Flow Rules

These rules are the easiest ones to break when refactoring runtime and UI code together.

  1. Normalize host input before prompt work.

    Text, file refs, dir refs, and image refs become typed message parts and attachment references.

  2. Runtime satisfies effects; sans-IO applies responses.

    Provider calls, tools, checkpoints, sleeps, and code execution are host responsibilities.

  3. Commit policy stays turn-scoped.

    TurnCommitPipeline owns graph deltas, checkpoint boundaries, and usage reconciliation.

  4. UI vocabulary stays in CLI crates.

    UiTimeline, activity blocks, render blocks, and chrome surfaces do not belong in lash.

  5. Usage is a ledger, not an exporter-only calculation.

    Runtime usage events carry source/model/token deltas; exporters add trace-derived prompt context detail when the JSONL trace is available.

  6. Tool output is projected before it is read.

    The state, model, and history all read through projector hooks. Skipping these — or projecting twice — breaks budget guarantees and the parity between persisted state and what the model actually saw.

  7. Handoffs are not a separate channel.

    A handoff is an outcome of the same turn path. App hosts get chaining through TurnBuilder::stream, run, or collect_with; lower-level runtime callers opt in with stream_turn_following_handoffs. Managed sessions, turn index, attachment store, and trace ids carry across the boundary automatically.