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