Skip to main content

lash_core/runtime/
mod.rs

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