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