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
24pub type ProviderFuture<'a> =
26 Pin<Box<dyn Future<Output = Result<ProviderResponse, RuntimeError>> + Send + 'a>>;
27
28#[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#[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#[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#[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#[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#[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#[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
233pub 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
248pub 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#[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#[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#[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#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
350pub struct InvocationHandle(pub String);
351
352#[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#[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#[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
395pub 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#[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 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#[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}