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