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//! Routing algorithms request external work through [`Driver`] and receive typed replies.
5//! Hosts execute that work, keeping routing policy independent of transport.
6
7use std::{
8    collections::HashMap, future::Future, panic::AssertUnwindSafe, pin::Pin, sync::Arc,
9    time::Instant,
10};
11
12use async_trait::async_trait;
13use futures::{FutureExt, Stream, StreamExt};
14use parking_lot::Mutex;
15use serde_json::Value;
16use tokio::sync::{mpsc, oneshot};
17use tokio_stream::wrappers::ReceiverStream;
18use tracing::Instrument;
19
20/// The request/response protocol types come from [`switchyard_protocol`].
21/// [`switchyard_protocol::LlmRequest`] is the normalized request;
22/// [`switchyard_protocol::AggLlmResponse`] is the buffered response;
23/// [`switchyard_protocol::LlmResponseChunk`] is normalized streaming content;
24/// [`switchyard_protocol::LlmResponseStreamEvent`] is its host/algorithm envelope; and
25/// [`switchyard_protocol::LlmResponse`] carries either a live
26/// [`switchyard_protocol::LlmResponseStream`] or the terminal aggregate.
27use switchyard_protocol::{
28    Category, DecisionRequest, DecisionResponse, ModelId, Request, Response,
29};
30
31use crate::{DriverError, LibsyError, Result, observability};
32
33/// A boxed, `Send` stream of [`Step`]s — the output of
34/// [`Algorithm::run_stream`]. Boxed so the trait method that produces it keeps
35/// `Arc<dyn Algorithm>` object-safe.
36pub type StepStream = Pin<Box<dyn Stream<Item = Result<Step>> + Send>>;
37
38/// The models one algorithm run may use, grouped by [`Category`]. Within a
39/// category they are ordered best-first.
40///
41/// Delegated sub-agent work gets its own groups, reachable only through
42/// [`Driver::for_subagent`]. Keeping them separate is what stops a sub-agent's
43/// `capable` from resolving to the parent's, and stops the parent falling back
44/// onto a model only its sub-agents were given.
45///
46/// One run's driver clones all read the same value, so it is passed as
47/// `Arc<RuntimeModels>` rather than cloned per driver.
48#[derive(Clone, Debug, Default)]
49pub struct RuntimeModels {
50    /// When using subagents this is the parent agent category.
51    by_category: HashMap<Category, Vec<ModelId>>,
52    subagent: Option<HashMap<Category, Vec<ModelId>>>,
53}
54
55impl RuntimeModels {
56    /// The models available to the algorithm itself.
57    pub fn new(by_category: HashMap<Category, Vec<ModelId>>) -> Self {
58        Self {
59            by_category,
60            subagent: None,
61        }
62    }
63
64    /// Adds the groups used for delegated sub-agent work.
65    pub fn with_subagent(mut self, models: HashMap<Category, Vec<ModelId>>) -> Self {
66        self.subagent = Some(models);
67        self
68    }
69
70    /// The models in `category`, ordered best-first.
71    pub fn models_for(&self, category: &Category) -> &[ModelId] {
72        self.by_category.get(category).map_or(&[], Vec::as_slice)
73    }
74
75    /// The models delegated sub-agent work uses for `category`, ordered best-first.
76    pub fn subagent_models_for(&self, category: &Category) -> &[ModelId] {
77        self.subagent
78            .as_ref()
79            .and_then(|models| models.get(category))
80            .map_or(&[], Vec::as_slice)
81    }
82}
83
84impl From<HashMap<Category, Vec<ModelId>>> for RuntimeModels {
85    fn from(by_category: HashMap<Category, Vec<ModelId>>) -> Self {
86        Self::new(by_category)
87    }
88}
89
90/// Which of a [`RuntimeModels`]' groups a driver reads.
91#[derive(Clone, Copy)]
92enum Scope {
93    Parent,
94    Subagent,
95}
96
97/// An offloaded model call, surfaced inside [`Step::CallModel`].
98///
99/// The host reads the public fields, performs (or delegates) the model call, and fulfills it
100/// with [`respond`](Self::respond) — unblocking the algorithm's [`Driver::call_model`] on the
101/// other side. `switchyard-llm-client`'s `run` is the ready-made consumer that does this for
102/// you.
103///
104/// [`Driver::call_model`] stamps the first candidate model onto the request before publishing
105/// the call. A consumer that falls through to a later candidate must re-stamp it.
106pub struct CallModel {
107    /// The name of the algorithm that produced this call, so a host instrumenting the
108    /// calls it serves can attribute its own spans to the algorithm behind them.
109    pub algorithm: String,
110    /// The request to serve; its `model` is stamped with the first candidate.
111    pub request: Request,
112    /// Candidate models, tried in order until one answers. Never empty.
113    pub models: Vec<ModelId>,
114    /// Return client errors to the algorithm so it can apply its fallback policy.
115    pub recover_errors: bool,
116    /// How to send the response back to the algorithm. `None` once the call is recorded.
117    reply: Option<oneshot::Sender<Result<Response>>>,
118    started: Instant,
119    // Retain the originating span even if the waiting algorithm is cancelled first.
120    span: tracing::Span,
121}
122
123impl CallModel {
124    /// Fulfill the promise with the caller's model-call result. Pass `Err(..)` to
125    /// propagate a failed model call back to the algorithm. Consumes the promise: it
126    /// can only be fulfilled once.
127    pub fn respond(mut self, result: Result<Response>) -> Result<()> {
128        self.record(result.is_ok());
129        if let Ok(response) = &result {
130            observability::record_llm_response(response, &self.span);
131        }
132        self.reply
133            .take()
134            .ok_or(DriverError::ResponseDropped)?
135            .send(result)
136            .map_err(|_| DriverError::ResponseDropped.into())
137    }
138
139    /// Record a failed call and return its error to stop [`drive`].
140    /// Leaves the promise unfulfilled so the driver can cancel the algorithm.
141    pub fn fail(mut self, error: LibsyError) -> Result<()> {
142        self.reply = None;
143        self.record(false);
144        Err(error)
145    }
146
147    fn record(&self, is_ok: bool) {
148        observability::record_llm_call(
149            &self.algorithm,
150            self.models
151                .first()
152                .map(ModelId::as_str)
153                .unwrap_or("NoTargets"),
154            self.started.elapsed(),
155            is_ok,
156        );
157        self.span
158            .record("outcome", if is_ok { "ok" } else { "error" });
159    }
160}
161
162impl Drop for CallModel {
163    fn drop(&mut self) {
164        if self.reply.is_some() {
165            self.record(false);
166        }
167    }
168}
169
170/// A host-owned decision call. Dropping it without replying yields [`DriverError::ResponseDropped`].
171/// Completion or drop records one call and its duration, including time waiting for the host.
172pub struct CallDecision {
173    /// Algorithm name for attributing host telemetry.
174    pub algorithm: String,
175    /// Its model is set to [`Self::model`] before dispatch.
176    pub request: DecisionRequest,
177    /// Target ID for client lookup.
178    pub model: ModelId,
179    reply: Option<oneshot::Sender<Result<DecisionResponse>>>,
180    started: Instant,
181    // Retain the originating span even if the waiting algorithm is cancelled first.
182    span: tracing::Span,
183}
184
185impl CallDecision {
186    /// Return a response or provider error to the algorithm so it can continue or fall back.
187    pub fn respond(mut self, result: Result<DecisionResponse>) -> Result<()> {
188        self.record(result.is_ok());
189        if let Ok(response) = &result {
190            observability::record_decision_response(response, &self.span);
191        }
192        self.reply
193            .take()
194            .ok_or(DriverError::ResponseDropped)?
195            .send(result)
196            .map_err(|_| DriverError::ResponseDropped.into())
197    }
198
199    /// Returning this error from the host handler aborts [`drive`].
200    pub fn fail(mut self, error: LibsyError) -> Result<()> {
201        self.reply = None;
202        self.record(false);
203        Err(error)
204    }
205
206    fn record(&self, is_ok: bool) {
207        observability::record_decision_call(
208            &self.algorithm,
209            &self.model,
210            self.started.elapsed(),
211            is_ok,
212        );
213        self.span
214            .record("outcome", if is_ok { "ok" } else { "error" });
215    }
216}
217
218impl Drop for CallDecision {
219    fn drop(&mut self) {
220        if self.reply.is_some() {
221            self.record(false);
222        }
223    }
224}
225
226/// The terminal result of routing.
227pub struct RoutingOutcome {
228    /// Models selected by the algorithm, ordered best model first.
229    pub selected_model_ids: Vec<ModelId>,
230    /// The request after all routing-time rewrites, stamped with the selected model.
231    pub request: Request,
232    /// A response produced while routing, or `None` when the client must make the answer call.
233    pub response: Option<Response>,
234    /// Outcome identity and optional algorithm evidence.
235    ///
236    /// Constructors leave this empty; [`Algorithm::run_stream`] fills it before publishing a
237    /// successful outcome.
238    pub metadata: Option<crate::OutcomeMetadata>,
239}
240
241impl RoutingOutcome {
242    /// The model the algorithm recommends, the best model for this request.
243    /// `LibsyError::NoTargets` if the algorithm selected no models, which should be impossible.
244    pub fn selected_model_id(&self) -> Result<&ModelId> {
245        self.selected_model_ids.first().ok_or(LibsyError::NoTargets)
246    }
247
248    /// The decision is that client should send this `request`. The `selected_model_id`
249    /// will be written into it by this function.
250    /// If that fails client should try the `fallback_models` in order.
251    pub fn route_to(
252        selected_model_id: ModelId,
253        fallback_models: Vec<ModelId>,
254        mut request: Request,
255    ) -> Self {
256        request.llm_request.model = Some(selected_model_id.to_string());
257        let mut selected_model_ids = Vec::with_capacity(1 + fallback_models.len());
258        selected_model_ids.push(selected_model_id);
259        selected_model_ids.extend(fallback_models);
260        Self {
261            selected_model_ids,
262            request,
263            response: None,
264            metadata: None,
265        }
266    }
267
268    /// Algorithm generated the response as part of the routing decision. Here it is.
269    /// The `request` will have the `selected_model_id` written into it by this function.
270    pub fn answered(selected_model_id: ModelId, mut request: Request, response: Response) -> Self {
271        request.llm_request.model = Some(selected_model_id.to_string());
272        Self {
273            selected_model_ids: vec![selected_model_id],
274            request,
275            response: Some(response),
276            metadata: None,
277        }
278    }
279}
280
281/// An algorithm's handle for requesting external work and awaiting the host's typed replies.
282#[derive(Clone)]
283pub struct Driver {
284    step_tx: mpsc::Sender<Result<Step>>,
285
286    /// The owning algorithm's telemetry label, stamped onto every call this driver publishes.
287    algorithm: String,
288
289    /// Run-scoped evidence shared by driver clones and attached only to a successful outcome.
290    evidence: Arc<Mutex<Option<Value>>>,
291
292    /// Every group this run may route over, shared by all driver clones.
293    models: Arc<RuntimeModels>,
294
295    /// Which of those groups this driver reads.
296    scope: Scope,
297}
298
299impl Driver {
300    /// Build an empty driver with its step channel ready. Created per call by
301    /// [`run_stream`](Algorithm::run_stream). Also returns the Step receiver.
302    pub(crate) fn new(
303        algorithm: &str,
304        models: Arc<RuntimeModels>,
305    ) -> (Self, mpsc::Receiver<Result<Step>>) {
306        // Capacity one keeps the algorithm paced by the stream consumer. It limits queued steps,
307        // not model calls already pulled from the stream, which can still run at the same time.
308        // A larger buffer would use more memory and let the algorithm run farther ahead with
309        // little benefit because reading a step is cheap compared with serving a model call.
310        let (step_tx, step_rx) = mpsc::channel(1);
311        (
312            Self {
313                step_tx,
314                algorithm: algorithm.to_string(),
315                evidence: Arc::new(Mutex::new(None)),
316                models,
317                scope: Scope::Parent,
318            },
319            step_rx,
320        )
321    }
322
323    /// Replace the current run's evidence when a component makes the final decision.
324    pub(crate) fn set_evidence(&self, evidence: Value) {
325        *self.evidence.lock() = Some(evidence);
326    }
327
328    /// Supply fallback evidence without replacing a decision made earlier in the cascade.
329    pub(crate) fn set_evidence_if_empty(&self, evidence: Value) {
330        let mut current = self.evidence.lock();
331        if current.is_none() {
332            *current = Some(evidence);
333        }
334    }
335
336    /// Publish a model call and await the consumer's response.
337    ///
338    /// Errors if the stream is closed or the call failed.
339    /// The await is wrapped in a `libsy.llm_call` span measuring *fulfillment* as
340    /// the algorithm observes it (host queueing/serving included; a streamed
341    /// response resolves when its stream handle arrives). The host records call metrics,
342    /// outcome, and buffered token usage when it completes or drops the call, even if the
343    /// waiting algorithm has been cancelled. The provider call itself is the
344    /// host's, and is instrumented by whoever makes it.
345    pub async fn call_model(&self, request: Request, models: Vec<ModelId>) -> Result<Response> {
346        self.call_model_with_error_recovery(request, models, false)
347            .await
348    }
349
350    /// Allows a routing policy to handle a failed call when recovery is enabled.
351    #[tracing::instrument(
352        target = "libsy",
353        name = "libsy.llm_call",
354        skip_all,
355        fields(
356            algorithm = self.algorithm,
357            selected_model = %models.first().map(ModelId::as_str).unwrap_or("NoTargets"),
358            openinference.span.kind = "CHAIN",
359            outcome = tracing::field::Empty,
360            input_tokens = tracing::field::Empty,
361            output_tokens = tracing::field::Empty,
362            total_tokens = tracing::field::Empty,
363            reasoning_tokens = tracing::field::Empty,
364        )
365    )]
366    pub(crate) async fn call_model_with_error_recovery(
367        &self,
368        mut request: Request,
369        models: Vec<ModelId>,
370        recover_errors: bool,
371    ) -> Result<Response> {
372        let Some(selected_model_id) = models.first() else {
373            return Err(LibsyError::NoTargets);
374        };
375        request.llm_request.model = Some(selected_model_id.to_string());
376        let started = Instant::now();
377        let (reply, response) = oneshot::channel::<Result<Response>>();
378        let call = CallModel {
379            algorithm: self.algorithm.clone(),
380            request,
381            models,
382            recover_errors,
383            reply: Some(reply),
384            started,
385            span: tracing::Span::current(),
386        };
387        self.step_tx
388            .send(Ok(Step::CallModel(Box::new(call))))
389            .await
390            .map_err(|_| DriverError::StreamClosed)?;
391        response
392            .await
393            .map_err(|_| LibsyError::from(DriverError::ResponseDropped))?
394    }
395
396    /// Override the request's model with `model` and wait for the host to reply.
397    #[tracing::instrument(
398        target = "libsy",
399        name = "libsy.decision_call",
400        skip_all,
401        fields(
402            algorithm = self.algorithm,
403            selected_model = %model,
404            openinference.span.kind = "CHAIN",
405            outcome = tracing::field::Empty,
406            input_tokens = tracing::field::Empty,
407            output_tokens = tracing::field::Empty,
408            total_tokens = tracing::field::Empty,
409            reasoning_tokens = tracing::field::Empty,
410            gen_ai.response.id = tracing::field::Empty,
411            gen_ai.response.model = tracing::field::Empty,
412        ),
413    )]
414    pub async fn call_decision(
415        &self,
416        mut request: DecisionRequest,
417        model: ModelId,
418    ) -> Result<DecisionResponse> {
419        request.model = Some(model.clone());
420        let started = Instant::now();
421        let (reply, response) = oneshot::channel();
422        let call = CallDecision {
423            algorithm: self.algorithm.clone(),
424            request,
425            model,
426            reply: Some(reply),
427            started,
428            span: tracing::Span::current(),
429        };
430        self.step_tx
431            .send(Ok(Step::CallDecision(Box::new(call))))
432            .await
433            .map_err(|_| DriverError::StreamClosed)?;
434        response
435            .await
436            .map_err(|_| LibsyError::from(DriverError::ResponseDropped))?
437    }
438
439    /// The available models for this category, typically ordered best-first.
440    pub fn models_for(&self, category: &Category) -> &[ModelId] {
441        match self.scope {
442            Scope::Parent => self.models.models_for(category),
443            Scope::Subagent => self.models.subagent_models_for(category),
444        }
445    }
446
447    /// The first available model for `category`.
448    pub fn first_model_for(&self, category: &Category) -> Result<&ModelId> {
449        self.models_for(category)
450            .first()
451            .ok_or_else(|| LibsyError::AlgorithmError {
452                message: format!("no models available for category {}", category.as_str()),
453            })
454    }
455
456    /// A driver scoped to delegated sub-agent work: its categories are the
457    /// sub-agent's own, and the parent's are no longer reachable through it.
458    pub fn for_subagent(&self) -> Result<Self> {
459        if self.models.subagent.is_none() {
460            return Err(LibsyError::AlgorithmError {
461                message: "delegated work has no sub-agent models".to_string(),
462            });
463        }
464        Ok(Self {
465            scope: Scope::Subagent,
466            ..self.clone()
467        })
468    }
469
470    /// Emit the terminal step: [`Step::Done`] on `Ok`, or an `Err` stream
471    /// item on failure. Internal: called once by [`run_stream`](Algorithm::run_stream)
472    /// when the algorithm finishes.
473    pub(crate) async fn finish(&self, result: Result<RoutingOutcome>) -> Result<()> {
474        let result = result.map(|mut outcome| {
475            let metadata = outcome.metadata.get_or_insert_with(|| {
476                crate::OutcomeMetadata::new(self.algorithm.clone(), self.evidence.lock().take())
477            });
478            observability::record_outcome(metadata, &outcome.selected_model_ids);
479            outcome
480        });
481        let selected_model = result
482            .as_ref()
483            .ok()
484            .and_then(|outcome| outcome.selected_model_id().ok().cloned());
485        let step = result.map(|outcome| Step::Done(Box::new(outcome)));
486        self.step_tx
487            .send(step)
488            .await
489            .map_err(|_| DriverError::StreamClosed)?;
490        if let Some(selected_model) = selected_model {
491            observability::record_decision(&self.algorithm, &selected_model);
492        }
493        Ok(())
494    }
495}
496
497/// Work requests and the terminal outcome of [`Algorithm::run_stream`].
498/// Each call carries its own reply channel, so the host can execute calls concurrently.
499pub enum Step {
500    /// The algorithm needs this model call performed. The host serves it and fulfills
501    /// it with [`CallModel::respond`]. Boxed: it is by far the largest variant.
502    CallModel(Box<CallModel>),
503    /// The host fulfills this decision call with [`CallDecision::respond`].
504    CallDecision(Box<CallDecision>),
505    /// The algorithm finished with its routing outcome — the last step of a run.
506    Done(Box<RoutingOutcome>),
507}
508
509/// Work dispatched through one host handler, with a typed reply channel per variant.
510/// [`drive`] handles terminal outcomes separately; the handler only executes calls.
511pub enum Call {
512    /// Fulfilled with an LLM [`Response`].
513    Model(Box<CallModel>),
514    /// Fulfilled with a [`DecisionResponse`].
515    Decision(Box<CallDecision>),
516}
517
518/// Drive [`Algorithm::run_stream`] to its routing outcome, serving calls concurrently.
519///
520/// The shared execution loop lets hosts supply clients or test doubles through `serve`
521/// without reimplementing stream handling and cancellation.
522///
523/// `serve` owns each [`Call`] and performs its I/O. Passing an error to `respond`
524/// lets the algorithm fall back; returning an error from `serve` aborts the run.
525///
526/// Dropping this future cancels the algorithm and pending handler futures.
527pub async fn drive<F, Fut>(
528    algorithm: Arc<dyn Algorithm>,
529    request: Request,
530    models: Arc<RuntimeModels>,
531    serve: F,
532) -> Result<RoutingOutcome>
533where
534    F: Fn(Call) -> Fut,
535    Fut: Future<Output = Result<()>>,
536{
537    let stream = algorithm.run_stream(request, models);
538    tokio::pin!(stream);
539
540    let mut in_flight = futures::stream::FuturesUnordered::new();
541    let mut final_outcome: Option<RoutingOutcome> = None;
542
543    loop {
544        tokio::select! {
545            Some(result) = in_flight.next() => match result {
546                Ok(()) => {}, // Call completed successfully
547                Err(err) => return Err(err), // Call failed, propagate the error
548            },
549            step = stream.next() => {
550                match step {
551                    None => break, // stream has ended, no more steps
552                    Some(item) => match item? {
553                        Step::CallModel(call) => in_flight.push(serve(Call::Model(call))),
554                        Step::CallDecision(call) => {
555                            in_flight.push(serve(Call::Decision(call)));
556                        }
557                        Step::Done(outcome) => {
558                            final_outcome = Some(*outcome);
559                            break;
560                        }
561                    }
562                }
563            },
564        }
565    }
566    final_outcome.ok_or(LibsyError::MissingFinalResponse)
567}
568
569/// Recover the message from an algorithm's panic.
570fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
571    payload
572        .downcast_ref::<&'static str>()
573        .map(|message| (*message).to_string())
574        .or_else(|| payload.downcast_ref::<String>().cloned())
575        .unwrap_or_else(|| "unknown panic payload".to_string())
576}
577
578/// Abort guard
579struct AbortOnDrop(tokio::task::AbortHandle);
580
581impl Drop for AbortOnDrop {
582    fn drop(&mut self) {
583        self.0.abort();
584    }
585}
586
587/// Errors unless `targets` contains `name`.
588///
589/// Config target names must be resolved before an algorithm is built. This list contains
590/// model IDs, not target names.
591pub(crate) fn ensure_model_is_target(targets: &[ModelId], name: &ModelId) -> Result<()> {
592    targets
593        .iter()
594        .any(|target| target == name)
595        .then_some(())
596        .ok_or_else(|| LibsyError::TargetNotFound {
597            target: name.clone(),
598        })
599}
600
601/// Key for routing affinity: a root request by its session, a child request by its session
602/// and agent.
603#[derive(Clone, Hash, PartialEq, Eq)]
604pub(crate) enum RoutingIdentity {
605    /// Root request, keyed by session ID.
606    Session(String),
607    /// Child request, keyed by session and agent IDs.
608    Subagent { session: String, agent: String },
609}
610
611impl RoutingIdentity {
612    /// Builds a root or child identity from non-empty request metadata.
613    ///
614    /// A child request missing either ID returns `None`, so it keeps no routing history
615    /// rather than sharing the parent's.
616    pub(crate) fn from_request(request: &Request) -> Option<Self> {
617        let metadata = request.metadata.as_ref()?;
618        let session = metadata.session_id.as_deref().filter(|id| !id.is_empty())?;
619        if metadata.is_subagent {
620            let agent = metadata.agent_id.as_deref().filter(|id| !id.is_empty())?;
621            Some(Self::Subagent {
622                session: session.to_string(),
623                agent: agent.to_string(),
624            })
625        } else {
626            Some(Self::Session(session.to_string()))
627        }
628    }
629}
630
631/// Routing policy independent of how external calls are executed.
632/// Implement [`route`](Self::route), using [`Driver`] to request work from the host.
633/// Hosts can use [`drive`] for the shared execution loop or consume
634/// [`run_stream`](Self::run_stream) for custom execution.
635///
636/// Methods take `self: Arc<Self>`: one algorithm (`Arc<dyn Algorithm>`) is shared across
637/// requests and run concurrently, so it owns its thread-safety and any shared state.
638///
639/// # Concurrency
640///
641/// A host may run the same algorithm concurrently for many requests. Implementations
642/// must synchronize their own mutable shared state. Each call to [`run_stream`](Self::run_stream)
643/// creates an independent [`Driver`], so model-call promises and emitted [`Step`]s cannot
644/// cross between runs.
645///
646/// # Observability
647///
648/// [`run_stream`](Self::run_stream) creates a `libsy.run` span, and each offloaded model
649/// call creates a nested span for its call kind, with outcome and available token usage.
650/// Call metrics include host queueing and count unfulfilled drops as errors.
651/// Successful outcomes record their
652/// [`OutcomeMetadata::outcome_id`](crate::OutcomeMetadata::outcome_id) on `libsy.run`,
653/// alongside `selected_model_ids` (an ordered OpenTelemetry string array).
654/// `algorithm` and `switchyard.algorithm` retain the run's [`Algorithm::name`].
655/// Optional `evidence.source`, `evidence.verdict`, `evidence.trigger`, and
656/// `evidence.reason_code` are strings; `evidence.score`, `evidence.confidence`, and
657/// `evidence.threshold` are numbers. Unknown evidence fields are not exported.
658/// These fields are span attributes, never metric labels.
659///
660/// The run/call observability helpers retain `outcome` status and operational metrics,
661/// but omit error details and arbitrary request extra metadata. Algorithms and hosts
662/// may emit their own logs. Errors still reach the caller unchanged.
663/// The host controls the tracing subscriber and global OpenTelemetry
664/// meter provider; libsy installs no exporter and performs no telemetry network I/O.
665#[async_trait]
666pub trait Algorithm: Send + Sync + 'static {
667    /// Stable, low-cardinality name identifying this algorithm — the
668    /// `algorithm` attribute on every span, metric, and log line the crate
669    /// emits for its runs.
670    fn name(&self) -> &str;
671
672    /// Select a route, requesting external work through [`Driver`] as needed.
673    /// [`run_stream`](Self::run_stream) runs this method and exposes its work to the host.
674    async fn route(self: Arc<Self>, driver: Driver, request: Request) -> Result<RoutingOutcome>;
675
676    /// Expose work requests and the final outcome so the host can control execution.
677    ///
678    /// Each call waits for its host reply. A run ends with one terminal item — [`Step::Done`] on
679    /// success, an `Err` item on failure, including when the algorithm panics. Dropping
680    /// the stream aborts the spawned algorithm task.
681    ///
682    /// Every invocation owns a separate [`Driver`].
683    fn run_stream(self: Arc<Self>, request: Request, models: Arc<RuntimeModels>) -> StepStream {
684        let (driver, step_rx) = Driver::new(self.name(), models);
685        let span = observability::run_span(self.name(), &request);
686        let handle = tokio::spawn(
687            async move {
688                let algorithm = self.name().to_string();
689                // Catch a panicking algorithm so the run still publishes a terminal step.
690                let route = AssertUnwindSafe(self.route(driver.clone(), request)).catch_unwind();
691                let result = observability::observe_run(&algorithm, async move {
692                    route.await.unwrap_or_else(|payload| {
693                        Err(LibsyError::AlgorithmError {
694                            message: format!(
695                                "algorithm task panicked: {}",
696                                panic_message(payload.as_ref())
697                            ),
698                        })
699                    })
700                })
701                .await;
702
703                let _ = driver.finish(result).await;
704            }
705            .instrument(span),
706        );
707        // Dropping the stream aborts the algorithm task when its consumer goes away.
708        let abort_guard = AbortOnDrop(handle.abort_handle());
709        Box::pin(ReceiverStream::new(step_rx).map(move |step| {
710            // link abort guard to stream
711            let _keep_alive = &abort_guard;
712            step
713        }))
714    }
715}
716
717#[cfg(test)]
718mod tests {
719    use std::collections::HashMap;
720
721    use super::*;
722    use crate::core::testing::{Serve, ServeResult, echo, reply, serve_decision, test_drive};
723    use futures::StreamExt;
724    use switchyard_protocol::{
725        LlmResponse, LlmResponseChunk, completion_text, text_request, text_response,
726    };
727
728    #[derive(Debug, thiserror::Error)]
729    #[error("{0}")]
730    struct TestError(&'static str);
731
732    fn test_error(message: &'static str) -> LibsyError {
733        LibsyError::external("test", TestError(message))
734    }
735
736    /// Trivial algo used only to exercise the orchestrator: calls the first target
737    /// and returns its response as the routing outcome.
738    struct TestAlgo {
739        target_set: Vec<ModelId>,
740    }
741
742    #[async_trait]
743    impl Algorithm for TestAlgo {
744        fn name(&self) -> &str {
745            "test"
746        }
747
748        async fn route(
749            self: Arc<Self>,
750            driver: Driver,
751            request: Request,
752        ) -> Result<RoutingOutcome> {
753            let target = self
754                .target_set
755                .first()
756                .ok_or(LibsyError::NoTargets)?
757                .clone();
758            let response = driver
759                .call_model(request.clone(), vec![target.clone()])
760                .await?;
761            driver.set_evidence(serde_json::json!({"source": "test"}));
762            driver.set_evidence_if_empty(serde_json::json!({"source": "ignored"}));
763            Ok(RoutingOutcome::answered(target, request, response))
764        }
765    }
766
767    /// Build a shared `TestAlgo` over the given target set.
768    fn orch(target_set: Vec<ModelId>) -> Arc<dyn Algorithm> {
769        Arc::new(TestAlgo { target_set })
770    }
771
772    fn request() -> Request {
773        Request {
774            llm_request: text_request(Some("auto".to_string()), "hi".to_string()),
775            raw_request: None,
776            metadata: None,
777        }
778    }
779
780    #[test]
781    fn routing_outcome_constructors_stamp_selection_and_preserve_payloads() {
782        let outcome = RoutingOutcome::route_to(
783            "selected".into(),
784            target_set(&["fallback-one", "fallback-two"]),
785            request(),
786        );
787
788        assert_eq!(
789            outcome.selected_model_ids,
790            target_set(&["selected", "fallback-one", "fallback-two"])
791        );
792        assert_eq!(outcome.request.model_id().as_deref(), Some("selected"));
793        assert!(outcome.response.is_none());
794        assert!(outcome.metadata.is_none());
795
796        let outcome = RoutingOutcome::route_to("only".into(), Vec::new(), request());
797        assert_eq!(outcome.selected_model_ids, target_set(&["only"]));
798
799        let outcome = RoutingOutcome::answered(
800            "answered".into(),
801            request(),
802            Response {
803                llm_response: LlmResponse::Agg(text_response(None, "existing")),
804                metadata: None,
805                upstream_headers: http::HeaderMap::new(),
806            },
807        );
808
809        assert_eq!(outcome.selected_model_ids, target_set(&["answered"]));
810        assert_eq!(outcome.request.model_id().as_deref(), Some("answered"));
811        assert_eq!(
812            outcome
813                .response
814                .as_ref()
815                .and_then(|response| response.llm_response.as_agg())
816                .map(completion_text),
817            Some("existing".to_string())
818        );
819    }
820
821    fn target_set(names: &[&str]) -> Vec<ModelId> {
822        names.iter().map(|name| ModelId::from(*name)).collect()
823    }
824
825    #[tokio::test]
826    async fn typed_driver_preserves_call_and_stream_boundaries() -> Result<()> {
827        tokio::time::timeout(std::time::Duration::from_secs(1), async {
828            // Distinct oneshots keep reverse-order replies paired with their producers, and a
829            // retained call remains pending until the host responds.
830            let (driver, mut step_rx) = Driver::new("test", Arc::new(RuntimeModels::default()));
831            let first_driver = driver.clone();
832            let mut first = tokio::spawn(async move {
833                first_driver
834                    .call_model(request(), vec![ModelId::from("first")])
835                    .await
836            });
837            let second = tokio::spawn(async move {
838                driver
839                    .call_model(request(), vec![ModelId::from("second")])
840                    .await
841            });
842
843            let mut calls = HashMap::new();
844            for _ in 0..2 {
845                let step = step_rx.recv().await.ok_or(DriverError::StreamClosed)??;
846                let Step::CallModel(call) = step else {
847                    return Err(test_error("expected a CallModel step"));
848                };
849                let selected_model = call
850                    .models
851                    .first()
852                    .ok_or_else(|| test_error("model call has no candidates"))?
853                    .to_string();
854                calls.insert(selected_model, call);
855            }
856            assert!(
857                tokio::time::timeout(std::time::Duration::from_millis(20), &mut first)
858                    .await
859                    .is_err(),
860                "call completed before the host responded"
861            );
862            calls
863                .remove("second")
864                .ok_or_else(|| test_error("missing second call"))?
865                .respond(Ok(reply("second response")))?;
866            calls
867                .remove("first")
868                .ok_or_else(|| test_error("missing first call"))?
869                .respond(Ok(reply("first response")))?;
870
871            let first_response = first
872                .await
873                .map_err(|source| LibsyError::external("joining a test task", source))??;
874            let second_response = second
875                .await
876                .map_err(|source| LibsyError::external("joining a test task", source))??;
877            assert_eq!(
878                first_response.llm_response.as_agg().map(completion_text),
879                Some("first response".to_string())
880            );
881            assert_eq!(
882                second_response.llm_response.as_agg().map(completion_text),
883                Some("second response".to_string())
884            );
885
886            // Dropping the host-facing promise closes only that call's reply channel.
887            let (driver, mut step_rx) = Driver::new("test", Arc::new(RuntimeModels::default()));
888            let producer = tokio::spawn(async move {
889                driver
890                    .call_model(request(), vec![ModelId::from("dropped")])
891                    .await
892            });
893            let step = step_rx.recv().await.ok_or(DriverError::StreamClosed)??;
894            let Step::CallModel(call) = step else {
895                return Err(test_error("expected a CallModel step"));
896            };
897            drop(call);
898            let result = producer
899                .await
900                .map_err(|source| LibsyError::external("joining a test task", source))?;
901            assert!(matches!(
902                result,
903                Err(LibsyError::Driver(DriverError::ResponseDropped))
904            ));
905
906            // A standalone driver reports the typed step receiver disappearing at its next call.
907            let (driver, step_rx) = Driver::new("test", Arc::new(RuntimeModels::default()));
908            drop(step_rx);
909            let result = driver
910                .call_model(request(), vec![ModelId::from("closed")])
911                .await;
912            assert!(matches!(
913                result,
914                Err(LibsyError::Driver(DriverError::StreamClosed))
915            ));
916
917            fn decision_request() -> DecisionRequest {
918                DecisionRequest {
919                    model: Some("overwritten".into()),
920                    context: serde_json::json!({"task": "choose a route"}),
921                    images: Vec::new(),
922                    questions: Default::default(),
923                }
924            }
925
926            fn decision_response() -> DecisionResponse {
927                DecisionResponse {
928                    id: Some("decision-1".to_string()),
929                    model: Some("provider-model".into()),
930                    answers: [(
931                        "p_solve".to_string(),
932                        switchyard_protocol::DecisionAnswer {
933                            value: switchyard_protocol::DecisionValue::Boolean(
934                                switchyard_protocol::BooleanEstimate::ProbabilityTrue(
935                                    switchyard_protocol::Probability(0.8),
936                                ),
937                            ),
938                            provider_confidence: None,
939                        },
940                    )]
941                    .into(),
942                    usage: Default::default(),
943                }
944            }
945
946            struct MixedCalls(&'static str);
947
948            #[async_trait]
949            impl Algorithm for MixedCalls {
950                fn name(&self) -> &str {
951                    "mixed"
952                }
953
954                async fn route(
955                    self: Arc<Self>,
956                    driver: Driver,
957                    request: Request,
958                ) -> Result<RoutingOutcome> {
959                    let (llm, decision) = tokio::join!(
960                        driver.call_model(request.clone(), vec!["llm".into()]),
961                        driver.call_decision(decision_request(), "decision".into()),
962                    );
963                    assert_eq!(
964                        llm?.llm_response.as_agg().map(completion_text),
965                        Some("llm reply".into())
966                    );
967                    match self.0 {
968                        "mock" => assert_eq!(decision?, DecisionResponse {
969                            id: None,
970                            model: Some("decision".into()),
971                            answers: Default::default(),
972                            usage: Default::default(),
973                        }),
974                        "reply" => assert_eq!(decision?, decision_response()),
975                        "error" => assert!(matches!(
976                            decision,
977                            Err(LibsyError::AlgorithmError { message }) if message == "provider failed"
978                        )),
979                        "drop" => assert!(matches!(
980                            decision,
981                            Err(LibsyError::Driver(DriverError::ResponseDropped))
982                        )),
983                        "abort" => return std::future::pending().await,
984                        _ => unreachable!(),
985                    }
986                    Ok(RoutingOutcome::route_to("answer".into(), vec![], request))
987                }
988            }
989
990            for mode in ["mock", "reply", "error", "drop", "abort"] {
991                // Serial dispatch would deadlock here and fail the enclosing timeout.
992                let barrier = Arc::new(tokio::sync::Barrier::new(2));
993                let outcome = drive(
994                    Arc::new(MixedCalls(mode)),
995                    request(),
996                    Arc::new(RuntimeModels::default()),
997                    move |call| {
998                        let barrier = barrier.clone();
999                        async move {
1000                            barrier.wait().await;
1001                            let call = match call {
1002                                Call::Model(call) => return call.respond(Ok(reply("llm reply"))),
1003                                Call::Decision(call) => *call,
1004                            };
1005                            assert_eq!(call.algorithm, "mixed");
1006                            assert_eq!(call.model, "decision");
1007                            assert_eq!(call.request.model, Some("decision".into()));
1008                            assert_eq!(call.request.context, decision_request().context);
1009                            match mode {
1010                                "mock" => serve_decision(call).await,
1011                                "reply" => call.respond(Ok(decision_response())),
1012                                "error" => call.respond(Err(LibsyError::AlgorithmError {
1013                                    message: "provider failed".into(),
1014                                })),
1015                                "drop" => {
1016                                    drop(call);
1017                                    Ok(())
1018                                }
1019                                "abort" => call.fail(test_error("host aborted")),
1020                                _ => unreachable!(),
1021                            }
1022                        }
1023                    },
1024                )
1025                .await;
1026                if mode == "abort" {
1027                    assert!(matches!(outcome, Err(LibsyError::External { .. })));
1028                } else {
1029                    assert_eq!(outcome?.selected_model_id()?, "answer");
1030                }
1031            }
1032
1033            let (driver, mut steps) = Driver::new("test", Arc::new(RuntimeModels::default()));
1034            let mut pending = Box::pin(driver.call_decision(decision_request(), "decision".into()));
1035            assert!(futures::poll!(&mut pending).is_pending());
1036            let Some(Ok(Step::CallDecision(call))) = steps.recv().await else {
1037                return Err(test_error("expected a decision call"));
1038            };
1039            drop(pending);
1040            assert!(matches!(
1041                call.respond(Ok(decision_response())),
1042                Err(LibsyError::Driver(DriverError::ResponseDropped))
1043            ));
1044            drop(steps);
1045            assert!(matches!(
1046                driver.call_decision(decision_request(), "decision".into()).await,
1047                Err(LibsyError::Driver(DriverError::StreamClosed))
1048            ));
1049            Ok(())
1050        })
1051        .await
1052        .map_err(|error| LibsyError::external("waiting for typed driver boundaries", error))?
1053    }
1054
1055    #[test]
1056    fn target_lookup_returns_the_missing_target() {
1057        let error = ensure_model_is_target(&target_set(&[]), &ModelId::from("missing")).err();
1058        assert!(matches!(
1059            error,
1060            Some(LibsyError::TargetNotFound { target }) if target == "missing"
1061        ));
1062    }
1063
1064    /// Build a single-target algo, plus a `serve` that answers it as a token stream
1065    /// replaying `chunks` in order (as `Ok` items).
1066    fn streaming_orch(chunks: Vec<LlmResponseChunk>) -> (Arc<dyn Algorithm>, impl Serve) {
1067        let algo = orch(target_set(&["stream/model"]));
1068        let serve = move |_target: ModelId, _request: Request| {
1069            let chunks = chunks.clone();
1070            async move {
1071                let stream =
1072                    futures::stream::iter(chunks.into_iter().map(|chunk| Ok(chunk.into()))).boxed();
1073                Ok(Response {
1074                    llm_response: LlmResponse::Stream(stream),
1075                    metadata: None,
1076                    upstream_headers: http::HeaderMap::new(),
1077                })
1078            }
1079        };
1080        (algo, serve)
1081    }
1082
1083    #[tokio::test]
1084    async fn run_returns_a_streamed_response_the_caller_aggregates() -> Result<()> {
1085        // A streaming client -> its chunks flow through the promise and `Done`,
1086        // and `run` returns the live stream untouched for the caller to fold.
1087        let (orch, serve) = streaming_orch(vec![
1088            LlmResponseChunk::MessageStart {
1089                id: Some("m1".to_string()),
1090                model: Some("stream/model".to_string()),
1091            },
1092            LlmResponseChunk::TextDelta {
1093                index: 0,
1094                text: "hel".to_string(),
1095            },
1096            LlmResponseChunk::TextDelta {
1097                index: 0,
1098                text: "lo".to_string(),
1099            },
1100            LlmResponseChunk::MessageStop {
1101                reason: Some("stop".to_string()),
1102            },
1103        ]);
1104        let (selected_model, response) = test_drive(orch, request(), serve).await?;
1105        // The run handed back the live stream; the caller folds it to a buffered aggregate.
1106        let agg = response
1107            .llm_response
1108            .into_agg()
1109            .await
1110            .map_err(|error| LibsyError::external("aggregating response stream", error))?;
1111        assert_eq!(completion_text(&agg), "hello");
1112        assert_eq!(agg.model.as_deref(), Some("stream/model"));
1113        assert_eq!(selected_model, "stream/model");
1114        Ok(())
1115    }
1116
1117    #[tokio::test]
1118    async fn aggregating_a_streamed_response_propagates_a_mid_stream_error() -> Result<()> {
1119        // The run succeeds and returns the stream; the in-band `Error` chunk surfaces only
1120        // when the caller aggregates it.
1121        let (orch, serve) = streaming_orch(vec![
1122            LlmResponseChunk::TextDelta {
1123                index: 0,
1124                text: "partial".to_string(),
1125            },
1126            LlmResponseChunk::StreamError {
1127                message: "upstream exploded".to_string(),
1128            },
1129        ]);
1130        let (_, response) = test_drive(orch, request(), serve).await?;
1131        match response.llm_response.into_agg().await {
1132            Ok(_) => panic!("expected a mid-stream error, got an aggregate"),
1133            Err(err) => {
1134                assert!(err.to_string().contains("upstream exploded"));
1135                Ok(())
1136            }
1137        }
1138    }
1139
1140    #[tokio::test]
1141    async fn run_offloads_via_promise_then_finishes() -> Result<()> {
1142        // Every call is offloaded via a promise the orchestrator surfaces as a
1143        // `CallModel` step for us to fulfill.
1144        let stream = orch(target_set(&["offload/model"]))
1145            .run_stream(request(), Arc::new(RuntimeModels::default()));
1146        tokio::pin!(stream);
1147
1148        let mut saw_call = false;
1149        let mut final_completion = None;
1150        while let Some(step) = stream.next().await {
1151            match step? {
1152                Step::CallDecision(_) => return Err(test_error("unexpected decision call")),
1153                Step::CallModel(call) => {
1154                    saw_call = true;
1155                    assert_eq!(call.models, vec![ModelId::from("offload/model")]);
1156                    // Fulfilling the promise is the "real" model call the caller makes.
1157                    call.respond(Ok(Response {
1158                        llm_response: LlmResponse::Agg(text_response(
1159                            None,
1160                            "fulfilled".to_string(),
1161                        )),
1162                        metadata: None,
1163                        upstream_headers: http::HeaderMap::new(),
1164                    }))?;
1165                }
1166                Step::Done(outcome) => {
1167                    let metadata = outcome
1168                        .metadata
1169                        .as_ref()
1170                        .expect("run_stream should attach outcome metadata");
1171                    assert_eq!(metadata.algorithm, "test");
1172                    assert_eq!(
1173                        uuid::Uuid::parse_str(metadata.outcome_id())
1174                            .expect("outcome id should be a UUID")
1175                            .get_version_num(),
1176                        7
1177                    );
1178                    assert_eq!(
1179                        metadata.evidence,
1180                        Some(serde_json::json!({"source": "test"}))
1181                    );
1182                    let response = outcome
1183                        .response
1184                        .ok_or_else(|| test_error("expected an answered outcome"))?;
1185                    final_completion = Some(
1186                        response
1187                            .llm_response
1188                            .as_agg()
1189                            .map(completion_text)
1190                            .unwrap_or_default(),
1191                    );
1192                }
1193            }
1194        }
1195
1196        assert!(saw_call, "expected a CallModel step before Done");
1197        assert_eq!(
1198            final_completion.ok_or_else(|| test_error("no Done step"))?,
1199            "fulfilled"
1200        );
1201        Ok(())
1202    }
1203
1204    #[tokio::test(flavor = "multi_thread", worker_threads = 12)]
1205    async fn requests_are_processed_in_parallel() -> Result<()> {
1206        use std::time::Duration;
1207        use tokio::sync::Barrier;
1208
1209        const N: usize = 12;
1210
1211        // Serving blocks until all N concurrent calls have arrived. If requests were
1212        // serialized (one algorithm behind a `Mutex`), only one call could be in flight,
1213        // the barrier would never reach N, and the test would time out. It passes only
1214        // because the shared algorithm is driven concurrently across requests.
1215        let barrier = Arc::new(Barrier::new(N));
1216        // One shared algorithm driven by many concurrent requests.
1217        let algo = orch(target_set(&["m"]));
1218
1219        let mut handles = Vec::new();
1220        for _ in 0..N {
1221            let algo = algo.clone();
1222            let barrier = barrier.clone();
1223            let serve = move |target: ModelId, _request: Request| {
1224                let barrier = barrier.clone();
1225                async move {
1226                    barrier.wait().await;
1227                    Ok(reply(target))
1228                }
1229            };
1230            handles.push(tokio::spawn(async move {
1231                test_drive(algo, request(), serve)
1232                    .await
1233                    .map(|(_, response)| {
1234                        response
1235                            .llm_response
1236                            .as_agg()
1237                            .map(completion_text)
1238                            .unwrap_or_default()
1239                    })
1240            }));
1241        }
1242
1243        for handle in handles {
1244            // The timeout turns a serialization deadlock into a failure, not a hang.
1245            let completion = tokio::time::timeout(Duration::from_secs(5), handle)
1246                .await
1247                .map_err(|error| LibsyError::external("waiting for test task", error))?
1248                .map_err(|source| LibsyError::external("joining a test task", source))??;
1249            assert_eq!(completion, "m");
1250        }
1251        Ok(())
1252    }
1253
1254    #[tokio::test]
1255    async fn offload_error_propagates_back_to_the_algorithm() -> Result<()> {
1256        // A client-less target offloads its call; we fulfill the promise with an
1257        // Err, which must flow back through `call_model_target` into the algorithm and
1258        // out as an error step — not a response.
1259        let stream = orch(target_set(&["offload/model"]))
1260            .run_stream(request(), Arc::new(RuntimeModels::default()));
1261        tokio::pin!(stream);
1262
1263        let mut saw_error = false;
1264        while let Some(step) = stream.next().await {
1265            match step {
1266                Ok(Step::CallDecision(_)) => return Err(test_error("unexpected decision call")),
1267                Ok(Step::CallModel(call)) => {
1268                    call.respond(Err(test_error("upstream model call failed")))?;
1269                }
1270                Ok(Step::Done(..)) => {
1271                    return Err(test_error(
1272                        "expected the offload error to propagate, got a response",
1273                    ));
1274                }
1275                Err(err) => {
1276                    // The algorithm's `call_model_target` saw the error via the promise.
1277                    assert!(err.to_string().contains("upstream model call failed"));
1278                    saw_error = true;
1279                }
1280            }
1281        }
1282
1283        assert!(saw_error, "expected an error step");
1284        Ok(())
1285    }
1286
1287    #[tokio::test]
1288    async fn dropping_the_stream_cancels_the_algorithm_task() -> Result<()> {
1289        use std::sync::atomic::{AtomicBool, Ordering};
1290        use std::time::Duration;
1291        use tokio::sync::mpsc;
1292
1293        // Sets a flag when dropped, so we can observe whether the algorithm task was
1294        // cancelled/dropped.
1295        struct DropGuard(Arc<AtomicBool>);
1296        impl Drop for DropGuard {
1297            fn drop(&mut self) {
1298                self.0.store(true, Ordering::SeqCst);
1299            }
1300        }
1301
1302        struct StuckAlgo {
1303            started: mpsc::UnboundedSender<()>,
1304            dropped: Arc<AtomicBool>,
1305        }
1306
1307        #[async_trait]
1308        impl Algorithm for StuckAlgo {
1309            fn name(&self) -> &str {
1310                "stuck"
1311            }
1312
1313            async fn route(
1314                self: Arc<Self>,
1315                _driver: Driver,
1316                _request: Request,
1317            ) -> Result<RoutingOutcome> {
1318                let _guard = DropGuard(self.dropped.clone());
1319                let _ = self.started.send(());
1320                // Await forever without ever touching the driver.
1321                std::future::pending::<()>().await;
1322                unreachable!()
1323            }
1324        }
1325
1326        let (started_tx, mut started_rx) = mpsc::unbounded_channel();
1327        let dropped = Arc::new(AtomicBool::new(false));
1328        let algo: Arc<dyn Algorithm> = Arc::new(StuckAlgo {
1329            started: started_tx,
1330            dropped: dropped.clone(),
1331        });
1332
1333        let stream = algo.run_stream(request(), Arc::new(RuntimeModels::default()));
1334        started_rx
1335            .recv()
1336            .await
1337            .ok_or_else(|| test_error("task never started"))?;
1338        drop(stream);
1339        tokio::time::sleep(Duration::from_millis(100)).await;
1340
1341        assert!(
1342            dropped.load(Ordering::SeqCst),
1343            "algorithm task was NOT cancelled after dropping the stream"
1344        );
1345        Ok(())
1346    }
1347
1348    #[tokio::test]
1349    async fn route_panic_surfaces_as_a_stream_error() -> Result<()> {
1350        // An algorithm whose task panics must surface an `Err` step carrying the panic
1351        // message, not abort the process from an unobserved detached task.
1352        struct Panicky;
1353
1354        #[async_trait]
1355        impl Algorithm for Panicky {
1356            fn name(&self) -> &str {
1357                "panicky"
1358            }
1359
1360            async fn route(
1361                self: Arc<Self>,
1362                _driver: Driver,
1363                _request: Request,
1364            ) -> Result<RoutingOutcome> {
1365                panic!("boom");
1366            }
1367        }
1368
1369        let algo: Arc<dyn Algorithm> = Arc::new(Panicky);
1370        let stream = algo.run_stream(request(), Arc::new(RuntimeModels::default()));
1371        tokio::pin!(stream);
1372
1373        let mut saw_error = false;
1374        while let Some(step) = stream.next().await {
1375            match step {
1376                Err(err) => {
1377                    // The panic message is preserved, not flattened into an opaque failure.
1378                    assert!(err.to_string().contains("algorithm task panicked: boom"));
1379                    saw_error = true;
1380                }
1381                Ok(_) => return Err(test_error("expected the panic to surface as an error step")),
1382            }
1383        }
1384
1385        assert!(saw_error, "expected an error step from the panicked task");
1386        Ok(())
1387    }
1388
1389    /// A panicking algorithm must publish its terminal step even when it left a `Driver`
1390    /// clone alive in another task. That clone holds the step channel open, so a run that
1391    /// merely unwound would never terminate and the consumer would wait forever.
1392    #[tokio::test]
1393    async fn a_panic_with_a_leaked_driver_clone_still_terminates_the_run() -> Result<()> {
1394        struct LeakyPanic;
1395
1396        #[async_trait]
1397        impl Algorithm for LeakyPanic {
1398            fn name(&self) -> &str {
1399                "leaky_panic"
1400            }
1401
1402            async fn route(
1403                self: Arc<Self>,
1404                driver: Driver,
1405                _request: Request,
1406            ) -> Result<RoutingOutcome> {
1407                tokio::spawn(async move {
1408                    // Outlives the panic below, keeping a sender clone alive.
1409                    let _keep_alive = driver;
1410                    std::future::pending::<()>().await;
1411                });
1412                tokio::task::yield_now().await;
1413                panic!("boom");
1414            }
1415        }
1416
1417        let algo: Arc<dyn Algorithm> = Arc::new(LeakyPanic);
1418        // The timeout turns the hang this guards against into a failure rather than a hang.
1419        let result = tokio::time::timeout(
1420            std::time::Duration::from_secs(1),
1421            test_drive(algo, request(), echo()),
1422        )
1423        .await
1424        .map_err(|error| LibsyError::external("waiting for the panicked run to end", error))?;
1425
1426        match result {
1427            Ok(_) => Err(test_error(
1428                "expected the panic to end the run with an error",
1429            )),
1430            Err(err) => {
1431                assert!(err.to_string().contains("algorithm task panicked: boom"));
1432                Ok(())
1433            }
1434        }
1435    }
1436
1437    #[tokio::test]
1438    async fn cancelling_run_cancels_the_algorithm_task() -> Result<()> {
1439        use std::sync::atomic::{AtomicBool, Ordering};
1440        use std::time::Duration;
1441        use tokio::sync::mpsc;
1442
1443        // Sets a flag when dropped, so we can observe whether the algorithm task was
1444        // cancelled once the `run` future driving it is dropped.
1445        struct DropGuard(Arc<AtomicBool>);
1446        impl Drop for DropGuard {
1447            fn drop(&mut self) {
1448                self.0.store(true, Ordering::SeqCst);
1449            }
1450        }
1451
1452        struct StuckAlgo {
1453            started: mpsc::UnboundedSender<()>,
1454            dropped: Arc<AtomicBool>,
1455        }
1456
1457        #[async_trait]
1458        impl Algorithm for StuckAlgo {
1459            fn name(&self) -> &str {
1460                "stuck"
1461            }
1462
1463            async fn route(
1464                self: Arc<Self>,
1465                _driver: Driver,
1466                _request: Request,
1467            ) -> Result<RoutingOutcome> {
1468                let _guard = DropGuard(self.dropped.clone());
1469                let _ = self.started.send(());
1470                // Hang forever without ever touching the driver, so only cancellation
1471                // (not a dropped step channel) can stop this task.
1472                std::future::pending::<()>().await;
1473                unreachable!()
1474            }
1475        }
1476
1477        let (started_tx, mut started_rx) = mpsc::unbounded_channel();
1478        let dropped = Arc::new(AtomicBool::new(false));
1479        let algo: Arc<dyn Algorithm> = Arc::new(StuckAlgo {
1480            started: started_tx,
1481            dropped: dropped.clone(),
1482        });
1483
1484        // Drive the run on its own task, wait until the algorithm task is up, then cancel
1485        // it — dropping its future (and the `run_stream` stream it holds).
1486        let run_task = tokio::spawn(async move { test_drive(algo, request(), echo()).await });
1487        started_rx
1488            .recv()
1489            .await
1490            .ok_or_else(|| test_error("task never started"))?;
1491        run_task.abort();
1492        tokio::time::sleep(Duration::from_millis(100)).await;
1493
1494        assert!(
1495            dropped.load(Ordering::SeqCst),
1496            "algorithm task was NOT cancelled after cancelling run"
1497        );
1498        Ok(())
1499    }
1500
1501    // --- first-wins hedging: `run` must not wait on losing speculative calls -------------
1502
1503    /// Offloads two targets concurrently and returns the first to resolve, dropping the
1504    /// loser's call (first-wins hedging).
1505    struct Hedge {
1506        winner: String,
1507        loser: String,
1508    }
1509
1510    #[async_trait]
1511    impl Algorithm for Hedge {
1512        fn name(&self) -> &str {
1513            "hedge"
1514        }
1515
1516        async fn route(
1517            self: Arc<Self>,
1518            driver: Driver,
1519            request: Request,
1520        ) -> Result<RoutingOutcome> {
1521            let outcome_request = request.clone();
1522            let win = driver.call_model(request.clone(), vec![self.winner.clone().into()]);
1523            let lose = driver.call_model(request, vec![self.loser.clone().into()]);
1524            // First to resolve wins; `select!` drops the losing future (and its promise).
1525            tokio::select! {
1526                res = win => Ok(RoutingOutcome::answered(
1527                    self.winner.clone().into(),
1528                    outcome_request,
1529                    res?,
1530                )),
1531                res = lose => Ok(RoutingOutcome::answered(
1532                    self.loser.clone().into(),
1533                    outcome_request,
1534                    res?,
1535                )),
1536            }
1537        }
1538    }
1539
1540    /// Builds a hedging algo and the `serve` that drives it: the winner is gated behind the
1541    /// loser starting (so the loser's serve is guaranteed in flight when the winner wins),
1542    /// and the loser finishes after `loser_delay` — or never, when `None`.
1543    fn hedge(loser_delay: Option<std::time::Duration>) -> (Arc<dyn Algorithm>, impl Serve) {
1544        let started = Arc::new(tokio::sync::Notify::new());
1545        let algo = Arc::new(Hedge {
1546            winner: "winner".to_string(),
1547            loser: "loser".to_string(),
1548        });
1549        let serve = move |target: ModelId, _request: Request| {
1550            let started = started.clone();
1551            async move {
1552                if target == "loser" {
1553                    started.notify_one();
1554                    match loser_delay {
1555                        Some(delay) => tokio::time::sleep(delay).await,
1556                        None => std::future::pending::<()>().await,
1557                    }
1558                } else {
1559                    started.notified().await;
1560                }
1561                Ok(reply(target))
1562            }
1563        };
1564        (algo, serve)
1565    }
1566
1567    #[tokio::test]
1568    async fn run_returns_the_winner_without_a_late_loser_overwriting_it() -> Result<()> {
1569        // The loser responds 50ms after the winner has already won. `run` must return the
1570        // winner, not the loser's `respond`-to-a-dropped-receiver error.
1571        let (algo, serve) = hedge(Some(std::time::Duration::from_millis(50)));
1572        let (_, response) = test_drive(algo, request(), serve).await?;
1573        assert_eq!(
1574            response
1575                .llm_response
1576                .as_agg()
1577                .map(completion_text)
1578                .unwrap_or_default(),
1579            "winner"
1580        );
1581        Ok(())
1582    }
1583
1584    #[tokio::test]
1585    async fn run_returns_the_winner_without_hanging_on_a_pending_loser() -> Result<()> {
1586        // The loser never resolves. `run` must return the winner promptly, not hang
1587        // waiting for the in-flight loser.
1588        let (algo, serve) = hedge(None);
1589        let run = test_drive(algo, request(), serve);
1590        let (_, response) = tokio::time::timeout(std::time::Duration::from_secs(1), run)
1591            .await
1592            .map_err(|error| LibsyError::external("waiting for pending loser", error))??;
1593        assert_eq!(
1594            response
1595                .llm_response
1596                .as_agg()
1597                .map(completion_text)
1598                .unwrap_or_default(),
1599            "winner"
1600        );
1601        Ok(())
1602    }
1603
1604    #[tokio::test]
1605    async fn run_surfaces_a_terminal_error_with_many_calls_in_flight() -> Result<()> {
1606        use std::sync::atomic::{AtomicUsize, Ordering};
1607
1608        // A large fan-out (10 matched the old, now-removed concurrency cap). The terminal
1609        // error must still reach the caller with all of these calls pending.
1610        const N: usize = 10;
1611
1612        // Fans out N calls, then errors as soon as all N are in flight — exercising a
1613        // terminal failure emitted while the offloaded calls are still pending.
1614        struct FanOutThenError {
1615            all_started: Arc<tokio::sync::Notify>,
1616            n: usize,
1617        }
1618
1619        #[async_trait]
1620        impl Algorithm for FanOutThenError {
1621            fn name(&self) -> &str {
1622                "fan_out_then_error"
1623            }
1624
1625            async fn route(
1626                self: Arc<Self>,
1627                driver: Driver,
1628                request: Request,
1629            ) -> Result<RoutingOutcome> {
1630                let offloads = futures::future::join_all(
1631                    (0..self.n)
1632                        .map(|i| driver.call_model(request.clone(), vec![format!("m{i}").into()])),
1633                );
1634                tokio::select! {
1635                    _ = offloads => Err(test_error("offloads unexpectedly completed")),
1636                    _ = self.all_started.notified() => {
1637                        Err(test_error("terminal error while calls pending"))
1638                    }
1639                }
1640            }
1641        }
1642
1643        let all_started = Arc::new(tokio::sync::Notify::new());
1644        let algo: Arc<dyn Algorithm> = Arc::new(FanOutThenError {
1645            all_started: all_started.clone(),
1646            n: N,
1647        });
1648
1649        // Serving enters each call; once all N are in flight it signals, then pends forever.
1650        let started = Arc::new(AtomicUsize::new(0));
1651        let serve = move |_target: ModelId, _request: Request| {
1652            let started = started.clone();
1653            let all_started = all_started.clone();
1654            async move {
1655                if started.fetch_add(1, Ordering::SeqCst) + 1 == N {
1656                    all_started.notify_one();
1657                }
1658                std::future::pending::<ServeResult>().await
1659            }
1660        };
1661
1662        // With the cap gone, the driver keeps polling the stream even with N calls in
1663        // flight, so the terminal error surfaces promptly instead of hanging.
1664        let run = test_drive(algo, request(), serve);
1665        let result = tokio::time::timeout(std::time::Duration::from_millis(500), run)
1666            .await
1667            .map_err(|error| {
1668                LibsyError::external("waiting for terminal error with full call cap", error)
1669            })?;
1670        match result {
1671            Ok(_) => Err(test_error("expected the terminal error, got a response")),
1672            Err(err) => {
1673                assert!(
1674                    err.to_string()
1675                        .contains("terminal error while calls pending")
1676                );
1677                Ok(())
1678            }
1679        }
1680    }
1681}