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::{
167    QueuedWorkDriver, QueuedWorkRunError, QueuedWorkRunErrorClass, QueuedWorkRunHandle,
168    QueuedWorkRunRequest, QueuedWorkWakeDisposition, QueuedWorkWakeFailure,
169};
170pub use scenario_contracts::{RUNTIME_SCENARIO_CONTRACTS, ScenarioContractSpec};
171pub use session_manager::DirectCompletionClient;
172pub use state::RuntimeSessionState;
173use state::{
174    append_session_nodes_to_state_with_clock, apply_residency_on_load, apply_session_checkpoint,
175    apply_session_head, normalize_session_graph, open_agent_frame_in_state_with_clock,
176};
177pub use turn_control::{
178    TurnAddress, TurnAttach, TurnCancelOriginHint, TurnCancelOutcome, TurnCancelReceipt,
179    TurnCancelRequest, TurnCancellationEvidence, TurnTerminal, TurnWorkDriver,
180};
181pub use turn_input_ingress::{
182    PendingTurnInput, PendingTurnInputCancelOutcome, PendingTurnInputCancelResult,
183    PendingTurnInputCancelTarget, PendingTurnInputClaimDiagnostics, PendingTurnInputDraft,
184    PendingTurnInputSuffixCancelOutcome, QueuedCheckpointTurnInput, TurnInputAcceptanceReceipt,
185    TurnInputApplication, TurnInputCheckpointBoundary, TurnInputClaim, TurnInputClaimMode,
186    TurnInputCompletion, TurnInputIngress, TurnInputState,
187};
188pub use turn_loop::ensure_durable_effect_input;
189pub use turn_queue::{
190    DeliveryPolicy, MergeKey, QueuedCheckpointWork, QueuedTurnWork, QueuedWorkBatch,
191    QueuedWorkBatchDraft, QueuedWorkClaim, QueuedWorkClaimBoundary, QueuedWorkClass,
192    QueuedWorkCompletion, QueuedWorkItem, QueuedWorkPayload, SessionCommand, SessionCommandReceipt,
193    SlotPolicy, process_wake_batch_draft,
194};
195pub use usage::{
196    SessionUsageReport, TokenLedgerEntry, UsageReportRow, UsageTotals, diff_token_ledger,
197    diff_usage_reports,
198};
199use usage::{merge_ledger_entry, merge_usage_delta_entries, normalize_prompt_usage};
200
201#[doc(hidden)]
202#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
203pub enum RuntimeTurnPhase {
204    ContextTransform,
205    BeforeTurnHooks,
206    PromptBuild,
207    EffectLoop,
208    FinalizeTurn,
209    PersistTurn,
210    FinalCommit,
211    PostPersistHooks,
212}
213
214#[doc(hidden)]
215pub trait RuntimeTurnPhaseProbe: Send + Sync {
216    fn begin(&self, phase: RuntimeTurnPhase);
217    fn end(&self, phase: RuntimeTurnPhase);
218    fn begin_named(&self, _phase: &str) {}
219    fn end_named(&self, _phase: &str) {}
220}
221
222#[doc(hidden)]
223#[derive(Clone, Default)]
224pub struct RuntimeTurnPhaseProbeSlot {
225    probes: Arc<StdMutex<HashMap<crate::SessionScopeId, Arc<dyn RuntimeTurnPhaseProbe>>>>,
226}
227
228impl RuntimeTurnPhaseProbeSlot {
229    pub fn set_for_session(
230        &self,
231        session_id: impl Into<String>,
232        probe: Arc<dyn RuntimeTurnPhaseProbe>,
233    ) {
234        self.set_for_scope(&crate::SessionScope::new(session_id), probe);
235    }
236
237    pub fn set_for_scope(
238        &self,
239        scope: &crate::SessionScope,
240        probe: Arc<dyn RuntimeTurnPhaseProbe>,
241    ) {
242        self.probes
243            .lock()
244            .expect("runtime phase probe slot")
245            .insert(scope.id(), probe);
246    }
247
248    pub fn get_for_scope(
249        &self,
250        scope: &crate::SessionScope,
251    ) -> Option<Arc<dyn RuntimeTurnPhaseProbe>> {
252        let probes = self.probes.lock().expect("runtime phase probe slot");
253        probes.get(&scope.id()).cloned().or_else(|| {
254            probes
255                .get(&crate::SessionScope::new(&scope.session_id).id())
256                .cloned()
257        })
258    }
259}
260
261#[doc(hidden)]
262pub struct RuntimeNamedPhase {
263    probe: Option<Arc<dyn RuntimeTurnPhaseProbe>>,
264    phase: &'static str,
265}
266
267impl RuntimeNamedPhase {
268    pub fn begin(
269        probe: Option<Arc<dyn RuntimeTurnPhaseProbe>>,
270        phase: &'static str,
271    ) -> RuntimeNamedPhase {
272        if let Some(probe) = probe.as_ref() {
273            probe.begin_named(phase);
274        }
275        RuntimeNamedPhase { probe, phase }
276    }
277}
278
279impl Drop for RuntimeNamedPhase {
280    fn drop(&mut self) {
281        if let Some(probe) = self.probe.as_ref() {
282            probe.end_named(self.phase);
283        }
284    }
285}
286
287/// Host-provided per-turn input.
288#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
289#[serde(tag = "type", rename_all = "snake_case")]
290pub enum InputItem {
291    Text { text: String },
292    Attachment { source: crate::AttachmentSource },
293}
294
295impl InputItem {
296    pub fn text(text: impl Into<String>) -> Self {
297        Self::Text { text: text.into() }
298    }
299
300    pub fn attachment(source: crate::AttachmentSource) -> Self {
301        Self::Attachment { source }
302    }
303}
304
305/// Host-provided per-turn input.
306#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
307pub struct TurnInput {
308    pub items: Vec<InputItem>,
309    /// Per-turn override for protocol-owned turn options.
310    #[serde(default, skip_serializing_if = "Option::is_none")]
311    pub protocol_turn_options: Option<crate::ProtocolTurnOptions>,
312    /// Optional externally-stable trace turn id. Normal runtime callers leave
313    /// this empty and the runtime generates one per outer turn.
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub trace_turn_id: Option<String>,
316    #[serde(skip)]
317    pub protocol_extension: Option<ProtocolTurnExtensionHandle>,
318    #[serde(skip)]
319    pub turn_context: TurnContext,
320}
321
322impl TurnInput {
323    pub fn empty() -> Self {
324        Self::items(std::iter::empty())
325    }
326
327    pub fn text(text: impl Into<String>) -> Self {
328        Self::items([InputItem::text(text)])
329    }
330
331    pub fn items(items: impl IntoIterator<Item = InputItem>) -> Self {
332        Self {
333            items: items.into_iter().collect(),
334            protocol_turn_options: None,
335            trace_turn_id: None,
336            protocol_extension: None,
337            turn_context: TurnContext::default(),
338        }
339    }
340
341    pub fn with_attachment(mut self, source: crate::AttachmentSource) -> Self {
342        self.items.push(InputItem::attachment(source));
343        self
344    }
345
346    pub fn with_protocol_turn_options(mut self, options: crate::ProtocolTurnOptions) -> Self {
347        self.protocol_turn_options = Some(options);
348        self
349    }
350
351    pub fn with_trace_turn_id(mut self, trace_turn_id: impl Into<String>) -> Self {
352        self.trace_turn_id = Some(trace_turn_id.into());
353        self
354    }
355}
356
357/// Per-turn, in-process side channel of typed plugin inputs.
358///
359/// This is an `Any`-keyed map of live Rust values handed to plugins for a
360/// single turn. It is deliberately **not** serializable: the values never
361/// survive a process boundary, so durable effect-host runs explicitly reject a
362/// turn that carries any live inputs (see
363/// [`LiveTurnInputs::durable_effect_rejection`]). Durable callers must instead
364/// encode replayable data in
365/// `protocol_turn_options` or persisted plugin state.
366#[derive(Clone, Default)]
367pub struct LiveTurnInputs {
368    inputs: HashMap<&'static str, Arc<dyn Any + Send + Sync>>,
369}
370
371impl LiveTurnInputs {
372    fn insert<T>(&mut self, plugin_id: &'static str, input: T)
373    where
374        T: Send + Sync + 'static,
375    {
376        self.inputs.insert(plugin_id, Arc::new(input));
377    }
378
379    fn get<T>(&self, plugin_id: &'static str) -> Option<&T>
380    where
381        T: 'static,
382    {
383        self.inputs
384            .get(plugin_id)
385            .and_then(|input| input.downcast_ref::<T>())
386    }
387
388    fn contains(&self, plugin_id: &'static str) -> bool {
389        self.inputs.contains_key(plugin_id)
390    }
391
392    pub fn plugin_ids(&self) -> Vec<&'static str> {
393        self.inputs.keys().copied().collect()
394    }
395
396    /// Returns an error when live per-turn inputs would make a durable effect
397    /// host replay depend on process-local values.
398    pub(crate) fn durable_effect_rejection(&self) -> Result<(), RuntimeError> {
399        if self.inputs.is_empty() {
400            return Ok(());
401        }
402        Err(RuntimeError::new(
403            RuntimeErrorCode::DurableEffectLivePluginInput,
404            "durable effect hosts do not support live TurnContext plugin inputs; encode replayable data in protocol_turn_options or persisted plugin state",
405        ))
406    }
407}
408
409#[derive(Clone, Default)]
410pub struct TurnContext {
411    plugin_inputs: LiveTurnInputs,
412    provider: Option<crate::ProviderHandle>,
413    prompt: crate::PromptLayer,
414    local_cancel_origin: TurnCancelOriginHint,
415}
416
417impl TurnContext {
418    pub fn new() -> Self {
419        Self::default()
420    }
421
422    pub fn insert_plugin_input<T>(&mut self, plugin_id: &'static str, input: T)
423    where
424        T: Send + Sync + 'static,
425    {
426        self.plugin_inputs.insert(plugin_id, input);
427    }
428
429    pub fn set_provider(&mut self, provider: crate::ProviderHandle) {
430        self.provider = Some(provider);
431    }
432
433    pub fn provider(&self) -> Option<&crate::ProviderHandle> {
434        self.provider.as_ref()
435    }
436
437    #[doc(hidden)]
438    pub fn set_local_cancel_origin_hint(&mut self, hint: TurnCancelOriginHint) {
439        self.local_cancel_origin = hint;
440    }
441
442    pub(crate) fn local_cancel_origin_hint(&self) -> TurnCancelOriginHint {
443        self.local_cancel_origin.clone()
444    }
445
446    pub fn plugin_input<T>(&self, plugin_id: &'static str) -> Option<&T>
447    where
448        T: 'static,
449    {
450        self.plugin_inputs.get(plugin_id)
451    }
452
453    pub fn has_plugin_input(&self, plugin_id: &'static str) -> bool {
454        self.plugin_inputs.contains(plugin_id)
455    }
456
457    pub fn has_live_plugin_inputs(&self) -> bool {
458        !self.plugin_inputs.inputs.is_empty()
459    }
460
461    pub fn live_plugin_input_ids(&self) -> Vec<&'static str> {
462        self.plugin_inputs.plugin_ids()
463    }
464
465    /// Live plugin inputs for this turn. The durable boundary inspects this to
466    /// reject turns carrying non-serializable live state.
467    pub(crate) fn live_plugin_inputs(&self) -> &LiveTurnInputs {
468        &self.plugin_inputs
469    }
470
471    pub fn set_prompt_template(&mut self, template: crate::PromptTemplate) {
472        self.prompt.template = Some(template);
473    }
474
475    pub fn add_prompt_contribution(&mut self, contribution: crate::PromptContribution) {
476        self.prompt.add_contribution(contribution);
477    }
478
479    pub fn replace_prompt_slot(
480        &mut self,
481        slot: crate::PromptSlot,
482        contributions: impl IntoIterator<Item = crate::PromptContribution>,
483    ) {
484        self.prompt.replace_slot(slot, contributions);
485    }
486
487    pub fn clear_prompt_slot(&mut self, slot: crate::PromptSlot) {
488        self.prompt.clear_slot(slot);
489    }
490
491    pub fn set_prompt_layer(&mut self, prompt: crate::PromptLayer) {
492        self.prompt = prompt;
493    }
494
495    pub fn prompt_layer(&self) -> &crate::PromptLayer {
496        &self.prompt
497    }
498}
499
500impl fmt::Debug for TurnContext {
501    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
502        f.debug_struct("TurnContext")
503            .field("plugin_inputs", &self.plugin_inputs.plugin_ids())
504            .field("has_provider", &self.provider.is_some())
505            .field("has_prompt_layer", &(!self.prompt.is_empty()))
506            .finish()
507    }
508}
509
510#[derive(Clone)]
511pub struct ProtocolTurnExtensionHandle(Arc<dyn ProtocolTurnExtension>);
512
513impl ProtocolTurnExtensionHandle {
514    pub fn new(extension: impl ProtocolTurnExtension + 'static) -> Self {
515        Self(Arc::new(extension))
516    }
517
518    pub fn as_any(&self) -> &dyn Any {
519        self.0.as_any()
520    }
521
522    pub fn prompt_contributions(&self) -> Vec<crate::PromptContribution> {
523        self.0.prompt_contributions()
524    }
525}
526
527impl fmt::Debug for ProtocolTurnExtensionHandle {
528    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
529        f.write_str("ProtocolTurnExtensionHandle(..)")
530    }
531}
532
533pub trait ProtocolTurnExtension: Send + Sync {
534    fn as_any(&self) -> &dyn Any;
535
536    fn prompt_contributions(&self) -> Vec<crate::PromptContribution> {
537        Vec::new()
538    }
539}
540
541#[derive(Clone)]
542pub struct ProtocolSessionExtensionHandle(Arc<dyn ProtocolSessionExtension>);
543
544impl ProtocolSessionExtensionHandle {
545    pub fn new(extension: impl ProtocolSessionExtension + 'static) -> Self {
546        Self(Arc::new(extension))
547    }
548
549    pub fn as_any(&self) -> &dyn Any {
550        self.0.as_any()
551    }
552}
553
554impl fmt::Debug for ProtocolSessionExtensionHandle {
555    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
556        f.write_str("ProtocolSessionExtensionHandle(..)")
557    }
558}
559
560pub trait ProtocolSessionExtension: Send + Sync {
561    fn as_any(&self) -> &dyn Any;
562}
563
564#[derive(Clone, Debug)]
565pub(super) enum NormalizedItem {
566    Text(String),
567    Attachment(crate::AttachmentSource),
568}
569
570/// Canonical assistant output payload.
571#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
572pub struct AssistantOutput {
573    pub safe_text: String,
574    pub raw_text: String,
575    pub state: OutputState,
576}
577
578/// Quality and usability of assembled terminal output.
579#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
580#[serde(rename_all = "snake_case")]
581pub enum OutputState {
582    Usable,
583    EmptyOutput,
584    TracebackOnly,
585    RecoveredFromError,
586}
587
588/// Code execution output observed during a turn.
589#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
590pub struct CodeOutputRecord {
591    pub output: String,
592    #[serde(default, skip_serializing_if = "Option::is_none")]
593    pub error: Option<String>,
594}
595
596/// High-level execution summary for a completed turn.
597#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
598pub struct ExecutionSummary {
599    #[serde(default)]
600    pub had_tool_calls: bool,
601    #[serde(default)]
602    pub had_code_execution: bool,
603    /// Wall-clock turn start as epoch milliseconds, read from the runtime
604    /// [`Clock`]. The measurement window opens when the runtime starts
605    /// claiming the turn (session-execution lease / queued-work claim), so
606    /// it covers the whole host-visible turn. `0` when the turn predates
607    /// this field.
608    #[serde(default)]
609    pub started_at_ms: u64,
610    /// Whole-turn duration in milliseconds — claim through final commit and
611    /// post-persist hooks — measured on the runtime [`Clock`]'s monotonic
612    /// source. `0` when the turn predates this field.
613    #[serde(default)]
614    pub duration_ms: u64,
615}
616
617/// Structured issue surfaced during turn execution.
618#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
619pub struct TurnIssue {
620    pub kind: String,
621    #[serde(default, skip_serializing_if = "Option::is_none")]
622    pub code: Option<String>,
623    #[serde(default, skip_serializing_if = "Option::is_none")]
624    pub terminal_reason: Option<crate::LlmTerminalReason>,
625    pub message: String,
626    #[serde(default, skip_serializing_if = "Option::is_none")]
627    pub raw: Option<String>,
628    /// Whether the failing operation is safe to retry, when the source
629    /// carried a typed signal (provider transports classify retryability;
630    /// terminal LLM responses are deterministic and report `Some(false)`).
631    /// `None` means the source did not know.
632    #[serde(default, skip_serializing_if = "Option::is_none")]
633    pub retryable: Option<bool>,
634    /// Typed provider-failure classification, present only when the issue
635    /// came from a classified LLM provider/transport failure.
636    #[serde(default, skip_serializing_if = "Option::is_none")]
637    pub provider_failure_kind: Option<crate::ProviderFailureKind>,
638}
639
640/// Canonical high-level turn result returned to hosts.
641#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
642pub struct AssembledTurn {
643    pub state: SessionSnapshot,
644    pub outcome: crate::TurnOutcome,
645    /// Durable request evidence, present exactly when `outcome` is cancelled.
646    #[serde(default, skip_serializing_if = "Option::is_none")]
647    pub cancellation: Option<TurnCancellationEvidence>,
648    pub assistant_output: AssistantOutput,
649    pub execution: ExecutionSummary,
650    #[serde(default)]
651    pub token_usage: TokenUsage,
652    /// Per-(session, source, model) ledger entries for child sessions whose
653    /// LLM calls completed during this turn. `token_usage` above is the
654    /// parent's own LLM tokens; `total_usage` (on the embed-facing
655    /// `TurnResult`) sums both.
656    #[serde(default)]
657    pub children_usage: Vec<TokenLedgerEntry>,
658    /// Provider calls made by this session during the turn, in protocol order.
659    /// Child-session calls remain on the child turn result.
660    #[serde(default)]
661    pub llm_calls: Vec<crate::LlmCallRecord>,
662    #[serde(default)]
663    pub tool_calls: Vec<ToolCallRecord>,
664    #[serde(default)]
665    pub errors: Vec<TurnIssue>,
666}
667
668/// Result of driving one logical host turn through any AgentFrame switches.
669///
670/// A frame switch is an internal runtime continuation, similar to compaction
671/// from a host's perspective. Callers that need a final answer can use
672/// [`LashRuntime::stream_turn_with_agent_frames`] and inspect `final_turn()`.
673#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
674pub struct AgentFrameRun {
675    pub turns: Vec<AssembledTurn>,
676}
677
678impl AgentFrameRun {
679    pub fn final_turn(&self) -> Option<&AssembledTurn> {
680        self.turns.last()
681    }
682
683    pub fn into_final_turn(mut self) -> Option<AssembledTurn> {
684        self.turns.pop()
685    }
686
687    pub fn frame_switch_count(&self) -> usize {
688        self.turns
689            .iter()
690            .filter(|turn| matches!(turn.outcome, crate::TurnOutcome::AgentFrameSwitch { .. }))
691            .count()
692    }
693}
694
695/// Termination policy knobs.
696#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
697pub struct TerminationPolicy {
698    #[serde(default)]
699    pub treat_missing_done_as_failure: bool,
700}
701
702impl Default for TerminationPolicy {
703    fn default() -> Self {
704        Self {
705            treat_missing_done_as_failure: true,
706        }
707    }
708}
709
710/// Host application sink for low-level streaming runtime events.
711/// `SessionStreamEvent` is protocol-specific preview/progress data.
712#[async_trait::async_trait]
713pub trait EventSink: Send + Sync {
714    fn is_noop(&self) -> bool {
715        false
716    }
717
718    async fn emit(&self, event: SessionStreamEvent);
719}
720
721/// No-op sink useful for callers that only care about final state.
722pub struct NoopEventSink;
723
724/// Static no-op event sink for callers that need a `&dyn EventSink` default.
725pub static NOOP_EVENT_SINK: NoopEventSink = NoopEventSink;
726
727#[async_trait::async_trait]
728impl EventSink for NoopEventSink {
729    fn is_noop(&self) -> bool {
730        true
731    }
732
733    async fn emit(&self, _event: SessionStreamEvent) {}
734}
735
736/// Stable identifier for a semantic turn activity.
737#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
738#[serde(transparent)]
739pub struct TurnActivityId(pub Arc<str>);
740
741impl TurnActivityId {
742    pub fn new(id: impl Into<Arc<str>>) -> Self {
743        Self(id.into())
744    }
745
746    pub fn fresh() -> Self {
747        Self(Arc::from(uuid::Uuid::new_v4().to_string()))
748    }
749}
750
751/// App-facing semantic activity emitted during a turn.
752///
753/// `id` is unique per emitted activity event. `correlation_id` groups related
754/// events in the same logical activity, such as code start/completion, tool
755/// start/completion, or text deltas from one output block.
756#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
757pub struct TurnActivity {
758    pub id: TurnActivityId,
759    pub correlation_id: TurnActivityId,
760    #[serde(flatten)]
761    pub event: TurnEvent,
762}
763
764impl TurnActivity {
765    pub fn new(correlation_id: TurnActivityId, event: TurnEvent) -> Self {
766        Self {
767            id: TurnActivityId::fresh(),
768            correlation_id,
769            event,
770        }
771    }
772
773    pub fn independent(event: TurnEvent) -> Self {
774        let correlation_id = TurnActivityId::fresh();
775        Self::new(correlation_id, event)
776    }
777}
778
779/// App-facing semantic event payload for a turn activity.
780///
781/// Unlike [`SessionStreamEvent`], these events are stable application signals rather
782/// than low-level runtime/debug events. Public streams carry these payloads
783/// inside [`TurnActivity`] so every emitted item has identity.
784#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
785#[serde(tag = "type", rename_all = "snake_case")]
786// justification: public turn events are transient stream DTOs kept inline for allocation-free emission and stable pattern matching.
787#[allow(clippy::large_enum_variant)]
788pub enum TurnEvent {
789    QueuedWorkStarted {
790        boundary: crate::QueuedWorkClaimBoundary,
791        batch_ids: Vec<String>,
792        causes: Vec<crate::TurnCause>,
793    },
794    ModelRequestStarted {
795        protocol_iteration: usize,
796    },
797    AssistantProseDelta {
798        text: Arc<str>,
799    },
800    ReasoningDelta {
801        text: Arc<str>,
802    },
803    /// Retracts visible text emitted by a provider attempt that will be retried.
804    ///
805    /// Observers remove only prose and reasoning deltas whose correlation ids
806    /// appear here. The reset is itself replayed in order, so reconnecting
807    /// observers converge on the same visible text as live observers.
808    ModelAttemptReset {
809        assistant_prose_correlation_ids: Vec<TurnActivityId>,
810        reasoning_correlation_ids: Vec<TurnActivityId>,
811    },
812    CodeBlockStarted {
813        language: String,
814        code: String,
815        #[serde(default, skip_serializing_if = "Option::is_none")]
816        graph_key: Option<String>,
817    },
818    CodeBlockCompleted {
819        language: String,
820        output: String,
821        #[serde(default, skip_serializing_if = "Option::is_none")]
822        error: Option<String>,
823        success: bool,
824        duration_ms: u64,
825        tool_call_ids: Vec<String>,
826        #[serde(default, skip_serializing_if = "Option::is_none")]
827        graph_key: Option<String>,
828    },
829    ToolCallStarted {
830        #[serde(default, skip_serializing_if = "Option::is_none")]
831        call_id: Option<String>,
832        name: String,
833        args: serde_json::Value,
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    ToolCallCompleted {
844        #[serde(default, skip_serializing_if = "Option::is_none")]
845        call_id: Option<String>,
846        name: String,
847        args: serde_json::Value,
848        output: crate::ToolCallOutput,
849        duration_ms: u64,
850        /// Graph key of the enclosing code block, when this tool call ran
851        /// inside one. `None` when the call did not run inside a code block.
852        #[serde(default, skip_serializing_if = "Option::is_none")]
853        graph_key: Option<String>,
854        /// Call id of the parent batch tool call, when this call is a child of
855        /// a `batch` dispatch. `None` for top-level tool calls.
856        #[serde(default, skip_serializing_if = "Option::is_none")]
857        parent_call_id: Option<String>,
858    },
859    FinalValue {
860        value: serde_json::Value,
861    },
862    ToolValue {
863        tool_name: String,
864        value: serde_json::Value,
865    },
866    Usage {
867        protocol_iteration: usize,
868        usage: TokenUsage,
869        cumulative: TokenUsage,
870    },
871    ChildUsage {
872        session_id: String,
873        source: String,
874        model: String,
875        protocol_iteration: usize,
876        usage: TokenUsage,
877        cumulative: TokenUsage,
878    },
879    RetryStatus {
880        wait_seconds: u64,
881        attempt: usize,
882        max_attempts: usize,
883        reason: String,
884    },
885    PluginRuntime {
886        plugin_id: String,
887        event: crate::PluginRuntimeEvent,
888    },
889    QueuedInputAccepted {
890        applications: Vec<crate::TurnInputApplication>,
891    },
892    QueuedMessagesCommitted {
893        messages: Vec<crate::PluginMessage>,
894        checkpoint: crate::CheckpointKind,
895    },
896    Error {
897        message: String,
898    },
899}
900
901#[async_trait::async_trait]
902pub trait TurnActivitySink: Send + Sync {
903    fn is_noop(&self) -> bool {
904        false
905    }
906
907    async fn emit(&self, activity: TurnActivity);
908}
909
910pub struct NoopTurnActivitySink;
911
912/// Static no-op turn-activity sink for callers that need a `&dyn TurnActivitySink` default.
913pub static NOOP_TURN_ACTIVITY_SINK: NoopTurnActivitySink = NoopTurnActivitySink;
914
915#[async_trait::async_trait]
916impl TurnActivitySink for NoopTurnActivitySink {
917    fn is_noop(&self) -> bool {
918        true
919    }
920
921    async fn emit(&self, _activity: TurnActivity) {}
922}
923
924/// Optional sinks and scoped effect controller passed to one of [`LashRuntime`]'s
925/// turn-driving entry points (`stream_turn`,
926/// `stream_turn_with_agent_frames`).
927///
928/// Construct via [`TurnOptions::new`] and chain `with_*` builders. Event sinks
929/// default to no-op sinks. Execution scope is explicit and required at every
930/// runtime boundary that can execute nondeterministic work.
931pub struct TurnOptions<'a> {
932    events: Option<&'a dyn EventSink>,
933    turn_events: Option<&'a dyn TurnActivitySink>,
934    scoped_effect_controller: ScopedEffectController<'a>,
935    cancel: CancellationToken,
936    local_cancel_origin: Option<TurnCancelOriginHint>,
937}
938
939impl<'a> TurnOptions<'a> {
940    pub fn new(
941        cancel: CancellationToken,
942        scoped_effect_controller: ScopedEffectController<'a>,
943    ) -> Self {
944        Self {
945            events: None,
946            turn_events: None,
947            scoped_effect_controller,
948            cancel,
949            local_cancel_origin: None,
950        }
951    }
952
953    pub fn with_events(mut self, events: &'a dyn EventSink) -> Self {
954        self.events = Some(events);
955        self
956    }
957
958    pub fn with_turn_events(mut self, turn_events: &'a dyn TurnActivitySink) -> Self {
959        self.turn_events = Some(turn_events);
960        self
961    }
962
963    #[doc(hidden)]
964    pub fn with_local_cancel_origin_hint(mut self, hint: TurnCancelOriginHint) -> Self {
965        self.local_cancel_origin = Some(hint);
966        self
967    }
968
969    pub(crate) fn local_cancel_origin_hint(&self) -> Option<TurnCancelOriginHint> {
970        self.local_cancel_origin.clone()
971    }
972
973    pub(crate) fn events_or_noop(&self) -> &'a dyn EventSink {
974        self.events.unwrap_or(&NOOP_EVENT_SINK)
975    }
976
977    pub(crate) fn turn_events_or_noop(&self) -> &'a dyn TurnActivitySink {
978        self.turn_events.unwrap_or(&NOOP_TURN_ACTIVITY_SINK)
979    }
980
981    pub(crate) fn execution_scope_id(&self) -> &str {
982        self.scoped_effect_controller.scope_id()
983    }
984
985    pub(crate) fn scoped_effect_controller(&self) -> ScopedEffectController<'a> {
986        self.scoped_effect_controller.clone()
987    }
988}
989
990enum RuntimeStreamEvent {
991    Session(SessionStreamEvent),
992    Turn(TurnActivity),
993}
994
995#[derive(Clone)]
996pub struct SessionStoreCreateRequest {
997    pub session_id: String,
998    pub relation: SessionRelation,
999    pub policy: SessionPolicy,
1000}
1001
1002impl SessionStoreCreateRequest {
1003    pub fn parent_session_id(&self) -> Option<&str> {
1004        self.relation.parent_session_id()
1005    }
1006}
1007
1008#[async_trait::async_trait]
1009pub trait SessionStoreFactory: Send + Sync {
1010    /// Durability tier the stores produced by this factory provide; defaults to
1011    /// [`DurabilityTier::Inline`].
1012    fn durability_tier(&self) -> crate::DurabilityTier {
1013        crate::DurabilityTier::Inline
1014    }
1015
1016    async fn create_store(
1017        &self,
1018        request: &SessionStoreCreateRequest,
1019    ) -> Result<Arc<dyn crate::store::RuntimePersistence>, String>;
1020
1021    async fn open_existing_store(
1022        &self,
1023        _request: &SessionStoreCreateRequest,
1024    ) -> Result<Option<Arc<dyn crate::store::RuntimePersistence>>, String> {
1025        Ok(None)
1026    }
1027
1028    async fn delete_session(&self, session_id: &str) -> Result<(), String>;
1029
1030    /// The attachment GC root set across every session this factory owns. An
1031    /// uncommitted intent is forgotten only when it is old enough and its
1032    /// durable owner is dead: a superseding session turn commit or an absent
1033    /// process row. Ownerless host puts use age alone. Factories with no
1034    /// attachment story default to empty; durable factories evaluate and prune
1035    /// the predicate atomically. Exposed to the GC lever via the blanket
1036    /// [`AttachmentRootSet`](crate::AttachmentRootSet) implementation.
1037    async fn live_attachment_refs(
1038        &self,
1039        intent_grace_cutoff_epoch_ms: u64,
1040    ) -> Result<std::collections::BTreeSet<crate::AttachmentId>, crate::store::StoreError> {
1041        let _ = intent_grace_cutoff_epoch_ms;
1042        Ok(std::collections::BTreeSet::new())
1043    }
1044
1045    /// Whether ANY session this factory owns currently holds a GC-live ref for
1046    /// `attachment_id` under that same age plus owner-reachability rule. The
1047    /// single-id counterpart to
1048    /// [`Self::live_attachment_refs`], used by the attachment GC lever's
1049    /// delete-time root re-check so it need not re-materialize the whole root set
1050    /// per candidate blob. The default re-materializes the root set and tests
1051    /// membership; the durable factories override with a targeted single-id query
1052    /// (Postgres one indexed `SELECT`; SQLite iterates its per-session databases
1053    /// only until the first hit).
1054    ///
1055    /// Unlike [`Self::live_attachment_refs`], this MUST NOT forget aged intents —
1056    /// it is a read-only probe run after the reconciling snapshot was already
1057    /// taken.
1058    async fn has_live_attachment_ref(
1059        &self,
1060        attachment_id: &crate::AttachmentId,
1061        intent_grace_cutoff_epoch_ms: u64,
1062    ) -> Result<bool, crate::store::StoreError> {
1063        Ok(self
1064            .live_attachment_refs(intent_grace_cutoff_epoch_ms)
1065            .await?
1066            .contains(attachment_id))
1067    }
1068}
1069
1070/// Generic runtime for CLI or programmatic embedding.
1071pub struct LashRuntime {
1072    pub(in crate::runtime) session: Option<Session>,
1073    pub(in crate::runtime) policy: SessionPolicy,
1074    pub(in crate::runtime) host: RuntimeHost,
1075    pub(in crate::runtime) services: RuntimeServices,
1076    pub(in crate::runtime) state: RuntimeSessionState,
1077    pub(in crate::runtime) runtime_scope_id: Arc<str>,
1078    pub(in crate::runtime) runtime_lease_owner: crate::LeaseOwnerIdentity,
1079    pub(in crate::runtime) managed_sessions: Arc<Mutex<HashMap<String, RuntimeHandle>>>,
1080    pub(in crate::runtime) managed_turns: Arc<Mutex<HashMap<String, ManagedSessionTurn>>>,
1081    /// Protocol-owned turn options for this session.
1082    pub(in crate::runtime) protocol_turn_options: crate::ProtocolTurnOptions,
1083    /// Session-scoped token cost ledger. Shared by ALL
1084    /// `RuntimeSessionServices` instances created from this runtime
1085    /// (both per-turn and async maintenance). Entries accumulate here
1086    /// and are drained into `state.token_ledger` at turn-commit time.
1087    pub(in crate::runtime) shared_token_ledger: Arc<std::sync::Mutex<Vec<TokenLedgerEntry>>>,
1088    pub(in crate::runtime) process_sync_needed: Arc<AtomicBool>,
1089    pub(in crate::runtime) turn_phase_probe: Option<Arc<dyn RuntimeTurnPhaseProbe>>,
1090    /// Lease-guard identity retained across a successful physical-turn commit.
1091    /// A match proves no release/reacquisition boundary occurred before the
1092    /// next physical turn on this handle.
1093    pub(in crate::runtime) last_committed_lease_continuity:
1094        Option<session_execution_lease::SessionExecutionLeaseContinuity>,
1095    /// Set only after this handle itself has attempted a durable graph load.
1096    pub(in crate::runtime) graph_loaded_from_store: bool,
1097    /// Resident-graph policy chosen by the host. Controls whether
1098    /// [`LashRuntime::refresh_session_graph_from_store`] reloads the full
1099    /// graph or just the active path, matching the trimming behavior set at
1100    /// load time via [`apply_residency_on_load`](crate::runtime::apply_residency_on_load).
1101    pub(in crate::runtime) residency: Residency,
1102}