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::{
28 AggLlmResponse, Category, ContentBlock, DecisionRequest, DecisionResponse, ModelId, Request,
29 Response, StopReason,
30};
31
32use crate::{DriverError, LibsyError, Result, State, observability};
33
34pub type StepStream = Pin<Box<dyn Stream<Item = Result<Step>> + Send>>;
38
39#[derive(Clone, Debug, Default)]
50pub struct RuntimeModels {
51 by_category: HashMap<Category, Vec<ModelId>>,
53 subagent: Option<HashMap<Category, Vec<ModelId>>>,
54}
55
56impl RuntimeModels {
57 pub fn new(by_category: HashMap<Category, Vec<ModelId>>) -> Self {
59 Self {
60 by_category,
61 subagent: None,
62 }
63 }
64
65 pub fn with_subagent(mut self, models: HashMap<Category, Vec<ModelId>>) -> Self {
67 self.subagent = Some(models);
68 self
69 }
70
71 pub fn models_for(&self, category: &Category) -> &[ModelId] {
73 self.by_category.get(category).map_or(&[], Vec::as_slice)
74 }
75
76 pub fn subagent_models_for(&self, category: &Category) -> &[ModelId] {
78 self.subagent
79 .as_ref()
80 .and_then(|models| models.get(category))
81 .map_or(&[], Vec::as_slice)
82 }
83}
84
85impl From<HashMap<Category, Vec<ModelId>>> for RuntimeModels {
86 fn from(by_category: HashMap<Category, Vec<ModelId>>) -> Self {
87 Self::new(by_category)
88 }
89}
90
91#[derive(Clone, Copy)]
93enum Scope {
94 Parent,
95 Subagent,
96}
97
98pub struct CallModel {
108 pub algorithm: String,
111 pub request: Request,
113 pub models: Vec<ModelId>,
115 pub recover_errors: bool,
117 reply: Option<oneshot::Sender<Result<Response>>>,
119 started: Instant,
120 span: tracing::Span,
122}
123
124impl CallModel {
125 pub async fn respond(mut self, work: impl Future<Output = Result<Response>>) -> Result<()> {
131 let reply = self.reply.as_mut().ok_or(DriverError::ResponseDropped)?;
132 let result = tokio::select! {
133 biased;
134 _ = reply.closed() => return Ok(()),
135 result = work => result,
136 };
137 if self.reply.as_ref().is_some_and(oneshot::Sender::is_closed) {
138 return Ok(());
139 }
140 if !self.recover_errors
141 && let Err(error) = result
142 {
143 return self.fail(error);
144 }
145 self.record(result.is_ok());
146 if let Ok(response) = &result {
147 observability::record_llm_response(response, &self.span);
148 }
149 self.reply
150 .take()
151 .ok_or(DriverError::ResponseDropped)?
152 .send(result)
153 .ok();
154 Ok(())
155 }
156
157 pub fn fail(mut self, error: LibsyError) -> Result<()> {
160 self.reply = None;
161 self.record(false);
162 Err(error)
163 }
164
165 fn record(&self, is_ok: bool) {
166 observability::record_llm_call(
167 &self.algorithm,
168 self.models
169 .first()
170 .map(ModelId::as_str)
171 .unwrap_or("NoTargets"),
172 self.started.elapsed(),
173 is_ok,
174 );
175 self.span
176 .record("outcome", if is_ok { "ok" } else { "error" });
177 }
178}
179
180impl Drop for CallModel {
181 fn drop(&mut self) {
182 if self.reply.is_some() {
183 self.record(false);
184 }
185 }
186}
187
188pub struct CallDecision {
191 pub algorithm: String,
193 pub request: DecisionRequest,
195 pub model: ModelId,
197 reply: Option<oneshot::Sender<Result<DecisionResponse>>>,
198 started: Instant,
199 span: tracing::Span,
201}
202
203impl CallDecision {
204 pub async fn respond(
208 mut self,
209 work: impl Future<Output = Result<DecisionResponse>>,
210 ) -> Result<()> {
211 let reply = self.reply.as_mut().ok_or(DriverError::ResponseDropped)?;
212 let result = tokio::select! {
213 biased;
214 _ = reply.closed() => return Ok(()),
215 result = work => result,
216 };
217 self.record(result.is_ok());
218 if let Ok(response) = &result {
219 observability::record_decision_response(response, &self.span);
220 }
221 self.reply
222 .take()
223 .ok_or(DriverError::ResponseDropped)?
224 .send(result)
225 .ok();
226 Ok(())
227 }
228
229 pub fn fail(mut self, error: LibsyError) -> Result<()> {
231 self.reply = None;
232 self.record(false);
233 Err(error)
234 }
235
236 fn record(&self, is_ok: bool) {
237 observability::record_decision_call(
238 &self.algorithm,
239 &self.model,
240 self.started.elapsed(),
241 is_ok,
242 );
243 self.span
244 .record("outcome", if is_ok { "ok" } else { "error" });
245 }
246}
247
248impl Drop for CallDecision {
249 fn drop(&mut self) {
250 if self.reply.is_some() {
251 self.record(false);
252 }
253 }
254}
255
256pub struct RoutingOutcome {
258 pub selected_model_ids: Vec<ModelId>,
260 pub request: Request,
262 pub fallback_request: Option<Request>,
266 pub candidate_requests: HashMap<ModelId, Request>,
268 pub response: Option<Response>,
270 pub metadata: Option<crate::OutcomeMetadata>,
275 pub state: State,
277}
278
279impl RoutingOutcome {
280 pub fn selected_model_id(&self) -> Result<&ModelId> {
283 self.selected_model_ids.first().ok_or(LibsyError::NoTargets)
284 }
285
286 pub fn route_to(
290 selected_model_id: ModelId,
291 fallback_models: Vec<ModelId>,
292 mut request: Request,
293 ) -> Self {
294 request.llm_request.model = Some(selected_model_id.to_string());
295 let mut selected_model_ids = Vec::with_capacity(1 + fallback_models.len());
296 selected_model_ids.push(selected_model_id);
297 selected_model_ids.extend(fallback_models);
298 Self {
299 selected_model_ids,
300 request,
301 fallback_request: None,
302 candidate_requests: HashMap::new(),
303 response: None,
304 metadata: None,
305 state: State::default(),
306 }
307 }
308
309 pub fn answered(selected_model_id: ModelId, mut request: Request, response: Response) -> Self {
312 request.llm_request.model = Some(selected_model_id.to_string());
313 Self {
314 selected_model_ids: vec![selected_model_id],
315 request,
316 fallback_request: None,
317 candidate_requests: HashMap::new(),
318 response: Some(response),
319 metadata: None,
320 state: State::default(),
321 }
322 }
323}
324
325#[derive(Clone, Copy, Debug, Eq, PartialEq)]
327pub struct AnswerSummary {
328 pub final_reply: bool,
330}
331
332impl AnswerSummary {
333 pub fn of(response: &AggLlmResponse) -> Self {
335 let final_reply = response.outputs.iter().all(|output| {
336 matches!(output.stop_reason, None | Some(StopReason::EndTurn))
337 && !output
338 .content
339 .iter()
340 .any(|block| matches!(block, ContentBlock::ToolCall(_)))
341 }) && response.outputs.iter().any(|output| {
342 output.role == switchyard_protocol::Role::Assistant
343 && output.content.iter().any(
344 |block| matches!(block, ContentBlock::Text { text } if !text.trim().is_empty()),
345 )
346 });
347 Self { final_reply }
348 }
349}
350
351#[derive(Clone)]
353pub struct Driver {
354 step_tx: mpsc::Sender<Result<Step>>,
355
356 algorithm: String,
358
359 evidence: Arc<Mutex<Option<Value>>>,
361
362 state: Arc<Mutex<State>>,
364
365 models: Arc<RuntimeModels>,
367
368 scope: Scope,
370 judge: Option<ModelId>,
371}
372
373impl Driver {
374 pub(crate) fn new(
377 algorithm: &str,
378 models: Arc<RuntimeModels>,
379 ) -> (Self, mpsc::Receiver<Result<Step>>) {
380 let (step_tx, step_rx) = mpsc::channel(1);
385 (
386 Self {
387 step_tx,
388 algorithm: algorithm.to_string(),
389 evidence: Arc::new(Mutex::new(None)),
390 state: Arc::new(Mutex::new(State::default())),
391 models,
392 scope: Scope::Parent,
393 judge: None,
394 },
395 step_rx,
396 )
397 }
398
399 pub(crate) fn take_state(&self) -> State {
400 std::mem::take(&mut *self.state.lock())
401 }
402
403 pub(crate) fn set_state(&self, state: State) {
404 *self.state.lock() = state;
405 }
406
407 pub(crate) fn set_evidence(&self, evidence: Value) {
409 *self.evidence.lock() = Some(evidence);
410 }
411
412 pub(crate) fn set_evidence_if_empty(&self, evidence: Value) {
414 let mut current = self.evidence.lock();
415 if current.is_none() {
416 *current = Some(evidence);
417 }
418 }
419
420 pub async fn call_model(&self, request: Request, models: Vec<ModelId>) -> Result<Response> {
430 self.call_model_with_error_recovery(request, models, false)
431 .await
432 }
433
434 #[tracing::instrument(
436 target = "libsy",
437 name = "libsy.llm_call",
438 skip_all,
439 fields(
440 algorithm = self.algorithm,
441 selected_model = %models.first().map(ModelId::as_str).unwrap_or("NoTargets"),
442 openinference.span.kind = "CHAIN",
443 outcome = tracing::field::Empty,
444 input_tokens = tracing::field::Empty,
445 output_tokens = tracing::field::Empty,
446 total_tokens = tracing::field::Empty,
447 reasoning_tokens = tracing::field::Empty,
448 )
449 )]
450 pub(crate) async fn call_model_with_error_recovery(
451 &self,
452 mut request: Request,
453 models: Vec<ModelId>,
454 recover_errors: bool,
455 ) -> Result<Response> {
456 let Some(selected_model_id) = models.first() else {
457 return Err(LibsyError::NoTargets);
458 };
459 request.llm_request.model = Some(selected_model_id.to_string());
460 let started = Instant::now();
461 let (reply, response) = oneshot::channel::<Result<Response>>();
462 let call = CallModel {
463 algorithm: self.algorithm.clone(),
464 request,
465 models,
466 recover_errors,
467 reply: Some(reply),
468 started,
469 span: tracing::Span::current(),
470 };
471 self.step_tx
472 .send(Ok(Step::CallModel(Box::new(call))))
473 .await
474 .map_err(|_| DriverError::StreamClosed)?;
475 response
476 .await
477 .map_err(|_| LibsyError::from(DriverError::ResponseDropped))?
478 }
479
480 #[tracing::instrument(
482 target = "libsy",
483 name = "libsy.decision_call",
484 skip_all,
485 fields(
486 algorithm = self.algorithm,
487 selected_model = %model,
488 openinference.span.kind = "CHAIN",
489 outcome = tracing::field::Empty,
490 input_tokens = tracing::field::Empty,
491 output_tokens = tracing::field::Empty,
492 total_tokens = tracing::field::Empty,
493 reasoning_tokens = tracing::field::Empty,
494 gen_ai.response.id = tracing::field::Empty,
495 gen_ai.response.model = tracing::field::Empty,
496 ),
497 )]
498 pub async fn call_decision(
499 &self,
500 mut request: DecisionRequest,
501 model: ModelId,
502 ) -> Result<DecisionResponse> {
503 request.model = Some(model.clone());
504 let started = Instant::now();
505 let (reply, response) = oneshot::channel();
506 let call = CallDecision {
507 algorithm: self.algorithm.clone(),
508 request,
509 model,
510 reply: Some(reply),
511 started,
512 span: tracing::Span::current(),
513 };
514 self.step_tx
515 .send(Ok(Step::CallDecision(Box::new(call))))
516 .await
517 .map_err(|_| DriverError::StreamClosed)?;
518 response
519 .await
520 .map_err(|_| LibsyError::from(DriverError::ResponseDropped))?
521 }
522
523 pub fn models_for(&self, category: &Category) -> &[ModelId] {
525 if *category == Category::Judge
526 && let Some(judge) = &self.judge
527 {
528 return std::slice::from_ref(judge);
529 }
530 match self.scope {
531 Scope::Parent => self.models.models_for(category),
532 Scope::Subagent => self.models.subagent_models_for(category),
533 }
534 }
535
536 pub(crate) fn for_judge(&self, judge: ModelId) -> Self {
537 Self {
538 judge: Some(judge),
539 ..self.clone()
540 }
541 }
542
543 pub fn first_model_for(&self, category: &Category) -> Result<&ModelId> {
545 self.models_for(category)
546 .first()
547 .ok_or_else(|| LibsyError::AlgorithmError {
548 message: format!("no models available for category {}", category.as_str()),
549 })
550 }
551
552 pub fn for_subagent(&self) -> Result<Self> {
555 if self.models.subagent.is_none() {
556 return Err(LibsyError::AlgorithmError {
557 message: "delegated work has no sub-agent models".to_string(),
558 });
559 }
560 Ok(Self {
561 scope: Scope::Subagent,
562 ..self.clone()
563 })
564 }
565
566 pub(crate) async fn finish(&self, result: Result<RoutingOutcome>) -> Result<()> {
570 let result = result.map(|mut outcome| {
571 outcome.state = self.take_state();
572 let metadata = outcome.metadata.get_or_insert_with(|| {
573 crate::OutcomeMetadata::new(self.algorithm.clone(), self.evidence.lock().take())
574 });
575 observability::record_outcome(metadata, &outcome.selected_model_ids);
576 outcome
577 });
578 let selected_model = result
579 .as_ref()
580 .ok()
581 .and_then(|outcome| outcome.selected_model_id().ok().cloned());
582 let step = result.map(|outcome| Step::Done(Box::new(outcome)));
583 self.step_tx
584 .send(step)
585 .await
586 .map_err(|_| DriverError::StreamClosed)?;
587 if let Some(selected_model) = selected_model {
588 observability::record_decision(&self.algorithm, &selected_model);
589 }
590 Ok(())
591 }
592}
593
594pub enum Step {
597 CallModel(Box<CallModel>),
600 CallDecision(Box<CallDecision>),
602 Done(Box<RoutingOutcome>),
604}
605
606pub enum Call {
609 Model(Box<CallModel>),
611 Decision(Box<CallDecision>),
613}
614
615pub async fn drive<F, Fut>(
625 algorithm: Arc<dyn Algorithm>,
626 request: Request,
627 models: Arc<RuntimeModels>,
628 serve: F,
629) -> Result<RoutingOutcome>
630where
631 F: Fn(Call) -> Fut,
632 Fut: Future<Output = Result<()>>,
633{
634 drive_with_state(algorithm, request, models, State::default(), serve).await
635}
636
637pub(crate) async fn drive_with_state<F, Fut>(
639 algorithm: Arc<dyn Algorithm>,
640 request: Request,
641 models: Arc<RuntimeModels>,
642 state: State,
643 serve: F,
644) -> Result<RoutingOutcome>
645where
646 F: Fn(Call) -> Fut,
647 Fut: Future<Output = Result<()>>,
648{
649 let stream = algorithm.run_stream(request, models, state);
650 tokio::pin!(stream);
651
652 let mut in_flight = futures::stream::FuturesUnordered::new();
653 let mut final_outcome: Option<RoutingOutcome> = None;
654
655 loop {
656 tokio::select! {
657 Some(result) = in_flight.next() => match result {
658 Ok(()) => {}, Err(err) => return Err(err), },
661 step = stream.next() => {
662 match step {
663 None => break, Some(item) => match item? {
665 Step::CallModel(call) => in_flight.push(serve(Call::Model(call))),
666 Step::CallDecision(call) => {
667 in_flight.push(serve(Call::Decision(call)));
668 }
669 Step::Done(outcome) => {
670 final_outcome = Some(*outcome);
671 break;
672 }
673 }
674 }
675 },
676 }
677 }
678 final_outcome.ok_or(LibsyError::MissingFinalResponse)
679}
680
681fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
683 payload
684 .downcast_ref::<&'static str>()
685 .map(|message| (*message).to_string())
686 .or_else(|| payload.downcast_ref::<String>().cloned())
687 .unwrap_or_else(|| "unknown panic payload".to_string())
688}
689
690struct AbortOnDrop(tokio::task::AbortHandle);
692
693impl Drop for AbortOnDrop {
694 fn drop(&mut self) {
695 self.0.abort();
696 }
697}
698
699pub(crate) fn ensure_model_is_target(targets: &[ModelId], name: &ModelId) -> Result<()> {
704 targets
705 .iter()
706 .any(|target| target == name)
707 .then_some(())
708 .ok_or_else(|| LibsyError::TargetNotFound {
709 target: name.clone(),
710 })
711}
712
713#[derive(Clone, Hash, PartialEq, Eq)]
716pub(crate) enum RoutingIdentity {
717 Session(String),
719 Subagent { session: String, agent: String },
721}
722
723impl RoutingIdentity {
724 pub(crate) fn from_request(request: &Request) -> Option<Self> {
729 let metadata = request.metadata.as_ref()?;
730 let session = metadata.session_id.as_deref().filter(|id| !id.is_empty())?;
731 if metadata.is_subagent {
732 let agent = metadata.agent_id.as_deref().filter(|id| !id.is_empty())?;
733 Some(Self::Subagent {
734 session: session.to_string(),
735 agent: agent.to_string(),
736 })
737 } else {
738 Some(Self::Session(session.to_string()))
739 }
740 }
741}
742
743#[async_trait]
778pub trait Algorithm: Send + Sync + 'static {
779 fn name(&self) -> &str;
783
784 async fn route(self: Arc<Self>, driver: Driver, request: Request) -> Result<RoutingOutcome>;
787
788 fn tracks_answers(&self) -> bool {
790 false
791 }
792
793 async fn observe_answer(&self, _session_id: &str, _answer: AnswerSummary) {}
798
799 fn run_stream(
807 self: Arc<Self>,
808 request: Request,
809 models: Arc<RuntimeModels>,
810 state: State,
811 ) -> StepStream {
812 let (driver, step_rx) = Driver::new(self.name(), models);
813 driver.set_state(state);
814 let span = observability::run_span(self.name(), &request);
815 let handle = tokio::spawn(
816 async move {
817 let algorithm = self.name().to_string();
818 let route = AssertUnwindSafe(self.route(driver.clone(), request)).catch_unwind();
820 let result = observability::observe_run(&algorithm, async move {
821 route.await.unwrap_or_else(|payload| {
822 Err(LibsyError::AlgorithmError {
823 message: format!(
824 "algorithm task panicked: {}",
825 panic_message(payload.as_ref())
826 ),
827 })
828 })
829 })
830 .await;
831
832 let _ = driver.finish(result).await;
833 }
834 .instrument(span),
835 );
836 let abort_guard = AbortOnDrop(handle.abort_handle());
838 Box::pin(ReceiverStream::new(step_rx).map(move |step| {
839 let _keep_alive = &abort_guard;
841 step
842 }))
843 }
844}
845
846#[cfg(test)]
847mod tests {
848 use std::collections::HashMap;
849
850 use super::*;
851 use crate::core::testing::{Serve, ServeResult, echo, reply, serve_decision, test_drive};
852 use futures::StreamExt;
853 use switchyard_protocol::{
854 LlmResponse, LlmResponseChunk, completion_text, text_request, text_response,
855 };
856
857 #[test]
858 fn a_final_answer_has_text_and_no_unfinished_or_tool_output() {
859 let mut answer = text_response(None, "done");
860 assert!(AnswerSummary::of(&answer).final_reply);
861 answer.outputs[0].stop_reason = Some(StopReason::MaxTokens);
862 assert!(!AnswerSummary::of(&answer).final_reply);
863 answer.outputs[0].stop_reason = Some(StopReason::EndTurn);
864 let mut tool = answer.outputs[0].clone();
865 tool.content = vec![ContentBlock::ToolCall(switchyard_protocol::ToolCall {
866 id: "call".into(),
867 name: "bash".into(),
868 arguments: serde_json::json!({}),
869 })];
870 answer.outputs.push(tool);
871 assert!(!AnswerSummary::of(&answer).final_reply);
872 answer.outputs.pop();
873 answer.outputs[0].content = vec![ContentBlock::Text { text: " ".into() }];
874 assert!(!AnswerSummary::of(&answer).final_reply);
875 assert!(!AnswerSummary::of(&AggLlmResponse::default()).final_reply);
876 }
877
878 #[derive(Debug, thiserror::Error)]
879 #[error("{0}")]
880 struct TestError(&'static str);
881
882 fn test_error(message: &'static str) -> LibsyError {
883 LibsyError::external("test", TestError(message))
884 }
885
886 struct TestAlgo {
889 target_set: Vec<ModelId>,
890 }
891
892 #[async_trait]
893 impl Algorithm for TestAlgo {
894 fn name(&self) -> &str {
895 "test"
896 }
897
898 async fn route(
899 self: Arc<Self>,
900 driver: Driver,
901 request: Request,
902 ) -> Result<RoutingOutcome> {
903 let target = self
904 .target_set
905 .first()
906 .ok_or(LibsyError::NoTargets)?
907 .clone();
908 let response = driver
909 .call_model(request.clone(), vec![target.clone()])
910 .await?;
911 driver.set_evidence(serde_json::json!({"source": "test"}));
912 driver.set_evidence_if_empty(serde_json::json!({"source": "ignored"}));
913 Ok(RoutingOutcome::answered(target, request, response))
914 }
915 }
916
917 fn orch(target_set: Vec<ModelId>) -> Arc<dyn Algorithm> {
919 Arc::new(TestAlgo { target_set })
920 }
921
922 fn request() -> Request {
923 Request {
924 llm_request: text_request(Some("auto".to_string()), "hi".to_string()),
925 raw_request: None,
926 metadata: None,
927 }
928 }
929
930 #[test]
931 fn routing_outcome_constructors_stamp_selection_and_preserve_payloads() {
932 let outcome = RoutingOutcome::route_to(
933 "selected".into(),
934 target_set(&["fallback-one", "fallback-two"]),
935 request(),
936 );
937
938 assert_eq!(
939 outcome.selected_model_ids,
940 target_set(&["selected", "fallback-one", "fallback-two"])
941 );
942 assert_eq!(outcome.request.model_id().as_deref(), Some("selected"));
943 assert!(outcome.response.is_none());
944 assert!(outcome.metadata.is_none());
945
946 let outcome = RoutingOutcome::route_to("only".into(), Vec::new(), request());
947 assert_eq!(outcome.selected_model_ids, target_set(&["only"]));
948
949 let outcome = RoutingOutcome::answered(
950 "answered".into(),
951 request(),
952 Response {
953 llm_response: LlmResponse::Agg(text_response(None, "existing")),
954 metadata: None,
955 upstream_headers: http::HeaderMap::new(),
956 },
957 );
958
959 assert_eq!(outcome.selected_model_ids, target_set(&["answered"]));
960 assert_eq!(outcome.request.model_id().as_deref(), Some("answered"));
961 assert_eq!(
962 outcome
963 .response
964 .as_ref()
965 .and_then(|response| response.llm_response.as_agg())
966 .map(completion_text),
967 Some("existing".to_string())
968 );
969 }
970
971 fn target_set(names: &[&str]) -> Vec<ModelId> {
972 names.iter().map(|name| ModelId::from(*name)).collect()
973 }
974
975 #[tokio::test]
976 async fn typed_driver_preserves_call_and_stream_boundaries() -> Result<()> {
977 tokio::time::timeout(std::time::Duration::from_secs(1), async {
978 let (driver, mut step_rx) = Driver::new("test", Arc::new(RuntimeModels::default()));
981 let first_driver = driver.clone();
982 let mut first = tokio::spawn(async move {
983 first_driver
984 .call_model(request(), vec![ModelId::from("first")])
985 .await
986 });
987 let second = tokio::spawn(async move {
988 driver
989 .call_model(request(), vec![ModelId::from("second")])
990 .await
991 });
992
993 let mut calls = HashMap::new();
994 for _ in 0..2 {
995 let step = step_rx.recv().await.ok_or(DriverError::StreamClosed)??;
996 let Step::CallModel(call) = step else {
997 return Err(test_error("expected a CallModel step"));
998 };
999 let selected_model = call
1000 .models
1001 .first()
1002 .ok_or_else(|| test_error("model call has no candidates"))?
1003 .to_string();
1004 calls.insert(selected_model, call);
1005 }
1006 assert!(
1007 tokio::time::timeout(std::time::Duration::from_millis(20), &mut first)
1008 .await
1009 .is_err(),
1010 "call completed before the host responded"
1011 );
1012 calls
1013 .remove("second")
1014 .ok_or_else(|| test_error("missing second call"))?
1015 .respond(std::future::ready(Ok(reply("second response")))).await?;
1016 calls
1017 .remove("first")
1018 .ok_or_else(|| test_error("missing first call"))?
1019 .respond(std::future::ready(Ok(reply("first response")))).await?;
1020
1021 let first_response = first
1022 .await
1023 .map_err(|source| LibsyError::external("joining a test task", source))??;
1024 let second_response = second
1025 .await
1026 .map_err(|source| LibsyError::external("joining a test task", source))??;
1027 assert_eq!(
1028 first_response.llm_response.as_agg().map(completion_text),
1029 Some("first response".to_string())
1030 );
1031 assert_eq!(
1032 second_response.llm_response.as_agg().map(completion_text),
1033 Some("second response".to_string())
1034 );
1035
1036 let (driver, mut step_rx) = Driver::new("test", Arc::new(RuntimeModels::default()));
1038 let producer = tokio::spawn(async move {
1039 driver
1040 .call_model(request(), vec![ModelId::from("dropped")])
1041 .await
1042 });
1043 let step = step_rx.recv().await.ok_or(DriverError::StreamClosed)??;
1044 let Step::CallModel(call) = step else {
1045 return Err(test_error("expected a CallModel step"));
1046 };
1047 drop(call);
1048 let result = producer
1049 .await
1050 .map_err(|source| LibsyError::external("joining a test task", source))?;
1051 assert!(matches!(
1052 result,
1053 Err(LibsyError::Driver(DriverError::ResponseDropped))
1054 ));
1055
1056 let (driver, step_rx) = Driver::new("test", Arc::new(RuntimeModels::default()));
1058 drop(step_rx);
1059 let result = driver
1060 .call_model(request(), vec![ModelId::from("closed")])
1061 .await;
1062 assert!(matches!(
1063 result,
1064 Err(LibsyError::Driver(DriverError::StreamClosed))
1065 ));
1066
1067 fn decision_request() -> DecisionRequest {
1068 DecisionRequest {
1069 model: Some("overwritten".into()),
1070 context: serde_json::json!({"task": "choose a route"}),
1071 questions: Default::default(),
1072 }
1073 }
1074
1075 fn decision_response() -> DecisionResponse {
1076 DecisionResponse {
1077 id: Some("decision-1".to_string()),
1078 model: Some("provider-model".into()),
1079 answers: [(
1080 "p_solve".to_string(),
1081 switchyard_protocol::DecisionAnswer {
1082 value: switchyard_protocol::DecisionValue::Boolean(
1083 switchyard_protocol::BooleanEstimate::ProbabilityTrue(
1084 switchyard_protocol::Probability(0.8),
1085 ),
1086 ),
1087 provider_confidence: None,
1088 },
1089 )]
1090 .into(),
1091 usage: Default::default(),
1092 }
1093 }
1094
1095 struct MixedCalls(&'static str);
1096
1097 #[async_trait]
1098 impl Algorithm for MixedCalls {
1099 fn name(&self) -> &str {
1100 "mixed"
1101 }
1102
1103 async fn route(
1104 self: Arc<Self>,
1105 driver: Driver,
1106 request: Request,
1107 ) -> Result<RoutingOutcome> {
1108 let (llm, decision) = tokio::join!(
1109 driver.call_model(request.clone(), vec!["llm".into()]),
1110 driver.call_decision(decision_request(), "decision".into()),
1111 );
1112 assert_eq!(
1113 llm?.llm_response.as_agg().map(completion_text),
1114 Some("llm reply".into())
1115 );
1116 match self.0 {
1117 "mock" => assert_eq!(decision?, DecisionResponse {
1118 id: None,
1119 model: Some("decision".into()),
1120 answers: Default::default(),
1121 usage: Default::default(),
1122 }),
1123 "reply" => assert_eq!(decision?, decision_response()),
1124 "error" => assert!(matches!(
1125 decision,
1126 Err(LibsyError::AlgorithmError { message }) if message == "provider failed"
1127 )),
1128 "drop" => assert!(matches!(
1129 decision,
1130 Err(LibsyError::Driver(DriverError::ResponseDropped))
1131 )),
1132 "abort" => return std::future::pending().await,
1133 _ => unreachable!(),
1134 }
1135 Ok(RoutingOutcome::route_to("answer".into(), vec![], request))
1136 }
1137 }
1138
1139 for mode in ["mock", "reply", "error", "drop", "abort"] {
1140 let barrier = Arc::new(tokio::sync::Barrier::new(2));
1142 let outcome = drive(
1143 Arc::new(MixedCalls(mode)),
1144 request(),
1145 Arc::new(RuntimeModels::default()),
1146 move |call| {
1147 let barrier = barrier.clone();
1148 async move {
1149 barrier.wait().await;
1150 let call = match call {
1151 Call::Model(call) => return call.respond(std::future::ready(Ok(reply("llm reply")))).await,
1152 Call::Decision(call) => *call,
1153 };
1154 assert_eq!(call.algorithm, "mixed");
1155 assert_eq!(call.model, "decision");
1156 assert_eq!(call.request.model, Some("decision".into()));
1157 assert_eq!(call.request.context, decision_request().context);
1158 match mode {
1159 "mock" => serve_decision(call).await,
1160 "reply" => call.respond(std::future::ready(Ok(decision_response()))).await,
1161 "error" => call.respond(std::future::ready(Err(LibsyError::AlgorithmError {
1162 message: "provider failed".into(),
1163 }))).await,
1164 "drop" => {
1165 drop(call);
1166 Ok(())
1167 }
1168 "abort" => call.fail(test_error("host aborted")),
1169 _ => unreachable!(),
1170 }
1171 }
1172 },
1173 )
1174 .await;
1175 if mode == "abort" {
1176 assert!(matches!(outcome, Err(LibsyError::External { .. })));
1177 } else {
1178 assert_eq!(outcome?.selected_model_id()?, "answer");
1179 }
1180 }
1181
1182 let (driver, mut steps) = Driver::new("test", Arc::new(RuntimeModels::default()));
1183 let mut pending = Box::pin(driver.call_decision(decision_request(), "decision".into()));
1184 assert!(futures::poll!(&mut pending).is_pending());
1185 let Some(Ok(Step::CallDecision(call))) = steps.recv().await else {
1186 return Err(test_error("expected a decision call"));
1187 };
1188 drop(pending);
1189 call.respond(async { panic!("cancelled work must not be polled") }).await?;
1190 drop(steps);
1191 assert!(matches!(
1192 driver.call_decision(decision_request(), "decision".into()).await,
1193 Err(LibsyError::Driver(DriverError::StreamClosed))
1194 ));
1195 Ok(())
1196 })
1197 .await
1198 .map_err(|error| LibsyError::external("waiting for typed driver boundaries", error))?
1199 }
1200
1201 #[test]
1202 fn target_lookup_returns_the_missing_target() {
1203 let error = ensure_model_is_target(&target_set(&[]), &ModelId::from("missing")).err();
1204 assert!(matches!(
1205 error,
1206 Some(LibsyError::TargetNotFound { target }) if target == "missing"
1207 ));
1208 }
1209
1210 fn streaming_orch(chunks: Vec<LlmResponseChunk>) -> (Arc<dyn Algorithm>, impl Serve) {
1213 let algo = orch(target_set(&["stream/model"]));
1214 let serve = move |_target: ModelId, _request: Request| {
1215 let chunks = chunks.clone();
1216 async move {
1217 let stream =
1218 futures::stream::iter(chunks.into_iter().map(|chunk| Ok(chunk.into()))).boxed();
1219 Ok(Response {
1220 llm_response: LlmResponse::Stream(stream),
1221 metadata: None,
1222 upstream_headers: http::HeaderMap::new(),
1223 })
1224 }
1225 };
1226 (algo, serve)
1227 }
1228
1229 #[tokio::test]
1230 async fn run_returns_a_streamed_response_the_caller_aggregates() -> Result<()> {
1231 let (orch, serve) = streaming_orch(vec![
1234 LlmResponseChunk::MessageStart {
1235 id: Some("m1".to_string()),
1236 model: Some("stream/model".to_string()),
1237 },
1238 LlmResponseChunk::TextDelta {
1239 index: 0,
1240 text: "hel".to_string(),
1241 },
1242 LlmResponseChunk::TextDelta {
1243 index: 0,
1244 text: "lo".to_string(),
1245 },
1246 LlmResponseChunk::MessageStop {
1247 reason: Some("stop".to_string()),
1248 },
1249 ]);
1250 let (selected_model, response) = test_drive(orch, request(), serve).await?;
1251 let agg = response
1253 .llm_response
1254 .into_agg()
1255 .await
1256 .map_err(|error| LibsyError::external("aggregating response stream", error))?;
1257 assert_eq!(completion_text(&agg), "hello");
1258 assert_eq!(agg.model.as_deref(), Some("stream/model"));
1259 assert_eq!(selected_model, "stream/model");
1260 Ok(())
1261 }
1262
1263 #[tokio::test]
1264 async fn aggregating_a_streamed_response_propagates_a_mid_stream_error() -> Result<()> {
1265 let (orch, serve) = streaming_orch(vec![
1268 LlmResponseChunk::TextDelta {
1269 index: 0,
1270 text: "partial".to_string(),
1271 },
1272 LlmResponseChunk::StreamError {
1273 message: "upstream exploded".to_string(),
1274 },
1275 ]);
1276 let (_, response) = test_drive(orch, request(), serve).await?;
1277 match response.llm_response.into_agg().await {
1278 Ok(_) => panic!("expected a mid-stream error, got an aggregate"),
1279 Err(err) => {
1280 assert!(err.to_string().contains("upstream exploded"));
1281 Ok(())
1282 }
1283 }
1284 }
1285
1286 #[tokio::test]
1287 async fn run_offloads_via_promise_then_finishes() -> Result<()> {
1288 let stream = orch(target_set(&["offload/model"])).run_stream(
1291 request(),
1292 Arc::new(RuntimeModels::default()),
1293 State::default(),
1294 );
1295 tokio::pin!(stream);
1296
1297 let mut saw_call = false;
1298 let mut final_completion = None;
1299 while let Some(step) = stream.next().await {
1300 match step? {
1301 Step::CallDecision(_) => return Err(test_error("unexpected decision call")),
1302 Step::CallModel(call) => {
1303 saw_call = true;
1304 assert_eq!(call.models, vec![ModelId::from("offload/model")]);
1305 call.respond(std::future::ready(Ok(Response {
1307 llm_response: LlmResponse::Agg(text_response(
1308 None,
1309 "fulfilled".to_string(),
1310 )),
1311 metadata: None,
1312 upstream_headers: http::HeaderMap::new(),
1313 })))
1314 .await?;
1315 }
1316 Step::Done(outcome) => {
1317 let metadata = outcome
1318 .metadata
1319 .as_ref()
1320 .expect("run_stream should attach outcome metadata");
1321 assert_eq!(metadata.algorithm, "test");
1322 assert_eq!(
1323 uuid::Uuid::parse_str(metadata.outcome_id())
1324 .expect("outcome id should be a UUID")
1325 .get_version_num(),
1326 7
1327 );
1328 assert_eq!(
1329 metadata.evidence,
1330 Some(serde_json::json!({"source": "test"}))
1331 );
1332 let response = outcome
1333 .response
1334 .ok_or_else(|| test_error("expected an answered outcome"))?;
1335 final_completion = Some(
1336 response
1337 .llm_response
1338 .as_agg()
1339 .map(completion_text)
1340 .unwrap_or_default(),
1341 );
1342 }
1343 }
1344 }
1345
1346 assert!(saw_call, "expected a CallModel step before Done");
1347 assert_eq!(
1348 final_completion.ok_or_else(|| test_error("no Done step"))?,
1349 "fulfilled"
1350 );
1351 Ok(())
1352 }
1353
1354 #[tokio::test(flavor = "multi_thread", worker_threads = 12)]
1355 async fn requests_are_processed_in_parallel() -> Result<()> {
1356 use std::time::Duration;
1357 use tokio::sync::Barrier;
1358
1359 const N: usize = 12;
1360
1361 let barrier = Arc::new(Barrier::new(N));
1366 let algo = orch(target_set(&["m"]));
1368
1369 let mut handles = Vec::new();
1370 for _ in 0..N {
1371 let algo = algo.clone();
1372 let barrier = barrier.clone();
1373 let serve = move |target: ModelId, _request: Request| {
1374 let barrier = barrier.clone();
1375 async move {
1376 barrier.wait().await;
1377 Ok(reply(target))
1378 }
1379 };
1380 handles.push(tokio::spawn(async move {
1381 test_drive(algo, request(), serve)
1382 .await
1383 .map(|(_, response)| {
1384 response
1385 .llm_response
1386 .as_agg()
1387 .map(completion_text)
1388 .unwrap_or_default()
1389 })
1390 }));
1391 }
1392
1393 for handle in handles {
1394 let completion = tokio::time::timeout(Duration::from_secs(5), handle)
1396 .await
1397 .map_err(|error| LibsyError::external("waiting for test task", error))?
1398 .map_err(|source| LibsyError::external("joining a test task", source))??;
1399 assert_eq!(completion, "m");
1400 }
1401 Ok(())
1402 }
1403
1404 #[tokio::test]
1405 async fn offload_error_propagates_back_to_the_algorithm() -> Result<()> {
1406 let stream = orch(target_set(&["offload/model"])).run_stream(
1410 request(),
1411 Arc::new(RuntimeModels::default()),
1412 State::default(),
1413 );
1414 tokio::pin!(stream);
1415
1416 let mut saw_error = false;
1417 while let Some(step) = stream.next().await {
1418 match step {
1419 Ok(Step::CallDecision(_)) => return Err(test_error("unexpected decision call")),
1420 Ok(Step::CallModel(mut call)) => {
1421 call.recover_errors = true;
1422 call.respond(std::future::ready(Err(test_error(
1423 "upstream model call failed",
1424 ))))
1425 .await?;
1426 }
1427 Ok(Step::Done(..)) => {
1428 return Err(test_error(
1429 "expected the offload error to propagate, got a response",
1430 ));
1431 }
1432 Err(err) => {
1433 assert!(err.to_string().contains("upstream model call failed"));
1435 saw_error = true;
1436 }
1437 }
1438 }
1439
1440 assert!(saw_error, "expected an error step");
1441 Ok(())
1442 }
1443
1444 #[tokio::test]
1445 async fn dropping_the_stream_cancels_the_algorithm_task() -> Result<()> {
1446 use std::sync::atomic::{AtomicBool, Ordering};
1447 use std::time::Duration;
1448 use tokio::sync::mpsc;
1449
1450 struct DropGuard(Arc<AtomicBool>);
1453 impl Drop for DropGuard {
1454 fn drop(&mut self) {
1455 self.0.store(true, Ordering::SeqCst);
1456 }
1457 }
1458
1459 struct StuckAlgo {
1460 started: mpsc::UnboundedSender<()>,
1461 dropped: Arc<AtomicBool>,
1462 }
1463
1464 #[async_trait]
1465 impl Algorithm for StuckAlgo {
1466 fn name(&self) -> &str {
1467 "stuck"
1468 }
1469
1470 async fn route(
1471 self: Arc<Self>,
1472 _driver: Driver,
1473 _request: Request,
1474 ) -> Result<RoutingOutcome> {
1475 let _guard = DropGuard(self.dropped.clone());
1476 let _ = self.started.send(());
1477 std::future::pending::<()>().await;
1479 unreachable!()
1480 }
1481 }
1482
1483 let (started_tx, mut started_rx) = mpsc::unbounded_channel();
1484 let dropped = Arc::new(AtomicBool::new(false));
1485 let algo: Arc<dyn Algorithm> = Arc::new(StuckAlgo {
1486 started: started_tx,
1487 dropped: dropped.clone(),
1488 });
1489
1490 let stream = algo.run_stream(
1491 request(),
1492 Arc::new(RuntimeModels::default()),
1493 State::default(),
1494 );
1495 started_rx
1496 .recv()
1497 .await
1498 .ok_or_else(|| test_error("task never started"))?;
1499 drop(stream);
1500 tokio::time::sleep(Duration::from_millis(100)).await;
1501
1502 assert!(
1503 dropped.load(Ordering::SeqCst),
1504 "algorithm task was NOT cancelled after dropping the stream"
1505 );
1506 Ok(())
1507 }
1508
1509 #[tokio::test]
1510 async fn route_panic_surfaces_as_a_stream_error() -> Result<()> {
1511 struct Panicky;
1514
1515 #[async_trait]
1516 impl Algorithm for Panicky {
1517 fn name(&self) -> &str {
1518 "panicky"
1519 }
1520
1521 async fn route(
1522 self: Arc<Self>,
1523 _driver: Driver,
1524 _request: Request,
1525 ) -> Result<RoutingOutcome> {
1526 panic!("boom");
1527 }
1528 }
1529
1530 let algo: Arc<dyn Algorithm> = Arc::new(Panicky);
1531 let stream = algo.run_stream(
1532 request(),
1533 Arc::new(RuntimeModels::default()),
1534 State::default(),
1535 );
1536 tokio::pin!(stream);
1537
1538 let mut saw_error = false;
1539 while let Some(step) = stream.next().await {
1540 match step {
1541 Err(err) => {
1542 assert!(err.to_string().contains("algorithm task panicked: boom"));
1544 saw_error = true;
1545 }
1546 Ok(_) => return Err(test_error("expected the panic to surface as an error step")),
1547 }
1548 }
1549
1550 assert!(saw_error, "expected an error step from the panicked task");
1551 Ok(())
1552 }
1553
1554 #[tokio::test]
1558 async fn a_panic_with_a_leaked_driver_clone_still_terminates_the_run() -> Result<()> {
1559 struct LeakyPanic;
1560
1561 #[async_trait]
1562 impl Algorithm for LeakyPanic {
1563 fn name(&self) -> &str {
1564 "leaky_panic"
1565 }
1566
1567 async fn route(
1568 self: Arc<Self>,
1569 driver: Driver,
1570 _request: Request,
1571 ) -> Result<RoutingOutcome> {
1572 tokio::spawn(async move {
1573 let _keep_alive = driver;
1575 std::future::pending::<()>().await;
1576 });
1577 tokio::task::yield_now().await;
1578 panic!("boom");
1579 }
1580 }
1581
1582 let algo: Arc<dyn Algorithm> = Arc::new(LeakyPanic);
1583 let result = tokio::time::timeout(
1585 std::time::Duration::from_secs(1),
1586 test_drive(algo, request(), echo()),
1587 )
1588 .await
1589 .map_err(|error| LibsyError::external("waiting for the panicked run to end", error))?;
1590
1591 match result {
1592 Ok(_) => Err(test_error(
1593 "expected the panic to end the run with an error",
1594 )),
1595 Err(err) => {
1596 assert!(err.to_string().contains("algorithm task panicked: boom"));
1597 Ok(())
1598 }
1599 }
1600 }
1601
1602 #[tokio::test]
1603 async fn cancelling_run_cancels_the_algorithm_task() -> Result<()> {
1604 use std::sync::atomic::{AtomicBool, Ordering};
1605 use std::time::Duration;
1606 use tokio::sync::mpsc;
1607
1608 struct DropGuard(Arc<AtomicBool>);
1611 impl Drop for DropGuard {
1612 fn drop(&mut self) {
1613 self.0.store(true, Ordering::SeqCst);
1614 }
1615 }
1616
1617 struct StuckAlgo {
1618 started: mpsc::UnboundedSender<()>,
1619 dropped: Arc<AtomicBool>,
1620 }
1621
1622 #[async_trait]
1623 impl Algorithm for StuckAlgo {
1624 fn name(&self) -> &str {
1625 "stuck"
1626 }
1627
1628 async fn route(
1629 self: Arc<Self>,
1630 _driver: Driver,
1631 _request: Request,
1632 ) -> Result<RoutingOutcome> {
1633 let _guard = DropGuard(self.dropped.clone());
1634 let _ = self.started.send(());
1635 std::future::pending::<()>().await;
1638 unreachable!()
1639 }
1640 }
1641
1642 let (started_tx, mut started_rx) = mpsc::unbounded_channel();
1643 let dropped = Arc::new(AtomicBool::new(false));
1644 let algo: Arc<dyn Algorithm> = Arc::new(StuckAlgo {
1645 started: started_tx,
1646 dropped: dropped.clone(),
1647 });
1648
1649 let run_task = tokio::spawn(async move { test_drive(algo, request(), echo()).await });
1652 started_rx
1653 .recv()
1654 .await
1655 .ok_or_else(|| test_error("task never started"))?;
1656 run_task.abort();
1657 tokio::time::sleep(Duration::from_millis(100)).await;
1658
1659 assert!(
1660 dropped.load(Ordering::SeqCst),
1661 "algorithm task was NOT cancelled after cancelling run"
1662 );
1663 Ok(())
1664 }
1665
1666 struct Hedge {
1671 winner: String,
1672 loser: String,
1673 }
1674
1675 #[async_trait]
1676 impl Algorithm for Hedge {
1677 fn name(&self) -> &str {
1678 "hedge"
1679 }
1680
1681 async fn route(
1682 self: Arc<Self>,
1683 driver: Driver,
1684 request: Request,
1685 ) -> Result<RoutingOutcome> {
1686 let outcome_request = request.clone();
1687 let win = driver.call_model(request.clone(), vec![self.winner.clone().into()]);
1688 let lose = driver.call_model(request, vec![self.loser.clone().into()]);
1689 tokio::select! {
1691 res = win => Ok(RoutingOutcome::answered(
1692 self.winner.clone().into(),
1693 outcome_request,
1694 res?,
1695 )),
1696 res = lose => Ok(RoutingOutcome::answered(
1697 self.loser.clone().into(),
1698 outcome_request,
1699 res?,
1700 )),
1701 }
1702 }
1703 }
1704
1705 fn hedge(loser_delay: Option<std::time::Duration>) -> (Arc<dyn Algorithm>, impl Serve) {
1709 let started = Arc::new(tokio::sync::Notify::new());
1710 let algo = Arc::new(Hedge {
1711 winner: "winner".to_string(),
1712 loser: "loser".to_string(),
1713 });
1714 let serve = move |target: ModelId, _request: Request| {
1715 let started = started.clone();
1716 async move {
1717 if target == "loser" {
1718 started.notify_one();
1719 match loser_delay {
1720 Some(delay) => tokio::time::sleep(delay).await,
1721 None => std::future::pending::<()>().await,
1722 }
1723 } else {
1724 started.notified().await;
1725 }
1726 Ok(reply(target))
1727 }
1728 };
1729 (algo, serve)
1730 }
1731
1732 #[tokio::test]
1733 async fn run_returns_the_winner_without_a_late_loser_overwriting_it() -> Result<()> {
1734 let (algo, serve) = hedge(Some(std::time::Duration::from_millis(50)));
1737 let (_, response) = test_drive(algo, request(), serve).await?;
1738 assert_eq!(
1739 response
1740 .llm_response
1741 .as_agg()
1742 .map(completion_text)
1743 .unwrap_or_default(),
1744 "winner"
1745 );
1746 Ok(())
1747 }
1748
1749 #[tokio::test]
1750 async fn run_returns_the_winner_without_hanging_on_a_pending_loser() -> Result<()> {
1751 let (algo, serve) = hedge(None);
1754 let run = test_drive(algo, request(), serve);
1755 let (_, response) = tokio::time::timeout(std::time::Duration::from_secs(1), run)
1756 .await
1757 .map_err(|error| LibsyError::external("waiting for pending loser", error))??;
1758 assert_eq!(
1759 response
1760 .llm_response
1761 .as_agg()
1762 .map(completion_text)
1763 .unwrap_or_default(),
1764 "winner"
1765 );
1766 Ok(())
1767 }
1768
1769 #[tokio::test]
1770 async fn run_surfaces_a_terminal_error_with_many_calls_in_flight() -> Result<()> {
1771 use std::sync::atomic::{AtomicUsize, Ordering};
1772
1773 const N: usize = 10;
1776
1777 struct FanOutThenError {
1780 all_started: Arc<tokio::sync::Notify>,
1781 n: usize,
1782 }
1783
1784 #[async_trait]
1785 impl Algorithm for FanOutThenError {
1786 fn name(&self) -> &str {
1787 "fan_out_then_error"
1788 }
1789
1790 async fn route(
1791 self: Arc<Self>,
1792 driver: Driver,
1793 request: Request,
1794 ) -> Result<RoutingOutcome> {
1795 let offloads = futures::future::join_all(
1796 (0..self.n)
1797 .map(|i| driver.call_model(request.clone(), vec![format!("m{i}").into()])),
1798 );
1799 tokio::select! {
1800 _ = offloads => Err(test_error("offloads unexpectedly completed")),
1801 _ = self.all_started.notified() => {
1802 Err(test_error("terminal error while calls pending"))
1803 }
1804 }
1805 }
1806 }
1807
1808 let all_started = Arc::new(tokio::sync::Notify::new());
1809 let algo: Arc<dyn Algorithm> = Arc::new(FanOutThenError {
1810 all_started: all_started.clone(),
1811 n: N,
1812 });
1813
1814 let started = Arc::new(AtomicUsize::new(0));
1816 let serve = move |_target: ModelId, _request: Request| {
1817 let started = started.clone();
1818 let all_started = all_started.clone();
1819 async move {
1820 if started.fetch_add(1, Ordering::SeqCst) + 1 == N {
1821 all_started.notify_one();
1822 }
1823 std::future::pending::<ServeResult>().await
1824 }
1825 };
1826
1827 let run = test_drive(algo, request(), serve);
1830 let result = tokio::time::timeout(std::time::Duration::from_millis(500), run)
1831 .await
1832 .map_err(|error| {
1833 LibsyError::external("waiting for terminal error with full call cap", error)
1834 })?;
1835 match result {
1836 Ok(_) => Err(test_error("expected the terminal error, got a response")),
1837 Err(err) => {
1838 assert!(
1839 err.to_string()
1840 .contains("terminal error while calls pending")
1841 );
1842 Ok(())
1843 }
1844 }
1845 }
1846}