Skip to main content

lash_core/runtime/
mod.rs

1mod assembly;
2mod builder;
3pub(crate) mod causal;
4mod clock;
5mod config_ops;
6mod effect;
7mod environment;
8mod error;
9mod host;
10mod in_memory_store;
11mod io;
12mod lifecycle;
13mod logical_turn;
14mod observation;
15mod process;
16mod process_work_driver;
17mod process_worker;
18mod queued_work_driver;
19pub mod scenario_contracts;
20mod session_api;
21mod session_execution_lease;
22mod session_manager;
23mod session_ops;
24mod state;
25#[cfg(test)]
26pub(crate) mod tests;
27mod turn_boundary;
28mod turn_commit_draft;
29mod turn_driver;
30mod turn_graph_editor;
31mod turn_input_ingress;
32mod turn_loop;
33mod turn_queue;
34mod usage;
35
36use std::any::Any;
37use std::collections::HashMap;
38use std::fmt;
39use std::sync::Arc;
40use std::sync::Mutex as StdMutex;
41use std::sync::atomic::{AtomicBool, Ordering};
42
43use tokio::sync::{Mutex, mpsc};
44use tokio_util::sync::CancellationToken;
45
46use crate::llm::types::{
47    LlmOutputPart, LlmProviderTraceEvent, LlmProviderTraceSender, LlmRequest, LlmResponse,
48    LlmStreamEvent, LlmUsage,
49};
50use crate::plugin::{
51    CheckpointHookContext, PrepareTurnRequest, SessionConfigChangedContext, SessionRelation,
52};
53use crate::sansio::{LlmCallError, Response};
54use crate::session_model::{
55    Message, MessageRole, Part, PartKind, PruneState, RuntimeSessionPolicy, SessionEvent,
56    SessionPolicy, TokenUsage, fresh_message_id, make_error_event, reassign_part_ids, shared_parts,
57    transport_stream_events,
58};
59use crate::{
60    CheckpointKind, PersistentRuntimeServices, PluginOperationInvokeError, PromptHookContext,
61    RuntimeServices, SandboxMessage, Session, SessionCreateRequest, SessionError, SessionHandle,
62    SessionSnapshot, SessionStartPoint, ToolCallRecord, TurnFinish, TurnOutcome, TurnStop,
63};
64use crate::{Effect, TurnMachine};
65
66use host::*;
67use session_execution_lease::*;
68use session_manager::*;
69use turn_boundary::*;
70use turn_commit_draft::*;
71use turn_driver::*;
72
73pub(super) fn runtime_error_from_store_commit(err: crate::store::StoreError) -> RuntimeError {
74    match err {
75        crate::store::StoreError::SessionExecutionLeaseExpired { session_id } => RuntimeError::new(
76            RuntimeErrorCode::SessionExecutionLeaseLost,
77            format!("session execution lease for session `{session_id}` was lost before commit"),
78        ),
79        err => RuntimeError::new(RuntimeErrorCode::StoreCommitFailed, err.to_string()),
80    }
81}
82
83// `PromptUsage` is re-exported below alongside the runtime's own types.
84pub use lash_sansio::PromptUsage;
85
86use assembly::{
87    LlmDebugText, LlmDebugToolCall, LlmStreamAccumulator, LlmStreamDebugState, LlmStreamEventLog,
88    LlmStreamState, LlmStreamSummary, TurnAssembler,
89};
90#[cfg(test)]
91#[allow(unused_imports)]
92use assembly::{classify_output_state, sanitize_assistant_output};
93pub use builder::EmbeddedRuntimeBuilder;
94pub use causal::process_event_invocation;
95pub(crate) use causal::tool_retry_sleep_invocation;
96pub use clock::{Clock, SystemClock};
97pub(crate) use effect::RuntimeEffectControllerHandle;
98pub use effect::{
99    AwaitEventKey, AwaitEventResolver, AwaitEventWaitIdentity, BoundaryReason, CausalRef,
100    EffectHost, ExecutionScope, ExternalCompletionError, InlineEffectHost,
101    InlineRuntimeEffectController, LlmAttachmentSpec, LlmRequestSpec, ProcessCommand,
102    ProcessEffectOutcome, Resolution, ResolveOutcome, RuntimeAwaitEventOptions,
103    RuntimeDirectLlmOutcome, RuntimeEffectCommand, RuntimeEffectController,
104    RuntimeEffectControllerError, RuntimeEffectEnvelope, RuntimeEffectKind,
105    RuntimeEffectLocalExecutor, RuntimeEffectOutcome, RuntimeInvocation, RuntimeLlmCallOutcome,
106    RuntimeReplay, RuntimeScope, RuntimeSubject, ScopedEffectController, SegmentProgress,
107    ToolAttemptEffectOutcome, ToolAttemptLaunch, ToolBatchEffectOutcome, ToolCallLaunch,
108};
109pub use environment::{ParkedSession, Residency, RuntimeEnvironment, RuntimeEnvironmentBuilder};
110pub use error::{DurableStoreFacet, RuntimeError, RuntimeErrorCode};
111pub use host::{EmbeddedRuntimeHost, ProcessRuntimeHost, RuntimeHostConfig};
112pub use in_memory_store::{InMemorySessionStore, InMemorySessionStoreFactory};
113use io::normalize_input_items;
114pub use observation::{
115    InMemoryLiveReplayStore, InMemoryLiveReplayStoreConfig, LiveReplayGap, LiveReplayGapReason,
116    LiveReplayResult, LiveReplayStore, LiveReplayStoreError, LiveReplaySubscribeResult,
117    LiveReplaySubscription, RuntimeHandle, RuntimeObservation, SessionCursor, SessionCursorError,
118    SessionObservation, SessionObservationEvent, SessionObservationEventPayload,
119    SessionObservationSubscription, SessionProcessEventKind, SessionQueueEventKind, SessionResume,
120    SessionRevision,
121};
122#[cfg(any(test, feature = "testing"))]
123pub use process::TestLocalProcessRegistry;
124pub use process::{
125    AbandonEvidence, AbandonRequest, AbandonWriter, DefaultProcessCancelAbility,
126    InMemoryProcessExecutionEnvStore, ObservedProcess, ObservedProcessEvent, ObservedWorkItem,
127    PROCESS_LEASE_SCHEMA_VERSION, PersistedSegmentHandover, ProcessAttach, ProcessAwaitOutput,
128    ProcessAwaiter, ProcessCancelAbility, ProcessCancelAllRequest, ProcessCancelRequest,
129    ProcessCancelSource, ProcessCancelSummary, ProcessChangeCursor, ProcessChangeHub,
130    ProcessCompletionAuthority, ProcessEngine, ProcessEngineRegistry, ProcessEngineRunContext,
131    ProcessEngineRunGuard, ProcessEngineRuntimeContext, ProcessEngineValidationContext,
132    ProcessEvent, ProcessEventAppendPlan, ProcessEventAppendRequest, ProcessEventAppendResult,
133    ProcessEventSemantics, ProcessEventSemanticsSpec, ProcessEventSink, ProcessEventType,
134    ProcessExecutionContext, ProcessExecutionEnvRef, ProcessExecutionEnvSpec,
135    ProcessExecutionEnvStore, ProcessExternalRef, ProcessHandleDescriptor, ProcessHandleGrant,
136    ProcessHandleGrantEntry, ProcessHandleSummary, ProcessId, ProcessIdentity, ProcessInput,
137    ProcessLease, ProcessLeaseClaimOutcome, ProcessLeaseCompletion, ProcessLifecycleStatus,
138    ProcessListFilter, ProcessListMode, ProcessLiveReferenceSummary, ProcessOpScope,
139    ProcessOriginator, ProcessProvenance, ProcessPruneReport, ProcessRecord, ProcessRegistration,
140    ProcessRegistry, ProcessRunOutcome, ProcessService, ProcessSessionDeleteReport,
141    ProcessSpawnProvenance, ProcessStartGrant, ProcessStartOptions, ProcessStartRequest,
142    ProcessStarted, ProcessStatus, ProcessStatusFilter, ProcessTerminalSemantics,
143    ProcessTerminalSpec, ProcessTerminalState, ProcessValueSelector, ProcessWake,
144    ProcessWakeDedupeKey, ProcessWakeDelivery, ProcessWakeDeliveryRequest, ProcessWakeSpec,
145    ProcessWorkObserver, ProcessWorkSnapshot, RecoveryDisposition, SegmentHandover, SessionScope,
146    SessionScopeId, UnavailableProcessService, WaitKind, WaitState,
147    apply_process_status_projection, current_epoch_ms, epoch_ms_from_system_time,
148    load_process_execution_env, materialize_process_event_semantics, persist_process_execution_env,
149    prepare_process_event_append, prepare_process_registration, process_event_payload_hash,
150    process_signal_event_type, process_signal_name_from_event_type, process_signal_wait_key,
151    process_wake_delivery, process_wake_input_from_event_payload, process_wake_turn_cause,
152    process_wake_turn_text, require_event_replay, system_time_from_epoch_ms,
153    terminal_append_request, terminal_event_type_name, validate_process_signal_name,
154    watch_process_registry, watch_process_registry_with_sink,
155};
156pub use process_work_driver::{InlineProcessRunHandle, ProcessRunHandle, ProcessWorkDriver};
157pub use process_worker::{DurableProcessWorker, DurableProcessWorkerConfig, ProcessDrainReport};
158pub use queued_work_driver::{QueuedWorkDriver, QueuedWorkRunHandle, QueuedWorkRunRequest};
159pub use scenario_contracts::{RUNTIME_SCENARIO_CONTRACTS, ScenarioContractSpec};
160pub use session_manager::DirectCompletionClient;
161pub use state::RuntimeSessionState;
162use state::{
163    append_session_nodes_to_state_with_clock, apply_residency_on_load, apply_session_checkpoint,
164    apply_session_head, normalize_session_graph, open_agent_frame_in_state_with_clock,
165};
166pub use turn_input_ingress::{
167    PendingTurnInput, PendingTurnInputCancelOutcome, PendingTurnInputCancelResult,
168    PendingTurnInputCancelTarget, PendingTurnInputClaimDiagnostics, PendingTurnInputDraft,
169    PendingTurnInputSuffixCancelOutcome, QueuedCheckpointTurnInput, TurnInputCheckpointBoundary,
170    TurnInputClaim, TurnInputClaimMode, TurnInputCompletion, TurnInputIngress, TurnInputState,
171};
172pub use turn_loop::ensure_durable_effect_input;
173pub use turn_queue::{
174    DeliveryPolicy, MergeKey, QueuedCheckpointWork, QueuedTurnWork, QueuedWorkBatch,
175    QueuedWorkBatchDraft, QueuedWorkClaim, QueuedWorkClaimBoundary, QueuedWorkClass,
176    QueuedWorkCompletion, QueuedWorkItem, QueuedWorkPayload, SessionCommand, SessionCommandReceipt,
177    SlotPolicy, process_wake_batch_draft,
178};
179pub use usage::{
180    SessionUsageReport, TokenLedgerEntry, UsageReportRow, UsageTotals, diff_token_ledger,
181    diff_usage_reports,
182};
183use usage::{merge_ledger_entry, merge_usage_delta_entries, normalize_prompt_usage};
184
185#[doc(hidden)]
186#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
187pub enum RuntimeTurnPhase {
188    ContextTransform,
189    BeforeTurnHooks,
190    PromptBuild,
191    EffectLoop,
192    FinalizeTurn,
193    PersistTurn,
194    FinalCommit,
195    PostPersistHooks,
196}
197
198#[doc(hidden)]
199pub trait RuntimeTurnPhaseProbe: Send + Sync {
200    fn begin(&self, phase: RuntimeTurnPhase);
201    fn end(&self, phase: RuntimeTurnPhase);
202    fn begin_named(&self, _phase: &str) {}
203    fn end_named(&self, _phase: &str) {}
204}
205
206#[doc(hidden)]
207#[derive(Clone, Default)]
208pub struct RuntimeTurnPhaseProbeSlot {
209    probes: Arc<StdMutex<HashMap<crate::SessionScopeId, Arc<dyn RuntimeTurnPhaseProbe>>>>,
210}
211
212impl RuntimeTurnPhaseProbeSlot {
213    pub fn set_for_session(
214        &self,
215        session_id: impl Into<String>,
216        probe: Arc<dyn RuntimeTurnPhaseProbe>,
217    ) {
218        self.set_for_scope(&crate::SessionScope::new(session_id), probe);
219    }
220
221    pub fn set_for_scope(
222        &self,
223        scope: &crate::SessionScope,
224        probe: Arc<dyn RuntimeTurnPhaseProbe>,
225    ) {
226        self.probes
227            .lock()
228            .expect("runtime phase probe slot")
229            .insert(scope.id(), probe);
230    }
231
232    pub fn get_for_scope(
233        &self,
234        scope: &crate::SessionScope,
235    ) -> Option<Arc<dyn RuntimeTurnPhaseProbe>> {
236        let probes = self.probes.lock().expect("runtime phase probe slot");
237        probes.get(&scope.id()).cloned().or_else(|| {
238            probes
239                .get(&crate::SessionScope::new(&scope.session_id).id())
240                .cloned()
241        })
242    }
243}
244
245#[doc(hidden)]
246pub struct RuntimeNamedPhase {
247    probe: Option<Arc<dyn RuntimeTurnPhaseProbe>>,
248    phase: &'static str,
249}
250
251impl RuntimeNamedPhase {
252    pub fn begin(
253        probe: Option<Arc<dyn RuntimeTurnPhaseProbe>>,
254        phase: &'static str,
255    ) -> RuntimeNamedPhase {
256        if let Some(probe) = probe.as_ref() {
257            probe.begin_named(phase);
258        }
259        RuntimeNamedPhase { probe, phase }
260    }
261}
262
263impl Drop for RuntimeNamedPhase {
264    fn drop(&mut self) {
265        if let Some(probe) = self.probe.as_ref() {
266            probe.end_named(self.phase);
267        }
268    }
269}
270
271/// Host-provided per-turn input.
272#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
273#[serde(tag = "type", rename_all = "snake_case")]
274pub enum InputItem {
275    Text { text: String },
276    ImageRef { id: String },
277}
278
279impl InputItem {
280    pub fn text(text: impl Into<String>) -> Self {
281        Self::Text { text: text.into() }
282    }
283
284    pub fn image_ref(id: impl Into<String>) -> Self {
285        Self::ImageRef { id: id.into() }
286    }
287}
288
289/// Host-provided per-turn input.
290#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
291pub struct TurnInput {
292    pub items: Vec<InputItem>,
293    #[serde(default)]
294    pub image_blobs: HashMap<String, Vec<u8>>,
295    /// Per-turn override for protocol-owned turn options.
296    #[serde(default, skip_serializing_if = "Option::is_none")]
297    pub protocol_turn_options: Option<crate::ProtocolTurnOptions>,
298    /// Optional externally-stable trace turn id. Normal runtime callers leave
299    /// this empty and the runtime generates one per outer turn.
300    #[serde(default, skip_serializing_if = "Option::is_none")]
301    pub trace_turn_id: Option<String>,
302    #[serde(skip)]
303    pub protocol_extension: Option<ProtocolTurnExtensionHandle>,
304    #[serde(skip)]
305    pub turn_context: TurnContext,
306}
307
308impl TurnInput {
309    pub fn empty() -> Self {
310        Self::items(std::iter::empty())
311    }
312
313    pub fn text(text: impl Into<String>) -> Self {
314        Self::items([InputItem::text(text)])
315    }
316
317    pub fn items(items: impl IntoIterator<Item = InputItem>) -> Self {
318        Self {
319            items: items.into_iter().collect(),
320            image_blobs: HashMap::new(),
321            protocol_turn_options: None,
322            trace_turn_id: None,
323            protocol_extension: None,
324            turn_context: TurnContext::default(),
325        }
326    }
327
328    pub fn with_image_blob(mut self, id: impl Into<String>, bytes: Vec<u8>) -> Self {
329        self.image_blobs.insert(id.into(), bytes);
330        self
331    }
332
333    pub fn with_image_blobs<I, K>(mut self, image_blobs: I) -> Self
334    where
335        I: IntoIterator<Item = (K, Vec<u8>)>,
336        K: Into<String>,
337    {
338        self.image_blobs.extend(
339            image_blobs
340                .into_iter()
341                .map(|(id, bytes)| (id.into(), bytes)),
342        );
343        self
344    }
345
346    pub fn with_image_ref(mut self, id: impl Into<String>, bytes: Vec<u8>) -> Self {
347        let id = id.into();
348        self.items.push(InputItem::image_ref(id.clone()));
349        self.image_blobs.insert(id, bytes);
350        self
351    }
352
353    pub fn with_protocol_turn_options(mut self, options: crate::ProtocolTurnOptions) -> Self {
354        self.protocol_turn_options = Some(options);
355        self
356    }
357
358    pub fn with_trace_turn_id(mut self, trace_turn_id: impl Into<String>) -> Self {
359        self.trace_turn_id = Some(trace_turn_id.into());
360        self
361    }
362}
363
364/// Per-turn, in-process side channel of typed plugin inputs.
365///
366/// This is an `Any`-keyed map of live Rust values handed to plugins for a
367/// single turn. It is deliberately **not** serializable: the values never
368/// survive a process boundary, so durable effect-host runs explicitly reject a
369/// turn that carries any live inputs (see
370/// [`LiveTurnInputs::durable_effect_rejection`]). Durable callers must instead
371/// encode replayable data in
372/// `protocol_turn_options` or persisted plugin state.
373#[derive(Clone, Default)]
374pub struct LiveTurnInputs {
375    inputs: HashMap<&'static str, Arc<dyn Any + Send + Sync>>,
376}
377
378impl LiveTurnInputs {
379    fn insert<T>(&mut self, plugin_id: &'static str, input: T)
380    where
381        T: Send + Sync + 'static,
382    {
383        self.inputs.insert(plugin_id, Arc::new(input));
384    }
385
386    fn get<T>(&self, plugin_id: &'static str) -> Option<&T>
387    where
388        T: 'static,
389    {
390        self.inputs
391            .get(plugin_id)
392            .and_then(|input| input.downcast_ref::<T>())
393    }
394
395    fn contains(&self, plugin_id: &'static str) -> bool {
396        self.inputs.contains_key(plugin_id)
397    }
398
399    pub fn plugin_ids(&self) -> Vec<&'static str> {
400        self.inputs.keys().copied().collect()
401    }
402
403    /// Returns an error when live per-turn inputs would make a durable effect
404    /// host replay depend on process-local values.
405    pub(crate) fn durable_effect_rejection(&self) -> Result<(), RuntimeError> {
406        if self.inputs.is_empty() {
407            return Ok(());
408        }
409        Err(RuntimeError::new(
410            RuntimeErrorCode::DurableEffectLivePluginInput,
411            "durable effect hosts do not support live TurnContext plugin inputs; encode replayable data in protocol_turn_options or persisted plugin state",
412        ))
413    }
414}
415
416#[derive(Clone, Default)]
417pub struct TurnContext {
418    plugin_inputs: LiveTurnInputs,
419    provider: Option<crate::ProviderHandle>,
420    prompt: crate::PromptLayer,
421}
422
423impl TurnContext {
424    pub fn new() -> Self {
425        Self::default()
426    }
427
428    pub fn insert_plugin_input<T>(&mut self, plugin_id: &'static str, input: T)
429    where
430        T: Send + Sync + 'static,
431    {
432        self.plugin_inputs.insert(plugin_id, input);
433    }
434
435    pub fn set_provider(&mut self, provider: crate::ProviderHandle) {
436        self.provider = Some(provider);
437    }
438
439    pub fn provider(&self) -> Option<&crate::ProviderHandle> {
440        self.provider.as_ref()
441    }
442
443    pub fn plugin_input<T>(&self, plugin_id: &'static str) -> Option<&T>
444    where
445        T: 'static,
446    {
447        self.plugin_inputs.get(plugin_id)
448    }
449
450    pub fn has_plugin_input(&self, plugin_id: &'static str) -> bool {
451        self.plugin_inputs.contains(plugin_id)
452    }
453
454    pub fn has_live_plugin_inputs(&self) -> bool {
455        !self.plugin_inputs.inputs.is_empty()
456    }
457
458    pub fn live_plugin_input_ids(&self) -> Vec<&'static str> {
459        self.plugin_inputs.plugin_ids()
460    }
461
462    /// Live plugin inputs for this turn. The durable boundary inspects this to
463    /// reject turns carrying non-serializable live state.
464    pub(crate) fn live_plugin_inputs(&self) -> &LiveTurnInputs {
465        &self.plugin_inputs
466    }
467
468    pub fn set_prompt_template(&mut self, template: crate::PromptTemplate) {
469        self.prompt.template = Some(template);
470    }
471
472    pub fn add_prompt_contribution(&mut self, contribution: crate::PromptContribution) {
473        self.prompt.add_contribution(contribution);
474    }
475
476    pub fn replace_prompt_slot(
477        &mut self,
478        slot: crate::PromptSlot,
479        contributions: impl IntoIterator<Item = crate::PromptContribution>,
480    ) {
481        self.prompt.replace_slot(slot, contributions);
482    }
483
484    pub fn clear_prompt_slot(&mut self, slot: crate::PromptSlot) {
485        self.prompt.clear_slot(slot);
486    }
487
488    pub fn set_prompt_layer(&mut self, prompt: crate::PromptLayer) {
489        self.prompt = prompt;
490    }
491
492    pub fn prompt_layer(&self) -> &crate::PromptLayer {
493        &self.prompt
494    }
495}
496
497impl fmt::Debug for TurnContext {
498    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
499        f.debug_struct("TurnContext")
500            .field("plugin_inputs", &self.plugin_inputs.plugin_ids())
501            .field("has_provider", &self.provider.is_some())
502            .field("has_prompt_layer", &(!self.prompt.is_empty()))
503            .finish()
504    }
505}
506
507#[derive(Clone)]
508pub struct ProtocolTurnExtensionHandle(Arc<dyn ProtocolTurnExtension>);
509
510impl ProtocolTurnExtensionHandle {
511    pub fn new(extension: impl ProtocolTurnExtension + 'static) -> Self {
512        Self(Arc::new(extension))
513    }
514
515    pub fn as_any(&self) -> &dyn Any {
516        self.0.as_any()
517    }
518
519    pub fn prompt_contributions(&self) -> Vec<crate::PromptContribution> {
520        self.0.prompt_contributions()
521    }
522}
523
524impl fmt::Debug for ProtocolTurnExtensionHandle {
525    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
526        f.write_str("ProtocolTurnExtensionHandle(..)")
527    }
528}
529
530pub trait ProtocolTurnExtension: Send + Sync {
531    fn as_any(&self) -> &dyn Any;
532
533    fn prompt_contributions(&self) -> Vec<crate::PromptContribution> {
534        Vec::new()
535    }
536}
537
538#[derive(Clone)]
539pub struct ProtocolSessionExtensionHandle(Arc<dyn ProtocolSessionExtension>);
540
541impl ProtocolSessionExtensionHandle {
542    pub fn new(extension: impl ProtocolSessionExtension + 'static) -> Self {
543        Self(Arc::new(extension))
544    }
545
546    pub fn as_any(&self) -> &dyn Any {
547        self.0.as_any()
548    }
549}
550
551impl fmt::Debug for ProtocolSessionExtensionHandle {
552    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
553        f.write_str("ProtocolSessionExtensionHandle(..)")
554    }
555}
556
557pub trait ProtocolSessionExtension: Send + Sync {
558    fn as_any(&self) -> &dyn Any;
559}
560
561#[derive(Clone, Debug)]
562pub(super) enum NormalizedItem {
563    Text(String),
564    Image(crate::AttachmentRef),
565}
566
567/// Canonical assistant output payload.
568#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
569pub struct AssistantOutput {
570    pub safe_text: String,
571    pub raw_text: String,
572    pub state: OutputState,
573}
574
575/// Quality and usability of assembled terminal output.
576#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
577#[serde(rename_all = "snake_case")]
578pub enum OutputState {
579    Usable,
580    EmptyOutput,
581    TracebackOnly,
582    RecoveredFromError,
583}
584
585/// Code execution output observed during a turn.
586#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
587pub struct CodeOutputRecord {
588    pub output: String,
589    #[serde(default, skip_serializing_if = "Option::is_none")]
590    pub error: Option<String>,
591}
592
593/// High-level execution summary for a completed turn.
594#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
595pub struct ExecutionSummary {
596    #[serde(default)]
597    pub had_tool_calls: bool,
598    #[serde(default)]
599    pub had_code_execution: bool,
600    /// Wall-clock turn start as epoch milliseconds, read from the runtime
601    /// [`Clock`]. The measurement window opens when the runtime starts
602    /// claiming the turn (session-execution lease / queued-work claim), so
603    /// it covers the whole host-visible turn. `0` when the turn predates
604    /// this field.
605    #[serde(default)]
606    pub started_at_ms: u64,
607    /// Whole-turn duration in milliseconds — claim through final commit and
608    /// post-persist hooks — measured on the runtime [`Clock`]'s monotonic
609    /// source. `0` when the turn predates this field.
610    #[serde(default)]
611    pub duration_ms: u64,
612}
613
614/// Structured issue surfaced during turn execution.
615#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
616pub struct TurnIssue {
617    pub kind: String,
618    #[serde(default, skip_serializing_if = "Option::is_none")]
619    pub code: Option<String>,
620    #[serde(default, skip_serializing_if = "Option::is_none")]
621    pub terminal_reason: Option<crate::LlmTerminalReason>,
622    pub message: String,
623    #[serde(default, skip_serializing_if = "Option::is_none")]
624    pub raw: Option<String>,
625    /// Whether the failing operation is safe to retry, when the source
626    /// carried a typed signal (provider transports classify retryability;
627    /// terminal LLM responses are deterministic and report `Some(false)`).
628    /// `None` means the source did not know.
629    #[serde(default, skip_serializing_if = "Option::is_none")]
630    pub retryable: Option<bool>,
631    /// Typed provider-failure classification, present only when the issue
632    /// came from a classified LLM provider/transport failure.
633    #[serde(default, skip_serializing_if = "Option::is_none")]
634    pub provider_failure_kind: Option<crate::ProviderFailureKind>,
635}
636
637/// Canonical high-level turn result returned to hosts.
638#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
639pub struct AssembledTurn {
640    pub state: SessionSnapshot,
641    pub outcome: crate::TurnOutcome,
642    pub assistant_output: AssistantOutput,
643    pub execution: ExecutionSummary,
644    #[serde(default)]
645    pub token_usage: TokenUsage,
646    /// Per-(session, source, model) ledger entries for child sessions whose
647    /// LLM calls completed during this turn. `token_usage` above is the
648    /// parent's own LLM tokens; `total_usage` (on the embed-facing
649    /// `TurnResult`) sums both.
650    #[serde(default)]
651    pub children_usage: Vec<TokenLedgerEntry>,
652    /// Provider calls made by this session during the turn, in protocol order.
653    /// Child-session calls remain on the child turn result.
654    #[serde(default)]
655    pub llm_calls: Vec<crate::LlmCallRecord>,
656    #[serde(default)]
657    pub tool_calls: Vec<ToolCallRecord>,
658    #[serde(default)]
659    pub errors: Vec<TurnIssue>,
660}
661
662/// Result of driving one logical host turn through any AgentFrame switches.
663///
664/// A frame switch is an internal runtime continuation, similar to compaction
665/// from a host's perspective. Callers that need a final answer can use
666/// [`LashRuntime::stream_turn_with_agent_frames`] and inspect `final_turn()`.
667#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
668pub struct AgentFrameRun {
669    pub turns: Vec<AssembledTurn>,
670}
671
672impl AgentFrameRun {
673    pub fn final_turn(&self) -> Option<&AssembledTurn> {
674        self.turns.last()
675    }
676
677    pub fn into_final_turn(mut self) -> Option<AssembledTurn> {
678        self.turns.pop()
679    }
680
681    pub fn frame_switch_count(&self) -> usize {
682        self.turns
683            .iter()
684            .filter(|turn| matches!(turn.outcome, crate::TurnOutcome::AgentFrameSwitch { .. }))
685            .count()
686    }
687}
688
689/// Termination policy knobs.
690#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
691pub struct TerminationPolicy {
692    #[serde(default)]
693    pub treat_missing_done_as_failure: bool,
694}
695
696impl Default for TerminationPolicy {
697    fn default() -> Self {
698        Self {
699            treat_missing_done_as_failure: true,
700        }
701    }
702}
703
704/// Host application sink for low-level streaming runtime events.
705/// `SessionEvent` is protocol-specific preview/progress data.
706#[async_trait::async_trait]
707pub trait EventSink: Send + Sync {
708    fn is_noop(&self) -> bool {
709        false
710    }
711
712    async fn emit(&self, event: SessionEvent);
713}
714
715/// No-op sink useful for callers that only care about final state.
716pub struct NoopEventSink;
717
718/// Static no-op event sink for callers that need a `&dyn EventSink` default.
719pub static NOOP_EVENT_SINK: NoopEventSink = NoopEventSink;
720
721#[async_trait::async_trait]
722impl EventSink for NoopEventSink {
723    fn is_noop(&self) -> bool {
724        true
725    }
726
727    async fn emit(&self, _event: SessionEvent) {}
728}
729
730/// Stable identifier for a semantic turn activity.
731#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
732#[serde(transparent)]
733pub struct TurnActivityId(pub String);
734
735impl TurnActivityId {
736    pub fn new(id: impl Into<String>) -> Self {
737        Self(id.into())
738    }
739
740    pub fn fresh() -> Self {
741        Self(uuid::Uuid::new_v4().to_string())
742    }
743}
744
745/// App-facing semantic activity emitted during a turn.
746///
747/// `id` is unique per emitted activity event. `correlation_id` groups related
748/// events in the same logical activity, such as code start/completion, tool
749/// start/completion, or text deltas from one output block.
750#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
751pub struct TurnActivity {
752    pub id: TurnActivityId,
753    pub correlation_id: TurnActivityId,
754    #[serde(flatten)]
755    pub event: TurnEvent,
756}
757
758impl TurnActivity {
759    pub fn new(correlation_id: TurnActivityId, event: TurnEvent) -> Self {
760        Self {
761            id: TurnActivityId::fresh(),
762            correlation_id,
763            event,
764        }
765    }
766
767    pub fn independent(event: TurnEvent) -> Self {
768        let correlation_id = TurnActivityId::fresh();
769        Self::new(correlation_id, event)
770    }
771}
772
773/// App-facing semantic event payload for a turn activity.
774///
775/// Unlike [`SessionEvent`], these events are stable application signals rather
776/// than low-level runtime/debug events. Public streams carry these payloads
777/// inside [`TurnActivity`] so every emitted item has identity.
778#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
779#[serde(tag = "type", rename_all = "snake_case")]
780#[allow(clippy::large_enum_variant)]
781pub enum TurnEvent {
782    QueuedWorkStarted {
783        boundary: crate::QueuedWorkClaimBoundary,
784        batch_ids: Vec<String>,
785        causes: Vec<crate::TurnCause>,
786    },
787    ModelRequestStarted {
788        protocol_iteration: usize,
789    },
790    AssistantProseDelta {
791        text: String,
792    },
793    ReasoningDelta {
794        text: String,
795    },
796    CodeBlockStarted {
797        language: String,
798        code: String,
799        #[serde(default, skip_serializing_if = "Option::is_none")]
800        graph_key: Option<String>,
801    },
802    CodeBlockCompleted {
803        language: String,
804        output: String,
805        #[serde(default, skip_serializing_if = "Option::is_none")]
806        error: Option<String>,
807        success: bool,
808        duration_ms: u64,
809        tool_call_ids: Vec<String>,
810        #[serde(default, skip_serializing_if = "Option::is_none")]
811        graph_key: Option<String>,
812    },
813    ToolCallStarted {
814        #[serde(default, skip_serializing_if = "Option::is_none")]
815        call_id: Option<String>,
816        name: String,
817        args: serde_json::Value,
818        /// Graph key of the enclosing code block, when this tool call ran
819        /// inside one. `None` when the call did not run inside a code block.
820        #[serde(default, skip_serializing_if = "Option::is_none")]
821        graph_key: Option<String>,
822        /// Call id of the parent batch tool call, when this call is a child of
823        /// a `batch` dispatch. `None` for top-level tool calls.
824        #[serde(default, skip_serializing_if = "Option::is_none")]
825        parent_call_id: Option<String>,
826    },
827    ToolCallCompleted {
828        #[serde(default, skip_serializing_if = "Option::is_none")]
829        call_id: Option<String>,
830        name: String,
831        args: serde_json::Value,
832        output: crate::ToolCallOutput,
833        duration_ms: u64,
834        /// Graph key of the enclosing code block, when this tool call ran
835        /// inside one. `None` when the call did not run inside a code block.
836        #[serde(default, skip_serializing_if = "Option::is_none")]
837        graph_key: Option<String>,
838        /// Call id of the parent batch tool call, when this call is a child of
839        /// a `batch` dispatch. `None` for top-level tool calls.
840        #[serde(default, skip_serializing_if = "Option::is_none")]
841        parent_call_id: Option<String>,
842    },
843    FinalValue {
844        value: serde_json::Value,
845    },
846    ToolValue {
847        tool_name: String,
848        value: serde_json::Value,
849    },
850    Usage {
851        protocol_iteration: usize,
852        usage: TokenUsage,
853        cumulative: TokenUsage,
854    },
855    ChildUsage {
856        session_id: String,
857        source: String,
858        model: String,
859        protocol_iteration: usize,
860        usage: TokenUsage,
861        cumulative: TokenUsage,
862    },
863    RetryStatus {
864        wait_seconds: u64,
865        attempt: usize,
866        max_attempts: usize,
867        reason: String,
868    },
869    PluginRuntime {
870        plugin_id: String,
871        event: crate::PluginRuntimeEvent,
872    },
873    QueuedInputAccepted {
874        checkpoint: crate::CheckpointKind,
875        inputs: Vec<crate::AcceptedInjectedTurnInput>,
876    },
877    QueuedMessagesCommitted {
878        messages: Vec<crate::PluginMessage>,
879        checkpoint: crate::CheckpointKind,
880    },
881    Error {
882        message: String,
883    },
884}
885
886#[async_trait::async_trait]
887pub trait TurnActivitySink: Send + Sync {
888    fn is_noop(&self) -> bool {
889        false
890    }
891
892    async fn emit(&self, activity: TurnActivity);
893}
894
895pub struct NoopTurnActivitySink;
896
897/// Static no-op turn-activity sink for callers that need a `&dyn TurnActivitySink` default.
898pub static NOOP_TURN_ACTIVITY_SINK: NoopTurnActivitySink = NoopTurnActivitySink;
899
900#[async_trait::async_trait]
901impl TurnActivitySink for NoopTurnActivitySink {
902    fn is_noop(&self) -> bool {
903        true
904    }
905
906    async fn emit(&self, _activity: TurnActivity) {}
907}
908
909/// Optional sinks and scoped effect controller passed to one of [`LashRuntime`]'s
910/// turn-driving entry points (`stream_turn`,
911/// `stream_turn_with_agent_frames`).
912///
913/// Construct via [`TurnOptions::new`] and chain `with_*` builders. Event sinks
914/// default to no-op sinks. Execution scope is explicit and required at every
915/// runtime boundary that can execute nondeterministic work.
916pub struct TurnOptions<'a> {
917    events: Option<&'a dyn EventSink>,
918    turn_events: Option<&'a dyn TurnActivitySink>,
919    scoped_effect_controller: ScopedEffectController<'a>,
920    cancel: CancellationToken,
921}
922
923impl<'a> TurnOptions<'a> {
924    pub fn new(
925        cancel: CancellationToken,
926        scoped_effect_controller: ScopedEffectController<'a>,
927    ) -> Self {
928        Self {
929            events: None,
930            turn_events: None,
931            scoped_effect_controller,
932            cancel,
933        }
934    }
935
936    pub fn with_events(mut self, events: &'a dyn EventSink) -> Self {
937        self.events = Some(events);
938        self
939    }
940
941    pub fn with_turn_events(mut self, turn_events: &'a dyn TurnActivitySink) -> Self {
942        self.turn_events = Some(turn_events);
943        self
944    }
945
946    pub(crate) fn events_or_noop(&self) -> &'a dyn EventSink {
947        self.events.unwrap_or(&NOOP_EVENT_SINK)
948    }
949
950    pub(crate) fn turn_events_or_noop(&self) -> &'a dyn TurnActivitySink {
951        self.turn_events.unwrap_or(&NOOP_TURN_ACTIVITY_SINK)
952    }
953
954    pub(crate) fn execution_scope_id(&self) -> &str {
955        self.scoped_effect_controller.scope_id()
956    }
957
958    pub(crate) fn scoped_effect_controller(&self) -> ScopedEffectController<'a> {
959        self.scoped_effect_controller.clone()
960    }
961}
962
963enum RuntimeStreamEvent {
964    Session(SessionEvent),
965    Turn(TurnActivity),
966}
967
968#[derive(Clone)]
969pub struct SessionStoreCreateRequest {
970    pub session_id: String,
971    pub relation: SessionRelation,
972    pub policy: SessionPolicy,
973}
974
975impl SessionStoreCreateRequest {
976    pub fn parent_session_id(&self) -> Option<&str> {
977        self.relation.parent_session_id()
978    }
979}
980
981#[async_trait::async_trait]
982pub trait SessionStoreFactory: Send + Sync {
983    /// Durability tier the stores produced by this factory provide; defaults to
984    /// [`DurabilityTier::Inline`].
985    fn durability_tier(&self) -> crate::DurabilityTier {
986        crate::DurabilityTier::Inline
987    }
988
989    async fn create_store(
990        &self,
991        request: &SessionStoreCreateRequest,
992    ) -> Result<Arc<dyn crate::store::RuntimePersistence>, String>;
993
994    async fn open_existing_store(
995        &self,
996        _request: &SessionStoreCreateRequest,
997    ) -> Result<Option<Arc<dyn crate::store::RuntimePersistence>>, String> {
998        Ok(None)
999    }
1000
1001    async fn delete_session(&self, session_id: &str) -> Result<(), String>;
1002
1003    /// The attachment GC root set across ALL sessions this factory owns,
1004    /// reconciled against `intent_grace_cutoff_epoch_ms`: every committed ref,
1005    /// plus every uncommitted intent younger than the cutoff. Intents at or
1006    /// before the cutoff are crash orphans (their turn never committed and has
1007    /// aged past the grace window) — the factory forgets them and excludes them,
1008    /// so their blobs become collectable. Factories with no attachment story
1009    /// default to empty; the durable factories override this (Postgres queries
1010    /// and prunes the global manifest table; SQLite unions and reconciles its
1011    /// per-session databases at sweep time). Exposed to the GC lever via the
1012    /// blanket [`AttachmentRootSet`](crate::AttachmentRootSet) implementation.
1013    async fn live_attachment_refs(
1014        &self,
1015        intent_grace_cutoff_epoch_ms: u64,
1016    ) -> Result<std::collections::BTreeSet<crate::AttachmentId>, crate::store::StoreError> {
1017        let _ = intent_grace_cutoff_epoch_ms;
1018        Ok(std::collections::BTreeSet::new())
1019    }
1020
1021    /// Whether ANY session this factory owns currently holds a GC-live ref for
1022    /// `attachment_id` (a committed ref, or an uncommitted intent younger than
1023    /// the cutoff). The single-id counterpart to
1024    /// [`Self::live_attachment_refs`], used by the attachment GC lever's
1025    /// delete-time root re-check so it need not re-materialize the whole root set
1026    /// per candidate blob. The default re-materializes the root set and tests
1027    /// membership; the durable factories override with a targeted single-id query
1028    /// (Postgres one indexed `SELECT`; SQLite iterates its per-session databases
1029    /// only until the first hit).
1030    ///
1031    /// Unlike [`Self::live_attachment_refs`], this MUST NOT forget aged intents —
1032    /// it is a read-only probe run after the reconciling snapshot was already
1033    /// taken.
1034    async fn has_live_attachment_ref(
1035        &self,
1036        attachment_id: &crate::AttachmentId,
1037        intent_grace_cutoff_epoch_ms: u64,
1038    ) -> Result<bool, crate::store::StoreError> {
1039        Ok(self
1040            .live_attachment_refs(intent_grace_cutoff_epoch_ms)
1041            .await?
1042            .contains(attachment_id))
1043    }
1044}
1045
1046/// Generic runtime for CLI or programmatic embedding.
1047pub struct LashRuntime {
1048    pub(in crate::runtime) session: Option<Session>,
1049    pub(in crate::runtime) policy: SessionPolicy,
1050    pub(in crate::runtime) host: RuntimeHost,
1051    pub(in crate::runtime) services: RuntimeServices,
1052    pub(in crate::runtime) state: RuntimeSessionState,
1053    pub(in crate::runtime) runtime_scope_id: Arc<str>,
1054    pub(in crate::runtime) runtime_lease_owner: crate::LeaseOwnerIdentity,
1055    pub(in crate::runtime) managed_sessions: Arc<Mutex<HashMap<String, RuntimeHandle>>>,
1056    pub(in crate::runtime) managed_turns: Arc<Mutex<HashMap<String, ManagedSessionTurn>>>,
1057    /// Protocol-owned turn options for this session.
1058    pub(in crate::runtime) protocol_turn_options: crate::ProtocolTurnOptions,
1059    /// Session-scoped token cost ledger. Shared by ALL
1060    /// `RuntimeSessionServices` instances created from this runtime
1061    /// (both per-turn and async maintenance). Entries accumulate here
1062    /// and are drained into `state.token_ledger` at turn-commit time.
1063    pub(in crate::runtime) shared_token_ledger: Arc<std::sync::Mutex<Vec<TokenLedgerEntry>>>,
1064    pub(in crate::runtime) process_sync_needed: Arc<AtomicBool>,
1065    pub(in crate::runtime) turn_phase_probe: Option<Arc<dyn RuntimeTurnPhaseProbe>>,
1066    /// Resident-graph policy chosen by the host. Controls whether
1067    /// [`LashRuntime::refresh_session_graph_from_store`] reloads the full
1068    /// graph or just the active path, matching the trimming behavior set at
1069    /// load time via [`apply_residency_on_load`](crate::runtime::apply_residency_on_load).
1070    pub(in crate::runtime) residency: Residency,
1071}