Skip to main content

lash_core/runtime/effect/
envelope.rs

1use std::sync::Arc;
2
3use serde::{Deserialize, Serialize};
4
5use crate::CheckpointKind;
6use crate::llm::types::{
7    AttachmentSource, LlmEventSender, LlmMessage, LlmOutputSpec, LlmProviderTraceSender,
8    LlmToolChoice, LlmToolSpec,
9};
10use crate::runtime::ProcessHandleGrantEntry;
11use crate::sansio::{CompletedToolCall, ExecutionEnvironmentSync, LlmCallError};
12use crate::tool_dispatch::ToolTriggerEffectOutcome;
13use crate::{
14    AttachmentCreateMeta, CausalRef, CheckpointDelivery, ExecResponse,
15    LlmRequest as CoreLlmRequest, LlmResponse, ProcessAwaitOutput, ProcessExecutionContext,
16    ProcessListMode, ProcessRecord, ProcessRegistration, ProcessStartGrant, SessionScope,
17};
18
19use super::executor::RuntimeEffectControllerError;
20
21/// Durable category for a runtime effect.
22#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum RuntimeEffectKind {
25    LlmCall,
26    Direct,
27    ToolAttempt,
28    ToolBatch,
29    Trigger,
30    Process,
31    ExecCode,
32    Checkpoint,
33    SyncExecutionEnvironment,
34    Sleep,
35    AwaitEvent,
36    PeekAwaitEvent,
37    DurableStep,
38}
39
40impl RuntimeEffectKind {
41    pub fn as_str(self) -> &'static str {
42        match self {
43            Self::LlmCall => "llm_call",
44            Self::Direct => "direct",
45            Self::ToolAttempt => "tool_attempt",
46            Self::ToolBatch => "tool_batch",
47            Self::Trigger => "trigger",
48            Self::Process => "process",
49            Self::ExecCode => "exec_code",
50            Self::Checkpoint => "checkpoint",
51            Self::SyncExecutionEnvironment => "sync_execution_environment",
52            Self::Sleep => "sleep",
53            Self::AwaitEvent => "await_event",
54            Self::PeekAwaitEvent => "peek_await_event",
55            Self::DurableStep => "durable_step",
56        }
57    }
58}
59
60/// Canonical lineage for a runtime-side invocation.
61#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
62pub struct RuntimeInvocation {
63    pub scope: RuntimeScope,
64    pub subject: RuntimeSubject,
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub caused_by: Option<CausalRef>,
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub replay: Option<RuntimeReplay>,
69}
70
71impl RuntimeInvocation {
72    pub fn effect(
73        scope: RuntimeScope,
74        effect_id: impl Into<String>,
75        kind: RuntimeEffectKind,
76        replay_key: impl Into<String>,
77    ) -> Self {
78        Self {
79            scope,
80            subject: RuntimeSubject::Effect {
81                effect_id: effect_id.into(),
82                kind,
83            },
84            caused_by: None,
85            replay: Some(RuntimeReplay {
86                key: replay_key.into(),
87            }),
88        }
89    }
90
91    pub fn with_caused_by(mut self, caused_by: Option<CausalRef>) -> Self {
92        self.caused_by = caused_by;
93        self
94    }
95
96    pub fn effect_id(&self) -> Option<&str> {
97        match &self.subject {
98            RuntimeSubject::Effect { effect_id, .. } => Some(effect_id),
99            _ => None,
100        }
101    }
102
103    pub fn effect_kind(&self) -> Option<RuntimeEffectKind> {
104        match &self.subject {
105            RuntimeSubject::Effect { kind, .. } => Some(*kind),
106            _ => None,
107        }
108    }
109
110    pub fn replay_key(&self) -> Option<&str> {
111        self.replay.as_ref().map(|replay| replay.key.as_str())
112    }
113
114    pub fn causal_ref(&self) -> Option<CausalRef> {
115        match &self.subject {
116            RuntimeSubject::Effect { effect_id, .. } => Some(CausalRef::Effect {
117                session_id: self.scope.session_id.clone(),
118                turn_id: self.scope.turn_id.clone(),
119                effect_id: effect_id.clone(),
120            }),
121            RuntimeSubject::Process { process_id } => Some(CausalRef::Process {
122                process_id: process_id.clone(),
123            }),
124            RuntimeSubject::ProcessEvent {
125                process_id,
126                sequence,
127                ..
128            } => Some(CausalRef::ProcessEvent {
129                process_id: process_id.clone(),
130                sequence: *sequence,
131            }),
132            RuntimeSubject::TriggerOccurrence { occurrence_id } => {
133                Some(CausalRef::TriggerOccurrence {
134                    occurrence_id: occurrence_id.clone(),
135                    subscription_id: None,
136                    subscription_incarnation: None,
137                    subscription_revision: None,
138                })
139            }
140            RuntimeSubject::SessionNode { node_id } => Some(CausalRef::SessionNode {
141                session_id: self.scope.session_id.clone(),
142                node_id: node_id.clone(),
143            }),
144        }
145    }
146}
147
148#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
149pub struct RuntimeScope {
150    pub session_id: String,
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub turn_id: Option<String>,
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub turn_index: Option<usize>,
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub protocol_iteration: Option<usize>,
157}
158
159impl RuntimeScope {
160    pub fn new(session_id: impl Into<String>) -> Self {
161        Self {
162            session_id: session_id.into(),
163            turn_id: None,
164            turn_index: None,
165            protocol_iteration: None,
166        }
167    }
168
169    pub fn for_turn(
170        session_id: impl Into<String>,
171        turn_id: impl Into<String>,
172        turn_index: usize,
173        protocol_iteration: usize,
174    ) -> Self {
175        Self {
176            session_id: session_id.into(),
177            turn_id: Some(turn_id.into()),
178            turn_index: Some(turn_index),
179            protocol_iteration: Some(protocol_iteration),
180        }
181    }
182}
183
184#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
185pub struct RuntimeReplay {
186    pub key: String,
187}
188
189#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
190#[serde(tag = "type", rename_all = "snake_case")]
191pub enum RuntimeSubject {
192    Effect {
193        effect_id: String,
194        kind: RuntimeEffectKind,
195    },
196    Process {
197        process_id: String,
198    },
199    ProcessEvent {
200        process_id: String,
201        sequence: u64,
202        event_type: String,
203    },
204    TriggerOccurrence {
205        occurrence_id: String,
206    },
207    SessionNode {
208        node_id: String,
209    },
210}
211
212/// Fully serializable envelope emitted at Lash's nondeterministic boundary.
213#[derive(Clone, Debug, Serialize, Deserialize)]
214pub struct RuntimeEffectEnvelope {
215    pub invocation: RuntimeInvocation,
216    pub command: RuntimeEffectCommand,
217}
218
219// Measured 448 B on rustc 1.97.0, x86_64-unknown-linux-gnu (FIG-595).
220const _: () = assert!(std::mem::size_of::<RuntimeEffectEnvelope>() <= 576);
221
222impl RuntimeEffectEnvelope {
223    pub fn new(invocation: RuntimeInvocation, command: RuntimeEffectCommand) -> Self {
224        Self::try_new(invocation, command).expect("valid runtime effect invocation")
225    }
226
227    pub fn try_new(
228        invocation: RuntimeInvocation,
229        command: RuntimeEffectCommand,
230    ) -> Result<Self, RuntimeEffectControllerError> {
231        validate_effect_invocation(&invocation, command.kind())?;
232        validate_effect_command(&command)?;
233        Ok(Self {
234            invocation,
235            command,
236        })
237    }
238
239    pub fn stable_hash(&self) -> Result<String, RuntimeEffectControllerError> {
240        Ok(self.canonical_form()?.hash().to_string())
241    }
242
243    pub fn canonical_form(
244        &self,
245    ) -> Result<super::CanonicalRuntimeEffectEnvelope, RuntimeEffectControllerError> {
246        super::CanonicalRuntimeEffectEnvelope::capture(self)
247    }
248}
249
250fn validate_effect_invocation(
251    invocation: &RuntimeInvocation,
252    command_kind: RuntimeEffectKind,
253) -> Result<(), RuntimeEffectControllerError> {
254    let RuntimeSubject::Effect { effect_id, kind } = &invocation.subject else {
255        return Err(RuntimeEffectControllerError::new(
256            "runtime_effect_invocation_subject",
257            "runtime effect envelope subject must be an effect",
258        ));
259    };
260    if effect_id.trim().is_empty() {
261        return Err(RuntimeEffectControllerError::new(
262            "runtime_effect_invocation_subject",
263            "runtime effect envelope effect id must be non-empty",
264        ));
265    }
266    if *kind != command_kind {
267        return Err(RuntimeEffectControllerError::new(
268            "runtime_effect_invocation_kind",
269            format!(
270                "runtime effect invocation kind {} does not match command kind {}",
271                kind.as_str(),
272                command_kind.as_str()
273            ),
274        ));
275    }
276    if invocation
277        .replay
278        .as_ref()
279        .is_none_or(|replay| replay.key.is_empty())
280    {
281        return Err(RuntimeEffectControllerError::new(
282            "runtime_effect_replay_required",
283            "runtime effect envelope requires replay.key",
284        ));
285    }
286    Ok(())
287}
288
289fn validate_effect_command(
290    command: &RuntimeEffectCommand,
291) -> Result<(), RuntimeEffectControllerError> {
292    if let RuntimeEffectCommand::DurableStep { step_id, .. } = command
293        && step_id.trim().is_empty()
294    {
295        return Err(RuntimeEffectControllerError::new(
296            "runtime_effect_durable_step_id",
297            "runtime effect durable step id must be non-empty",
298        ));
299    }
300    if let RuntimeEffectCommand::ToolAttempt {
301        call,
302        execution_grant: _,
303        attempt,
304        max_attempts,
305    } = command
306    {
307        if call.call_id.trim().is_empty() {
308            return Err(RuntimeEffectControllerError::new(
309                "runtime_effect_tool_attempt_call_id",
310                "runtime effect tool attempt requires a non-empty call id",
311            ));
312        }
313        if *attempt == 0 || *max_attempts == 0 || *attempt > *max_attempts {
314            return Err(RuntimeEffectControllerError::new(
315                "runtime_effect_tool_attempt_index",
316                format!(
317                    "runtime effect tool attempt must satisfy 1 <= attempt <= max_attempts, got {attempt}/{max_attempts}"
318                ),
319            ));
320        }
321    }
322    if let RuntimeEffectCommand::ToolBatch { batch } = command {
323        if batch.batch_id.trim().is_empty() {
324            return Err(RuntimeEffectControllerError::new(
325                "runtime_effect_tool_batch_id",
326                "runtime effect tool batch id must be non-empty",
327            ));
328        }
329        if batch.calls.is_empty() {
330            return Err(RuntimeEffectControllerError::new(
331                "runtime_effect_tool_batch_empty",
332                "runtime effect tool batch must contain at least one prepared call",
333            ));
334        }
335        for (index, call) in batch.calls.iter().enumerate() {
336            if call.call.call_id.trim().is_empty() {
337                return Err(RuntimeEffectControllerError::new(
338                    "runtime_effect_tool_batch_call_id",
339                    format!("runtime effect tool batch call {index} has an empty call id"),
340                ));
341            }
342            if call.replay_suffix.trim().is_empty() {
343                return Err(RuntimeEffectControllerError::new(
344                    "runtime_effect_tool_batch_call_replay",
345                    format!("runtime effect tool batch call {index} has an empty replay suffix"),
346                ));
347            }
348        }
349    }
350    Ok(())
351}
352
353/// Serializable command emitted at Lash's nondeterministic runtime boundary.
354#[derive(Clone, Debug, Serialize, Deserialize)]
355#[serde(tag = "type", rename_all = "snake_case")]
356pub enum RuntimeEffectCommand {
357    LlmCall {
358        request: Box<LlmRequestSpec>,
359    },
360    Direct {
361        request: Box<LlmRequestSpec>,
362        usage_source: String,
363    },
364    ToolAttempt {
365        call: crate::PreparedToolCall,
366        #[serde(default, skip_serializing_if = "Option::is_none")]
367        execution_grant: Option<Box<crate::ToolExecutionGrant>>,
368        attempt: u32,
369        max_attempts: u32,
370    },
371    ToolBatch {
372        batch: crate::PreparedToolBatch,
373    },
374    Trigger {
375        command: Box<crate::TriggerCommand>,
376    },
377    Process {
378        command: Box<ProcessCommand>,
379    },
380    ExecCode {
381        language: String,
382        code: String,
383    },
384    Checkpoint {
385        checkpoint: CheckpointKind,
386    },
387    SyncExecutionEnvironment {
388        update_machine_config: bool,
389    },
390    Sleep {
391        duration_ms: u64,
392    },
393    AwaitEvent {
394        key: crate::AwaitEventKey,
395    },
396    PeekAwaitEvent {
397        key: crate::AwaitEventKey,
398    },
399    DurableStep {
400        step_id: String,
401        input: serde_json::Value,
402    },
403}
404
405// Measured 200 B on rustc 1.97.0, x86_64-unknown-linux-gnu (FIG-595).
406const _: () = assert!(std::mem::size_of::<RuntimeEffectCommand>() <= 256);
407
408impl RuntimeEffectCommand {
409    pub fn process(command: ProcessCommand) -> Self {
410        Self::Process {
411            command: Box::new(command),
412        }
413    }
414
415    pub fn kind(&self) -> RuntimeEffectKind {
416        match self {
417            Self::LlmCall { .. } => RuntimeEffectKind::LlmCall,
418            Self::Direct { .. } => RuntimeEffectKind::Direct,
419            Self::ToolAttempt { .. } => RuntimeEffectKind::ToolAttempt,
420            Self::ToolBatch { .. } => RuntimeEffectKind::ToolBatch,
421            Self::Trigger { .. } => RuntimeEffectKind::Trigger,
422            Self::Process { .. } => RuntimeEffectKind::Process,
423            Self::ExecCode { .. } => RuntimeEffectKind::ExecCode,
424            Self::Checkpoint { .. } => RuntimeEffectKind::Checkpoint,
425            Self::SyncExecutionEnvironment { .. } => RuntimeEffectKind::SyncExecutionEnvironment,
426            Self::Sleep { .. } => RuntimeEffectKind::Sleep,
427            Self::AwaitEvent { .. } => RuntimeEffectKind::AwaitEvent,
428            Self::PeekAwaitEvent { .. } => RuntimeEffectKind::PeekAwaitEvent,
429            Self::DurableStep { .. } => RuntimeEffectKind::DurableStep,
430        }
431    }
432}
433
434/// Serializable operation against the process admin plane.
435#[derive(Clone, Debug, Serialize, Deserialize)]
436#[serde(tag = "op", rename_all = "snake_case")]
437// justification: RuntimeEffectCommand already boxes every ProcessCommand, so boxing its Start payload again is redundant.
438#[allow(clippy::large_enum_variant)]
439pub enum ProcessCommand {
440    Start {
441        registration: ProcessRegistration,
442        #[serde(default, skip_serializing_if = "Option::is_none")]
443        grant: Option<ProcessStartGrant>,
444        #[serde(
445            default,
446            skip_serializing_if = "boxed_process_execution_context_is_empty"
447        )]
448        execution_context: Box<ProcessExecutionContext>,
449    },
450    List {
451        session_scope: SessionScope,
452        #[serde(default)]
453        mode: ProcessListMode,
454    },
455    Transfer {
456        from_scope: SessionScope,
457        to_scope: SessionScope,
458        process_ids: Vec<String>,
459    },
460    DeleteSession {
461        session_id: String,
462    },
463    Await {
464        process_id: String,
465    },
466    Cancel {
467        process_id: String,
468        reason: Option<String>,
469    },
470    Signal {
471        process_id: String,
472        signal_name: String,
473        signal_id: String,
474        request: crate::ProcessEventAppendRequest,
475    },
476}
477
478fn boxed_process_execution_context_is_empty(context: &ProcessExecutionContext) -> bool {
479    context.is_empty()
480}
481
482type CheckpointOutcome = Result<CheckpointDelivery, RuntimeEffectControllerError>;
483
484impl ProcessCommand {
485    pub fn effect_id(&self) -> String {
486        match self {
487            Self::Start { registration, .. } => format!("process:start:{}", registration.id),
488            Self::List {
489                session_scope,
490                mode,
491            } => {
492                format!("process:list:{}:{}", session_scope.id(), mode.as_str())
493            }
494            Self::Transfer {
495                from_scope,
496                to_scope,
497                process_ids,
498            } => {
499                let digest = crate::stable_hash::stable_json_sha256_hex(process_ids)
500                    .unwrap_or_else(|_| "unhashable".to_string());
501                format!(
502                    "process:transfer:{}:{}:{digest}",
503                    from_scope.id(),
504                    to_scope.id()
505                )
506            }
507            Self::DeleteSession { session_id } => format!("process:delete-session:{session_id}"),
508            Self::Await { process_id } => format!("process:await:{process_id}"),
509            Self::Cancel { process_id, .. } => format!("process:cancel:{process_id}"),
510            Self::Signal {
511                process_id,
512                signal_name,
513                signal_id,
514                ..
515            } => {
516                format!("process:signal:{process_id}:signal.{signal_name}:{signal_id}")
517            }
518        }
519    }
520}
521
522/// Serializable result of a process operation.
523#[derive(Clone, Debug, Serialize, Deserialize)]
524#[serde(tag = "op", rename_all = "snake_case")]
525pub enum ProcessEffectOutcome {
526    Start {
527        // Boxed so the fat durable record does not size the whole outcome enum
528        // (and the runtime effect enum wrapping it) inline through the recursive
529        // effect executor.
530        record: Box<ProcessRecord>,
531    },
532    List {
533        entries: Vec<ProcessHandleGrantEntry>,
534    },
535    Transfer,
536    DeleteSession {
537        report: crate::ProcessSessionDeleteReport,
538    },
539    Await {
540        // Keep the full terminal record while bounding every process outcome
541        // carried through the recursive effect executor.
542        output: Box<ProcessAwaitOutput>,
543    },
544    Cancel {
545        record: Box<ProcessRecord>,
546    },
547    Signal {
548        // Boxed for the same reason as the record variants: a fat event should
549        // not size the outcome enum inline through the recursive executor.
550        event: Box<crate::ProcessEvent>,
551    },
552}
553
554// Measured 88 B on rustc 1.97.0, x86_64-unknown-linux-gnu (FIG-595).
555const _: () = assert!(std::mem::size_of::<ProcessEffectOutcome>() <= 112);
556
557#[derive(Clone, Debug, Serialize, Deserialize)]
558pub struct ToolAttemptEffectOutcome {
559    pub launch: ToolAttemptLaunch,
560    #[serde(default, skip_serializing_if = "Vec::is_empty")]
561    pub triggers: Vec<ToolTriggerEffectOutcome>,
562}
563
564#[derive(Clone, Debug, Serialize, Deserialize)]
565pub struct ToolBatchEffectOutcome {
566    pub launches: Vec<ToolCallLaunch>,
567    #[serde(default, skip_serializing_if = "Vec::is_empty")]
568    pub triggers: Vec<ToolTriggerEffectOutcome>,
569}
570
571#[derive(Clone, Debug, Serialize, Deserialize)]
572#[serde(tag = "status", rename_all = "snake_case")]
573pub enum ToolCallLaunch {
574    Done {
575        result: Box<CompletedToolCall>,
576    },
577    Pending {
578        key: crate::AwaitEventKey,
579        pending: crate::PendingCompletion,
580        duration_ms: u64,
581    },
582}
583
584#[derive(Clone, Debug, Serialize, Deserialize)]
585#[serde(tag = "status", rename_all = "snake_case")]
586pub enum ToolAttemptLaunch {
587    Done {
588        record: Box<crate::ToolCallRecord>,
589    },
590    Pending {
591        key: crate::AwaitEventKey,
592        pending: crate::PendingCompletion,
593        duration_ms: u64,
594    },
595}
596
597pub type RuntimeLlmCallOutcome = (
598    Result<LlmResponse, LlmCallError>,
599    bool,
600    Option<crate::LlmCallRecord>,
601);
602
603pub type RuntimeDirectLlmOutcome = (
604    Result<LlmResponse, LlmCallError>,
605    Option<crate::LlmCallRecord>,
606);
607
608/// Serializable result of a runtime effect command.
609///
610/// Large payloads stay boxed so this boundary type remains cheap to retain in
611/// nested async controller frames. `Box<T>` is serde-transparent, so durable
612/// journal records keep their established JSON shape and full-record evidence.
613#[derive(Clone, Debug, Serialize, Deserialize)]
614#[serde(tag = "type", rename_all = "snake_case")]
615pub enum RuntimeEffectOutcome {
616    LlmCall {
617        result: Box<Result<LlmResponse, LlmCallError>>,
618        text_streamed: bool,
619        /// Sealed provider-attempt history. Older journal entries and calls
620        /// interrupted before the provider handle returns have no record.
621        #[serde(default, skip_serializing_if = "Option::is_none")]
622        call_record: Option<crate::LlmCallRecord>,
623    },
624    Direct {
625        result: Box<Result<LlmResponse, LlmCallError>>,
626        /// Sealed provider-attempt history for this single direct call.
627        #[serde(default, skip_serializing_if = "Option::is_none")]
628        call_record: Option<crate::LlmCallRecord>,
629    },
630    ToolAttempt {
631        launch: Box<ToolAttemptLaunch>,
632        #[serde(default, skip_serializing_if = "Vec::is_empty")]
633        triggers: Vec<ToolTriggerEffectOutcome>,
634    },
635    ToolBatch {
636        launches: Vec<ToolCallLaunch>,
637        #[serde(default, skip_serializing_if = "Vec::is_empty")]
638        triggers: Vec<ToolTriggerEffectOutcome>,
639    },
640    Trigger {
641        result: Box<crate::TriggerEffectResult>,
642    },
643    Process {
644        result: ProcessEffectOutcome,
645    },
646    ExecCode {
647        result: Box<Result<ExecResponse, String>>,
648    },
649    Checkpoint {
650        result: CheckpointOutcome,
651    },
652    SyncExecutionEnvironment {
653        result: Result<Option<ExecutionEnvironmentSync>, String>,
654    },
655    Sleep,
656    AwaitEvent {
657        resolution: crate::Resolution,
658    },
659    PeekAwaitEvent {
660        resolution: Option<crate::Resolution>,
661    },
662    DurableStep {
663        value: serde_json::Value,
664    },
665}
666
667// Measured 96 B on rustc 1.97.0, x86_64-unknown-linux-gnu (FIG-595).
668const _: () = assert!(std::mem::size_of::<RuntimeEffectOutcome>() <= 128);
669
670// =============================================================================
671// Request specs (serializable forms of LLM/Direct requests)
672// =============================================================================
673
674/// Serializable attachment data for runtime effect envelopes.
675///
676/// Inline sources are normalized to `Stored` before this durable shape is
677/// created. Borrowed sources round-trip without entering Lash storage.
678#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
679pub struct LlmAttachmentSpec {
680    pub source: AttachmentSource,
681}
682
683/// Serializable LLM request data. Live stream and provider-trace callbacks are
684/// attached by the local executor, and attachment bytes are resolved locally
685/// from refs rather than persisted in the effect envelope.
686#[derive(Clone, Debug, Serialize, Deserialize)]
687pub struct LlmRequestSpec {
688    pub model: String,
689    pub messages: Vec<LlmMessage>,
690    pub attachments: Vec<LlmAttachmentSpec>,
691    pub tools: Arc<Vec<LlmToolSpec>>,
692    pub tool_choice: LlmToolChoice,
693    pub model_variant: crate::ReasoningSelection,
694    #[serde(default)]
695    pub model_capability: crate::ModelCapability,
696    #[serde(default)]
697    pub generation: crate::GenerationOptions,
698    pub scope: crate::LlmRequestScope,
699    pub output_spec: Option<LlmOutputSpec>,
700}
701
702impl LlmRequestSpec {
703    pub async fn from_request(
704        request: &CoreLlmRequest,
705        attachment_store: &crate::SessionAttachmentStore,
706    ) -> Result<Self, RuntimeEffectControllerError> {
707        Ok(Self {
708            model: request.model.clone(),
709            messages: request.messages.clone(),
710            attachments: attachment_specs_from_attachments(&request.attachments, attachment_store)
711                .await?,
712            tools: Arc::clone(&request.tools),
713            tool_choice: request.tool_choice.clone(),
714            model_variant: request.model_variant.clone(),
715            model_capability: request.model_capability.clone(),
716            generation: request.generation.clone(),
717            scope: request.scope.clone(),
718            output_spec: request.output_spec.clone(),
719        })
720    }
721
722    pub fn into_request(
723        self,
724        stream_events: Option<LlmEventSender>,
725        provider_trace: Option<LlmProviderTraceSender>,
726    ) -> CoreLlmRequest {
727        CoreLlmRequest {
728            model: self.model,
729            messages: self.messages,
730            attachments: self
731                .attachments
732                .into_iter()
733                .map(|spec| spec.source)
734                .collect(),
735            resolved_stored: Default::default(),
736            tools: self.tools,
737            tool_choice: self.tool_choice,
738            model_variant: self.model_variant,
739            model_capability: self.model_capability,
740            generation: self.generation,
741            scope: self.scope,
742            output_spec: self.output_spec,
743            stream_events,
744            provider_trace,
745        }
746    }
747}
748
749async fn attachment_specs_from_attachments(
750    attachments: &[AttachmentSource],
751    attachment_store: &crate::SessionAttachmentStore,
752) -> Result<Vec<LlmAttachmentSpec>, RuntimeEffectControllerError> {
753    let mut specs = Vec::with_capacity(attachments.len());
754    for attachment in attachments {
755        specs.push(attachment_spec_from_attachment(attachment, attachment_store).await?);
756    }
757    Ok(specs)
758}
759
760async fn attachment_spec_from_attachment(
761    attachment: &AttachmentSource,
762    attachment_store: &crate::SessionAttachmentStore,
763) -> Result<LlmAttachmentSpec, RuntimeEffectControllerError> {
764    let source = match attachment {
765        AttachmentSource::Inline { media_type, bytes } => {
766            let attachment_ref = attachment_store
767                .put(
768                    bytes.clone(),
769                    AttachmentCreateMeta::new(media_type.clone(), None, None),
770                )
771                .await
772                .map_err(|err| {
773                    RuntimeEffectControllerError::new(
774                        "runtime_effect_attachment_store",
775                        format!(
776                            "failed to store attachment before runtime effect invocation: {err}"
777                        ),
778                    )
779                })?;
780            AttachmentSource::stored(attachment_ref)
781        }
782        durable => durable.clone(),
783    };
784    Ok(LlmAttachmentSpec { source })
785}
786
787impl RuntimeEffectOutcome {
788    pub fn into_llm_call(self) -> Result<RuntimeLlmCallOutcome, RuntimeEffectControllerError> {
789        match self {
790            Self::LlmCall {
791                result,
792                text_streamed,
793                call_record,
794            } => Ok((*result, text_streamed, call_record)),
795            other => Err(RuntimeEffectControllerError::wrong_outcome(
796                RuntimeEffectKind::LlmCall,
797                other.kind(),
798            )),
799        }
800    }
801
802    pub fn into_direct_response(
803        self,
804    ) -> Result<RuntimeDirectLlmOutcome, RuntimeEffectControllerError> {
805        match self {
806            Self::Direct {
807                result,
808                call_record,
809            } => Ok((*result, call_record)),
810            other => Err(RuntimeEffectControllerError::wrong_outcome(
811                RuntimeEffectKind::Direct,
812                other.kind(),
813            )),
814        }
815    }
816
817    pub fn into_tool_attempt_effect(
818        self,
819    ) -> Result<ToolAttemptEffectOutcome, RuntimeEffectControllerError> {
820        match self {
821            Self::ToolAttempt { launch, triggers } => Ok(ToolAttemptEffectOutcome {
822                launch: *launch,
823                triggers,
824            }),
825            other => Err(RuntimeEffectControllerError::wrong_outcome(
826                RuntimeEffectKind::ToolAttempt,
827                other.kind(),
828            )),
829        }
830    }
831
832    pub fn into_tool_batch_effect(
833        self,
834    ) -> Result<ToolBatchEffectOutcome, RuntimeEffectControllerError> {
835        match self {
836            Self::ToolBatch { launches, triggers } => {
837                Ok(ToolBatchEffectOutcome { launches, triggers })
838            }
839            other => Err(RuntimeEffectControllerError::wrong_outcome(
840                RuntimeEffectKind::ToolBatch,
841                other.kind(),
842            )),
843        }
844    }
845
846    pub fn into_process(self) -> Result<ProcessEffectOutcome, RuntimeEffectControllerError> {
847        match self {
848            Self::Process { result } => Ok(result),
849            other => Err(RuntimeEffectControllerError::wrong_outcome(
850                RuntimeEffectKind::Process,
851                other.kind(),
852            )),
853        }
854    }
855
856    pub fn into_trigger(self) -> Result<crate::TriggerEffectResult, RuntimeEffectControllerError> {
857        match self {
858            Self::Trigger { result } => Ok(*result),
859            other => Err(RuntimeEffectControllerError::new(
860                "runtime_effect_wrong_outcome",
861                format!("expected trigger outcome, got {}", other.kind().as_str()),
862            )),
863        }
864    }
865
866    pub fn into_exec_code(
867        self,
868    ) -> Result<Result<ExecResponse, String>, RuntimeEffectControllerError> {
869        match self {
870            Self::ExecCode { result } => Ok(*result),
871            other => Err(RuntimeEffectControllerError::wrong_outcome(
872                RuntimeEffectKind::ExecCode,
873                other.kind(),
874            )),
875        }
876    }
877
878    pub(crate) fn into_checkpoint(self) -> Result<CheckpointOutcome, RuntimeEffectControllerError> {
879        match self {
880            Self::Checkpoint { result } => Ok(result),
881            other => Err(RuntimeEffectControllerError::wrong_outcome(
882                RuntimeEffectKind::Checkpoint,
883                other.kind(),
884            )),
885        }
886    }
887
888    pub fn into_sync_execution_environment(
889        self,
890    ) -> Result<Result<Option<ExecutionEnvironmentSync>, String>, RuntimeEffectControllerError>
891    {
892        match self {
893            Self::SyncExecutionEnvironment { result } => Ok(result),
894            other => Err(RuntimeEffectControllerError::wrong_outcome(
895                RuntimeEffectKind::SyncExecutionEnvironment,
896                other.kind(),
897            )),
898        }
899    }
900
901    pub fn into_await_event(self) -> Result<crate::Resolution, RuntimeEffectControllerError> {
902        match self {
903            Self::AwaitEvent { resolution } => Ok(resolution),
904            other => Err(RuntimeEffectControllerError::wrong_outcome(
905                RuntimeEffectKind::AwaitEvent,
906                other.kind(),
907            )),
908        }
909    }
910
911    pub fn into_peek_await_event(
912        self,
913    ) -> Result<Option<crate::Resolution>, RuntimeEffectControllerError> {
914        match self {
915            Self::PeekAwaitEvent { resolution } => Ok(resolution),
916            other => Err(RuntimeEffectControllerError::wrong_outcome(
917                RuntimeEffectKind::PeekAwaitEvent,
918                other.kind(),
919            )),
920        }
921    }
922
923    pub fn into_durable_step(self) -> Result<serde_json::Value, RuntimeEffectControllerError> {
924        match self {
925            Self::DurableStep { value } => Ok(value),
926            other => Err(RuntimeEffectControllerError::wrong_outcome(
927                RuntimeEffectKind::DurableStep,
928                other.kind(),
929            )),
930        }
931    }
932
933    pub fn kind(&self) -> RuntimeEffectKind {
934        match self {
935            Self::LlmCall { .. } => RuntimeEffectKind::LlmCall,
936            Self::Direct { .. } => RuntimeEffectKind::Direct,
937            Self::ToolAttempt { .. } => RuntimeEffectKind::ToolAttempt,
938            Self::ToolBatch { .. } => RuntimeEffectKind::ToolBatch,
939            Self::Trigger { .. } => RuntimeEffectKind::Trigger,
940            Self::Process { .. } => RuntimeEffectKind::Process,
941            Self::ExecCode { .. } => RuntimeEffectKind::ExecCode,
942            Self::Checkpoint { .. } => RuntimeEffectKind::Checkpoint,
943            Self::SyncExecutionEnvironment { .. } => RuntimeEffectKind::SyncExecutionEnvironment,
944            Self::Sleep => RuntimeEffectKind::Sleep,
945            Self::AwaitEvent { .. } => RuntimeEffectKind::AwaitEvent,
946            Self::PeekAwaitEvent { .. } => RuntimeEffectKind::PeekAwaitEvent,
947            Self::DurableStep { .. } => RuntimeEffectKind::DurableStep,
948        }
949    }
950}