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