Architecture chapter 04

execution/runtime

Execution starts in the CLI bootstrap path, then branches into interactive TUI or autonomous --print. Inside a turn, the active mode decides whether model output becomes native tool calls or Lashlang code execution.

CLI Startup

The CLI resolves durable session state and runtime composition before entering the terminal UI or single-shot autonomous runner. App hosts use the app-facing lash facade (LashCore, LashSession, TurnBuilder) and never go through bootstrap::run.

Command execution path
flowchart TD Args["lash-cli Args
--print, --export, trace flags, provider/model"] --> Main["main()"] Main --> Export{"--export?"} Export -->|yes| ExportRun["lash-export
session DB + trace JSONL"] Export -->|no| Bootstrap["bootstrap::run"] Bootstrap --> SessionBootstrap["SessionBootstrap
new/resume/fork"] Bootstrap --> Providers["lash-providers-builtin::register_all"] Bootstrap --> Plugins["PluginHost + plugin factories"] Bootstrap --> Dynamic["DynamicToolProvider + MCP"] Bootstrap --> Runtime["CliSessionOpener -> LashSession"] Runtime --> Mode{"Host mode"} Mode -->|--print| Auto["run_autonomous"] Mode -->|interactive| Terminal["Terminal::enter + run_app"]

Mode Execution

Modes contribute protocol drivers and preambles. The runtime loop does not need provider-specific branches to understand Standard versus RLM behavior.

standard

Native provider tool calls

StandardDriver::handle_llm_success interprets provider output. Tool calls become StartTools, then RuntimeTurnDriver::run_tool_calls dispatches native mode tools first and then registered ToolProviders.

rlm

Persistent Lashlang execution

RlmDriver extracts a closed lashlang fence, starts ExecCode, and the RLM executor runs the program through RlmExecutionState against a HostBridge. Read-only host bindings (e.g., history) are projected into Lashlang scope via ProjectedBindings. continue_as is a mode-owned control tool; llm_query is supplied by lash-llm-tools; the stream_mask plugin hides the still-streaming fence from live UI until execution is complete.

background

Subagents and monitors

Subagents, monitor tasks, and async tool calls share handle-shaped background work. RLM discovers live handles with list_async_handles and resolves them with await.

handoff

continue_as successors

An RLM block ending with continue_as { task, seed } finishes the low-level turn as TurnOutcome::Handoff { session_id }; continue_as and spawn_agent decode their payload into RlmCreateExtras (in lash-rlm-types), and the successor session is created with SessionRelation::Handoff so the runtime can carry parent ids, turn index, and trace continuity. App hosts normally call session.turn(input).stream(&sink) or .run(); the facade follows handoffs and returns the final TurnResult.

Lash API

lash is the higher-level app-facing API used by the examples. It hides CLI bootstrap details and lash-core runtime plumbing while leaving providers, app persistence, HTTP protocols, auth, and frontend streaming under the host application's ownership.

App-facing lash facade
flowchart LR App["host app
HTTP / DB / auth / UI"] --> Core["LashCore
shared runtime environment"] Core --> Builder["LashCoreBuilder
provider, model, modes, tools, plugins"] Builder --> Advanced["builder.advanced()
host/runtime knobs"] Advanced --> Env["runtime environment
plugins + stores + tracing"] App --> Session["core.session(id)
SessionBuilder"] Session --> Runtime["parked runtime handle
per chat/session"] Runtime --> Turn["turn(TurnInput).stream(&sink) / .run()"] Turn --> Result["TurnResult / TurnOutput
result (+ activities)"] Turn --> Events["TurnActivitySink
text, reasoning, tools, usage, done"]
LashCore
Cloneable shared core that owns the runtime environment, default session policy, installed mode presets, optional store factory, tool providers, plugin factories, attachment store, and trace sink. Residency and termination policy are advanced runtime-host settings.
ModePreset
Installs semantic modes such as ModePreset::standard() and ModePreset::rlm(). Hosts can install both and choose a default with default_mode, then override per session with .standard(), .rlm(), or .mode(...).
LashSession
Represents one app conversation. It wraps the parked/resumable runtime handle, exposes run(TurnInput), turn(TurnInput), read_view(), and lower-level control groups through control(), and can carry a parent session id for app-level parent/child relationships.
TurnBuilder
Lets apps attach a cancellation token, per-turn mode options, plugin turn input, and RLM-projected bindings before running. .stream(&sink) drives the turn against a TurnActivitySink and returns a rich TurnResult with state, outcome, assistant output, usage, tool calls, execution summary, and errors; .run() returns a TurnOutput with the terminal result and ordered turn activities.
TurnInput
Core input is text plus image references. Hosts resolve UI syntax such as @path before constructing the turn, either as text markers or as future host-owned attachments.
TurnActivity
Normal app streams are semantic activity records with stable ids and correlation ids. Session-level raw event streams are runtime internals and tracing inputs, not lash API UI contracts.
let core = LashCore::standard()
    .provider(provider)
    .model("anthropic/claude-sonnet-4.6", None)
    .max_context_tokens(200_000)
    .store_factory(store_factory)
    .build()?;

let session = core.session(chat_id).standard().open().await?;
let result = session
    .turn(TurnInput::text(user_text))
    .stream(&events)
    .await?;

Example shape

examples/agent-service opens a LashSession from the chat id and store for each request, records projected TurnEvents through a TurnActivitySink, and wires an app database, a SessionStoreFactory, Axum routes, and browser streaming. The app-facing walkthrough lives in Lash API.

Boundary rule

The lash API is intentionally above raw runtime internals but below an app framework. It should not import CLI/TUI vocabulary, own product chat storage, or dictate HTTP and frontend protocols.

Background Work

Subagents are sessions, not special messages. Monitors are background tasks that wake future turns from stdout lines. Generic async tool calls are tracked inside the session.

Subagent and monitor path
flowchart LR Rlm["RLM lashlang
start call spawn_agent / call monitor"] --> Provider["spawn_agent_tool_definition
+ lash_core::runtime_controls factories"] Provider --> Host["LocalSubagentHost / MonitorHost"] Host --> Manager["RuntimeSessionManager"] Manager --> Child["start_turn -> stream_turn"] Manager --> Registry["background task registry"] Registry --> Handles["list_async_handles
monitor.*, subagent.*, tool.*"] Handles --> Await["await / cancel / submit_error"] Child --> Parent["usage relay + final result"]

The public subagent entry is lash_subagents::spawn_agent_tool_definition; the per-session RlmSubagentToolsProvider is crate-private. BuiltinMonitorToolPluginFactory and BuiltinTaskControlsPluginFactory live in lash_core::runtime_controls (re-exported by the lash facade as lash::plugins::*). A child can fail terminally with call submit_error { reason } (defined in lash-subagents/src/shared.rs), which surfaces back to the parent through the spawn_agent result.

Host Rendering

The terminal receives both live stream events and completed authoritative read views.

Interactive TUI

run_app drives event handling. Completed turns reconcile with finish_turn_from_read_view; live markdown lanes and activity state bridge partial output until the final projection arrives.

Export and trace

lash-export reads store state plus the full provider trace JSONL when available, then renders chronological projections, prompt snapshots, context percentages, cached-token percentages, and usage bars. lash-trace-viewer reads JSONL traces and displays timeline, LLM calls, stream events, and raw records.