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, Notify};
13
14use crate::{EffectRequest, EffectResult, RuntimeSnapshot};
15
16const SNAPSHOT_VERSION: u32 = 1;
17const DEFAULT_TIMEOUT_MS: u64 = 30_000;
18const MAX_TIMEOUT_MS: u64 = 120_000;
19const DEFAULT_EFFECT_LIMIT: usize = 64;
20const MAX_IN_FLIGHT_INVOCATIONS: usize = 64;
21const MAX_RETAINED_INVOCATIONS: usize = 256;
22const WORKER_QUEUE_CAPACITY: usize = 64;
23
24/// A boxed provider future used by [`AgentProvider`].
25pub type ProviderFuture<'a> =
26    Pin<Box<dyn Future<Output = Result<ProviderResponse, RuntimeError>> + Send + 'a>>;
27
28/// JSON or binary data passed through an embedded runtime invocation.
29#[derive(Clone, PartialEq, Serialize, Deserialize)]
30#[serde(tag = "format", content = "value", rename_all = "camelCase")]
31pub enum InvocationData {
32    Json(Value),
33    Binary(Vec<u8>),
34}
35
36impl fmt::Debug for InvocationData {
37    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
38        match self {
39            Self::Json(_) => formatter.write_str("InvocationData::Json([REDACTED])"),
40            Self::Binary(bytes) => formatter
41                .debug_tuple("InvocationData::Binary")
42                .field(&format_args!("{} bytes", bytes.len()))
43                .finish(),
44        }
45    }
46}
47
48impl Default for InvocationData {
49    fn default() -> Self {
50        Self::Json(Value::Null)
51    }
52}
53
54/// Input for one application-facing endpoint invocation.
55#[derive(Clone, PartialEq, Serialize, Deserialize)]
56#[serde(rename_all = "camelCase")]
57pub struct InvocationInput {
58    pub endpoint: String,
59    #[serde(default = "default_session_id")]
60    pub session_id: String,
61    #[serde(default)]
62    pub data: InvocationData,
63    #[serde(default)]
64    pub metadata: Value,
65}
66
67impl InvocationInput {
68    pub fn json(endpoint: impl Into<String>, data: Value) -> Self {
69        Self {
70            endpoint: endpoint.into(),
71            session_id: default_session_id(),
72            data: InvocationData::Json(data),
73            metadata: Value::Object(Default::default()),
74        }
75    }
76
77    pub fn with_session(mut self, session_id: impl Into<String>) -> Self {
78        self.session_id = session_id.into();
79        self
80    }
81}
82
83impl fmt::Debug for InvocationInput {
84    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
85        formatter
86            .debug_struct("InvocationInput")
87            .field("endpoint", &self.endpoint)
88            .field("session_id", &self.session_id)
89            .field("data", &"[REDACTED]")
90            .field("metadata", &"[REDACTED]")
91            .finish()
92    }
93}
94
95/// An agent registered inside one application runtime.
96#[derive(Clone, PartialEq, Serialize, Deserialize)]
97#[serde(rename_all = "camelCase")]
98pub struct AgentDefinition {
99    pub id: String,
100    pub name: String,
101    pub provider: String,
102    pub capabilities: Vec<String>,
103    #[serde(default)]
104    pub metadata: Value,
105}
106
107impl fmt::Debug for AgentDefinition {
108    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
109        formatter
110            .debug_struct("AgentDefinition")
111            .field("id", &self.id)
112            .field("name", &self.name)
113            .field("provider", &self.provider)
114            .field("capabilities", &self.capabilities)
115            .field("metadata", &"[REDACTED]")
116            .finish()
117    }
118}
119
120/// A stable, named application endpoint backed by one registered agent.
121#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
122#[serde(rename_all = "camelCase")]
123pub struct EndpointDefinition {
124    pub name: String,
125    pub agent: String,
126    pub capability: String,
127    #[serde(default = "default_timeout_ms")]
128    pub timeout_ms: u64,
129}
130
131/// Request delivered to a dynamically registered [`AgentProvider`].
132#[derive(Clone)]
133pub struct ProviderRequest {
134    pub project_id: String,
135    pub endpoint: String,
136    pub session_id: String,
137    pub agent: AgentDefinition,
138    pub capability: String,
139    pub data: InvocationData,
140    pub metadata: Value,
141    pub snapshot: RuntimeSnapshot,
142}
143
144impl fmt::Debug for ProviderRequest {
145    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
146        formatter
147            .debug_struct("ProviderRequest")
148            .field("project_id", &self.project_id)
149            .field("endpoint", &self.endpoint)
150            .field("session_id", &self.session_id)
151            .field("agent", &self.agent.id)
152            .field("capability", &self.capability)
153            .field("data", &"[REDACTED]")
154            .field("metadata", &"[REDACTED]")
155            .field("snapshot_revision", &self.snapshot.revision)
156            .finish()
157    }
158}
159
160/// Provider result and an optional replacement for the session's durable state.
161#[derive(Clone, PartialEq, Serialize, Deserialize)]
162#[serde(rename_all = "camelCase")]
163pub struct ProviderResponse {
164    #[serde(default)]
165    pub data: InvocationData,
166    #[serde(default)]
167    pub metadata: Value,
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub state: Option<Value>,
170}
171
172impl ProviderResponse {
173    pub fn json(data: Value) -> Self {
174        Self {
175            data: InvocationData::Json(data),
176            metadata: Value::Object(Default::default()),
177            state: None,
178        }
179    }
180}
181
182impl fmt::Debug for ProviderResponse {
183    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
184        formatter
185            .debug_struct("ProviderResponse")
186            .field("data", &"[REDACTED]")
187            .field("metadata", &"[REDACTED]")
188            .field("state", &self.state.as_ref().map(|_| "[REDACTED]"))
189            .finish()
190    }
191}
192
193/// Cooperative cancellation signal passed to providers.
194#[derive(Clone, Default)]
195pub struct CancellationToken {
196    inner: Arc<CancellationState>,
197}
198
199#[derive(Default)]
200struct CancellationState {
201    cancelled: std::sync::atomic::AtomicBool,
202    notify: Notify,
203}
204
205impl CancellationToken {
206    pub fn cancel(&self) {
207        if !self.inner.cancelled.swap(true, Ordering::AcqRel) {
208            self.inner.notify.notify_waiters();
209        }
210    }
211
212    pub fn is_cancelled(&self) -> bool {
213        self.inner.cancelled.load(Ordering::Acquire)
214    }
215
216    pub async fn cancelled(&self) {
217        if self.is_cancelled() {
218            return;
219        }
220        self.inner.notify.notified().await;
221    }
222}
223
224impl fmt::Debug for CancellationToken {
225    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
226        formatter
227            .debug_struct("CancellationToken")
228            .field("cancelled", &self.is_cancelled())
229            .finish()
230    }
231}
232
233/// Runtime-selected provider implementation.
234///
235/// Providers are registered dynamically by name. A provider may hold credentials
236/// internally, but credentials must never be placed in agent definitions,
237/// invocation metadata, snapshots, or returned trace attributes.
238pub trait AgentProvider: Send + Sync + 'static {
239    fn supports(&self, capability: &str) -> bool;
240
241    fn invoke<'a>(
242        &'a self,
243        request: ProviderRequest,
244        cancellation: CancellationToken,
245    ) -> ProviderFuture<'a>;
246}
247
248/// Persistence adapter supplied by an embedding host.
249///
250/// The default [`MemoryRuntimeStore`] keeps session state in memory. Server
251/// deployments can implement this trait with their database adapter.
252pub trait RuntimeStore: Send + Sync + 'static {
253    fn load(
254        &self,
255        project_id: &str,
256        session_id: &str,
257    ) -> Result<Option<RuntimeSnapshot>, RuntimeError>;
258
259    fn save(
260        &self,
261        project_id: &str,
262        session_id: &str,
263        snapshot: &RuntimeSnapshot,
264    ) -> Result<(), RuntimeError>;
265}
266
267/// In-memory persistence used by the standalone embedded runtime.
268#[derive(Default)]
269pub struct MemoryRuntimeStore {
270    snapshots: RwLock<HashMap<(String, String), RuntimeSnapshot>>,
271}
272
273impl RuntimeStore for MemoryRuntimeStore {
274    fn load(
275        &self,
276        project_id: &str,
277        session_id: &str,
278    ) -> Result<Option<RuntimeSnapshot>, RuntimeError> {
279        let snapshots = self.snapshots.read().map_err(|_| RuntimeError::Internal)?;
280        Ok(snapshots
281            .get(&(project_id.to_string(), session_id.to_string()))
282            .cloned())
283    }
284
285    fn save(
286        &self,
287        project_id: &str,
288        session_id: &str,
289        snapshot: &RuntimeSnapshot,
290    ) -> Result<(), RuntimeError> {
291        let mut snapshots = self.snapshots.write().map_err(|_| RuntimeError::Internal)?;
292        snapshots.insert(
293            (project_id.to_string(), session_id.to_string()),
294            snapshot.clone(),
295        );
296        Ok(())
297    }
298}
299
300/// One safe trace event emitted by the application runtime.
301#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
302#[serde(rename_all = "camelCase")]
303pub struct InvocationTraceEvent {
304    pub name: String,
305    pub status: String,
306    pub duration_ms: u64,
307    #[serde(default)]
308    pub attributes: Value,
309}
310
311/// Result of one endpoint invocation.
312#[derive(Clone, PartialEq, Serialize, Deserialize)]
313#[serde(rename_all = "camelCase")]
314pub struct InvocationOutput {
315    pub invocation_id: String,
316    pub project_id: String,
317    pub endpoint: String,
318    pub session_id: String,
319    pub agent: String,
320    pub provider: String,
321    pub capability: String,
322    pub data: InvocationData,
323    #[serde(default)]
324    pub metadata: Value,
325    pub snapshot: RuntimeSnapshot,
326    pub trace: Vec<InvocationTraceEvent>,
327}
328
329impl fmt::Debug for InvocationOutput {
330    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
331        formatter
332            .debug_struct("InvocationOutput")
333            .field("invocation_id", &self.invocation_id)
334            .field("project_id", &self.project_id)
335            .field("endpoint", &self.endpoint)
336            .field("session_id", &self.session_id)
337            .field("agent", &self.agent)
338            .field("provider", &self.provider)
339            .field("capability", &self.capability)
340            .field("data", &"[REDACTED]")
341            .field("metadata", &"[REDACTED]")
342            .field("snapshot_revision", &self.snapshot.revision)
343            .field("trace_count", &self.trace.len())
344            .finish()
345    }
346}
347
348/// Opaque handle returned by the game-loop invocation API.
349#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
350pub struct InvocationHandle(pub String);
351
352/// Current state of an invocation started with [`VifuRuntime::start_invoke`].
353#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
354#[serde(rename_all = "camelCase")]
355pub enum InvocationStatus {
356    Pending,
357    Running,
358    Completed,
359    Failed,
360    Cancelled,
361}
362
363/// Non-blocking game-loop poll result.
364#[derive(Clone, PartialEq, Serialize, Deserialize)]
365#[serde(rename_all = "camelCase")]
366pub struct InvocationPoll {
367    pub handle: InvocationHandle,
368    pub status: InvocationStatus,
369    #[serde(default, skip_serializing_if = "Option::is_none")]
370    pub output: Option<InvocationOutput>,
371    #[serde(default, skip_serializing_if = "Option::is_none")]
372    pub error: Option<String>,
373}
374
375impl fmt::Debug for InvocationPoll {
376    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
377        formatter
378            .debug_struct("InvocationPoll")
379            .field("handle", &self.handle)
380            .field("status", &self.status)
381            .field("output", &self.output.as_ref().map(|_| "[REDACTED]"))
382            .field("error", &self.error.as_ref().map(|_| "[REDACTED]"))
383            .finish()
384    }
385}
386
387/// Result of running host effects through the runtime.
388#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
389#[serde(rename_all = "camelCase")]
390pub struct EffectExecution {
391    pub results: Vec<EffectResult>,
392    pub unhandled: Vec<EffectRequest>,
393}
394
395/// Errors returned by the embedded application runtime.
396pub enum RuntimeError {
397    InvalidDefinition(String),
398    EndpointNotFound(String),
399    AgentNotFound(String),
400    ProviderNotFound(String),
401    CapabilityUnavailable {
402        provider: String,
403        capability: String,
404    },
405    Timeout(u64),
406    Cancelled,
407    Unavailable(String),
408    Backpressure(String),
409    Provider {
410        provider: String,
411        message: String,
412    },
413    Store(String),
414    Snapshot(String),
415    EffectLimitExceeded(usize),
416    InvocationNotFound(String),
417    Internal,
418}
419
420impl RuntimeError {
421    pub fn provider(provider: impl Into<String>, message: impl Into<String>) -> Self {
422        Self::Provider {
423            provider: provider.into(),
424            message: message.into(),
425        }
426    }
427
428    pub fn store(message: impl Into<String>) -> Self {
429        Self::Store(message.into())
430    }
431
432    pub fn public_message(&self) -> String {
433        match self {
434            Self::Provider { provider, .. } => {
435                format!("provider {provider} request failed")
436            }
437            Self::Store(_) => "runtime state could not be persisted".to_string(),
438            Self::Snapshot(_) => "runtime snapshot is invalid".to_string(),
439            Self::Unavailable(_) => "provider is not available".to_string(),
440            Self::Backpressure(_) => "runtime is busy".to_string(),
441            _ => self.to_string(),
442        }
443    }
444}
445
446impl fmt::Debug for RuntimeError {
447    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
448        match self {
449            Self::InvalidDefinition(_) => formatter.write_str("InvalidDefinition([REDACTED])"),
450            Self::EndpointNotFound(endpoint) => formatter
451                .debug_tuple("EndpointNotFound")
452                .field(endpoint)
453                .finish(),
454            Self::AgentNotFound(agent) => {
455                formatter.debug_tuple("AgentNotFound").field(agent).finish()
456            }
457            Self::ProviderNotFound(provider) => formatter
458                .debug_tuple("ProviderNotFound")
459                .field(provider)
460                .finish(),
461            Self::CapabilityUnavailable {
462                provider,
463                capability,
464            } => formatter
465                .debug_struct("CapabilityUnavailable")
466                .field("provider", provider)
467                .field("capability", capability)
468                .finish(),
469            Self::Timeout(timeout) => formatter.debug_tuple("Timeout").field(timeout).finish(),
470            Self::Cancelled => formatter.write_str("Cancelled"),
471            Self::Unavailable(_) => formatter.write_str("Unavailable([REDACTED])"),
472            Self::Backpressure(_) => formatter.write_str("Backpressure([REDACTED])"),
473            Self::Provider { provider, .. } => formatter
474                .debug_struct("Provider")
475                .field("provider", provider)
476                .field("message", &"[REDACTED]")
477                .finish(),
478            Self::Store(_) => formatter.write_str("Store([REDACTED])"),
479            Self::Snapshot(_) => formatter.write_str("Snapshot([REDACTED])"),
480            Self::EffectLimitExceeded(limit) => formatter
481                .debug_tuple("EffectLimitExceeded")
482                .field(limit)
483                .finish(),
484            Self::InvocationNotFound(handle) => formatter
485                .debug_tuple("InvocationNotFound")
486                .field(handle)
487                .finish(),
488            Self::Internal => formatter.write_str("Internal"),
489        }
490    }
491}
492
493impl fmt::Display for RuntimeError {
494    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
495        match self {
496            Self::InvalidDefinition(message) => {
497                write!(formatter, "invalid runtime definition: {message}")
498            }
499            Self::EndpointNotFound(endpoint) => {
500                write!(formatter, "endpoint {endpoint} is not registered")
501            }
502            Self::AgentNotFound(agent) => write!(formatter, "agent {agent} is not registered"),
503            Self::ProviderNotFound(provider) => {
504                write!(formatter, "provider {provider} is not registered")
505            }
506            Self::CapabilityUnavailable {
507                provider,
508                capability,
509            } => write!(
510                formatter,
511                "provider {provider} does not support capability {capability}"
512            ),
513            Self::Timeout(timeout_ms) => {
514                write!(
515                    formatter,
516                    "agent invocation timed out after {timeout_ms} ms"
517                )
518            }
519            Self::Cancelled => formatter.write_str("agent invocation was cancelled"),
520            Self::Unavailable(message) => {
521                write!(formatter, "provider is not available: {message}")
522            }
523            Self::Backpressure(message) => write!(formatter, "runtime is busy: {message}"),
524            Self::Provider { provider, message } => {
525                write!(formatter, "provider {provider} failed: {message}")
526            }
527            Self::Store(message) => write!(formatter, "runtime store failed: {message}"),
528            Self::Snapshot(message) => write!(formatter, "runtime snapshot failed: {message}"),
529            Self::EffectLimitExceeded(limit) => {
530                write!(formatter, "runtime effect limit {limit} was exceeded")
531            }
532            Self::InvocationNotFound(handle) => {
533                write!(formatter, "invocation {handle} was not found")
534            }
535            Self::Internal => formatter.write_str("runtime internal error"),
536        }
537    }
538}
539
540impl std::error::Error for RuntimeError {}
541
542#[derive(Default)]
543struct RuntimeRegistry {
544    providers: HashMap<String, Arc<dyn AgentProvider>>,
545    agents: HashMap<String, AgentDefinition>,
546    endpoints: HashMap<String, EndpointDefinition>,
547}
548
549struct RuntimeCore {
550    project_id: String,
551    registry: RwLock<RuntimeRegistry>,
552    store: Arc<dyn RuntimeStore>,
553    sessions: RwLock<HashMap<String, RuntimeSnapshot>>,
554    session_locks: Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
555    invocations: Mutex<InvocationRegistry>,
556    next_invocation: AtomicU64,
557}
558
559struct InvocationEntry {
560    poll: InvocationPoll,
561    cancellation: CancellationToken,
562}
563
564#[derive(Default)]
565struct InvocationRegistry {
566    entries: HashMap<String, InvocationEntry>,
567    terminal_order: VecDeque<String>,
568    active_count: usize,
569}
570
571impl InvocationRegistry {
572    fn insert(
573        &mut self,
574        handle: InvocationHandle,
575        cancellation: CancellationToken,
576    ) -> Result<(), RuntimeError> {
577        if self.active_count >= MAX_IN_FLIGHT_INVOCATIONS {
578            return Err(RuntimeError::Backpressure(
579                "too many invocations are already running".to_string(),
580            ));
581        }
582        self.entries.insert(
583            handle.0.clone(),
584            InvocationEntry {
585                poll: InvocationPoll {
586                    handle,
587                    status: InvocationStatus::Pending,
588                    output: None,
589                    error: None,
590                },
591                cancellation,
592            },
593        );
594        self.active_count += 1;
595        Ok(())
596    }
597
598    fn update(
599        &mut self,
600        handle: &InvocationHandle,
601        status: InvocationStatus,
602        output: Option<InvocationOutput>,
603        error: Option<String>,
604    ) {
605        let Some(entry) = self.entries.get_mut(&handle.0) else {
606            return;
607        };
608        if is_terminal_status(entry.poll.status) {
609            return;
610        }
611        entry.poll.status = status;
612        entry.poll.output = output;
613        entry.poll.error = error;
614        if is_terminal_status(status) {
615            self.active_count = self.active_count.saturating_sub(1);
616            self.terminal_order.push_back(handle.0.clone());
617            self.evict_old_terminal_entries();
618        }
619    }
620
621    fn remove(&mut self, handle: &InvocationHandle) {
622        if let Some(entry) = self.entries.remove(&handle.0) {
623            if !is_terminal_status(entry.poll.status) {
624                self.active_count = self.active_count.saturating_sub(1);
625            }
626        }
627        self.terminal_order.retain(|stored| stored != &handle.0);
628    }
629
630    fn take(&mut self, handle: &InvocationHandle) -> Result<InvocationPoll, RuntimeError> {
631        let poll = self
632            .entries
633            .get(&handle.0)
634            .map(|entry| entry.poll.clone())
635            .ok_or_else(|| RuntimeError::InvocationNotFound(handle.0.clone()))?;
636        if is_terminal_status(poll.status) {
637            self.remove(handle);
638        }
639        Ok(poll)
640    }
641
642    fn evict_old_terminal_entries(&mut self) {
643        while self.terminal_order.len() > MAX_RETAINED_INVOCATIONS {
644            if let Some(handle) = self.terminal_order.pop_front() {
645                self.entries.remove(&handle);
646            }
647        }
648    }
649}
650
651impl RuntimeCore {
652    async fn invoke(
653        &self,
654        invocation_id: String,
655        input: InvocationInput,
656        cancellation: CancellationToken,
657    ) -> Result<InvocationOutput, RuntimeError> {
658        validate_identifier("endpoint", &input.endpoint)?;
659        validate_identifier("session", &input.session_id)?;
660        let session_lock = {
661            let mut locks = self
662                .session_locks
663                .lock()
664                .map_err(|_| RuntimeError::Internal)?;
665            Arc::clone(
666                locks
667                    .entry(input.session_id.clone())
668                    .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))),
669            )
670        };
671        let _session_guard = session_lock.lock().await;
672        let (endpoint, agent, provider) = {
673            let registry = self.registry.read().map_err(|_| RuntimeError::Internal)?;
674            let endpoint = registry
675                .endpoints
676                .get(&input.endpoint)
677                .cloned()
678                .ok_or_else(|| RuntimeError::EndpointNotFound(input.endpoint.clone()))?;
679            let agent = registry
680                .agents
681                .get(&endpoint.agent)
682                .cloned()
683                .ok_or_else(|| RuntimeError::AgentNotFound(endpoint.agent.clone()))?;
684            let provider = registry
685                .providers
686                .get(&agent.provider)
687                .cloned()
688                .ok_or_else(|| RuntimeError::ProviderNotFound(agent.provider.clone()))?;
689            (endpoint, agent, provider)
690        };
691        if !agent
692            .capabilities
693            .iter()
694            .any(|capability| capability == &endpoint.capability)
695            || !provider.supports(&endpoint.capability)
696        {
697            return Err(RuntimeError::CapabilityUnavailable {
698                provider: agent.provider.clone(),
699                capability: endpoint.capability,
700            });
701        }
702        if cancellation.is_cancelled() {
703            return Err(RuntimeError::Cancelled);
704        }
705
706        let snapshot = self.load_snapshot(&input.session_id)?;
707        let request = ProviderRequest {
708            project_id: self.project_id.clone(),
709            endpoint: endpoint.name.clone(),
710            session_id: input.session_id.clone(),
711            agent: agent.clone(),
712            capability: endpoint.capability.clone(),
713            data: input.data,
714            metadata: input.metadata,
715            snapshot: snapshot.clone(),
716        };
717        let started = Instant::now();
718        let provider_call = provider.invoke(request, cancellation.clone());
719        let response = tokio::select! {
720            _ = cancellation.cancelled() => return Err(RuntimeError::Cancelled),
721            response = tokio::time::timeout(Duration::from_millis(endpoint.timeout_ms), provider_call) => {
722                response.map_err(|_| RuntimeError::Timeout(endpoint.timeout_ms))??
723            }
724        };
725        if cancellation.is_cancelled() {
726            return Err(RuntimeError::Cancelled);
727        }
728
729        let next_snapshot = RuntimeSnapshot {
730            revision: snapshot.revision.saturating_add(1),
731            state: response.state.unwrap_or(snapshot.state),
732        };
733        self.store
734            .save(&self.project_id, &input.session_id, &next_snapshot)?;
735        self.sessions
736            .write()
737            .map_err(|_| RuntimeError::Internal)?
738            .insert(input.session_id.clone(), next_snapshot.clone());
739        Ok(InvocationOutput {
740            invocation_id,
741            project_id: self.project_id.clone(),
742            endpoint: endpoint.name,
743            session_id: input.session_id,
744            agent: agent.id,
745            provider: agent.provider,
746            capability: endpoint.capability,
747            data: response.data,
748            metadata: response.metadata,
749            snapshot: next_snapshot,
750            trace: vec![InvocationTraceEvent {
751                name: "provider.invoke".to_string(),
752                status: "completed".to_string(),
753                duration_ms: duration_ms(started.elapsed()),
754                attributes: json!({
755                    "endpoint": input.endpoint,
756                }),
757            }],
758        })
759    }
760
761    fn load_snapshot(&self, session_id: &str) -> Result<RuntimeSnapshot, RuntimeError> {
762        if let Some(snapshot) = self
763            .sessions
764            .read()
765            .map_err(|_| RuntimeError::Internal)?
766            .get(session_id)
767            .cloned()
768        {
769            return Ok(snapshot);
770        }
771        let snapshot = self
772            .store
773            .load(&self.project_id, session_id)?
774            .unwrap_or_default();
775        self.sessions
776            .write()
777            .map_err(|_| RuntimeError::Internal)?
778            .insert(session_id.to_string(), snapshot.clone());
779        Ok(snapshot)
780    }
781
782    fn next_invocation_id(&self) -> String {
783        let id = self.next_invocation.fetch_add(1, Ordering::Relaxed);
784        format!("invocation-{id}")
785    }
786
787    fn update_poll(
788        &self,
789        handle: &InvocationHandle,
790        status: InvocationStatus,
791        output: Option<InvocationOutput>,
792        error: Option<String>,
793    ) {
794        if let Ok(mut invocations) = self.invocations.lock() {
795            invocations.update(handle, status, output, error);
796        }
797    }
798}
799
800enum WorkerCommand {
801    Start {
802        handle: InvocationHandle,
803        input: InvocationInput,
804        cancellation: CancellationToken,
805    },
806}
807
808struct RuntimeWorker {
809    sender: Mutex<Option<mpsc::Sender<WorkerCommand>>>,
810    thread: Mutex<Option<JoinHandle<()>>>,
811}
812
813impl RuntimeWorker {
814    fn spawn(core: Arc<RuntimeCore>) -> Result<Self, RuntimeError> {
815        let (sender, mut receiver) = mpsc::channel(WORKER_QUEUE_CAPACITY);
816        let thread = std::thread::Builder::new()
817            .name(format!("vifu-runtime-{}", core.project_id))
818            .spawn(move || {
819                let Ok(runtime) = tokio::runtime::Builder::new_current_thread()
820                    .enable_time()
821                    .build()
822                else {
823                    return;
824                };
825                runtime.block_on(async move {
826                    while let Some(command) = receiver.recv().await {
827                        match command {
828                            WorkerCommand::Start {
829                                handle,
830                                input,
831                                cancellation,
832                            } => {
833                                let invocation_core = Arc::clone(&core);
834                                tokio::spawn(async move {
835                                    invocation_core.update_poll(
836                                        &handle,
837                                        InvocationStatus::Running,
838                                        None,
839                                        None,
840                                    );
841                                    let result = invocation_core
842                                        .invoke(handle.0.clone(), input, cancellation)
843                                        .await;
844                                    match result {
845                                        Ok(output) => invocation_core.update_poll(
846                                            &handle,
847                                            InvocationStatus::Completed,
848                                            Some(output),
849                                            None,
850                                        ),
851                                        Err(RuntimeError::Cancelled) => invocation_core
852                                            .update_poll(
853                                                &handle,
854                                                InvocationStatus::Cancelled,
855                                                None,
856                                                None,
857                                            ),
858                                        Err(error) => invocation_core.update_poll(
859                                            &handle,
860                                            InvocationStatus::Failed,
861                                            None,
862                                            Some(error.public_message()),
863                                        ),
864                                    }
865                                });
866                            }
867                        }
868                    }
869                });
870            })
871            .map_err(|_error| RuntimeError::Internal)?;
872        Ok(Self {
873            sender: Mutex::new(Some(sender)),
874            thread: Mutex::new(Some(thread)),
875        })
876    }
877
878    fn send(&self, command: WorkerCommand) -> Result<(), RuntimeError> {
879        self.sender
880            .lock()
881            .map_err(|_| RuntimeError::Internal)?
882            .as_ref()
883            .ok_or(RuntimeError::Internal)?
884            .try_send(command)
885            .map_err(|error| match error {
886                mpsc::error::TrySendError::Full(_) => {
887                    RuntimeError::Backpressure("invocation queue is full".to_string())
888                }
889                mpsc::error::TrySendError::Closed(_) => RuntimeError::Internal,
890            })
891    }
892}
893
894impl Drop for RuntimeWorker {
895    fn drop(&mut self) {
896        if let Ok(sender) = self.sender.get_mut() {
897            sender.take();
898        }
899        if let Ok(thread) = self.thread.get_mut() {
900            if let Some(thread) = thread.take() {
901                let _ = thread.join();
902            }
903        }
904    }
905}
906
907/// A self-contained runtime for one application or project.
908///
909/// One runtime may register multiple providers, agents, and stable named
910/// endpoints. It can run directly inside a Rust host; Vifu Server and Agent
911/// Gateway are optional deployment components.
912#[derive(Clone)]
913pub struct VifuRuntime {
914    core: Arc<RuntimeCore>,
915    worker: Arc<Mutex<Option<RuntimeWorker>>>,
916}
917
918impl fmt::Debug for VifuRuntime {
919    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
920        let counts = self.core.registry.read().ok().map(|registry| {
921            (
922                registry.providers.len(),
923                registry.agents.len(),
924                registry.endpoints.len(),
925            )
926        });
927        formatter
928            .debug_struct("VifuRuntime")
929            .field("project_id", &self.core.project_id)
930            .field("resource_counts", &counts)
931            .finish()
932    }
933}
934
935impl VifuRuntime {
936    pub fn new(project_id: impl Into<String>) -> Result<Self, RuntimeError> {
937        Self::with_store(project_id, Arc::new(MemoryRuntimeStore::default()))
938    }
939
940    pub fn with_store(
941        project_id: impl Into<String>,
942        store: Arc<dyn RuntimeStore>,
943    ) -> Result<Self, RuntimeError> {
944        let project_id = project_id.into();
945        validate_identifier("project", &project_id)?;
946        let core = Arc::new(RuntimeCore {
947            project_id,
948            registry: RwLock::new(RuntimeRegistry::default()),
949            store,
950            sessions: RwLock::new(HashMap::new()),
951            session_locks: Mutex::new(HashMap::new()),
952            invocations: Mutex::new(InvocationRegistry::default()),
953            next_invocation: AtomicU64::new(1),
954        });
955        let worker = Arc::new(Mutex::new(None));
956        Ok(Self { core, worker })
957    }
958
959    pub fn project_id(&self) -> &str {
960        &self.core.project_id
961    }
962
963    pub fn register_provider(
964        &self,
965        name: impl Into<String>,
966        provider: Arc<dyn AgentProvider>,
967    ) -> Result<(), RuntimeError> {
968        let name = name.into();
969        validate_identifier("provider", &name)?;
970        self.core
971            .registry
972            .write()
973            .map_err(|_| RuntimeError::Internal)?
974            .providers
975            .insert(name, provider);
976        Ok(())
977    }
978
979    pub fn register_agent(&self, mut agent: AgentDefinition) -> Result<(), RuntimeError> {
980        validate_identifier("agent", &agent.id)?;
981        validate_identifier("provider", &agent.provider)?;
982        if agent.name.trim().is_empty() || agent.capabilities.is_empty() {
983            return Err(RuntimeError::InvalidDefinition(
984                "agent name and at least one capability are required".to_string(),
985            ));
986        }
987        for capability in &mut agent.capabilities {
988            *capability = capability.trim().to_ascii_lowercase();
989            validate_identifier("capability", capability)?;
990        }
991        agent.capabilities.sort();
992        agent.capabilities.dedup();
993        let mut registry = self
994            .core
995            .registry
996            .write()
997            .map_err(|_| RuntimeError::Internal)?;
998        if !registry.providers.contains_key(&agent.provider) {
999            return Err(RuntimeError::ProviderNotFound(agent.provider));
1000        }
1001        registry.agents.insert(agent.id.clone(), agent);
1002        Ok(())
1003    }
1004
1005    pub fn register_endpoint(&self, mut endpoint: EndpointDefinition) -> Result<(), RuntimeError> {
1006        validate_identifier("endpoint", &endpoint.name)?;
1007        validate_identifier("agent", &endpoint.agent)?;
1008        endpoint.capability = endpoint.capability.trim().to_ascii_lowercase();
1009        validate_identifier("capability", &endpoint.capability)?;
1010        if !(1..=MAX_TIMEOUT_MS).contains(&endpoint.timeout_ms) {
1011            return Err(RuntimeError::InvalidDefinition(format!(
1012                "endpoint timeout must be between 1 and {MAX_TIMEOUT_MS} ms"
1013            )));
1014        }
1015        let mut registry = self
1016            .core
1017            .registry
1018            .write()
1019            .map_err(|_| RuntimeError::Internal)?;
1020        let agent = registry
1021            .agents
1022            .get(&endpoint.agent)
1023            .ok_or_else(|| RuntimeError::AgentNotFound(endpoint.agent.clone()))?;
1024        if !agent
1025            .capabilities
1026            .iter()
1027            .any(|capability| capability == &endpoint.capability)
1028        {
1029            return Err(RuntimeError::CapabilityUnavailable {
1030                provider: agent.provider.clone(),
1031                capability: endpoint.capability,
1032            });
1033        }
1034        registry.endpoints.insert(endpoint.name.clone(), endpoint);
1035        Ok(())
1036    }
1037
1038    pub fn session(&self, session_id: impl Into<String>) -> Result<RuntimeSession, RuntimeError> {
1039        let session_id = session_id.into();
1040        validate_identifier("session", &session_id)?;
1041        Ok(RuntimeSession {
1042            runtime: self.clone(),
1043            session_id,
1044        })
1045    }
1046
1047    pub async fn invoke(&self, input: InvocationInput) -> Result<InvocationOutput, RuntimeError> {
1048        let invocation_id = self.core.next_invocation_id();
1049        self.core
1050            .invoke(invocation_id, input, CancellationToken::default())
1051            .await
1052    }
1053
1054    pub fn start_invoke(&self, input: InvocationInput) -> Result<InvocationHandle, RuntimeError> {
1055        validate_identifier("endpoint", &input.endpoint)?;
1056        validate_identifier("session", &input.session_id)?;
1057        let handle = InvocationHandle(self.core.next_invocation_id());
1058        let cancellation = CancellationToken::default();
1059        let mut worker = self.worker.lock().map_err(|_| RuntimeError::Internal)?;
1060        if worker.is_none() {
1061            *worker = Some(RuntimeWorker::spawn(Arc::clone(&self.core))?);
1062        }
1063        self.core
1064            .invocations
1065            .lock()
1066            .map_err(|_| RuntimeError::Internal)?
1067            .insert(handle.clone(), cancellation.clone())?;
1068        let send_result =
1069            worker
1070                .as_ref()
1071                .ok_or(RuntimeError::Internal)?
1072                .send(WorkerCommand::Start {
1073                    handle: handle.clone(),
1074                    input,
1075                    cancellation,
1076                });
1077        if let Err(error) = send_result {
1078            self.core
1079                .invocations
1080                .lock()
1081                .map_err(|_| RuntimeError::Internal)?
1082                .remove(&handle);
1083            return Err(error);
1084        }
1085        Ok(handle)
1086    }
1087
1088    pub fn poll_invocation(
1089        &self,
1090        handle: &InvocationHandle,
1091    ) -> Result<InvocationPoll, RuntimeError> {
1092        self.core
1093            .invocations
1094            .lock()
1095            .map_err(|_| RuntimeError::Internal)?
1096            .entries
1097            .get(&handle.0)
1098            .map(|entry| entry.poll.clone())
1099            .ok_or_else(|| RuntimeError::InvocationNotFound(handle.0.clone()))
1100    }
1101
1102    /// Returns the current poll state and removes it once it is terminal.
1103    ///
1104    /// Pending and running invocations remain registered so callers can keep
1105    /// polling the same handle.
1106    pub fn take_invocation(
1107        &self,
1108        handle: &InvocationHandle,
1109    ) -> Result<InvocationPoll, RuntimeError> {
1110        self.core
1111            .invocations
1112            .lock()
1113            .map_err(|_| RuntimeError::Internal)?
1114            .take(handle)
1115    }
1116
1117    pub fn cancel_invocation(&self, handle: &InvocationHandle) -> Result<(), RuntimeError> {
1118        let cancellation = self
1119            .core
1120            .invocations
1121            .lock()
1122            .map_err(|_| RuntimeError::Internal)?
1123            .entries
1124            .get(&handle.0)
1125            .map(|entry| entry.cancellation.clone())
1126            .ok_or_else(|| RuntimeError::InvocationNotFound(handle.0.clone()))?;
1127        cancellation.cancel();
1128        self.core
1129            .update_poll(handle, InvocationStatus::Cancelled, None, None);
1130        Ok(())
1131    }
1132
1133    pub async fn execute_effects(
1134        &self,
1135        effects: Vec<EffectRequest>,
1136    ) -> Result<EffectExecution, RuntimeError> {
1137        self.execute_effects_with_limit(effects, DEFAULT_EFFECT_LIMIT)
1138            .await
1139    }
1140
1141    pub async fn execute_effects_with_limit(
1142        &self,
1143        effects: Vec<EffectRequest>,
1144        limit: usize,
1145    ) -> Result<EffectExecution, RuntimeError> {
1146        if effects.len() > limit {
1147            return Err(RuntimeError::EffectLimitExceeded(limit));
1148        }
1149        let mut results = Vec::new();
1150        let mut unhandled = Vec::new();
1151        for effect in effects {
1152            if effect.kind != "agent.invoke" {
1153                unhandled.push(effect);
1154                continue;
1155            }
1156            let input = serde_json::from_value::<InvocationInput>(effect.payload.clone())
1157                .map_err(|error| RuntimeError::InvalidDefinition(error.to_string()))?;
1158            let result = self.invoke(input).await;
1159            match result {
1160                Ok(output) => results.push(EffectResult {
1161                    effect_id: effect.id,
1162                    succeeded: true,
1163                    output: serde_json::to_value(output)
1164                        .map_err(|_error| RuntimeError::Internal)?,
1165                }),
1166                Err(error) => results.push(EffectResult {
1167                    effect_id: effect.id,
1168                    succeeded: false,
1169                    output: json!({ "error": error.public_message() }),
1170                }),
1171            }
1172        }
1173        Ok(EffectExecution { results, unhandled })
1174    }
1175
1176    pub fn export_snapshot(&self) -> Result<Vec<u8>, RuntimeError> {
1177        let snapshot = PortableProjectSnapshot {
1178            version: SNAPSHOT_VERSION,
1179            project_id: self.core.project_id.clone(),
1180            sessions: self
1181                .core
1182                .sessions
1183                .read()
1184                .map_err(|_| RuntimeError::Internal)?
1185                .clone(),
1186        };
1187        serde_json::to_vec(&snapshot).map_err(|error| RuntimeError::Snapshot(error.to_string()))
1188    }
1189
1190    pub fn restore_snapshot(&self, bytes: &[u8]) -> Result<(), RuntimeError> {
1191        let snapshot = serde_json::from_slice::<PortableProjectSnapshot>(bytes)
1192            .map_err(|error| RuntimeError::Snapshot(error.to_string()))?;
1193        if snapshot.version != SNAPSHOT_VERSION || snapshot.project_id != self.core.project_id {
1194            return Err(RuntimeError::Snapshot(
1195                "snapshot version or project does not match".to_string(),
1196            ));
1197        }
1198        for (session_id, state) in &snapshot.sessions {
1199            validate_identifier("session", session_id)?;
1200            self.core
1201                .store
1202                .save(&self.core.project_id, session_id, state)?;
1203        }
1204        *self
1205            .core
1206            .sessions
1207            .write()
1208            .map_err(|_| RuntimeError::Internal)? = snapshot.sessions;
1209        Ok(())
1210    }
1211}
1212
1213/// A session-scoped view over [`VifuRuntime`].
1214#[derive(Clone, Debug)]
1215pub struct RuntimeSession {
1216    runtime: VifuRuntime,
1217    session_id: String,
1218}
1219
1220impl RuntimeSession {
1221    pub fn id(&self) -> &str {
1222        &self.session_id
1223    }
1224
1225    pub async fn invoke(
1226        &self,
1227        mut input: InvocationInput,
1228    ) -> Result<InvocationOutput, RuntimeError> {
1229        input.session_id.clone_from(&self.session_id);
1230        self.runtime.invoke(input).await
1231    }
1232
1233    pub fn start_invoke(
1234        &self,
1235        mut input: InvocationInput,
1236    ) -> Result<InvocationHandle, RuntimeError> {
1237        input.session_id.clone_from(&self.session_id);
1238        self.runtime.start_invoke(input)
1239    }
1240}
1241
1242#[derive(Serialize, Deserialize)]
1243#[serde(rename_all = "camelCase")]
1244struct PortableProjectSnapshot {
1245    version: u32,
1246    project_id: String,
1247    sessions: HashMap<String, RuntimeSnapshot>,
1248}
1249
1250fn default_session_id() -> String {
1251    "default".to_string()
1252}
1253
1254const fn default_timeout_ms() -> u64 {
1255    DEFAULT_TIMEOUT_MS
1256}
1257
1258fn validate_identifier(kind: &str, value: &str) -> Result<(), RuntimeError> {
1259    if value.is_empty()
1260        || value.len() > 128
1261        || !value
1262            .bytes()
1263            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':'))
1264    {
1265        return Err(RuntimeError::InvalidDefinition(format!(
1266            "{kind} must be a portable identifier"
1267        )));
1268    }
1269    Ok(())
1270}
1271
1272fn duration_ms(duration: Duration) -> u64 {
1273    u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
1274}
1275
1276const fn is_terminal_status(status: InvocationStatus) -> bool {
1277    matches!(
1278        status,
1279        InvocationStatus::Completed | InvocationStatus::Failed | InvocationStatus::Cancelled
1280    )
1281}
1282
1283#[cfg(test)]
1284mod tests {
1285    use super::*;
1286
1287    struct TestProvider {
1288        fail: bool,
1289        delay: Duration,
1290    }
1291
1292    impl TestProvider {
1293        fn immediate() -> Self {
1294            Self {
1295                fail: false,
1296                delay: Duration::ZERO,
1297            }
1298        }
1299    }
1300
1301    impl AgentProvider for TestProvider {
1302        fn supports(&self, capability: &str) -> bool {
1303            matches!(capability, "chat" | "speech" | "transcription")
1304        }
1305
1306        fn invoke<'a>(
1307            &'a self,
1308            request: ProviderRequest,
1309            cancellation: CancellationToken,
1310        ) -> ProviderFuture<'a> {
1311            Box::pin(async move {
1312                if !self.delay.is_zero() {
1313                    tokio::select! {
1314                        _ = tokio::time::sleep(self.delay) => {}
1315                        _ = cancellation.cancelled() => {
1316                            return Err(RuntimeError::Cancelled);
1317                        }
1318                    }
1319                }
1320                if self.fail {
1321                    return Err(RuntimeError::provider(
1322                        request.agent.provider,
1323                        "synthetic provider failure",
1324                    ));
1325                }
1326                Ok(ProviderResponse {
1327                    data: match request.data {
1328                        InvocationData::Json(data) => InvocationData::Json(json!({
1329                            "capability": request.capability,
1330                            "input": data,
1331                        })),
1332                        InvocationData::Binary(bytes) => InvocationData::Binary(bytes),
1333                    },
1334                    metadata: json!({}),
1335                    state: Some(json!({
1336                        "lastEndpoint": request.endpoint,
1337                        "previousRevision": request.snapshot.revision,
1338                    })),
1339                })
1340            })
1341        }
1342    }
1343
1344    fn configured_runtime(provider: Arc<dyn AgentProvider>) -> VifuRuntime {
1345        let runtime = VifuRuntime::new("test-project").expect("runtime should start");
1346        runtime
1347            .register_provider("test-provider", provider)
1348            .expect("provider should register");
1349        runtime
1350            .register_agent(AgentDefinition {
1351                id: "guide".to_string(),
1352                name: "Guide".to_string(),
1353                provider: "test-provider".to_string(),
1354                capabilities: vec![
1355                    "chat".to_string(),
1356                    "speech".to_string(),
1357                    "transcription".to_string(),
1358                ],
1359                metadata: json!({ "public": true }),
1360            })
1361            .expect("agent should register");
1362        for capability in ["chat", "speech", "transcription"] {
1363            runtime
1364                .register_endpoint(EndpointDefinition {
1365                    name: capability.to_string(),
1366                    agent: "guide".to_string(),
1367                    capability: capability.to_string(),
1368                    timeout_ms: 500,
1369                })
1370                .expect("endpoint should register");
1371        }
1372        runtime
1373    }
1374
1375    #[tokio::test(flavor = "current_thread")]
1376    async fn embedded_runtime_invokes_chat_speech_and_transcription_without_a_server() {
1377        let runtime = configured_runtime(Arc::new(TestProvider::immediate()));
1378
1379        for capability in ["chat", "speech", "transcription"] {
1380            let output = runtime
1381                .invoke(InvocationInput::json(
1382                    capability,
1383                    json!({ "message": capability }),
1384                ))
1385                .await
1386                .expect("endpoint should invoke");
1387            assert_eq!(output.capability, capability);
1388        }
1389    }
1390
1391    #[tokio::test(flavor = "current_thread")]
1392    async fn runtime_sessions_keep_independent_durable_state() {
1393        let runtime = configured_runtime(Arc::new(TestProvider::immediate()));
1394        let first = runtime
1395            .session("player-one")
1396            .expect("first session should open");
1397        let second = runtime
1398            .session("player-two")
1399            .expect("second session should open");
1400
1401        let first_output = first
1402            .invoke(InvocationInput::json("chat", json!({ "text": "one" })))
1403            .await
1404            .expect("first session should invoke");
1405        let second_output = second
1406            .invoke(InvocationInput::json("chat", json!({ "text": "two" })))
1407            .await
1408            .expect("second session should invoke");
1409
1410        assert_eq!(first_output.snapshot.revision, 1);
1411        assert_eq!(second_output.snapshot.revision, 1);
1412    }
1413
1414    #[tokio::test(flavor = "current_thread")]
1415    async fn concurrent_calls_serialize_state_updates_for_one_session() {
1416        let runtime = configured_runtime(Arc::new(TestProvider {
1417            fail: false,
1418            delay: Duration::from_millis(5),
1419        }));
1420        let first = runtime.invoke(
1421            InvocationInput::json("chat", json!({ "text": "one" })).with_session("shared-session"),
1422        );
1423        let second = runtime.invoke(
1424            InvocationInput::json("chat", json!({ "text": "two" })).with_session("shared-session"),
1425        );
1426
1427        let (first, second) = tokio::join!(first, second);
1428        let mut revisions = [
1429            first
1430                .expect("first invocation should complete")
1431                .snapshot
1432                .revision,
1433            second
1434                .expect("second invocation should complete")
1435                .snapshot
1436                .revision,
1437        ];
1438        revisions.sort_unstable();
1439        assert_eq!(revisions, [1, 2]);
1440    }
1441
1442    #[tokio::test(flavor = "current_thread")]
1443    async fn runtime_round_trips_binary_provider_results() {
1444        let runtime = configured_runtime(Arc::new(TestProvider::immediate()));
1445        let output = runtime
1446            .invoke(InvocationInput {
1447                endpoint: "speech".to_string(),
1448                session_id: "audio-session".to_string(),
1449                data: InvocationData::Binary(vec![1, 2, 3, 4]),
1450                metadata: json!({}),
1451            })
1452            .await
1453            .expect("binary invocation should complete");
1454
1455        assert_eq!(output.data, InvocationData::Binary(vec![1, 2, 3, 4]));
1456    }
1457
1458    #[tokio::test(flavor = "current_thread")]
1459    async fn runtime_times_out_slow_providers() {
1460        let runtime = VifuRuntime::new("timeout-project").expect("runtime should start");
1461        runtime
1462            .register_provider(
1463                "slow",
1464                Arc::new(TestProvider {
1465                    fail: false,
1466                    delay: Duration::from_millis(100),
1467                }),
1468            )
1469            .expect("provider should register");
1470        runtime
1471            .register_agent(AgentDefinition {
1472                id: "slow-agent".to_string(),
1473                name: "Slow agent".to_string(),
1474                provider: "slow".to_string(),
1475                capabilities: vec!["chat".to_string()],
1476                metadata: json!({}),
1477            })
1478            .expect("agent should register");
1479        runtime
1480            .register_endpoint(EndpointDefinition {
1481                name: "slow-chat".to_string(),
1482                agent: "slow-agent".to_string(),
1483                capability: "chat".to_string(),
1484                timeout_ms: 10,
1485            })
1486            .expect("endpoint should register");
1487
1488        let error = runtime
1489            .invoke(InvocationInput::json("slow-chat", json!({})))
1490            .await
1491            .expect_err("slow invocation should time out");
1492        assert!(matches!(error, RuntimeError::Timeout(10)));
1493    }
1494
1495    #[test]
1496    fn game_loop_api_starts_polls_and_cancels_invocations() {
1497        let runtime = configured_runtime(Arc::new(TestProvider {
1498            fail: false,
1499            delay: Duration::from_secs(5),
1500        }));
1501        let handle = runtime
1502            .start_invoke(InvocationInput::json("chat", json!({})))
1503            .expect("invocation should start");
1504        let running_deadline = Instant::now() + Duration::from_secs(1);
1505        loop {
1506            let poll = runtime
1507                .poll_invocation(&handle)
1508                .expect("invocation should remain pollable");
1509            if poll.status == InvocationStatus::Running {
1510                break;
1511            }
1512            assert!(
1513                Instant::now() < running_deadline,
1514                "invocation did not start"
1515            );
1516            std::thread::sleep(Duration::from_millis(5));
1517        }
1518        runtime
1519            .cancel_invocation(&handle)
1520            .expect("invocation should cancel");
1521
1522        let deadline = Instant::now() + Duration::from_secs(1);
1523        loop {
1524            let poll = runtime
1525                .poll_invocation(&handle)
1526                .expect("invocation should remain pollable");
1527            if poll.status == InvocationStatus::Cancelled {
1528                break;
1529            }
1530            assert!(
1531                Instant::now() < deadline,
1532                "cancelled provider did not observe cancellation"
1533            );
1534            std::thread::sleep(Duration::from_millis(5));
1535        }
1536    }
1537
1538    #[test]
1539    fn game_loop_poll_returns_the_same_provider_result_shape_as_async_invoke() {
1540        let runtime = configured_runtime(Arc::new(TestProvider::immediate()));
1541        let handle = runtime
1542            .start_invoke(
1543                InvocationInput::json("chat", json!({ "text": "hello" }))
1544                    .with_session("poll-session"),
1545            )
1546            .expect("invocation should start");
1547        let deadline = Instant::now() + Duration::from_secs(1);
1548        let output = loop {
1549            let poll = runtime
1550                .poll_invocation(&handle)
1551                .expect("invocation should remain pollable");
1552            if let Some(output) = poll.output {
1553                break output;
1554            }
1555            assert!(
1556                !matches!(
1557                    poll.status,
1558                    InvocationStatus::Failed | InvocationStatus::Cancelled
1559                ),
1560                "invocation unexpectedly failed: {poll:?}"
1561            );
1562            assert!(Instant::now() < deadline, "invocation did not complete");
1563            std::thread::sleep(Duration::from_millis(5));
1564        };
1565
1566        assert_eq!(
1567            output.data,
1568            InvocationData::Json(json!({
1569                "capability": "chat",
1570                "input": { "text": "hello" },
1571            }))
1572        );
1573    }
1574
1575    #[test]
1576    fn taking_a_terminal_invocation_releases_its_result() {
1577        let runtime = configured_runtime(Arc::new(TestProvider::immediate()));
1578        let handle = runtime
1579            .start_invoke(InvocationInput::json("chat", json!({})))
1580            .expect("invocation should start");
1581        let deadline = Instant::now() + Duration::from_secs(1);
1582        loop {
1583            let poll = runtime
1584                .take_invocation(&handle)
1585                .expect("invocation should remain available until terminal");
1586            if is_terminal_status(poll.status) {
1587                break;
1588            }
1589            assert!(Instant::now() < deadline, "invocation did not complete");
1590            std::thread::sleep(Duration::from_millis(5));
1591        }
1592
1593        assert!(matches!(
1594            runtime.poll_invocation(&handle),
1595            Err(RuntimeError::InvocationNotFound(_))
1596        ));
1597    }
1598
1599    #[test]
1600    fn game_loop_api_applies_backpressure_to_excess_invocations() {
1601        let runtime = configured_runtime(Arc::new(TestProvider {
1602            fail: false,
1603            delay: Duration::from_secs(5),
1604        }));
1605        let handles = (0..MAX_IN_FLIGHT_INVOCATIONS)
1606            .map(|index| {
1607                runtime
1608                    .start_invoke(
1609                        InvocationInput::json("chat", json!({}))
1610                            .with_session(format!("session-{index}")),
1611                    )
1612                    .expect("invocation within the bound should start")
1613            })
1614            .collect::<Vec<_>>();
1615
1616        let error = runtime
1617            .start_invoke(
1618                InvocationInput::json("chat", json!({})).with_session("one-session-too-many"),
1619            )
1620            .expect_err("invocations above the bound should be rejected");
1621        assert!(matches!(error, RuntimeError::Backpressure(_)));
1622
1623        for handle in handles {
1624            runtime
1625                .cancel_invocation(&handle)
1626                .expect("test invocation should cancel");
1627        }
1628    }
1629
1630    #[test]
1631    fn terminal_invocation_history_is_bounded() {
1632        let runtime = configured_runtime(Arc::new(TestProvider::immediate()));
1633        let first = runtime
1634            .start_invoke(
1635                InvocationInput::json("chat", json!({})).with_session("retained-session-0"),
1636            )
1637            .expect("first invocation should start");
1638        let mut last = first.clone();
1639        for index in 0..=MAX_RETAINED_INVOCATIONS {
1640            let handle = if index == 0 {
1641                first.clone()
1642            } else {
1643                runtime
1644                    .start_invoke(
1645                        InvocationInput::json("chat", json!({}))
1646                            .with_session(format!("retained-session-{index}")),
1647                    )
1648                    .expect("invocation should start")
1649            };
1650            let deadline = Instant::now() + Duration::from_secs(1);
1651            loop {
1652                let poll = runtime
1653                    .poll_invocation(&handle)
1654                    .expect("latest invocation should remain available");
1655                if is_terminal_status(poll.status) {
1656                    break;
1657                }
1658                assert!(Instant::now() < deadline, "invocation did not complete");
1659                std::thread::sleep(Duration::from_millis(2));
1660            }
1661            last = handle;
1662        }
1663
1664        assert!(matches!(
1665            runtime.poll_invocation(&first),
1666            Err(RuntimeError::InvocationNotFound(_))
1667        ));
1668        assert!(runtime.poll_invocation(&last).is_ok());
1669    }
1670
1671    #[tokio::test(flavor = "current_thread")]
1672    async fn runtime_executes_agent_effects_and_returns_custom_effects_to_the_host() {
1673        let runtime = configured_runtime(Arc::new(TestProvider::immediate()));
1674        let execution = runtime
1675            .execute_effects(vec![
1676                EffectRequest {
1677                    id: "agent-effect".to_string(),
1678                    kind: "agent.invoke".to_string(),
1679                    payload: serde_json::to_value(InvocationInput::json(
1680                        "chat",
1681                        json!({ "text": "hello" }),
1682                    ))
1683                    .unwrap(),
1684                },
1685                EffectRequest {
1686                    id: "host-effect".to_string(),
1687                    kind: "game.play_animation".to_string(),
1688                    payload: json!({ "name": "wave" }),
1689                },
1690            ])
1691            .await
1692            .expect("effects should execute");
1693
1694        assert_eq!(execution.results.len(), 1);
1695        assert_eq!(execution.unhandled[0].kind, "game.play_animation");
1696    }
1697
1698    #[tokio::test(flavor = "current_thread")]
1699    async fn runtime_rejects_effect_batches_above_the_bound() {
1700        let runtime = configured_runtime(Arc::new(TestProvider::immediate()));
1701        let effects = (0..3)
1702            .map(|index| EffectRequest {
1703                id: format!("effect-{index}"),
1704                kind: "host.effect".to_string(),
1705                payload: json!({}),
1706            })
1707            .collect();
1708
1709        let error = runtime
1710            .execute_effects_with_limit(effects, 2)
1711            .await
1712            .expect_err("oversized effect batch should fail");
1713        assert!(matches!(error, RuntimeError::EffectLimitExceeded(2)));
1714    }
1715
1716    #[tokio::test(flavor = "current_thread")]
1717    async fn snapshots_restore_session_state_without_runtime_definitions_or_secrets() {
1718        let secret = "synthetic-secret-must-not-leak";
1719        let runtime = configured_runtime(Arc::new(TestProvider::immediate()));
1720        runtime
1721            .invoke(
1722                InvocationInput::json("chat", json!({ "text": "hello" }))
1723                    .with_session("saved-session"),
1724            )
1725            .await
1726            .expect("invocation should create state");
1727        let bytes = runtime.export_snapshot().expect("snapshot should export");
1728        assert!(!String::from_utf8_lossy(&bytes).contains(secret));
1729
1730        let restored = configured_runtime(Arc::new(TestProvider::immediate()));
1731        restored
1732            .restore_snapshot(&bytes)
1733            .expect("snapshot should restore");
1734        let output = restored
1735            .invoke(
1736                InvocationInput::json("chat", json!({ "text": "again" }))
1737                    .with_session("saved-session"),
1738            )
1739            .await
1740            .expect("restored session should invoke");
1741
1742        assert_eq!(output.snapshot.revision, 2);
1743    }
1744
1745    #[test]
1746    fn debug_output_redacts_payloads_provider_errors_and_snapshots() {
1747        let secret = "synthetic-secret-must-not-leak";
1748        let input = InvocationInput::json("chat", json!({ "secret": secret }));
1749        let error = RuntimeError::provider("test-provider", secret);
1750        let runtime = configured_runtime(Arc::new(TestProvider::immediate()));
1751
1752        assert!(!format!("{input:?}").contains(secret));
1753        assert!(!format!("{error:?}").contains(secret));
1754        assert!(!format!("{runtime:?}").contains(secret));
1755    }
1756}