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