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