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
12use std::sync::Arc;
13
14use switchyard_protocol::Request;
15
16use self::text::ToolRecord;
17use crate::Result;
18use crate::algorithms::fall_through::FallThrough;
19use crate::algorithms::util::affinity::AffinityRouter;
20use crate::core::algorithm::{Algorithm, Driver, RoutingOutcome};
21
22mod config;
23mod decide;
24mod readout;
25mod render;
26mod rungs;
27mod runtime;
28mod safety;
29mod text;
30
31pub use config::{ACTIVE_APPROVAL, ServingMode, Targets, VgrConfig};
32pub use safety::{BreakerConfig, KillSwitch};
33
34#[cfg(test)]
35mod tests;
36
37/// A verification-gated route between a local and a capable tier.
38pub struct Vgr {
39    route: FallThrough,
40    classifier: Arc<runtime::VgrClassifier>,
41}
42
43impl Vgr {
44    /// Validates and constructs a verification-gated route.
45    pub fn new(config: VgrConfig) -> Result<Self> {
46        config.validate()?;
47        let mut affinity = AffinityRouter::new().with_latch_only([config.targets.cloud.clone()]);
48        let reason = if config.latch_escalation && config.mode != ServingMode::Shadow {
49            "session_latched"
50        } else {
51            affinity = affinity.with_release_on_user_turn();
52            "turn_verification_latched"
53        };
54        let affinity = Arc::new(affinity);
55        let classifier = Arc::new(runtime::VgrClassifier {
56            breaker: safety::CircuitBreaker::new(config.breaker),
57            config,
58        });
59        let route = FallThrough::new_with_state()
60            .with_name("vgr")
61            .with_processor(affinity.clone())
62            .with_classifier(Arc::new(runtime::Held(affinity, reason)))
63            .with_classifier(classifier.clone());
64        Ok(Self { route, classifier })
65    }
66}
67
68#[async_trait::async_trait]
69impl Algorithm for Vgr {
70    fn name(&self) -> &str {
71        "vgr"
72    }
73
74    async fn route(self: Arc<Self>, driver: Driver, request: Request) -> Result<RoutingOutcome> {
75        // The handoff note is written for the cloud tier, so a local stand-in gets the request
76        // as it arrived.
77        let arrived = self
78            .classifier
79            .local_may_stand_in(&request)
80            .then(|| request.clone());
81        let mut outcome = self.route.execute(driver, request).await?;
82        // Local answers are already buffered and have no fallback.
83        outcome.selected_model_ids.truncate(1);
84        let config = &self.classifier.config;
85        if let Some(arrived) = arrived
86            && outcome.response.is_none()
87            && outcome.selected_model_ids.first() == Some(&config.targets.cloud)
88            // The local tier may have failed while this request was routed.
89            && self.classifier.local_may_stand_in(&arrived)
90        {
91            outcome
92                .selected_model_ids
93                .push(config.targets.local.clone());
94            outcome.fallback_request = Some(arrived);
95        }
96        Ok(outcome)
97    }
98}
99
100/// The verification regime a request's capabilities license.
101#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
102enum Branch {
103    /// Code or test activity, with no sandboxed checker to appeal to.
104    Coding,
105    /// A single-turn request typed as answer-seeking.
106    Answer,
107    /// Conversational traffic.
108    Chat,
109    /// A tool-using session verified from its trajectory.
110    Agentic,
111    /// Anything else with an attempt to verify.
112    DefaultVerified,
113    /// Nothing to verify.
114    #[default]
115    Unknown,
116}
117
118/// A task type produced by the router's own typing call; `None` abstains.
119#[derive(Clone, Copy, Debug, Eq, PartialEq)]
120enum TaskType {
121    Coding,
122    Agentic,
123    Answer,
124    Chat,
125}
126
127/// The complete input the decision core sees.
128#[derive(Default)]
129struct Capabilities {
130    branch: Branch,
131    /// The judged view of the request and attempt.
132    transcript: Option<String>,
133    /// The router's own tool-result record; unused by the answer regime.
134    tools: Option<ToolRecord>,
135}
136
137/// Derives capabilities from the router's own evidence, failing closed.
138fn derive_capabilities(
139    request: &Request,
140    attempt: &str,
141    task_type: Option<TaskType>,
142) -> Capabilities {
143    let (turns, unsupported) = text::turns(request);
144    if unsupported || text::user_task_text(&turns).trim().is_empty() || attempt.trim().is_empty() {
145        return Capabilities::default();
146    }
147    let caps = |branch, transcript| Capabilities {
148        branch,
149        transcript,
150        tools: Some(ToolRecord::from_request(request)),
151    };
152
153    if task_type == Some(TaskType::Answer) && !text::has_assistant_turn(&turns) {
154        return Capabilities {
155            tools: None,
156            ..caps(
157                Branch::Answer,
158                Some(render::render_session(&turns, attempt)),
159            )
160        };
161    }
162    // A tool trajectory is judged as agentic work unless typed conversational.
163    if task_type == Some(TaskType::Agentic)
164        || (text::has_tool_trajectory(request)
165            && !matches!(task_type, Some(TaskType::Answer | TaskType::Chat)))
166    {
167        return caps(
168            Branch::Agentic,
169            Some(render::render_agentic_view(request, &turns, attempt)),
170        );
171    }
172    let transcript = Some(render::render_session(&turns, attempt));
173    if text::observed_hardening(attempt) || task_type == Some(TaskType::Coding) {
174        return caps(Branch::Coding, transcript);
175    }
176    if text::observed_tool_activity(attempt) {
177        caps(Branch::Agentic, transcript)
178    } else if matches!(task_type, Some(TaskType::Chat | TaskType::Answer)) {
179        caps(Branch::Chat, transcript)
180    } else {
181        caps(Branch::DefaultVerified, transcript)
182    }
183}