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<runtime::VgrState>,
40}
41
42impl Vgr {
43    /// Validates and constructs a verification-gated route.
44    pub fn new(config: VgrConfig) -> Result<Self> {
45        config.validate()?;
46        let cloud = config.targets.cloud.clone();
47        // Every local turn re-enters verification; an escalation holds until the
48        // next user turn.
49        let turn_affinity = Arc::new(
50            AffinityRouter::new()
51                .with_release_on_user_turn()
52                .with_latch_only([cloud.clone()]),
53        );
54        let classifier = Arc::new(runtime::VgrClassifier {
55            breaker: safety::CircuitBreaker::new(config.breaker),
56            config,
57        });
58        let route = FallThrough::new_with_state()
59            .with_name("vgr")
60            .with_processor(turn_affinity.clone())
61            .with_classifier(turn_affinity)
62            .with_classifier(classifier);
63        Ok(Self { route })
64    }
65}
66
67#[async_trait::async_trait]
68impl Algorithm for Vgr {
69    fn name(&self) -> &str {
70        "vgr"
71    }
72
73    async fn route(self: Arc<Self>, driver: Driver, request: Request) -> Result<RoutingOutcome> {
74        let mut outcome = self.route.execute(driver, request).await?;
75        // Local answers are already buffered; cloud decisions cannot fall back to local.
76        outcome.selected_model_ids.truncate(1);
77        Ok(outcome)
78    }
79}
80
81/// The verification regime a request's capabilities license.
82#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
83enum Branch {
84    /// Code or test activity, with no sandboxed checker to appeal to.
85    Coding,
86    /// A single-turn request typed as answer-seeking.
87    Answer,
88    /// Conversational traffic.
89    Chat,
90    /// A tool-using session verified from its trajectory.
91    Agentic,
92    /// Anything else with an attempt to verify.
93    DefaultVerified,
94    /// Nothing to verify.
95    #[default]
96    Unknown,
97}
98
99/// A task type produced by the router's own typing call; `None` abstains.
100#[derive(Clone, Copy, Debug, Eq, PartialEq)]
101enum TaskType {
102    Coding,
103    Agentic,
104    Answer,
105    Chat,
106}
107
108/// The complete input the decision core sees.
109#[derive(Default)]
110struct Capabilities {
111    branch: Branch,
112    /// The judged view of the request and attempt.
113    transcript: Option<String>,
114    /// The router's own tool-result record; unused by the answer regime.
115    tools: Option<ToolRecord>,
116}
117
118/// Derives capabilities from the router's own evidence, failing closed.
119fn derive_capabilities(
120    request: &Request,
121    attempt: &str,
122    task_type: Option<TaskType>,
123) -> Capabilities {
124    let (turns, unsupported) = text::turns(request);
125    if unsupported || text::user_task_text(&turns).trim().is_empty() || attempt.trim().is_empty() {
126        return Capabilities::default();
127    }
128    let caps = |branch, transcript| Capabilities {
129        branch,
130        transcript,
131        tools: Some(ToolRecord::from_request(request)),
132    };
133
134    if task_type == Some(TaskType::Answer) && !text::has_assistant_turn(&turns) {
135        return Capabilities {
136            tools: None,
137            ..caps(
138                Branch::Answer,
139                Some(render::render_session(&turns, attempt)),
140            )
141        };
142    }
143    // A tool trajectory is judged as agentic work unless typed conversational.
144    if task_type == Some(TaskType::Agentic)
145        || (text::has_tool_trajectory(request)
146            && !matches!(task_type, Some(TaskType::Answer | TaskType::Chat)))
147    {
148        return caps(
149            Branch::Agentic,
150            Some(render::render_agentic_view(request, &turns, attempt)),
151        );
152    }
153    if text::observed_hardening(attempt) || task_type == Some(TaskType::Coding) {
154        return caps(Branch::Coding, None);
155    }
156    let transcript = Some(render::render_session(&turns, attempt));
157    if text::observed_tool_activity(attempt) {
158        caps(Branch::Agentic, transcript)
159    } else if matches!(task_type, Some(TaskType::Chat | TaskType::Answer)) {
160        caps(Branch::Chat, transcript)
161    } else {
162        caps(Branch::DefaultVerified, transcript)
163    }
164}