1use std::{future::Future, panic::AssertUnwindSafe, pin::Pin, sync::Arc, time::Instant};
8
9use async_trait::async_trait;
10use futures::{FutureExt, Stream, StreamExt};
11use parking_lot::Mutex;
12use serde_json::Value;
13use tokio::sync::{mpsc, oneshot};
14use tokio_stream::wrappers::ReceiverStream;
15use tracing::Instrument;
16
17use switchyard_protocol::{ModelId, Request, Response};
25
26use crate::{DriverError, LibsyError, Result, observability};
27
28pub type StepStream = Pin<Box<dyn Stream<Item = Result<Step>> + Send>>;
32
33pub struct CallModel {
43 pub algorithm: String,
46 pub request: Request,
48 pub models: Vec<ModelId>,
50 reply: oneshot::Sender<Result<Response>>,
52}
53
54impl CallModel {
55 pub fn respond(self, result: Result<Response>) -> Result<()> {
59 self.reply
60 .send(result)
61 .map_err(|_| DriverError::ResponseDropped.into())
62 }
63}
64
65pub struct RoutingOutcome {
67 pub selected_model_ids: Vec<ModelId>,
69 pub request: Request,
71 pub response: Option<Response>,
73 pub metadata: Option<crate::OutcomeMetadata>,
78}
79
80impl RoutingOutcome {
81 pub fn selected_model_id(&self) -> Result<&ModelId> {
84 self.selected_model_ids.first().ok_or(LibsyError::NoTargets)
85 }
86
87 pub fn route_to(
91 selected_model_id: ModelId,
92 fallback_models: Vec<ModelId>,
93 mut request: Request,
94 ) -> Self {
95 request.llm_request.model = Some(selected_model_id.to_string());
96 let mut selected_model_ids = Vec::with_capacity(1 + fallback_models.len());
97 selected_model_ids.push(selected_model_id);
98 selected_model_ids.extend(fallback_models);
99 Self {
100 selected_model_ids,
101 request,
102 response: None,
103 metadata: None,
104 }
105 }
106
107 pub fn answered(selected_model_id: ModelId, mut request: Request, response: Response) -> Self {
110 request.llm_request.model = Some(selected_model_id.to_string());
111 Self {
112 selected_model_ids: vec![selected_model_id],
113 request,
114 response: Some(response),
115 metadata: None,
116 }
117 }
118}
119
120#[derive(Clone)]
122pub struct Driver {
123 step_tx: mpsc::Sender<Result<Step>>,
124 algorithm: String,
126 evidence: Arc<Mutex<Option<Value>>>,
128}
129
130impl Driver {
131 pub(crate) fn new(algorithm: &str) -> (Self, mpsc::Receiver<Result<Step>>) {
134 let (step_tx, step_rx) = mpsc::channel(1);
139 (
140 Self {
141 step_tx,
142 algorithm: algorithm.to_string(),
143 evidence: Arc::new(Mutex::new(None)),
144 },
145 step_rx,
146 )
147 }
148
149 pub(crate) fn set_evidence(&self, evidence: Value) {
151 *self.evidence.lock() = Some(evidence);
152 }
153
154 pub(crate) fn set_evidence_if_empty(&self, evidence: Value) {
156 let mut current = self.evidence.lock();
157 if current.is_none() {
158 *current = Some(evidence);
159 }
160 }
161
162 #[tracing::instrument(
171 target = "libsy",
172 name = "libsy.llm_call",
173 skip_all,
174 fields(
175 algorithm = self.algorithm,
176 selected_model = %models.first().map(ModelId::as_str).unwrap_or("NoTargets"),
177 openinference.span.kind = "CHAIN",
178 outcome = tracing::field::Empty,
179 input_tokens = tracing::field::Empty,
180 output_tokens = tracing::field::Empty,
181 total_tokens = tracing::field::Empty,
182 reasoning_tokens = tracing::field::Empty,
183 )
184 )]
185 pub async fn call_model(&self, mut request: Request, models: Vec<ModelId>) -> Result<Response> {
186 let Some(selected_model_id) = models.first().cloned() else {
187 return Err(LibsyError::NoTargets);
188 };
189 request.llm_request.model = Some(selected_model_id.to_string());
190 let started = Instant::now();
191 let (reply, response) = oneshot::channel::<Result<Response>>();
192 let call = CallModel {
193 algorithm: self.algorithm.clone(),
194 request,
195 models,
196 reply,
197 };
198 let result = async {
199 self.step_tx
200 .send(Ok(Step::CallModel(Box::new(call))))
201 .await
202 .map_err(|_| DriverError::StreamClosed)?;
203 response
204 .await
205 .map_err(|_| LibsyError::from(DriverError::ResponseDropped))?
206 }
207 .await;
208 let elapsed = started.elapsed();
209 observability::record_llm_call(
210 &self.algorithm,
211 selected_model_id.as_str(),
212 elapsed,
213 &result,
214 &tracing::Span::current(),
215 );
216 result
217 }
218
219 pub(crate) async fn finish(&self, result: Result<RoutingOutcome>) -> Result<()> {
223 let result = result.map(|mut outcome| {
224 let metadata = outcome.metadata.get_or_insert_with(|| {
225 crate::OutcomeMetadata::new(self.algorithm.clone(), self.evidence.lock().take())
226 });
227 observability::record_outcome(metadata, &outcome.selected_model_ids);
228 outcome
229 });
230 let selected_model = result
231 .as_ref()
232 .ok()
233 .and_then(|outcome| outcome.selected_model_id().ok().cloned());
234 let step = result.map(|outcome| Step::Done(Box::new(outcome)));
235 self.step_tx
236 .send(step)
237 .await
238 .map_err(|_| DriverError::StreamClosed)?;
239 if let Some(selected_model) = selected_model {
240 observability::record_decision(&self.algorithm, &selected_model);
241 }
242 Ok(())
243 }
244}
245
246pub enum Step {
248 CallModel(Box<CallModel>),
251 Done(Box<RoutingOutcome>),
253}
254
255pub async fn drive<F, Fut>(
268 algorithm: Arc<dyn Algorithm>,
269 request: Request,
270 serve: F,
271) -> Result<RoutingOutcome>
272where
273 F: Fn(CallModel) -> Fut,
274 Fut: Future<Output = Result<()>>,
275{
276 let stream = algorithm.run_stream(request);
277 tokio::pin!(stream);
278
279 let mut in_flight = futures::stream::FuturesUnordered::new();
280 let mut final_outcome: Option<RoutingOutcome> = None;
281
282 loop {
283 tokio::select! {
284 Some(result) = in_flight.next() => match result {
285 Ok(()) => {}, Err(err) => return Err(err), },
288 step = stream.next() => {
289 match step {
290 None => break, Some(item) => match item? {
292 Step::CallModel(call) => in_flight.push(serve(*call)),
293 Step::Done(outcome) => {
294 final_outcome = Some(*outcome);
295 break;
296 }
297 }
298 }
299 },
300 }
301 }
302 final_outcome.ok_or(LibsyError::MissingFinalResponse)
303}
304
305fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
307 payload
308 .downcast_ref::<&'static str>()
309 .map(|message| (*message).to_string())
310 .or_else(|| payload.downcast_ref::<String>().cloned())
311 .unwrap_or_else(|| "unknown panic payload".to_string())
312}
313
314struct AbortOnDrop(tokio::task::AbortHandle);
316
317impl Drop for AbortOnDrop {
318 fn drop(&mut self) {
319 self.0.abort();
320 }
321}
322
323pub(crate) fn ensure_model_is_target(targets: &[ModelId], name: &ModelId) -> Result<()> {
328 targets
329 .iter()
330 .any(|target| target == name)
331 .then_some(())
332 .ok_or_else(|| LibsyError::TargetNotFound {
333 target: name.clone(),
334 })
335}
336
337#[derive(Clone, Hash, PartialEq, Eq)]
340pub(crate) enum RoutingIdentity {
341 Session(String),
343 Subagent { session: String, agent: String },
345}
346
347impl RoutingIdentity {
348 pub(crate) fn from_request(request: &Request) -> Option<Self> {
353 let metadata = request.metadata.as_ref()?;
354 let session = metadata.session_id.as_deref().filter(|id| !id.is_empty())?;
355 if metadata.is_subagent {
356 let agent = metadata.agent_id.as_deref().filter(|id| !id.is_empty())?;
357 Some(Self::Subagent {
358 session: session.to_string(),
359 agent: agent.to_string(),
360 })
361 } else {
362 Some(Self::Session(session.to_string()))
363 }
364 }
365}
366
367#[async_trait]
400pub trait Algorithm: Send + Sync + 'static {
401 fn name(&self) -> &str;
405
406 async fn route(self: Arc<Self>, driver: Driver, request: Request) -> Result<RoutingOutcome>;
410
411 fn run_stream(self: Arc<Self>, request: Request) -> StepStream {
420 let (driver, step_rx) = Driver::new(self.name());
421 let span = observability::run_span(self.name(), &request);
422 let handle = tokio::spawn(
423 async move {
424 let algorithm = self.name().to_string();
425 let route = AssertUnwindSafe(self.route(driver.clone(), request)).catch_unwind();
427 let result = observability::observe_run(&algorithm, async move {
428 route.await.unwrap_or_else(|payload| {
429 Err(LibsyError::AlgorithmError {
430 message: format!(
431 "algorithm task panicked: {}",
432 panic_message(payload.as_ref())
433 ),
434 })
435 })
436 })
437 .await;
438
439 let _ = driver.finish(result).await;
440 }
441 .instrument(span),
442 );
443 let abort_guard = AbortOnDrop(handle.abort_handle());
445 Box::pin(ReceiverStream::new(step_rx).map(move |step| {
446 let _keep_alive = &abort_guard;
448 step
449 }))
450 }
451}
452
453#[cfg(test)]
454mod tests {
455 use std::collections::HashMap;
456
457 use super::*;
458 use crate::core::testing::{Serve, ServeResult, echo, reply, test_drive};
459 use futures::StreamExt;
460 use switchyard_protocol::{
461 LlmResponse, LlmResponseChunk, completion_text, text_request, text_response,
462 };
463
464 #[derive(Debug, thiserror::Error)]
465 #[error("{0}")]
466 struct TestError(&'static str);
467
468 fn test_error(message: &'static str) -> LibsyError {
469 LibsyError::external("test", TestError(message))
470 }
471
472 struct TestAlgo {
475 target_set: Vec<ModelId>,
476 }
477
478 #[async_trait]
479 impl Algorithm for TestAlgo {
480 fn name(&self) -> &str {
481 "test"
482 }
483
484 async fn route(
485 self: Arc<Self>,
486 driver: Driver,
487 request: Request,
488 ) -> Result<RoutingOutcome> {
489 let target = self
490 .target_set
491 .first()
492 .ok_or(LibsyError::NoTargets)?
493 .clone();
494 let response = driver
495 .call_model(request.clone(), vec![target.clone()])
496 .await?;
497 driver.set_evidence(serde_json::json!({"source": "test"}));
498 driver.set_evidence_if_empty(serde_json::json!({"source": "ignored"}));
499 Ok(RoutingOutcome::answered(target, request, response))
500 }
501 }
502
503 fn orch(target_set: Vec<ModelId>) -> Arc<dyn Algorithm> {
505 Arc::new(TestAlgo { target_set })
506 }
507
508 fn request() -> Request {
509 Request {
510 llm_request: text_request(Some("auto".to_string()), "hi".to_string()),
511 raw_request: None,
512 metadata: None,
513 }
514 }
515
516 #[test]
517 fn routing_outcome_constructors_stamp_selection_and_preserve_payloads() {
518 let outcome = RoutingOutcome::route_to(
519 "selected".into(),
520 target_set(&["fallback-one", "fallback-two"]),
521 request(),
522 );
523
524 assert_eq!(
525 outcome.selected_model_ids,
526 target_set(&["selected", "fallback-one", "fallback-two"])
527 );
528 assert_eq!(outcome.request.model_id().as_deref(), Some("selected"));
529 assert!(outcome.response.is_none());
530 assert!(outcome.metadata.is_none());
531
532 let outcome = RoutingOutcome::route_to("only".into(), Vec::new(), request());
533 assert_eq!(outcome.selected_model_ids, target_set(&["only"]));
534
535 let outcome = RoutingOutcome::answered(
536 "answered".into(),
537 request(),
538 Response {
539 llm_response: LlmResponse::Agg(text_response(None, "existing")),
540 metadata: None,
541 },
542 );
543
544 assert_eq!(outcome.selected_model_ids, target_set(&["answered"]));
545 assert_eq!(outcome.request.model_id().as_deref(), Some("answered"));
546 assert_eq!(
547 outcome
548 .response
549 .as_ref()
550 .and_then(|response| response.llm_response.as_agg())
551 .map(completion_text),
552 Some("existing".to_string())
553 );
554 }
555
556 fn target_set(names: &[&str]) -> Vec<ModelId> {
557 names.iter().map(|name| ModelId::from(*name)).collect()
558 }
559
560 #[tokio::test]
561 async fn typed_driver_preserves_call_and_stream_boundaries() -> Result<()> {
562 tokio::time::timeout(std::time::Duration::from_secs(1), async {
563 let (driver, mut step_rx) = Driver::new("test");
566 let first_driver = driver.clone();
567 let mut first = tokio::spawn(async move {
568 first_driver
569 .call_model(request(), vec![ModelId::from("first")])
570 .await
571 });
572 let second = tokio::spawn(async move {
573 driver
574 .call_model(request(), vec![ModelId::from("second")])
575 .await
576 });
577
578 let mut calls = HashMap::new();
579 for _ in 0..2 {
580 let step = step_rx.recv().await.ok_or(DriverError::StreamClosed)??;
581 let Step::CallModel(call) = step else {
582 return Err(test_error("expected a CallModel step"));
583 };
584 let selected_model = call
585 .models
586 .first()
587 .ok_or_else(|| test_error("model call has no candidates"))?
588 .to_string();
589 calls.insert(selected_model, call);
590 }
591 assert!(
592 tokio::time::timeout(std::time::Duration::from_millis(20), &mut first)
593 .await
594 .is_err(),
595 "call completed before the host responded"
596 );
597 calls
598 .remove("second")
599 .ok_or_else(|| test_error("missing second call"))?
600 .respond(Ok(reply("second response")))?;
601 calls
602 .remove("first")
603 .ok_or_else(|| test_error("missing first call"))?
604 .respond(Ok(reply("first response")))?;
605
606 let first_response = first
607 .await
608 .map_err(|source| LibsyError::external("joining a test task", source))??;
609 let second_response = second
610 .await
611 .map_err(|source| LibsyError::external("joining a test task", source))??;
612 assert_eq!(
613 first_response.llm_response.as_agg().map(completion_text),
614 Some("first response".to_string())
615 );
616 assert_eq!(
617 second_response.llm_response.as_agg().map(completion_text),
618 Some("second response".to_string())
619 );
620
621 let (driver, mut step_rx) = Driver::new("test");
623 let producer = tokio::spawn(async move {
624 driver
625 .call_model(request(), vec![ModelId::from("dropped")])
626 .await
627 });
628 let step = step_rx.recv().await.ok_or(DriverError::StreamClosed)??;
629 let Step::CallModel(call) = step else {
630 return Err(test_error("expected a CallModel step"));
631 };
632 drop(call);
633 let result = producer
634 .await
635 .map_err(|source| LibsyError::external("joining a test task", source))?;
636 assert!(matches!(
637 result,
638 Err(LibsyError::Driver(DriverError::ResponseDropped))
639 ));
640
641 let (driver, step_rx) = Driver::new("test");
643 drop(step_rx);
644 let result = driver
645 .call_model(request(), vec![ModelId::from("closed")])
646 .await;
647 assert!(matches!(
648 result,
649 Err(LibsyError::Driver(DriverError::StreamClosed))
650 ));
651 Ok(())
652 })
653 .await
654 .map_err(|error| LibsyError::external("waiting for typed driver boundaries", error))?
655 }
656
657 #[test]
658 fn target_lookup_returns_the_missing_target() {
659 let error = ensure_model_is_target(&target_set(&[]), &ModelId::from("missing")).err();
660 assert!(matches!(
661 error,
662 Some(LibsyError::TargetNotFound { target }) if target == "missing"
663 ));
664 }
665
666 fn streaming_orch(chunks: Vec<LlmResponseChunk>) -> (Arc<dyn Algorithm>, impl Serve) {
669 let algo = orch(target_set(&["stream/model"]));
670 let serve = move |_target: ModelId, _request: Request| {
671 let chunks = chunks.clone();
672 async move {
673 let stream =
674 futures::stream::iter(chunks.into_iter().map(|chunk| Ok(chunk.into()))).boxed();
675 Ok(Response {
676 llm_response: LlmResponse::Stream(stream),
677 metadata: None,
678 })
679 }
680 };
681 (algo, serve)
682 }
683
684 #[tokio::test]
685 async fn run_returns_a_streamed_response_the_caller_aggregates() -> Result<()> {
686 let (orch, serve) = streaming_orch(vec![
689 LlmResponseChunk::MessageStart {
690 id: Some("m1".to_string()),
691 model: Some("stream/model".to_string()),
692 },
693 LlmResponseChunk::TextDelta {
694 index: 0,
695 text: "hel".to_string(),
696 },
697 LlmResponseChunk::TextDelta {
698 index: 0,
699 text: "lo".to_string(),
700 },
701 LlmResponseChunk::MessageStop {
702 reason: Some("stop".to_string()),
703 },
704 ]);
705 let (selected_model, response) = test_drive(orch, request(), serve).await?;
706 let agg = response
708 .llm_response
709 .into_agg()
710 .await
711 .map_err(|error| LibsyError::external("aggregating response stream", error))?;
712 assert_eq!(completion_text(&agg), "hello");
713 assert_eq!(agg.model.as_deref(), Some("stream/model"));
714 assert_eq!(selected_model, "stream/model");
715 Ok(())
716 }
717
718 #[tokio::test]
719 async fn aggregating_a_streamed_response_propagates_a_mid_stream_error() -> Result<()> {
720 let (orch, serve) = streaming_orch(vec![
723 LlmResponseChunk::TextDelta {
724 index: 0,
725 text: "partial".to_string(),
726 },
727 LlmResponseChunk::StreamError {
728 message: "upstream exploded".to_string(),
729 },
730 ]);
731 let (_, response) = test_drive(orch, request(), serve).await?;
732 match response.llm_response.into_agg().await {
733 Ok(_) => panic!("expected a mid-stream error, got an aggregate"),
734 Err(err) => {
735 assert!(err.to_string().contains("upstream exploded"));
736 Ok(())
737 }
738 }
739 }
740
741 #[tokio::test]
742 async fn run_offloads_via_promise_then_finishes() -> Result<()> {
743 let stream = orch(target_set(&["offload/model"])).run_stream(request());
746 tokio::pin!(stream);
747
748 let mut saw_call = false;
749 let mut final_completion = None;
750 while let Some(step) = stream.next().await {
751 match step? {
752 Step::CallModel(call) => {
753 saw_call = true;
754 assert_eq!(call.models, vec![ModelId::from("offload/model")]);
755 call.respond(Ok(Response {
757 llm_response: LlmResponse::Agg(text_response(
758 None,
759 "fulfilled".to_string(),
760 )),
761 metadata: None,
762 }))?;
763 }
764 Step::Done(outcome) => {
765 let metadata = outcome
766 .metadata
767 .as_ref()
768 .expect("run_stream should attach outcome metadata");
769 assert_eq!(metadata.algorithm, "test");
770 assert_eq!(
771 uuid::Uuid::parse_str(metadata.outcome_id())
772 .expect("outcome id should be a UUID")
773 .get_version_num(),
774 7
775 );
776 assert_eq!(
777 metadata.evidence,
778 Some(serde_json::json!({"source": "test"}))
779 );
780 let response = outcome
781 .response
782 .ok_or_else(|| test_error("expected an answered outcome"))?;
783 final_completion = Some(
784 response
785 .llm_response
786 .as_agg()
787 .map(completion_text)
788 .unwrap_or_default(),
789 );
790 }
791 }
792 }
793
794 assert!(saw_call, "expected a CallModel step before Done");
795 assert_eq!(
796 final_completion.ok_or_else(|| test_error("no Done step"))?,
797 "fulfilled"
798 );
799 Ok(())
800 }
801
802 #[tokio::test(flavor = "multi_thread", worker_threads = 12)]
803 async fn requests_are_processed_in_parallel() -> Result<()> {
804 use std::time::Duration;
805 use tokio::sync::Barrier;
806
807 const N: usize = 12;
808
809 let barrier = Arc::new(Barrier::new(N));
814 let algo = orch(target_set(&["m"]));
816
817 let mut handles = Vec::new();
818 for _ in 0..N {
819 let algo = algo.clone();
820 let barrier = barrier.clone();
821 let serve = move |target: ModelId, _request: Request| {
822 let barrier = barrier.clone();
823 async move {
824 barrier.wait().await;
825 Ok(reply(target))
826 }
827 };
828 handles.push(tokio::spawn(async move {
829 test_drive(algo, request(), serve)
830 .await
831 .map(|(_, response)| {
832 response
833 .llm_response
834 .as_agg()
835 .map(completion_text)
836 .unwrap_or_default()
837 })
838 }));
839 }
840
841 for handle in handles {
842 let completion = tokio::time::timeout(Duration::from_secs(5), handle)
844 .await
845 .map_err(|error| LibsyError::external("waiting for test task", error))?
846 .map_err(|source| LibsyError::external("joining a test task", source))??;
847 assert_eq!(completion, "m");
848 }
849 Ok(())
850 }
851
852 #[tokio::test]
853 async fn offload_error_propagates_back_to_the_algorithm() -> Result<()> {
854 let stream = orch(target_set(&["offload/model"])).run_stream(request());
858 tokio::pin!(stream);
859
860 let mut saw_error = false;
861 while let Some(step) = stream.next().await {
862 match step {
863 Ok(Step::CallModel(call)) => {
864 call.respond(Err(test_error("upstream model call failed")))?;
865 }
866 Ok(Step::Done(..)) => {
867 return Err(test_error(
868 "expected the offload error to propagate, got a response",
869 ));
870 }
871 Err(err) => {
872 assert!(err.to_string().contains("upstream model call failed"));
874 saw_error = true;
875 }
876 }
877 }
878
879 assert!(saw_error, "expected an error step");
880 Ok(())
881 }
882
883 #[tokio::test]
884 async fn dropping_the_stream_cancels_the_algorithm_task() -> Result<()> {
885 use std::sync::atomic::{AtomicBool, Ordering};
886 use std::time::Duration;
887 use tokio::sync::mpsc;
888
889 struct DropGuard(Arc<AtomicBool>);
892 impl Drop for DropGuard {
893 fn drop(&mut self) {
894 self.0.store(true, Ordering::SeqCst);
895 }
896 }
897
898 struct StuckAlgo {
899 started: mpsc::UnboundedSender<()>,
900 dropped: Arc<AtomicBool>,
901 }
902
903 #[async_trait]
904 impl Algorithm for StuckAlgo {
905 fn name(&self) -> &str {
906 "stuck"
907 }
908
909 async fn route(
910 self: Arc<Self>,
911 _driver: Driver,
912 _request: Request,
913 ) -> Result<RoutingOutcome> {
914 let _guard = DropGuard(self.dropped.clone());
915 let _ = self.started.send(());
916 std::future::pending::<()>().await;
918 unreachable!()
919 }
920 }
921
922 let (started_tx, mut started_rx) = mpsc::unbounded_channel();
923 let dropped = Arc::new(AtomicBool::new(false));
924 let algo: Arc<dyn Algorithm> = Arc::new(StuckAlgo {
925 started: started_tx,
926 dropped: dropped.clone(),
927 });
928
929 let stream = algo.run_stream(request());
930 started_rx
931 .recv()
932 .await
933 .ok_or_else(|| test_error("task never started"))?;
934 drop(stream);
935 tokio::time::sleep(Duration::from_millis(100)).await;
936
937 assert!(
938 dropped.load(Ordering::SeqCst),
939 "algorithm task was NOT cancelled after dropping the stream"
940 );
941 Ok(())
942 }
943
944 #[tokio::test]
945 async fn route_panic_surfaces_as_a_stream_error() -> Result<()> {
946 struct Panicky;
949
950 #[async_trait]
951 impl Algorithm for Panicky {
952 fn name(&self) -> &str {
953 "panicky"
954 }
955
956 async fn route(
957 self: Arc<Self>,
958 _driver: Driver,
959 _request: Request,
960 ) -> Result<RoutingOutcome> {
961 panic!("boom");
962 }
963 }
964
965 let algo: Arc<dyn Algorithm> = Arc::new(Panicky);
966 let stream = algo.run_stream(request());
967 tokio::pin!(stream);
968
969 let mut saw_error = false;
970 while let Some(step) = stream.next().await {
971 match step {
972 Err(err) => {
973 assert!(err.to_string().contains("algorithm task panicked: boom"));
975 saw_error = true;
976 }
977 Ok(_) => return Err(test_error("expected the panic to surface as an error step")),
978 }
979 }
980
981 assert!(saw_error, "expected an error step from the panicked task");
982 Ok(())
983 }
984
985 #[tokio::test]
989 async fn a_panic_with_a_leaked_driver_clone_still_terminates_the_run() -> Result<()> {
990 struct LeakyPanic;
991
992 #[async_trait]
993 impl Algorithm for LeakyPanic {
994 fn name(&self) -> &str {
995 "leaky_panic"
996 }
997
998 async fn route(
999 self: Arc<Self>,
1000 driver: Driver,
1001 _request: Request,
1002 ) -> Result<RoutingOutcome> {
1003 tokio::spawn(async move {
1004 let _keep_alive = driver;
1006 std::future::pending::<()>().await;
1007 });
1008 tokio::task::yield_now().await;
1009 panic!("boom");
1010 }
1011 }
1012
1013 let algo: Arc<dyn Algorithm> = Arc::new(LeakyPanic);
1014 let result = tokio::time::timeout(
1016 std::time::Duration::from_secs(1),
1017 test_drive(algo, request(), echo()),
1018 )
1019 .await
1020 .map_err(|error| LibsyError::external("waiting for the panicked run to end", error))?;
1021
1022 match result {
1023 Ok(_) => Err(test_error(
1024 "expected the panic to end the run with an error",
1025 )),
1026 Err(err) => {
1027 assert!(err.to_string().contains("algorithm task panicked: boom"));
1028 Ok(())
1029 }
1030 }
1031 }
1032
1033 #[tokio::test]
1034 async fn cancelling_run_cancels_the_algorithm_task() -> Result<()> {
1035 use std::sync::atomic::{AtomicBool, Ordering};
1036 use std::time::Duration;
1037 use tokio::sync::mpsc;
1038
1039 struct DropGuard(Arc<AtomicBool>);
1042 impl Drop for DropGuard {
1043 fn drop(&mut self) {
1044 self.0.store(true, Ordering::SeqCst);
1045 }
1046 }
1047
1048 struct StuckAlgo {
1049 started: mpsc::UnboundedSender<()>,
1050 dropped: Arc<AtomicBool>,
1051 }
1052
1053 #[async_trait]
1054 impl Algorithm for StuckAlgo {
1055 fn name(&self) -> &str {
1056 "stuck"
1057 }
1058
1059 async fn route(
1060 self: Arc<Self>,
1061 _driver: Driver,
1062 _request: Request,
1063 ) -> Result<RoutingOutcome> {
1064 let _guard = DropGuard(self.dropped.clone());
1065 let _ = self.started.send(());
1066 std::future::pending::<()>().await;
1069 unreachable!()
1070 }
1071 }
1072
1073 let (started_tx, mut started_rx) = mpsc::unbounded_channel();
1074 let dropped = Arc::new(AtomicBool::new(false));
1075 let algo: Arc<dyn Algorithm> = Arc::new(StuckAlgo {
1076 started: started_tx,
1077 dropped: dropped.clone(),
1078 });
1079
1080 let run_task = tokio::spawn(async move { test_drive(algo, request(), echo()).await });
1083 started_rx
1084 .recv()
1085 .await
1086 .ok_or_else(|| test_error("task never started"))?;
1087 run_task.abort();
1088 tokio::time::sleep(Duration::from_millis(100)).await;
1089
1090 assert!(
1091 dropped.load(Ordering::SeqCst),
1092 "algorithm task was NOT cancelled after cancelling run"
1093 );
1094 Ok(())
1095 }
1096
1097 struct Hedge {
1102 winner: String,
1103 loser: String,
1104 }
1105
1106 #[async_trait]
1107 impl Algorithm for Hedge {
1108 fn name(&self) -> &str {
1109 "hedge"
1110 }
1111
1112 async fn route(
1113 self: Arc<Self>,
1114 driver: Driver,
1115 request: Request,
1116 ) -> Result<RoutingOutcome> {
1117 let outcome_request = request.clone();
1118 let win = driver.call_model(request.clone(), vec![self.winner.clone().into()]);
1119 let lose = driver.call_model(request, vec![self.loser.clone().into()]);
1120 tokio::select! {
1122 res = win => Ok(RoutingOutcome::answered(
1123 self.winner.clone().into(),
1124 outcome_request,
1125 res?,
1126 )),
1127 res = lose => Ok(RoutingOutcome::answered(
1128 self.loser.clone().into(),
1129 outcome_request,
1130 res?,
1131 )),
1132 }
1133 }
1134 }
1135
1136 fn hedge(loser_delay: Option<std::time::Duration>) -> (Arc<dyn Algorithm>, impl Serve) {
1140 let started = Arc::new(tokio::sync::Notify::new());
1141 let algo = Arc::new(Hedge {
1142 winner: "winner".to_string(),
1143 loser: "loser".to_string(),
1144 });
1145 let serve = move |target: ModelId, _request: Request| {
1146 let started = started.clone();
1147 async move {
1148 if target == "loser" {
1149 started.notify_one();
1150 match loser_delay {
1151 Some(delay) => tokio::time::sleep(delay).await,
1152 None => std::future::pending::<()>().await,
1153 }
1154 } else {
1155 started.notified().await;
1156 }
1157 Ok(reply(target))
1158 }
1159 };
1160 (algo, serve)
1161 }
1162
1163 #[tokio::test]
1164 async fn run_returns_the_winner_without_a_late_loser_overwriting_it() -> Result<()> {
1165 let (algo, serve) = hedge(Some(std::time::Duration::from_millis(50)));
1168 let (_, response) = test_drive(algo, request(), serve).await?;
1169 assert_eq!(
1170 response
1171 .llm_response
1172 .as_agg()
1173 .map(completion_text)
1174 .unwrap_or_default(),
1175 "winner"
1176 );
1177 Ok(())
1178 }
1179
1180 #[tokio::test]
1181 async fn run_returns_the_winner_without_hanging_on_a_pending_loser() -> Result<()> {
1182 let (algo, serve) = hedge(None);
1185 let run = test_drive(algo, request(), serve);
1186 let (_, response) = tokio::time::timeout(std::time::Duration::from_secs(1), run)
1187 .await
1188 .map_err(|error| LibsyError::external("waiting for pending loser", error))??;
1189 assert_eq!(
1190 response
1191 .llm_response
1192 .as_agg()
1193 .map(completion_text)
1194 .unwrap_or_default(),
1195 "winner"
1196 );
1197 Ok(())
1198 }
1199
1200 #[tokio::test]
1201 async fn run_surfaces_a_terminal_error_with_many_calls_in_flight() -> Result<()> {
1202 use std::sync::atomic::{AtomicUsize, Ordering};
1203
1204 const N: usize = 10;
1207
1208 struct FanOutThenError {
1211 all_started: Arc<tokio::sync::Notify>,
1212 n: usize,
1213 }
1214
1215 #[async_trait]
1216 impl Algorithm for FanOutThenError {
1217 fn name(&self) -> &str {
1218 "fan_out_then_error"
1219 }
1220
1221 async fn route(
1222 self: Arc<Self>,
1223 driver: Driver,
1224 request: Request,
1225 ) -> Result<RoutingOutcome> {
1226 let offloads = futures::future::join_all(
1227 (0..self.n)
1228 .map(|i| driver.call_model(request.clone(), vec![format!("m{i}").into()])),
1229 );
1230 tokio::select! {
1231 _ = offloads => Err(test_error("offloads unexpectedly completed")),
1232 _ = self.all_started.notified() => {
1233 Err(test_error("terminal error while calls pending"))
1234 }
1235 }
1236 }
1237 }
1238
1239 let all_started = Arc::new(tokio::sync::Notify::new());
1240 let algo: Arc<dyn Algorithm> = Arc::new(FanOutThenError {
1241 all_started: all_started.clone(),
1242 n: N,
1243 });
1244
1245 let started = Arc::new(AtomicUsize::new(0));
1247 let serve = move |_target: ModelId, _request: Request| {
1248 let started = started.clone();
1249 let all_started = all_started.clone();
1250 async move {
1251 if started.fetch_add(1, Ordering::SeqCst) + 1 == N {
1252 all_started.notify_one();
1253 }
1254 std::future::pending::<ServeResult>().await
1255 }
1256 };
1257
1258 let run = test_drive(algo, request(), serve);
1261 let result = tokio::time::timeout(std::time::Duration::from_millis(500), run)
1262 .await
1263 .map_err(|error| {
1264 LibsyError::external("waiting for terminal error with full call cap", error)
1265 })?;
1266 match result {
1267 Ok(_) => Err(test_error("expected the terminal error, got a response")),
1268 Err(err) => {
1269 assert!(
1270 err.to_string()
1271 .contains("terminal error while calls pending")
1272 );
1273 Ok(())
1274 }
1275 }
1276 }
1277}