1use std::{
8 collections::HashMap, future::Future, panic::AssertUnwindSafe, pin::Pin, sync::Arc,
9 time::Instant,
10};
11
12use async_trait::async_trait;
13use futures::{FutureExt, Stream, StreamExt};
14use parking_lot::Mutex;
15use serde_json::Value;
16use tokio::sync::{mpsc, oneshot};
17use tokio_stream::wrappers::ReceiverStream;
18use tracing::Instrument;
19
20use switchyard_protocol::{Category, ModelId, Request, Response};
28
29use crate::{DriverError, LibsyError, Result, observability};
30
31pub type StepStream = Pin<Box<dyn Stream<Item = Result<Step>> + Send>>;
35
36#[derive(Clone, Debug, Default)]
47pub struct RuntimeModels {
48 by_category: HashMap<Category, Vec<ModelId>>,
50 subagent: Option<HashMap<Category, Vec<ModelId>>>,
51}
52
53impl RuntimeModels {
54 pub fn new(by_category: HashMap<Category, Vec<ModelId>>) -> Self {
56 Self {
57 by_category,
58 subagent: None,
59 }
60 }
61
62 pub fn with_subagent(mut self, models: HashMap<Category, Vec<ModelId>>) -> Self {
64 self.subagent = Some(models);
65 self
66 }
67
68 pub fn models_for(&self, category: &Category) -> &[ModelId] {
70 self.by_category.get(category).map_or(&[], Vec::as_slice)
71 }
72
73 pub fn subagent_models_for(&self, category: &Category) -> &[ModelId] {
75 self.subagent
76 .as_ref()
77 .and_then(|models| models.get(category))
78 .map_or(&[], Vec::as_slice)
79 }
80}
81
82impl From<HashMap<Category, Vec<ModelId>>> for RuntimeModels {
83 fn from(by_category: HashMap<Category, Vec<ModelId>>) -> Self {
84 Self::new(by_category)
85 }
86}
87
88#[derive(Clone, Copy)]
90enum Scope {
91 Parent,
92 Subagent,
93}
94
95pub struct CallModel {
105 pub algorithm: String,
108 pub request: Request,
110 pub models: Vec<ModelId>,
112 reply: oneshot::Sender<Result<Response>>,
114}
115
116impl CallModel {
117 pub fn respond(self, result: Result<Response>) -> Result<()> {
121 self.reply
122 .send(result)
123 .map_err(|_| DriverError::ResponseDropped.into())
124 }
125}
126
127pub struct RoutingOutcome {
129 pub selected_model_ids: Vec<ModelId>,
131 pub request: Request,
133 pub response: Option<Response>,
135 pub metadata: Option<crate::OutcomeMetadata>,
140}
141
142impl RoutingOutcome {
143 pub fn selected_model_id(&self) -> Result<&ModelId> {
146 self.selected_model_ids.first().ok_or(LibsyError::NoTargets)
147 }
148
149 pub fn route_to(
153 selected_model_id: ModelId,
154 fallback_models: Vec<ModelId>,
155 mut request: Request,
156 ) -> Self {
157 request.llm_request.model = Some(selected_model_id.to_string());
158 let mut selected_model_ids = Vec::with_capacity(1 + fallback_models.len());
159 selected_model_ids.push(selected_model_id);
160 selected_model_ids.extend(fallback_models);
161 Self {
162 selected_model_ids,
163 request,
164 response: None,
165 metadata: None,
166 }
167 }
168
169 pub fn answered(selected_model_id: ModelId, mut request: Request, response: Response) -> Self {
172 request.llm_request.model = Some(selected_model_id.to_string());
173 Self {
174 selected_model_ids: vec![selected_model_id],
175 request,
176 response: Some(response),
177 metadata: None,
178 }
179 }
180}
181
182#[derive(Clone)]
184pub struct Driver {
185 step_tx: mpsc::Sender<Result<Step>>,
186
187 algorithm: String,
189
190 evidence: Arc<Mutex<Option<Value>>>,
192
193 models: Arc<RuntimeModels>,
195
196 scope: Scope,
198}
199
200impl Driver {
201 pub(crate) fn new(
204 algorithm: &str,
205 models: Arc<RuntimeModels>,
206 ) -> (Self, mpsc::Receiver<Result<Step>>) {
207 let (step_tx, step_rx) = mpsc::channel(1);
212 (
213 Self {
214 step_tx,
215 algorithm: algorithm.to_string(),
216 evidence: Arc::new(Mutex::new(None)),
217 models,
218 scope: Scope::Parent,
219 },
220 step_rx,
221 )
222 }
223
224 pub(crate) fn set_evidence(&self, evidence: Value) {
226 *self.evidence.lock() = Some(evidence);
227 }
228
229 pub(crate) fn set_evidence_if_empty(&self, evidence: Value) {
231 let mut current = self.evidence.lock();
232 if current.is_none() {
233 *current = Some(evidence);
234 }
235 }
236
237 #[tracing::instrument(
246 target = "libsy",
247 name = "libsy.llm_call",
248 skip_all,
249 fields(
250 algorithm = self.algorithm,
251 selected_model = %models.first().map(ModelId::as_str).unwrap_or("NoTargets"),
252 openinference.span.kind = "CHAIN",
253 outcome = tracing::field::Empty,
254 input_tokens = tracing::field::Empty,
255 output_tokens = tracing::field::Empty,
256 total_tokens = tracing::field::Empty,
257 reasoning_tokens = tracing::field::Empty,
258 )
259 )]
260 pub async fn call_model(&self, mut request: Request, models: Vec<ModelId>) -> Result<Response> {
261 let Some(selected_model_id) = models.first().cloned() else {
262 return Err(LibsyError::NoTargets);
263 };
264 request.llm_request.model = Some(selected_model_id.to_string());
265 let started = Instant::now();
266 let (reply, response) = oneshot::channel::<Result<Response>>();
267 let call = CallModel {
268 algorithm: self.algorithm.clone(),
269 request,
270 models,
271 reply,
272 };
273 let result = async {
274 self.step_tx
275 .send(Ok(Step::CallModel(Box::new(call))))
276 .await
277 .map_err(|_| DriverError::StreamClosed)?;
278 response
279 .await
280 .map_err(|_| LibsyError::from(DriverError::ResponseDropped))?
281 }
282 .await;
283 let elapsed = started.elapsed();
284 observability::record_llm_call(
285 &self.algorithm,
286 selected_model_id.as_str(),
287 elapsed,
288 &result,
289 &tracing::Span::current(),
290 );
291 result
292 }
293
294 pub fn models_for(&self, category: &Category) -> &[ModelId] {
296 match self.scope {
297 Scope::Parent => self.models.models_for(category),
298 Scope::Subagent => self.models.subagent_models_for(category),
299 }
300 }
301
302 pub fn first_model_for(&self, category: &Category) -> Result<&ModelId> {
304 self.models_for(category)
305 .first()
306 .ok_or_else(|| LibsyError::AlgorithmError {
307 message: format!("no models available for category {}", category.as_str()),
308 })
309 }
310
311 pub fn for_subagent(&self) -> Result<Self> {
314 if self.models.subagent.is_none() {
315 return Err(LibsyError::AlgorithmError {
316 message: "delegated work has no sub-agent models".to_string(),
317 });
318 }
319 Ok(Self {
320 scope: Scope::Subagent,
321 ..self.clone()
322 })
323 }
324
325 pub(crate) async fn finish(&self, result: Result<RoutingOutcome>) -> Result<()> {
329 let result = result.map(|mut outcome| {
330 let metadata = outcome.metadata.get_or_insert_with(|| {
331 crate::OutcomeMetadata::new(self.algorithm.clone(), self.evidence.lock().take())
332 });
333 observability::record_outcome(metadata, &outcome.selected_model_ids);
334 outcome
335 });
336 let selected_model = result
337 .as_ref()
338 .ok()
339 .and_then(|outcome| outcome.selected_model_id().ok().cloned());
340 let step = result.map(|outcome| Step::Done(Box::new(outcome)));
341 self.step_tx
342 .send(step)
343 .await
344 .map_err(|_| DriverError::StreamClosed)?;
345 if let Some(selected_model) = selected_model {
346 observability::record_decision(&self.algorithm, &selected_model);
347 }
348 Ok(())
349 }
350}
351
352pub enum Step {
354 CallModel(Box<CallModel>),
357 Done(Box<RoutingOutcome>),
359}
360
361pub async fn drive<F, Fut>(
374 algorithm: Arc<dyn Algorithm>,
375 request: Request,
376 models: Arc<RuntimeModels>,
377 serve: F,
378) -> Result<RoutingOutcome>
379where
380 F: Fn(CallModel) -> Fut,
381 Fut: Future<Output = Result<()>>,
382{
383 let stream = algorithm.run_stream(request, models);
384 tokio::pin!(stream);
385
386 let mut in_flight = futures::stream::FuturesUnordered::new();
387 let mut final_outcome: Option<RoutingOutcome> = None;
388
389 loop {
390 tokio::select! {
391 Some(result) = in_flight.next() => match result {
392 Ok(()) => {}, Err(err) => return Err(err), },
395 step = stream.next() => {
396 match step {
397 None => break, Some(item) => match item? {
399 Step::CallModel(call) => in_flight.push(serve(*call)),
400 Step::Done(outcome) => {
401 final_outcome = Some(*outcome);
402 break;
403 }
404 }
405 }
406 },
407 }
408 }
409 final_outcome.ok_or(LibsyError::MissingFinalResponse)
410}
411
412fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
414 payload
415 .downcast_ref::<&'static str>()
416 .map(|message| (*message).to_string())
417 .or_else(|| payload.downcast_ref::<String>().cloned())
418 .unwrap_or_else(|| "unknown panic payload".to_string())
419}
420
421struct AbortOnDrop(tokio::task::AbortHandle);
423
424impl Drop for AbortOnDrop {
425 fn drop(&mut self) {
426 self.0.abort();
427 }
428}
429
430pub(crate) fn ensure_model_is_target(targets: &[ModelId], name: &ModelId) -> Result<()> {
435 targets
436 .iter()
437 .any(|target| target == name)
438 .then_some(())
439 .ok_or_else(|| LibsyError::TargetNotFound {
440 target: name.clone(),
441 })
442}
443
444#[derive(Clone, Hash, PartialEq, Eq)]
447pub(crate) enum RoutingIdentity {
448 Session(String),
450 Subagent { session: String, agent: String },
452}
453
454impl RoutingIdentity {
455 pub(crate) fn from_request(request: &Request) -> Option<Self> {
460 let metadata = request.metadata.as_ref()?;
461 let session = metadata.session_id.as_deref().filter(|id| !id.is_empty())?;
462 if metadata.is_subagent {
463 let agent = metadata.agent_id.as_deref().filter(|id| !id.is_empty())?;
464 Some(Self::Subagent {
465 session: session.to_string(),
466 agent: agent.to_string(),
467 })
468 } else {
469 Some(Self::Session(session.to_string()))
470 }
471 }
472}
473
474#[async_trait]
507pub trait Algorithm: Send + Sync + 'static {
508 fn name(&self) -> &str;
512
513 async fn route(self: Arc<Self>, driver: Driver, request: Request) -> Result<RoutingOutcome>;
517
518 fn run_stream(self: Arc<Self>, request: Request, models: Arc<RuntimeModels>) -> StepStream {
527 let (driver, step_rx) = Driver::new(self.name(), models);
528 let span = observability::run_span(self.name(), &request);
529 let handle = tokio::spawn(
530 async move {
531 let algorithm = self.name().to_string();
532 let route = AssertUnwindSafe(self.route(driver.clone(), request)).catch_unwind();
534 let result = observability::observe_run(&algorithm, async move {
535 route.await.unwrap_or_else(|payload| {
536 Err(LibsyError::AlgorithmError {
537 message: format!(
538 "algorithm task panicked: {}",
539 panic_message(payload.as_ref())
540 ),
541 })
542 })
543 })
544 .await;
545
546 let _ = driver.finish(result).await;
547 }
548 .instrument(span),
549 );
550 let abort_guard = AbortOnDrop(handle.abort_handle());
552 Box::pin(ReceiverStream::new(step_rx).map(move |step| {
553 let _keep_alive = &abort_guard;
555 step
556 }))
557 }
558}
559
560#[cfg(test)]
561mod tests {
562 use std::collections::HashMap;
563
564 use super::*;
565 use crate::core::testing::{Serve, ServeResult, echo, reply, test_drive};
566 use futures::StreamExt;
567 use switchyard_protocol::{
568 LlmResponse, LlmResponseChunk, completion_text, text_request, text_response,
569 };
570
571 #[derive(Debug, thiserror::Error)]
572 #[error("{0}")]
573 struct TestError(&'static str);
574
575 fn test_error(message: &'static str) -> LibsyError {
576 LibsyError::external("test", TestError(message))
577 }
578
579 struct TestAlgo {
582 target_set: Vec<ModelId>,
583 }
584
585 #[async_trait]
586 impl Algorithm for TestAlgo {
587 fn name(&self) -> &str {
588 "test"
589 }
590
591 async fn route(
592 self: Arc<Self>,
593 driver: Driver,
594 request: Request,
595 ) -> Result<RoutingOutcome> {
596 let target = self
597 .target_set
598 .first()
599 .ok_or(LibsyError::NoTargets)?
600 .clone();
601 let response = driver
602 .call_model(request.clone(), vec![target.clone()])
603 .await?;
604 driver.set_evidence(serde_json::json!({"source": "test"}));
605 driver.set_evidence_if_empty(serde_json::json!({"source": "ignored"}));
606 Ok(RoutingOutcome::answered(target, request, response))
607 }
608 }
609
610 fn orch(target_set: Vec<ModelId>) -> Arc<dyn Algorithm> {
612 Arc::new(TestAlgo { target_set })
613 }
614
615 fn request() -> Request {
616 Request {
617 llm_request: text_request(Some("auto".to_string()), "hi".to_string()),
618 raw_request: None,
619 metadata: None,
620 }
621 }
622
623 #[test]
624 fn routing_outcome_constructors_stamp_selection_and_preserve_payloads() {
625 let outcome = RoutingOutcome::route_to(
626 "selected".into(),
627 target_set(&["fallback-one", "fallback-two"]),
628 request(),
629 );
630
631 assert_eq!(
632 outcome.selected_model_ids,
633 target_set(&["selected", "fallback-one", "fallback-two"])
634 );
635 assert_eq!(outcome.request.model_id().as_deref(), Some("selected"));
636 assert!(outcome.response.is_none());
637 assert!(outcome.metadata.is_none());
638
639 let outcome = RoutingOutcome::route_to("only".into(), Vec::new(), request());
640 assert_eq!(outcome.selected_model_ids, target_set(&["only"]));
641
642 let outcome = RoutingOutcome::answered(
643 "answered".into(),
644 request(),
645 Response {
646 llm_response: LlmResponse::Agg(text_response(None, "existing")),
647 metadata: None,
648 },
649 );
650
651 assert_eq!(outcome.selected_model_ids, target_set(&["answered"]));
652 assert_eq!(outcome.request.model_id().as_deref(), Some("answered"));
653 assert_eq!(
654 outcome
655 .response
656 .as_ref()
657 .and_then(|response| response.llm_response.as_agg())
658 .map(completion_text),
659 Some("existing".to_string())
660 );
661 }
662
663 fn target_set(names: &[&str]) -> Vec<ModelId> {
664 names.iter().map(|name| ModelId::from(*name)).collect()
665 }
666
667 #[tokio::test]
668 async fn typed_driver_preserves_call_and_stream_boundaries() -> Result<()> {
669 tokio::time::timeout(std::time::Duration::from_secs(1), async {
670 let (driver, mut step_rx) = Driver::new("test", Arc::new(RuntimeModels::default()));
673 let first_driver = driver.clone();
674 let mut first = tokio::spawn(async move {
675 first_driver
676 .call_model(request(), vec![ModelId::from("first")])
677 .await
678 });
679 let second = tokio::spawn(async move {
680 driver
681 .call_model(request(), vec![ModelId::from("second")])
682 .await
683 });
684
685 let mut calls = HashMap::new();
686 for _ in 0..2 {
687 let step = step_rx.recv().await.ok_or(DriverError::StreamClosed)??;
688 let Step::CallModel(call) = step else {
689 return Err(test_error("expected a CallModel step"));
690 };
691 let selected_model = call
692 .models
693 .first()
694 .ok_or_else(|| test_error("model call has no candidates"))?
695 .to_string();
696 calls.insert(selected_model, call);
697 }
698 assert!(
699 tokio::time::timeout(std::time::Duration::from_millis(20), &mut first)
700 .await
701 .is_err(),
702 "call completed before the host responded"
703 );
704 calls
705 .remove("second")
706 .ok_or_else(|| test_error("missing second call"))?
707 .respond(Ok(reply("second response")))?;
708 calls
709 .remove("first")
710 .ok_or_else(|| test_error("missing first call"))?
711 .respond(Ok(reply("first response")))?;
712
713 let first_response = first
714 .await
715 .map_err(|source| LibsyError::external("joining a test task", source))??;
716 let second_response = second
717 .await
718 .map_err(|source| LibsyError::external("joining a test task", source))??;
719 assert_eq!(
720 first_response.llm_response.as_agg().map(completion_text),
721 Some("first response".to_string())
722 );
723 assert_eq!(
724 second_response.llm_response.as_agg().map(completion_text),
725 Some("second response".to_string())
726 );
727
728 let (driver, mut step_rx) = Driver::new("test", Arc::new(RuntimeModels::default()));
730 let producer = tokio::spawn(async move {
731 driver
732 .call_model(request(), vec![ModelId::from("dropped")])
733 .await
734 });
735 let step = step_rx.recv().await.ok_or(DriverError::StreamClosed)??;
736 let Step::CallModel(call) = step else {
737 return Err(test_error("expected a CallModel step"));
738 };
739 drop(call);
740 let result = producer
741 .await
742 .map_err(|source| LibsyError::external("joining a test task", source))?;
743 assert!(matches!(
744 result,
745 Err(LibsyError::Driver(DriverError::ResponseDropped))
746 ));
747
748 let (driver, step_rx) = Driver::new("test", Arc::new(RuntimeModels::default()));
750 drop(step_rx);
751 let result = driver
752 .call_model(request(), vec![ModelId::from("closed")])
753 .await;
754 assert!(matches!(
755 result,
756 Err(LibsyError::Driver(DriverError::StreamClosed))
757 ));
758 Ok(())
759 })
760 .await
761 .map_err(|error| LibsyError::external("waiting for typed driver boundaries", error))?
762 }
763
764 #[test]
765 fn target_lookup_returns_the_missing_target() {
766 let error = ensure_model_is_target(&target_set(&[]), &ModelId::from("missing")).err();
767 assert!(matches!(
768 error,
769 Some(LibsyError::TargetNotFound { target }) if target == "missing"
770 ));
771 }
772
773 fn streaming_orch(chunks: Vec<LlmResponseChunk>) -> (Arc<dyn Algorithm>, impl Serve) {
776 let algo = orch(target_set(&["stream/model"]));
777 let serve = move |_target: ModelId, _request: Request| {
778 let chunks = chunks.clone();
779 async move {
780 let stream =
781 futures::stream::iter(chunks.into_iter().map(|chunk| Ok(chunk.into()))).boxed();
782 Ok(Response {
783 llm_response: LlmResponse::Stream(stream),
784 metadata: None,
785 })
786 }
787 };
788 (algo, serve)
789 }
790
791 #[tokio::test]
792 async fn run_returns_a_streamed_response_the_caller_aggregates() -> Result<()> {
793 let (orch, serve) = streaming_orch(vec![
796 LlmResponseChunk::MessageStart {
797 id: Some("m1".to_string()),
798 model: Some("stream/model".to_string()),
799 },
800 LlmResponseChunk::TextDelta {
801 index: 0,
802 text: "hel".to_string(),
803 },
804 LlmResponseChunk::TextDelta {
805 index: 0,
806 text: "lo".to_string(),
807 },
808 LlmResponseChunk::MessageStop {
809 reason: Some("stop".to_string()),
810 },
811 ]);
812 let (selected_model, response) = test_drive(orch, request(), serve).await?;
813 let agg = response
815 .llm_response
816 .into_agg()
817 .await
818 .map_err(|error| LibsyError::external("aggregating response stream", error))?;
819 assert_eq!(completion_text(&agg), "hello");
820 assert_eq!(agg.model.as_deref(), Some("stream/model"));
821 assert_eq!(selected_model, "stream/model");
822 Ok(())
823 }
824
825 #[tokio::test]
826 async fn aggregating_a_streamed_response_propagates_a_mid_stream_error() -> Result<()> {
827 let (orch, serve) = streaming_orch(vec![
830 LlmResponseChunk::TextDelta {
831 index: 0,
832 text: "partial".to_string(),
833 },
834 LlmResponseChunk::StreamError {
835 message: "upstream exploded".to_string(),
836 },
837 ]);
838 let (_, response) = test_drive(orch, request(), serve).await?;
839 match response.llm_response.into_agg().await {
840 Ok(_) => panic!("expected a mid-stream error, got an aggregate"),
841 Err(err) => {
842 assert!(err.to_string().contains("upstream exploded"));
843 Ok(())
844 }
845 }
846 }
847
848 #[tokio::test]
849 async fn run_offloads_via_promise_then_finishes() -> Result<()> {
850 let stream = orch(target_set(&["offload/model"]))
853 .run_stream(request(), Arc::new(RuntimeModels::default()));
854 tokio::pin!(stream);
855
856 let mut saw_call = false;
857 let mut final_completion = None;
858 while let Some(step) = stream.next().await {
859 match step? {
860 Step::CallModel(call) => {
861 saw_call = true;
862 assert_eq!(call.models, vec![ModelId::from("offload/model")]);
863 call.respond(Ok(Response {
865 llm_response: LlmResponse::Agg(text_response(
866 None,
867 "fulfilled".to_string(),
868 )),
869 metadata: None,
870 }))?;
871 }
872 Step::Done(outcome) => {
873 let metadata = outcome
874 .metadata
875 .as_ref()
876 .expect("run_stream should attach outcome metadata");
877 assert_eq!(metadata.algorithm, "test");
878 assert_eq!(
879 uuid::Uuid::parse_str(metadata.outcome_id())
880 .expect("outcome id should be a UUID")
881 .get_version_num(),
882 7
883 );
884 assert_eq!(
885 metadata.evidence,
886 Some(serde_json::json!({"source": "test"}))
887 );
888 let response = outcome
889 .response
890 .ok_or_else(|| test_error("expected an answered outcome"))?;
891 final_completion = Some(
892 response
893 .llm_response
894 .as_agg()
895 .map(completion_text)
896 .unwrap_or_default(),
897 );
898 }
899 }
900 }
901
902 assert!(saw_call, "expected a CallModel step before Done");
903 assert_eq!(
904 final_completion.ok_or_else(|| test_error("no Done step"))?,
905 "fulfilled"
906 );
907 Ok(())
908 }
909
910 #[tokio::test(flavor = "multi_thread", worker_threads = 12)]
911 async fn requests_are_processed_in_parallel() -> Result<()> {
912 use std::time::Duration;
913 use tokio::sync::Barrier;
914
915 const N: usize = 12;
916
917 let barrier = Arc::new(Barrier::new(N));
922 let algo = orch(target_set(&["m"]));
924
925 let mut handles = Vec::new();
926 for _ in 0..N {
927 let algo = algo.clone();
928 let barrier = barrier.clone();
929 let serve = move |target: ModelId, _request: Request| {
930 let barrier = barrier.clone();
931 async move {
932 barrier.wait().await;
933 Ok(reply(target))
934 }
935 };
936 handles.push(tokio::spawn(async move {
937 test_drive(algo, request(), serve)
938 .await
939 .map(|(_, response)| {
940 response
941 .llm_response
942 .as_agg()
943 .map(completion_text)
944 .unwrap_or_default()
945 })
946 }));
947 }
948
949 for handle in handles {
950 let completion = tokio::time::timeout(Duration::from_secs(5), handle)
952 .await
953 .map_err(|error| LibsyError::external("waiting for test task", error))?
954 .map_err(|source| LibsyError::external("joining a test task", source))??;
955 assert_eq!(completion, "m");
956 }
957 Ok(())
958 }
959
960 #[tokio::test]
961 async fn offload_error_propagates_back_to_the_algorithm() -> Result<()> {
962 let stream = orch(target_set(&["offload/model"]))
966 .run_stream(request(), Arc::new(RuntimeModels::default()));
967 tokio::pin!(stream);
968
969 let mut saw_error = false;
970 while let Some(step) = stream.next().await {
971 match step {
972 Ok(Step::CallModel(call)) => {
973 call.respond(Err(test_error("upstream model call failed")))?;
974 }
975 Ok(Step::Done(..)) => {
976 return Err(test_error(
977 "expected the offload error to propagate, got a response",
978 ));
979 }
980 Err(err) => {
981 assert!(err.to_string().contains("upstream model call failed"));
983 saw_error = true;
984 }
985 }
986 }
987
988 assert!(saw_error, "expected an error step");
989 Ok(())
990 }
991
992 #[tokio::test]
993 async fn dropping_the_stream_cancels_the_algorithm_task() -> Result<()> {
994 use std::sync::atomic::{AtomicBool, Ordering};
995 use std::time::Duration;
996 use tokio::sync::mpsc;
997
998 struct DropGuard(Arc<AtomicBool>);
1001 impl Drop for DropGuard {
1002 fn drop(&mut self) {
1003 self.0.store(true, Ordering::SeqCst);
1004 }
1005 }
1006
1007 struct StuckAlgo {
1008 started: mpsc::UnboundedSender<()>,
1009 dropped: Arc<AtomicBool>,
1010 }
1011
1012 #[async_trait]
1013 impl Algorithm for StuckAlgo {
1014 fn name(&self) -> &str {
1015 "stuck"
1016 }
1017
1018 async fn route(
1019 self: Arc<Self>,
1020 _driver: Driver,
1021 _request: Request,
1022 ) -> Result<RoutingOutcome> {
1023 let _guard = DropGuard(self.dropped.clone());
1024 let _ = self.started.send(());
1025 std::future::pending::<()>().await;
1027 unreachable!()
1028 }
1029 }
1030
1031 let (started_tx, mut started_rx) = mpsc::unbounded_channel();
1032 let dropped = Arc::new(AtomicBool::new(false));
1033 let algo: Arc<dyn Algorithm> = Arc::new(StuckAlgo {
1034 started: started_tx,
1035 dropped: dropped.clone(),
1036 });
1037
1038 let stream = algo.run_stream(request(), Arc::new(RuntimeModels::default()));
1039 started_rx
1040 .recv()
1041 .await
1042 .ok_or_else(|| test_error("task never started"))?;
1043 drop(stream);
1044 tokio::time::sleep(Duration::from_millis(100)).await;
1045
1046 assert!(
1047 dropped.load(Ordering::SeqCst),
1048 "algorithm task was NOT cancelled after dropping the stream"
1049 );
1050 Ok(())
1051 }
1052
1053 #[tokio::test]
1054 async fn route_panic_surfaces_as_a_stream_error() -> Result<()> {
1055 struct Panicky;
1058
1059 #[async_trait]
1060 impl Algorithm for Panicky {
1061 fn name(&self) -> &str {
1062 "panicky"
1063 }
1064
1065 async fn route(
1066 self: Arc<Self>,
1067 _driver: Driver,
1068 _request: Request,
1069 ) -> Result<RoutingOutcome> {
1070 panic!("boom");
1071 }
1072 }
1073
1074 let algo: Arc<dyn Algorithm> = Arc::new(Panicky);
1075 let stream = algo.run_stream(request(), Arc::new(RuntimeModels::default()));
1076 tokio::pin!(stream);
1077
1078 let mut saw_error = false;
1079 while let Some(step) = stream.next().await {
1080 match step {
1081 Err(err) => {
1082 assert!(err.to_string().contains("algorithm task panicked: boom"));
1084 saw_error = true;
1085 }
1086 Ok(_) => return Err(test_error("expected the panic to surface as an error step")),
1087 }
1088 }
1089
1090 assert!(saw_error, "expected an error step from the panicked task");
1091 Ok(())
1092 }
1093
1094 #[tokio::test]
1098 async fn a_panic_with_a_leaked_driver_clone_still_terminates_the_run() -> Result<()> {
1099 struct LeakyPanic;
1100
1101 #[async_trait]
1102 impl Algorithm for LeakyPanic {
1103 fn name(&self) -> &str {
1104 "leaky_panic"
1105 }
1106
1107 async fn route(
1108 self: Arc<Self>,
1109 driver: Driver,
1110 _request: Request,
1111 ) -> Result<RoutingOutcome> {
1112 tokio::spawn(async move {
1113 let _keep_alive = driver;
1115 std::future::pending::<()>().await;
1116 });
1117 tokio::task::yield_now().await;
1118 panic!("boom");
1119 }
1120 }
1121
1122 let algo: Arc<dyn Algorithm> = Arc::new(LeakyPanic);
1123 let result = tokio::time::timeout(
1125 std::time::Duration::from_secs(1),
1126 test_drive(algo, request(), echo()),
1127 )
1128 .await
1129 .map_err(|error| LibsyError::external("waiting for the panicked run to end", error))?;
1130
1131 match result {
1132 Ok(_) => Err(test_error(
1133 "expected the panic to end the run with an error",
1134 )),
1135 Err(err) => {
1136 assert!(err.to_string().contains("algorithm task panicked: boom"));
1137 Ok(())
1138 }
1139 }
1140 }
1141
1142 #[tokio::test]
1143 async fn cancelling_run_cancels_the_algorithm_task() -> Result<()> {
1144 use std::sync::atomic::{AtomicBool, Ordering};
1145 use std::time::Duration;
1146 use tokio::sync::mpsc;
1147
1148 struct DropGuard(Arc<AtomicBool>);
1151 impl Drop for DropGuard {
1152 fn drop(&mut self) {
1153 self.0.store(true, Ordering::SeqCst);
1154 }
1155 }
1156
1157 struct StuckAlgo {
1158 started: mpsc::UnboundedSender<()>,
1159 dropped: Arc<AtomicBool>,
1160 }
1161
1162 #[async_trait]
1163 impl Algorithm for StuckAlgo {
1164 fn name(&self) -> &str {
1165 "stuck"
1166 }
1167
1168 async fn route(
1169 self: Arc<Self>,
1170 _driver: Driver,
1171 _request: Request,
1172 ) -> Result<RoutingOutcome> {
1173 let _guard = DropGuard(self.dropped.clone());
1174 let _ = self.started.send(());
1175 std::future::pending::<()>().await;
1178 unreachable!()
1179 }
1180 }
1181
1182 let (started_tx, mut started_rx) = mpsc::unbounded_channel();
1183 let dropped = Arc::new(AtomicBool::new(false));
1184 let algo: Arc<dyn Algorithm> = Arc::new(StuckAlgo {
1185 started: started_tx,
1186 dropped: dropped.clone(),
1187 });
1188
1189 let run_task = tokio::spawn(async move { test_drive(algo, request(), echo()).await });
1192 started_rx
1193 .recv()
1194 .await
1195 .ok_or_else(|| test_error("task never started"))?;
1196 run_task.abort();
1197 tokio::time::sleep(Duration::from_millis(100)).await;
1198
1199 assert!(
1200 dropped.load(Ordering::SeqCst),
1201 "algorithm task was NOT cancelled after cancelling run"
1202 );
1203 Ok(())
1204 }
1205
1206 struct Hedge {
1211 winner: String,
1212 loser: String,
1213 }
1214
1215 #[async_trait]
1216 impl Algorithm for Hedge {
1217 fn name(&self) -> &str {
1218 "hedge"
1219 }
1220
1221 async fn route(
1222 self: Arc<Self>,
1223 driver: Driver,
1224 request: Request,
1225 ) -> Result<RoutingOutcome> {
1226 let outcome_request = request.clone();
1227 let win = driver.call_model(request.clone(), vec![self.winner.clone().into()]);
1228 let lose = driver.call_model(request, vec![self.loser.clone().into()]);
1229 tokio::select! {
1231 res = win => Ok(RoutingOutcome::answered(
1232 self.winner.clone().into(),
1233 outcome_request,
1234 res?,
1235 )),
1236 res = lose => Ok(RoutingOutcome::answered(
1237 self.loser.clone().into(),
1238 outcome_request,
1239 res?,
1240 )),
1241 }
1242 }
1243 }
1244
1245 fn hedge(loser_delay: Option<std::time::Duration>) -> (Arc<dyn Algorithm>, impl Serve) {
1249 let started = Arc::new(tokio::sync::Notify::new());
1250 let algo = Arc::new(Hedge {
1251 winner: "winner".to_string(),
1252 loser: "loser".to_string(),
1253 });
1254 let serve = move |target: ModelId, _request: Request| {
1255 let started = started.clone();
1256 async move {
1257 if target == "loser" {
1258 started.notify_one();
1259 match loser_delay {
1260 Some(delay) => tokio::time::sleep(delay).await,
1261 None => std::future::pending::<()>().await,
1262 }
1263 } else {
1264 started.notified().await;
1265 }
1266 Ok(reply(target))
1267 }
1268 };
1269 (algo, serve)
1270 }
1271
1272 #[tokio::test]
1273 async fn run_returns_the_winner_without_a_late_loser_overwriting_it() -> Result<()> {
1274 let (algo, serve) = hedge(Some(std::time::Duration::from_millis(50)));
1277 let (_, response) = test_drive(algo, request(), serve).await?;
1278 assert_eq!(
1279 response
1280 .llm_response
1281 .as_agg()
1282 .map(completion_text)
1283 .unwrap_or_default(),
1284 "winner"
1285 );
1286 Ok(())
1287 }
1288
1289 #[tokio::test]
1290 async fn run_returns_the_winner_without_hanging_on_a_pending_loser() -> Result<()> {
1291 let (algo, serve) = hedge(None);
1294 let run = test_drive(algo, request(), serve);
1295 let (_, response) = tokio::time::timeout(std::time::Duration::from_secs(1), run)
1296 .await
1297 .map_err(|error| LibsyError::external("waiting for pending loser", error))??;
1298 assert_eq!(
1299 response
1300 .llm_response
1301 .as_agg()
1302 .map(completion_text)
1303 .unwrap_or_default(),
1304 "winner"
1305 );
1306 Ok(())
1307 }
1308
1309 #[tokio::test]
1310 async fn run_surfaces_a_terminal_error_with_many_calls_in_flight() -> Result<()> {
1311 use std::sync::atomic::{AtomicUsize, Ordering};
1312
1313 const N: usize = 10;
1316
1317 struct FanOutThenError {
1320 all_started: Arc<tokio::sync::Notify>,
1321 n: usize,
1322 }
1323
1324 #[async_trait]
1325 impl Algorithm for FanOutThenError {
1326 fn name(&self) -> &str {
1327 "fan_out_then_error"
1328 }
1329
1330 async fn route(
1331 self: Arc<Self>,
1332 driver: Driver,
1333 request: Request,
1334 ) -> Result<RoutingOutcome> {
1335 let offloads = futures::future::join_all(
1336 (0..self.n)
1337 .map(|i| driver.call_model(request.clone(), vec![format!("m{i}").into()])),
1338 );
1339 tokio::select! {
1340 _ = offloads => Err(test_error("offloads unexpectedly completed")),
1341 _ = self.all_started.notified() => {
1342 Err(test_error("terminal error while calls pending"))
1343 }
1344 }
1345 }
1346 }
1347
1348 let all_started = Arc::new(tokio::sync::Notify::new());
1349 let algo: Arc<dyn Algorithm> = Arc::new(FanOutThenError {
1350 all_started: all_started.clone(),
1351 n: N,
1352 });
1353
1354 let started = Arc::new(AtomicUsize::new(0));
1356 let serve = move |_target: ModelId, _request: Request| {
1357 let started = started.clone();
1358 let all_started = all_started.clone();
1359 async move {
1360 if started.fetch_add(1, Ordering::SeqCst) + 1 == N {
1361 all_started.notify_one();
1362 }
1363 std::future::pending::<ServeResult>().await
1364 }
1365 };
1366
1367 let run = test_drive(algo, request(), serve);
1370 let result = tokio::time::timeout(std::time::Duration::from_millis(500), run)
1371 .await
1372 .map_err(|error| {
1373 LibsyError::external("waiting for terminal error with full call cap", error)
1374 })?;
1375 match result {
1376 Ok(_) => Err(test_error("expected the terminal error, got a response")),
1377 Err(err) => {
1378 assert!(
1379 err.to_string()
1380 .contains("terminal error while calls pending")
1381 );
1382 Ok(())
1383 }
1384 }
1385 }
1386}