1use std::{future::Future, panic::AssertUnwindSafe, pin::Pin, sync::Arc, time::Instant};
9
10use async_trait::async_trait;
11use futures::{FutureExt, Stream, StreamExt};
12use tokio::sync::{mpsc, oneshot};
13use tokio_stream::wrappers::ReceiverStream;
14use tracing::Instrument;
15
16use switchyard_protocol::{Decision, ModelId, Request, Response};
24
25use crate::{DriverError, LibsyError, Result, observability};
26
27pub type StepStream = Pin<Box<dyn Stream<Item = Result<Step>> + Send>>;
31
32pub struct CallModel {
43 pub algorithm: String,
46 pub request: Request,
48 pub models: Vec<ModelId>,
50 pub is_answer_call: bool,
52 reply: oneshot::Sender<Result<Response>>,
54}
55
56impl CallModel {
57 pub fn respond(self, result: Result<Response>) -> Result<()> {
61 self.reply
62 .send(result)
63 .map_err(|_| DriverError::ResponseDropped.into())
64 }
65
66 pub fn into_parts(self) -> (Request, Vec<ModelId>) {
77 let Self {
78 request,
79 models,
80 reply,
81 ..
82 } = self;
83 let _ = reply.send(Err(DriverError::Abandoned.into()));
87 (request, models)
88 }
89}
90
91#[derive(Clone)]
93pub struct Driver {
94 step_tx: mpsc::Sender<Result<Step>>,
95 algorithm: String,
98}
99
100impl Driver {
101 pub(crate) fn new(algorithm: &str) -> (Self, mpsc::Receiver<Result<Step>>) {
104 let (step_tx, step_rx) = mpsc::channel(1);
109 (
110 Self {
111 step_tx,
112 algorithm: algorithm.to_string(),
113 },
114 step_rx,
115 )
116 }
117
118 #[tracing::instrument(
127 target = "libsy",
128 name = "libsy.llm_call",
129 skip_all,
130 fields(
131 algorithm = self.algorithm,
132 selected_model = %models.first().map(ModelId::as_str).unwrap_or("NoTargets"),
133 openinference.span.kind = "CHAIN",
134 outcome = tracing::field::Empty,
135 error = tracing::field::Empty,
136 input_tokens = tracing::field::Empty,
137 output_tokens = tracing::field::Empty,
138 total_tokens = tracing::field::Empty,
139 reasoning_tokens = tracing::field::Empty,
140 )
141 )]
142 pub async fn call_model(
143 &self,
144 mut request: Request,
145 models: Vec<ModelId>,
146 is_answer_call: bool,
147 ) -> Result<Response> {
148 let Some(selected_model_id) = models.first().cloned() else {
149 return Err(LibsyError::NoTargets);
150 };
151 request.llm_request.model = Some(selected_model_id.to_string());
152 let started = Instant::now();
153 let (reply, response) = oneshot::channel::<Result<Response>>();
154 let call = CallModel {
155 algorithm: self.algorithm.clone(),
156 request,
157 models,
158 is_answer_call,
159 reply,
160 };
161 let result = async {
162 self.step_tx
163 .send(Ok(Step::CallModel(Box::new(call))))
164 .await
165 .map_err(|_| DriverError::StreamClosed)?;
166 response
167 .await
168 .map_err(|_| LibsyError::from(DriverError::ResponseDropped))?
169 }
170 .await;
171 let elapsed = started.elapsed();
172 observability::record_llm_call(
173 &self.algorithm,
174 selected_model_id.as_str(),
175 is_answer_call,
176 elapsed,
177 &result,
178 &tracing::Span::current(),
179 );
180 result
181 }
182
183 pub async fn decide(&self, decision: Decision) -> Result<()> {
187 self.step_tx
188 .send(Ok(Step::Decision(decision.clone())))
189 .await
190 .map_err(|_| DriverError::StreamClosed)?;
191 observability::record_decision(&self.algorithm, &decision);
192 Ok(())
193 }
194
195 pub(crate) async fn finish(&self, result: Result<Response>) -> Result<()> {
199 let step = result.map(|response| Step::Done(Box::new(response)));
200 self.step_tx
201 .send(step)
202 .await
203 .map_err(|_| DriverError::StreamClosed.into())
204 }
205}
206
207pub enum Step {
209 CallModel(Box<CallModel>),
212 Decision(Decision),
215 Done(Box<Response>),
217}
218
219pub async fn drive<F, Fut>(
232 algorithm: Arc<dyn Algorithm>,
233 request: Request,
234 serve: F,
235) -> Result<(Vec<Decision>, Response)>
236where
237 F: Fn(CallModel) -> Fut,
238 Fut: Future<Output = Result<()>>,
239{
240 let stream = algorithm.run_stream(request);
241 tokio::pin!(stream);
242
243 let mut trace: Vec<Decision> = Vec::new();
244 let mut in_flight = futures::stream::FuturesUnordered::new();
245 let mut final_response: Option<Response> = None;
246
247 loop {
248 tokio::select! {
249 Some(result) = in_flight.next() => match result {
250 Ok(()) => {}, Err(err) => return Err(err), },
253 step = stream.next() => {
254 match step {
255 None => break, Some(item) => match item? {
257 Step::CallModel(call) => in_flight.push(serve(*call)),
258 Step::Decision(decision) => trace.push(decision),
259 Step::Done(response) => {
260 final_response = Some(*response);
261 break;
262 }
263 }
264 }
265 },
266 }
267 }
268 final_response
269 .map(|response| (trace, response))
270 .ok_or(LibsyError::MissingFinalResponse)
271}
272
273fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
275 payload
276 .downcast_ref::<&'static str>()
277 .map(|message| (*message).to_string())
278 .or_else(|| payload.downcast_ref::<String>().cloned())
279 .unwrap_or_else(|| "unknown panic payload".to_string())
280}
281
282struct AbortOnDrop(tokio::task::AbortHandle);
284
285impl Drop for AbortOnDrop {
286 fn drop(&mut self) {
287 self.0.abort();
288 }
289}
290
291pub(crate) fn ensure_model_is_target(targets: &[ModelId], name: &ModelId) -> Result<()> {
296 targets
297 .iter()
298 .any(|target| target == name)
299 .then_some(())
300 .ok_or_else(|| LibsyError::TargetNotFound {
301 target: name.clone(),
302 })
303}
304
305#[derive(Clone, Hash, PartialEq, Eq)]
308pub(crate) enum RoutingIdentity {
309 Session(String),
311 Subagent { session: String, agent: String },
313}
314
315impl RoutingIdentity {
316 pub(crate) fn from_request(request: &Request) -> Option<Self> {
321 let metadata = request.metadata.as_ref()?;
322 let session = metadata.session_id.as_deref().filter(|id| !id.is_empty())?;
323 if metadata.is_subagent {
324 let agent = metadata.agent_id.as_deref().filter(|id| !id.is_empty())?;
325 Some(Self::Subagent {
326 session: session.to_string(),
327 agent: agent.to_string(),
328 })
329 } else {
330 Some(Self::Session(session.to_string()))
331 }
332 }
333}
334
335#[async_trait]
357pub trait Algorithm: Send + Sync + 'static {
358 fn name(&self) -> &str;
362
363 async fn route(self: Arc<Self>, driver: Driver, request: Request) -> Result<Response>;
367
368 fn run_stream(self: Arc<Self>, request: Request) -> StepStream {
377 let (driver, step_rx) = Driver::new(self.name());
378 let span = observability::run_span(self.name(), &request);
379 let handle = tokio::spawn(
380 async move {
381 let algorithm = self.name().to_string();
382 let route = AssertUnwindSafe(self.route(driver.clone(), request)).catch_unwind();
384 let result = observability::observe_run(&algorithm, async move {
385 route.await.unwrap_or_else(|payload| {
386 Err(LibsyError::AlgorithmError {
387 message: format!(
388 "algorithm task panicked: {}",
389 panic_message(payload.as_ref())
390 ),
391 })
392 })
393 })
394 .await;
395
396 let _ = driver.finish(result).await;
397 }
398 .instrument(span),
399 );
400 let abort_guard = AbortOnDrop(handle.abort_handle());
402 Box::pin(ReceiverStream::new(step_rx).map(move |step| {
403 let _keep_alive = &abort_guard;
405 step
406 }))
407 }
408}
409
410#[cfg(test)]
411mod tests {
412 use std::collections::HashMap;
413
414 use super::*;
415 use crate::core::testing::{Serve, ServeResult, echo, reply, test_drive};
416 use futures::StreamExt;
417 use switchyard_protocol::{
418 LlmResponse, LlmResponseChunk, completion_text, text_request, text_response,
419 };
420
421 #[derive(Debug, thiserror::Error)]
422 #[error("{0}")]
423 struct TestError(&'static str);
424
425 fn test_error(message: &'static str) -> LibsyError {
426 LibsyError::external("test", TestError(message))
427 }
428
429 fn test_decision(selected_model_id: ModelId) -> Decision {
431 Decision::new(selected_model_id, true)
432 }
433
434 struct TestAlgo {
437 target_set: Vec<ModelId>,
438 }
439
440 #[async_trait]
441 impl Algorithm for TestAlgo {
442 fn name(&self) -> &str {
443 "test"
444 }
445
446 async fn route(self: Arc<Self>, driver: Driver, request: Request) -> Result<Response> {
447 let target = self
448 .target_set
449 .first()
450 .ok_or(LibsyError::NoTargets)?
451 .clone();
452 let decision = test_decision(target.clone());
453 driver.decide(decision.clone()).await?;
454 driver.call_model(request, vec![target], true).await
455 }
456 }
457
458 fn orch(target_set: Vec<ModelId>) -> Arc<dyn Algorithm> {
460 Arc::new(TestAlgo { target_set })
461 }
462
463 fn request() -> Request {
464 Request {
465 llm_request: text_request(Some("auto".to_string()), "hi".to_string()),
466 raw_request: None,
467 metadata: None,
468 }
469 }
470
471 fn target_set(names: &[&str]) -> Vec<ModelId> {
472 names.iter().map(|name| ModelId::from(*name)).collect()
473 }
474
475 #[tokio::test]
476 async fn typed_driver_preserves_call_and_stream_boundaries() -> Result<()> {
477 tokio::time::timeout(std::time::Duration::from_secs(1), async {
478 let (driver, mut step_rx) = Driver::new("test");
481 let first_driver = driver.clone();
482 let mut first = tokio::spawn(async move {
483 first_driver
484 .call_model(request(), vec![ModelId::from("first")], true)
485 .await
486 });
487 let second = tokio::spawn(async move {
488 driver
489 .call_model(request(), vec![ModelId::from("second")], true)
490 .await
491 });
492
493 let mut calls = HashMap::new();
494 for _ in 0..2 {
495 let step = step_rx.recv().await.ok_or(DriverError::StreamClosed)??;
496 let Step::CallModel(call) = step else {
497 return Err(test_error("expected a CallModel step"));
498 };
499 let selected_model = call
500 .models
501 .first()
502 .ok_or_else(|| test_error("model call has no candidates"))?
503 .to_string();
504 calls.insert(selected_model, call);
505 }
506 assert!(
507 tokio::time::timeout(std::time::Duration::from_millis(20), &mut first)
508 .await
509 .is_err(),
510 "call completed before the host responded"
511 );
512 calls
513 .remove("second")
514 .ok_or_else(|| test_error("missing second call"))?
515 .respond(Ok(reply("second response")))?;
516 calls
517 .remove("first")
518 .ok_or_else(|| test_error("missing first call"))?
519 .respond(Ok(reply("first response")))?;
520
521 let first_response = first
522 .await
523 .map_err(|source| LibsyError::external("joining a test task", source))??;
524 let second_response = second
525 .await
526 .map_err(|source| LibsyError::external("joining a test task", source))??;
527 assert_eq!(
528 first_response.llm_response.as_agg().map(completion_text),
529 Some("first response".to_string())
530 );
531 assert_eq!(
532 second_response.llm_response.as_agg().map(completion_text),
533 Some("second response".to_string())
534 );
535
536 let (driver, mut step_rx) = Driver::new("test");
538 let producer = tokio::spawn(async move {
539 driver
540 .call_model(request(), vec![ModelId::from("dropped")], true)
541 .await
542 });
543 let step = step_rx.recv().await.ok_or(DriverError::StreamClosed)??;
544 let Step::CallModel(call) = step else {
545 return Err(test_error("expected a CallModel step"));
546 };
547 drop(call);
548 let result = producer
549 .await
550 .map_err(|source| LibsyError::external("joining a test task", source))?;
551 assert!(matches!(
552 result,
553 Err(LibsyError::Driver(DriverError::ResponseDropped))
554 ));
555
556 let (driver, step_rx) = Driver::new("test");
558 drop(step_rx);
559 let decision = test_decision(ModelId::from("closed"));
560 let result = driver.decide(decision).await;
561 assert!(matches!(
562 result,
563 Err(LibsyError::Driver(DriverError::StreamClosed))
564 ));
565 Ok(())
566 })
567 .await
568 .map_err(|error| LibsyError::external("waiting for typed driver boundaries", error))?
569 }
570
571 #[tokio::test]
576 async fn into_parts_yields_the_selected_model_without_answering_it() -> Result<()> {
577 let (driver, mut step_rx) = Driver::new("test");
578 let producer = tokio::spawn(async move {
579 driver
580 .call_model(request(), vec![ModelId::from("answer/model")], true)
581 .await
582 });
583
584 let step = step_rx.recv().await.ok_or(DriverError::StreamClosed)??;
585 let Step::CallModel(call) = step else {
586 return Err(test_error("expected a CallModel step"));
587 };
588 let (taken_request, taken_models) = call.into_parts();
589 assert_eq!(taken_models, vec![ModelId::from("answer/model")]);
590 assert_eq!(
591 taken_request.llm_request.model.as_deref(),
592 Some("answer/model")
593 );
594 let mut expected = request().llm_request;
595 expected.model = Some("answer/model".to_string());
596 assert_eq!(taken_request.llm_request, expected);
597
598 let result = producer
599 .await
600 .map_err(|source| LibsyError::external("joining a test task", source))?;
601 assert!(matches!(
602 result,
603 Err(LibsyError::Driver(DriverError::Abandoned))
604 ));
605 Ok(())
606 }
607
608 #[test]
611 fn abandoning_and_dropping_a_call_report_different_outcomes() {
612 let abandoned: Result<Response> = Err(DriverError::Abandoned.into());
613 let dropped: Result<Response> = Err(DriverError::ResponseDropped.into());
614 assert_eq!(observability::outcome_value(&abandoned), "abandoned");
615 assert_eq!(observability::outcome_value(&dropped), "error");
616 assert!(observability::is_abandoned(&abandoned));
617 assert!(!observability::is_abandoned(&dropped));
618 }
619
620 #[test]
621 fn target_lookup_returns_the_missing_target() {
622 let error = ensure_model_is_target(&target_set(&[]), &ModelId::from("missing")).err();
623 assert!(matches!(
624 error,
625 Some(LibsyError::TargetNotFound { target }) if target == "missing"
626 ));
627 }
628
629 fn streaming_orch(chunks: Vec<LlmResponseChunk>) -> (Arc<dyn Algorithm>, impl Serve) {
632 let algo = orch(target_set(&["stream/model"]));
633 let serve = move |_target: ModelId, _request: Request| {
634 let chunks = chunks.clone();
635 async move {
636 let stream =
637 futures::stream::iter(chunks.into_iter().map(|chunk| Ok(chunk.into()))).boxed();
638 Ok(Response {
639 llm_response: LlmResponse::Stream(stream),
640 metadata: None,
641 })
642 }
643 };
644 (algo, serve)
645 }
646
647 #[tokio::test]
648 async fn run_returns_a_streamed_response_the_caller_aggregates() -> Result<()> {
649 let (orch, serve) = streaming_orch(vec![
652 LlmResponseChunk::MessageStart {
653 id: Some("m1".to_string()),
654 model: Some("stream/model".to_string()),
655 },
656 LlmResponseChunk::TextDelta {
657 index: 0,
658 text: "hel".to_string(),
659 },
660 LlmResponseChunk::TextDelta {
661 index: 0,
662 text: "lo".to_string(),
663 },
664 LlmResponseChunk::MessageStop {
665 reason: Some("stop".to_string()),
666 },
667 ]);
668 let (trace, response) = test_drive(orch, request(), serve).await?;
669 let agg = response
671 .llm_response
672 .into_agg()
673 .await
674 .map_err(|error| LibsyError::external("aggregating response stream", error))?;
675 assert_eq!(completion_text(&agg), "hello");
676 assert_eq!(agg.model.as_deref(), Some("stream/model"));
677 assert_eq!(trace.len(), 1);
678 Ok(())
679 }
680
681 #[tokio::test]
682 async fn aggregating_a_streamed_response_propagates_a_mid_stream_error() -> Result<()> {
683 let (orch, serve) = streaming_orch(vec![
686 LlmResponseChunk::TextDelta {
687 index: 0,
688 text: "partial".to_string(),
689 },
690 LlmResponseChunk::StreamError {
691 message: "upstream exploded".to_string(),
692 },
693 ]);
694 let (_, response) = test_drive(orch, request(), serve).await?;
695 match response.llm_response.into_agg().await {
696 Ok(_) => panic!("expected a mid-stream error, got an aggregate"),
697 Err(err) => {
698 assert!(err.to_string().contains("upstream exploded"));
699 Ok(())
700 }
701 }
702 }
703
704 #[tokio::test]
705 async fn run_offloads_via_promise_then_finishes() -> Result<()> {
706 let stream = orch(target_set(&["offload/model"])).run_stream(request());
709 tokio::pin!(stream);
710
711 let mut saw_call = false;
712 let mut final_completion = None;
713 while let Some(step) = stream.next().await {
714 match step? {
715 Step::CallModel(call) => {
716 saw_call = true;
717 assert_eq!(call.models, vec![ModelId::from("offload/model")]);
718 call.respond(Ok(Response {
720 llm_response: LlmResponse::Agg(text_response(
721 None,
722 "fulfilled".to_string(),
723 )),
724 metadata: None,
725 }))?;
726 }
727 Step::Decision(decision) => {
728 assert_eq!(decision.selected_model_id(), "offload/model");
729 }
730 Step::Done(response) => {
731 final_completion = Some(
732 response
733 .llm_response
734 .as_agg()
735 .map(completion_text)
736 .unwrap_or_default(),
737 );
738 }
739 }
740 }
741
742 assert!(saw_call, "expected a CallModel step before Done");
743 assert_eq!(
744 final_completion.ok_or_else(|| test_error("no Done step"))?,
745 "fulfilled"
746 );
747 Ok(())
748 }
749
750 #[tokio::test]
751 async fn a_driven_run_returns_the_trace_and_the_final_response() -> Result<()> {
752 let (trace, response) =
753 test_drive(orch(target_set(&["direct/model"])), request(), echo()).await?;
754 assert_eq!(
756 response
757 .llm_response
758 .as_agg()
759 .map(completion_text)
760 .unwrap_or_default(),
761 "direct/model"
762 );
763 assert_eq!(trace[0].selected_model_id(), "direct/model");
764 Ok(())
765 }
766
767 #[tokio::test(flavor = "multi_thread", worker_threads = 12)]
768 async fn requests_are_processed_in_parallel() -> Result<()> {
769 use std::time::Duration;
770 use tokio::sync::Barrier;
771
772 const N: usize = 12;
773
774 let barrier = Arc::new(Barrier::new(N));
779 let algo = orch(target_set(&["m"]));
781
782 let mut handles = Vec::new();
783 for _ in 0..N {
784 let algo = algo.clone();
785 let barrier = barrier.clone();
786 let serve = move |target: ModelId, _request: Request| {
787 let barrier = barrier.clone();
788 async move {
789 barrier.wait().await;
790 Ok(reply(target))
791 }
792 };
793 handles.push(tokio::spawn(async move {
794 test_drive(algo, request(), serve)
795 .await
796 .map(|(_, response)| {
797 response
798 .llm_response
799 .as_agg()
800 .map(completion_text)
801 .unwrap_or_default()
802 })
803 }));
804 }
805
806 for handle in handles {
807 let completion = tokio::time::timeout(Duration::from_secs(5), handle)
809 .await
810 .map_err(|error| LibsyError::external("waiting for test task", error))?
811 .map_err(|source| LibsyError::external("joining a test task", source))??;
812 assert_eq!(completion, "m");
813 }
814 Ok(())
815 }
816
817 #[tokio::test]
818 async fn offload_error_propagates_back_to_the_algorithm() -> Result<()> {
819 let stream = orch(target_set(&["offload/model"])).run_stream(request());
823 tokio::pin!(stream);
824
825 let mut saw_error = false;
826 while let Some(step) = stream.next().await {
827 match step {
828 Ok(Step::CallModel(call)) => {
829 call.respond(Err(test_error("upstream model call failed")))?;
830 }
831 Ok(Step::Decision(_)) => {}
832 Ok(Step::Done(..)) => {
833 return Err(test_error(
834 "expected the offload error to propagate, got a response",
835 ));
836 }
837 Err(err) => {
838 assert!(err.to_string().contains("upstream model call failed"));
840 saw_error = true;
841 }
842 }
843 }
844
845 assert!(saw_error, "expected an error step");
846 Ok(())
847 }
848
849 #[tokio::test]
850 async fn dropping_the_stream_cancels_the_algorithm_task() -> Result<()> {
851 use std::sync::atomic::{AtomicBool, Ordering};
852 use std::time::Duration;
853 use tokio::sync::mpsc;
854
855 struct DropGuard(Arc<AtomicBool>);
858 impl Drop for DropGuard {
859 fn drop(&mut self) {
860 self.0.store(true, Ordering::SeqCst);
861 }
862 }
863
864 struct StuckAlgo {
865 started: mpsc::UnboundedSender<()>,
866 dropped: Arc<AtomicBool>,
867 }
868
869 #[async_trait]
870 impl Algorithm for StuckAlgo {
871 fn name(&self) -> &str {
872 "stuck"
873 }
874
875 async fn route(
876 self: Arc<Self>,
877 _driver: Driver,
878 _request: Request,
879 ) -> Result<Response> {
880 let _guard = DropGuard(self.dropped.clone());
881 let _ = self.started.send(());
882 std::future::pending::<()>().await;
884 unreachable!()
885 }
886 }
887
888 let (started_tx, mut started_rx) = mpsc::unbounded_channel();
889 let dropped = Arc::new(AtomicBool::new(false));
890 let algo: Arc<dyn Algorithm> = Arc::new(StuckAlgo {
891 started: started_tx,
892 dropped: dropped.clone(),
893 });
894
895 let stream = algo.run_stream(request());
896 started_rx
897 .recv()
898 .await
899 .ok_or_else(|| test_error("task never started"))?;
900 drop(stream);
901 tokio::time::sleep(Duration::from_millis(100)).await;
902
903 assert!(
904 dropped.load(Ordering::SeqCst),
905 "algorithm task was NOT cancelled after dropping the stream"
906 );
907 Ok(())
908 }
909
910 #[tokio::test]
911 async fn route_panic_surfaces_as_a_stream_error() -> Result<()> {
912 struct Panicky;
915
916 #[async_trait]
917 impl Algorithm for Panicky {
918 fn name(&self) -> &str {
919 "panicky"
920 }
921
922 async fn route(
923 self: Arc<Self>,
924 _driver: Driver,
925 _request: Request,
926 ) -> Result<Response> {
927 panic!("boom");
928 }
929 }
930
931 let algo: Arc<dyn Algorithm> = Arc::new(Panicky);
932 let stream = algo.run_stream(request());
933 tokio::pin!(stream);
934
935 let mut saw_error = false;
936 while let Some(step) = stream.next().await {
937 match step {
938 Err(err) => {
939 assert!(err.to_string().contains("algorithm task panicked: boom"));
941 saw_error = true;
942 }
943 Ok(_) => return Err(test_error("expected the panic to surface as an error step")),
944 }
945 }
946
947 assert!(saw_error, "expected an error step from the panicked task");
948 Ok(())
949 }
950
951 #[tokio::test]
955 async fn a_panic_with_a_leaked_driver_clone_still_terminates_the_run() -> Result<()> {
956 struct LeakyPanic;
957
958 #[async_trait]
959 impl Algorithm for LeakyPanic {
960 fn name(&self) -> &str {
961 "leaky_panic"
962 }
963
964 async fn route(self: Arc<Self>, driver: Driver, _request: Request) -> Result<Response> {
965 tokio::spawn(async move {
966 let _keep_alive = driver;
968 std::future::pending::<()>().await;
969 });
970 tokio::task::yield_now().await;
971 panic!("boom");
972 }
973 }
974
975 let algo: Arc<dyn Algorithm> = Arc::new(LeakyPanic);
976 let result = tokio::time::timeout(
978 std::time::Duration::from_secs(1),
979 test_drive(algo, request(), echo()),
980 )
981 .await
982 .map_err(|error| LibsyError::external("waiting for the panicked run to end", error))?;
983
984 match result {
985 Ok(_) => Err(test_error(
986 "expected the panic to end the run with an error",
987 )),
988 Err(err) => {
989 assert!(err.to_string().contains("algorithm task panicked: boom"));
990 Ok(())
991 }
992 }
993 }
994
995 #[tokio::test]
996 async fn cancelling_run_cancels_the_algorithm_task() -> Result<()> {
997 use std::sync::atomic::{AtomicBool, Ordering};
998 use std::time::Duration;
999 use tokio::sync::mpsc;
1000
1001 struct DropGuard(Arc<AtomicBool>);
1004 impl Drop for DropGuard {
1005 fn drop(&mut self) {
1006 self.0.store(true, Ordering::SeqCst);
1007 }
1008 }
1009
1010 struct StuckAlgo {
1011 started: mpsc::UnboundedSender<()>,
1012 dropped: Arc<AtomicBool>,
1013 }
1014
1015 #[async_trait]
1016 impl Algorithm for StuckAlgo {
1017 fn name(&self) -> &str {
1018 "stuck"
1019 }
1020
1021 async fn route(
1022 self: Arc<Self>,
1023 _driver: Driver,
1024 _request: Request,
1025 ) -> Result<Response> {
1026 let _guard = DropGuard(self.dropped.clone());
1027 let _ = self.started.send(());
1028 std::future::pending::<()>().await;
1031 unreachable!()
1032 }
1033 }
1034
1035 let (started_tx, mut started_rx) = mpsc::unbounded_channel();
1036 let dropped = Arc::new(AtomicBool::new(false));
1037 let algo: Arc<dyn Algorithm> = Arc::new(StuckAlgo {
1038 started: started_tx,
1039 dropped: dropped.clone(),
1040 });
1041
1042 let run_task = tokio::spawn(async move { test_drive(algo, request(), echo()).await });
1045 started_rx
1046 .recv()
1047 .await
1048 .ok_or_else(|| test_error("task never started"))?;
1049 run_task.abort();
1050 tokio::time::sleep(Duration::from_millis(100)).await;
1051
1052 assert!(
1053 dropped.load(Ordering::SeqCst),
1054 "algorithm task was NOT cancelled after cancelling run"
1055 );
1056 Ok(())
1057 }
1058
1059 struct Hedge {
1064 winner: String,
1065 loser: String,
1066 }
1067
1068 #[async_trait]
1069 impl Algorithm for Hedge {
1070 fn name(&self) -> &str {
1071 "hedge"
1072 }
1073
1074 async fn route(self: Arc<Self>, driver: Driver, request: Request) -> Result<Response> {
1075 let win = driver.call_model(request.clone(), vec![self.winner.clone().into()], true);
1076 let lose = driver.call_model(request, vec![self.loser.clone().into()], true);
1077 tokio::select! {
1079 res = win => res,
1080 res = lose => res,
1081 }
1082 }
1083 }
1084
1085 fn hedge(loser_delay: Option<std::time::Duration>) -> (Arc<dyn Algorithm>, impl Serve) {
1089 let started = Arc::new(tokio::sync::Notify::new());
1090 let algo = Arc::new(Hedge {
1091 winner: "winner".to_string(),
1092 loser: "loser".to_string(),
1093 });
1094 let serve = move |target: ModelId, _request: Request| {
1095 let started = started.clone();
1096 async move {
1097 if target == "loser" {
1098 started.notify_one();
1099 match loser_delay {
1100 Some(delay) => tokio::time::sleep(delay).await,
1101 None => std::future::pending::<()>().await,
1102 }
1103 } else {
1104 started.notified().await;
1105 }
1106 Ok(reply(target))
1107 }
1108 };
1109 (algo, serve)
1110 }
1111
1112 #[tokio::test]
1113 async fn run_returns_the_winner_without_a_late_loser_overwriting_it() -> Result<()> {
1114 let (algo, serve) = hedge(Some(std::time::Duration::from_millis(50)));
1117 let (_trace, response) = test_drive(algo, request(), serve).await?;
1118 assert_eq!(
1119 response
1120 .llm_response
1121 .as_agg()
1122 .map(completion_text)
1123 .unwrap_or_default(),
1124 "winner"
1125 );
1126 Ok(())
1127 }
1128
1129 #[tokio::test]
1130 async fn run_returns_the_winner_without_hanging_on_a_pending_loser() -> Result<()> {
1131 let (algo, serve) = hedge(None);
1134 let run = test_drive(algo, request(), serve);
1135 let (_trace, response) = tokio::time::timeout(std::time::Duration::from_secs(1), run)
1136 .await
1137 .map_err(|error| LibsyError::external("waiting for pending loser", error))??;
1138 assert_eq!(
1139 response
1140 .llm_response
1141 .as_agg()
1142 .map(completion_text)
1143 .unwrap_or_default(),
1144 "winner"
1145 );
1146 Ok(())
1147 }
1148
1149 #[tokio::test]
1150 async fn run_surfaces_a_terminal_error_with_many_calls_in_flight() -> Result<()> {
1151 use std::sync::atomic::{AtomicUsize, Ordering};
1152
1153 const N: usize = 10;
1156
1157 struct FanOutThenError {
1160 all_started: Arc<tokio::sync::Notify>,
1161 n: usize,
1162 }
1163
1164 #[async_trait]
1165 impl Algorithm for FanOutThenError {
1166 fn name(&self) -> &str {
1167 "fan_out_then_error"
1168 }
1169
1170 async fn route(self: Arc<Self>, driver: Driver, request: Request) -> Result<Response> {
1171 let offloads = futures::future::join_all((0..self.n).map(|i| {
1172 driver.call_model(request.clone(), vec![format!("m{i}").into()], true)
1173 }));
1174 tokio::select! {
1175 _ = offloads => Err(test_error("offloads unexpectedly completed")),
1176 _ = self.all_started.notified() => {
1177 Err(test_error("terminal error while calls pending"))
1178 }
1179 }
1180 }
1181 }
1182
1183 let all_started = Arc::new(tokio::sync::Notify::new());
1184 let algo: Arc<dyn Algorithm> = Arc::new(FanOutThenError {
1185 all_started: all_started.clone(),
1186 n: N,
1187 });
1188
1189 let started = Arc::new(AtomicUsize::new(0));
1191 let serve = move |_target: ModelId, _request: Request| {
1192 let started = started.clone();
1193 let all_started = all_started.clone();
1194 async move {
1195 if started.fetch_add(1, Ordering::SeqCst) + 1 == N {
1196 all_started.notify_one();
1197 }
1198 std::future::pending::<ServeResult>().await
1199 }
1200 };
1201
1202 let run = test_drive(algo, request(), serve);
1205 let result = tokio::time::timeout(std::time::Duration::from_millis(500), run)
1206 .await
1207 .map_err(|error| {
1208 LibsyError::external("waiting for terminal error with full call cap", error)
1209 })?;
1210 match result {
1211 Ok(_) => Err(test_error("expected the terminal error, got a response")),
1212 Err(err) => {
1213 assert!(
1214 err.to_string()
1215 .contains("terminal error while calls pending")
1216 );
1217 Ok(())
1218 }
1219 }
1220 }
1221}