Skip to main content

vona_core/
runtime.rs

1use crate::skills::{SkillError, SkillExecutor};
2use crate::types::{AuditEvent, AuditEventKind, ControlEvent, ExternalContextEvent, SkillContext};
3use serde::{Deserialize, Serialize};
4use std::sync::Arc;
5use tokio::time::{Duration, timeout};
6
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case")]
9pub enum FallbackReason {
10    BackendUnavailable,
11    ControlRejected,
12    ToolTimeout,
13    ToolFailed,
14    Silence,
15    Interrupted,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum FillerStrategy {
21    None,
22    StaticClip,
23    BackendGenerated,
24}
25
26#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
27pub enum RuntimeDecision {
28    Ignore,
29    Continue,
30    InjectContext(ExternalContextEvent),
31    Fallback { reason: FallbackReason },
32}
33
34pub trait SessionPolicy: Send + Sync {
35    fn should_accept_control_event(&self, event: &ControlEvent) -> bool;
36    fn should_fallback_to_bridge(&self, reason: &FallbackReason) -> bool;
37    fn max_tool_latency_ms(&self) -> u64;
38}
39
40pub struct VonaRuntime<E: SkillExecutor + ?Sized, P: SessionPolicy + ?Sized> {
41    skill_executor: Arc<E>,
42    policy: Arc<P>,
43    filler_strategy: FillerStrategy,
44}
45
46impl<E, P> VonaRuntime<E, P>
47where
48    E: SkillExecutor + ?Sized,
49    P: SessionPolicy + ?Sized,
50{
51    pub fn new(skill_executor: Arc<E>, policy: Arc<P>, filler_strategy: FillerStrategy) -> Self {
52        Self {
53            skill_executor,
54            policy,
55            filler_strategy,
56        }
57    }
58
59    pub fn filler_strategy(&self) -> &FillerStrategy {
60        &self.filler_strategy
61    }
62
63    pub async fn handle_control_event(
64        &self,
65        event: &ControlEvent,
66        context: SkillContext,
67    ) -> Result<RuntimeDecision, SkillError> {
68        if !self.policy.should_accept_control_event(event) {
69            return Ok(
70                if self
71                    .policy
72                    .should_fallback_to_bridge(&FallbackReason::ControlRejected)
73                {
74                    RuntimeDecision::Fallback {
75                        reason: FallbackReason::ControlRejected,
76                    }
77                } else {
78                    RuntimeDecision::Ignore
79                },
80            );
81        }
82
83        match event {
84            ControlEvent::SkillCall(call) => {
85                let budget = Duration::from_millis(self.policy.max_tool_latency_ms());
86                let result = timeout(
87                    budget,
88                    self.skill_executor.execute(call.clone(), context.clone()),
89                )
90                .await;
91
92                match result {
93                    Ok(Ok(output)) => Ok(RuntimeDecision::InjectContext(ExternalContextEvent {
94                        source: format!("skill:{}", call.name),
95                        spoken_summary: Some(output.spoken_summary),
96                        payload: output.structured_payload.unwrap_or(serde_json::Value::Null),
97                    })),
98                    Ok(Err(err)) => {
99                        if self
100                            .policy
101                            .should_fallback_to_bridge(&FallbackReason::ToolFailed)
102                        {
103                            Ok(RuntimeDecision::Fallback {
104                                reason: FallbackReason::ToolFailed,
105                            })
106                        } else {
107                            Err(err)
108                        }
109                    }
110                    Err(_elapsed) => {
111                        // Timeout — emit fallback decision; caller can record the AuditEvent
112                        // if it has access to a session_id and an AuditSink.
113                        if self
114                            .policy
115                            .should_fallback_to_bridge(&FallbackReason::ToolTimeout)
116                        {
117                            Ok(RuntimeDecision::Fallback {
118                                reason: FallbackReason::ToolTimeout,
119                            })
120                        } else {
121                            Err(SkillError::Execution(format!(
122                                "tool '{}' exceeded latency budget of {} ms",
123                                call.name,
124                                self.policy.max_tool_latency_ms()
125                            )))
126                        }
127                    }
128                }
129            }
130            ControlEvent::TranscriptFragment { .. }
131            | ControlEvent::Interruption { .. }
132            | ControlEvent::Diagnostic { .. } => Ok(RuntimeDecision::Continue),
133        }
134    }
135
136    /// Variant of `handle_control_event` that also records audit events for timeouts.
137    pub async fn handle_control_event_audited(
138        &self,
139        event: &ControlEvent,
140        context: SkillContext,
141        audit_sink: &(impl crate::skills::AuditSink + ?Sized),
142    ) -> Result<RuntimeDecision, SkillError> {
143        let decision = self.handle_control_event(event, context.clone()).await?;
144
145        if let RuntimeDecision::Fallback {
146            reason: FallbackReason::ToolTimeout,
147        } = &decision
148            && let ControlEvent::SkillCall(call) = event
149        {
150            audit_sink
151                .record(AuditEvent::now(
152                    &context.session_id,
153                    AuditEventKind::ToolTimeout {
154                        name: call.name.clone(),
155                        budget_ms: self.policy.max_tool_latency_ms(),
156                    },
157                ))
158                .await;
159        }
160
161        Ok(decision)
162    }
163}