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]
399pub trait Algorithm: Send + Sync + 'static {
400 fn name(&self) -> &str;
404
405 async fn route(self: Arc<Self>, driver: Driver, request: Request) -> Result<RoutingOutcome>;
409
410 fn run_stream(self: Arc<Self>, request: Request) -> StepStream {
419 let (driver, step_rx) = Driver::new(self.name());
420 let span = observability::run_span(self.name(), &request);
421 let handle = tokio::spawn(
422 async move {
423 let algorithm = self.name().to_string();
424 let route = AssertUnwindSafe(self.route(driver.clone(), request)).catch_unwind();
426 let result = observability::observe_run(&algorithm, async move {
427 route.await.unwrap_or_else(|payload| {
428 Err(LibsyError::AlgorithmError {
429 message: format!(
430 "algorithm task panicked: {}",
431 panic_message(payload.as_ref())
432 ),
433 })
434 })
435 })
436 .await;
437
438 let _ = driver.finish(result).await;
439 }
440 .instrument(span),
441 );
442 let abort_guard = AbortOnDrop(handle.abort_handle());
444 Box::pin(ReceiverStream::new(step_rx).map(move |step| {
445 let _keep_alive = &abort_guard;
447 step
448 }))
449 }
450}
451
452#[cfg(test)]
453mod tests {
454 use std::collections::HashMap;
455
456 use super::*;
457 use crate::core::testing::{Serve, ServeResult, echo, reply, test_drive};
458 use futures::StreamExt;
459 use switchyard_protocol::{
460 LlmResponse, LlmResponseChunk, completion_text, text_request, text_response,
461 };
462
463 #[derive(Debug, thiserror::Error)]
464 #[error("{0}")]
465 struct TestError(&'static str);
466
467 fn test_error(message: &'static str) -> LibsyError {
468 LibsyError::external("test", TestError(message))
469 }
470
471 struct TestAlgo {
474 target_set: Vec<ModelId>,
475 }
476
477 #[async_trait]
478 impl Algorithm for TestAlgo {
479 fn name(&self) -> &str {
480 "test"
481 }
482
483 async fn route(
484 self: Arc<Self>,
485 driver: Driver,
486 request: Request,
487 ) -> Result<RoutingOutcome> {
488 let target = self
489 .target_set
490 .first()
491 .ok_or(LibsyError::NoTargets)?
492 .clone();
493 let response = driver
494 .call_model(request.clone(), vec![target.clone()])
495 .await?;
496 driver.set_evidence(serde_json::json!({"source": "test"}));
497 driver.set_evidence_if_empty(serde_json::json!({"source": "ignored"}));
498 Ok(RoutingOutcome::answered(target, request, response))
499 }
500 }
501
502 fn orch(target_set: Vec<ModelId>) -> Arc<dyn Algorithm> {
504 Arc::new(TestAlgo { target_set })
505 }
506
507 fn request() -> Request {
508 Request {
509 llm_request: text_request(Some("auto".to_string()), "hi".to_string()),
510 raw_request: None,
511 metadata: None,
512 }
513 }
514
515 #[test]
516 fn routing_outcome_constructors_stamp_selection_and_preserve_payloads() {
517 let outcome = RoutingOutcome::route_to(
518 "selected".into(),
519 target_set(&["fallback-one", "fallback-two"]),
520 request(),
521 );
522
523 assert_eq!(
524 outcome.selected_model_ids,
525 target_set(&["selected", "fallback-one", "fallback-two"])
526 );
527 assert_eq!(outcome.request.model_id().as_deref(), Some("selected"));
528 assert!(outcome.response.is_none());
529 assert!(outcome.metadata.is_none());
530
531 let outcome = RoutingOutcome::route_to("only".into(), Vec::new(), request());
532 assert_eq!(outcome.selected_model_ids, target_set(&["only"]));
533
534 let outcome = RoutingOutcome::answered(
535 "answered".into(),
536 request(),
537 Response {
538 llm_response: LlmResponse::Agg(text_response(None, "existing")),
539 metadata: None,
540 },
541 );
542
543 assert_eq!(outcome.selected_model_ids, target_set(&["answered"]));
544 assert_eq!(outcome.request.model_id().as_deref(), Some("answered"));
545 assert_eq!(
546 outcome
547 .response
548 .as_ref()
549 .and_then(|response| response.llm_response.as_agg())
550 .map(completion_text),
551 Some("existing".to_string())
552 );
553 }
554
555 fn target_set(names: &[&str]) -> Vec<ModelId> {
556 names.iter().map(|name| ModelId::from(*name)).collect()
557 }
558
559 #[tokio::test]
560 async fn typed_driver_preserves_call_and_stream_boundaries() -> Result<()> {
561 tokio::time::timeout(std::time::Duration::from_secs(1), async {
562 let (driver, mut step_rx) = Driver::new("test");
565 let first_driver = driver.clone();
566 let mut first = tokio::spawn(async move {
567 first_driver
568 .call_model(request(), vec![ModelId::from("first")])
569 .await
570 });
571 let second = tokio::spawn(async move {
572 driver
573 .call_model(request(), vec![ModelId::from("second")])
574 .await
575 });
576
577 let mut calls = HashMap::new();
578 for _ in 0..2 {
579 let step = step_rx.recv().await.ok_or(DriverError::StreamClosed)??;
580 let Step::CallModel(call) = step else {
581 return Err(test_error("expected a CallModel step"));
582 };
583 let selected_model = call
584 .models
585 .first()
586 .ok_or_else(|| test_error("model call has no candidates"))?
587 .to_string();
588 calls.insert(selected_model, call);
589 }
590 assert!(
591 tokio::time::timeout(std::time::Duration::from_millis(20), &mut first)
592 .await
593 .is_err(),
594 "call completed before the host responded"
595 );
596 calls
597 .remove("second")
598 .ok_or_else(|| test_error("missing second call"))?
599 .respond(Ok(reply("second response")))?;
600 calls
601 .remove("first")
602 .ok_or_else(|| test_error("missing first call"))?
603 .respond(Ok(reply("first response")))?;
604
605 let first_response = first
606 .await
607 .map_err(|source| LibsyError::external("joining a test task", source))??;
608 let second_response = second
609 .await
610 .map_err(|source| LibsyError::external("joining a test task", source))??;
611 assert_eq!(
612 first_response.llm_response.as_agg().map(completion_text),
613 Some("first response".to_string())
614 );
615 assert_eq!(
616 second_response.llm_response.as_agg().map(completion_text),
617 Some("second response".to_string())
618 );
619
620 let (driver, mut step_rx) = Driver::new("test");
622 let producer = tokio::spawn(async move {
623 driver
624 .call_model(request(), vec![ModelId::from("dropped")])
625 .await
626 });
627 let step = step_rx.recv().await.ok_or(DriverError::StreamClosed)??;
628 let Step::CallModel(call) = step else {
629 return Err(test_error("expected a CallModel step"));
630 };
631 drop(call);
632 let result = producer
633 .await
634 .map_err(|source| LibsyError::external("joining a test task", source))?;
635 assert!(matches!(
636 result,
637 Err(LibsyError::Driver(DriverError::ResponseDropped))
638 ));
639
640 let (driver, step_rx) = Driver::new("test");
642 drop(step_rx);
643 let result = driver
644 .call_model(request(), vec![ModelId::from("closed")])
645 .await;
646 assert!(matches!(
647 result,
648 Err(LibsyError::Driver(DriverError::StreamClosed))
649 ));
650 Ok(())
651 })
652 .await
653 .map_err(|error| LibsyError::external("waiting for typed driver boundaries", error))?
654 }
655
656 #[test]
657 fn target_lookup_returns_the_missing_target() {
658 let error = ensure_model_is_target(&target_set(&[]), &ModelId::from("missing")).err();
659 assert!(matches!(
660 error,
661 Some(LibsyError::TargetNotFound { target }) if target == "missing"
662 ));
663 }
664
665 fn streaming_orch(chunks: Vec<LlmResponseChunk>) -> (Arc<dyn Algorithm>, impl Serve) {
668 let algo = orch(target_set(&["stream/model"]));
669 let serve = move |_target: ModelId, _request: Request| {
670 let chunks = chunks.clone();
671 async move {
672 let stream =
673 futures::stream::iter(chunks.into_iter().map(|chunk| Ok(chunk.into()))).boxed();
674 Ok(Response {
675 llm_response: LlmResponse::Stream(stream),
676 metadata: None,
677 })
678 }
679 };
680 (algo, serve)
681 }
682
683 #[tokio::test]
684 async fn run_returns_a_streamed_response_the_caller_aggregates() -> Result<()> {
685 let (orch, serve) = streaming_orch(vec![
688 LlmResponseChunk::MessageStart {
689 id: Some("m1".to_string()),
690 model: Some("stream/model".to_string()),
691 },
692 LlmResponseChunk::TextDelta {
693 index: 0,
694 text: "hel".to_string(),
695 },
696 LlmResponseChunk::TextDelta {
697 index: 0,
698 text: "lo".to_string(),
699 },
700 LlmResponseChunk::MessageStop {
701 reason: Some("stop".to_string()),
702 },
703 ]);
704 let (selected_model, response) = test_drive(orch, request(), serve).await?;
705 let agg = response
707 .llm_response
708 .into_agg()
709 .await
710 .map_err(|error| LibsyError::external("aggregating response stream", error))?;
711 assert_eq!(completion_text(&agg), "hello");
712 assert_eq!(agg.model.as_deref(), Some("stream/model"));
713 assert_eq!(selected_model, "stream/model");
714 Ok(())
715 }
716
717 #[tokio::test]
718 async fn aggregating_a_streamed_response_propagates_a_mid_stream_error() -> Result<()> {
719 let (orch, serve) = streaming_orch(vec![
722 LlmResponseChunk::TextDelta {
723 index: 0,
724 text: "partial".to_string(),
725 },
726 LlmResponseChunk::StreamError {
727 message: "upstream exploded".to_string(),
728 },
729 ]);
730 let (_, response) = test_drive(orch, request(), serve).await?;
731 match response.llm_response.into_agg().await {
732 Ok(_) => panic!("expected a mid-stream error, got an aggregate"),
733 Err(err) => {
734 assert!(err.to_string().contains("upstream exploded"));
735 Ok(())
736 }
737 }
738 }
739
740 #[tokio::test]
741 async fn run_offloads_via_promise_then_finishes() -> Result<()> {
742 let stream = orch(target_set(&["offload/model"])).run_stream(request());
745 tokio::pin!(stream);
746
747 let mut saw_call = false;
748 let mut final_completion = None;
749 while let Some(step) = stream.next().await {
750 match step? {
751 Step::CallModel(call) => {
752 saw_call = true;
753 assert_eq!(call.models, vec![ModelId::from("offload/model")]);
754 call.respond(Ok(Response {
756 llm_response: LlmResponse::Agg(text_response(
757 None,
758 "fulfilled".to_string(),
759 )),
760 metadata: None,
761 }))?;
762 }
763 Step::Done(outcome) => {
764 let metadata = outcome
765 .metadata
766 .as_ref()
767 .expect("run_stream should attach outcome metadata");
768 assert_eq!(metadata.algorithm, "test");
769 assert_eq!(
770 uuid::Uuid::parse_str(metadata.outcome_id())
771 .expect("outcome id should be a UUID")
772 .get_version_num(),
773 7
774 );
775 assert_eq!(
776 metadata.evidence,
777 Some(serde_json::json!({"source": "test"}))
778 );
779 let response = outcome
780 .response
781 .ok_or_else(|| test_error("expected an answered outcome"))?;
782 final_completion = Some(
783 response
784 .llm_response
785 .as_agg()
786 .map(completion_text)
787 .unwrap_or_default(),
788 );
789 }
790 }
791 }
792
793 assert!(saw_call, "expected a CallModel step before Done");
794 assert_eq!(
795 final_completion.ok_or_else(|| test_error("no Done step"))?,
796 "fulfilled"
797 );
798 Ok(())
799 }
800
801 #[tokio::test(flavor = "multi_thread", worker_threads = 12)]
802 async fn requests_are_processed_in_parallel() -> Result<()> {
803 use std::time::Duration;
804 use tokio::sync::Barrier;
805
806 const N: usize = 12;
807
808 let barrier = Arc::new(Barrier::new(N));
813 let algo = orch(target_set(&["m"]));
815
816 let mut handles = Vec::new();
817 for _ in 0..N {
818 let algo = algo.clone();
819 let barrier = barrier.clone();
820 let serve = move |target: ModelId, _request: Request| {
821 let barrier = barrier.clone();
822 async move {
823 barrier.wait().await;
824 Ok(reply(target))
825 }
826 };
827 handles.push(tokio::spawn(async move {
828 test_drive(algo, request(), serve)
829 .await
830 .map(|(_, response)| {
831 response
832 .llm_response
833 .as_agg()
834 .map(completion_text)
835 .unwrap_or_default()
836 })
837 }));
838 }
839
840 for handle in handles {
841 let completion = tokio::time::timeout(Duration::from_secs(5), handle)
843 .await
844 .map_err(|error| LibsyError::external("waiting for test task", error))?
845 .map_err(|source| LibsyError::external("joining a test task", source))??;
846 assert_eq!(completion, "m");
847 }
848 Ok(())
849 }
850
851 #[tokio::test]
852 async fn offload_error_propagates_back_to_the_algorithm() -> Result<()> {
853 let stream = orch(target_set(&["offload/model"])).run_stream(request());
857 tokio::pin!(stream);
858
859 let mut saw_error = false;
860 while let Some(step) = stream.next().await {
861 match step {
862 Ok(Step::CallModel(call)) => {
863 call.respond(Err(test_error("upstream model call failed")))?;
864 }
865 Ok(Step::Done(..)) => {
866 return Err(test_error(
867 "expected the offload error to propagate, got a response",
868 ));
869 }
870 Err(err) => {
871 assert!(err.to_string().contains("upstream model call failed"));
873 saw_error = true;
874 }
875 }
876 }
877
878 assert!(saw_error, "expected an error step");
879 Ok(())
880 }
881
882 #[tokio::test]
883 async fn dropping_the_stream_cancels_the_algorithm_task() -> Result<()> {
884 use std::sync::atomic::{AtomicBool, Ordering};
885 use std::time::Duration;
886 use tokio::sync::mpsc;
887
888 struct DropGuard(Arc<AtomicBool>);
891 impl Drop for DropGuard {
892 fn drop(&mut self) {
893 self.0.store(true, Ordering::SeqCst);
894 }
895 }
896
897 struct StuckAlgo {
898 started: mpsc::UnboundedSender<()>,
899 dropped: Arc<AtomicBool>,
900 }
901
902 #[async_trait]
903 impl Algorithm for StuckAlgo {
904 fn name(&self) -> &str {
905 "stuck"
906 }
907
908 async fn route(
909 self: Arc<Self>,
910 _driver: Driver,
911 _request: Request,
912 ) -> Result<RoutingOutcome> {
913 let _guard = DropGuard(self.dropped.clone());
914 let _ = self.started.send(());
915 std::future::pending::<()>().await;
917 unreachable!()
918 }
919 }
920
921 let (started_tx, mut started_rx) = mpsc::unbounded_channel();
922 let dropped = Arc::new(AtomicBool::new(false));
923 let algo: Arc<dyn Algorithm> = Arc::new(StuckAlgo {
924 started: started_tx,
925 dropped: dropped.clone(),
926 });
927
928 let stream = algo.run_stream(request());
929 started_rx
930 .recv()
931 .await
932 .ok_or_else(|| test_error("task never started"))?;
933 drop(stream);
934 tokio::time::sleep(Duration::from_millis(100)).await;
935
936 assert!(
937 dropped.load(Ordering::SeqCst),
938 "algorithm task was NOT cancelled after dropping the stream"
939 );
940 Ok(())
941 }
942
943 #[tokio::test]
944 async fn route_panic_surfaces_as_a_stream_error() -> Result<()> {
945 struct Panicky;
948
949 #[async_trait]
950 impl Algorithm for Panicky {
951 fn name(&self) -> &str {
952 "panicky"
953 }
954
955 async fn route(
956 self: Arc<Self>,
957 _driver: Driver,
958 _request: Request,
959 ) -> Result<RoutingOutcome> {
960 panic!("boom");
961 }
962 }
963
964 let algo: Arc<dyn Algorithm> = Arc::new(Panicky);
965 let stream = algo.run_stream(request());
966 tokio::pin!(stream);
967
968 let mut saw_error = false;
969 while let Some(step) = stream.next().await {
970 match step {
971 Err(err) => {
972 assert!(err.to_string().contains("algorithm task panicked: boom"));
974 saw_error = true;
975 }
976 Ok(_) => return Err(test_error("expected the panic to surface as an error step")),
977 }
978 }
979
980 assert!(saw_error, "expected an error step from the panicked task");
981 Ok(())
982 }
983
984 #[tokio::test]
988 async fn a_panic_with_a_leaked_driver_clone_still_terminates_the_run() -> Result<()> {
989 struct LeakyPanic;
990
991 #[async_trait]
992 impl Algorithm for LeakyPanic {
993 fn name(&self) -> &str {
994 "leaky_panic"
995 }
996
997 async fn route(
998 self: Arc<Self>,
999 driver: Driver,
1000 _request: Request,
1001 ) -> Result<RoutingOutcome> {
1002 tokio::spawn(async move {
1003 let _keep_alive = driver;
1005 std::future::pending::<()>().await;
1006 });
1007 tokio::task::yield_now().await;
1008 panic!("boom");
1009 }
1010 }
1011
1012 let algo: Arc<dyn Algorithm> = Arc::new(LeakyPanic);
1013 let result = tokio::time::timeout(
1015 std::time::Duration::from_secs(1),
1016 test_drive(algo, request(), echo()),
1017 )
1018 .await
1019 .map_err(|error| LibsyError::external("waiting for the panicked run to end", error))?;
1020
1021 match result {
1022 Ok(_) => Err(test_error(
1023 "expected the panic to end the run with an error",
1024 )),
1025 Err(err) => {
1026 assert!(err.to_string().contains("algorithm task panicked: boom"));
1027 Ok(())
1028 }
1029 }
1030 }
1031
1032 #[tokio::test]
1033 async fn cancelling_run_cancels_the_algorithm_task() -> Result<()> {
1034 use std::sync::atomic::{AtomicBool, Ordering};
1035 use std::time::Duration;
1036 use tokio::sync::mpsc;
1037
1038 struct DropGuard(Arc<AtomicBool>);
1041 impl Drop for DropGuard {
1042 fn drop(&mut self) {
1043 self.0.store(true, Ordering::SeqCst);
1044 }
1045 }
1046
1047 struct StuckAlgo {
1048 started: mpsc::UnboundedSender<()>,
1049 dropped: Arc<AtomicBool>,
1050 }
1051
1052 #[async_trait]
1053 impl Algorithm for StuckAlgo {
1054 fn name(&self) -> &str {
1055 "stuck"
1056 }
1057
1058 async fn route(
1059 self: Arc<Self>,
1060 _driver: Driver,
1061 _request: Request,
1062 ) -> Result<RoutingOutcome> {
1063 let _guard = DropGuard(self.dropped.clone());
1064 let _ = self.started.send(());
1065 std::future::pending::<()>().await;
1068 unreachable!()
1069 }
1070 }
1071
1072 let (started_tx, mut started_rx) = mpsc::unbounded_channel();
1073 let dropped = Arc::new(AtomicBool::new(false));
1074 let algo: Arc<dyn Algorithm> = Arc::new(StuckAlgo {
1075 started: started_tx,
1076 dropped: dropped.clone(),
1077 });
1078
1079 let run_task = tokio::spawn(async move { test_drive(algo, request(), echo()).await });
1082 started_rx
1083 .recv()
1084 .await
1085 .ok_or_else(|| test_error("task never started"))?;
1086 run_task.abort();
1087 tokio::time::sleep(Duration::from_millis(100)).await;
1088
1089 assert!(
1090 dropped.load(Ordering::SeqCst),
1091 "algorithm task was NOT cancelled after cancelling run"
1092 );
1093 Ok(())
1094 }
1095
1096 struct Hedge {
1101 winner: String,
1102 loser: String,
1103 }
1104
1105 #[async_trait]
1106 impl Algorithm for Hedge {
1107 fn name(&self) -> &str {
1108 "hedge"
1109 }
1110
1111 async fn route(
1112 self: Arc<Self>,
1113 driver: Driver,
1114 request: Request,
1115 ) -> Result<RoutingOutcome> {
1116 let outcome_request = request.clone();
1117 let win = driver.call_model(request.clone(), vec![self.winner.clone().into()]);
1118 let lose = driver.call_model(request, vec![self.loser.clone().into()]);
1119 tokio::select! {
1121 res = win => Ok(RoutingOutcome::answered(
1122 self.winner.clone().into(),
1123 outcome_request,
1124 res?,
1125 )),
1126 res = lose => Ok(RoutingOutcome::answered(
1127 self.loser.clone().into(),
1128 outcome_request,
1129 res?,
1130 )),
1131 }
1132 }
1133 }
1134
1135 fn hedge(loser_delay: Option<std::time::Duration>) -> (Arc<dyn Algorithm>, impl Serve) {
1139 let started = Arc::new(tokio::sync::Notify::new());
1140 let algo = Arc::new(Hedge {
1141 winner: "winner".to_string(),
1142 loser: "loser".to_string(),
1143 });
1144 let serve = move |target: ModelId, _request: Request| {
1145 let started = started.clone();
1146 async move {
1147 if target == "loser" {
1148 started.notify_one();
1149 match loser_delay {
1150 Some(delay) => tokio::time::sleep(delay).await,
1151 None => std::future::pending::<()>().await,
1152 }
1153 } else {
1154 started.notified().await;
1155 }
1156 Ok(reply(target))
1157 }
1158 };
1159 (algo, serve)
1160 }
1161
1162 #[tokio::test]
1163 async fn run_returns_the_winner_without_a_late_loser_overwriting_it() -> Result<()> {
1164 let (algo, serve) = hedge(Some(std::time::Duration::from_millis(50)));
1167 let (_, response) = test_drive(algo, request(), serve).await?;
1168 assert_eq!(
1169 response
1170 .llm_response
1171 .as_agg()
1172 .map(completion_text)
1173 .unwrap_or_default(),
1174 "winner"
1175 );
1176 Ok(())
1177 }
1178
1179 #[tokio::test]
1180 async fn run_returns_the_winner_without_hanging_on_a_pending_loser() -> Result<()> {
1181 let (algo, serve) = hedge(None);
1184 let run = test_drive(algo, request(), serve);
1185 let (_, response) = tokio::time::timeout(std::time::Duration::from_secs(1), run)
1186 .await
1187 .map_err(|error| LibsyError::external("waiting for pending loser", error))??;
1188 assert_eq!(
1189 response
1190 .llm_response
1191 .as_agg()
1192 .map(completion_text)
1193 .unwrap_or_default(),
1194 "winner"
1195 );
1196 Ok(())
1197 }
1198
1199 #[tokio::test]
1200 async fn run_surfaces_a_terminal_error_with_many_calls_in_flight() -> Result<()> {
1201 use std::sync::atomic::{AtomicUsize, Ordering};
1202
1203 const N: usize = 10;
1206
1207 struct FanOutThenError {
1210 all_started: Arc<tokio::sync::Notify>,
1211 n: usize,
1212 }
1213
1214 #[async_trait]
1215 impl Algorithm for FanOutThenError {
1216 fn name(&self) -> &str {
1217 "fan_out_then_error"
1218 }
1219
1220 async fn route(
1221 self: Arc<Self>,
1222 driver: Driver,
1223 request: Request,
1224 ) -> Result<RoutingOutcome> {
1225 let offloads = futures::future::join_all(
1226 (0..self.n)
1227 .map(|i| driver.call_model(request.clone(), vec![format!("m{i}").into()])),
1228 );
1229 tokio::select! {
1230 _ = offloads => Err(test_error("offloads unexpectedly completed")),
1231 _ = self.all_started.notified() => {
1232 Err(test_error("terminal error while calls pending"))
1233 }
1234 }
1235 }
1236 }
1237
1238 let all_started = Arc::new(tokio::sync::Notify::new());
1239 let algo: Arc<dyn Algorithm> = Arc::new(FanOutThenError {
1240 all_started: all_started.clone(),
1241 n: N,
1242 });
1243
1244 let started = Arc::new(AtomicUsize::new(0));
1246 let serve = move |_target: ModelId, _request: Request| {
1247 let started = started.clone();
1248 let all_started = all_started.clone();
1249 async move {
1250 if started.fetch_add(1, Ordering::SeqCst) + 1 == N {
1251 all_started.notify_one();
1252 }
1253 std::future::pending::<ServeResult>().await
1254 }
1255 };
1256
1257 let run = test_drive(algo, request(), serve);
1260 let result = tokio::time::timeout(std::time::Duration::from_millis(500), run)
1261 .await
1262 .map_err(|error| {
1263 LibsyError::external("waiting for terminal error with full call cap", error)
1264 })?;
1265 match result {
1266 Ok(_) => Err(test_error("expected the terminal error, got a response")),
1267 Err(err) => {
1268 assert!(
1269 err.to_string()
1270 .contains("terminal error while calls pending")
1271 );
1272 Ok(())
1273 }
1274 }
1275 }
1276}