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::{future::Future, panic::AssertUnwindSafe, pin::Pin, sync::Arc, time::Instant};
8
9use async_trait::async_trait;
10use futures::{FutureExt, Stream, StreamExt};
11use tokio::sync::{mpsc, oneshot};
12use tokio_stream::wrappers::ReceiverStream;
13use tracing::Instrument;
14
15/// The request/response protocol types come from [`switchyard_protocol`].
16/// [`switchyard_protocol::LlmRequest`] is the normalized request;
17/// [`switchyard_protocol::AggLlmResponse`] is the buffered response;
18/// [`switchyard_protocol::LlmResponseChunk`] is normalized streaming content;
19/// [`switchyard_protocol::LlmResponseStreamEvent`] is its host/algorithm envelope; and
20/// [`switchyard_protocol::LlmResponse`] carries either a live
21/// [`switchyard_protocol::LlmResponseStream`] or the terminal aggregate.
22use switchyard_protocol::{ModelId, Request, Response};
23
24use crate::{DriverError, LibsyError, Result, observability};
25
26/// A boxed, `Send` stream of [`Step`]s — the output of
27/// [`Algorithm::run_stream`]. Boxed so the trait method that produces it keeps
28/// `Arc<dyn Algorithm>` object-safe.
29pub type StepStream = Pin<Box<dyn Stream<Item = Result<Step>> + Send>>;
30
31/// An offloaded model call, surfaced inside [`Step::CallModel`].
32///
33/// The host reads the public fields, performs (or delegates) the model call, and fulfills it
34/// with [`respond`](Self::respond) — unblocking the algorithm's [`Driver::call_model`] on the
35/// other side. `switchyard-llm-client`'s `run` is the ready-made consumer that does this for
36/// you.
37///
38/// [`Driver::call_model`] stamps the first candidate model onto the request before publishing
39/// the call. A consumer that falls through to a later candidate must re-stamp it.
40pub struct CallModel {
41    /// The name of the algorithm that produced this call, so a host instrumenting the
42    /// calls it serves can attribute its own spans to the algorithm behind them.
43    pub algorithm: String,
44    /// The request to serve; its `model` is stamped with the first candidate.
45    pub request: Request,
46    /// Candidate models, tried in order until one answers. Never empty.
47    pub models: Vec<ModelId>,
48    // How to send the response back to the algorithm
49    reply: oneshot::Sender<Result<Response>>,
50}
51
52impl CallModel {
53    /// Fulfill the promise with the caller's model-call result. Pass `Err(..)` to
54    /// propagate a failed model call back to the algorithm. Consumes the promise: it
55    /// can only be fulfilled once.
56    pub fn respond(self, result: Result<Response>) -> Result<()> {
57        self.reply
58            .send(result)
59            .map_err(|_| DriverError::ResponseDropped.into())
60    }
61}
62
63/// The terminal result of routing.
64pub struct RoutingOutcome {
65    /// Models selected by the algorithm, ordered best model first.
66    pub selected_model_ids: Vec<ModelId>,
67    /// The request after all routing-time rewrites, stamped with the selected model.
68    pub request: Request,
69    /// A response produced while routing, or `None` when the client must make the answer call.
70    pub response: Option<Response>,
71    /// Outcome identity and optional algorithm evidence.
72    ///
73    /// Constructors leave this empty; [`Algorithm::run_stream`] fills it before publishing a
74    /// successful outcome.
75    pub metadata: Option<crate::OutcomeMetadata>,
76}
77
78impl RoutingOutcome {
79    /// The model the algorithm recommends, the best model for this request.
80    /// `LibsyError::NoTargets` if the algorithm selected no models, which should be impossible.
81    pub fn selected_model_id(&self) -> Result<&ModelId> {
82        self.selected_model_ids.first().ok_or(LibsyError::NoTargets)
83    }
84
85    /// The decision is that client should send this `request`. The `selected_model_id`
86    /// will be written into it by this function.
87    /// If that fails client should try the `fallback_models` in order.
88    pub fn route_to(
89        selected_model_id: ModelId,
90        fallback_models: Vec<ModelId>,
91        mut request: Request,
92    ) -> Self {
93        request.llm_request.model = Some(selected_model_id.to_string());
94        let mut selected_model_ids = Vec::with_capacity(1 + fallback_models.len());
95        selected_model_ids.push(selected_model_id);
96        selected_model_ids.extend(fallback_models);
97        Self {
98            selected_model_ids,
99            request,
100            response: None,
101            metadata: None,
102        }
103    }
104
105    /// Algorithm generated the response as part of the routing decision. Here it is.
106    /// The `request` will have the `selected_model_id` written into it by this function.
107    pub fn answered(selected_model_id: ModelId, mut request: Request, response: Response) -> Self {
108        request.llm_request.model = Some(selected_model_id.to_string());
109        Self {
110            selected_model_ids: vec![selected_model_id],
111            request,
112            response: Some(response),
113            metadata: None,
114        }
115    }
116}
117
118/// How an algorithm's [`route`](Algorithm::route) makes model calls.
119#[derive(Clone)]
120pub struct Driver {
121    step_tx: mpsc::Sender<Result<Step>>,
122    /// The owning algorithm's telemetry label, stamped onto every call this driver publishes.
123    algorithm: String,
124}
125
126impl Driver {
127    /// Build an empty driver with its step channel ready. Created per call by
128    /// [`run_stream`](Algorithm::run_stream). Also returns the Step receiver.
129    pub(crate) fn new(algorithm: &str) -> (Self, mpsc::Receiver<Result<Step>>) {
130        // Capacity one keeps the algorithm paced by the stream consumer. It limits queued steps,
131        // not model calls already pulled from the stream, which can still run at the same time.
132        // A larger buffer would use more memory and let the algorithm run farther ahead with
133        // little benefit because reading a step is cheap compared with serving a model call.
134        let (step_tx, step_rx) = mpsc::channel(1);
135        (
136            Self {
137                step_tx,
138                algorithm: algorithm.to_string(),
139            },
140            step_rx,
141        )
142    }
143
144    /// Publish a model call and await the consumer's response.
145    ///
146    /// Errors if the stream is closed or the call failed.
147    /// The await is wrapped in a `libsy.llm_call` span measuring *fulfillment* as
148    /// the algorithm observes it (host queueing/serving included; a streamed
149    /// response resolves when its stream handle arrives); latency, outcome, and
150    /// token usage are recorded when it resolves. The provider call itself is the
151    /// host's, and is instrumented by whoever makes it.
152    #[tracing::instrument(
153        target = "libsy",
154        name = "libsy.llm_call",
155        skip_all,
156        fields(
157            algorithm = self.algorithm,
158            selected_model = %models.first().map(ModelId::as_str).unwrap_or("NoTargets"),
159            openinference.span.kind = "CHAIN",
160            outcome = tracing::field::Empty,
161            error = tracing::field::Empty,
162            input_tokens = tracing::field::Empty,
163            output_tokens = tracing::field::Empty,
164            total_tokens = tracing::field::Empty,
165            reasoning_tokens = tracing::field::Empty,
166        )
167    )]
168    pub async fn call_model(&self, mut request: Request, models: Vec<ModelId>) -> Result<Response> {
169        let Some(selected_model_id) = models.first().cloned() else {
170            return Err(LibsyError::NoTargets);
171        };
172        request.llm_request.model = Some(selected_model_id.to_string());
173        let started = Instant::now();
174        let (reply, response) = oneshot::channel::<Result<Response>>();
175        let call = CallModel {
176            algorithm: self.algorithm.clone(),
177            request,
178            models,
179            reply,
180        };
181        let result = async {
182            self.step_tx
183                .send(Ok(Step::CallModel(Box::new(call))))
184                .await
185                .map_err(|_| DriverError::StreamClosed)?;
186            response
187                .await
188                .map_err(|_| LibsyError::from(DriverError::ResponseDropped))?
189        }
190        .await;
191        let elapsed = started.elapsed();
192        observability::record_llm_call(
193            &self.algorithm,
194            selected_model_id.as_str(),
195            elapsed,
196            &result,
197            &tracing::Span::current(),
198        );
199        result
200    }
201
202    /// Emit the terminal step: [`Step::Done`] on `Ok`, or an `Err` stream
203    /// item on failure. Internal: called once by [`run_stream`](Algorithm::run_stream)
204    /// when the algorithm finishes.
205    pub(crate) async fn finish(&self, result: Result<RoutingOutcome>) -> Result<()> {
206        let result = result.map(|mut outcome| {
207            let metadata = outcome
208                .metadata
209                .get_or_insert_with(|| crate::OutcomeMetadata::new(self.algorithm.clone(), None));
210            tracing::Span::current().record("outcome_id", metadata.outcome_id());
211            outcome
212        });
213        let selected_model = result
214            .as_ref()
215            .ok()
216            .and_then(|outcome| outcome.selected_model_id().ok().cloned());
217        let step = result.map(|outcome| Step::Done(Box::new(outcome)));
218        self.step_tx
219            .send(step)
220            .await
221            .map_err(|_| DriverError::StreamClosed)?;
222        if let Some(selected_model) = selected_model {
223            observability::record_decision(&self.algorithm, &selected_model);
224        }
225        Ok(())
226    }
227}
228
229/// One item in the stream returned by [`Algorithm::run_stream`].
230pub enum Step {
231    /// The algorithm needs this model call performed. The host serves it and fulfills
232    /// it with [`CallModel::respond`]. Boxed: it is by far the largest variant.
233    CallModel(Box<CallModel>),
234    /// The algorithm finished with its routing outcome — the last step of a run.
235    Done(Box<RoutingOutcome>),
236}
237
238/// Drive [`Algorithm::run_stream`] to completion, handing each offloaded call to `serve`.
239///
240/// Returns the final [`RoutingOutcome`].
241/// `serve` owns the call: it performs it however the host likes and must fulfill the promise
242/// with [`CallModel::respond`]. A failed *model* call belongs in `respond` — the
243/// algorithm may route around it. Returning `Err` from `serve` aborts the whole run, so
244/// reserve it for infrastructure failures. Calls are served concurrently, so an algorithm
245/// that offloads several at once (hedging, fan-out) gets real parallelism.
246///
247/// libsy performs no I/O; this is only the mechanics of consuming its own step stream, kept
248/// here so every host does not reimplement the same loop. `switchyard-llm-client`'s `run`
249/// is this function plus an HTTP client.
250pub async fn drive<F, Fut>(
251    algorithm: Arc<dyn Algorithm>,
252    request: Request,
253    serve: F,
254) -> Result<RoutingOutcome>
255where
256    F: Fn(CallModel) -> Fut,
257    Fut: Future<Output = Result<()>>,
258{
259    let stream = algorithm.run_stream(request);
260    tokio::pin!(stream);
261
262    let mut in_flight = futures::stream::FuturesUnordered::new();
263    let mut final_outcome: Option<RoutingOutcome> = None;
264
265    loop {
266        tokio::select! {
267            Some(result) = in_flight.next() => match result {
268                Ok(()) => {}, // CallModel completed successfully
269                Err(err) => return Err(err), // CallModel failed, propagate the error
270            },
271            step = stream.next() => {
272                match step {
273                    None => break, // stream has ended, no more steps
274                    Some(item) => match item? {
275                        Step::CallModel(call) => in_flight.push(serve(*call)),
276                        Step::Done(outcome) => {
277                            final_outcome = Some(*outcome);
278                            break;
279                        }
280                    }
281                }
282            },
283        }
284    }
285    final_outcome.ok_or(LibsyError::MissingFinalResponse)
286}
287
288/// Recover the message from an algorithm's panic.
289fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
290    payload
291        .downcast_ref::<&'static str>()
292        .map(|message| (*message).to_string())
293        .or_else(|| payload.downcast_ref::<String>().cloned())
294        .unwrap_or_else(|| "unknown panic payload".to_string())
295}
296
297/// Abort guard
298struct AbortOnDrop(tokio::task::AbortHandle);
299
300impl Drop for AbortOnDrop {
301    fn drop(&mut self) {
302        self.0.abort();
303    }
304}
305
306/// Errors unless `targets` contains `name`.
307///
308/// Config target names must be resolved before an algorithm is built. This list contains
309/// model IDs, not target names.
310pub(crate) fn ensure_model_is_target(targets: &[ModelId], name: &ModelId) -> Result<()> {
311    targets
312        .iter()
313        .any(|target| target == name)
314        .then_some(())
315        .ok_or_else(|| LibsyError::TargetNotFound {
316            target: name.clone(),
317        })
318}
319
320/// Key for routing affinity: a root request by its session, a child request by its session
321/// and agent.
322#[derive(Clone, Hash, PartialEq, Eq)]
323pub(crate) enum RoutingIdentity {
324    /// Root request, keyed by session ID.
325    Session(String),
326    /// Child request, keyed by session and agent IDs.
327    Subagent { session: String, agent: String },
328}
329
330impl RoutingIdentity {
331    /// Builds a root or child identity from non-empty request metadata.
332    ///
333    /// A child request missing either ID returns `None`, so it keeps no routing history
334    /// rather than sharing the parent's.
335    pub(crate) fn from_request(request: &Request) -> Option<Self> {
336        let metadata = request.metadata.as_ref()?;
337        let session = metadata.session_id.as_deref().filter(|id| !id.is_empty())?;
338        if metadata.is_subagent {
339            let agent = metadata.agent_id.as_deref().filter(|id| !id.is_empty())?;
340            Some(Self::Subagent {
341                session: session.to_string(),
342                agent: agent.to_string(),
343            })
344        } else {
345            Some(Self::Session(session.to_string()))
346        }
347    }
348}
349
350/// An optimization strategy. Implement [`route`](Self::route);
351/// callers drive it with [`run_stream`](Self::run_stream), serving each [`Step::CallModel`]
352/// it emits. `switchyard-llm-client`'s `run` is the ready-made consumer that does this
353/// over HTTP.
354///
355/// Methods take `self: Arc<Self>`: one algorithm (`Arc<dyn Algorithm>`) is shared across
356/// requests and run concurrently, so it owns its thread-safety and any shared state.
357///
358/// # Concurrency
359///
360/// A host may run the same algorithm concurrently for many requests. Implementations
361/// must synchronize their own mutable shared state. Each call to [`run_stream`](Self::run_stream)
362/// creates an independent [`Driver`], so model-call promises and emitted [`Step`]s cannot
363/// cross between runs.
364///
365/// # Observability
366///
367/// [`run_stream`](Self::run_stream) creates a `libsy.run` span, and each offloaded model
368/// call creates a `libsy.llm_call` span. Routing decisions and failures are emitted through
369/// `tracing`; metrics use the global OpenTelemetry meter provider. The provider call
370/// itself belongs to the host, and is instrumented by whoever makes it.
371#[async_trait]
372pub trait Algorithm: Send + Sync + 'static {
373    /// Stable, low-cardinality name identifying this algorithm — the
374    /// `algorithm` attribute on every span, metric, and log line the crate
375    /// emits for its runs.
376    fn name(&self) -> &str;
377
378    /// Run one request to completion: make routing-time model calls with
379    /// [`Driver::call_model`] and return the terminal [`RoutingOutcome`].
380    /// The method an algorithm implements; [`run_stream`](Self::run_stream) drives it.
381    async fn route(self: Arc<Self>, driver: Driver, request: Request) -> Result<RoutingOutcome>;
382
383    /// Process a request to completion, returning a stream of [`Step`]s.
384    ///
385    /// The consumer must fulfill every [`Step::CallModel`] before the algorithm can
386    /// continue. Every run ends with exactly one terminal item — [`Step::Done`] on
387    /// success, an `Err` item on failure, including when the algorithm panics. Dropping
388    /// the stream aborts the spawned algorithm task.
389    ///
390    /// Every invocation owns a separate [`Driver`].
391    fn run_stream(self: Arc<Self>, request: Request) -> StepStream {
392        let (driver, step_rx) = Driver::new(self.name());
393        let span = observability::run_span(self.name(), &request);
394        let handle = tokio::spawn(
395            async move {
396                let algorithm = self.name().to_string();
397                // Catch a panicking algorithm so the run still publishes a terminal step.
398                let route = AssertUnwindSafe(self.route(driver.clone(), request)).catch_unwind();
399                let result = observability::observe_run(&algorithm, async move {
400                    route.await.unwrap_or_else(|payload| {
401                        Err(LibsyError::AlgorithmError {
402                            message: format!(
403                                "algorithm task panicked: {}",
404                                panic_message(payload.as_ref())
405                            ),
406                        })
407                    })
408                })
409                .await;
410
411                let _ = driver.finish(result).await;
412            }
413            .instrument(span),
414        );
415        // Dropping the stream aborts the algorithm task when its consumer goes away.
416        let abort_guard = AbortOnDrop(handle.abort_handle());
417        Box::pin(ReceiverStream::new(step_rx).map(move |step| {
418            // link abort guard to stream
419            let _keep_alive = &abort_guard;
420            step
421        }))
422    }
423}
424
425#[cfg(test)]
426mod tests {
427    use std::collections::HashMap;
428
429    use super::*;
430    use crate::core::testing::{Serve, ServeResult, echo, reply, test_drive};
431    use futures::StreamExt;
432    use switchyard_protocol::{
433        LlmResponse, LlmResponseChunk, completion_text, text_request, text_response,
434    };
435
436    #[derive(Debug, thiserror::Error)]
437    #[error("{0}")]
438    struct TestError(&'static str);
439
440    fn test_error(message: &'static str) -> LibsyError {
441        LibsyError::external("test", TestError(message))
442    }
443
444    /// Trivial algo used only to exercise the orchestrator: calls the first target
445    /// and returns its response as the routing outcome.
446    struct TestAlgo {
447        target_set: Vec<ModelId>,
448    }
449
450    #[async_trait]
451    impl Algorithm for TestAlgo {
452        fn name(&self) -> &str {
453            "test"
454        }
455
456        async fn route(
457            self: Arc<Self>,
458            driver: Driver,
459            request: Request,
460        ) -> Result<RoutingOutcome> {
461            let target = self
462                .target_set
463                .first()
464                .ok_or(LibsyError::NoTargets)?
465                .clone();
466            let response = driver
467                .call_model(request.clone(), vec![target.clone()])
468                .await?;
469            Ok(RoutingOutcome::answered(target, request, response))
470        }
471    }
472
473    /// Build a shared `TestAlgo` over the given target set.
474    fn orch(target_set: Vec<ModelId>) -> Arc<dyn Algorithm> {
475        Arc::new(TestAlgo { target_set })
476    }
477
478    fn request() -> Request {
479        Request {
480            llm_request: text_request(Some("auto".to_string()), "hi".to_string()),
481            raw_request: None,
482            metadata: None,
483        }
484    }
485
486    #[test]
487    fn routing_outcome_constructors_stamp_selection_and_preserve_payloads() {
488        let outcome = RoutingOutcome::route_to(
489            "selected".into(),
490            target_set(&["fallback-one", "fallback-two"]),
491            request(),
492        );
493
494        assert_eq!(
495            outcome.selected_model_ids,
496            target_set(&["selected", "fallback-one", "fallback-two"])
497        );
498        assert_eq!(outcome.request.model_id().as_deref(), Some("selected"));
499        assert!(outcome.response.is_none());
500        assert!(outcome.metadata.is_none());
501
502        let outcome = RoutingOutcome::route_to("only".into(), Vec::new(), request());
503        assert_eq!(outcome.selected_model_ids, target_set(&["only"]));
504
505        let outcome = RoutingOutcome::answered(
506            "answered".into(),
507            request(),
508            Response {
509                llm_response: LlmResponse::Agg(text_response(None, "existing")),
510                metadata: None,
511            },
512        );
513
514        assert_eq!(outcome.selected_model_ids, target_set(&["answered"]));
515        assert_eq!(outcome.request.model_id().as_deref(), Some("answered"));
516        assert_eq!(
517            outcome
518                .response
519                .as_ref()
520                .and_then(|response| response.llm_response.as_agg())
521                .map(completion_text),
522            Some("existing".to_string())
523        );
524    }
525
526    fn target_set(names: &[&str]) -> Vec<ModelId> {
527        names.iter().map(|name| ModelId::from(*name)).collect()
528    }
529
530    #[tokio::test]
531    async fn typed_driver_preserves_call_and_stream_boundaries() -> Result<()> {
532        tokio::time::timeout(std::time::Duration::from_secs(1), async {
533            // Distinct oneshots keep reverse-order replies paired with their producers, and a
534            // retained call remains pending until the host responds.
535            let (driver, mut step_rx) = Driver::new("test");
536            let first_driver = driver.clone();
537            let mut first = tokio::spawn(async move {
538                first_driver
539                    .call_model(request(), vec![ModelId::from("first")])
540                    .await
541            });
542            let second = tokio::spawn(async move {
543                driver
544                    .call_model(request(), vec![ModelId::from("second")])
545                    .await
546            });
547
548            let mut calls = HashMap::new();
549            for _ in 0..2 {
550                let step = step_rx.recv().await.ok_or(DriverError::StreamClosed)??;
551                let Step::CallModel(call) = step else {
552                    return Err(test_error("expected a CallModel step"));
553                };
554                let selected_model = call
555                    .models
556                    .first()
557                    .ok_or_else(|| test_error("model call has no candidates"))?
558                    .to_string();
559                calls.insert(selected_model, call);
560            }
561            assert!(
562                tokio::time::timeout(std::time::Duration::from_millis(20), &mut first)
563                    .await
564                    .is_err(),
565                "call completed before the host responded"
566            );
567            calls
568                .remove("second")
569                .ok_or_else(|| test_error("missing second call"))?
570                .respond(Ok(reply("second response")))?;
571            calls
572                .remove("first")
573                .ok_or_else(|| test_error("missing first call"))?
574                .respond(Ok(reply("first response")))?;
575
576            let first_response = first
577                .await
578                .map_err(|source| LibsyError::external("joining a test task", source))??;
579            let second_response = second
580                .await
581                .map_err(|source| LibsyError::external("joining a test task", source))??;
582            assert_eq!(
583                first_response.llm_response.as_agg().map(completion_text),
584                Some("first response".to_string())
585            );
586            assert_eq!(
587                second_response.llm_response.as_agg().map(completion_text),
588                Some("second response".to_string())
589            );
590
591            // Dropping the host-facing promise closes only that call's reply channel.
592            let (driver, mut step_rx) = Driver::new("test");
593            let producer = tokio::spawn(async move {
594                driver
595                    .call_model(request(), vec![ModelId::from("dropped")])
596                    .await
597            });
598            let step = step_rx.recv().await.ok_or(DriverError::StreamClosed)??;
599            let Step::CallModel(call) = step else {
600                return Err(test_error("expected a CallModel step"));
601            };
602            drop(call);
603            let result = producer
604                .await
605                .map_err(|source| LibsyError::external("joining a test task", source))?;
606            assert!(matches!(
607                result,
608                Err(LibsyError::Driver(DriverError::ResponseDropped))
609            ));
610
611            // A standalone driver reports the typed step receiver disappearing at its next call.
612            let (driver, step_rx) = Driver::new("test");
613            drop(step_rx);
614            let result = driver
615                .call_model(request(), vec![ModelId::from("closed")])
616                .await;
617            assert!(matches!(
618                result,
619                Err(LibsyError::Driver(DriverError::StreamClosed))
620            ));
621            Ok(())
622        })
623        .await
624        .map_err(|error| LibsyError::external("waiting for typed driver boundaries", error))?
625    }
626
627    #[test]
628    fn target_lookup_returns_the_missing_target() {
629        let error = ensure_model_is_target(&target_set(&[]), &ModelId::from("missing")).err();
630        assert!(matches!(
631            error,
632            Some(LibsyError::TargetNotFound { target }) if target == "missing"
633        ));
634    }
635
636    /// Build a single-target algo, plus a `serve` that answers it as a token stream
637    /// replaying `chunks` in order (as `Ok` items).
638    fn streaming_orch(chunks: Vec<LlmResponseChunk>) -> (Arc<dyn Algorithm>, impl Serve) {
639        let algo = orch(target_set(&["stream/model"]));
640        let serve = move |_target: ModelId, _request: Request| {
641            let chunks = chunks.clone();
642            async move {
643                let stream =
644                    futures::stream::iter(chunks.into_iter().map(|chunk| Ok(chunk.into()))).boxed();
645                Ok(Response {
646                    llm_response: LlmResponse::Stream(stream),
647                    metadata: None,
648                })
649            }
650        };
651        (algo, serve)
652    }
653
654    #[tokio::test]
655    async fn run_returns_a_streamed_response_the_caller_aggregates() -> Result<()> {
656        // A streaming client -> its chunks flow through the promise and `Done`,
657        // and `run` returns the live stream untouched for the caller to fold.
658        let (orch, serve) = streaming_orch(vec![
659            LlmResponseChunk::MessageStart {
660                id: Some("m1".to_string()),
661                model: Some("stream/model".to_string()),
662            },
663            LlmResponseChunk::TextDelta {
664                index: 0,
665                text: "hel".to_string(),
666            },
667            LlmResponseChunk::TextDelta {
668                index: 0,
669                text: "lo".to_string(),
670            },
671            LlmResponseChunk::MessageStop {
672                reason: Some("stop".to_string()),
673            },
674        ]);
675        let (selected_model, response) = test_drive(orch, request(), serve).await?;
676        // The run handed back the live stream; the caller folds it to a buffered aggregate.
677        let agg = response
678            .llm_response
679            .into_agg()
680            .await
681            .map_err(|error| LibsyError::external("aggregating response stream", error))?;
682        assert_eq!(completion_text(&agg), "hello");
683        assert_eq!(agg.model.as_deref(), Some("stream/model"));
684        assert_eq!(selected_model, "stream/model");
685        Ok(())
686    }
687
688    #[tokio::test]
689    async fn aggregating_a_streamed_response_propagates_a_mid_stream_error() -> Result<()> {
690        // The run succeeds and returns the stream; the in-band `Error` chunk surfaces only
691        // when the caller aggregates it.
692        let (orch, serve) = streaming_orch(vec![
693            LlmResponseChunk::TextDelta {
694                index: 0,
695                text: "partial".to_string(),
696            },
697            LlmResponseChunk::StreamError {
698                message: "upstream exploded".to_string(),
699            },
700        ]);
701        let (_, response) = test_drive(orch, request(), serve).await?;
702        match response.llm_response.into_agg().await {
703            Ok(_) => panic!("expected a mid-stream error, got an aggregate"),
704            Err(err) => {
705                assert!(err.to_string().contains("upstream exploded"));
706                Ok(())
707            }
708        }
709    }
710
711    #[tokio::test]
712    async fn run_offloads_via_promise_then_finishes() -> Result<()> {
713        // Every call is offloaded via a promise the orchestrator surfaces as a
714        // `CallModel` step for us to fulfill.
715        let stream = orch(target_set(&["offload/model"])).run_stream(request());
716        tokio::pin!(stream);
717
718        let mut saw_call = false;
719        let mut final_completion = None;
720        while let Some(step) = stream.next().await {
721            match step? {
722                Step::CallModel(call) => {
723                    saw_call = true;
724                    assert_eq!(call.models, vec![ModelId::from("offload/model")]);
725                    // Fulfilling the promise is the "real" model call the caller makes.
726                    call.respond(Ok(Response {
727                        llm_response: LlmResponse::Agg(text_response(
728                            None,
729                            "fulfilled".to_string(),
730                        )),
731                        metadata: None,
732                    }))?;
733                }
734                Step::Done(outcome) => {
735                    let metadata = outcome
736                        .metadata
737                        .as_ref()
738                        .expect("run_stream should attach outcome metadata");
739                    assert_eq!(metadata.algorithm, "test");
740                    assert_eq!(
741                        uuid::Uuid::parse_str(metadata.outcome_id())
742                            .expect("outcome id should be a UUID")
743                            .get_version_num(),
744                        7
745                    );
746                    assert!(metadata.evidence.is_none());
747                    let response = outcome
748                        .response
749                        .ok_or_else(|| test_error("expected an answered outcome"))?;
750                    final_completion = Some(
751                        response
752                            .llm_response
753                            .as_agg()
754                            .map(completion_text)
755                            .unwrap_or_default(),
756                    );
757                }
758            }
759        }
760
761        assert!(saw_call, "expected a CallModel step before Done");
762        assert_eq!(
763            final_completion.ok_or_else(|| test_error("no Done step"))?,
764            "fulfilled"
765        );
766        Ok(())
767    }
768
769    #[tokio::test(flavor = "multi_thread", worker_threads = 12)]
770    async fn requests_are_processed_in_parallel() -> Result<()> {
771        use std::time::Duration;
772        use tokio::sync::Barrier;
773
774        const N: usize = 12;
775
776        // Serving blocks until all N concurrent calls have arrived. If requests were
777        // serialized (one algorithm behind a `Mutex`), only one call could be in flight,
778        // the barrier would never reach N, and the test would time out. It passes only
779        // because the shared algorithm is driven concurrently across requests.
780        let barrier = Arc::new(Barrier::new(N));
781        // One shared algorithm driven by many concurrent requests.
782        let algo = orch(target_set(&["m"]));
783
784        let mut handles = Vec::new();
785        for _ in 0..N {
786            let algo = algo.clone();
787            let barrier = barrier.clone();
788            let serve = move |target: ModelId, _request: Request| {
789                let barrier = barrier.clone();
790                async move {
791                    barrier.wait().await;
792                    Ok(reply(target))
793                }
794            };
795            handles.push(tokio::spawn(async move {
796                test_drive(algo, request(), serve)
797                    .await
798                    .map(|(_, response)| {
799                        response
800                            .llm_response
801                            .as_agg()
802                            .map(completion_text)
803                            .unwrap_or_default()
804                    })
805            }));
806        }
807
808        for handle in handles {
809            // The timeout turns a serialization deadlock into a failure, not a hang.
810            let completion = tokio::time::timeout(Duration::from_secs(5), handle)
811                .await
812                .map_err(|error| LibsyError::external("waiting for test task", error))?
813                .map_err(|source| LibsyError::external("joining a test task", source))??;
814            assert_eq!(completion, "m");
815        }
816        Ok(())
817    }
818
819    #[tokio::test]
820    async fn offload_error_propagates_back_to_the_algorithm() -> Result<()> {
821        // A client-less target offloads its call; we fulfill the promise with an
822        // Err, which must flow back through `call_model_target` into the algorithm and
823        // out as an error step — not a response.
824        let stream = orch(target_set(&["offload/model"])).run_stream(request());
825        tokio::pin!(stream);
826
827        let mut saw_error = false;
828        while let Some(step) = stream.next().await {
829            match step {
830                Ok(Step::CallModel(call)) => {
831                    call.respond(Err(test_error("upstream model call failed")))?;
832                }
833                Ok(Step::Done(..)) => {
834                    return Err(test_error(
835                        "expected the offload error to propagate, got a response",
836                    ));
837                }
838                Err(err) => {
839                    // The algorithm's `call_model_target` saw the error via the promise.
840                    assert!(err.to_string().contains("upstream model call failed"));
841                    saw_error = true;
842                }
843            }
844        }
845
846        assert!(saw_error, "expected an error step");
847        Ok(())
848    }
849
850    #[tokio::test]
851    async fn dropping_the_stream_cancels_the_algorithm_task() -> Result<()> {
852        use std::sync::atomic::{AtomicBool, Ordering};
853        use std::time::Duration;
854        use tokio::sync::mpsc;
855
856        // Sets a flag when dropped, so we can observe whether the algorithm task was
857        // cancelled/dropped.
858        struct DropGuard(Arc<AtomicBool>);
859        impl Drop for DropGuard {
860            fn drop(&mut self) {
861                self.0.store(true, Ordering::SeqCst);
862            }
863        }
864
865        struct StuckAlgo {
866            started: mpsc::UnboundedSender<()>,
867            dropped: Arc<AtomicBool>,
868        }
869
870        #[async_trait]
871        impl Algorithm for StuckAlgo {
872            fn name(&self) -> &str {
873                "stuck"
874            }
875
876            async fn route(
877                self: Arc<Self>,
878                _driver: Driver,
879                _request: Request,
880            ) -> Result<RoutingOutcome> {
881                let _guard = DropGuard(self.dropped.clone());
882                let _ = self.started.send(());
883                // Await forever without ever touching the driver.
884                std::future::pending::<()>().await;
885                unreachable!()
886            }
887        }
888
889        let (started_tx, mut started_rx) = mpsc::unbounded_channel();
890        let dropped = Arc::new(AtomicBool::new(false));
891        let algo: Arc<dyn Algorithm> = Arc::new(StuckAlgo {
892            started: started_tx,
893            dropped: dropped.clone(),
894        });
895
896        let stream = algo.run_stream(request());
897        started_rx
898            .recv()
899            .await
900            .ok_or_else(|| test_error("task never started"))?;
901        drop(stream);
902        tokio::time::sleep(Duration::from_millis(100)).await;
903
904        assert!(
905            dropped.load(Ordering::SeqCst),
906            "algorithm task was NOT cancelled after dropping the stream"
907        );
908        Ok(())
909    }
910
911    #[tokio::test]
912    async fn route_panic_surfaces_as_a_stream_error() -> Result<()> {
913        // An algorithm whose task panics must surface an `Err` step carrying the panic
914        // message, not abort the process from an unobserved detached task.
915        struct Panicky;
916
917        #[async_trait]
918        impl Algorithm for Panicky {
919            fn name(&self) -> &str {
920                "panicky"
921            }
922
923            async fn route(
924                self: Arc<Self>,
925                _driver: Driver,
926                _request: Request,
927            ) -> Result<RoutingOutcome> {
928                panic!("boom");
929            }
930        }
931
932        let algo: Arc<dyn Algorithm> = Arc::new(Panicky);
933        let stream = algo.run_stream(request());
934        tokio::pin!(stream);
935
936        let mut saw_error = false;
937        while let Some(step) = stream.next().await {
938            match step {
939                Err(err) => {
940                    // The panic message is preserved, not flattened into an opaque failure.
941                    assert!(err.to_string().contains("algorithm task panicked: boom"));
942                    saw_error = true;
943                }
944                Ok(_) => return Err(test_error("expected the panic to surface as an error step")),
945            }
946        }
947
948        assert!(saw_error, "expected an error step from the panicked task");
949        Ok(())
950    }
951
952    /// A panicking algorithm must publish its terminal step even when it left a `Driver`
953    /// clone alive in another task. That clone holds the step channel open, so a run that
954    /// merely unwound would never terminate and the consumer would wait forever.
955    #[tokio::test]
956    async fn a_panic_with_a_leaked_driver_clone_still_terminates_the_run() -> Result<()> {
957        struct LeakyPanic;
958
959        #[async_trait]
960        impl Algorithm for LeakyPanic {
961            fn name(&self) -> &str {
962                "leaky_panic"
963            }
964
965            async fn route(
966                self: Arc<Self>,
967                driver: Driver,
968                _request: Request,
969            ) -> Result<RoutingOutcome> {
970                tokio::spawn(async move {
971                    // Outlives the panic below, keeping a sender clone alive.
972                    let _keep_alive = driver;
973                    std::future::pending::<()>().await;
974                });
975                tokio::task::yield_now().await;
976                panic!("boom");
977            }
978        }
979
980        let algo: Arc<dyn Algorithm> = Arc::new(LeakyPanic);
981        // The timeout turns the hang this guards against into a failure rather than a hang.
982        let result = tokio::time::timeout(
983            std::time::Duration::from_secs(1),
984            test_drive(algo, request(), echo()),
985        )
986        .await
987        .map_err(|error| LibsyError::external("waiting for the panicked run to end", error))?;
988
989        match result {
990            Ok(_) => Err(test_error(
991                "expected the panic to end the run with an error",
992            )),
993            Err(err) => {
994                assert!(err.to_string().contains("algorithm task panicked: boom"));
995                Ok(())
996            }
997        }
998    }
999
1000    #[tokio::test]
1001    async fn cancelling_run_cancels_the_algorithm_task() -> Result<()> {
1002        use std::sync::atomic::{AtomicBool, Ordering};
1003        use std::time::Duration;
1004        use tokio::sync::mpsc;
1005
1006        // Sets a flag when dropped, so we can observe whether the algorithm task was
1007        // cancelled once the `run` future driving it is dropped.
1008        struct DropGuard(Arc<AtomicBool>);
1009        impl Drop for DropGuard {
1010            fn drop(&mut self) {
1011                self.0.store(true, Ordering::SeqCst);
1012            }
1013        }
1014
1015        struct StuckAlgo {
1016            started: mpsc::UnboundedSender<()>,
1017            dropped: Arc<AtomicBool>,
1018        }
1019
1020        #[async_trait]
1021        impl Algorithm for StuckAlgo {
1022            fn name(&self) -> &str {
1023                "stuck"
1024            }
1025
1026            async fn route(
1027                self: Arc<Self>,
1028                _driver: Driver,
1029                _request: Request,
1030            ) -> Result<RoutingOutcome> {
1031                let _guard = DropGuard(self.dropped.clone());
1032                let _ = self.started.send(());
1033                // Hang forever without ever touching the driver, so only cancellation
1034                // (not a dropped step channel) can stop this task.
1035                std::future::pending::<()>().await;
1036                unreachable!()
1037            }
1038        }
1039
1040        let (started_tx, mut started_rx) = mpsc::unbounded_channel();
1041        let dropped = Arc::new(AtomicBool::new(false));
1042        let algo: Arc<dyn Algorithm> = Arc::new(StuckAlgo {
1043            started: started_tx,
1044            dropped: dropped.clone(),
1045        });
1046
1047        // Drive the run on its own task, wait until the algorithm task is up, then cancel
1048        // it — dropping its future (and the `run_stream` stream it holds).
1049        let run_task = tokio::spawn(async move { test_drive(algo, request(), echo()).await });
1050        started_rx
1051            .recv()
1052            .await
1053            .ok_or_else(|| test_error("task never started"))?;
1054        run_task.abort();
1055        tokio::time::sleep(Duration::from_millis(100)).await;
1056
1057        assert!(
1058            dropped.load(Ordering::SeqCst),
1059            "algorithm task was NOT cancelled after cancelling run"
1060        );
1061        Ok(())
1062    }
1063
1064    // --- first-wins hedging: `run` must not wait on losing speculative calls -------------
1065
1066    /// Offloads two targets concurrently and returns the first to resolve, dropping the
1067    /// loser's call (first-wins hedging).
1068    struct Hedge {
1069        winner: String,
1070        loser: String,
1071    }
1072
1073    #[async_trait]
1074    impl Algorithm for Hedge {
1075        fn name(&self) -> &str {
1076            "hedge"
1077        }
1078
1079        async fn route(
1080            self: Arc<Self>,
1081            driver: Driver,
1082            request: Request,
1083        ) -> Result<RoutingOutcome> {
1084            let outcome_request = request.clone();
1085            let win = driver.call_model(request.clone(), vec![self.winner.clone().into()]);
1086            let lose = driver.call_model(request, vec![self.loser.clone().into()]);
1087            // First to resolve wins; `select!` drops the losing future (and its promise).
1088            tokio::select! {
1089                res = win => Ok(RoutingOutcome::answered(
1090                    self.winner.clone().into(),
1091                    outcome_request,
1092                    res?,
1093                )),
1094                res = lose => Ok(RoutingOutcome::answered(
1095                    self.loser.clone().into(),
1096                    outcome_request,
1097                    res?,
1098                )),
1099            }
1100        }
1101    }
1102
1103    /// Builds a hedging algo and the `serve` that drives it: the winner is gated behind the
1104    /// loser starting (so the loser's serve is guaranteed in flight when the winner wins),
1105    /// and the loser finishes after `loser_delay` — or never, when `None`.
1106    fn hedge(loser_delay: Option<std::time::Duration>) -> (Arc<dyn Algorithm>, impl Serve) {
1107        let started = Arc::new(tokio::sync::Notify::new());
1108        let algo = Arc::new(Hedge {
1109            winner: "winner".to_string(),
1110            loser: "loser".to_string(),
1111        });
1112        let serve = move |target: ModelId, _request: Request| {
1113            let started = started.clone();
1114            async move {
1115                if target == "loser" {
1116                    started.notify_one();
1117                    match loser_delay {
1118                        Some(delay) => tokio::time::sleep(delay).await,
1119                        None => std::future::pending::<()>().await,
1120                    }
1121                } else {
1122                    started.notified().await;
1123                }
1124                Ok(reply(target))
1125            }
1126        };
1127        (algo, serve)
1128    }
1129
1130    #[tokio::test]
1131    async fn run_returns_the_winner_without_a_late_loser_overwriting_it() -> Result<()> {
1132        // The loser responds 50ms after the winner has already won. `run` must return the
1133        // winner, not the loser's `respond`-to-a-dropped-receiver error.
1134        let (algo, serve) = hedge(Some(std::time::Duration::from_millis(50)));
1135        let (_, response) = test_drive(algo, request(), serve).await?;
1136        assert_eq!(
1137            response
1138                .llm_response
1139                .as_agg()
1140                .map(completion_text)
1141                .unwrap_or_default(),
1142            "winner"
1143        );
1144        Ok(())
1145    }
1146
1147    #[tokio::test]
1148    async fn run_returns_the_winner_without_hanging_on_a_pending_loser() -> Result<()> {
1149        // The loser never resolves. `run` must return the winner promptly, not hang
1150        // waiting for the in-flight loser.
1151        let (algo, serve) = hedge(None);
1152        let run = test_drive(algo, request(), serve);
1153        let (_, response) = tokio::time::timeout(std::time::Duration::from_secs(1), run)
1154            .await
1155            .map_err(|error| LibsyError::external("waiting for pending loser", error))??;
1156        assert_eq!(
1157            response
1158                .llm_response
1159                .as_agg()
1160                .map(completion_text)
1161                .unwrap_or_default(),
1162            "winner"
1163        );
1164        Ok(())
1165    }
1166
1167    #[tokio::test]
1168    async fn run_surfaces_a_terminal_error_with_many_calls_in_flight() -> Result<()> {
1169        use std::sync::atomic::{AtomicUsize, Ordering};
1170
1171        // A large fan-out (10 matched the old, now-removed concurrency cap). The terminal
1172        // error must still reach the caller with all of these calls pending.
1173        const N: usize = 10;
1174
1175        // Fans out N calls, then errors as soon as all N are in flight — exercising a
1176        // terminal failure emitted while the offloaded calls are still pending.
1177        struct FanOutThenError {
1178            all_started: Arc<tokio::sync::Notify>,
1179            n: usize,
1180        }
1181
1182        #[async_trait]
1183        impl Algorithm for FanOutThenError {
1184            fn name(&self) -> &str {
1185                "fan_out_then_error"
1186            }
1187
1188            async fn route(
1189                self: Arc<Self>,
1190                driver: Driver,
1191                request: Request,
1192            ) -> Result<RoutingOutcome> {
1193                let offloads = futures::future::join_all(
1194                    (0..self.n)
1195                        .map(|i| driver.call_model(request.clone(), vec![format!("m{i}").into()])),
1196                );
1197                tokio::select! {
1198                    _ = offloads => Err(test_error("offloads unexpectedly completed")),
1199                    _ = self.all_started.notified() => {
1200                        Err(test_error("terminal error while calls pending"))
1201                    }
1202                }
1203            }
1204        }
1205
1206        let all_started = Arc::new(tokio::sync::Notify::new());
1207        let algo: Arc<dyn Algorithm> = Arc::new(FanOutThenError {
1208            all_started: all_started.clone(),
1209            n: N,
1210        });
1211
1212        // Serving enters each call; once all N are in flight it signals, then pends forever.
1213        let started = Arc::new(AtomicUsize::new(0));
1214        let serve = move |_target: ModelId, _request: Request| {
1215            let started = started.clone();
1216            let all_started = all_started.clone();
1217            async move {
1218                if started.fetch_add(1, Ordering::SeqCst) + 1 == N {
1219                    all_started.notify_one();
1220                }
1221                std::future::pending::<ServeResult>().await
1222            }
1223        };
1224
1225        // With the cap gone, the driver keeps polling the stream even with N calls in
1226        // flight, so the terminal error surfaces promptly instead of hanging.
1227        let run = test_drive(algo, request(), serve);
1228        let result = tokio::time::timeout(std::time::Duration::from_millis(500), run)
1229            .await
1230            .map_err(|error| {
1231                LibsyError::external("waiting for terminal error with full call cap", error)
1232            })?;
1233        match result {
1234            Ok(_) => Err(test_error("expected the terminal error, got a response")),
1235            Err(err) => {
1236                assert!(
1237                    err.to_string()
1238                        .contains("terminal error while calls pending")
1239                );
1240                Ok(())
1241            }
1242        }
1243    }
1244}