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