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