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