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