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//! The [`Algorithm`] trait and its [`Driver`] — the orchestration contract every
5//! algorithm implements, and the offload channel it uses to make model calls and
6//! publish [`Decision`]s.
7
8use std::{
9    collections::{HashMap, HashSet},
10    future::Future,
11    pin::Pin,
12    sync::Arc,
13    time::Instant,
14};
15
16use async_trait::async_trait;
17use futures::{Stream, StreamExt};
18use parking_lot::Mutex;
19use tracing::Instrument;
20
21/// The request/response protocol types come from [`switchyard_protocol`].
22/// [`switchyard_protocol::LlmRequest`] is the normalized request;
23/// [`switchyard_protocol::AggLlmResponse`] is the buffered response;
24/// [`switchyard_protocol::LlmResponseChunk`] is normalized streaming content;
25/// [`switchyard_protocol::LlmResponseStreamEvent`] is its host/algorithm envelope; and
26/// [`switchyard_protocol::LlmResponse`] carries either a live
27/// [`switchyard_protocol::LlmResponseStream`] or the terminal aggregate.
28use switchyard_protocol::{
29    Context, Decision, LlmClientError, Request, Response, RoutingFallbackReason, Signals,
30};
31
32use super::driver::{DriverRequest, DriverStep, TypeErasedDriver};
33use crate::{DriverError, LibsyError, Result, observability};
34
35/// A boxed, `Send` stream of [`Step`]s — the output of
36/// [`Algorithm::run_stream`]. Boxed so the trait method that produces it keeps
37/// `Arc<dyn Algorithm>` object-safe.
38pub type StepStream = Pin<Box<dyn Stream<Item = Result<Step>> + Send>>;
39
40/// A request paired with the routing [`Decision`] that produced it — the offload
41/// payload a host reads (via [`CallLlmRequest::get_routed`]) to serve the call.
42///
43/// The two model identifiers live in separate, unambiguous places: the model to
44/// call is [`decision.selected_model()`](Decision::selected_model), while
45/// `request.llm_request.model` is the *inbound* name the agent asked for (libsy
46/// never overwrites it). A client maps `selected_model()` to the provider model
47/// id it hits.
48#[derive(Clone)]
49pub struct RoutedRequest {
50    /// The request to serve; its `model` is the agent's original name.
51    pub request: Request,
52    /// The routing decision behind this call; `selected_model()` is the model to hit.
53    pub decision: Arc<dyn Decision>,
54    /// The request's cross-cutting context, carried through the offload so the host
55    /// serving the call can pass it to its client.
56    pub ctx: Context,
57}
58
59/// The host-facing half of an offloaded model call, surfaced inside [`Step::CallLlm`].
60///
61/// Wraps a `DriverRequest` whose payload is a [`RoutedRequest`]. The host reads the
62/// routed request ([`get_routed`](Self::get_routed)) and the decision behind it
63/// ([`get_decision`](Self::get_decision)), performs (or delegates) the model call, and
64/// fulfills it with [`respond`](Self::respond) — unblocking the algorithm's
65/// [`Driver::call_llm`] on the other side. `switchyard-llm-client`'s `run` is the
66/// ready-made consumer that does this for you.
67pub struct CallLlmRequest {
68    inner: DriverRequest,
69    routed: RoutedRequest,
70}
71
72impl CallLlmRequest {
73    /// Wrap a driver request whose payload is a [`RoutedRequest`]. Caches an owned copy
74    /// so the accessors are plain field reads.
75    fn new(inner: DriverRequest) -> Self {
76        // The payload is always a `RoutedRequest` (set by `Driver::call_llm`); a
77        // mismatch would be a libsy bug, not a runtime condition.
78        let routed = match inner.request::<RoutedRequest>() {
79            Ok(routed) => routed.clone(),
80            Err(_) => unreachable!("CallLlmRequest payload is always a RoutedRequest"),
81        };
82        Self { inner, routed }
83    }
84
85    /// The routed request the host should serve; its `decision.selected_model()` names
86    /// the model to hit.
87    pub fn get_routed(&self) -> &RoutedRequest {
88        &self.routed
89    }
90
91    /// The model request to perform (the [`Request`] inside the routed request).
92    pub fn get_request(&self) -> &Request {
93        &self.get_routed().request
94    }
95
96    /// The decision that led to this call — its `selected_model()` is the model to hit.
97    pub fn get_decision(&self) -> &dyn Decision {
98        self.get_routed().decision.as_ref()
99    }
100
101    /// Fulfill the promise with the caller's model-call result. Pass `Err(..)` to
102    /// propagate a failed model call back to the algorithm. Consumes the promise: it
103    /// can only be fulfilled once.
104    pub fn respond(self, result: Result<Response>) -> Result<()> {
105        self.inner.respond::<Response>(result)
106    }
107}
108
109/// The offload channel handed to an algorithm's
110/// [`create_run_task`](Algorithm::create_run_task). The algorithm makes model calls
111/// with [`call_llm`](Self::call_llm) and publishes its [`Decision`]s with
112/// [`info`](Self::info); each call is offloaded to the request's [`Step`] stream and
113/// awaits the consumer's response. The step channel is bounded, so the consumer paces
114/// the algorithm one step at a time.
115#[derive(Clone)]
116pub struct Driver {
117    driver: TypeErasedDriver,
118}
119
120impl Driver {
121    /// Build an empty driver with its step channel ready. Created per call by
122    /// [`run_stream`](Algorithm::run_stream).
123    pub(crate) fn new() -> Self {
124        Self {
125            driver: TypeErasedDriver::new(),
126        }
127    }
128
129    /// Offload a model call: publish `routed` as a [`Step::CallLlm`] and await the
130    /// consumer's [`Response`]. The call's context travels inside
131    /// [`routed.ctx`](RoutedRequest::ctx). Errors if the stream is closed or the call failed.
132    /// The await is wrapped in a `libsy.llm_call` span measuring *fulfillment* as
133    /// the algorithm observes it (host queueing/serving included; a streamed
134    /// response resolves when its stream handle arrives); latency, outcome, and
135    /// token usage are recorded when it resolves. The provider call itself is the
136    /// host's, and is instrumented by whoever makes it.
137    #[tracing::instrument(
138        target = "libsy",
139        name = "libsy.llm_call",
140        skip_all,
141        fields(
142            algorithm = observability::algorithm_label(&routed.ctx),
143            selected_model = routed.decision.selected_model(),
144            openinference.span.kind = "CHAIN",
145            outcome = tracing::field::Empty,
146            error = tracing::field::Empty,
147            input_tokens = tracing::field::Empty,
148            output_tokens = tracing::field::Empty,
149            total_tokens = tracing::field::Empty,
150            reasoning_tokens = tracing::field::Empty,
151        )
152    )]
153    pub async fn call_llm(&self, routed: RoutedRequest) -> Result<Response> {
154        let algorithm = observability::algorithm_label(&routed.ctx).to_string();
155        let selected_model = routed.decision.selected_model().to_string();
156        let tier = routed.decision.routing_tier().map(str::to_string);
157        let is_routed = routed.decision.is_routed_call();
158        let started = Instant::now();
159        let result = self
160            .driver
161            .fulfill_request::<RoutedRequest, Response>(routed.ctx.clone(), routed)
162            .await;
163        let elapsed = started.elapsed();
164        observability::record_llm_call(
165            &algorithm,
166            &selected_model,
167            tier.as_deref(),
168            is_routed,
169            elapsed,
170            &result,
171            &tracing::Span::current(),
172        );
173        result
174    }
175
176    /// Publish a routing [`Decision`] as a [`Step::Decision`] on the stream.
177    /// Each successfully published decision is counted and logged with its
178    /// reasoning; a decision the stream never accepted is not recorded.
179    pub async fn info(&self, ctx: Context, decision: Arc<dyn Decision>) -> Result<()> {
180        self.driver.info(ctx.clone(), decision.clone()).await?;
181        observability::record_decision(&ctx, decision.as_ref());
182        Ok(())
183    }
184
185    /// Emit the terminal step: [`Step::ReturnToAgent`] on `Ok`, or an `Err` stream
186    /// item on failure. Internal: called once by [`run_stream`](Algorithm::run_stream)
187    /// when the algorithm finishes.
188    pub(crate) async fn finish(&self, ctx: Context, result: Result<Response>) -> Result<()> {
189        match result {
190            Ok(response) => self.driver.done(ctx, response).await,
191            Err(err) => self.driver.fail(ctx, err).await,
192        }
193    }
194
195    /// Transform the raw driver stream into a stream of [`Step`]s. Internal: the
196    /// consumer stream is taken (once) by [`run_stream`](Algorithm::run_stream). A
197    /// payload that does not match the expected type for its step becomes an `Err` item.
198    pub(crate) fn stream(&self) -> impl Stream<Item = Result<Step>> + use<> {
199        self.driver.stream().map(|item| match item? {
200            DriverStep::Request(req) => Ok(Step::CallLlm(Box::new(CallLlmRequest::new(req)))),
201            DriverStep::Info(payload) => payload
202                .downcast::<Arc<dyn Decision>>()
203                .map(|decision| Step::Decision(*decision))
204                .map_err(|_| {
205                    DriverError::TypeMismatch {
206                        expected: "Arc<dyn Decision>",
207                    }
208                    .into()
209                }),
210            DriverStep::Done(payload) => payload
211                .downcast::<Response>()
212                .map(Step::ReturnToAgent)
213                .map_err(|_| {
214                    DriverError::TypeMismatch {
215                        expected: "Response",
216                    }
217                    .into()
218                }),
219        })
220    }
221}
222
223impl Default for Driver {
224    fn default() -> Self {
225        Self::new()
226    }
227}
228
229/// One item in the stream returned by `Driver::stream` / [`Algorithm::run_stream`].
230pub enum Step {
231    /// The algorithm needs this model call performed. The host serves it and fulfills
232    /// it with [`CallLlmRequest::respond`]. Boxed: it is by far the largest variant.
233    CallLlm(Box<CallLlmRequest>),
234    /// A routing decision the algorithm made, published via [`Driver::info`] as it
235    /// happens (rather than collected into a trace returned at the end).
236    Decision(Arc<dyn Decision>),
237    /// The algorithm finished with its final response — the last step of a run.
238    ReturnToAgent(Box<Response>),
239}
240
241/// Drive [`Algorithm::run_stream`] to completion, handing each offloaded call to `serve`.
242///
243/// Returns the final [`Response`] and the trace of [`Decision`]s the algorithm published.
244/// `serve` owns the call: it performs it however the host likes and must fulfill the promise
245/// with [`CallLlmRequest::respond`]. A failed *model* call belongs in `respond` — the
246/// algorithm may route around it. Returning `Err` from `serve` aborts the whole run, so
247/// reserve it for infrastructure failures. Calls are served concurrently, so an algorithm
248/// that offloads several at once (hedging, fan-out) gets real parallelism.
249///
250/// libsy performs no I/O; this is only the mechanics of consuming its own step stream, kept
251/// here so every host does not reimplement the same loop. `switchyard-llm-client`'s `run`
252/// is this function plus an HTTP client.
253pub async fn drive<F, Fut>(
254    algorithm: Arc<dyn Algorithm>,
255    ctx: Context,
256    request: Request,
257    serve: F,
258) -> Result<(Vec<Arc<dyn Decision>>, Response)>
259where
260    F: Fn(CallLlmRequest) -> Fut,
261    Fut: Future<Output = Result<()>>,
262{
263    let stream = algorithm.run_stream(ctx, request);
264    tokio::pin!(stream);
265
266    let mut trace: Vec<Arc<dyn Decision>> = Vec::new();
267    let mut in_flight = futures::stream::FuturesUnordered::new();
268    let mut final_response: Option<Response> = None;
269
270    loop {
271        tokio::select! {
272            Some(result) = in_flight.next() => match result {
273                Ok(()) => {}, // CallLlm completed successfully
274                Err(err) => return Err(err), // CallLlm failed, propagate the error
275            },
276            step = stream.next() => {
277                match step {
278                    None => break, // stream has ended, no more steps
279                    Some(item) => match item? {
280                        Step::CallLlm(call) => in_flight.push(serve(*call)),
281                        Step::Decision(decision) => trace.push(decision),
282                        Step::ReturnToAgent(response) => {
283                            final_response = Some(*response);
284                            break;
285                        }
286                    }
287                }
288            },
289        }
290    }
291    final_response
292        .map(|response| (trace, response))
293        .ok_or(LibsyError::MissingFinalResponse)
294}
295
296/// Abort guard
297struct AbortOnDrop(tokio::task::AbortHandle);
298
299impl Drop for AbortOnDrop {
300    fn drop(&mut self) {
301        self.0.abort();
302    }
303}
304
305/// A named routing target an algorithm routes by. Serving its calls is the stream
306/// consumer's concern: the target's name reaches the consumer as
307/// `decision.selected_model()` on the offloaded [`RoutedRequest`].
308#[derive(Clone)]
309pub struct LlmTarget {
310    /// The routing label an algorithm selects this target by — a logical tier like
311    /// `"strong"`, or the model id when they coincide. Mapping it to a provider model
312    /// id is the consumer's concern, never the algorithm's.
313    pub semantic_name: String,
314}
315
316/// The set of targets an algorithm may route among. An algorithm is constructed
317/// with one and picks targets by position ([`targets`](Self::targets)) or by name
318/// ([`get_target`](Self::get_target)).
319#[derive(Clone)]
320pub struct LlmTargetSet {
321    targets: Vec<LlmTarget>,
322}
323
324impl LlmTargetSet {
325    /// Build a target set from a list of targets.
326    pub fn new(targets: Vec<LlmTarget>) -> Self {
327        Self { targets }
328    }
329
330    /// All targets in the set — e.g. for an algorithm to select among.
331    pub fn targets(&self) -> &[LlmTarget] {
332        &self.targets
333    }
334
335    /// Look up a target by name; errors if no target has that name.
336    pub fn get_target(&self, name: &str) -> Result<LlmTarget> {
337        self.targets
338            .iter()
339            .find(|t| t.semantic_name == name)
340            .cloned()
341            .ok_or_else(|| LibsyError::TargetNotFound {
342                target: name.to_string(),
343            })
344    }
345
346    /// The named target, or the first one this request is not barred from when it has been
347    /// excluded (see [`Context::exclude_target`]). Errors if every target is excluded.
348    pub fn resolve_target(&self, name: &str, ctx: &Context) -> Result<LlmTarget> {
349        let target = self.get_target(name)?;
350        if !ctx.is_excluded(&target.semantic_name) {
351            return Ok(target);
352        }
353        self.targets
354            .iter()
355            .find(|t| !ctx.is_excluded(&t.semantic_name))
356            .cloned()
357            .ok_or(LibsyError::AllTargetsExcluded)
358    }
359}
360
361/// Key for overflow history: a root request by its session, a child request by its session
362/// and agent. Keying a child finer than its session keeps one child's overflow from evicting
363/// a target for the parent or a sibling sharing the session.
364#[derive(Clone, Hash, PartialEq, Eq)]
365pub(crate) enum RoutingIdentity {
366    /// Root request, keyed by session ID.
367    Session(String),
368    /// Child request, keyed by session and agent IDs.
369    Subagent { session: String, agent: String },
370}
371
372impl RoutingIdentity {
373    /// Builds a root or child identity from non-empty request metadata.
374    ///
375    /// A child request missing either ID returns `None`, so it keeps no routing history
376    /// rather than sharing the parent's.
377    pub(crate) fn from_request(request: &Request) -> Option<Self> {
378        let metadata = request.metadata.as_ref()?;
379        let session = metadata.session_id.as_deref().filter(|id| !id.is_empty())?;
380        if metadata.is_subagent {
381            let agent = metadata.agent_id.as_deref().filter(|id| !id.is_empty())?;
382            Some(Self::Subagent {
383                session: session.to_string(),
384                agent: agent.to_string(),
385            })
386        } else {
387            Some(Self::Session(session.to_string()))
388        }
389    }
390
391    /// The session this identity belongs to; shared by a session's root and its children.
392    fn session(&self) -> &str {
393        match self {
394            Self::Session(session) | Self::Subagent { session, .. } => session,
395        }
396    }
397}
398
399/// Bounds process-local overflow history. Dropping a live entry costs one rediscovered
400/// overflow, so the victim choice does not need to be exact.
401const MAX_EVICTION_IDENTITIES: usize = 1_024;
402
403/// Per-identity record of the targets that overflowed their context window.
404///
405/// A conversation only grows, so a target that could not fit one turn will not fit a
406/// later one; remembering it lets the next turn skip a call certain to fail. Requests
407/// without a routing identity are not tracked — there is nothing to remember them by.
408#[derive(Default)]
409pub(crate) struct SessionEvictions {
410    by_identity: Mutex<HashMap<RoutingIdentity, HashSet<String>>>,
411}
412
413impl SessionEvictions {
414    /// Forgets overflow history for a completed session, including every child of it.
415    pub(crate) fn remove_session(&self, session: &str) {
416        self.by_identity
417            .lock()
418            .retain(|identity, _| identity.session() != session);
419    }
420
421    /// The targets `identity` has already overflowed; empty for an untracked request.
422    fn evicted_for(&self, identity: Option<&RoutingIdentity>) -> Vec<String> {
423        let Some(identity) = identity else {
424            return Vec::new();
425        };
426        self.by_identity
427            .lock()
428            .get(identity)
429            .map(|targets| targets.iter().cloned().collect())
430            .unwrap_or_default()
431    }
432
433    /// Remembers that `target` overflowed for `identity`, tracking at most
434    /// [`MAX_EVICTION_IDENTITIES`] identities.
435    fn record(&self, identity: Option<&RoutingIdentity>, target: &str) {
436        let Some(identity) = identity else { return };
437        let mut histories = self.by_identity.lock();
438        if histories.len() >= MAX_EVICTION_IDENTITIES
439            && !histories.contains_key(identity)
440            && let Some(oldest) = histories.keys().next().cloned()
441        {
442            histories.remove(&oldest);
443        }
444        histories
445            .entry(identity.clone())
446            .or_default()
447            .insert(target.to_string());
448    }
449}
450
451/// How many of `targets` this request is still allowed to reach.
452fn eligible_targets(targets: &LlmTargetSet, ctx: &Context) -> usize {
453    targets
454        .targets()
455        .iter()
456        .filter(|t| !ctx.is_excluded(&t.semantic_name))
457        .count()
458}
459
460/// Bars the targets `identity` has already overflowed from this request, so routing does
461/// not select one that is certain to fail again.
462pub(crate) fn exclude_evicted(
463    ctx: &mut Context,
464    targets: &LlmTargetSet,
465    evictions: &SessionEvictions,
466    identity: Option<&RoutingIdentity>,
467) {
468    for target in evictions.evicted_for(identity) {
469        // Never seed the pool empty: a later turn may be small enough to serve, and the
470        // caller should get the upstream's answer rather than a routing error.
471        if eligible_targets(targets, ctx) <= 1 {
472            break;
473        }
474        ctx.exclude_target(target);
475    }
476}
477
478/// Returns the failed target and routing fallback policy for a terminal client error.
479fn classify_fallback(error: &LibsyError) -> Option<(&str, RoutingFallbackReason)> {
480    let LibsyError::ClientCall { target, source } = error else {
481        return None;
482    };
483    let reason = match source {
484        LlmClientError::ContextWindowExceeded { .. } => RoutingFallbackReason::ContextWindow,
485        LlmClientError::Transport { .. } | LlmClientError::Timeout { .. } => {
486            RoutingFallbackReason::Unavailable
487        }
488        LlmClientError::UpstreamHttp { status, .. }
489            if matches!(*status, 403 | 408 | 429) || (500..=599).contains(status) =>
490        {
491            RoutingFallbackReason::Unavailable
492        }
493        _ => return None,
494    };
495    Some((target, reason))
496}
497
498/// Calls `target`, falling back to the next eligible target after a route-level failure,
499/// until a call succeeds or every target has been tried.
500///
501/// Routing is deliberately not re-run: the fallback replaces the target in place, so the
502/// caller's request-side work and retained state still see exactly one turn.
503/// `fallback_decision` builds the [`Decision`] published for a `from -> to` hop. Context
504/// overflows are recorded for `identity`; unavailable targets remain request-local.
505#[allow(clippy::too_many_arguments)]
506pub(crate) async fn call_llm_with_fallback(
507    mut ctx: Context,
508    driver: &Driver,
509    targets: &LlmTargetSet,
510    mut target: LlmTarget,
511    mut decision: Arc<dyn Decision>,
512    request: Request,
513    identity: Option<&RoutingIdentity>,
514    evictions: &SessionEvictions,
515    target_unavailable: impl Fn(&Request, &str),
516    fallback_decision: impl Fn(&LlmTarget, &LlmTarget, RoutingFallbackReason) -> Arc<dyn Decision>,
517) -> Result<Response> {
518    loop {
519        let result = driver
520            .call_llm(RoutedRequest {
521                request: request.clone(),
522                decision: decision.clone(),
523                ctx: ctx.clone(),
524            })
525            .await;
526        let Err(error) = result else { return result };
527        let Some((failed, reason)) = classify_fallback(&error) else {
528            return Err(error);
529        };
530        // A target already excluded means the pool is spent; surface the client error
531        // so the caller still sees the concrete upstream failure.
532        if !ctx.exclude_target(failed) {
533            return Err(error);
534        }
535        match reason {
536            RoutingFallbackReason::ContextWindow => evictions.record(identity, failed),
537            RoutingFallbackReason::Unavailable => target_unavailable(&request, failed),
538        }
539        let Ok(next) = targets.resolve_target(&target.semantic_name, &ctx) else {
540            return Err(error);
541        };
542        decision = fallback_decision(&target, &next, reason);
543        target = next;
544        driver.info(ctx.clone(), decision.clone()).await?;
545    }
546}
547
548/// An optimization strategy. Implement [`create_run_task`](Self::create_run_task);
549/// callers drive it with [`run_stream`](Self::run_stream), serving each [`Step::CallLlm`]
550/// it emits. `switchyard-llm-client`'s `run` is the ready-made consumer that does this
551/// over HTTP.
552///
553/// Methods take `self: Arc<Self>`: one algorithm (`Arc<dyn Algorithm>`) is shared across
554/// requests and run concurrently, so it owns its thread-safety and any shared state.
555///
556/// # Concurrency
557///
558/// A host may run the same algorithm concurrently for many requests. Implementations
559/// must synchronize their own mutable shared state. Each call to [`run_stream`](Self::run_stream)
560/// creates an independent [`Driver`], so model-call promises and emitted [`Step`]s cannot
561/// cross between runs.
562///
563/// # Observability
564///
565/// [`run_stream`](Self::run_stream) creates a `libsy.run` span, and each offloaded model
566/// call creates a `libsy.llm_call` span. Decisions and failures are emitted through
567/// `tracing`; metrics use the global OpenTelemetry meter provider. The provider call
568/// itself belongs to the host, and is instrumented by whoever makes it.
569#[async_trait]
570pub trait Algorithm: Send + Sync + 'static {
571    /// Stable, low-cardinality name identifying this algorithm — the
572    /// `algorithm` attribute on every span, metric, and log line the crate
573    /// emits for its runs.
574    fn name(&self) -> &str;
575
576    /// Run one request to completion: make model calls with [`Driver::call_llm`],
577    /// publish [`Decision`]s with [`Driver::info`], and return the final [`Response`].
578    /// The method an algorithm implements; [`run_stream`](Self::run_stream) drives it.
579    /// `ctx` carries the request's cross-cutting values (today: the algorithm's
580    /// telemetry label in [`Context::values`]).
581    async fn create_run_task(
582        self: Arc<Self>,
583        ctx: Context,
584        driver: Driver,
585        request: Request,
586    ) -> Result<Response>;
587
588    /// Feed the algorithm agentic-stack events (tool results, budgets, etc.). The
589    /// reference algorithms ignore signals; a stateful algorithm updates its own
590    /// (interior-mutable) state. Takes `self: Arc<Self>` like the other run methods.
591    #[allow(unused_variables)]
592    async fn process_signals(self: Arc<Self>, signals: Signals) -> Result<()> {
593        Ok(())
594    }
595
596    /// Process a request to completion, returning a stream of [`Step`]s.
597    ///
598    /// The consumer must fulfill every [`Step::CallLlm`] before the algorithm can
599    /// continue. The bounded step channel applies backpressure when the consumer is
600    /// not polling. A successful run ends with [`Step::ReturnToAgent`]; a failure is
601    /// emitted as an `Err` item. Dropping the stream aborts the spawned algorithm task.
602    ///
603    /// Every invocation owns a separate [`Driver`].
604    fn run_stream(self: Arc<Self>, ctx: Context, request: Request) -> StepStream {
605        // Stamp the algorithm's telemetry label into the request context; the
606        // context rides on every driver call, so its telemetry is attributed.
607        let mut ctx = ctx;
608        ctx.values.insert(
609            observability::ALGORITHM_KEY.to_string(),
610            self.name().to_string(),
611        );
612        let driver = Driver::new();
613        let task_driver = driver.clone();
614        let task_ctx = ctx.clone();
615        let stream = task_driver.stream();
616        // One `libsy.run` span covers the whole algorithm task; the driver's
617        // `libsy.llm_call` spans and decision logs nest inside it via `tracing`'s
618        // contextual parenting.
619        let span = observability::run_span(self.name(), &request);
620        let handle = tokio::spawn(
621            async move {
622                observability::observe_run(
623                    task_ctx.clone(),
624                    self.create_run_task(task_ctx, task_driver, request),
625                )
626                .await
627            }
628            .instrument(span),
629        );
630        // Dropping the stream aborts the algorithm task when its consumer goes away.
631        let abort_guard = AbortOnDrop(handle.abort_handle());
632
633        let finish_driver = driver.clone();
634        let finish_ctx = ctx;
635        let tail: StepStream = Box::pin(
636            futures::stream::once(async move {
637                let result = match handle.await {
638                    Ok(response) => response,
639                    Err(source) => Err(LibsyError::AlgorithmTask { source }),
640                };
641                finish_driver.finish(finish_ctx, result).await
642            })
643            .filter_map(|finish_result| async move { finish_result.err().map(Err) }),
644        );
645
646        let stream: StepStream = Box::pin(stream);
647        Box::pin(futures::stream::select(stream, tail).map(move |step| {
648            // link abort guard to stream
649            let _keep_alive = &abort_guard;
650            step
651        }))
652    }
653}
654
655#[cfg(test)]
656mod tests {
657    use super::*;
658    use crate::core::testing::{Serve, ServeResult, echo, reply, test_drive};
659    use futures::StreamExt;
660    use switchyard_protocol::{
661        LlmResponse, LlmResponseChunk, completion_text, text_request, text_response,
662    };
663
664    #[derive(Debug, thiserror::Error)]
665    #[error("{0}")]
666    struct TestError(&'static str);
667
668    fn test_error(message: &'static str) -> LibsyError {
669        LibsyError::external("test", TestError(message))
670    }
671
672    fn classified_client_error(source: LlmClientError) -> Option<RoutingFallbackReason> {
673        classify_fallback(&LibsyError::client_call("target", source)).map(|(_, reason)| reason)
674    }
675
676    #[test]
677    fn route_fallback_only_accepts_context_and_unavailable_failures() {
678        assert_eq!(
679            classified_client_error(LlmClientError::ContextWindowExceeded {
680                model: "target".to_string(),
681                message: "too long".to_string(),
682            }),
683            Some(RoutingFallbackReason::ContextWindow)
684        );
685        for source in [
686            LlmClientError::Transport {
687                source: Box::new(std::io::Error::other("connection failed")),
688            },
689            LlmClientError::Timeout {
690                source: Box::new(std::io::Error::other("request timed out")),
691            },
692        ] {
693            assert_eq!(
694                classified_client_error(source),
695                Some(RoutingFallbackReason::Unavailable)
696            );
697        }
698        for (status, expected) in [
699            (400, None),
700            (401, None),
701            (403, Some(RoutingFallbackReason::Unavailable)),
702            (404, None),
703            (408, Some(RoutingFallbackReason::Unavailable)),
704            (409, None),
705            (429, Some(RoutingFallbackReason::Unavailable)),
706            (499, None),
707            (500, Some(RoutingFallbackReason::Unavailable)),
708            (599, Some(RoutingFallbackReason::Unavailable)),
709            (600, None),
710        ] {
711            assert_eq!(
712                classified_client_error(LlmClientError::UpstreamHttp {
713                    status,
714                    body: "failed".to_string(),
715                }),
716                expected
717            );
718        }
719        assert_eq!(
720            classified_client_error(LlmClientError::InvalidResponse {
721                source: Box::new(std::io::Error::other("invalid response")),
722            }),
723            None
724        );
725    }
726
727    /// Trivial decision + algo used only to exercise the orchestrator: calls the
728    /// first target and returns its response with a one-item trace.
729    struct TestDecision {
730        model: String,
731    }
732
733    impl Decision for TestDecision {
734        fn selected_model(&self) -> &str {
735            &self.model
736        }
737        fn reasoning(&self) -> Option<&str> {
738            None
739        }
740        fn as_any(&self) -> &dyn std::any::Any {
741            self
742        }
743    }
744
745    struct TestAlgo {
746        target_set: LlmTargetSet,
747    }
748
749    #[async_trait]
750    impl Algorithm for TestAlgo {
751        fn name(&self) -> &str {
752            "test"
753        }
754
755        async fn create_run_task(
756            self: Arc<Self>,
757            ctx: Context,
758            driver: Driver,
759            request: Request,
760        ) -> Result<Response> {
761            let target = self
762                .target_set
763                .targets()
764                .first()
765                .ok_or(LibsyError::NoTargets)?
766                .clone();
767            let decision: Arc<dyn Decision> = Arc::new(TestDecision {
768                model: target.semantic_name.clone(),
769            });
770            driver.info(ctx.clone(), decision.clone()).await?;
771            driver
772                .call_llm(RoutedRequest {
773                    request,
774                    decision,
775                    ctx,
776                })
777                .await
778        }
779    }
780
781    /// Build a shared `TestAlgo` over the given target set.
782    fn orch(target_set: LlmTargetSet) -> Arc<dyn Algorithm> {
783        Arc::new(TestAlgo { target_set })
784    }
785
786    fn request() -> Request {
787        Request {
788            llm_request: text_request(Some("auto".to_string()), "hi".to_string()),
789            raw_request: None,
790            metadata: None,
791        }
792    }
793
794    fn target_set(names: &[&str]) -> LlmTargetSet {
795        let targets = names
796            .iter()
797            .map(|name| LlmTarget {
798                semantic_name: name.to_string(),
799            })
800            .collect();
801        LlmTargetSet::new(targets)
802    }
803
804    #[test]
805    fn target_lookup_returns_the_missing_target() {
806        let error = target_set(&[]).get_target("missing").err();
807        assert!(matches!(
808            error,
809            Some(LibsyError::TargetNotFound { target }) if target == "missing"
810        ));
811    }
812
813    /// Build a single-target algo, plus a `serve` that answers it as a token stream
814    /// replaying `chunks` in order (as `Ok` items).
815    fn streaming_orch(chunks: Vec<LlmResponseChunk>) -> (Arc<dyn Algorithm>, impl Serve) {
816        let algo = orch(target_set(&["stream/model"]));
817        let serve = move |_decision: Arc<dyn Decision>, _request: Request| {
818            let chunks = chunks.clone();
819            async move {
820                let stream =
821                    futures::stream::iter(chunks.into_iter().map(|chunk| Ok(chunk.into()))).boxed();
822                Ok(Response {
823                    llm_response: LlmResponse::Stream(stream),
824                    metadata: None,
825                })
826            }
827        };
828        (algo, serve)
829    }
830
831    #[tokio::test]
832    async fn run_returns_a_streamed_response_the_caller_aggregates() -> Result<()> {
833        // A streaming client -> its chunks flow through the promise and `ReturnToAgent`,
834        // and `run` returns the live stream untouched for the caller to fold.
835        let (orch, serve) = streaming_orch(vec![
836            LlmResponseChunk::MessageStart {
837                id: Some("m1".to_string()),
838                model: Some("stream/model".to_string()),
839            },
840            LlmResponseChunk::TextDelta {
841                index: 0,
842                text: "hel".to_string(),
843            },
844            LlmResponseChunk::TextDelta {
845                index: 0,
846                text: "lo".to_string(),
847            },
848            LlmResponseChunk::MessageStop {
849                reason: Some("stop".to_string()),
850            },
851        ]);
852        let (trace, response) = test_drive(orch, Context::default(), request(), serve).await?;
853        // The run handed back the live stream; the caller folds it to a buffered aggregate.
854        let agg = response
855            .llm_response
856            .into_agg()
857            .await
858            .map_err(|error| LibsyError::external("aggregating response stream", error))?;
859        assert_eq!(completion_text(&agg), "hello");
860        assert_eq!(agg.model.as_deref(), Some("stream/model"));
861        assert_eq!(trace.len(), 1);
862        Ok(())
863    }
864
865    #[tokio::test]
866    async fn aggregating_a_streamed_response_propagates_a_mid_stream_error() -> Result<()> {
867        // The run succeeds and returns the stream; the in-band `Error` chunk surfaces only
868        // when the caller aggregates it.
869        let (orch, serve) = streaming_orch(vec![
870            LlmResponseChunk::TextDelta {
871                index: 0,
872                text: "partial".to_string(),
873            },
874            LlmResponseChunk::StreamError {
875                message: "upstream exploded".to_string(),
876            },
877        ]);
878        let (_, response) = test_drive(orch, Context::default(), request(), serve).await?;
879        match response.llm_response.into_agg().await {
880            Ok(_) => panic!("expected a mid-stream error, got an aggregate"),
881            Err(err) => {
882                assert!(err.to_string().contains("upstream exploded"));
883                Ok(())
884            }
885        }
886    }
887
888    #[tokio::test]
889    async fn run_offloads_via_promise_then_returns_to_agent() -> Result<()> {
890        // Every call is offloaded via a promise the orchestrator surfaces as a
891        // `CallLlm` step for us to fulfill.
892        let stream = orch(target_set(&["offload/model"])).run_stream(Context::default(), request());
893        tokio::pin!(stream);
894
895        let mut saw_call = false;
896        let mut final_completion = None;
897        while let Some(step) = stream.next().await {
898            match step? {
899                Step::CallLlm(call) => {
900                    saw_call = true;
901                    // The decision rode along with the promise.
902                    assert_eq!(call.get_decision().selected_model(), "offload/model");
903                    // Fulfilling the promise is the "real" model call the caller makes.
904                    call.respond(Ok(Response {
905                        llm_response: LlmResponse::Agg(text_response(
906                            None,
907                            "fulfilled".to_string(),
908                        )),
909                        metadata: None,
910                    }))?;
911                }
912                Step::Decision(decision) => {
913                    assert_eq!(decision.selected_model(), "offload/model");
914                }
915                Step::ReturnToAgent(response) => {
916                    final_completion = Some(
917                        response
918                            .llm_response
919                            .as_agg()
920                            .map(completion_text)
921                            .unwrap_or_default(),
922                    );
923                }
924            }
925        }
926
927        assert!(saw_call, "expected a CallLlm step before ReturnToAgent");
928        assert_eq!(
929            final_completion.ok_or_else(|| test_error("no ReturnToAgent step"))?,
930            "fulfilled"
931        );
932        Ok(())
933    }
934
935    #[tokio::test]
936    async fn a_driven_run_returns_the_trace_and_the_final_response() -> Result<()> {
937        let (trace, response) = test_drive(
938            orch(target_set(&["direct/model"])),
939            Context::default(),
940            request(),
941            echo(),
942        )
943        .await?;
944        // TestAlgo calls the first target; `echo` answers with its name.
945        assert_eq!(
946            response
947                .llm_response
948                .as_agg()
949                .map(completion_text)
950                .unwrap_or_default(),
951            "direct/model"
952        );
953        assert_eq!(trace[0].selected_model(), "direct/model");
954        Ok(())
955    }
956
957    #[tokio::test(flavor = "multi_thread", worker_threads = 12)]
958    async fn requests_are_processed_in_parallel() -> Result<()> {
959        use std::time::Duration;
960        use tokio::sync::Barrier;
961
962        const N: usize = 12;
963
964        // Serving blocks until all N concurrent calls have arrived. If requests were
965        // serialized (one algorithm behind a `Mutex`), only one call could be in flight,
966        // the barrier would never reach N, and the test would time out. It passes only
967        // because the shared algorithm is driven concurrently across requests.
968        let barrier = Arc::new(Barrier::new(N));
969        // One shared algorithm driven by many concurrent requests.
970        let algo = orch(target_set(&["m"]));
971
972        let mut handles = Vec::new();
973        for _ in 0..N {
974            let algo = algo.clone();
975            let barrier = barrier.clone();
976            let serve = move |decision: Arc<dyn Decision>, _request: Request| {
977                let barrier = barrier.clone();
978                async move {
979                    barrier.wait().await;
980                    Ok(reply(decision.selected_model()))
981                }
982            };
983            handles.push(tokio::spawn(async move {
984                test_drive(algo, Context::default(), request(), serve)
985                    .await
986                    .map(|(_, response)| {
987                        response
988                            .llm_response
989                            .as_agg()
990                            .map(completion_text)
991                            .unwrap_or_default()
992                    })
993            }));
994        }
995
996        for handle in handles {
997            // The timeout turns a serialization deadlock into a failure, not a hang.
998            let completion = tokio::time::timeout(Duration::from_secs(5), handle)
999                .await
1000                .map_err(|error| LibsyError::external("waiting for test task", error))?
1001                .map_err(|source| LibsyError::AlgorithmTask { source })??;
1002            assert_eq!(completion, "m");
1003        }
1004        Ok(())
1005    }
1006
1007    #[tokio::test]
1008    async fn offload_error_propagates_back_to_the_algorithm() -> Result<()> {
1009        // A client-less target offloads its call; we fulfill the promise with an
1010        // Err, which must flow back through `call_llm_target` into the algorithm and
1011        // out as an error step — not a response.
1012        let stream = orch(target_set(&["offload/model"])).run_stream(Context::default(), request());
1013        tokio::pin!(stream);
1014
1015        let mut saw_error = false;
1016        while let Some(step) = stream.next().await {
1017            match step {
1018                Ok(Step::CallLlm(call)) => {
1019                    call.respond(Err(test_error("upstream model call failed")))?;
1020                }
1021                Ok(Step::Decision(_)) => {}
1022                Ok(Step::ReturnToAgent(..)) => {
1023                    return Err(test_error(
1024                        "expected the offload error to propagate, got a response",
1025                    ));
1026                }
1027                Err(err) => {
1028                    // The algorithm's `call_llm_target` saw the error via the promise.
1029                    assert!(err.to_string().contains("upstream model call failed"));
1030                    saw_error = true;
1031                }
1032            }
1033        }
1034
1035        assert!(saw_error, "expected an error step");
1036        Ok(())
1037    }
1038
1039    #[tokio::test]
1040    async fn dropping_the_stream_cancels_the_algorithm_task() -> Result<()> {
1041        use std::sync::atomic::{AtomicBool, Ordering};
1042        use std::time::Duration;
1043        use tokio::sync::mpsc;
1044
1045        // Sets a flag when dropped, so we can observe whether the algorithm task was
1046        // cancelled/dropped.
1047        struct DropGuard(Arc<AtomicBool>);
1048        impl Drop for DropGuard {
1049            fn drop(&mut self) {
1050                self.0.store(true, Ordering::SeqCst);
1051            }
1052        }
1053
1054        struct StuckAlgo {
1055            started: mpsc::UnboundedSender<()>,
1056            dropped: Arc<AtomicBool>,
1057        }
1058
1059        #[async_trait]
1060        impl Algorithm for StuckAlgo {
1061            fn name(&self) -> &str {
1062                "stuck"
1063            }
1064
1065            async fn create_run_task(
1066                self: Arc<Self>,
1067                _ctx: Context,
1068                _driver: Driver,
1069                _request: Request,
1070            ) -> Result<Response> {
1071                let _guard = DropGuard(self.dropped.clone());
1072                let _ = self.started.send(());
1073                // Await forever without ever touching the driver.
1074                std::future::pending::<()>().await;
1075                unreachable!()
1076            }
1077        }
1078
1079        let (started_tx, mut started_rx) = mpsc::unbounded_channel();
1080        let dropped = Arc::new(AtomicBool::new(false));
1081        let algo: Arc<dyn Algorithm> = Arc::new(StuckAlgo {
1082            started: started_tx,
1083            dropped: dropped.clone(),
1084        });
1085
1086        let stream = algo.run_stream(Context::default(), request());
1087        started_rx
1088            .recv()
1089            .await
1090            .ok_or_else(|| test_error("task never started"))?;
1091        drop(stream);
1092        tokio::time::sleep(Duration::from_millis(100)).await;
1093
1094        assert!(
1095            dropped.load(Ordering::SeqCst),
1096            "algorithm task was NOT cancelled after dropping the stream"
1097        );
1098        Ok(())
1099    }
1100
1101    #[tokio::test]
1102    async fn create_run_task_panic_surfaces_as_a_stream_error() -> Result<()> {
1103        // An algorithm whose task panics must surface an `Err` step to the stream
1104        // consumer, not abort the process from an unobserved detached task.
1105        struct Panicky;
1106
1107        #[async_trait]
1108        impl Algorithm for Panicky {
1109            fn name(&self) -> &str {
1110                "panicky"
1111            }
1112
1113            async fn create_run_task(
1114                self: Arc<Self>,
1115                _ctx: Context,
1116                _driver: Driver,
1117                _request: Request,
1118            ) -> Result<Response> {
1119                panic!("boom");
1120            }
1121        }
1122
1123        let algo: Arc<dyn Algorithm> = Arc::new(Panicky);
1124        let stream = algo.run_stream(Context::default(), request());
1125        tokio::pin!(stream);
1126
1127        let mut saw_error = false;
1128        while let Some(step) = stream.next().await {
1129            match step {
1130                Err(err) => {
1131                    assert!(matches!(err, LibsyError::AlgorithmTask { .. }));
1132                    saw_error = true;
1133                }
1134                Ok(_) => return Err(test_error("expected the panic to surface as an error step")),
1135            }
1136        }
1137
1138        assert!(saw_error, "expected an error step from the panicked task");
1139        Ok(())
1140    }
1141
1142    #[tokio::test]
1143    async fn run_returns_an_error_when_the_algorithm_task_panics() -> Result<()> {
1144        // The panic surfaces as an `Err` step inside `run_stream`; `run` propagates it
1145        // via `?`, so the caller gets an `Err` rather than a hang or a silent panic.
1146        struct Panicky;
1147
1148        #[async_trait]
1149        impl Algorithm for Panicky {
1150            fn name(&self) -> &str {
1151                "panicky"
1152            }
1153
1154            async fn create_run_task(
1155                self: Arc<Self>,
1156                _ctx: Context,
1157                _driver: Driver,
1158                _request: Request,
1159            ) -> Result<Response> {
1160                panic!("boom");
1161            }
1162        }
1163
1164        let algo: Arc<dyn Algorithm> = Arc::new(Panicky);
1165        match test_drive(algo, Context::default(), request(), echo()).await {
1166            Ok(_) => Err(test_error(
1167                "expected the run to surface the algorithm panic as an error",
1168            )),
1169            Err(err) => {
1170                assert!(matches!(err, LibsyError::AlgorithmTask { .. }));
1171                Ok(())
1172            }
1173        }
1174    }
1175
1176    #[tokio::test]
1177    async fn cancelling_run_cancels_the_algorithm_task() -> Result<()> {
1178        use std::sync::atomic::{AtomicBool, Ordering};
1179        use std::time::Duration;
1180        use tokio::sync::mpsc;
1181
1182        // Sets a flag when dropped, so we can observe whether the algorithm task was
1183        // cancelled once the `run` future driving it is dropped.
1184        struct DropGuard(Arc<AtomicBool>);
1185        impl Drop for DropGuard {
1186            fn drop(&mut self) {
1187                self.0.store(true, Ordering::SeqCst);
1188            }
1189        }
1190
1191        struct StuckAlgo {
1192            started: mpsc::UnboundedSender<()>,
1193            dropped: Arc<AtomicBool>,
1194        }
1195
1196        #[async_trait]
1197        impl Algorithm for StuckAlgo {
1198            fn name(&self) -> &str {
1199                "stuck"
1200            }
1201
1202            async fn create_run_task(
1203                self: Arc<Self>,
1204                _ctx: Context,
1205                _driver: Driver,
1206                _request: Request,
1207            ) -> Result<Response> {
1208                let _guard = DropGuard(self.dropped.clone());
1209                let _ = self.started.send(());
1210                // Hang forever without ever touching the driver, so only cancellation
1211                // (not a dropped step channel) can stop this task.
1212                std::future::pending::<()>().await;
1213                unreachable!()
1214            }
1215        }
1216
1217        let (started_tx, mut started_rx) = mpsc::unbounded_channel();
1218        let dropped = Arc::new(AtomicBool::new(false));
1219        let algo: Arc<dyn Algorithm> = Arc::new(StuckAlgo {
1220            started: started_tx,
1221            dropped: dropped.clone(),
1222        });
1223
1224        // Drive the run on its own task, wait until the algorithm task is up, then cancel
1225        // it — dropping its future (and the `run_stream` stream it holds).
1226        let run_task =
1227            tokio::spawn(
1228                async move { test_drive(algo, Context::default(), request(), echo()).await },
1229            );
1230        started_rx
1231            .recv()
1232            .await
1233            .ok_or_else(|| test_error("task never started"))?;
1234        run_task.abort();
1235        tokio::time::sleep(Duration::from_millis(100)).await;
1236
1237        assert!(
1238            dropped.load(Ordering::SeqCst),
1239            "algorithm task was NOT cancelled after cancelling run"
1240        );
1241        Ok(())
1242    }
1243
1244    // --- first-wins hedging: `run` must not wait on losing speculative calls -------------
1245
1246    /// Offloads two targets concurrently and returns the first to resolve, dropping the
1247    /// loser's call (first-wins hedging).
1248    struct Hedge {
1249        winner: LlmTarget,
1250        loser: LlmTarget,
1251    }
1252
1253    #[async_trait]
1254    impl Algorithm for Hedge {
1255        fn name(&self) -> &str {
1256            "hedge"
1257        }
1258
1259        async fn create_run_task(
1260            self: Arc<Self>,
1261            ctx: Context,
1262            driver: Driver,
1263            request: Request,
1264        ) -> Result<Response> {
1265            let dec_w: Arc<dyn Decision> = Arc::new(TestDecision {
1266                model: self.winner.semantic_name.clone(),
1267            });
1268            let dec_l: Arc<dyn Decision> = Arc::new(TestDecision {
1269                model: self.loser.semantic_name.clone(),
1270            });
1271            let win = driver.call_llm(RoutedRequest {
1272                request: request.clone(),
1273                decision: dec_w,
1274                ctx: ctx.clone(),
1275            });
1276            let lose = driver.call_llm(RoutedRequest {
1277                request,
1278                decision: dec_l,
1279                ctx,
1280            });
1281            // First to resolve wins; `select!` drops the losing future (and its promise).
1282            tokio::select! {
1283                res = win => res,
1284                res = lose => res,
1285            }
1286        }
1287    }
1288
1289    /// Builds a hedging algo and the `serve` that drives it: the winner is gated behind the
1290    /// loser starting (so the loser's serve is guaranteed in flight when the winner wins),
1291    /// and the loser finishes after `loser_delay` — or never, when `None`.
1292    fn hedge(loser_delay: Option<std::time::Duration>) -> (Arc<dyn Algorithm>, impl Serve) {
1293        let started = Arc::new(tokio::sync::Notify::new());
1294        let algo = Arc::new(Hedge {
1295            winner: LlmTarget {
1296                semantic_name: "winner".to_string(),
1297            },
1298            loser: LlmTarget {
1299                semantic_name: "loser".to_string(),
1300            },
1301        });
1302        let serve = move |decision: Arc<dyn Decision>, _request: Request| {
1303            let started = started.clone();
1304            async move {
1305                if decision.selected_model() == "loser" {
1306                    started.notify_one();
1307                    match loser_delay {
1308                        Some(delay) => tokio::time::sleep(delay).await,
1309                        None => std::future::pending::<()>().await,
1310                    }
1311                } else {
1312                    started.notified().await;
1313                }
1314                Ok(reply(decision.selected_model()))
1315            }
1316        };
1317        (algo, serve)
1318    }
1319
1320    #[tokio::test]
1321    async fn run_returns_the_winner_without_a_late_loser_overwriting_it() -> Result<()> {
1322        // The loser responds 50ms after the winner has already won. `run` must return the
1323        // winner, not the loser's `respond`-to-a-dropped-receiver error.
1324        let (algo, serve) = hedge(Some(std::time::Duration::from_millis(50)));
1325        let (_trace, response) = test_drive(algo, Context::default(), request(), serve).await?;
1326        assert_eq!(
1327            response
1328                .llm_response
1329                .as_agg()
1330                .map(completion_text)
1331                .unwrap_or_default(),
1332            "winner"
1333        );
1334        Ok(())
1335    }
1336
1337    #[tokio::test]
1338    async fn run_returns_the_winner_without_hanging_on_a_pending_loser() -> Result<()> {
1339        // The loser never resolves. `run` must return the winner promptly, not hang
1340        // waiting for the in-flight loser.
1341        let (algo, serve) = hedge(None);
1342        let run = test_drive(algo, Context::default(), request(), serve);
1343        let (_trace, response) = tokio::time::timeout(std::time::Duration::from_secs(1), run)
1344            .await
1345            .map_err(|error| LibsyError::external("waiting for pending loser", error))??;
1346        assert_eq!(
1347            response
1348                .llm_response
1349                .as_agg()
1350                .map(completion_text)
1351                .unwrap_or_default(),
1352            "winner"
1353        );
1354        Ok(())
1355    }
1356
1357    #[tokio::test]
1358    async fn run_surfaces_a_terminal_error_with_many_calls_in_flight() -> Result<()> {
1359        use std::sync::atomic::{AtomicUsize, Ordering};
1360
1361        // A large fan-out (10 matched the old, now-removed concurrency cap). The terminal
1362        // error must still reach the caller with all of these calls pending.
1363        const N: usize = 10;
1364
1365        // Fans out N calls, then errors as soon as all N are in flight — exercising a
1366        // terminal failure emitted while the offloaded calls are still pending.
1367        struct FanOutThenError {
1368            all_started: Arc<tokio::sync::Notify>,
1369            n: usize,
1370        }
1371
1372        #[async_trait]
1373        impl Algorithm for FanOutThenError {
1374            fn name(&self) -> &str {
1375                "fan_out_then_error"
1376            }
1377
1378            async fn create_run_task(
1379                self: Arc<Self>,
1380                ctx: Context,
1381                driver: Driver,
1382                request: Request,
1383            ) -> Result<Response> {
1384                let offloads = futures::future::join_all((0..self.n).map(|i| {
1385                    let decision: Arc<dyn Decision> = Arc::new(TestDecision {
1386                        model: format!("m{i}"),
1387                    });
1388                    driver.call_llm(RoutedRequest {
1389                        request: request.clone(),
1390                        decision,
1391                        ctx: ctx.clone(),
1392                    })
1393                }));
1394                tokio::select! {
1395                    _ = offloads => Err(test_error("offloads unexpectedly completed")),
1396                    _ = self.all_started.notified() => {
1397                        Err(test_error("terminal error while calls pending"))
1398                    }
1399                }
1400            }
1401        }
1402
1403        let all_started = Arc::new(tokio::sync::Notify::new());
1404        let algo: Arc<dyn Algorithm> = Arc::new(FanOutThenError {
1405            all_started: all_started.clone(),
1406            n: N,
1407        });
1408
1409        // Serving enters each call; once all N are in flight it signals, then pends forever.
1410        let started = Arc::new(AtomicUsize::new(0));
1411        let serve = move |_decision: Arc<dyn Decision>, _request: Request| {
1412            let started = started.clone();
1413            let all_started = all_started.clone();
1414            async move {
1415                if started.fetch_add(1, Ordering::SeqCst) + 1 == N {
1416                    all_started.notify_one();
1417                }
1418                std::future::pending::<ServeResult>().await
1419            }
1420        };
1421
1422        // With the cap gone, the driver keeps polling the stream even with N calls in
1423        // flight, so the terminal error surfaces promptly instead of hanging.
1424        let run = test_drive(algo, Context::default(), request(), serve);
1425        let result = tokio::time::timeout(std::time::Duration::from_millis(500), run)
1426            .await
1427            .map_err(|error| {
1428                LibsyError::external("waiting for terminal error with full call cap", error)
1429            })?;
1430        match result {
1431            Ok(_) => Err(test_error("expected the terminal error, got a response")),
1432            Err(err) => {
1433                assert!(
1434                    err.to_string()
1435                        .contains("terminal error while calls pending")
1436                );
1437                Ok(())
1438            }
1439        }
1440    }
1441}