1use std::collections::HashMap;
29use std::sync::atomic::{AtomicU64, Ordering};
30use std::sync::{Arc, Mutex};
31
32use car_eventlog::{EventKind, EventLog};
33use car_inference::{GenerateRequest, InferenceEngine, StreamEvent};
34use serde_json::Value;
35use tokio::sync::{mpsc, oneshot};
36use tokio_util::sync::CancellationToken;
37
38fn next_turn_id() -> u64 {
40 static COUNTER: AtomicU64 = AtomicU64::new(1);
41 COUNTER.fetch_add(1, Ordering::Relaxed)
42}
43
44#[derive(Debug, Clone)]
47pub struct SidecarResult {
48 pub turn_id: u64,
52 pub text: String,
54 pub data: Option<serde_json::Value>,
57}
58
59#[derive(Debug, thiserror::Error)]
61pub enum VoiceTurnError {
62 #[error("inference failed: {0}")]
66 Inference(String),
67 #[error("turn cancelled (barge-in or supersession)")]
70 Cancelled,
71}
72
73#[derive(Clone)]
79pub struct VoiceTurnControl {
80 pub turn_id: u64,
83 cancel: CancellationToken,
84}
85
86impl VoiceTurnControl {
87 pub fn cancel(&self) {
89 self.cancel.cancel();
90 }
91
92 pub fn is_cancelled(&self) -> bool {
94 self.cancel.is_cancelled()
95 }
96}
97
98pub struct VoiceTurnHandle {
111 pub control: VoiceTurnControl,
112 pub fast: mpsc::Receiver<StreamEvent>,
113 pub sidecar: oneshot::Receiver<Result<SidecarResult, VoiceTurnError>>,
114}
115
116impl VoiceTurnHandle {
117 pub fn turn_id(&self) -> u64 {
119 self.control.turn_id
120 }
121
122 pub fn cancel(&self) {
124 self.control.cancel();
125 }
126}
127
128#[async_trait::async_trait]
142pub trait DirectDataFetcher: Send + Sync {
143 async fn try_fetch(&self, utterance: &str) -> Option<Result<String, String>>;
150}
151
152#[derive(Clone)]
156pub struct VoiceTelemetry {
157 log: Arc<Mutex<EventLog>>,
158}
159
160impl VoiceTelemetry {
161 pub fn new(log: Arc<Mutex<EventLog>>) -> Self {
163 Self { log }
164 }
165
166 pub fn emit(&self, kind: EventKind, turn_id: u64, extra: Vec<(&str, Value)>) {
170 let mut data: HashMap<String, Value> = HashMap::new();
171 data.insert("turn_id".to_string(), Value::from(turn_id));
172 for (k, v) in extra {
173 data.insert(k.to_string(), v);
174 }
175 if let Ok(mut guard) = self.log.lock() {
176 guard.append(kind, None, None, data);
177 }
178 }
179}
180
181pub fn fast_done_frame(
197 turn_id: u64,
198 substitution: Option<&car_inference::StreamSubstitution>,
199) -> String {
200 let mut frame = serde_json::json!({ "type": "voice.turn.fast_done", "turn_id": turn_id });
201 if let Some(served) = substitution {
202 served.annotate(&mut frame);
203 }
204 frame.to_string()
205}
206
207pub fn dispatch_voice_turn(
210 engine: Arc<InferenceEngine>,
211 utterance: String,
212 fast_request: GenerateRequest,
213 sidecar_request: GenerateRequest,
214) -> VoiceTurnHandle {
215 dispatch_voice_turn_with_telemetry(engine, utterance, fast_request, sidecar_request, None)
216}
217
218pub fn dispatch_voice_turn_with_telemetry(
223 engine: Arc<InferenceEngine>,
224 _utterance: String,
225 fast_request: GenerateRequest,
226 sidecar_request: GenerateRequest,
227 telemetry: Option<VoiceTelemetry>,
228) -> VoiceTurnHandle {
229 let turn_id = next_turn_id();
230 let cancel = CancellationToken::new();
231 let (fast_tx, fast_rx) = mpsc::channel::<StreamEvent>(64);
232 let (sidecar_tx, sidecar_rx) = oneshot::channel();
233
234 if let Some(t) = telemetry.as_ref() {
235 t.emit(EventKind::VoiceFastTurnStarted, turn_id, vec![]);
236 }
237
238 spawn_fast_task(
239 engine.clone(),
240 fast_request,
241 fast_tx,
242 cancel.clone(),
243 turn_id,
244 telemetry.clone(),
245 );
246 spawn_sidecar_task(
247 engine,
248 sidecar_request,
249 sidecar_tx,
250 cancel.clone(),
251 turn_id,
252 telemetry,
253 );
254
255 VoiceTurnHandle {
256 control: VoiceTurnControl { turn_id, cancel },
257 fast: fast_rx,
258 sidecar: sidecar_rx,
259 }
260}
261
262pub fn dispatch_voice_turn_sidecar_only(
270 engine: Arc<InferenceEngine>,
271 utterance: String,
272 sidecar_request: GenerateRequest,
273) -> VoiceTurnHandle {
274 dispatch_voice_turn_sidecar_only_with_telemetry(engine, utterance, sidecar_request, None)
275}
276
277pub fn dispatch_voice_turn_sidecar_only_with_telemetry(
280 engine: Arc<InferenceEngine>,
281 utterance: String,
282 sidecar_request: GenerateRequest,
283 telemetry: Option<VoiceTelemetry>,
284) -> VoiceTurnHandle {
285 dispatch_voice_turn_sidecar_only_with_classifier(
286 engine,
287 utterance,
288 sidecar_request,
289 None,
290 telemetry,
291 )
292}
293
294pub fn dispatch_voice_turn_sidecar_only_with_classifier(
300 engine: Arc<InferenceEngine>,
301 utterance: String,
302 sidecar_request: GenerateRequest,
303 fetcher: Option<Arc<dyn DirectDataFetcher>>,
304 telemetry: Option<VoiceTelemetry>,
305) -> VoiceTurnHandle {
306 let turn_id = next_turn_id();
307 let cancel = CancellationToken::new();
308 let (fast_tx, fast_rx) = mpsc::channel::<StreamEvent>(1);
311 drop(fast_tx);
312 let (sidecar_tx, sidecar_rx) = oneshot::channel();
313
314 spawn_sidecar_task_classified(
315 engine,
316 utterance,
317 sidecar_request,
318 sidecar_tx,
319 cancel.clone(),
320 turn_id,
321 fetcher,
322 telemetry,
323 );
324
325 VoiceTurnHandle {
326 control: VoiceTurnControl { turn_id, cancel },
327 fast: fast_rx,
328 sidecar: sidecar_rx,
329 }
330}
331
332fn spawn_fast_task(
333 engine: Arc<InferenceEngine>,
334 request: GenerateRequest,
335 out: mpsc::Sender<StreamEvent>,
336 cancel: CancellationToken,
337 turn_id: u64,
338 telemetry: Option<VoiceTelemetry>,
339) {
340 tokio::spawn(async move {
341 let cancelled_during = tokio::select! {
342 biased;
343 _ = cancel.cancelled() => {
344 tracing::debug!(turn_id, "fast task cancelled before inference start");
345 true
346 }
347 res = engine.generate_tracked_stream(request) => {
348 match res {
349 Ok(mut handle) => {
350 relay_fast_stream(&mut handle.events, &out, &cancel, turn_id).await;
351 cancel.is_cancelled()
352 }
353 Err(e) => {
354 tracing::error!(turn_id, error=%e, "fast turn inference failed");
355 false
356 }
357 }
358 }
359 };
360 if let Some(t) = telemetry {
361 if cancelled_during {
362 t.emit(
363 EventKind::VoiceTurnCancelled,
364 turn_id,
365 vec![("track", "fast".into())],
366 );
367 } else {
368 t.emit(EventKind::VoiceFastTurnEnded, turn_id, vec![]);
369 }
370 }
371 });
372}
373
374async fn relay_fast_stream(
375 rx: &mut mpsc::Receiver<StreamEvent>,
376 out: &mpsc::Sender<StreamEvent>,
377 cancel: &CancellationToken,
378 turn_id: u64,
379) {
380 loop {
381 tokio::select! {
382 biased;
383 _ = cancel.cancelled() => {
384 tracing::debug!(turn_id, "fast stream cancelled mid-relay");
385 break;
386 }
387 evt = rx.recv() => match evt {
388 Some(e) => {
389 if out.send(e).await.is_err() {
390 break;
392 }
393 }
394 None => break,
395 }
396 }
397 }
398}
399
400fn spawn_sidecar_task_classified(
401 engine: Arc<InferenceEngine>,
402 utterance: String,
403 request: GenerateRequest,
404 sender: oneshot::Sender<Result<SidecarResult, VoiceTurnError>>,
405 cancel: CancellationToken,
406 turn_id: u64,
407 fetcher: Option<Arc<dyn DirectDataFetcher>>,
408 telemetry: Option<VoiceTelemetry>,
409) {
410 tokio::spawn(async move {
411 if let Some(f) = fetcher.as_ref() {
413 let fetch_outcome = tokio::select! {
414 biased;
415 _ = cancel.cancelled() => None,
416 outcome = f.try_fetch(&utterance) => outcome,
417 };
418 match fetch_outcome {
419 Some(Ok(text)) => {
420 let result = Ok(SidecarResult {
421 turn_id,
422 text: text.clone(),
423 data: None,
424 });
425 if let Some(t) = telemetry {
426 t.emit(
427 EventKind::VoiceSidecarResolved,
428 turn_id,
429 vec![
430 ("text_len", Value::from(text.len())),
431 ("source", "direct_fetch".into()),
432 ],
433 );
434 }
435 let _ = sender.send(result);
436 return;
437 }
438 Some(Err(e)) => {
439 tracing::debug!(turn_id, error=%e, "DirectDataFetcher errored; falling through to LLM");
440 }
441 None => { }
442 }
443 if cancel.is_cancelled() {
446 let _ = sender.send(Err(VoiceTurnError::Cancelled));
447 if let Some(t) = telemetry {
448 t.emit(
449 EventKind::VoiceTurnCancelled,
450 turn_id,
451 vec![("track", "sidecar".into())],
452 );
453 }
454 return;
455 }
456 }
457 run_llm_sidecar(engine, request, sender, cancel, turn_id, telemetry).await;
458 });
459}
460
461async fn run_llm_sidecar(
462 engine: Arc<InferenceEngine>,
463 request: GenerateRequest,
464 sender: oneshot::Sender<Result<SidecarResult, VoiceTurnError>>,
465 cancel: CancellationToken,
466 turn_id: u64,
467 telemetry: Option<VoiceTelemetry>,
468) {
469 let result = tokio::select! {
470 biased;
471 _ = cancel.cancelled() => Err(VoiceTurnError::Cancelled),
472 res = engine.generate(request) => {
473 res.map(|text| SidecarResult { turn_id, text, data: None })
474 .map_err(|e| VoiceTurnError::Inference(e.to_string()))
475 }
476 };
477 if let Some(t) = telemetry {
478 match &result {
479 Ok(r) => t.emit(
480 EventKind::VoiceSidecarResolved,
481 turn_id,
482 vec![("text_len", Value::from(r.text.len()))],
483 ),
484 Err(VoiceTurnError::Cancelled) => {
485 t.emit(
486 EventKind::VoiceTurnCancelled,
487 turn_id,
488 vec![("track", "sidecar".into())],
489 );
490 }
491 Err(VoiceTurnError::Inference(e)) => {
492 t.emit(
493 EventKind::VoiceSidecarFailed,
494 turn_id,
495 vec![("error", Value::from(e.clone()))],
496 );
497 }
498 }
499 }
500 let _ = sender.send(result);
501}
502
503fn spawn_sidecar_task(
504 engine: Arc<InferenceEngine>,
505 request: GenerateRequest,
506 sender: oneshot::Sender<Result<SidecarResult, VoiceTurnError>>,
507 cancel: CancellationToken,
508 turn_id: u64,
509 telemetry: Option<VoiceTelemetry>,
510) {
511 tokio::spawn(run_llm_sidecar(
512 engine, request, sender, cancel, turn_id, telemetry,
513 ));
514}
515
516#[cfg(test)]
517mod tests {
518 use super::*;
519
520 #[test]
521 fn turn_ids_are_monotonic_and_unique() {
522 let a = next_turn_id();
523 let b = next_turn_id();
524 let c = next_turn_id();
525 assert!(b > a);
526 assert!(c > b);
527 }
528
529 #[test]
530 fn control_cancel_is_observable() {
531 let control = VoiceTurnControl {
532 turn_id: 42,
533 cancel: CancellationToken::new(),
534 };
535 assert!(!control.is_cancelled());
536 let clone = control.clone();
537 clone.cancel();
538 assert!(control.is_cancelled());
539 }
540
541 #[test]
542 fn handle_turn_id_delegates_to_control() {
543 let (_tx, fast_rx) = mpsc::channel::<StreamEvent>(1);
544 let (_stx, sidecar_rx) = oneshot::channel();
545 let handle = VoiceTurnHandle {
546 control: VoiceTurnControl {
547 turn_id: 7,
548 cancel: CancellationToken::new(),
549 },
550 fast: fast_rx,
551 sidecar: sidecar_rx,
552 };
553 assert_eq!(handle.turn_id(), 7);
554 assert!(!handle.control.is_cancelled());
555 handle.cancel();
556 assert!(handle.control.is_cancelled());
557 }
558
559 #[tokio::test]
560 async fn closed_fast_channel_recv_is_none() {
561 let (fast_tx, mut fast_rx) = mpsc::channel::<StreamEvent>(1);
565 drop(fast_tx);
566 assert!(fast_rx.recv().await.is_none());
567 }
568
569 #[tokio::test]
570 async fn cancellation_propagates_to_relay_fast_stream() {
571 let (in_tx, mut in_rx) = mpsc::channel::<StreamEvent>(8);
575 let (out_tx, mut out_rx) = mpsc::channel::<StreamEvent>(8);
576 let cancel = CancellationToken::new();
577
578 let producer = tokio::spawn(async move {
581 for i in 0..100u32 {
582 if in_tx
583 .send(StreamEvent::TextDelta(format!("d{i}")))
584 .await
585 .is_err()
586 {
587 break;
588 }
589 }
590 });
591
592 let cancel_clone = cancel.clone();
593 let relay = tokio::spawn(async move {
594 relay_fast_stream(&mut in_rx, &out_tx, &cancel_clone, 1).await;
595 });
596
597 let first = out_rx.recv().await.expect("first event");
599 match first {
600 StreamEvent::TextDelta(_) => {}
601 other => panic!("unexpected event: {other:?}"),
602 }
603 cancel.cancel();
604
605 tokio::time::timeout(std::time::Duration::from_secs(1), relay)
607 .await
608 .expect("relay did not exit after cancel")
609 .expect("relay panicked");
610
611 producer.abort();
612 }
613
614 #[tokio::test]
615 async fn direct_fetcher_hit_skips_llm_and_resolves_sidecar() {
616 struct Hit;
617 #[async_trait::async_trait]
618 impl DirectDataFetcher for Hit {
619 async fn try_fetch(&self, _u: &str) -> Option<Result<String, String>> {
620 Some(Ok("3 emails: Bob, Alice, Carol".to_string()))
621 }
622 }
623 let cancel = CancellationToken::new();
624 let (tx, rx) = oneshot::channel();
625 let log = Arc::new(Mutex::new(EventLog::new()));
626 let telemetry = VoiceTelemetry::new(log.clone());
627 let dummy_engine = Arc::new(car_inference::InferenceEngine::new(
631 car_inference::InferenceConfig::default(),
632 ));
633 spawn_sidecar_task_classified(
634 dummy_engine,
635 "any new email today".to_string(),
636 GenerateRequest::default(),
637 tx,
638 cancel,
639 99,
640 Some(Arc::new(Hit)),
641 Some(telemetry),
642 );
643 let r = rx.await.expect("oneshot delivered").expect("ok");
644 assert_eq!(r.turn_id, 99);
645 assert_eq!(r.text, "3 emails: Bob, Alice, Carol");
646 let g = log.lock().unwrap();
648 let evt = g.events().last().expect("event emitted");
649 assert_eq!(evt.kind, EventKind::VoiceSidecarResolved);
650 assert_eq!(evt.data.get("source"), Some(&Value::from("direct_fetch")));
651 }
652
653 #[tokio::test]
654 async fn direct_fetcher_miss_falls_through_but_we_observe_no_short_circuit() {
655 struct Miss;
662 #[async_trait::async_trait]
663 impl DirectDataFetcher for Miss {
664 async fn try_fetch(&self, _u: &str) -> Option<Result<String, String>> {
665 None
666 }
667 }
668 let cancel = CancellationToken::new();
669 let (tx, rx) = oneshot::channel();
670 let dummy_engine = Arc::new(car_inference::InferenceEngine::new(
671 car_inference::InferenceConfig::default(),
672 ));
673 spawn_sidecar_task_classified(
674 dummy_engine,
675 "what's the weather".to_string(),
676 GenerateRequest::default(),
677 tx,
678 cancel.clone(),
679 100,
680 Some(Arc::new(Miss)),
681 None,
682 );
683 cancel.cancel();
685 match rx.await.expect("oneshot delivered") {
686 Err(VoiceTurnError::Cancelled) => {}
687 other => panic!("expected Cancelled after fetcher miss + cancel, got {other:?}"),
688 }
689 }
690
691 #[test]
692 fn telemetry_emit_appends_to_eventlog() {
693 let log = Arc::new(Mutex::new(EventLog::new()));
694 let telemetry = VoiceTelemetry::new(log.clone());
695 telemetry.emit(EventKind::VoiceFastTurnStarted, 7, vec![]);
696 telemetry.emit(
697 EventKind::VoiceSidecarResolved,
698 7,
699 vec![("text_len", Value::from(42usize))],
700 );
701 let g = log.lock().unwrap();
702 let events = g.events();
703 assert_eq!(events.len(), 2);
704 assert_eq!(events[0].kind, EventKind::VoiceFastTurnStarted);
705 assert_eq!(events[0].data.get("turn_id"), Some(&Value::from(7u64)));
706 assert_eq!(events[1].kind, EventKind::VoiceSidecarResolved);
707 assert_eq!(events[1].data.get("text_len"), Some(&Value::from(42usize)));
708 }
709
710 #[tokio::test]
711 async fn dropped_out_channel_stops_relay_without_cancel() {
712 let (in_tx, mut in_rx) = mpsc::channel::<StreamEvent>(8);
713 let (out_tx, out_rx) = mpsc::channel::<StreamEvent>(8);
714 let cancel = CancellationToken::new();
715
716 drop(out_rx);
719
720 let cancel_clone = cancel.clone();
721 let relay = tokio::spawn(async move {
722 relay_fast_stream(&mut in_rx, &out_tx, &cancel_clone, 1).await;
723 });
724
725 in_tx
727 .send(StreamEvent::TextDelta("x".into()))
728 .await
729 .unwrap();
730
731 tokio::time::timeout(std::time::Duration::from_secs(1), relay)
732 .await
733 .expect("relay did not exit after out_rx drop")
734 .expect("relay panicked");
735 }
736}