Skip to main content

switchyard_libsy/algorithms/
vgr.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Verification-gated routing: fail-closed capability derivation.
5//!
6//! Capabilities are never client-declared. They come from the router's own
7//! typing of the request, the attempt it produced locally, and its own reading
8//! of the tool results in the conversation. No client-reachable input selects
9//! a weaker regime than the default one; unsupported content and missing
10//! evidence select [`Branch::Unknown`], which never commits.
11
12#![allow(dead_code)]
13
14use std::sync::Arc;
15
16use switchyard_protocol::{ModelId, Request};
17
18use self::text::ToolRecord;
19use crate::Result;
20use crate::algorithms::fall_through::FallThrough;
21use crate::algorithms::util::affinity::AffinityRouter;
22use crate::core::algorithm::{Algorithm, Driver, RoutingOutcome};
23use crate::core::state::State;
24
25mod compaction;
26mod config;
27mod decide;
28mod readout;
29mod render;
30mod rungs;
31mod runtime;
32mod safety;
33mod text;
34
35pub use config::{ACTIVE_APPROVAL, ServingMode, Targets, VgrConfig};
36pub use safety::{BreakerConfig, KillSwitch};
37
38#[cfg(test)]
39mod tests;
40
41/// A verification-gated route between a local and a capable tier.
42pub struct Vgr {
43    route: FallThrough<State>,
44    local: ModelId,
45    cloud: ModelId,
46}
47
48impl Vgr {
49    /// Validates and constructs a verification-gated route.
50    pub fn new(config: VgrConfig) -> Result<Self> {
51        config.validate()?;
52        let local = config.targets.local.clone();
53        let cloud = config.targets.cloud.clone();
54        // Every local turn re-enters verification; an escalation holds until the
55        // next user turn.
56        let turn_affinity = Arc::new(
57            AffinityRouter::new()
58                .with_release_on_user_turn()
59                .with_latch_only([cloud.clone()]),
60        );
61        let mut route = FallThrough::new_with_state().with_name("vgr");
62        if config.compact_handoff {
63            // First, so every later classifier and the served call see the
64            // condensed history.
65            route = route.with_classifier(Arc::new(compaction::Compactor));
66        }
67        let classifier = Arc::new(runtime::VgrClassifier {
68            breaker: safety::CircuitBreaker::new(config.breaker),
69            config,
70        });
71        let route = route
72            .with_processor(turn_affinity.clone())
73            .with_classifier(turn_affinity)
74            .with_classifier(classifier);
75        Ok(Self {
76            route,
77            local,
78            cloud,
79        })
80    }
81}
82
83#[async_trait::async_trait]
84impl Algorithm for Vgr {
85    fn name(&self) -> &str {
86        "vgr"
87    }
88
89    async fn route(self: Arc<Self>, driver: Driver, request: Request) -> Result<RoutingOutcome> {
90        let mut outcome = self.route.execute(driver, request).await?;
91        let selected = outcome.selected_model_id()?.clone();
92        if selected == self.cloud {
93            // Falling back to local would bypass the decision that selected cloud.
94            outcome.selected_model_ids.truncate(1);
95        } else if selected == self.local && outcome.response.is_none() {
96            outcome.selected_model_ids = vec![self.local.clone(), self.cloud.clone()];
97        }
98        Ok(outcome)
99    }
100}
101
102/// The verification regime a request's capabilities license.
103#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
104enum Branch {
105    /// Code or test activity, with no sandboxed checker to appeal to.
106    Coding,
107    /// A single-turn request typed as answer-seeking.
108    Answer,
109    /// Conversational traffic.
110    Chat,
111    /// A tool-using session verified from its trajectory.
112    Agentic,
113    /// Anything else with an attempt to verify.
114    DefaultVerified,
115    /// Nothing to verify.
116    #[default]
117    Unknown,
118}
119
120/// A task type produced by the router's own typing call; `None` abstains.
121#[derive(Clone, Copy, Debug, Eq, PartialEq)]
122enum TaskType {
123    Coding,
124    Agentic,
125    Answer,
126    Chat,
127}
128
129/// The complete input the decision core sees.
130#[derive(Clone, Debug, Default, PartialEq)]
131struct Capabilities {
132    branch: Branch,
133    /// The judged view of the request and attempt.
134    transcript: Option<String>,
135    /// The router's own tool-result record; unused by the answer regime.
136    tools: Option<ToolRecord>,
137}
138
139/// Derives capabilities from the router's own evidence, failing closed.
140fn derive_capabilities(
141    request: &Request,
142    attempt: &str,
143    task_type: Option<TaskType>,
144) -> Capabilities {
145    let (turns, unsupported) = text::turns(request);
146    if unsupported || text::user_task_text(&turns).trim().is_empty() || attempt.trim().is_empty() {
147        return Capabilities::default();
148    }
149    let caps = |branch, transcript| Capabilities {
150        branch,
151        transcript,
152        tools: Some(ToolRecord::from_request(request)),
153    };
154
155    if task_type == Some(TaskType::Answer) && !text::has_assistant_turn(&turns) {
156        return Capabilities {
157            tools: None,
158            ..caps(
159                Branch::Answer,
160                Some(render::render_session(&turns, attempt)),
161            )
162        };
163    }
164    // A tool trajectory is judged as agentic work unless typed conversational.
165    if task_type == Some(TaskType::Agentic)
166        || (text::has_tool_trajectory(request)
167            && !matches!(task_type, Some(TaskType::Answer | TaskType::Chat)))
168    {
169        return caps(
170            Branch::Agentic,
171            Some(render::render_agentic_view(request, &turns, attempt)),
172        );
173    }
174    if text::observed_hardening(attempt) || task_type == Some(TaskType::Coding) {
175        return caps(Branch::Coding, None);
176    }
177    let transcript = Some(render::render_session(&turns, attempt));
178    if text::observed_tool_activity(attempt) {
179        caps(Branch::Agentic, transcript)
180    } else if matches!(task_type, Some(TaskType::Chat | TaskType::Answer)) {
181        caps(Branch::Chat, transcript)
182    } else {
183        caps(Branch::DefaultVerified, transcript)
184    }
185}