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