Skip to main content

switchyard_libsy/core/
algorithm.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The [`Algorithm`] trait and its [`Driver`] — the orchestration contract every
5//! algorithm implements and the offload channel it uses for routing-time model calls.
6
7use 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
20/// The request/response protocol types come from [`switchyard_protocol`].
21/// [`switchyard_protocol::LlmRequest`] is the normalized request;
22/// [`switchyard_protocol::AggLlmResponse`] is the buffered response;
23/// [`switchyard_protocol::LlmResponseChunk`] is normalized streaming content;
24/// [`switchyard_protocol::LlmResponseStreamEvent`] is its host/algorithm envelope; and
25/// [`switchyard_protocol::LlmResponse`] carries either a live
26/// [`switchyard_protocol::LlmResponseStream`] or the terminal aggregate.
27use switchyard_protocol::{Category, ModelId, Request, Response};
28
29use crate::{DriverError, LibsyError, Result, observability};
30
31/// A boxed, `Send` stream of [`Step`]s — the output of
32/// [`Algorithm::run_stream`]. Boxed so the trait method that produces it keeps
33/// `Arc<dyn Algorithm>` object-safe.
34pub type StepStream = Pin<Box<dyn Stream<Item = Result<Step>> + Send>>;
35
36/// The models one algorithm run may use, grouped by [`Category`]. Within a
37/// category they are ordered best-first.
38///
39/// Delegated sub-agent work gets its own groups, reachable only through
40/// [`Driver::for_subagent`]. Keeping them separate is what stops a sub-agent's
41/// `capable` from resolving to the parent's, and stops the parent falling back
42/// onto a model only its sub-agents were given.
43///
44/// One run's driver clones all read the same value, so it is passed as
45/// `Arc<RuntimeModels>` rather than cloned per driver.
46#[derive(Clone, Debug, Default)]
47pub struct RuntimeModels {
48    /// When using subagents this is the parent agent category.
49    by_category: HashMap<Category, Vec<ModelId>>,
50    subagent: Option<HashMap<Category, Vec<ModelId>>>,
51}
52
53impl RuntimeModels {
54    /// The models available to the algorithm itself.
55    pub fn new(by_category: HashMap<Category, Vec<ModelId>>) -> Self {
56        Self {
57            by_category,
58            subagent: None,
59        }
60    }
61
62    /// Adds the groups used for delegated sub-agent work.
63    pub fn with_subagent(mut self, models: HashMap<Category, Vec<ModelId>>) -> Self {
64        self.subagent = Some(models);
65        self
66    }
67
68    /// The models in `category`, ordered best-first.
69    pub fn models_for(&self, category: &Category) -> &[ModelId] {
70        self.by_category.get(category).map_or(&[], Vec::as_slice)
71    }
72
73    /// The models delegated sub-agent work uses for `category`, ordered best-first.
74    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/// Which of a [`RuntimeModels`]' groups a driver reads.
89#[derive(Clone, Copy)]
90enum Scope {
91    Parent,
92    Subagent,
93}
94
95/// An offloaded model call, surfaced inside [`Step::CallModel`].
96///
97/// The host reads the public fields, performs (or delegates) the model call, and fulfills it
98/// with [`respond`](Self::respond) — unblocking the algorithm's [`Driver::call_model`] on the
99/// other side. `switchyard-llm-client`'s `run` is the ready-made consumer that does this for
100/// you.
101///
102/// [`Driver::call_model`] stamps the first candidate model onto the request before publishing
103/// the call. A consumer that falls through to a later candidate must re-stamp it.
104pub struct CallModel {
105    /// The name of the algorithm that produced this call, so a host instrumenting the
106    /// calls it serves can attribute its own spans to the algorithm behind them.
107    pub algorithm: String,
108    /// The request to serve; its `model` is stamped with the first candidate.
109    pub request: Request,
110    /// Candidate models, tried in order until one answers. Never empty.
111    pub models: Vec<ModelId>,
112    // How to send the response back to the algorithm
113    reply: oneshot::Sender<Result<Response>>,
114}
115
116impl CallModel {
117    /// Fulfill the promise with the caller's model-call result. Pass `Err(..)` to
118    /// propagate a failed model call back to the algorithm. Consumes the promise: it
119    /// can only be fulfilled once.
120    pub fn respond(self, result: Result<Response>) -> Result<()> {
121        self.reply
122            .send(result)
123            .map_err(|_| DriverError::ResponseDropped.into())
124    }
125}
126
127/// The terminal result of routing.
128pub struct RoutingOutcome {
129    /// Models selected by the algorithm, ordered best model first.
130    pub selected_model_ids: Vec<ModelId>,
131    /// The request after all routing-time rewrites, stamped with the selected model.
132    pub request: Request,
133    /// A response produced while routing, or `None` when the client must make the answer call.
134    pub response: Option<Response>,
135    /// Outcome identity and optional algorithm evidence.
136    ///
137    /// Constructors leave this empty; [`Algorithm::run_stream`] fills it before publishing a
138    /// successful outcome.
139    pub metadata: Option<crate::OutcomeMetadata>,
140}
141
142impl RoutingOutcome {
143    /// The model the algorithm recommends, the best model for this request.
144    /// `LibsyError::NoTargets` if the algorithm selected no models, which should be impossible.
145    pub fn selected_model_id(&self) -> Result<&ModelId> {
146        self.selected_model_ids.first().ok_or(LibsyError::NoTargets)
147    }
148
149    /// The decision is that client should send this `request`. The `selected_model_id`
150    /// will be written into it by this function.
151    /// If that fails client should try the `fallback_models` in order.
152    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    /// Algorithm generated the response as part of the routing decision. Here it is.
170    /// The `request` will have the `selected_model_id` written into it by this function.
171    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/// How an algorithm's [`route`](Algorithm::route) makes model calls.
183#[derive(Clone)]
184pub struct Driver {
185    step_tx: mpsc::Sender<Result<Step>>,
186
187    /// The owning algorithm's telemetry label, stamped onto every call this driver publishes.
188    algorithm: String,
189
190    /// Run-scoped evidence shared by driver clones and attached only to a successful outcome.
191    evidence: Arc<Mutex<Option<Value>>>,
192
193    /// Every group this run may route over, shared by all driver clones.
194    models: Arc<RuntimeModels>,
195
196    /// Which of those groups this driver reads.
197    scope: Scope,
198}
199
200impl Driver {
201    /// Build an empty driver with its step channel ready. Created per call by
202    /// [`run_stream`](Algorithm::run_stream). Also returns the Step receiver.
203    pub(crate) fn new(
204        algorithm: &str,
205        models: Arc<RuntimeModels>,
206    ) -> (Self, mpsc::Receiver<Result<Step>>) {
207        // Capacity one keeps the algorithm paced by the stream consumer. It limits queued steps,
208        // not model calls already pulled from the stream, which can still run at the same time.
209        // A larger buffer would use more memory and let the algorithm run farther ahead with
210        // little benefit because reading a step is cheap compared with serving a model call.
211        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    /// Replace the current run's evidence when a component makes the final decision.
225    pub(crate) fn set_evidence(&self, evidence: Value) {
226        *self.evidence.lock() = Some(evidence);
227    }
228
229    /// Supply fallback evidence without replacing a decision made earlier in the cascade.
230    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    /// Publish a model call and await the consumer's response.
238    ///
239    /// Errors if the stream is closed or the call failed.
240    /// The await is wrapped in a `libsy.llm_call` span measuring *fulfillment* as
241    /// the algorithm observes it (host queueing/serving included; a streamed
242    /// response resolves when its stream handle arrives); latency, outcome, and
243    /// token usage are recorded when it resolves. The provider call itself is the
244    /// host's, and is instrumented by whoever makes it.
245    #[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    /// The available models for this category, typically ordered best-first.
295    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    /// The first available model for `category`.
303    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    /// A driver scoped to delegated sub-agent work: its categories are the
312    /// sub-agent's own, and the parent's are no longer reachable through it.
313    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    /// Emit the terminal step: [`Step::Done`] on `Ok`, or an `Err` stream
326    /// item on failure. Internal: called once by [`run_stream`](Algorithm::run_stream)
327    /// when the algorithm finishes.
328    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
352/// One item in the stream returned by [`Algorithm::run_stream`].
353pub enum Step {
354    /// The algorithm needs this model call performed. The host serves it and fulfills
355    /// it with [`CallModel::respond`]. Boxed: it is by far the largest variant.
356    CallModel(Box<CallModel>),
357    /// The algorithm finished with its routing outcome — the last step of a run.
358    Done(Box<RoutingOutcome>),
359}
360
361/// Drive [`Algorithm::run_stream`] to completion, handing each offloaded call to `serve`.
362///
363/// Returns the final [`RoutingOutcome`].
364/// `serve` owns the call: it performs it however the host likes and must fulfill the promise
365/// with [`CallModel::respond`]. A failed *model* call belongs in `respond` — the
366/// algorithm may route around it. Returning `Err` from `serve` aborts the whole run, so
367/// reserve it for infrastructure failures. Calls are served concurrently, so an algorithm
368/// that offloads several at once (hedging, fan-out) gets real parallelism.
369///
370/// libsy performs no I/O; this is only the mechanics of consuming its own step stream, kept
371/// here so every host does not reimplement the same loop. `switchyard-llm-client`'s `run`
372/// is this function plus an HTTP client.
373pub 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(()) => {}, // CallModel completed successfully
393                Err(err) => return Err(err), // CallModel failed, propagate the error
394            },
395            step = stream.next() => {
396                match step {
397                    None => break, // stream has ended, no more steps
398                    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
412/// Recover the message from an algorithm's panic.
413fn 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
421/// Abort guard
422struct AbortOnDrop(tokio::task::AbortHandle);
423
424impl Drop for AbortOnDrop {
425    fn drop(&mut self) {
426        self.0.abort();
427    }
428}
429
430/// Errors unless `targets` contains `name`.
431///
432/// Config target names must be resolved before an algorithm is built. This list contains
433/// model IDs, not target names.
434pub(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/// Key for routing affinity: a root request by its session, a child request by its session
445/// and agent.
446#[derive(Clone, Hash, PartialEq, Eq)]
447pub(crate) enum RoutingIdentity {
448    /// Root request, keyed by session ID.
449    Session(String),
450    /// Child request, keyed by session and agent IDs.
451    Subagent { session: String, agent: String },
452}
453
454impl RoutingIdentity {
455    /// Builds a root or child identity from non-empty request metadata.
456    ///
457    /// A child request missing either ID returns `None`, so it keeps no routing history
458    /// rather than sharing the parent's.
459    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/// An optimization strategy. Implement [`route`](Self::route);
475/// callers drive it with [`run_stream`](Self::run_stream), serving each [`Step::CallModel`]
476/// it emits. `switchyard-llm-client`'s `run` is the ready-made consumer that does this
477/// over HTTP.
478///
479/// Methods take `self: Arc<Self>`: one algorithm (`Arc<dyn Algorithm>`) is shared across
480/// requests and run concurrently, so it owns its thread-safety and any shared state.
481///
482/// # Concurrency
483///
484/// A host may run the same algorithm concurrently for many requests. Implementations
485/// must synchronize their own mutable shared state. Each call to [`run_stream`](Self::run_stream)
486/// creates an independent [`Driver`], so model-call promises and emitted [`Step`]s cannot
487/// cross between runs.
488///
489/// # Observability
490///
491/// [`run_stream`](Self::run_stream) creates a `libsy.run` span, and each offloaded model
492/// call creates a nested `libsy.llm_call` span. Successful outcomes record their
493/// [`OutcomeMetadata::outcome_id`](crate::OutcomeMetadata::outcome_id) on `libsy.run`,
494/// alongside `selected_model_ids` (an ordered OpenTelemetry string array).
495/// `algorithm` and `switchyard.algorithm` retain the run's [`Algorithm::name`].
496/// Optional `evidence.source`, `evidence.verdict`, `evidence.trigger`, and
497/// `evidence.reason_code` are strings; `evidence.score`, `evidence.confidence`, and
498/// `evidence.threshold` are numbers. Unknown evidence fields are not exported.
499/// These fields are span attributes, never metric labels.
500///
501/// The run/call observability helpers retain `outcome` status and operational metrics,
502/// but omit error details and arbitrary request extra metadata. Algorithms and hosts
503/// may emit their own logs. Errors still reach the caller unchanged.
504/// The host controls the tracing subscriber and global OpenTelemetry
505/// meter provider; libsy installs no exporter and performs no telemetry network I/O.
506#[async_trait]
507pub trait Algorithm: Send + Sync + 'static {
508    /// Stable, low-cardinality name identifying this algorithm — the
509    /// `algorithm` attribute on every span, metric, and log line the crate
510    /// emits for its runs.
511    fn name(&self) -> &str;
512
513    /// Run one request to completion: make routing-time model calls with
514    /// [`Driver::call_model`] and return the terminal [`RoutingOutcome`].
515    /// The method an algorithm implements; [`run_stream`](Self::run_stream) drives it.
516    async fn route(self: Arc<Self>, driver: Driver, request: Request) -> Result<RoutingOutcome>;
517
518    /// Process a request to completion, returning a stream of [`Step`]s.
519    ///
520    /// The consumer must fulfill every [`Step::CallModel`] before the algorithm can
521    /// continue. Every run ends with exactly one terminal item — [`Step::Done`] on
522    /// success, an `Err` item on failure, including when the algorithm panics. Dropping
523    /// the stream aborts the spawned algorithm task.
524    ///
525    /// Every invocation owns a separate [`Driver`].
526    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                // Catch a panicking algorithm so the run still publishes a terminal step.
533                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        // Dropping the stream aborts the algorithm task when its consumer goes away.
551        let abort_guard = AbortOnDrop(handle.abort_handle());
552        Box::pin(ReceiverStream::new(step_rx).map(move |step| {
553            // link abort guard to stream
554            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    /// Trivial algo used only to exercise the orchestrator: calls the first target
580    /// and returns its response as the routing outcome.
581    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    /// Build a shared `TestAlgo` over the given target set.
611    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                upstream_headers: http::HeaderMap::new(),
649            },
650        );
651
652        assert_eq!(outcome.selected_model_ids, target_set(&["answered"]));
653        assert_eq!(outcome.request.model_id().as_deref(), Some("answered"));
654        assert_eq!(
655            outcome
656                .response
657                .as_ref()
658                .and_then(|response| response.llm_response.as_agg())
659                .map(completion_text),
660            Some("existing".to_string())
661        );
662    }
663
664    fn target_set(names: &[&str]) -> Vec<ModelId> {
665        names.iter().map(|name| ModelId::from(*name)).collect()
666    }
667
668    #[tokio::test]
669    async fn typed_driver_preserves_call_and_stream_boundaries() -> Result<()> {
670        tokio::time::timeout(std::time::Duration::from_secs(1), async {
671            // Distinct oneshots keep reverse-order replies paired with their producers, and a
672            // retained call remains pending until the host responds.
673            let (driver, mut step_rx) = Driver::new("test", Arc::new(RuntimeModels::default()));
674            let first_driver = driver.clone();
675            let mut first = tokio::spawn(async move {
676                first_driver
677                    .call_model(request(), vec![ModelId::from("first")])
678                    .await
679            });
680            let second = tokio::spawn(async move {
681                driver
682                    .call_model(request(), vec![ModelId::from("second")])
683                    .await
684            });
685
686            let mut calls = HashMap::new();
687            for _ in 0..2 {
688                let step = step_rx.recv().await.ok_or(DriverError::StreamClosed)??;
689                let Step::CallModel(call) = step else {
690                    return Err(test_error("expected a CallModel step"));
691                };
692                let selected_model = call
693                    .models
694                    .first()
695                    .ok_or_else(|| test_error("model call has no candidates"))?
696                    .to_string();
697                calls.insert(selected_model, call);
698            }
699            assert!(
700                tokio::time::timeout(std::time::Duration::from_millis(20), &mut first)
701                    .await
702                    .is_err(),
703                "call completed before the host responded"
704            );
705            calls
706                .remove("second")
707                .ok_or_else(|| test_error("missing second call"))?
708                .respond(Ok(reply("second response")))?;
709            calls
710                .remove("first")
711                .ok_or_else(|| test_error("missing first call"))?
712                .respond(Ok(reply("first response")))?;
713
714            let first_response = first
715                .await
716                .map_err(|source| LibsyError::external("joining a test task", source))??;
717            let second_response = second
718                .await
719                .map_err(|source| LibsyError::external("joining a test task", source))??;
720            assert_eq!(
721                first_response.llm_response.as_agg().map(completion_text),
722                Some("first response".to_string())
723            );
724            assert_eq!(
725                second_response.llm_response.as_agg().map(completion_text),
726                Some("second response".to_string())
727            );
728
729            // Dropping the host-facing promise closes only that call's reply channel.
730            let (driver, mut step_rx) = Driver::new("test", Arc::new(RuntimeModels::default()));
731            let producer = tokio::spawn(async move {
732                driver
733                    .call_model(request(), vec![ModelId::from("dropped")])
734                    .await
735            });
736            let step = step_rx.recv().await.ok_or(DriverError::StreamClosed)??;
737            let Step::CallModel(call) = step else {
738                return Err(test_error("expected a CallModel step"));
739            };
740            drop(call);
741            let result = producer
742                .await
743                .map_err(|source| LibsyError::external("joining a test task", source))?;
744            assert!(matches!(
745                result,
746                Err(LibsyError::Driver(DriverError::ResponseDropped))
747            ));
748
749            // A standalone driver reports the typed step receiver disappearing at its next call.
750            let (driver, step_rx) = Driver::new("test", Arc::new(RuntimeModels::default()));
751            drop(step_rx);
752            let result = driver
753                .call_model(request(), vec![ModelId::from("closed")])
754                .await;
755            assert!(matches!(
756                result,
757                Err(LibsyError::Driver(DriverError::StreamClosed))
758            ));
759            Ok(())
760        })
761        .await
762        .map_err(|error| LibsyError::external("waiting for typed driver boundaries", error))?
763    }
764
765    #[test]
766    fn target_lookup_returns_the_missing_target() {
767        let error = ensure_model_is_target(&target_set(&[]), &ModelId::from("missing")).err();
768        assert!(matches!(
769            error,
770            Some(LibsyError::TargetNotFound { target }) if target == "missing"
771        ));
772    }
773
774    /// Build a single-target algo, plus a `serve` that answers it as a token stream
775    /// replaying `chunks` in order (as `Ok` items).
776    fn streaming_orch(chunks: Vec<LlmResponseChunk>) -> (Arc<dyn Algorithm>, impl Serve) {
777        let algo = orch(target_set(&["stream/model"]));
778        let serve = move |_target: ModelId, _request: Request| {
779            let chunks = chunks.clone();
780            async move {
781                let stream =
782                    futures::stream::iter(chunks.into_iter().map(|chunk| Ok(chunk.into()))).boxed();
783                Ok(Response {
784                    llm_response: LlmResponse::Stream(stream),
785                    metadata: None,
786                    upstream_headers: http::HeaderMap::new(),
787                })
788            }
789        };
790        (algo, serve)
791    }
792
793    #[tokio::test]
794    async fn run_returns_a_streamed_response_the_caller_aggregates() -> Result<()> {
795        // A streaming client -> its chunks flow through the promise and `Done`,
796        // and `run` returns the live stream untouched for the caller to fold.
797        let (orch, serve) = streaming_orch(vec![
798            LlmResponseChunk::MessageStart {
799                id: Some("m1".to_string()),
800                model: Some("stream/model".to_string()),
801            },
802            LlmResponseChunk::TextDelta {
803                index: 0,
804                text: "hel".to_string(),
805            },
806            LlmResponseChunk::TextDelta {
807                index: 0,
808                text: "lo".to_string(),
809            },
810            LlmResponseChunk::MessageStop {
811                reason: Some("stop".to_string()),
812            },
813        ]);
814        let (selected_model, response) = test_drive(orch, request(), serve).await?;
815        // The run handed back the live stream; the caller folds it to a buffered aggregate.
816        let agg = response
817            .llm_response
818            .into_agg()
819            .await
820            .map_err(|error| LibsyError::external("aggregating response stream", error))?;
821        assert_eq!(completion_text(&agg), "hello");
822        assert_eq!(agg.model.as_deref(), Some("stream/model"));
823        assert_eq!(selected_model, "stream/model");
824        Ok(())
825    }
826
827    #[tokio::test]
828    async fn aggregating_a_streamed_response_propagates_a_mid_stream_error() -> Result<()> {
829        // The run succeeds and returns the stream; the in-band `Error` chunk surfaces only
830        // when the caller aggregates it.
831        let (orch, serve) = streaming_orch(vec![
832            LlmResponseChunk::TextDelta {
833                index: 0,
834                text: "partial".to_string(),
835            },
836            LlmResponseChunk::StreamError {
837                message: "upstream exploded".to_string(),
838            },
839        ]);
840        let (_, response) = test_drive(orch, request(), serve).await?;
841        match response.llm_response.into_agg().await {
842            Ok(_) => panic!("expected a mid-stream error, got an aggregate"),
843            Err(err) => {
844                assert!(err.to_string().contains("upstream exploded"));
845                Ok(())
846            }
847        }
848    }
849
850    #[tokio::test]
851    async fn run_offloads_via_promise_then_finishes() -> Result<()> {
852        // Every call is offloaded via a promise the orchestrator surfaces as a
853        // `CallModel` step for us to fulfill.
854        let stream = orch(target_set(&["offload/model"]))
855            .run_stream(request(), Arc::new(RuntimeModels::default()));
856        tokio::pin!(stream);
857
858        let mut saw_call = false;
859        let mut final_completion = None;
860        while let Some(step) = stream.next().await {
861            match step? {
862                Step::CallModel(call) => {
863                    saw_call = true;
864                    assert_eq!(call.models, vec![ModelId::from("offload/model")]);
865                    // Fulfilling the promise is the "real" model call the caller makes.
866                    call.respond(Ok(Response {
867                        llm_response: LlmResponse::Agg(text_response(
868                            None,
869                            "fulfilled".to_string(),
870                        )),
871                        metadata: None,
872                        upstream_headers: http::HeaderMap::new(),
873                    }))?;
874                }
875                Step::Done(outcome) => {
876                    let metadata = outcome
877                        .metadata
878                        .as_ref()
879                        .expect("run_stream should attach outcome metadata");
880                    assert_eq!(metadata.algorithm, "test");
881                    assert_eq!(
882                        uuid::Uuid::parse_str(metadata.outcome_id())
883                            .expect("outcome id should be a UUID")
884                            .get_version_num(),
885                        7
886                    );
887                    assert_eq!(
888                        metadata.evidence,
889                        Some(serde_json::json!({"source": "test"}))
890                    );
891                    let response = outcome
892                        .response
893                        .ok_or_else(|| test_error("expected an answered outcome"))?;
894                    final_completion = Some(
895                        response
896                            .llm_response
897                            .as_agg()
898                            .map(completion_text)
899                            .unwrap_or_default(),
900                    );
901                }
902            }
903        }
904
905        assert!(saw_call, "expected a CallModel step before Done");
906        assert_eq!(
907            final_completion.ok_or_else(|| test_error("no Done step"))?,
908            "fulfilled"
909        );
910        Ok(())
911    }
912
913    #[tokio::test(flavor = "multi_thread", worker_threads = 12)]
914    async fn requests_are_processed_in_parallel() -> Result<()> {
915        use std::time::Duration;
916        use tokio::sync::Barrier;
917
918        const N: usize = 12;
919
920        // Serving blocks until all N concurrent calls have arrived. If requests were
921        // serialized (one algorithm behind a `Mutex`), only one call could be in flight,
922        // the barrier would never reach N, and the test would time out. It passes only
923        // because the shared algorithm is driven concurrently across requests.
924        let barrier = Arc::new(Barrier::new(N));
925        // One shared algorithm driven by many concurrent requests.
926        let algo = orch(target_set(&["m"]));
927
928        let mut handles = Vec::new();
929        for _ in 0..N {
930            let algo = algo.clone();
931            let barrier = barrier.clone();
932            let serve = move |target: ModelId, _request: Request| {
933                let barrier = barrier.clone();
934                async move {
935                    barrier.wait().await;
936                    Ok(reply(target))
937                }
938            };
939            handles.push(tokio::spawn(async move {
940                test_drive(algo, request(), serve)
941                    .await
942                    .map(|(_, response)| {
943                        response
944                            .llm_response
945                            .as_agg()
946                            .map(completion_text)
947                            .unwrap_or_default()
948                    })
949            }));
950        }
951
952        for handle in handles {
953            // The timeout turns a serialization deadlock into a failure, not a hang.
954            let completion = tokio::time::timeout(Duration::from_secs(5), handle)
955                .await
956                .map_err(|error| LibsyError::external("waiting for test task", error))?
957                .map_err(|source| LibsyError::external("joining a test task", source))??;
958            assert_eq!(completion, "m");
959        }
960        Ok(())
961    }
962
963    #[tokio::test]
964    async fn offload_error_propagates_back_to_the_algorithm() -> Result<()> {
965        // A client-less target offloads its call; we fulfill the promise with an
966        // Err, which must flow back through `call_model_target` into the algorithm and
967        // out as an error step — not a response.
968        let stream = orch(target_set(&["offload/model"]))
969            .run_stream(request(), Arc::new(RuntimeModels::default()));
970        tokio::pin!(stream);
971
972        let mut saw_error = false;
973        while let Some(step) = stream.next().await {
974            match step {
975                Ok(Step::CallModel(call)) => {
976                    call.respond(Err(test_error("upstream model call failed")))?;
977                }
978                Ok(Step::Done(..)) => {
979                    return Err(test_error(
980                        "expected the offload error to propagate, got a response",
981                    ));
982                }
983                Err(err) => {
984                    // The algorithm's `call_model_target` saw the error via the promise.
985                    assert!(err.to_string().contains("upstream model call failed"));
986                    saw_error = true;
987                }
988            }
989        }
990
991        assert!(saw_error, "expected an error step");
992        Ok(())
993    }
994
995    #[tokio::test]
996    async fn dropping_the_stream_cancels_the_algorithm_task() -> Result<()> {
997        use std::sync::atomic::{AtomicBool, Ordering};
998        use std::time::Duration;
999        use tokio::sync::mpsc;
1000
1001        // Sets a flag when dropped, so we can observe whether the algorithm task was
1002        // cancelled/dropped.
1003        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<RoutingOutcome> {
1026                let _guard = DropGuard(self.dropped.clone());
1027                let _ = self.started.send(());
1028                // Await forever without ever touching the driver.
1029                std::future::pending::<()>().await;
1030                unreachable!()
1031            }
1032        }
1033
1034        let (started_tx, mut started_rx) = mpsc::unbounded_channel();
1035        let dropped = Arc::new(AtomicBool::new(false));
1036        let algo: Arc<dyn Algorithm> = Arc::new(StuckAlgo {
1037            started: started_tx,
1038            dropped: dropped.clone(),
1039        });
1040
1041        let stream = algo.run_stream(request(), Arc::new(RuntimeModels::default()));
1042        started_rx
1043            .recv()
1044            .await
1045            .ok_or_else(|| test_error("task never started"))?;
1046        drop(stream);
1047        tokio::time::sleep(Duration::from_millis(100)).await;
1048
1049        assert!(
1050            dropped.load(Ordering::SeqCst),
1051            "algorithm task was NOT cancelled after dropping the stream"
1052        );
1053        Ok(())
1054    }
1055
1056    #[tokio::test]
1057    async fn route_panic_surfaces_as_a_stream_error() -> Result<()> {
1058        // An algorithm whose task panics must surface an `Err` step carrying the panic
1059        // message, not abort the process from an unobserved detached task.
1060        struct Panicky;
1061
1062        #[async_trait]
1063        impl Algorithm for Panicky {
1064            fn name(&self) -> &str {
1065                "panicky"
1066            }
1067
1068            async fn route(
1069                self: Arc<Self>,
1070                _driver: Driver,
1071                _request: Request,
1072            ) -> Result<RoutingOutcome> {
1073                panic!("boom");
1074            }
1075        }
1076
1077        let algo: Arc<dyn Algorithm> = Arc::new(Panicky);
1078        let stream = algo.run_stream(request(), Arc::new(RuntimeModels::default()));
1079        tokio::pin!(stream);
1080
1081        let mut saw_error = false;
1082        while let Some(step) = stream.next().await {
1083            match step {
1084                Err(err) => {
1085                    // The panic message is preserved, not flattened into an opaque failure.
1086                    assert!(err.to_string().contains("algorithm task panicked: boom"));
1087                    saw_error = true;
1088                }
1089                Ok(_) => return Err(test_error("expected the panic to surface as an error step")),
1090            }
1091        }
1092
1093        assert!(saw_error, "expected an error step from the panicked task");
1094        Ok(())
1095    }
1096
1097    /// A panicking algorithm must publish its terminal step even when it left a `Driver`
1098    /// clone alive in another task. That clone holds the step channel open, so a run that
1099    /// merely unwound would never terminate and the consumer would wait forever.
1100    #[tokio::test]
1101    async fn a_panic_with_a_leaked_driver_clone_still_terminates_the_run() -> Result<()> {
1102        struct LeakyPanic;
1103
1104        #[async_trait]
1105        impl Algorithm for LeakyPanic {
1106            fn name(&self) -> &str {
1107                "leaky_panic"
1108            }
1109
1110            async fn route(
1111                self: Arc<Self>,
1112                driver: Driver,
1113                _request: Request,
1114            ) -> Result<RoutingOutcome> {
1115                tokio::spawn(async move {
1116                    // Outlives the panic below, keeping a sender clone alive.
1117                    let _keep_alive = driver;
1118                    std::future::pending::<()>().await;
1119                });
1120                tokio::task::yield_now().await;
1121                panic!("boom");
1122            }
1123        }
1124
1125        let algo: Arc<dyn Algorithm> = Arc::new(LeakyPanic);
1126        // The timeout turns the hang this guards against into a failure rather than a hang.
1127        let result = tokio::time::timeout(
1128            std::time::Duration::from_secs(1),
1129            test_drive(algo, request(), echo()),
1130        )
1131        .await
1132        .map_err(|error| LibsyError::external("waiting for the panicked run to end", error))?;
1133
1134        match result {
1135            Ok(_) => Err(test_error(
1136                "expected the panic to end the run with an error",
1137            )),
1138            Err(err) => {
1139                assert!(err.to_string().contains("algorithm task panicked: boom"));
1140                Ok(())
1141            }
1142        }
1143    }
1144
1145    #[tokio::test]
1146    async fn cancelling_run_cancels_the_algorithm_task() -> Result<()> {
1147        use std::sync::atomic::{AtomicBool, Ordering};
1148        use std::time::Duration;
1149        use tokio::sync::mpsc;
1150
1151        // Sets a flag when dropped, so we can observe whether the algorithm task was
1152        // cancelled once the `run` future driving it is dropped.
1153        struct DropGuard(Arc<AtomicBool>);
1154        impl Drop for DropGuard {
1155            fn drop(&mut self) {
1156                self.0.store(true, Ordering::SeqCst);
1157            }
1158        }
1159
1160        struct StuckAlgo {
1161            started: mpsc::UnboundedSender<()>,
1162            dropped: Arc<AtomicBool>,
1163        }
1164
1165        #[async_trait]
1166        impl Algorithm for StuckAlgo {
1167            fn name(&self) -> &str {
1168                "stuck"
1169            }
1170
1171            async fn route(
1172                self: Arc<Self>,
1173                _driver: Driver,
1174                _request: Request,
1175            ) -> Result<RoutingOutcome> {
1176                let _guard = DropGuard(self.dropped.clone());
1177                let _ = self.started.send(());
1178                // Hang forever without ever touching the driver, so only cancellation
1179                // (not a dropped step channel) can stop this task.
1180                std::future::pending::<()>().await;
1181                unreachable!()
1182            }
1183        }
1184
1185        let (started_tx, mut started_rx) = mpsc::unbounded_channel();
1186        let dropped = Arc::new(AtomicBool::new(false));
1187        let algo: Arc<dyn Algorithm> = Arc::new(StuckAlgo {
1188            started: started_tx,
1189            dropped: dropped.clone(),
1190        });
1191
1192        // Drive the run on its own task, wait until the algorithm task is up, then cancel
1193        // it — dropping its future (and the `run_stream` stream it holds).
1194        let run_task = tokio::spawn(async move { test_drive(algo, request(), echo()).await });
1195        started_rx
1196            .recv()
1197            .await
1198            .ok_or_else(|| test_error("task never started"))?;
1199        run_task.abort();
1200        tokio::time::sleep(Duration::from_millis(100)).await;
1201
1202        assert!(
1203            dropped.load(Ordering::SeqCst),
1204            "algorithm task was NOT cancelled after cancelling run"
1205        );
1206        Ok(())
1207    }
1208
1209    // --- first-wins hedging: `run` must not wait on losing speculative calls -------------
1210
1211    /// Offloads two targets concurrently and returns the first to resolve, dropping the
1212    /// loser's call (first-wins hedging).
1213    struct Hedge {
1214        winner: String,
1215        loser: String,
1216    }
1217
1218    #[async_trait]
1219    impl Algorithm for Hedge {
1220        fn name(&self) -> &str {
1221            "hedge"
1222        }
1223
1224        async fn route(
1225            self: Arc<Self>,
1226            driver: Driver,
1227            request: Request,
1228        ) -> Result<RoutingOutcome> {
1229            let outcome_request = request.clone();
1230            let win = driver.call_model(request.clone(), vec![self.winner.clone().into()]);
1231            let lose = driver.call_model(request, vec![self.loser.clone().into()]);
1232            // First to resolve wins; `select!` drops the losing future (and its promise).
1233            tokio::select! {
1234                res = win => Ok(RoutingOutcome::answered(
1235                    self.winner.clone().into(),
1236                    outcome_request,
1237                    res?,
1238                )),
1239                res = lose => Ok(RoutingOutcome::answered(
1240                    self.loser.clone().into(),
1241                    outcome_request,
1242                    res?,
1243                )),
1244            }
1245        }
1246    }
1247
1248    /// Builds a hedging algo and the `serve` that drives it: the winner is gated behind the
1249    /// loser starting (so the loser's serve is guaranteed in flight when the winner wins),
1250    /// and the loser finishes after `loser_delay` — or never, when `None`.
1251    fn hedge(loser_delay: Option<std::time::Duration>) -> (Arc<dyn Algorithm>, impl Serve) {
1252        let started = Arc::new(tokio::sync::Notify::new());
1253        let algo = Arc::new(Hedge {
1254            winner: "winner".to_string(),
1255            loser: "loser".to_string(),
1256        });
1257        let serve = move |target: ModelId, _request: Request| {
1258            let started = started.clone();
1259            async move {
1260                if target == "loser" {
1261                    started.notify_one();
1262                    match loser_delay {
1263                        Some(delay) => tokio::time::sleep(delay).await,
1264                        None => std::future::pending::<()>().await,
1265                    }
1266                } else {
1267                    started.notified().await;
1268                }
1269                Ok(reply(target))
1270            }
1271        };
1272        (algo, serve)
1273    }
1274
1275    #[tokio::test]
1276    async fn run_returns_the_winner_without_a_late_loser_overwriting_it() -> Result<()> {
1277        // The loser responds 50ms after the winner has already won. `run` must return the
1278        // winner, not the loser's `respond`-to-a-dropped-receiver error.
1279        let (algo, serve) = hedge(Some(std::time::Duration::from_millis(50)));
1280        let (_, response) = test_drive(algo, request(), serve).await?;
1281        assert_eq!(
1282            response
1283                .llm_response
1284                .as_agg()
1285                .map(completion_text)
1286                .unwrap_or_default(),
1287            "winner"
1288        );
1289        Ok(())
1290    }
1291
1292    #[tokio::test]
1293    async fn run_returns_the_winner_without_hanging_on_a_pending_loser() -> Result<()> {
1294        // The loser never resolves. `run` must return the winner promptly, not hang
1295        // waiting for the in-flight loser.
1296        let (algo, serve) = hedge(None);
1297        let run = test_drive(algo, request(), serve);
1298        let (_, response) = tokio::time::timeout(std::time::Duration::from_secs(1), run)
1299            .await
1300            .map_err(|error| LibsyError::external("waiting for pending loser", error))??;
1301        assert_eq!(
1302            response
1303                .llm_response
1304                .as_agg()
1305                .map(completion_text)
1306                .unwrap_or_default(),
1307            "winner"
1308        );
1309        Ok(())
1310    }
1311
1312    #[tokio::test]
1313    async fn run_surfaces_a_terminal_error_with_many_calls_in_flight() -> Result<()> {
1314        use std::sync::atomic::{AtomicUsize, Ordering};
1315
1316        // A large fan-out (10 matched the old, now-removed concurrency cap). The terminal
1317        // error must still reach the caller with all of these calls pending.
1318        const N: usize = 10;
1319
1320        // Fans out N calls, then errors as soon as all N are in flight — exercising a
1321        // terminal failure emitted while the offloaded calls are still pending.
1322        struct FanOutThenError {
1323            all_started: Arc<tokio::sync::Notify>,
1324            n: usize,
1325        }
1326
1327        #[async_trait]
1328        impl Algorithm for FanOutThenError {
1329            fn name(&self) -> &str {
1330                "fan_out_then_error"
1331            }
1332
1333            async fn route(
1334                self: Arc<Self>,
1335                driver: Driver,
1336                request: Request,
1337            ) -> Result<RoutingOutcome> {
1338                let offloads = futures::future::join_all(
1339                    (0..self.n)
1340                        .map(|i| driver.call_model(request.clone(), vec![format!("m{i}").into()])),
1341                );
1342                tokio::select! {
1343                    _ = offloads => Err(test_error("offloads unexpectedly completed")),
1344                    _ = self.all_started.notified() => {
1345                        Err(test_error("terminal error while calls pending"))
1346                    }
1347                }
1348            }
1349        }
1350
1351        let all_started = Arc::new(tokio::sync::Notify::new());
1352        let algo: Arc<dyn Algorithm> = Arc::new(FanOutThenError {
1353            all_started: all_started.clone(),
1354            n: N,
1355        });
1356
1357        // Serving enters each call; once all N are in flight it signals, then pends forever.
1358        let started = Arc::new(AtomicUsize::new(0));
1359        let serve = move |_target: ModelId, _request: Request| {
1360            let started = started.clone();
1361            let all_started = all_started.clone();
1362            async move {
1363                if started.fetch_add(1, Ordering::SeqCst) + 1 == N {
1364                    all_started.notify_one();
1365                }
1366                std::future::pending::<ServeResult>().await
1367            }
1368        };
1369
1370        // With the cap gone, the driver keeps polling the stream even with N calls in
1371        // flight, so the terminal error surfaces promptly instead of hanging.
1372        let run = test_drive(algo, request(), serve);
1373        let result = tokio::time::timeout(std::time::Duration::from_millis(500), run)
1374            .await
1375            .map_err(|error| {
1376                LibsyError::external("waiting for terminal error with full call cap", error)
1377            })?;
1378        match result {
1379            Ok(_) => Err(test_error("expected the terminal error, got a response")),
1380            Err(err) => {
1381                assert!(
1382                    err.to_string()
1383                        .contains("terminal error while calls pending")
1384                );
1385                Ok(())
1386            }
1387        }
1388    }
1389}