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