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 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 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}