Skip to main content

vifu_runtime/
application.rs

1use std::collections::{HashMap, VecDeque};
2use std::fmt;
3use std::future::Future;
4use std::pin::Pin;
5use std::sync::atomic::{AtomicU64, Ordering};
6use std::sync::{Arc, Mutex, RwLock};
7use std::thread::JoinHandle;
8use std::time::{Duration, Instant};
9
10use serde::{Deserialize, Serialize};
11use serde_json::{json, Value};
12use tokio::sync::{mpsc, watch, Notify};
13
14use crate::{
15    EffectRequest, EffectResult, LocalProviderBinding, ProjectSettings, RuntimeManifest,
16    RuntimeRelease, RuntimeSnapshot, RuntimeTraceRecord,
17};
18
19const SNAPSHOT_VERSION: u32 = 1;
20const DEFAULT_TIMEOUT_MS: u64 = 30_000;
21const MAX_TIMEOUT_MS: u64 = 120_000;
22const DEFAULT_EFFECT_LIMIT: usize = 64;
23const MAX_IN_FLIGHT_INVOCATIONS: usize = 64;
24const MAX_RETAINED_INVOCATIONS: usize = 256;
25const MAX_RETAINED_INVOCATION_EVENTS: usize = 256;
26const MAX_COALESCED_EVENT_BYTES: usize = 64 * 1024;
27const WORKER_QUEUE_CAPACITY: usize = 64;
28
29/// A boxed provider future used by [`AgentProvider`].
30pub type ProviderFuture<'a> =
31    Pin<Box<dyn Future<Output = Result<ProviderResponse, RuntimeError>> + Send + 'a>>;
32
33/// JSON or binary data passed through an embedded runtime invocation.
34#[derive(Clone, PartialEq, Serialize, Deserialize)]
35#[serde(tag = "format", content = "value", rename_all = "camelCase")]
36pub enum InvocationData {
37    Json(Value),
38    Binary(Vec<u8>),
39}
40
41impl fmt::Debug for InvocationData {
42    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
43        match self {
44            Self::Json(_) => formatter.write_str("InvocationData::Json([REDACTED])"),
45            Self::Binary(bytes) => formatter
46                .debug_tuple("InvocationData::Binary")
47                .field(&format_args!("{} bytes", bytes.len()))
48                .finish(),
49        }
50    }
51}
52
53impl Default for InvocationData {
54    fn default() -> Self {
55        Self::Json(Value::Null)
56    }
57}
58
59/// Input for one application-facing endpoint invocation.
60#[derive(Clone, PartialEq, Serialize, Deserialize)]
61#[serde(rename_all = "camelCase")]
62pub struct InvocationInput {
63    pub endpoint: String,
64    #[serde(default = "default_session_id")]
65    pub session_id: String,
66    #[serde(default)]
67    pub data: InvocationData,
68    #[serde(default)]
69    pub metadata: Value,
70}
71
72impl InvocationInput {
73    pub fn json(endpoint: impl Into<String>, data: Value) -> Self {
74        Self {
75            endpoint: endpoint.into(),
76            session_id: default_session_id(),
77            data: InvocationData::Json(data),
78            metadata: Value::Object(Default::default()),
79        }
80    }
81
82    pub fn with_session(mut self, session_id: impl Into<String>) -> Self {
83        self.session_id = session_id.into();
84        self
85    }
86}
87
88impl fmt::Debug for InvocationInput {
89    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
90        formatter
91            .debug_struct("InvocationInput")
92            .field("endpoint", &self.endpoint)
93            .field("session_id", &self.session_id)
94            .field("data", &"[REDACTED]")
95            .field("metadata", &"[REDACTED]")
96            .finish()
97    }
98}
99
100/// An agent registered inside one application runtime.
101#[derive(Clone, PartialEq, Serialize, Deserialize)]
102#[serde(rename_all = "camelCase")]
103pub struct AgentDefinition {
104    pub id: String,
105    pub name: String,
106    pub provider: String,
107    pub capabilities: Vec<String>,
108    #[serde(default)]
109    pub metadata: Value,
110}
111
112impl fmt::Debug for AgentDefinition {
113    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
114        formatter
115            .debug_struct("AgentDefinition")
116            .field("id", &self.id)
117            .field("name", &self.name)
118            .field("provider", &self.provider)
119            .field("capabilities", &self.capabilities)
120            .field("metadata", &"[REDACTED]")
121            .finish()
122    }
123}
124
125/// A stable, named application endpoint backed by one registered agent.
126#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
127#[serde(rename_all = "camelCase")]
128pub struct EndpointDefinition {
129    pub name: String,
130    pub agent: String,
131    pub capability: String,
132    /// Maximum time without a provider event before the invocation is cancelled.
133    #[serde(default = "default_timeout_ms")]
134    pub timeout_ms: u64,
135}
136
137/// Request delivered to a dynamically registered [`AgentProvider`].
138#[derive(Clone)]
139pub struct ProviderRequest {
140    pub project_id: String,
141    pub endpoint: String,
142    pub session_id: String,
143    pub agent: AgentDefinition,
144    pub capability: String,
145    pub data: InvocationData,
146    pub metadata: Value,
147    pub snapshot: RuntimeSnapshot,
148}
149
150impl fmt::Debug for ProviderRequest {
151    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
152        formatter
153            .debug_struct("ProviderRequest")
154            .field("project_id", &self.project_id)
155            .field("endpoint", &self.endpoint)
156            .field("session_id", &self.session_id)
157            .field("agent", &self.agent.id)
158            .field("capability", &self.capability)
159            .field("data", &"[REDACTED]")
160            .field("metadata", &"[REDACTED]")
161            .field("snapshot_revision", &self.snapshot.revision)
162            .finish()
163    }
164}
165
166/// Provider result and an optional replacement for the session's durable state.
167#[derive(Clone, PartialEq, Serialize, Deserialize)]
168#[serde(rename_all = "camelCase")]
169pub struct ProviderResponse {
170    #[serde(default)]
171    pub data: InvocationData,
172    #[serde(default)]
173    pub metadata: Value,
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub state: Option<Value>,
176}
177
178impl ProviderResponse {
179    pub fn json(data: Value) -> Self {
180        Self {
181            data: InvocationData::Json(data),
182            metadata: Value::Object(Default::default()),
183            state: None,
184        }
185    }
186}
187
188impl fmt::Debug for ProviderResponse {
189    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
190        formatter
191            .debug_struct("ProviderResponse")
192            .field("data", &"[REDACTED]")
193            .field("metadata", &"[REDACTED]")
194            .field("state", &self.state.as_ref().map(|_| "[REDACTED]"))
195            .finish()
196    }
197}
198
199/// Cooperative cancellation signal passed to providers.
200#[derive(Clone, Default)]
201pub struct CancellationToken {
202    inner: Arc<CancellationState>,
203}
204
205#[derive(Default)]
206struct CancellationState {
207    cancelled: std::sync::atomic::AtomicBool,
208    notify: Notify,
209}
210
211impl CancellationToken {
212    pub fn cancel(&self) {
213        if !self.inner.cancelled.swap(true, Ordering::AcqRel) {
214            self.inner.notify.notify_waiters();
215        }
216    }
217
218    pub fn is_cancelled(&self) -> bool {
219        self.inner.cancelled.load(Ordering::Acquire)
220    }
221
222    pub async fn cancelled(&self) {
223        if self.is_cancelled() {
224            return;
225        }
226        self.inner.notify.notified().await;
227    }
228}
229
230impl fmt::Debug for CancellationToken {
231    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
232        formatter
233            .debug_struct("CancellationToken")
234            .field("cancelled", &self.is_cancelled())
235            .finish()
236    }
237}
238
239/// Kind of event emitted while a non-blocking invocation is running.
240#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
241#[serde(rename_all = "camelCase")]
242pub enum InvocationEventKind {
243    Started,
244    OutputDelta,
245    Completed,
246    Failed,
247    Cancelled,
248}
249
250/// A provider stage that can be rendered as an observation in a live trace.
251#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)]
252#[serde(rename_all = "camelCase")]
253pub enum ProviderStage {
254    Queue,
255    Load,
256    Tokenize,
257    Prefill,
258    FirstToken,
259    Decode,
260    Validate,
261}
262
263/// A typed provider event emitted while an invocation is running.
264#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
265#[serde(tag = "type", rename_all = "camelCase")]
266pub enum ProviderEvent {
267    /// Payload-free liveness signal for a provider that is still making
268    /// progress inside a long-running stage.
269    Activity,
270    OutputDelta {
271        data: InvocationData,
272    },
273    StageStarted {
274        stage: ProviderStage,
275        #[serde(default, skip_serializing_if = "is_null")]
276        metadata: Value,
277    },
278    StageCompleted {
279        stage: ProviderStage,
280        elapsed_ms: u64,
281        #[serde(default, skip_serializing_if = "is_null")]
282        metadata: Value,
283    },
284    StageFailed {
285        stage: ProviderStage,
286        elapsed_ms: u64,
287        error: String,
288        #[serde(default, skip_serializing_if = "is_null")]
289        metadata: Value,
290    },
291}
292
293/// Terminal outcome reported to an embedded runtime monitor.
294#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
295#[serde(rename_all = "camelCase")]
296pub enum RuntimeMonitorStatus {
297    Completed,
298    Cancelled,
299    Error,
300}
301
302/// State of a provider stage reported to an embedded runtime monitor.
303#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
304#[serde(rename_all = "camelCase")]
305pub enum RuntimeMonitorStageStatus {
306    Started,
307    Completed,
308    Failed,
309}
310
311/// Payload-safe lifecycle metadata for one embedded runtime invocation.
312///
313/// Prompt content and streamed output are intentionally excluded. Hosts may
314/// forward these events to a remote monitor without exposing model input or
315/// output data.
316#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
317#[serde(tag = "type", rename_all = "camelCase")]
318pub enum RuntimeMonitorEvent {
319    InvocationStarted {
320        trace_id: String,
321        invocation_id: String,
322        project_id: String,
323        endpoint: String,
324        agent_id: String,
325        provider_id: String,
326        capability: String,
327        started_at_ms: u64,
328    },
329    ProviderStage {
330        trace_id: String,
331        invocation_id: String,
332        stage: ProviderStage,
333        status: RuntimeMonitorStageStatus,
334        #[serde(default, skip_serializing_if = "Option::is_none")]
335        elapsed_ms: Option<u64>,
336        request_elapsed_ms: u64,
337        #[serde(default, skip_serializing_if = "Option::is_none")]
338        error: Option<String>,
339    },
340    InvocationFinished {
341        trace_id: String,
342        invocation_id: String,
343        status: RuntimeMonitorStatus,
344        duration_ms: u64,
345        ended_at_ms: u64,
346        #[serde(default, skip_serializing_if = "Option::is_none")]
347        error: Option<String>,
348    },
349}
350
351/// Thread-safe callback installed by an embedding host that wants live,
352/// payload-safe runtime lifecycle metadata.
353pub type RuntimeMonitorObserver = Arc<dyn Fn(RuntimeMonitorEvent) + Send + Sync>;
354
355/// One ordered event produced by an invocation.
356#[derive(Clone, PartialEq, Serialize, Deserialize)]
357#[serde(rename_all = "camelCase")]
358pub struct InvocationEvent {
359    pub sequence: u64,
360    pub kind: InvocationEventKind,
361    #[serde(default, skip_serializing_if = "Option::is_none")]
362    pub data: Option<InvocationData>,
363    #[serde(default, skip_serializing_if = "Option::is_none")]
364    pub error: Option<String>,
365}
366
367impl fmt::Debug for InvocationEvent {
368    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
369        formatter
370            .debug_struct("InvocationEvent")
371            .field("sequence", &self.sequence)
372            .field("kind", &self.kind)
373            .field("data", &self.data.as_ref().map(|_| "[REDACTED]"))
374            .field("error", &self.error.as_ref().map(|_| "[REDACTED]"))
375            .finish()
376    }
377}
378
379/// Sink supplied to providers that can produce incremental output.
380///
381/// Providers that only return a final response can keep implementing
382/// [`AgentProvider::invoke`]. Streaming providers call [`Self::output_delta`]
383/// while their invocation is running.
384#[derive(Clone)]
385pub struct ProviderEventSink {
386    emit: Arc<dyn Fn(ProviderEvent) + Send + Sync>,
387}
388
389impl ProviderEventSink {
390    fn new(emit: impl Fn(ProviderEvent) + Send + Sync + 'static) -> Self {
391        Self {
392            emit: Arc::new(emit),
393        }
394    }
395
396    /// Creates a sink that forwards every typed provider event to `emit`.
397    pub fn from_fn(emit: impl Fn(ProviderEvent) + Send + Sync + 'static) -> Self {
398        Self::new(emit)
399    }
400
401    pub fn discard() -> Self {
402        Self::new(|_event| {})
403    }
404
405    pub fn output_delta(&self, data: InvocationData) {
406        (self.emit)(ProviderEvent::OutputDelta { data });
407    }
408
409    pub fn activity(&self) {
410        (self.emit)(ProviderEvent::Activity);
411    }
412
413    pub fn stage_started(&self, stage: ProviderStage, metadata: Value) {
414        (self.emit)(ProviderEvent::StageStarted { stage, metadata });
415    }
416
417    pub fn stage_completed(&self, stage: ProviderStage, elapsed_ms: u64, metadata: Value) {
418        (self.emit)(ProviderEvent::StageCompleted {
419            stage,
420            elapsed_ms,
421            metadata,
422        });
423    }
424
425    pub fn stage_failed(
426        &self,
427        stage: ProviderStage,
428        elapsed_ms: u64,
429        error: impl Into<String>,
430        metadata: Value,
431    ) {
432        (self.emit)(ProviderEvent::StageFailed {
433            stage,
434            elapsed_ms,
435            error: error.into(),
436            metadata,
437        });
438    }
439}
440
441impl fmt::Debug for ProviderEventSink {
442    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
443        formatter.write_str("ProviderEventSink")
444    }
445}
446
447/// Runtime-selected provider implementation.
448///
449/// Providers are registered dynamically by name. A provider may hold credentials
450/// internally, but credentials must never be placed in agent definitions,
451/// invocation metadata, snapshots, or returned trace attributes.
452pub trait AgentProvider: Send + Sync + 'static {
453    fn supports(&self, capability: &str) -> bool;
454
455    fn invoke<'a>(
456        &'a self,
457        request: ProviderRequest,
458        cancellation: CancellationToken,
459    ) -> ProviderFuture<'a>;
460
461    fn invoke_with_events<'a>(
462        &'a self,
463        request: ProviderRequest,
464        cancellation: CancellationToken,
465        _events: ProviderEventSink,
466    ) -> ProviderFuture<'a> {
467        self.invoke(request, cancellation)
468    }
469}
470
471/// Persistence adapter supplied by an embedding host.
472///
473/// The default [`MemoryRuntimeStore`] keeps session state in memory. Server
474/// deployments can implement this trait with their database adapter.
475pub trait RuntimeStore: Send + Sync + 'static {
476    fn load(
477        &self,
478        project_id: &str,
479        session_id: &str,
480    ) -> Result<Option<RuntimeSnapshot>, RuntimeError>;
481
482    fn save(
483        &self,
484        project_id: &str,
485        session_id: &str,
486        snapshot: &RuntimeSnapshot,
487    ) -> Result<(), RuntimeError>;
488
489    fn save_release(&self, _release: &RuntimeRelease) -> Result<(), RuntimeError> {
490        Err(RuntimeError::store(
491            "this runtime store does not support releases".to_string(),
492        ))
493    }
494
495    fn load_release(
496        &self,
497        _project_id: &str,
498        _version: u64,
499    ) -> Result<Option<RuntimeRelease>, RuntimeError> {
500        Ok(None)
501    }
502
503    fn list_releases(&self, _project_id: &str) -> Result<Vec<RuntimeRelease>, RuntimeError> {
504        Ok(Vec::new())
505    }
506
507    fn active_release(&self, _project_id: &str) -> Result<Option<u64>, RuntimeError> {
508        Ok(None)
509    }
510
511    fn set_active_release(&self, _project_id: &str, _version: u64) -> Result<(), RuntimeError> {
512        Err(RuntimeError::store(
513            "this runtime store does not support releases".to_string(),
514        ))
515    }
516
517    fn save_local_provider_binding(
518        &self,
519        _project_id: &str,
520        _binding: &LocalProviderBinding,
521    ) -> Result<(), RuntimeError> {
522        Err(RuntimeError::store(
523            "this runtime store does not support provider bindings".to_string(),
524        ))
525    }
526
527    fn local_provider_bindings(
528        &self,
529        _project_id: &str,
530    ) -> Result<Vec<LocalProviderBinding>, RuntimeError> {
531        Ok(Vec::new())
532    }
533
534    fn enqueue_trace(&self, _trace: &RuntimeTraceRecord) -> Result<(), RuntimeError> {
535        Ok(())
536    }
537
538    fn pending_traces(&self, _limit: usize) -> Result<Vec<RuntimeTraceRecord>, RuntimeError> {
539        Ok(Vec::new())
540    }
541
542    fn acknowledge_traces(&self, _trace_ids: &[String]) -> Result<(), RuntimeError> {
543        Ok(())
544    }
545}
546
547/// In-memory persistence used by the standalone embedded runtime.
548#[derive(Default)]
549pub struct MemoryRuntimeStore {
550    snapshots: RwLock<HashMap<(String, String), RuntimeSnapshot>>,
551    releases: RwLock<HashMap<(String, u64), RuntimeRelease>>,
552    active_releases: RwLock<HashMap<String, u64>>,
553    provider_bindings: RwLock<HashMap<(String, String), LocalProviderBinding>>,
554    trace_outbox: RwLock<VecDeque<RuntimeTraceRecord>>,
555}
556
557impl RuntimeStore for MemoryRuntimeStore {
558    fn load(
559        &self,
560        project_id: &str,
561        session_id: &str,
562    ) -> Result<Option<RuntimeSnapshot>, RuntimeError> {
563        let snapshots = self.snapshots.read().map_err(|_| RuntimeError::Internal)?;
564        Ok(snapshots
565            .get(&(project_id.to_string(), session_id.to_string()))
566            .cloned())
567    }
568
569    fn save(
570        &self,
571        project_id: &str,
572        session_id: &str,
573        snapshot: &RuntimeSnapshot,
574    ) -> Result<(), RuntimeError> {
575        let mut snapshots = self.snapshots.write().map_err(|_| RuntimeError::Internal)?;
576        snapshots.insert(
577            (project_id.to_string(), session_id.to_string()),
578            snapshot.clone(),
579        );
580        Ok(())
581    }
582
583    fn save_release(&self, release: &RuntimeRelease) -> Result<(), RuntimeError> {
584        release.validate()?;
585        let key = (release.manifest.project_id.clone(), release.version);
586        let mut releases = self.releases.write().map_err(|_| RuntimeError::Internal)?;
587        if let Some(existing) = releases.get(&key) {
588            if existing != release {
589                return Err(RuntimeError::store(
590                    "runtime release versions are immutable".to_string(),
591                ));
592            }
593            return Ok(());
594        }
595        releases.insert(key, release.clone());
596        Ok(())
597    }
598
599    fn load_release(
600        &self,
601        project_id: &str,
602        version: u64,
603    ) -> Result<Option<RuntimeRelease>, RuntimeError> {
604        Ok(self
605            .releases
606            .read()
607            .map_err(|_| RuntimeError::Internal)?
608            .get(&(project_id.to_string(), version))
609            .cloned())
610    }
611
612    fn list_releases(&self, project_id: &str) -> Result<Vec<RuntimeRelease>, RuntimeError> {
613        let mut releases = self
614            .releases
615            .read()
616            .map_err(|_| RuntimeError::Internal)?
617            .iter()
618            .filter(|((stored_project_id, _), _)| stored_project_id == project_id)
619            .map(|(_, release)| release.clone())
620            .collect::<Vec<_>>();
621        releases.sort_by_key(|release| std::cmp::Reverse(release.version));
622        Ok(releases)
623    }
624
625    fn active_release(&self, project_id: &str) -> Result<Option<u64>, RuntimeError> {
626        Ok(self
627            .active_releases
628            .read()
629            .map_err(|_| RuntimeError::Internal)?
630            .get(project_id)
631            .copied())
632    }
633
634    fn set_active_release(&self, project_id: &str, version: u64) -> Result<(), RuntimeError> {
635        if !self
636            .releases
637            .read()
638            .map_err(|_| RuntimeError::Internal)?
639            .contains_key(&(project_id.to_string(), version))
640        {
641            return Err(RuntimeError::store("runtime release was not found"));
642        }
643        self.active_releases
644            .write()
645            .map_err(|_| RuntimeError::Internal)?
646            .insert(project_id.to_string(), version);
647        Ok(())
648    }
649
650    fn save_local_provider_binding(
651        &self,
652        project_id: &str,
653        binding: &LocalProviderBinding,
654    ) -> Result<(), RuntimeError> {
655        self.provider_bindings
656            .write()
657            .map_err(|_| RuntimeError::Internal)?
658            .insert(
659                (project_id.to_string(), binding.provider_id.clone()),
660                binding.clone(),
661            );
662        Ok(())
663    }
664
665    fn local_provider_bindings(
666        &self,
667        project_id: &str,
668    ) -> Result<Vec<LocalProviderBinding>, RuntimeError> {
669        let mut bindings = self
670            .provider_bindings
671            .read()
672            .map_err(|_| RuntimeError::Internal)?
673            .iter()
674            .filter(|((stored_project_id, _), _)| stored_project_id == project_id)
675            .map(|(_, binding)| binding.clone())
676            .collect::<Vec<_>>();
677        bindings.sort_by(|left, right| left.provider_id.cmp(&right.provider_id));
678        Ok(bindings)
679    }
680
681    fn enqueue_trace(&self, trace: &RuntimeTraceRecord) -> Result<(), RuntimeError> {
682        const MAX_MEMORY_TRACES: usize = 1_000;
683        let mut traces = self
684            .trace_outbox
685            .write()
686            .map_err(|_| RuntimeError::Internal)?;
687        if traces.iter().any(|stored| stored.id == trace.id) {
688            return Ok(());
689        }
690        traces.push_back(trace.clone());
691        while traces.len() > MAX_MEMORY_TRACES {
692            traces.pop_front();
693        }
694        Ok(())
695    }
696
697    fn pending_traces(&self, limit: usize) -> Result<Vec<RuntimeTraceRecord>, RuntimeError> {
698        Ok(self
699            .trace_outbox
700            .read()
701            .map_err(|_| RuntimeError::Internal)?
702            .iter()
703            .take(limit)
704            .cloned()
705            .collect())
706    }
707
708    fn acknowledge_traces(&self, trace_ids: &[String]) -> Result<(), RuntimeError> {
709        self.trace_outbox
710            .write()
711            .map_err(|_| RuntimeError::Internal)?
712            .retain(|trace| !trace_ids.contains(&trace.id));
713        Ok(())
714    }
715}
716
717/// One safe trace event emitted by the application runtime.
718#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
719#[serde(rename_all = "camelCase")]
720pub struct InvocationTraceEvent {
721    pub name: String,
722    pub status: String,
723    pub duration_ms: u64,
724    #[serde(default)]
725    pub attributes: Value,
726}
727
728/// Result of one endpoint invocation.
729#[derive(Clone, PartialEq, Serialize, Deserialize)]
730#[serde(rename_all = "camelCase")]
731pub struct InvocationOutput {
732    pub invocation_id: String,
733    pub project_id: String,
734    pub endpoint: String,
735    pub session_id: String,
736    pub agent: String,
737    pub provider: String,
738    pub capability: String,
739    pub data: InvocationData,
740    #[serde(default)]
741    pub metadata: Value,
742    pub snapshot: RuntimeSnapshot,
743    pub trace: Vec<InvocationTraceEvent>,
744}
745
746impl fmt::Debug for InvocationOutput {
747    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
748        formatter
749            .debug_struct("InvocationOutput")
750            .field("invocation_id", &self.invocation_id)
751            .field("project_id", &self.project_id)
752            .field("endpoint", &self.endpoint)
753            .field("session_id", &self.session_id)
754            .field("agent", &self.agent)
755            .field("provider", &self.provider)
756            .field("capability", &self.capability)
757            .field("data", &"[REDACTED]")
758            .field("metadata", &"[REDACTED]")
759            .field("snapshot_revision", &self.snapshot.revision)
760            .field("trace_count", &self.trace.len())
761            .finish()
762    }
763}
764
765/// Opaque handle returned by the game-loop invocation API.
766#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
767pub struct InvocationHandle(pub String);
768
769/// Current state of an invocation started with [`VifuRuntime::start_invoke`].
770#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
771#[serde(rename_all = "camelCase")]
772pub enum InvocationStatus {
773    Pending,
774    Running,
775    Completed,
776    Failed,
777    Cancelled,
778}
779
780/// Non-blocking game-loop poll result.
781#[derive(Clone, PartialEq, Serialize, Deserialize)]
782#[serde(rename_all = "camelCase")]
783pub struct InvocationPoll {
784    pub handle: InvocationHandle,
785    pub status: InvocationStatus,
786    #[serde(default, skip_serializing_if = "Option::is_none")]
787    pub output: Option<InvocationOutput>,
788    #[serde(default, skip_serializing_if = "Option::is_none")]
789    pub error: Option<String>,
790}
791
792impl fmt::Debug for InvocationPoll {
793    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
794        formatter
795            .debug_struct("InvocationPoll")
796            .field("handle", &self.handle)
797            .field("status", &self.status)
798            .field("output", &self.output.as_ref().map(|_| "[REDACTED]"))
799            .field("error", &self.error.as_ref().map(|_| "[REDACTED]"))
800            .finish()
801    }
802}
803
804/// Result of running host effects through the runtime.
805#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
806#[serde(rename_all = "camelCase")]
807pub struct EffectExecution {
808    pub results: Vec<EffectResult>,
809    pub unhandled: Vec<EffectRequest>,
810}
811
812/// Errors returned by the embedded application runtime.
813pub enum RuntimeError {
814    InvalidDefinition(String),
815    EndpointNotFound(String),
816    AgentNotFound(String),
817    ProviderNotFound(String),
818    CapabilityUnavailable {
819        provider: String,
820        capability: String,
821    },
822    Timeout(u64),
823    Cancelled,
824    Unavailable(String),
825    Backpressure(String),
826    Provider {
827        provider: String,
828        message: String,
829    },
830    Store(String),
831    Snapshot(String),
832    EffectLimitExceeded(usize),
833    InvocationNotFound(String),
834    Internal,
835}
836
837impl RuntimeError {
838    pub fn provider(provider: impl Into<String>, message: impl Into<String>) -> Self {
839        Self::Provider {
840            provider: provider.into(),
841            message: message.into(),
842        }
843    }
844
845    pub fn store(message: impl Into<String>) -> Self {
846        Self::Store(message.into())
847    }
848
849    pub fn public_message(&self) -> String {
850        match self {
851            Self::Provider { provider, .. } => {
852                format!("provider {provider} request failed")
853            }
854            Self::Store(_) => "runtime state could not be persisted".to_string(),
855            Self::Snapshot(_) => "runtime snapshot is invalid".to_string(),
856            Self::Unavailable(_) => "provider is not available".to_string(),
857            Self::Backpressure(_) => "runtime is busy".to_string(),
858            _ => self.to_string(),
859        }
860    }
861}
862
863impl fmt::Debug for RuntimeError {
864    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
865        match self {
866            Self::InvalidDefinition(_) => formatter.write_str("InvalidDefinition([REDACTED])"),
867            Self::EndpointNotFound(endpoint) => formatter
868                .debug_tuple("EndpointNotFound")
869                .field(endpoint)
870                .finish(),
871            Self::AgentNotFound(agent) => {
872                formatter.debug_tuple("AgentNotFound").field(agent).finish()
873            }
874            Self::ProviderNotFound(provider) => formatter
875                .debug_tuple("ProviderNotFound")
876                .field(provider)
877                .finish(),
878            Self::CapabilityUnavailable {
879                provider,
880                capability,
881            } => formatter
882                .debug_struct("CapabilityUnavailable")
883                .field("provider", provider)
884                .field("capability", capability)
885                .finish(),
886            Self::Timeout(timeout) => formatter.debug_tuple("Timeout").field(timeout).finish(),
887            Self::Cancelled => formatter.write_str("Cancelled"),
888            Self::Unavailable(_) => formatter.write_str("Unavailable([REDACTED])"),
889            Self::Backpressure(_) => formatter.write_str("Backpressure([REDACTED])"),
890            Self::Provider { provider, .. } => formatter
891                .debug_struct("Provider")
892                .field("provider", provider)
893                .field("message", &"[REDACTED]")
894                .finish(),
895            Self::Store(_) => formatter.write_str("Store([REDACTED])"),
896            Self::Snapshot(_) => formatter.write_str("Snapshot([REDACTED])"),
897            Self::EffectLimitExceeded(limit) => formatter
898                .debug_tuple("EffectLimitExceeded")
899                .field(limit)
900                .finish(),
901            Self::InvocationNotFound(handle) => formatter
902                .debug_tuple("InvocationNotFound")
903                .field(handle)
904                .finish(),
905            Self::Internal => formatter.write_str("Internal"),
906        }
907    }
908}
909
910impl fmt::Display for RuntimeError {
911    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
912        match self {
913            Self::InvalidDefinition(message) => {
914                write!(formatter, "invalid runtime definition: {message}")
915            }
916            Self::EndpointNotFound(endpoint) => {
917                write!(formatter, "endpoint {endpoint} is not registered")
918            }
919            Self::AgentNotFound(agent) => write!(formatter, "agent {agent} is not registered"),
920            Self::ProviderNotFound(provider) => {
921                write!(formatter, "provider {provider} is not registered")
922            }
923            Self::CapabilityUnavailable {
924                provider,
925                capability,
926            } => write!(
927                formatter,
928                "provider {provider} does not support capability {capability}"
929            ),
930            Self::Timeout(timeout_ms) => {
931                write!(formatter, "agent invocation was idle for {timeout_ms} ms")
932            }
933            Self::Cancelled => formatter.write_str("agent invocation was cancelled"),
934            Self::Unavailable(message) => {
935                write!(formatter, "provider is not available: {message}")
936            }
937            Self::Backpressure(message) => write!(formatter, "runtime is busy: {message}"),
938            Self::Provider { provider, message } => {
939                write!(formatter, "provider {provider} failed: {message}")
940            }
941            Self::Store(message) => write!(formatter, "runtime store failed: {message}"),
942            Self::Snapshot(message) => write!(formatter, "runtime snapshot failed: {message}"),
943            Self::EffectLimitExceeded(limit) => {
944                write!(formatter, "runtime effect limit {limit} was exceeded")
945            }
946            Self::InvocationNotFound(handle) => {
947                write!(formatter, "invocation {handle} was not found")
948            }
949            Self::Internal => formatter.write_str("runtime internal error"),
950        }
951    }
952}
953
954impl std::error::Error for RuntimeError {}
955
956#[derive(Default)]
957struct RuntimeRegistry {
958    providers: HashMap<String, Arc<dyn AgentProvider>>,
959    agents: HashMap<String, AgentDefinition>,
960    endpoints: HashMap<String, EndpointDefinition>,
961}
962
963struct RuntimeCore {
964    project_id: String,
965    registry: RwLock<RuntimeRegistry>,
966    manifest: RwLock<Option<RuntimeManifest>>,
967    store: Arc<dyn RuntimeStore>,
968    sessions: RwLock<HashMap<String, RuntimeSnapshot>>,
969    session_locks: Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
970    invocations: Mutex<InvocationRegistry>,
971    next_invocation: AtomicU64,
972    monitor_observer: RwLock<Option<RuntimeMonitorObserver>>,
973}
974
975struct InvocationEntry {
976    poll: InvocationPoll,
977    cancellation: CancellationToken,
978    events: VecDeque<InvocationEvent>,
979    next_event_sequence: u64,
980}
981
982#[derive(Default)]
983struct InvocationRegistry {
984    entries: HashMap<String, InvocationEntry>,
985    terminal_order: VecDeque<String>,
986    active_count: usize,
987}
988
989impl InvocationRegistry {
990    fn insert(
991        &mut self,
992        handle: InvocationHandle,
993        cancellation: CancellationToken,
994    ) -> Result<(), RuntimeError> {
995        if self.active_count >= MAX_IN_FLIGHT_INVOCATIONS {
996            return Err(RuntimeError::Backpressure(
997                "too many invocations are already running".to_string(),
998            ));
999        }
1000        self.entries.insert(
1001            handle.0.clone(),
1002            InvocationEntry {
1003                poll: InvocationPoll {
1004                    handle,
1005                    status: InvocationStatus::Pending,
1006                    output: None,
1007                    error: None,
1008                },
1009                cancellation,
1010                events: VecDeque::new(),
1011                next_event_sequence: 1,
1012            },
1013        );
1014        self.active_count += 1;
1015        Ok(())
1016    }
1017
1018    fn update(
1019        &mut self,
1020        handle: &InvocationHandle,
1021        status: InvocationStatus,
1022        output: Option<InvocationOutput>,
1023        error: Option<String>,
1024    ) {
1025        let Some(entry) = self.entries.get_mut(&handle.0) else {
1026            return;
1027        };
1028        if is_terminal_status(entry.poll.status) {
1029            return;
1030        }
1031        entry.poll.status = status;
1032        entry.poll.output = output;
1033        entry.poll.error = error;
1034        let event = match status {
1035            InvocationStatus::Pending => None,
1036            InvocationStatus::Running => Some((InvocationEventKind::Started, None, None)),
1037            InvocationStatus::Completed => Some((
1038                InvocationEventKind::Completed,
1039                entry.poll.output.as_ref().map(|value| value.data.clone()),
1040                None,
1041            )),
1042            InvocationStatus::Failed => {
1043                Some((InvocationEventKind::Failed, None, entry.poll.error.clone()))
1044            }
1045            InvocationStatus::Cancelled => Some((InvocationEventKind::Cancelled, None, None)),
1046        };
1047        if let Some((kind, data, error)) = event {
1048            entry.push_event(kind, data, error);
1049        }
1050        if is_terminal_status(status) {
1051            self.active_count = self.active_count.saturating_sub(1);
1052            self.terminal_order.push_back(handle.0.clone());
1053            self.evict_old_terminal_entries();
1054        }
1055    }
1056
1057    fn remove(&mut self, handle: &InvocationHandle) {
1058        if let Some(entry) = self.entries.remove(&handle.0) {
1059            if !is_terminal_status(entry.poll.status) {
1060                self.active_count = self.active_count.saturating_sub(1);
1061            }
1062        }
1063        self.terminal_order.retain(|stored| stored != &handle.0);
1064    }
1065
1066    fn take(&mut self, handle: &InvocationHandle) -> Result<InvocationPoll, RuntimeError> {
1067        let poll = self
1068            .entries
1069            .get(&handle.0)
1070            .map(|entry| entry.poll.clone())
1071            .ok_or_else(|| RuntimeError::InvocationNotFound(handle.0.clone()))?;
1072        if is_terminal_status(poll.status) {
1073            self.remove(handle);
1074        }
1075        Ok(poll)
1076    }
1077
1078    fn push_provider_event(&mut self, handle: &InvocationHandle, event: ProviderEvent) {
1079        let Some(entry) = self.entries.get_mut(&handle.0) else {
1080            return;
1081        };
1082        if entry.poll.status != InvocationStatus::Running {
1083            return;
1084        }
1085        match event {
1086            ProviderEvent::Activity => {}
1087            ProviderEvent::OutputDelta { data } => {
1088                entry.push_event(InvocationEventKind::OutputDelta, Some(data), None);
1089            }
1090            ProviderEvent::StageStarted { .. }
1091            | ProviderEvent::StageCompleted { .. }
1092            | ProviderEvent::StageFailed { .. } => {}
1093        }
1094    }
1095
1096    fn drain_events(
1097        &mut self,
1098        handle: &InvocationHandle,
1099    ) -> Result<Vec<InvocationEvent>, RuntimeError> {
1100        let entry = self
1101            .entries
1102            .get_mut(&handle.0)
1103            .ok_or_else(|| RuntimeError::InvocationNotFound(handle.0.clone()))?;
1104        Ok(entry.events.drain(..).collect())
1105    }
1106
1107    fn evict_old_terminal_entries(&mut self) {
1108        while self.terminal_order.len() > MAX_RETAINED_INVOCATIONS {
1109            if let Some(handle) = self.terminal_order.pop_front() {
1110                self.entries.remove(&handle);
1111            }
1112        }
1113    }
1114}
1115
1116impl InvocationEntry {
1117    fn push_event(
1118        &mut self,
1119        kind: InvocationEventKind,
1120        data: Option<InvocationData>,
1121        error: Option<String>,
1122    ) {
1123        if kind == InvocationEventKind::OutputDelta {
1124            if let (
1125                Some(InvocationEvent {
1126                    kind: InvocationEventKind::OutputDelta,
1127                    data: Some(previous),
1128                    ..
1129                }),
1130                Some(next),
1131            ) = (self.events.back_mut(), data.as_ref())
1132            {
1133                if merge_invocation_data(previous, next) {
1134                    return;
1135                }
1136            }
1137        }
1138        self.events.push_back(InvocationEvent {
1139            sequence: self.next_event_sequence,
1140            kind,
1141            data,
1142            error,
1143        });
1144        self.next_event_sequence = self.next_event_sequence.saturating_add(1);
1145        while self.events.len() > MAX_RETAINED_INVOCATION_EVENTS {
1146            self.events.pop_front();
1147        }
1148    }
1149}
1150
1151impl RuntimeCore {
1152    async fn invoke(
1153        self: &Arc<Self>,
1154        invocation_id: String,
1155        input: InvocationInput,
1156        cancellation: CancellationToken,
1157        forwarded_events: ProviderEventSink,
1158    ) -> Result<InvocationOutput, RuntimeError> {
1159        let endpoint = input.endpoint.clone();
1160        let created_at_ms = crate::unix_time_ms();
1161        let trace_id = format!("trace-{created_at_ms}-{invocation_id}");
1162        let started = Instant::now();
1163        let result = self
1164            .invoke_provider(
1165                trace_id.clone(),
1166                created_at_ms,
1167                invocation_id.clone(),
1168                input,
1169                cancellation,
1170                forwarded_events,
1171            )
1172            .await;
1173        let elapsed_ms = duration_ms(started.elapsed());
1174        let trace = match &result {
1175            Ok(output) => RuntimeTraceRecord {
1176                id: trace_id.clone(),
1177                project_id: self.project_id.clone(),
1178                invocation_id: invocation_id.clone(),
1179                endpoint: endpoint.clone(),
1180                agent: Some(output.agent.clone()),
1181                provider: Some(output.provider.clone()),
1182                capability: Some(output.capability.clone()),
1183                status: "completed".to_string(),
1184                duration_ms: elapsed_ms,
1185                created_at_ms,
1186            },
1187            Err(error) => RuntimeTraceRecord {
1188                id: trace_id.clone(),
1189                project_id: self.project_id.clone(),
1190                invocation_id: invocation_id.clone(),
1191                endpoint,
1192                agent: None,
1193                provider: None,
1194                capability: None,
1195                status: match error {
1196                    RuntimeError::Cancelled => "cancelled",
1197                    _ => "error",
1198                }
1199                .to_string(),
1200                duration_ms: elapsed_ms,
1201                created_at_ms,
1202            },
1203        };
1204        let _ = self.store.enqueue_trace(&trace);
1205        self.emit_monitor_event(RuntimeMonitorEvent::InvocationFinished {
1206            trace_id,
1207            invocation_id,
1208            status: match &result {
1209                Ok(_) => RuntimeMonitorStatus::Completed,
1210                Err(RuntimeError::Cancelled) => RuntimeMonitorStatus::Cancelled,
1211                Err(_) => RuntimeMonitorStatus::Error,
1212            },
1213            duration_ms: elapsed_ms,
1214            // Derive the terminal timestamp from the invocation start and a
1215            // monotonic duration. A wall-clock adjustment during the request
1216            // must not produce an end time before the start time.
1217            ended_at_ms: created_at_ms.saturating_add(elapsed_ms),
1218            error: result.as_ref().err().map(RuntimeError::public_message),
1219        });
1220        result
1221    }
1222
1223    async fn invoke_provider(
1224        self: &Arc<Self>,
1225        trace_id: String,
1226        started_at_ms: u64,
1227        invocation_id: String,
1228        input: InvocationInput,
1229        cancellation: CancellationToken,
1230        forwarded_events: ProviderEventSink,
1231    ) -> Result<InvocationOutput, RuntimeError> {
1232        validate_identifier("endpoint", &input.endpoint)?;
1233        validate_identifier("session", &input.session_id)?;
1234        let session_lock = {
1235            let mut locks = self
1236                .session_locks
1237                .lock()
1238                .map_err(|_| RuntimeError::Internal)?;
1239            Arc::clone(
1240                locks
1241                    .entry(input.session_id.clone())
1242                    .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))),
1243            )
1244        };
1245        let _session_guard = session_lock.lock().await;
1246        let (endpoint, agent, provider) = {
1247            let registry = self.registry.read().map_err(|_| RuntimeError::Internal)?;
1248            let endpoint = registry
1249                .endpoints
1250                .get(&input.endpoint)
1251                .cloned()
1252                .ok_or_else(|| RuntimeError::EndpointNotFound(input.endpoint.clone()))?;
1253            let agent = registry
1254                .agents
1255                .get(&endpoint.agent)
1256                .cloned()
1257                .ok_or_else(|| RuntimeError::AgentNotFound(endpoint.agent.clone()))?;
1258            let provider = registry
1259                .providers
1260                .get(&agent.provider)
1261                .cloned()
1262                .ok_or_else(|| RuntimeError::ProviderNotFound(agent.provider.clone()))?;
1263            (endpoint, agent, provider)
1264        };
1265        if !agent
1266            .capabilities
1267            .iter()
1268            .any(|capability| capability == &endpoint.capability)
1269            || !provider.supports(&endpoint.capability)
1270        {
1271            return Err(RuntimeError::CapabilityUnavailable {
1272                provider: agent.provider.clone(),
1273                capability: endpoint.capability,
1274            });
1275        }
1276        if cancellation.is_cancelled() {
1277            return Err(RuntimeError::Cancelled);
1278        }
1279
1280        self.emit_monitor_event(RuntimeMonitorEvent::InvocationStarted {
1281            trace_id: trace_id.clone(),
1282            invocation_id: invocation_id.clone(),
1283            project_id: self.project_id.clone(),
1284            endpoint: endpoint.name.clone(),
1285            agent_id: agent.id.clone(),
1286            provider_id: agent.provider.clone(),
1287            capability: endpoint.capability.clone(),
1288            started_at_ms,
1289        });
1290
1291        let snapshot = self.load_snapshot(&input.session_id)?;
1292        let request = ProviderRequest {
1293            project_id: self.project_id.clone(),
1294            endpoint: endpoint.name.clone(),
1295            session_id: input.session_id.clone(),
1296            agent: agent.clone(),
1297            capability: endpoint.capability.clone(),
1298            data: input.data,
1299            metadata: input.metadata,
1300            snapshot: snapshot.clone(),
1301        };
1302        let started = Instant::now();
1303        let (activity_sender, mut activity_receiver) = watch::channel(0_u64);
1304        let events = self.provider_event_sink(
1305            &InvocationHandle(invocation_id.clone()),
1306            trace_id,
1307            started,
1308            activity_sender,
1309            forwarded_events,
1310        );
1311        let provider_call = provider.invoke_with_events(request, cancellation.clone(), events);
1312        tokio::pin!(provider_call);
1313        let idle_timeout = Duration::from_millis(endpoint.timeout_ms);
1314        let idle_deadline = tokio::time::sleep(idle_timeout);
1315        tokio::pin!(idle_deadline);
1316        let mut activity_open = true;
1317        let response = loop {
1318            tokio::select! {
1319                biased;
1320                _ = cancellation.cancelled() => return Err(RuntimeError::Cancelled),
1321                response = &mut provider_call => break response?,
1322                changed = activity_receiver.changed(), if activity_open => {
1323                    if changed.is_err() {
1324                        activity_open = false;
1325                    } else {
1326                        idle_deadline.as_mut().reset(tokio::time::Instant::now() + idle_timeout);
1327                    }
1328                }
1329                _ = &mut idle_deadline => {
1330                    cancellation.cancel();
1331                    return Err(RuntimeError::Timeout(endpoint.timeout_ms));
1332                }
1333            }
1334        };
1335        if cancellation.is_cancelled() {
1336            return Err(RuntimeError::Cancelled);
1337        }
1338
1339        let next_snapshot = RuntimeSnapshot {
1340            revision: snapshot.revision.saturating_add(1),
1341            state: response.state.unwrap_or(snapshot.state),
1342        };
1343        self.store
1344            .save(&self.project_id, &input.session_id, &next_snapshot)?;
1345        self.sessions
1346            .write()
1347            .map_err(|_| RuntimeError::Internal)?
1348            .insert(input.session_id.clone(), next_snapshot.clone());
1349        Ok(InvocationOutput {
1350            invocation_id,
1351            project_id: self.project_id.clone(),
1352            endpoint: endpoint.name,
1353            session_id: input.session_id,
1354            agent: agent.id,
1355            provider: agent.provider,
1356            capability: endpoint.capability,
1357            data: response.data,
1358            metadata: response.metadata,
1359            snapshot: next_snapshot,
1360            trace: vec![InvocationTraceEvent {
1361                name: "provider.invoke".to_string(),
1362                status: "completed".to_string(),
1363                duration_ms: duration_ms(started.elapsed()),
1364                attributes: json!({
1365                    "endpoint": input.endpoint,
1366                }),
1367            }],
1368        })
1369    }
1370
1371    fn load_snapshot(&self, session_id: &str) -> Result<RuntimeSnapshot, RuntimeError> {
1372        if let Some(snapshot) = self
1373            .sessions
1374            .read()
1375            .map_err(|_| RuntimeError::Internal)?
1376            .get(session_id)
1377            .cloned()
1378        {
1379            return Ok(snapshot);
1380        }
1381        let snapshot = self
1382            .store
1383            .load(&self.project_id, session_id)?
1384            .unwrap_or_default();
1385        self.sessions
1386            .write()
1387            .map_err(|_| RuntimeError::Internal)?
1388            .insert(session_id.to_string(), snapshot.clone());
1389        Ok(snapshot)
1390    }
1391
1392    fn next_invocation_id(&self) -> String {
1393        let id = self.next_invocation.fetch_add(1, Ordering::Relaxed);
1394        format!("invocation-{id}")
1395    }
1396
1397    fn update_poll(
1398        &self,
1399        handle: &InvocationHandle,
1400        status: InvocationStatus,
1401        output: Option<InvocationOutput>,
1402        error: Option<String>,
1403    ) {
1404        if let Ok(mut invocations) = self.invocations.lock() {
1405            invocations.update(handle, status, output, error);
1406        }
1407    }
1408
1409    fn provider_event_sink(
1410        self: &Arc<Self>,
1411        handle: &InvocationHandle,
1412        trace_id: String,
1413        request_started: Instant,
1414        activity: watch::Sender<u64>,
1415        forwarded_events: ProviderEventSink,
1416    ) -> ProviderEventSink {
1417        let core = Arc::clone(self);
1418        let handle = handle.clone();
1419        ProviderEventSink::new(move |event| {
1420            activity.send_modify(|sequence| *sequence = sequence.saturating_add(1));
1421            (forwarded_events.emit)(event.clone());
1422            if let Ok(mut invocations) = core.invocations.lock() {
1423                invocations.push_provider_event(&handle, event.clone());
1424            }
1425            let (stage, status, elapsed_ms, error) = match event {
1426                ProviderEvent::Activity => return,
1427                ProviderEvent::OutputDelta { .. } => return,
1428                ProviderEvent::StageStarted { stage, .. } => {
1429                    (stage, RuntimeMonitorStageStatus::Started, None, None)
1430                }
1431                ProviderEvent::StageCompleted {
1432                    stage, elapsed_ms, ..
1433                } => (
1434                    stage,
1435                    RuntimeMonitorStageStatus::Completed,
1436                    Some(elapsed_ms),
1437                    None,
1438                ),
1439                ProviderEvent::StageFailed {
1440                    stage,
1441                    elapsed_ms,
1442                    error,
1443                    ..
1444                } => (
1445                    stage,
1446                    RuntimeMonitorStageStatus::Failed,
1447                    Some(elapsed_ms),
1448                    Some(error),
1449                ),
1450            };
1451            core.emit_monitor_event(RuntimeMonitorEvent::ProviderStage {
1452                trace_id: trace_id.clone(),
1453                invocation_id: handle.0.clone(),
1454                stage,
1455                status,
1456                elapsed_ms,
1457                request_elapsed_ms: duration_ms(request_started.elapsed()),
1458                error,
1459            });
1460        })
1461    }
1462
1463    fn emit_monitor_event(&self, event: RuntimeMonitorEvent) {
1464        let observer = self
1465            .monitor_observer
1466            .read()
1467            .ok()
1468            .and_then(|observer| observer.clone());
1469        if let Some(observer) = observer {
1470            observer(event);
1471        }
1472    }
1473}
1474
1475fn is_null(value: &Value) -> bool {
1476    value.is_null()
1477}
1478
1479fn merge_invocation_data(previous: &mut InvocationData, next: &InvocationData) -> bool {
1480    match (previous, next) {
1481        (
1482            InvocationData::Json(Value::String(previous)),
1483            InvocationData::Json(Value::String(next)),
1484        ) if previous.len().saturating_add(next.len()) <= MAX_COALESCED_EVENT_BYTES => {
1485            previous.push_str(next);
1486            true
1487        }
1488        (InvocationData::Binary(previous), InvocationData::Binary(next))
1489            if previous.len().saturating_add(next.len()) <= MAX_COALESCED_EVENT_BYTES =>
1490        {
1491            previous.extend_from_slice(next);
1492            true
1493        }
1494        _ => false,
1495    }
1496}
1497
1498enum WorkerCommand {
1499    Start {
1500        handle: InvocationHandle,
1501        input: InvocationInput,
1502        cancellation: CancellationToken,
1503    },
1504}
1505
1506struct RuntimeWorker {
1507    sender: Mutex<Option<mpsc::Sender<WorkerCommand>>>,
1508    thread: Mutex<Option<JoinHandle<()>>>,
1509}
1510
1511impl RuntimeWorker {
1512    fn spawn(core: Arc<RuntimeCore>) -> Result<Self, RuntimeError> {
1513        let (sender, mut receiver) = mpsc::channel(WORKER_QUEUE_CAPACITY);
1514        let thread = std::thread::Builder::new()
1515            .name(format!("vifu-runtime-{}", core.project_id))
1516            .spawn(move || {
1517                let Ok(runtime) = tokio::runtime::Builder::new_current_thread()
1518                    .enable_time()
1519                    .build()
1520                else {
1521                    return;
1522                };
1523                runtime.block_on(async move {
1524                    while let Some(command) = receiver.recv().await {
1525                        match command {
1526                            WorkerCommand::Start {
1527                                handle,
1528                                input,
1529                                cancellation,
1530                            } => {
1531                                let invocation_core = Arc::clone(&core);
1532                                tokio::spawn(async move {
1533                                    invocation_core.update_poll(
1534                                        &handle,
1535                                        InvocationStatus::Running,
1536                                        None,
1537                                        None,
1538                                    );
1539                                    let result = invocation_core
1540                                        .invoke(
1541                                            handle.0.clone(),
1542                                            input,
1543                                            cancellation,
1544                                            ProviderEventSink::discard(),
1545                                        )
1546                                        .await;
1547                                    match result {
1548                                        Ok(output) => invocation_core.update_poll(
1549                                            &handle,
1550                                            InvocationStatus::Completed,
1551                                            Some(output),
1552                                            None,
1553                                        ),
1554                                        Err(RuntimeError::Cancelled) => invocation_core
1555                                            .update_poll(
1556                                                &handle,
1557                                                InvocationStatus::Cancelled,
1558                                                None,
1559                                                None,
1560                                            ),
1561                                        Err(error) => invocation_core.update_poll(
1562                                            &handle,
1563                                            InvocationStatus::Failed,
1564                                            None,
1565                                            Some(error.public_message()),
1566                                        ),
1567                                    }
1568                                });
1569                            }
1570                        }
1571                    }
1572                });
1573            })
1574            .map_err(|_error| RuntimeError::Internal)?;
1575        Ok(Self {
1576            sender: Mutex::new(Some(sender)),
1577            thread: Mutex::new(Some(thread)),
1578        })
1579    }
1580
1581    fn send(&self, command: WorkerCommand) -> Result<(), RuntimeError> {
1582        self.sender
1583            .lock()
1584            .map_err(|_| RuntimeError::Internal)?
1585            .as_ref()
1586            .ok_or(RuntimeError::Internal)?
1587            .try_send(command)
1588            .map_err(|error| match error {
1589                mpsc::error::TrySendError::Full(_) => {
1590                    RuntimeError::Backpressure("invocation queue is full".to_string())
1591                }
1592                mpsc::error::TrySendError::Closed(_) => RuntimeError::Internal,
1593            })
1594    }
1595}
1596
1597impl Drop for RuntimeWorker {
1598    fn drop(&mut self) {
1599        if let Ok(sender) = self.sender.get_mut() {
1600            sender.take();
1601        }
1602        if let Ok(thread) = self.thread.get_mut() {
1603            if let Some(thread) = thread.take() {
1604                let _ = thread.join();
1605            }
1606        }
1607    }
1608}
1609
1610/// A self-contained runtime for one application or project.
1611///
1612/// One runtime may register multiple providers, agents, and stable named
1613/// endpoints. It can run directly inside a Rust host; Vifu Server and Agent
1614/// Gateway are optional deployment components.
1615#[derive(Clone)]
1616pub struct VifuRuntime {
1617    core: Arc<RuntimeCore>,
1618    worker: Arc<Mutex<Option<RuntimeWorker>>>,
1619}
1620
1621impl fmt::Debug for VifuRuntime {
1622    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1623        let counts = self.core.registry.read().ok().map(|registry| {
1624            (
1625                registry.providers.len(),
1626                registry.agents.len(),
1627                registry.endpoints.len(),
1628            )
1629        });
1630        formatter
1631            .debug_struct("VifuRuntime")
1632            .field("project_id", &self.core.project_id)
1633            .field("resource_counts", &counts)
1634            .finish()
1635    }
1636}
1637
1638impl VifuRuntime {
1639    pub fn new(project_id: impl Into<String>) -> Result<Self, RuntimeError> {
1640        Self::with_store(project_id, Arc::new(MemoryRuntimeStore::default()))
1641    }
1642
1643    pub fn with_store(
1644        project_id: impl Into<String>,
1645        store: Arc<dyn RuntimeStore>,
1646    ) -> Result<Self, RuntimeError> {
1647        let project_id = project_id.into();
1648        validate_identifier("project", &project_id)?;
1649        let core = Arc::new(RuntimeCore {
1650            project_id,
1651            registry: RwLock::new(RuntimeRegistry::default()),
1652            manifest: RwLock::new(None),
1653            store,
1654            sessions: RwLock::new(HashMap::new()),
1655            session_locks: Mutex::new(HashMap::new()),
1656            invocations: Mutex::new(InvocationRegistry::default()),
1657            next_invocation: AtomicU64::new(1),
1658            monitor_observer: RwLock::new(None),
1659        });
1660        let worker = Arc::new(Mutex::new(None));
1661        Ok(Self { core, worker })
1662    }
1663
1664    pub fn project_id(&self) -> &str {
1665        &self.core.project_id
1666    }
1667
1668    /// Installs or clears the payload-safe runtime lifecycle observer.
1669    pub fn set_monitor_observer(
1670        &self,
1671        observer: Option<RuntimeMonitorObserver>,
1672    ) -> Result<(), RuntimeError> {
1673        *self
1674            .core
1675            .monitor_observer
1676            .write()
1677            .map_err(|_| RuntimeError::Internal)? = observer;
1678        Ok(())
1679    }
1680
1681    pub fn register_provider(
1682        &self,
1683        name: impl Into<String>,
1684        provider: Arc<dyn AgentProvider>,
1685    ) -> Result<(), RuntimeError> {
1686        let name = name.into();
1687        validate_identifier("provider", &name)?;
1688        self.core
1689            .registry
1690            .write()
1691            .map_err(|_| RuntimeError::Internal)?
1692            .providers
1693            .insert(name, provider);
1694        Ok(())
1695    }
1696
1697    pub fn register_agent(&self, mut agent: AgentDefinition) -> Result<(), RuntimeError> {
1698        validate_identifier("agent", &agent.id)?;
1699        validate_identifier("provider", &agent.provider)?;
1700        if agent.name.trim().is_empty() || agent.capabilities.is_empty() {
1701            return Err(RuntimeError::InvalidDefinition(
1702                "agent name and at least one capability are required".to_string(),
1703            ));
1704        }
1705        for capability in &mut agent.capabilities {
1706            *capability = capability.trim().to_ascii_lowercase();
1707            validate_identifier("capability", capability)?;
1708        }
1709        agent.capabilities.sort();
1710        agent.capabilities.dedup();
1711        let mut registry = self
1712            .core
1713            .registry
1714            .write()
1715            .map_err(|_| RuntimeError::Internal)?;
1716        if !registry.providers.contains_key(&agent.provider) {
1717            return Err(RuntimeError::ProviderNotFound(agent.provider));
1718        }
1719        registry.agents.insert(agent.id.clone(), agent);
1720        Ok(())
1721    }
1722
1723    pub fn register_endpoint(&self, mut endpoint: EndpointDefinition) -> Result<(), RuntimeError> {
1724        validate_identifier("endpoint", &endpoint.name)?;
1725        validate_identifier("agent", &endpoint.agent)?;
1726        endpoint.capability = endpoint.capability.trim().to_ascii_lowercase();
1727        validate_identifier("capability", &endpoint.capability)?;
1728        if !(1..=MAX_TIMEOUT_MS).contains(&endpoint.timeout_ms) {
1729            return Err(RuntimeError::InvalidDefinition(format!(
1730                "endpoint timeout must be between 1 and {MAX_TIMEOUT_MS} ms"
1731            )));
1732        }
1733        let mut registry = self
1734            .core
1735            .registry
1736            .write()
1737            .map_err(|_| RuntimeError::Internal)?;
1738        let agent = registry
1739            .agents
1740            .get(&endpoint.agent)
1741            .ok_or_else(|| RuntimeError::AgentNotFound(endpoint.agent.clone()))?;
1742        if !agent
1743            .capabilities
1744            .iter()
1745            .any(|capability| capability == &endpoint.capability)
1746        {
1747            return Err(RuntimeError::CapabilityUnavailable {
1748                provider: agent.provider.clone(),
1749                capability: endpoint.capability,
1750            });
1751        }
1752        registry.endpoints.insert(endpoint.name.clone(), endpoint);
1753        Ok(())
1754    }
1755
1756    pub fn agent_definitions(&self) -> Result<Vec<AgentDefinition>, RuntimeError> {
1757        let mut agents = self
1758            .core
1759            .registry
1760            .read()
1761            .map_err(|_| RuntimeError::Internal)?
1762            .agents
1763            .values()
1764            .cloned()
1765            .collect::<Vec<_>>();
1766        agents.sort_by(|left, right| left.id.cmp(&right.id));
1767        Ok(agents)
1768    }
1769
1770    pub fn endpoint_definitions(&self) -> Result<Vec<EndpointDefinition>, RuntimeError> {
1771        let mut endpoints = self
1772            .core
1773            .registry
1774            .read()
1775            .map_err(|_| RuntimeError::Internal)?
1776            .endpoints
1777            .values()
1778            .cloned()
1779            .collect::<Vec<_>>();
1780        endpoints.sort_by(|left, right| left.name.cmp(&right.name));
1781        Ok(endpoints)
1782    }
1783
1784    /// Replaces the portable agent and endpoint graph with a validated manifest.
1785    /// Provider implementations must be registered locally before activation.
1786    pub fn apply_manifest(&self, manifest: RuntimeManifest) -> Result<(), RuntimeError> {
1787        manifest.validate()?;
1788        if manifest.project_id != self.core.project_id {
1789            return Err(RuntimeError::InvalidDefinition(
1790                "project settings belong to another project".to_string(),
1791            ));
1792        }
1793        let mut registry = self
1794            .core
1795            .registry
1796            .write()
1797            .map_err(|_| RuntimeError::Internal)?;
1798        for requirement in &manifest.providers {
1799            let provider = registry
1800                .providers
1801                .get(&requirement.id)
1802                .ok_or_else(|| RuntimeError::ProviderNotFound(requirement.id.clone()))?;
1803            for capability in &requirement.capabilities {
1804                if !provider.supports(capability) {
1805                    return Err(RuntimeError::CapabilityUnavailable {
1806                        provider: requirement.id.clone(),
1807                        capability: capability.clone(),
1808                    });
1809                }
1810            }
1811        }
1812        registry.agents = manifest
1813            .agents
1814            .iter()
1815            .cloned()
1816            .map(|agent| (agent.id.clone(), agent))
1817            .collect();
1818        registry.endpoints = manifest
1819            .endpoints
1820            .iter()
1821            .cloned()
1822            .map(|endpoint| (endpoint.name.clone(), endpoint))
1823            .collect();
1824        *self
1825            .core
1826            .manifest
1827            .write()
1828            .map_err(|_| RuntimeError::Internal)? = Some(manifest);
1829        Ok(())
1830    }
1831
1832    pub fn apply_project_settings(&self, settings: ProjectSettings) -> Result<(), RuntimeError> {
1833        self.apply_manifest(settings)
1834    }
1835
1836    pub fn current_manifest(&self) -> Result<Option<RuntimeManifest>, RuntimeError> {
1837        Ok(self
1838            .core
1839            .manifest
1840            .read()
1841            .map_err(|_| RuntimeError::Internal)?
1842            .clone())
1843    }
1844
1845    pub fn current_project_settings(&self) -> Result<Option<ProjectSettings>, RuntimeError> {
1846        self.current_manifest()
1847    }
1848
1849    pub fn install_release(&self, release: &RuntimeRelease) -> Result<(), RuntimeError> {
1850        release.validate()?;
1851        if release.manifest.project_id != self.core.project_id {
1852            return Err(RuntimeError::InvalidDefinition(
1853                "runtime release belongs to another project".to_string(),
1854            ));
1855        }
1856        self.core.store.save_release(release)
1857    }
1858
1859    pub fn releases(&self) -> Result<Vec<RuntimeRelease>, RuntimeError> {
1860        self.core.store.list_releases(&self.core.project_id)
1861    }
1862
1863    pub fn active_release_version(&self) -> Result<Option<u64>, RuntimeError> {
1864        self.core.store.active_release(&self.core.project_id)
1865    }
1866
1867    pub fn activate_release(&self, version: u64) -> Result<RuntimeRelease, RuntimeError> {
1868        let release = self
1869            .core
1870            .store
1871            .load_release(&self.core.project_id, version)?
1872            .ok_or_else(|| RuntimeError::store("runtime release was not found"))?;
1873        self.apply_manifest(release.manifest.clone())?;
1874        self.core
1875            .store
1876            .set_active_release(&self.core.project_id, version)?;
1877        Ok(release)
1878    }
1879
1880    pub fn restore_active_release(&self) -> Result<Option<RuntimeRelease>, RuntimeError> {
1881        self.active_release_version()?
1882            .map(|version| self.activate_release(version))
1883            .transpose()
1884    }
1885
1886    pub fn bootstrap_release(
1887        &self,
1888        manifest: RuntimeManifest,
1889    ) -> Result<RuntimeRelease, RuntimeError> {
1890        if let Some(active) = self.restore_active_release()? {
1891            return Ok(active);
1892        }
1893        let release = RuntimeRelease::new(1, manifest)?;
1894        self.install_release(&release)?;
1895        self.activate_release(release.version)
1896    }
1897
1898    pub fn bootstrap_project_settings(
1899        &self,
1900        settings: ProjectSettings,
1901    ) -> Result<RuntimeRelease, RuntimeError> {
1902        self.bootstrap_release(settings)
1903    }
1904
1905    pub fn save_local_provider_binding(
1906        &self,
1907        binding: &LocalProviderBinding,
1908    ) -> Result<(), RuntimeError> {
1909        validate_identifier("provider", &binding.provider_id)?;
1910        self.core
1911            .store
1912            .save_local_provider_binding(&self.core.project_id, binding)
1913    }
1914
1915    pub fn local_provider_bindings(&self) -> Result<Vec<LocalProviderBinding>, RuntimeError> {
1916        self.core
1917            .store
1918            .local_provider_bindings(&self.core.project_id)
1919    }
1920
1921    pub fn pending_traces(&self, limit: usize) -> Result<Vec<RuntimeTraceRecord>, RuntimeError> {
1922        self.core.store.pending_traces(limit.min(1_000))
1923    }
1924
1925    pub fn acknowledge_traces(&self, trace_ids: &[String]) -> Result<(), RuntimeError> {
1926        self.core.store.acknowledge_traces(trace_ids)
1927    }
1928
1929    pub fn session(&self, session_id: impl Into<String>) -> Result<RuntimeSession, RuntimeError> {
1930        let session_id = session_id.into();
1931        validate_identifier("session", &session_id)?;
1932        Ok(RuntimeSession {
1933            runtime: self.clone(),
1934            session_id,
1935        })
1936    }
1937
1938    pub async fn invoke(&self, input: InvocationInput) -> Result<InvocationOutput, RuntimeError> {
1939        self.invoke_with_cancellation(input, CancellationToken::default())
1940            .await
1941    }
1942
1943    /// Invokes an endpoint while honoring a cancellation signal owned by the
1944    /// embedding host.
1945    pub async fn invoke_with_cancellation(
1946        &self,
1947        input: InvocationInput,
1948        cancellation: CancellationToken,
1949    ) -> Result<InvocationOutput, RuntimeError> {
1950        self.invoke_with_events_and_cancellation(input, cancellation, ProviderEventSink::discard())
1951            .await
1952    }
1953
1954    /// Invokes an endpoint while forwarding real provider progress to an
1955    /// embedding host and honoring the host's cancellation signal.
1956    pub async fn invoke_with_events_and_cancellation(
1957        &self,
1958        input: InvocationInput,
1959        cancellation: CancellationToken,
1960        events: ProviderEventSink,
1961    ) -> Result<InvocationOutput, RuntimeError> {
1962        let invocation_id = self.core.next_invocation_id();
1963        self.core
1964            .invoke(invocation_id, input, cancellation, events)
1965            .await
1966    }
1967
1968    pub fn start_invoke(&self, input: InvocationInput) -> Result<InvocationHandle, RuntimeError> {
1969        validate_identifier("endpoint", &input.endpoint)?;
1970        validate_identifier("session", &input.session_id)?;
1971        let handle = InvocationHandle(self.core.next_invocation_id());
1972        let cancellation = CancellationToken::default();
1973        let mut worker = self.worker.lock().map_err(|_| RuntimeError::Internal)?;
1974        if worker.is_none() {
1975            *worker = Some(RuntimeWorker::spawn(Arc::clone(&self.core))?);
1976        }
1977        self.core
1978            .invocations
1979            .lock()
1980            .map_err(|_| RuntimeError::Internal)?
1981            .insert(handle.clone(), cancellation.clone())?;
1982        let send_result =
1983            worker
1984                .as_ref()
1985                .ok_or(RuntimeError::Internal)?
1986                .send(WorkerCommand::Start {
1987                    handle: handle.clone(),
1988                    input,
1989                    cancellation,
1990                });
1991        if let Err(error) = send_result {
1992            self.core
1993                .invocations
1994                .lock()
1995                .map_err(|_| RuntimeError::Internal)?
1996                .remove(&handle);
1997            return Err(error);
1998        }
1999        Ok(handle)
2000    }
2001
2002    pub fn poll_invocation(
2003        &self,
2004        handle: &InvocationHandle,
2005    ) -> Result<InvocationPoll, RuntimeError> {
2006        self.core
2007            .invocations
2008            .lock()
2009            .map_err(|_| RuntimeError::Internal)?
2010            .entries
2011            .get(&handle.0)
2012            .map(|entry| entry.poll.clone())
2013            .ok_or_else(|| RuntimeError::InvocationNotFound(handle.0.clone()))
2014    }
2015
2016    /// Drains incremental events produced since the previous call.
2017    pub fn drain_invocation_events(
2018        &self,
2019        handle: &InvocationHandle,
2020    ) -> Result<Vec<InvocationEvent>, RuntimeError> {
2021        self.core
2022            .invocations
2023            .lock()
2024            .map_err(|_| RuntimeError::Internal)?
2025            .drain_events(handle)
2026    }
2027
2028    /// Returns the current poll state and removes it once it is terminal.
2029    ///
2030    /// Pending and running invocations remain registered so callers can keep
2031    /// polling the same handle.
2032    pub fn take_invocation(
2033        &self,
2034        handle: &InvocationHandle,
2035    ) -> Result<InvocationPoll, RuntimeError> {
2036        self.core
2037            .invocations
2038            .lock()
2039            .map_err(|_| RuntimeError::Internal)?
2040            .take(handle)
2041    }
2042
2043    pub fn cancel_invocation(&self, handle: &InvocationHandle) -> Result<(), RuntimeError> {
2044        let cancellation = self
2045            .core
2046            .invocations
2047            .lock()
2048            .map_err(|_| RuntimeError::Internal)?
2049            .entries
2050            .get(&handle.0)
2051            .map(|entry| entry.cancellation.clone())
2052            .ok_or_else(|| RuntimeError::InvocationNotFound(handle.0.clone()))?;
2053        cancellation.cancel();
2054        self.core
2055            .update_poll(handle, InvocationStatus::Cancelled, None, None);
2056        Ok(())
2057    }
2058
2059    pub async fn execute_effects(
2060        &self,
2061        effects: Vec<EffectRequest>,
2062    ) -> Result<EffectExecution, RuntimeError> {
2063        self.execute_effects_with_limit(effects, DEFAULT_EFFECT_LIMIT)
2064            .await
2065    }
2066
2067    pub async fn execute_effects_with_limit(
2068        &self,
2069        effects: Vec<EffectRequest>,
2070        limit: usize,
2071    ) -> Result<EffectExecution, RuntimeError> {
2072        if effects.len() > limit {
2073            return Err(RuntimeError::EffectLimitExceeded(limit));
2074        }
2075        let mut results = Vec::new();
2076        let mut unhandled = Vec::new();
2077        for effect in effects {
2078            if effect.kind != "agent.invoke" {
2079                unhandled.push(effect);
2080                continue;
2081            }
2082            let input = serde_json::from_value::<InvocationInput>(effect.payload.clone())
2083                .map_err(|error| RuntimeError::InvalidDefinition(error.to_string()))?;
2084            let result = self.invoke(input).await;
2085            match result {
2086                Ok(output) => results.push(EffectResult {
2087                    effect_id: effect.id,
2088                    succeeded: true,
2089                    output: serde_json::to_value(output)
2090                        .map_err(|_error| RuntimeError::Internal)?,
2091                }),
2092                Err(error) => results.push(EffectResult {
2093                    effect_id: effect.id,
2094                    succeeded: false,
2095                    output: json!({ "error": error.public_message() }),
2096                }),
2097            }
2098        }
2099        Ok(EffectExecution { results, unhandled })
2100    }
2101
2102    pub fn export_snapshot(&self) -> Result<Vec<u8>, RuntimeError> {
2103        let snapshot = PortableProjectSnapshot {
2104            version: SNAPSHOT_VERSION,
2105            project_id: self.core.project_id.clone(),
2106            sessions: self
2107                .core
2108                .sessions
2109                .read()
2110                .map_err(|_| RuntimeError::Internal)?
2111                .clone(),
2112        };
2113        serde_json::to_vec(&snapshot).map_err(|error| RuntimeError::Snapshot(error.to_string()))
2114    }
2115
2116    pub fn restore_snapshot(&self, bytes: &[u8]) -> Result<(), RuntimeError> {
2117        let snapshot = serde_json::from_slice::<PortableProjectSnapshot>(bytes)
2118            .map_err(|error| RuntimeError::Snapshot(error.to_string()))?;
2119        if snapshot.version != SNAPSHOT_VERSION || snapshot.project_id != self.core.project_id {
2120            return Err(RuntimeError::Snapshot(
2121                "snapshot version or project does not match".to_string(),
2122            ));
2123        }
2124        for (session_id, state) in &snapshot.sessions {
2125            validate_identifier("session", session_id)?;
2126            self.core
2127                .store
2128                .save(&self.core.project_id, session_id, state)?;
2129        }
2130        *self
2131            .core
2132            .sessions
2133            .write()
2134            .map_err(|_| RuntimeError::Internal)? = snapshot.sessions;
2135        Ok(())
2136    }
2137}
2138
2139/// A session-scoped view over [`VifuRuntime`].
2140#[derive(Clone, Debug)]
2141pub struct RuntimeSession {
2142    runtime: VifuRuntime,
2143    session_id: String,
2144}
2145
2146impl RuntimeSession {
2147    pub fn id(&self) -> &str {
2148        &self.session_id
2149    }
2150
2151    pub async fn invoke(
2152        &self,
2153        mut input: InvocationInput,
2154    ) -> Result<InvocationOutput, RuntimeError> {
2155        input.session_id.clone_from(&self.session_id);
2156        self.runtime.invoke(input).await
2157    }
2158
2159    pub fn start_invoke(
2160        &self,
2161        mut input: InvocationInput,
2162    ) -> Result<InvocationHandle, RuntimeError> {
2163        input.session_id.clone_from(&self.session_id);
2164        self.runtime.start_invoke(input)
2165    }
2166}
2167
2168#[derive(Serialize, Deserialize)]
2169#[serde(rename_all = "camelCase")]
2170struct PortableProjectSnapshot {
2171    version: u32,
2172    project_id: String,
2173    sessions: HashMap<String, RuntimeSnapshot>,
2174}
2175
2176fn default_session_id() -> String {
2177    "default".to_string()
2178}
2179
2180const fn default_timeout_ms() -> u64 {
2181    DEFAULT_TIMEOUT_MS
2182}
2183
2184fn validate_identifier(kind: &str, value: &str) -> Result<(), RuntimeError> {
2185    if value.is_empty()
2186        || value.len() > 128
2187        || !value
2188            .bytes()
2189            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':'))
2190    {
2191        return Err(RuntimeError::InvalidDefinition(format!(
2192            "{kind} must be a portable identifier"
2193        )));
2194    }
2195    Ok(())
2196}
2197
2198fn duration_ms(duration: Duration) -> u64 {
2199    u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
2200}
2201
2202const fn is_terminal_status(status: InvocationStatus) -> bool {
2203    matches!(
2204        status,
2205        InvocationStatus::Completed | InvocationStatus::Failed | InvocationStatus::Cancelled
2206    )
2207}
2208
2209#[cfg(test)]
2210mod tests {
2211    use super::*;
2212
2213    struct TestProvider {
2214        fail: bool,
2215        delay: Duration,
2216    }
2217
2218    impl TestProvider {
2219        fn immediate() -> Self {
2220            Self {
2221                fail: false,
2222                delay: Duration::ZERO,
2223            }
2224        }
2225    }
2226
2227    impl AgentProvider for TestProvider {
2228        fn supports(&self, capability: &str) -> bool {
2229            matches!(capability, "chat" | "speech" | "transcription")
2230        }
2231
2232        fn invoke<'a>(
2233            &'a self,
2234            request: ProviderRequest,
2235            cancellation: CancellationToken,
2236        ) -> ProviderFuture<'a> {
2237            Box::pin(async move {
2238                if !self.delay.is_zero() {
2239                    tokio::select! {
2240                        _ = tokio::time::sleep(self.delay) => {}
2241                        _ = cancellation.cancelled() => {
2242                            return Err(RuntimeError::Cancelled);
2243                        }
2244                    }
2245                }
2246                if self.fail {
2247                    return Err(RuntimeError::provider(
2248                        request.agent.provider,
2249                        "synthetic provider failure",
2250                    ));
2251                }
2252                Ok(ProviderResponse {
2253                    data: match request.data {
2254                        InvocationData::Json(data) => InvocationData::Json(json!({
2255                            "capability": request.capability,
2256                            "input": data,
2257                        })),
2258                        InvocationData::Binary(bytes) => InvocationData::Binary(bytes),
2259                    },
2260                    metadata: json!({}),
2261                    state: Some(json!({
2262                        "lastEndpoint": request.endpoint,
2263                        "previousRevision": request.snapshot.revision,
2264                    })),
2265                })
2266            })
2267        }
2268    }
2269
2270    struct StreamingTestProvider;
2271
2272    impl AgentProvider for StreamingTestProvider {
2273        fn supports(&self, capability: &str) -> bool {
2274            capability == "chat"
2275        }
2276
2277        fn invoke<'a>(
2278            &'a self,
2279            request: ProviderRequest,
2280            cancellation: CancellationToken,
2281        ) -> ProviderFuture<'a> {
2282            self.invoke_with_events(request, cancellation, ProviderEventSink::discard())
2283        }
2284
2285        fn invoke_with_events<'a>(
2286            &'a self,
2287            _request: ProviderRequest,
2288            cancellation: CancellationToken,
2289            events: ProviderEventSink,
2290        ) -> ProviderFuture<'a> {
2291            Box::pin(async move {
2292                if cancellation.is_cancelled() {
2293                    return Err(RuntimeError::Cancelled);
2294                }
2295                events.stage_started(ProviderStage::Tokenize, Value::Null);
2296                events.stage_completed(ProviderStage::Tokenize, 2, json!({ "inputTokens": 4 }));
2297                events.output_delta(InvocationData::Json(Value::String("Hello".to_string())));
2298                events.output_delta(InvocationData::Json(Value::String(", world".to_string())));
2299                Ok(ProviderResponse::json(json!({ "text": "Hello, world" })))
2300            })
2301        }
2302    }
2303
2304    struct ActiveSlowProvider;
2305
2306    impl AgentProvider for ActiveSlowProvider {
2307        fn supports(&self, capability: &str) -> bool {
2308            capability == "chat"
2309        }
2310
2311        fn invoke<'a>(
2312            &'a self,
2313            request: ProviderRequest,
2314            cancellation: CancellationToken,
2315        ) -> ProviderFuture<'a> {
2316            self.invoke_with_events(request, cancellation, ProviderEventSink::discard())
2317        }
2318
2319        fn invoke_with_events<'a>(
2320            &'a self,
2321            _request: ProviderRequest,
2322            cancellation: CancellationToken,
2323            events: ProviderEventSink,
2324        ) -> ProviderFuture<'a> {
2325            Box::pin(async move {
2326                for _ in 0..4 {
2327                    tokio::select! {
2328                        _ = tokio::time::sleep(Duration::from_millis(8)) => events.activity(),
2329                        _ = cancellation.cancelled() => return Err(RuntimeError::Cancelled),
2330                    }
2331                }
2332                Ok(ProviderResponse::json(json!({ "ok": true })))
2333            })
2334        }
2335    }
2336
2337    fn configured_runtime(provider: Arc<dyn AgentProvider>) -> VifuRuntime {
2338        let runtime = VifuRuntime::new("test-project").expect("runtime should start");
2339        runtime
2340            .register_provider("test-provider", provider)
2341            .expect("provider should register");
2342        runtime
2343            .register_agent(AgentDefinition {
2344                id: "guide".to_string(),
2345                name: "Guide".to_string(),
2346                provider: "test-provider".to_string(),
2347                capabilities: vec![
2348                    "chat".to_string(),
2349                    "speech".to_string(),
2350                    "transcription".to_string(),
2351                ],
2352                metadata: json!({ "public": true }),
2353            })
2354            .expect("agent should register");
2355        for capability in ["chat", "speech", "transcription"] {
2356            runtime
2357                .register_endpoint(EndpointDefinition {
2358                    name: capability.to_string(),
2359                    agent: "guide".to_string(),
2360                    capability: capability.to_string(),
2361                    timeout_ms: 500,
2362                })
2363                .expect("endpoint should register");
2364        }
2365        runtime
2366    }
2367
2368    #[tokio::test(flavor = "current_thread")]
2369    async fn embedded_runtime_invokes_chat_speech_and_transcription_without_a_server() {
2370        let runtime = configured_runtime(Arc::new(TestProvider::immediate()));
2371
2372        for capability in ["chat", "speech", "transcription"] {
2373            let output = runtime
2374                .invoke(InvocationInput::json(
2375                    capability,
2376                    json!({ "message": capability }),
2377                ))
2378                .await
2379                .expect("endpoint should invoke");
2380            assert_eq!(output.capability, capability);
2381        }
2382    }
2383
2384    #[tokio::test(flavor = "current_thread")]
2385    async fn runtime_sessions_keep_independent_durable_state() {
2386        let runtime = configured_runtime(Arc::new(TestProvider::immediate()));
2387        let first = runtime
2388            .session("player-one")
2389            .expect("first session should open");
2390        let second = runtime
2391            .session("player-two")
2392            .expect("second session should open");
2393
2394        let first_output = first
2395            .invoke(InvocationInput::json("chat", json!({ "text": "one" })))
2396            .await
2397            .expect("first session should invoke");
2398        let second_output = second
2399            .invoke(InvocationInput::json("chat", json!({ "text": "two" })))
2400            .await
2401            .expect("second session should invoke");
2402
2403        assert_eq!(first_output.snapshot.revision, 1);
2404        assert_eq!(second_output.snapshot.revision, 1);
2405    }
2406
2407    #[tokio::test(flavor = "current_thread")]
2408    async fn concurrent_calls_serialize_state_updates_for_one_session() {
2409        let runtime = configured_runtime(Arc::new(TestProvider {
2410            fail: false,
2411            delay: Duration::from_millis(5),
2412        }));
2413        let first = runtime.invoke(
2414            InvocationInput::json("chat", json!({ "text": "one" })).with_session("shared-session"),
2415        );
2416        let second = runtime.invoke(
2417            InvocationInput::json("chat", json!({ "text": "two" })).with_session("shared-session"),
2418        );
2419
2420        let (first, second) = tokio::join!(first, second);
2421        let mut revisions = [
2422            first
2423                .expect("first invocation should complete")
2424                .snapshot
2425                .revision,
2426            second
2427                .expect("second invocation should complete")
2428                .snapshot
2429                .revision,
2430        ];
2431        revisions.sort_unstable();
2432        assert_eq!(revisions, [1, 2]);
2433    }
2434
2435    #[tokio::test(flavor = "current_thread")]
2436    async fn runtime_round_trips_binary_provider_results() {
2437        let runtime = configured_runtime(Arc::new(TestProvider::immediate()));
2438        let output = runtime
2439            .invoke(InvocationInput {
2440                endpoint: "speech".to_string(),
2441                session_id: "audio-session".to_string(),
2442                data: InvocationData::Binary(vec![1, 2, 3, 4]),
2443                metadata: json!({}),
2444            })
2445            .await
2446            .expect("binary invocation should complete");
2447
2448        assert_eq!(output.data, InvocationData::Binary(vec![1, 2, 3, 4]));
2449    }
2450
2451    #[tokio::test(flavor = "current_thread")]
2452    async fn runtime_times_out_slow_providers() {
2453        let runtime = VifuRuntime::new("timeout-project").expect("runtime should start");
2454        runtime
2455            .register_provider(
2456                "slow",
2457                Arc::new(TestProvider {
2458                    fail: false,
2459                    delay: Duration::from_millis(100),
2460                }),
2461            )
2462            .expect("provider should register");
2463        runtime
2464            .register_agent(AgentDefinition {
2465                id: "slow-agent".to_string(),
2466                name: "Slow agent".to_string(),
2467                provider: "slow".to_string(),
2468                capabilities: vec!["chat".to_string()],
2469                metadata: json!({}),
2470            })
2471            .expect("agent should register");
2472        runtime
2473            .register_endpoint(EndpointDefinition {
2474                name: "slow-chat".to_string(),
2475                agent: "slow-agent".to_string(),
2476                capability: "chat".to_string(),
2477                timeout_ms: 10,
2478            })
2479            .expect("endpoint should register");
2480
2481        let error = runtime
2482            .invoke(InvocationInput::json("slow-chat", json!({})))
2483            .await
2484            .expect_err("slow invocation should time out");
2485        assert!(matches!(error, RuntimeError::Timeout(10)));
2486    }
2487
2488    #[tokio::test(flavor = "current_thread")]
2489    async fn provider_activity_resets_the_runtime_idle_timeout() {
2490        let runtime = VifuRuntime::new("active-project").expect("runtime should start");
2491        runtime
2492            .register_provider("active", Arc::new(ActiveSlowProvider))
2493            .expect("provider should register");
2494        runtime
2495            .register_agent(AgentDefinition {
2496                id: "active-agent".to_string(),
2497                name: "Active agent".to_string(),
2498                provider: "active".to_string(),
2499                capabilities: vec!["chat".to_string()],
2500                metadata: json!({}),
2501            })
2502            .expect("agent should register");
2503        runtime
2504            .register_endpoint(EndpointDefinition {
2505                name: "active-chat".to_string(),
2506                agent: "active-agent".to_string(),
2507                capability: "chat".to_string(),
2508                timeout_ms: 10,
2509            })
2510            .expect("endpoint should register");
2511
2512        let output = runtime
2513            .invoke(InvocationInput::json("active-chat", json!({})))
2514            .await
2515            .expect("ongoing provider activity should renew the idle timeout");
2516        assert_eq!(output.data, InvocationData::Json(json!({ "ok": true })));
2517    }
2518
2519    #[test]
2520    fn game_loop_api_starts_polls_and_cancels_invocations() {
2521        let runtime = configured_runtime(Arc::new(TestProvider {
2522            fail: false,
2523            delay: Duration::from_secs(5),
2524        }));
2525        let handle = runtime
2526            .start_invoke(InvocationInput::json("chat", json!({})))
2527            .expect("invocation should start");
2528        let running_deadline = Instant::now() + Duration::from_secs(1);
2529        loop {
2530            let poll = runtime
2531                .poll_invocation(&handle)
2532                .expect("invocation should remain pollable");
2533            if poll.status == InvocationStatus::Running {
2534                break;
2535            }
2536            assert!(
2537                Instant::now() < running_deadline,
2538                "invocation did not start"
2539            );
2540            std::thread::sleep(Duration::from_millis(5));
2541        }
2542        runtime
2543            .cancel_invocation(&handle)
2544            .expect("invocation should cancel");
2545
2546        let deadline = Instant::now() + Duration::from_secs(1);
2547        loop {
2548            let poll = runtime
2549                .poll_invocation(&handle)
2550                .expect("invocation should remain pollable");
2551            if poll.status == InvocationStatus::Cancelled {
2552                break;
2553            }
2554            assert!(
2555                Instant::now() < deadline,
2556                "cancelled provider did not observe cancellation"
2557            );
2558            std::thread::sleep(Duration::from_millis(5));
2559        }
2560    }
2561
2562    #[test]
2563    fn game_loop_poll_returns_the_same_provider_result_shape_as_async_invoke() {
2564        let runtime = configured_runtime(Arc::new(TestProvider::immediate()));
2565        let handle = runtime
2566            .start_invoke(
2567                InvocationInput::json("chat", json!({ "text": "hello" }))
2568                    .with_session("poll-session"),
2569            )
2570            .expect("invocation should start");
2571        let deadline = Instant::now() + Duration::from_secs(1);
2572        let output = loop {
2573            let poll = runtime
2574                .poll_invocation(&handle)
2575                .expect("invocation should remain pollable");
2576            if let Some(output) = poll.output {
2577                break output;
2578            }
2579            assert!(
2580                !matches!(
2581                    poll.status,
2582                    InvocationStatus::Failed | InvocationStatus::Cancelled
2583                ),
2584                "invocation unexpectedly failed: {poll:?}"
2585            );
2586            assert!(Instant::now() < deadline, "invocation did not complete");
2587            std::thread::sleep(Duration::from_millis(5));
2588        };
2589
2590        assert_eq!(
2591            output.data,
2592            InvocationData::Json(json!({
2593                "capability": "chat",
2594                "input": { "text": "hello" },
2595            }))
2596        );
2597    }
2598
2599    #[test]
2600    fn game_loop_v1_event_stream_ignores_provider_stages() {
2601        let runtime = configured_runtime(Arc::new(StreamingTestProvider));
2602        let handle = runtime
2603            .start_invoke(InvocationInput::json("chat", json!({})))
2604            .expect("invocation should start");
2605        let deadline = Instant::now() + Duration::from_secs(1);
2606        loop {
2607            let poll = runtime
2608                .poll_invocation(&handle)
2609                .expect("invocation should remain pollable");
2610            if poll.status == InvocationStatus::Completed {
2611                break;
2612            }
2613            assert!(Instant::now() < deadline, "invocation did not complete");
2614            std::thread::sleep(Duration::from_millis(5));
2615        }
2616
2617        let events = runtime
2618            .drain_invocation_events(&handle)
2619            .expect("events should be available");
2620        assert_eq!(
2621            events.iter().map(|event| event.kind).collect::<Vec<_>>(),
2622            vec![
2623                InvocationEventKind::Started,
2624                InvocationEventKind::OutputDelta,
2625                InvocationEventKind::Completed,
2626            ]
2627        );
2628        assert_eq!(
2629            events[1].data,
2630            Some(InvocationData::Json(Value::String(
2631                "Hello, world".to_string()
2632            )))
2633        );
2634    }
2635
2636    #[test]
2637    fn invocation_registry_ignores_output_after_terminal_event() {
2638        let handle = InvocationHandle("late-output".to_string());
2639        let mut registry = InvocationRegistry::default();
2640        registry
2641            .insert(handle.clone(), CancellationToken::default())
2642            .expect("invocation should be registered");
2643        registry.update(&handle, InvocationStatus::Running, None, None);
2644        registry.update(
2645            &handle,
2646            InvocationStatus::Failed,
2647            None,
2648            Some("provider failed".to_string()),
2649        );
2650
2651        registry.push_provider_event(
2652            &handle,
2653            ProviderEvent::OutputDelta {
2654                data: InvocationData::Json(Value::String("too late".to_string())),
2655            },
2656        );
2657
2658        let events = registry
2659            .drain_events(&handle)
2660            .expect("events should remain available");
2661        assert_eq!(
2662            events.iter().map(|event| event.kind).collect::<Vec<_>>(),
2663            vec![InvocationEventKind::Started, InvocationEventKind::Failed]
2664        );
2665    }
2666
2667    #[test]
2668    fn taking_a_terminal_invocation_releases_its_result() {
2669        let runtime = configured_runtime(Arc::new(TestProvider::immediate()));
2670        let handle = runtime
2671            .start_invoke(InvocationInput::json("chat", json!({})))
2672            .expect("invocation should start");
2673        let deadline = Instant::now() + Duration::from_secs(1);
2674        loop {
2675            let poll = runtime
2676                .take_invocation(&handle)
2677                .expect("invocation should remain available until terminal");
2678            if is_terminal_status(poll.status) {
2679                break;
2680            }
2681            assert!(Instant::now() < deadline, "invocation did not complete");
2682            std::thread::sleep(Duration::from_millis(5));
2683        }
2684
2685        assert!(matches!(
2686            runtime.poll_invocation(&handle),
2687            Err(RuntimeError::InvocationNotFound(_))
2688        ));
2689    }
2690
2691    #[test]
2692    fn game_loop_api_applies_backpressure_to_excess_invocations() {
2693        let runtime = configured_runtime(Arc::new(TestProvider {
2694            fail: false,
2695            delay: Duration::from_secs(5),
2696        }));
2697        let handles = (0..MAX_IN_FLIGHT_INVOCATIONS)
2698            .map(|index| {
2699                runtime
2700                    .start_invoke(
2701                        InvocationInput::json("chat", json!({}))
2702                            .with_session(format!("session-{index}")),
2703                    )
2704                    .expect("invocation within the bound should start")
2705            })
2706            .collect::<Vec<_>>();
2707
2708        let error = runtime
2709            .start_invoke(
2710                InvocationInput::json("chat", json!({})).with_session("one-session-too-many"),
2711            )
2712            .expect_err("invocations above the bound should be rejected");
2713        assert!(matches!(error, RuntimeError::Backpressure(_)));
2714
2715        for handle in handles {
2716            runtime
2717                .cancel_invocation(&handle)
2718                .expect("test invocation should cancel");
2719        }
2720    }
2721
2722    #[test]
2723    fn terminal_invocation_history_is_bounded() {
2724        let runtime = configured_runtime(Arc::new(TestProvider::immediate()));
2725        let first = runtime
2726            .start_invoke(
2727                InvocationInput::json("chat", json!({})).with_session("retained-session-0"),
2728            )
2729            .expect("first invocation should start");
2730        let mut last = first.clone();
2731        for index in 0..=MAX_RETAINED_INVOCATIONS {
2732            let handle = if index == 0 {
2733                first.clone()
2734            } else {
2735                runtime
2736                    .start_invoke(
2737                        InvocationInput::json("chat", json!({}))
2738                            .with_session(format!("retained-session-{index}")),
2739                    )
2740                    .expect("invocation should start")
2741            };
2742            let deadline = Instant::now() + Duration::from_secs(1);
2743            loop {
2744                let poll = runtime
2745                    .poll_invocation(&handle)
2746                    .expect("latest invocation should remain available");
2747                if is_terminal_status(poll.status) {
2748                    break;
2749                }
2750                assert!(Instant::now() < deadline, "invocation did not complete");
2751                std::thread::sleep(Duration::from_millis(2));
2752            }
2753            last = handle;
2754        }
2755
2756        assert!(matches!(
2757            runtime.poll_invocation(&first),
2758            Err(RuntimeError::InvocationNotFound(_))
2759        ));
2760        assert!(runtime.poll_invocation(&last).is_ok());
2761    }
2762
2763    #[tokio::test(flavor = "current_thread")]
2764    async fn runtime_executes_agent_effects_and_returns_custom_effects_to_the_host() {
2765        let runtime = configured_runtime(Arc::new(TestProvider::immediate()));
2766        let execution = runtime
2767            .execute_effects(vec![
2768                EffectRequest {
2769                    id: "agent-effect".to_string(),
2770                    kind: "agent.invoke".to_string(),
2771                    payload: serde_json::to_value(InvocationInput::json(
2772                        "chat",
2773                        json!({ "text": "hello" }),
2774                    ))
2775                    .unwrap(),
2776                },
2777                EffectRequest {
2778                    id: "host-effect".to_string(),
2779                    kind: "game.play_animation".to_string(),
2780                    payload: json!({ "name": "wave" }),
2781                },
2782            ])
2783            .await
2784            .expect("effects should execute");
2785
2786        assert_eq!(execution.results.len(), 1);
2787        assert_eq!(execution.unhandled[0].kind, "game.play_animation");
2788    }
2789
2790    #[tokio::test(flavor = "current_thread")]
2791    async fn runtime_rejects_effect_batches_above_the_bound() {
2792        let runtime = configured_runtime(Arc::new(TestProvider::immediate()));
2793        let effects = (0..3)
2794            .map(|index| EffectRequest {
2795                id: format!("effect-{index}"),
2796                kind: "host.effect".to_string(),
2797                payload: json!({}),
2798            })
2799            .collect();
2800
2801        let error = runtime
2802            .execute_effects_with_limit(effects, 2)
2803            .await
2804            .expect_err("oversized effect batch should fail");
2805        assert!(matches!(error, RuntimeError::EffectLimitExceeded(2)));
2806    }
2807
2808    #[tokio::test(flavor = "current_thread")]
2809    async fn snapshots_restore_session_state_without_runtime_definitions_or_secrets() {
2810        let secret = "synthetic-secret-must-not-leak";
2811        let runtime = configured_runtime(Arc::new(TestProvider::immediate()));
2812        runtime
2813            .invoke(
2814                InvocationInput::json("chat", json!({ "text": "hello" }))
2815                    .with_session("saved-session"),
2816            )
2817            .await
2818            .expect("invocation should create state");
2819        let bytes = runtime.export_snapshot().expect("snapshot should export");
2820        assert!(!String::from_utf8_lossy(&bytes).contains(secret));
2821
2822        let restored = configured_runtime(Arc::new(TestProvider::immediate()));
2823        restored
2824            .restore_snapshot(&bytes)
2825            .expect("snapshot should restore");
2826        let output = restored
2827            .invoke(
2828                InvocationInput::json("chat", json!({ "text": "again" }))
2829                    .with_session("saved-session"),
2830            )
2831            .await
2832            .expect("restored session should invoke");
2833
2834        assert_eq!(output.snapshot.revision, 2);
2835    }
2836
2837    #[test]
2838    fn debug_output_redacts_payloads_provider_errors_and_snapshots() {
2839        let secret = "synthetic-secret-must-not-leak";
2840        let input = InvocationInput::json("chat", json!({ "secret": secret }));
2841        let error = RuntimeError::provider("test-provider", secret);
2842        let runtime = configured_runtime(Arc::new(TestProvider::immediate()));
2843
2844        assert!(!format!("{input:?}").contains(secret));
2845        assert!(!format!("{error:?}").contains(secret));
2846        assert!(!format!("{runtime:?}").contains(secret));
2847    }
2848}