From 470cc2364c3297b503a4e7d9c75c5a438ffbe504 Mon Sep 17 00:00:00 2001 From: ayushag Date: Thu, 20 Aug 2026 15:21:08 -0700 Subject: [PATCH 1/7] chore: add passthrough route to a target for sub-agent Signed-off-by: ayushag --- crates/libsy/src/algorithms/passthrough.rs | 41 +++++++++++-- crates/switchyard-server/src/config.rs | 64 ++++++++++++++------- docs/reference/toml_schema.md | 6 +- docs/routing_algorithms/overview.md | 2 +- docs/routing_algorithms/subagent_routing.md | 14 ++++- 5 files changed, 94 insertions(+), 33 deletions(-) diff --git a/crates/libsy/src/algorithms/passthrough.rs b/crates/libsy/src/algorithms/passthrough.rs index 6f9952f64..94e0fc17e 100644 --- a/crates/libsy/src/algorithms/passthrough.rs +++ b/crates/libsy/src/algorithms/passthrough.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Direct parent routing with an optional delegated-work classifier cascade. +//! Direct parent routing with an optional delegated-work gate. use std::sync::Arc; @@ -15,13 +15,13 @@ use crate::core::classifier::Classifier; use crate::core::state::State; use crate::{LibsyError, Result, RoutingOutcome}; -/// Routes parent traffic directly and optionally classifies delegated sub-agent work. +/// Routes parent traffic directly and optionally routes delegated sub-agent work. pub struct Passthrough { parent_target: ModelId, route: FallThrough, } -/// Runtime components for classifying and retaining delegated sub-agent work. +/// Runtime components for delegated sub-agent routing. pub struct PassthroughSubagentConfig { /// Targets the delegated-work classifier may select. pub targets: Vec, @@ -35,6 +35,20 @@ pub struct PassthroughSubagentConfig { pub message_hash_fallback: bool, } +impl PassthroughSubagentConfig { + /// Routes delegated work directly to one fixed target. + pub fn fixed_target(target: impl Into) -> Self { + let target = target.into(); + Self { + targets: vec![target.clone()], + classifier: Arc::new(DefaultTarget::new(target.clone())), + default_target: target, + classify_trigger: ClassifyTrigger::EveryRequest, + message_hash_fallback: false, + } + } +} + /// Complete construction settings for [`Passthrough`]. pub struct PassthroughConfig { /// Target used for parent and harness-maintenance traffic. @@ -46,9 +60,9 @@ pub struct PassthroughConfig { impl Passthrough { /// Creates direct parent routing, optionally with a decision gate for sub-agents. /// - /// When configured, the sub-agent classifier runs once per identified child. Its first - /// decision is retained by `session + agent`; an abstaining classifier uses the child - /// default. Root and harness-maintenance traffic continue to the parent target. + /// A `new_session` classifier retains its first decision by `session + agent`; an + /// `every_request` gate decides each delegated request independently. An abstaining gate uses + /// the child default. Root and harness-maintenance traffic continue to the parent target. /// /// # Errors /// @@ -258,6 +272,21 @@ mod tests { Ok(()) } + #[tokio::test] + async fn fixed_subagent_gate_routes_parent_and_child() -> crate::Result<()> { + let router = Arc::new(Passthrough::new(PassthroughConfig { + parent_target: ModelId::from("parent"), + subagent: Some(PassthroughSubagentConfig::fixed_target("worker")), + })?); + + let (parent, _) = test_drive(router.clone(), request(None), echo()).await?; + let (child, _) = test_drive(router, child("child-1"), echo()).await?; + + assert_eq!(parent, "parent"); + assert_eq!(child, "worker"); + Ok(()) + } + #[tokio::test] async fn custom_classifier_receives_only_the_delegated_prompt() -> crate::Result<()> { let classifier = LlmTaskClassifier::new(LlmClassifierConfig::Custom { diff --git a/crates/switchyard-server/src/config.rs b/crates/switchyard-server/src/config.rs index feee52ca7..1fdc4db88 100644 --- a/crates/switchyard-server/src/config.rs +++ b/crates/switchyard-server/src/config.rs @@ -415,6 +415,13 @@ struct CustomClassifierRouteConfig { max_output_tokens: u64, } +#[derive(Clone, Debug, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +enum SubagentRouteConfig { + Passthrough { target: String }, + LlmClassifier(CustomClassifierRouteConfig), +} + #[derive(Debug, Deserialize)] #[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] enum RouteConfig { @@ -449,7 +456,7 @@ enum RouteConfig { reasoning: Option, target: String, #[serde(default)] - subagent_classifier: Option, + subagents: Option, }, LlmClassifier { id: ModelId, @@ -627,13 +634,15 @@ impl RouteConfig { Self::Noop { .. } => Vec::new(), Self::Random { targets, .. } => targets.iter().map(String::as_str).collect(), Self::Passthrough { - target, - subagent_classifier, - .. + target, subagents, .. } => { let mut names = vec![target.as_str()]; - if let Some(classifier) = subagent_classifier { - names.extend(classifier.targets.iter().map(String::as_str)); + match subagents { + Some(SubagentRouteConfig::Passthrough { target }) => names.push(target), + Some(SubagentRouteConfig::LlmClassifier(classifier)) => { + names.extend(classifier.targets.iter().map(String::as_str)); + } + None => {} } names } @@ -685,7 +694,7 @@ impl RouteConfig { classifier_target, .. } => names.push(classifier_target), Self::Passthrough { - subagent_classifier: Some(classifier), + subagents: Some(SubagentRouteConfig::LlmClassifier(classifier)), .. } => names.push(&classifier.classifier_target), Self::StageRouter { @@ -1016,12 +1025,10 @@ fn build_algorithm( Ok(Arc::new(algorithm)) } RouteConfig::Passthrough { - target, - subagent_classifier, - .. + target, subagents, .. } => { let parent_target = resolve_target_model_id(route_name, target, targets)?; - let subagent = if let Some(config) = subagent_classifier { + let subagent = if let Some(SubagentRouteConfig::LlmClassifier(config)) = subagents { let judge_target = resolve_target_model_id(route_name, &config.classifier_target, targets)?; let resolved_targets = config @@ -1038,14 +1045,14 @@ fn build_algorithm( .map(|(_, target)| target.clone()) .ok_or_else(|| { ServerError::new(format!( - "passthrough route {route_name}: subagent_classifier default_target {:?} must be one of its configured targets", + "passthrough route {route_name}: subagents llm_classifier default_target {:?} must be one of its configured targets", config.default_target )) })?; let response_schema = serde_json::from_str(&config.response_schema).map_err(|error| { ServerError::new(format!( - "passthrough route {route_name}: subagent_classifier response_schema is invalid JSON: {error}" + "passthrough route {route_name}: subagents llm_classifier response_schema is invalid JSON: {error}" )) })?; let mut classifier_config = CustomClassifierConfig::new( @@ -1068,7 +1075,7 @@ fn build_algorithm( }) .map_err(|error| { ServerError::new(format!( - "passthrough route {route_name}: subagent_classifier: {error}" + "passthrough route {route_name}: subagents llm_classifier: {error}" )) })?, ); @@ -1079,6 +1086,10 @@ fn build_algorithm( classify_trigger: config.classify_trigger, message_hash_fallback: config.message_hash_fallback, }) + } else if let Some(SubagentRouteConfig::Passthrough { target }) = subagents { + Some(PassthroughSubagentConfig::fixed_target( + resolve_target_model_id(route_name, target, targets)?, + )) } else { None }; @@ -1407,7 +1418,7 @@ target = "weak" } } - fn passthrough_with_subagent_classifier(extra: &str) -> String { + fn passthrough_with_subagent_llm_classifier(extra: &str) -> String { let configured = VALID_CONFIG.replace( "[routes.passthrough]\nid = \"switchyard/passthrough\"\ntype = \"passthrough\"\ntarget = \"weak\"", r#"[routes.passthrough] @@ -1415,7 +1426,8 @@ id = "switchyard/passthrough" type = "passthrough" target = "weak" -[routes.passthrough.subagent_classifier] +[routes.passthrough.subagents] +type = "llm_classifier" classifier_target = "classifier" targets = ["strong", "weak"] default_target = "weak" @@ -1430,6 +1442,13 @@ classify_trigger = "new_session""#, ) } + fn passthrough_with_subagent_passthrough() -> String { + VALID_CONFIG.replace( + "[routes.passthrough]\nid = \"switchyard/passthrough\"\ntype = \"passthrough\"\ntarget = \"weak\"", + "[routes.passthrough]\nid = \"switchyard/passthrough\"\ntype = \"passthrough\"\ntarget = \"weak\"\n\n[routes.passthrough.subagents]\ntype = \"passthrough\"\ntarget = \"strong\"", + ) + } + #[test] fn builds_all_supported_algorithm_types() -> ServerResult<()> { let state = server_state_from_toml(VALID_CONFIG)?; @@ -1447,10 +1466,13 @@ classify_trigger = "new_session""#, } #[test] - fn passthrough_accepts_a_custom_subagent_classifier() -> ServerResult<()> { - let configured = passthrough_with_subagent_classifier(""); - - server_state_from_toml(&configured)?; + fn passthrough_accepts_subagent_gates() -> ServerResult<()> { + for configured in [ + passthrough_with_subagent_llm_classifier(""), + passthrough_with_subagent_passthrough(), + ] { + server_state_from_toml(&configured)?; + } Ok(()) } @@ -1674,7 +1696,7 @@ classifier_magic = true "message_hash_fallback requires classify_trigger = new_session", ), ( - passthrough_with_subagent_classifier("\nmessage_hash_fallback = true"), + passthrough_with_subagent_llm_classifier("\nmessage_hash_fallback = true"), "cannot use message_hash_fallback", ), ( diff --git a/docs/reference/toml_schema.md b/docs/reference/toml_schema.md index f5945acbd..1faba889a 100644 --- a/docs/reference/toml_schema.md +++ b/docs/reference/toml_schema.md @@ -115,13 +115,13 @@ type = "noop" ### `passthrough` -Sends every request to one target. It can also classify delegated sub-agent work; +Sends parent requests to one target. It can also route delegated sub-agent work; see [Sub-Agent-Aware Routing](../routing_algorithms/subagent_routing.md). | Key | Required | Meaning | |---|:---:|---| -| `target` | Yes | Target every request is sent to. | -| `subagent_classifier` | No | Custom classifier used only for delegated sub-agent work. | +| `target` | Yes | Target used for parent and harness-maintenance requests. | +| `subagents` | No | Nested `passthrough` or `llm_classifier` policy used only for delegated sub-agent work. | ### `random` diff --git a/docs/routing_algorithms/overview.md b/docs/routing_algorithms/overview.md index f7b971987..17910281e 100644 --- a/docs/routing_algorithms/overview.md +++ b/docs/routing_algorithms/overview.md @@ -12,7 +12,7 @@ configuration and tuning. For the vocabulary these pages use, see | Strategy | Use it when | Route `type` | |---|---|---| -| [Sub-Agent-Aware Routing](subagent_routing.md) | Parent traffic should use one target while delegated sub-agents are classified across other targets. | `passthrough` with `subagent_classifier` | +| [Sub-Agent-Aware Routing](subagent_routing.md) | Parent traffic should use one target while delegated sub-agents use a separate routing policy. | `passthrough` with `subagents` | | [Random Routing](random_routing.md) | You need a fixed traffic split for A/B tests, baselines, or cost experiments. | `random` | | [LLM Classifier Routing](llm_classifier_routing.md) | Request content should decide whether a turn needs the weak or strong tier. | `llm_classifier` | | [Stage-Router Routing](stage_router_routing.md) | Tool-result and agent-progress signals should route most turns without an extra classifier call. | `stage_router` | diff --git a/docs/routing_algorithms/subagent_routing.md b/docs/routing_algorithms/subagent_routing.md index d4e6add70..b371588b0 100644 --- a/docs/routing_algorithms/subagent_routing.md +++ b/docs/routing_algorithms/subagent_routing.md @@ -2,7 +2,7 @@ Sub-agent-aware routing keeps parent-agent traffic on one target while routing delegated sub-agent work across separate targets. Current support is available -through the `passthrough` route's optional `subagent_classifier` table. +through the `passthrough` route's optional `subagents` table. ```toml schema_version = 1 @@ -36,7 +36,8 @@ context_window = 400000 tool_calling = true reasoning = true -[routes.agent.subagent_classifier] +[routes.agent.subagents] +type = "llm_classifier" classifier_target = "classifier" targets = ["worker", "reviewer"] default_target = "worker" @@ -83,3 +84,12 @@ Clients must still request the route ID (`agent` above). An explicit model name that is not registered as a route is rejected before sub-agent classification. `message_hash_fallback` is not supported for sub-agent routing because affinity requires harness-provided child identity. + +To send every delegated sub-agent request to one fixed target without calling a +classifier, replace the `subagents` table above with: + +```toml +[routes.agent.subagents] +type = "passthrough" +target = "worker" +``` From 58ff5ab96ede3132de1a885de233e3825626fab5 Mon Sep 17 00:00:00 2001 From: ayushag Date: Thu, 20 Aug 2026 16:04:34 -0700 Subject: [PATCH 2/7] chore: subagent UX now is same as main agent Signed-off-by: ayushag --- crates/switchyard-server/src/config.rs | 163 +++++++++++--------- docs/routing_algorithms/subagent_routing.md | 1 + 2 files changed, 92 insertions(+), 72 deletions(-) diff --git a/crates/switchyard-server/src/config.rs b/crates/switchyard-server/src/config.rs index 1fdc4db88..aacbcc925 100644 --- a/crates/switchyard-server/src/config.rs +++ b/crates/switchyard-server/src/config.rs @@ -415,11 +415,49 @@ struct CustomClassifierRouteConfig { max_output_tokens: u64, } -#[derive(Clone, Debug, Deserialize)] +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct LlmClassifierRouteConfig { + classifier_target: String, + #[serde(default)] + mode: Option, + #[serde(default)] + strong_target: Option, + #[serde(default)] + weak_target: Option, + #[serde(default)] + base_threshold: Option, + #[serde(default)] + threshold_step: Option, + #[serde(default)] + classify_trigger: ClassifyTrigger, + #[serde(default)] + message_hash_fallback: bool, + #[serde(default)] + recent_turn_window: Option, + #[serde(default)] + prompt: Option, + #[serde(default)] + response_format_type: ClassifierResponseFormat, + #[serde(default = "default_classifier_max_output_tokens")] + max_output_tokens: u64, + #[serde(default)] + escalation: Option, + #[serde(default)] + targets: Option>, + #[serde(default)] + default_target: Option, + #[serde(default)] + response_schema: Option, + #[serde(default)] + policy: Option, +} + +#[derive(Debug, Deserialize)] #[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] enum SubagentRouteConfig { Passthrough { target: String }, - LlmClassifier(CustomClassifierRouteConfig), + LlmClassifier(Box), } #[derive(Debug, Deserialize)] @@ -466,39 +504,8 @@ enum RouteConfig { tool_calling: Option, #[serde(default)] reasoning: Option, - classifier_target: String, - #[serde(default)] - mode: Option, - #[serde(default)] - strong_target: Option, - #[serde(default)] - weak_target: Option, - #[serde(default)] - base_threshold: Option, - #[serde(default)] - threshold_step: Option, - #[serde(default)] - classify_trigger: ClassifyTrigger, - #[serde(default)] - message_hash_fallback: bool, - #[serde(default)] - recent_turn_window: Option, - #[serde(default)] - prompt: Option, - #[serde(default)] - response_format_type: ClassifierResponseFormat, - #[serde(default = "default_classifier_max_output_tokens")] - max_output_tokens: u64, - #[serde(default)] - escalation: Option, - #[serde(default)] - targets: Option>, - #[serde(default)] - default_target: Option, - #[serde(default)] - response_schema: Option, - #[serde(default)] - policy: Option, + #[serde(flatten)] + config: LlmClassifierRouteConfig, }, StageRouter { id: ModelId, @@ -640,36 +647,38 @@ impl RouteConfig { match subagents { Some(SubagentRouteConfig::Passthrough { target }) => names.push(target), Some(SubagentRouteConfig::LlmClassifier(classifier)) => { - names.extend(classifier.targets.iter().map(String::as_str)); + names.extend(classifier.targets.iter().flatten().map(String::as_str)); } None => {} } names } - Self::LlmClassifier { - mode, - strong_target, - weak_target, - escalation, - targets, - .. - } => match mode.unwrap_or(if escalation.is_some() { - ClassifierMode::Escalation - } else { - ClassifierMode::Capability - }) { - ClassifierMode::Capability => weak_target - .iter() - .chain(strong_target) - .map(String::as_str) - .collect(), - ClassifierMode::Escalation => strong_target - .iter() - .chain(weak_target) - .map(String::as_str) - .collect(), - ClassifierMode::Custom => targets.iter().flatten().map(String::as_str).collect(), - }, + Self::LlmClassifier { config, .. } => { + match config.mode.unwrap_or(if config.escalation.is_some() { + ClassifierMode::Escalation + } else { + ClassifierMode::Capability + }) { + ClassifierMode::Capability => config + .weak_target + .iter() + .chain(&config.strong_target) + .map(String::as_str) + .collect(), + ClassifierMode::Escalation => config + .strong_target + .iter() + .chain(&config.weak_target) + .map(String::as_str) + .collect(), + ClassifierMode::Custom => config + .targets + .iter() + .flatten() + .map(String::as_str) + .collect(), + } + } Self::StageRouter { capable_target, efficient_target, @@ -690,9 +699,7 @@ impl RouteConfig { fn callable_target_names(&self) -> Vec<&str> { let mut names = self.routing_target_names(); match self { - Self::LlmClassifier { - classifier_target, .. - } => names.push(classifier_target), + Self::LlmClassifier { config, .. } => names.push(&config.classifier_target), Self::Passthrough { subagents: Some(SubagentRouteConfig::LlmClassifier(classifier)), .. @@ -752,9 +759,11 @@ impl RouteConfig { }, } } +} +impl LlmClassifierRouteConfig { fn classifier_mode(&self, route_name: &str) -> ServerResult { - let Self::LlmClassifier { + let Self { classifier_target, mode, strong_target, @@ -772,11 +781,7 @@ impl RouteConfig { default_target, response_schema, policy, - .. - } = self - else { - return Err(ServerError::new("route is not an llm_classifier")); - }; + } = self; let selected_mode = match (mode, escalation.is_some()) { (Some(mode), _) => *mode, @@ -1029,6 +1034,12 @@ fn build_algorithm( } => { let parent_target = resolve_target_model_id(route_name, target, targets)?; let subagent = if let Some(SubagentRouteConfig::LlmClassifier(config)) = subagents { + let LlmClassifierModeConfig::Custom(config) = config.classifier_mode(route_name)? + else { + return Err(ServerError::new(format!( + "passthrough route {route_name}: subagents llm_classifier only supports mode custom" + ))); + }; let judge_target = resolve_target_model_id(route_name, &config.classifier_target, targets)?; let resolved_targets = config @@ -1103,10 +1114,12 @@ fn build_algorithm( Ok(Arc::new(algorithm)) } RouteConfig::LlmClassifier { - classifier_target, .. + config: classifier_config, + .. } => { - let classifier = resolve_target_model_id(route_name, classifier_target, targets)?; - let mode = config.classifier_mode(route_name)?; + let classifier = + resolve_target_model_id(route_name, &classifier_config.classifier_target, targets)?; + let mode = classifier_config.classifier_mode(route_name)?; let algorithm = match mode { LlmClassifierModeConfig::Capability(config) => { let strong = @@ -1428,6 +1441,7 @@ target = "weak" [routes.passthrough.subagents] type = "llm_classifier" +mode = "custom" classifier_target = "classifier" targets = ["strong", "weak"] default_target = "weak" @@ -1699,6 +1713,11 @@ classifier_magic = true passthrough_with_subagent_llm_classifier("\nmessage_hash_fallback = true"), "cannot use message_hash_fallback", ), + ( + passthrough_with_subagent_llm_classifier("") + .replace("mode = \"custom\"", "mode = \"capability\""), + "mode capability cannot use custom classifier fields", + ), ( VALID_CONFIG.replace( "base_threshold = 0.5", diff --git a/docs/routing_algorithms/subagent_routing.md b/docs/routing_algorithms/subagent_routing.md index b371588b0..50380d05f 100644 --- a/docs/routing_algorithms/subagent_routing.md +++ b/docs/routing_algorithms/subagent_routing.md @@ -38,6 +38,7 @@ reasoning = true [routes.agent.subagents] type = "llm_classifier" +mode = "custom" classifier_target = "classifier" targets = ["worker", "reviewer"] default_target = "worker" From bcd1ccb91fb83cc069d84aec64ea110fa6cee546 Mon Sep 17 00:00:00 2001 From: ayushag Date: Thu, 20 Aug 2026 16:23:58 -0700 Subject: [PATCH 3/7] chore: review cleanup Signed-off-by: ayushag --- crates/libsy/src/algorithms/passthrough.rs | 2 +- crates/switchyard-server/src/config.rs | 7 +------ docs/reference/toml_schema.md | 2 +- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/crates/libsy/src/algorithms/passthrough.rs b/crates/libsy/src/algorithms/passthrough.rs index 94e0fc17e..3b0459a6c 100644 --- a/crates/libsy/src/algorithms/passthrough.rs +++ b/crates/libsy/src/algorithms/passthrough.rs @@ -25,7 +25,7 @@ pub struct Passthrough { pub struct PassthroughSubagentConfig { /// Targets the delegated-work classifier may select. pub targets: Vec, - /// Classifier invoked for the first request from each identified child. + /// Classifier invoked for delegated work according to `classify_trigger`. pub classifier: Arc>, /// Child target used when `classifier` abstains. pub default_target: ModelId, diff --git a/crates/switchyard-server/src/config.rs b/crates/switchyard-server/src/config.rs index aacbcc925..f8ca6c45b 100644 --- a/crates/switchyard-server/src/config.rs +++ b/crates/switchyard-server/src/config.rs @@ -396,8 +396,7 @@ struct EscalationClassifierRouteConfig { judge: EscalationJudgeConfig, } -#[derive(Clone, Debug, Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Debug)] struct CustomClassifierRouteConfig { classifier_target: String, targets: Vec, @@ -405,13 +404,9 @@ struct CustomClassifierRouteConfig { prompt: String, response_schema: String, policy: ClassifierPolicyConfig, - #[serde(default)] classify_trigger: ClassifyTrigger, - #[serde(default)] message_hash_fallback: bool, - #[serde(default)] recent_turn_window: Option, - #[serde(default = "default_classifier_max_output_tokens")] max_output_tokens: u64, } diff --git a/docs/reference/toml_schema.md b/docs/reference/toml_schema.md index 1faba889a..88257c0c0 100644 --- a/docs/reference/toml_schema.md +++ b/docs/reference/toml_schema.md @@ -121,7 +121,7 @@ see [Sub-Agent-Aware Routing](../routing_algorithms/subagent_routing.md). | Key | Required | Meaning | |---|:---:|---| | `target` | Yes | Target used for parent and harness-maintenance requests. | -| `subagents` | No | Nested `passthrough` or `llm_classifier` policy used only for delegated sub-agent work. | +| `subagents` | No | Nested `passthrough` or `llm_classifier` policy used only for delegated sub-agent work. Nested classifiers currently support only `mode = "custom"`. | ### `random` From fe4c51d69bcbde8226dbd7392f8b86fac83dd275 Mon Sep 17 00:00:00 2001 From: ayushag Date: Thu, 20 Aug 2026 16:43:16 -0700 Subject: [PATCH 4/7] refactor: subagent as own algorithm Signed-off-by: ayushag --- crates/libsy/src/algorithms.rs | 1 + crates/libsy/src/algorithms/passthrough.rs | 276 ++----------------- crates/libsy/src/algorithms/subagent.rs | 305 +++++++++++++++++++++ crates/libsy/src/lib.rs | 1 + crates/switchyard-server/src/config.rs | 12 +- 5 files changed, 335 insertions(+), 260 deletions(-) create mode 100644 crates/libsy/src/algorithms/subagent.rs diff --git a/crates/libsy/src/algorithms.rs b/crates/libsy/src/algorithms.rs index 2470738ac..fa3453ab9 100644 --- a/crates/libsy/src/algorithms.rs +++ b/crates/libsy/src/algorithms.rs @@ -13,6 +13,7 @@ pub mod noop; pub mod passthrough; pub mod rand; pub mod stage; +pub mod subagent; pub mod util; diff --git a/crates/libsy/src/algorithms/passthrough.rs b/crates/libsy/src/algorithms/passthrough.rs index 3b0459a6c..9f0b38c54 100644 --- a/crates/libsy/src/algorithms/passthrough.rs +++ b/crates/libsy/src/algorithms/passthrough.rs @@ -1,60 +1,31 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Direct parent routing with an optional delegated-work gate. +//! Direct parent routing with an optional delegated-work route. use std::sync::Arc; use switchyard_protocol::{ModelId, Request}; use super::fall_through::{DefaultTarget, FallThrough}; -use super::util::affinity::{AffinityRouter, ClassifyTrigger}; -use super::util::subagent::{SubagentGate, SubagentOverride}; -use crate::core::algorithm::{self, Algorithm, Driver}; -use crate::core::classifier::Classifier; -use crate::core::state::State; -use crate::{LibsyError, Result, RoutingOutcome}; +use super::subagent::{SubagentRouter, SubagentRouterConfig}; +use crate::core::algorithm::{Algorithm, Driver}; +use crate::{Result, RoutingOutcome}; + +/// Backwards-compatible name for [`SubagentRouterConfig`]. +pub use super::subagent::SubagentRouterConfig as PassthroughSubagentConfig; /// Routes parent traffic directly and optionally routes delegated sub-agent work. pub struct Passthrough { - parent_target: ModelId, - route: FallThrough, -} - -/// Runtime components for delegated sub-agent routing. -pub struct PassthroughSubagentConfig { - /// Targets the delegated-work classifier may select. - pub targets: Vec, - /// Classifier invoked for delegated work according to `classify_trigger`. - pub classifier: Arc>, - /// Child target used when `classifier` abstains. - pub default_target: ModelId, - /// Controls whether each child is classified once or on every request. - pub classify_trigger: ClassifyTrigger, - /// Unsupported for child routing because child identity must come from harness metadata. - pub message_hash_fallback: bool, -} - -impl PassthroughSubagentConfig { - /// Routes delegated work directly to one fixed target. - pub fn fixed_target(target: impl Into) -> Self { - let target = target.into(); - Self { - targets: vec![target.clone()], - classifier: Arc::new(DefaultTarget::new(target.clone())), - default_target: target, - classify_trigger: ClassifyTrigger::EveryRequest, - message_hash_fallback: false, - } - } + route: Arc, } /// Complete construction settings for [`Passthrough`]. pub struct PassthroughConfig { /// Target used for parent and harness-maintenance traffic. pub parent_target: ModelId, - /// Optional delegated-work decision gate. - pub subagent: Option, + /// Optional delegated-work routing. + pub subagent: Option, } impl Passthrough { @@ -66,51 +37,19 @@ impl Passthrough { /// /// # Errors /// - /// Returns an error when the configured child default is not a child target. + /// Returns an error when the delegated-work routing configuration is invalid. pub fn new(config: PassthroughConfig) -> Result { let parent_target = config.parent_target; - let route = match config.subagent { - None => FallThrough::new_with_state(vec![parent_target.clone()]) + let parent: Arc = Arc::new( + FallThrough::new(vec![parent_target.clone()]) .with_name("passthrough") - .with_classifier(Arc::new(DefaultTarget::new(parent_target.clone()))), - Some(subagent) => { - algorithm::ensure_model_is_target(&subagent.targets, &subagent.default_target)?; - if subagent.message_hash_fallback { - return Err(LibsyError::AlgorithmError { - message: "sub-agent routing cannot use message_hash_fallback".to_string(), - }); - } - let mut targets = subagent.targets; - if !targets.contains(&parent_target) { - targets.push(parent_target.clone()); - } - let mut route = FallThrough::new_with_state(targets).with_name("passthrough"); - match subagent.classify_trigger { - ClassifyTrigger::EveryRequest => {} - ClassifyTrigger::NewSession => { - let affinity = Arc::new(AffinityRouter::for_subagents()); - route = route - .with_processor(affinity.clone()) - .with_classifier(affinity); - } - ClassifyTrigger::UserTurn => { - return Err(LibsyError::AlgorithmError { - message: "sub-agent routing cannot use classify_trigger = user_turn" - .to_string(), - }); - } - } - route - .with_classifier(Arc::new(SubagentGate::new(subagent.classifier))) - .with_classifier(Arc::new(SubagentOverride::new(subagent.default_target))) - .with_classifier(Arc::new(DefaultTarget::new(parent_target.clone()))) - } + .with_classifier(Arc::new(DefaultTarget::new(parent_target))), + ); + let route: Arc = match config.subagent { + Some(subagent) => Arc::new(SubagentRouter::new(parent, subagent)?), + None => parent, }; - - Ok(Self { - parent_target, - route, - }) + Ok(Self { route }) } } @@ -121,68 +60,18 @@ impl Algorithm for Passthrough { } async fn route(self: Arc, driver: Driver, request: Request) -> Result { - let mut outcome = self.route.execute(driver, request).await?; - // Parent traffic preserves passthrough's no-fallback contract. Child traffic may - // fall back only within the child target set, never into the parent route. - if outcome.selected_model_id == self.parent_target { - outcome.fallback_models.clear(); - } else { - outcome - .fallback_models - .retain(|target| *target != self.parent_target); - } - Ok(outcome) + self.route.clone().route(driver, request).await } } #[cfg(test)] mod tests { use std::sync::Arc; - use std::sync::atomic::{AtomicUsize, Ordering}; - - use async_trait::async_trait; - use parking_lot::Mutex; - use serde_json::json; use super::{Passthrough, PassthroughConfig, PassthroughSubagentConfig}; use crate::core::algorithm::Algorithm; - use crate::core::classifier::{Classification, Classifier, Score}; - use crate::core::testing::{echo, reply, test_drive}; - use crate::{ - ClassifyTrigger, CustomClassifierConfig, CustomClassifierPolicy, Driver, - LlmClassifierConfig, LlmTaskClassifier, State, - }; - use switchyard_protocol::{ - ContentBlock, InstructionBlock, Message, Metadata, ModelId, Request, Response, Role, - completion_text, text_request, - }; - - struct ScriptedClassifier { - calls: AtomicUsize, - } - - #[async_trait] - impl Classifier for ScriptedClassifier { - async fn score( - &self, - _state: &mut State, - _request: &mut Request, - _driver: Option<&Driver>, - ) -> crate::Result<(Classification, Option)> { - let scores = match self.calls.fetch_add(1, Ordering::Relaxed) { - 0 => vec![Score { - confidence: 1.0, - target: ModelId::from("worker"), - }], - 1 => vec![Score { - confidence: 1.0, - target: ModelId::from("reviewer"), - }], - _ => Vec::new(), - }; - Ok((Classification::Scores(scores), None)) - } - } + use crate::core::testing::{echo, test_drive}; + use switchyard_protocol::{Metadata, ModelId, Request, completion_text, text_request}; fn request(metadata: Option) -> Request { Request { @@ -202,19 +91,6 @@ mod tests { })) } - fn configured(classifier: Arc>) -> crate::Result> { - Ok(Arc::new(Passthrough::new(PassthroughConfig { - parent_target: ModelId::from("parent"), - subagent: Some(PassthroughSubagentConfig { - targets: vec![ModelId::from("worker"), ModelId::from("reviewer")], - classifier, - default_target: ModelId::from("worker"), - classify_trigger: ClassifyTrigger::NewSession, - message_hash_fallback: false, - }), - })?)) - } - #[tokio::test] async fn test_passthrough() -> crate::Result<()> { const MODEL_ID: &str = "testing/passthrough"; @@ -241,37 +117,6 @@ mod tests { Ok(()) } - #[tokio::test] - async fn routes_parent_and_children_with_affinity_and_default() -> crate::Result<()> { - let classifier = Arc::new(ScriptedClassifier { - calls: AtomicUsize::new(0), - }); - let router = configured(classifier.clone())?; - - let (parent, _) = test_drive(router.clone(), request(None), echo()).await?; - let (first, _) = test_drive(router.clone(), child("child-1"), echo()).await?; - let (same_child, _) = test_drive(router.clone(), child("child-1"), echo()).await?; - let (sibling, _) = test_drive(router.clone(), child("child-2"), echo()).await?; - let (defaulted, _) = test_drive(router.clone(), child("child-3"), echo()).await?; - let maintenance = request(Some(Metadata { - session_id: Some("session-1".to_string()), - agent_id: Some("child-1".to_string()), - is_subagent: true, - is_delegated_work: false, - ..Metadata::default() - })); - let (maintenance, _) = test_drive(router, maintenance, echo()).await?; - - assert_eq!(parent, "parent"); - assert_eq!(first, "worker"); - assert_eq!(same_child, "worker"); - assert_eq!(sibling, "reviewer"); - assert_eq!(defaulted, "worker"); - assert_eq!(maintenance, "parent"); - assert_eq!(classifier.calls.load(Ordering::Relaxed), 3); - Ok(()) - } - #[tokio::test] async fn fixed_subagent_gate_routes_parent_and_child() -> crate::Result<()> { let router = Arc::new(Passthrough::new(PassthroughConfig { @@ -286,81 +131,4 @@ mod tests { assert_eq!(child, "worker"); Ok(()) } - - #[tokio::test] - async fn custom_classifier_receives_only_the_delegated_prompt() -> crate::Result<()> { - let classifier = LlmTaskClassifier::new(LlmClassifierConfig::Custom { - judge_target: ModelId::from("judge"), - targets: vec![ - ("worker".to_string(), ModelId::from("worker")), - ("reviewer".to_string(), ModelId::from("reviewer")), - ], - default_target: "worker".to_string(), - config: CustomClassifierConfig::new( - "classify the delegated task", - json!({ - "type": "object", - "properties": { - "target": {"type": "string", "enum": ["worker", "reviewer"]} - }, - "required": ["target"], - "additionalProperties": false - }), - CustomClassifierPolicy::target_selector("/target"), - ), - })?; - let router = configured(Arc::new(classifier))?; - let mut request = child("child-1"); - request.llm_request.instructions = vec![InstructionBlock { - role: Role::System, - content: Message::text(Role::System, "child system instructions").content, - }]; - request.llm_request.messages = vec![ - Message::text(Role::User, "harness context"), - Message { - role: Role::User, - content: vec![ - ContentBlock::Text { - text: "tool context".to_string(), - }, - ContentBlock::Text { - text: "review this parser".to_string(), - }, - ], - }, - ]; - let calls = Arc::new(Mutex::new(Vec::new())); - let served_calls = calls.clone(); - - let (selected, _) = test_drive(router, request, move |target, request| { - let calls = served_calls.clone(); - async move { - let completion = if target == "judge" { - r#"{"target":"reviewer"}"# - } else { - "child answer" - }; - calls.lock().push((target, request)); - Ok(reply(completion)) - } - }) - .await?; - - assert_eq!(selected, "reviewer"); - let calls = calls.lock(); - assert_eq!(calls.len(), 2); - assert_eq!(calls[0].0, "judge"); - assert_eq!( - calls[0].1.llm_request.instructions[0].content, - Message::text(Role::System, "classify the delegated task").content - ); - assert_eq!( - calls[0].1.llm_request.messages, - vec![Message::text(Role::User, "review this parser")] - ); - assert_eq!(calls[1].0, "reviewer"); - assert_eq!(calls[1].1.llm_request.instructions.len(), 1); - assert_eq!(calls[1].1.llm_request.messages.len(), 2); - Ok(()) - } } diff --git a/crates/libsy/src/algorithms/subagent.rs b/crates/libsy/src/algorithms/subagent.rs new file mode 100644 index 000000000..0d65d6f55 --- /dev/null +++ b/crates/libsy/src/algorithms/subagent.rs @@ -0,0 +1,305 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Delegated sub-agent routing around an arbitrary parent algorithm. + +use std::sync::Arc; + +use switchyard_protocol::{Metadata, ModelId, Request}; + +use super::fall_through::{DefaultTarget, FallThrough}; +use super::util::affinity::{AffinityRouter, ClassifyTrigger}; +use super::util::subagent::SubagentGate; +use crate::core::algorithm::{self, Algorithm, Driver}; +use crate::core::classifier::Classifier; +use crate::core::state::State; +use crate::{LibsyError, Result, RoutingOutcome}; + +/// Runtime components for delegated sub-agent routing. +pub struct SubagentRouterConfig { + /// Targets the delegated-work classifier may select. + pub targets: Vec, + /// Classifier invoked for delegated work according to `classify_trigger`. + pub classifier: Arc>, + /// Child target used when `classifier` abstains. + pub default_target: ModelId, + /// Controls whether each child is classified once or on every request. + pub classify_trigger: ClassifyTrigger, + /// Unsupported for child routing because child identity must come from harness metadata. + pub message_hash_fallback: bool, +} + +impl SubagentRouterConfig { + /// Routes delegated work directly to one fixed target. + pub fn fixed_target(target: impl Into) -> Self { + let target = target.into(); + Self { + targets: vec![target.clone()], + classifier: Arc::new(DefaultTarget::new(target.clone())), + default_target: target, + classify_trigger: ClassifyTrigger::EveryRequest, + message_hash_fallback: false, + } + } +} + +/// Routes delegated work independently while preserving the parent algorithm for other traffic. +pub struct SubagentRouter { + parent: Arc, + subagent: FallThrough, +} + +impl SubagentRouter { + /// Wraps `parent` with the configured delegated-work route. + /// + /// # Errors + /// + /// Returns an error when the child default is not a child target or when the affinity + /// settings cannot identify delegated children safely. + pub fn new(parent: Arc, config: SubagentRouterConfig) -> Result { + algorithm::ensure_model_is_target(&config.targets, &config.default_target)?; + if config.message_hash_fallback { + return Err(LibsyError::AlgorithmError { + message: "sub-agent routing cannot use message_hash_fallback".to_string(), + }); + } + + let mut subagent = FallThrough::new_with_state(config.targets).with_name("subagent"); + match config.classify_trigger { + ClassifyTrigger::EveryRequest => {} + ClassifyTrigger::NewSession => { + let affinity = Arc::new(AffinityRouter::for_subagents()); + subagent = subagent + .with_processor(affinity.clone()) + .with_classifier(affinity); + } + ClassifyTrigger::UserTurn => { + return Err(LibsyError::AlgorithmError { + message: "sub-agent routing cannot use classify_trigger = user_turn" + .to_string(), + }); + } + } + subagent = subagent + .with_classifier(Arc::new(SubagentGate::new(config.classifier))) + .with_classifier(Arc::new(DefaultTarget::new(config.default_target))); + + Ok(Self { parent, subagent }) + } +} + +#[async_trait::async_trait] +impl Algorithm for SubagentRouter { + fn name(&self) -> &str { + self.parent.name() + } + + async fn route(self: Arc, driver: Driver, request: Request) -> Result { + if request + .metadata + .as_ref() + .is_some_and(Metadata::is_subagent_work) + { + self.subagent.execute(driver, request).await + } else { + self.parent.clone().route(driver, request).await + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use async_trait::async_trait; + use parking_lot::Mutex; + use serde_json::json; + use switchyard_protocol::{ + ContentBlock, InstructionBlock, Message, Metadata, ModelId, Request, Response, Role, + text_request, + }; + + use super::{SubagentRouter, SubagentRouterConfig}; + use crate::algorithms::passthrough::{Passthrough, PassthroughConfig}; + use crate::core::algorithm::Algorithm; + use crate::core::classifier::{Classification, Classifier, Score}; + use crate::core::testing::{echo, reply, test_drive}; + use crate::{ + ClassifyTrigger, CustomClassifierConfig, CustomClassifierPolicy, Driver, + LlmClassifierConfig, LlmTaskClassifier, State, + }; + + struct ScriptedClassifier { + calls: AtomicUsize, + } + + #[async_trait] + impl Classifier for ScriptedClassifier { + async fn score( + &self, + _state: &mut State, + _request: &mut Request, + _driver: Option<&Driver>, + ) -> crate::Result<(Classification, Option)> { + let scores = match self.calls.fetch_add(1, Ordering::Relaxed) { + 0 => vec![Score { + confidence: 1.0, + target: ModelId::from("worker"), + }], + 1 => vec![Score { + confidence: 1.0, + target: ModelId::from("reviewer"), + }], + _ => Vec::new(), + }; + Ok((Classification::Scores(scores), None)) + } + } + + fn request(metadata: Option) -> Request { + Request { + llm_request: text_request(Some("auto".to_string()), "hi"), + raw_request: None, + metadata, + } + } + + fn child(agent_id: &str) -> Request { + request(Some(Metadata { + session_id: Some("session-1".to_string()), + agent_id: Some(agent_id.to_string()), + is_subagent: true, + is_delegated_work: true, + ..Metadata::default() + })) + } + + fn parent() -> crate::Result> { + Ok(Arc::new(Passthrough::new(PassthroughConfig { + parent_target: ModelId::from("parent"), + subagent: None, + })?)) + } + + fn configured(classifier: Arc>) -> crate::Result> { + Ok(Arc::new(SubagentRouter::new( + parent()?, + SubagentRouterConfig { + targets: vec![ModelId::from("worker"), ModelId::from("reviewer")], + classifier, + default_target: ModelId::from("worker"), + classify_trigger: ClassifyTrigger::NewSession, + message_hash_fallback: false, + }, + )?)) + } + + #[tokio::test] + async fn routes_parent_and_children_with_affinity_and_default() -> crate::Result<()> { + let classifier = Arc::new(ScriptedClassifier { + calls: AtomicUsize::new(0), + }); + let router = configured(classifier.clone())?; + + let (parent, _) = test_drive(router.clone(), request(None), echo()).await?; + let (first, _) = test_drive(router.clone(), child("child-1"), echo()).await?; + let (same_child, _) = test_drive(router.clone(), child("child-1"), echo()).await?; + let (sibling, _) = test_drive(router.clone(), child("child-2"), echo()).await?; + let (defaulted, _) = test_drive(router.clone(), child("child-3"), echo()).await?; + let maintenance = request(Some(Metadata { + session_id: Some("session-1".to_string()), + agent_id: Some("child-1".to_string()), + is_subagent: true, + is_delegated_work: false, + ..Metadata::default() + })); + let (maintenance, _) = test_drive(router, maintenance, echo()).await?; + + assert_eq!(parent, "parent"); + assert_eq!(first, "worker"); + assert_eq!(same_child, "worker"); + assert_eq!(sibling, "reviewer"); + assert_eq!(defaulted, "worker"); + assert_eq!(maintenance, "parent"); + assert_eq!(classifier.calls.load(Ordering::Relaxed), 3); + Ok(()) + } + + #[tokio::test] + async fn custom_classifier_receives_only_the_delegated_prompt() -> crate::Result<()> { + let classifier = LlmTaskClassifier::new(LlmClassifierConfig::Custom { + judge_target: ModelId::from("judge"), + targets: vec![ + ("worker".to_string(), ModelId::from("worker")), + ("reviewer".to_string(), ModelId::from("reviewer")), + ], + default_target: "worker".to_string(), + config: CustomClassifierConfig::new( + "classify the delegated task", + json!({ + "type": "object", + "properties": { + "target": {"type": "string", "enum": ["worker", "reviewer"]} + }, + "required": ["target"], + "additionalProperties": false + }), + CustomClassifierPolicy::target_selector("/target"), + ), + })?; + let router = configured(Arc::new(classifier))?; + let mut request = child("child-1"); + request.llm_request.instructions = vec![InstructionBlock { + role: Role::System, + content: Message::text(Role::System, "child system instructions").content, + }]; + request.llm_request.messages = vec![ + Message::text(Role::User, "harness context"), + Message { + role: Role::User, + content: vec![ + ContentBlock::Text { + text: "tool context".to_string(), + }, + ContentBlock::Text { + text: "review this parser".to_string(), + }, + ], + }, + ]; + let calls = Arc::new(Mutex::new(Vec::new())); + let served_calls = calls.clone(); + + let (selected, _) = test_drive(router, request, move |target, request| { + let calls = served_calls.clone(); + async move { + let completion = if target == "judge" { + r#"{"target":"reviewer"}"# + } else { + "child answer" + }; + calls.lock().push((target, request)); + Ok(reply(completion)) + } + }) + .await?; + + assert_eq!(selected, "reviewer"); + let calls = calls.lock(); + assert_eq!(calls.len(), 2); + assert_eq!(calls[0].0, "judge"); + assert_eq!( + calls[0].1.llm_request.instructions[0].content, + Message::text(Role::System, "classify the delegated task").content + ); + assert_eq!( + calls[0].1.llm_request.messages, + vec![Message::text(Role::User, "review this parser")] + ); + assert_eq!(calls[1].0, "reviewer"); + assert_eq!(calls[1].1.llm_request.instructions.len(), 1); + assert_eq!(calls[1].1.llm_request.messages.len(), 2); + Ok(()) + } +} diff --git a/crates/libsy/src/lib.rs b/crates/libsy/src/lib.rs index b1f9c2943..8610ef62b 100644 --- a/crates/libsy/src/lib.rs +++ b/crates/libsy/src/lib.rs @@ -23,6 +23,7 @@ pub use algorithms::noop::Noop; pub use algorithms::passthrough::{Passthrough, PassthroughConfig, PassthroughSubagentConfig}; pub use algorithms::rand::{Random, RandomClassifier}; pub use algorithms::stage::{LlmFallback, StageRouter, StageRouterConfig}; +pub use algorithms::subagent::{SubagentRouter, SubagentRouterConfig}; pub use algorithms::util::affinity::{AffinityRouter, ClassifyTrigger}; pub use algorithms::util::classifier_contract::{ ClassifierContractConfig, ClassifierResponseFormat, diff --git a/crates/switchyard-server/src/config.rs b/crates/switchyard-server/src/config.rs index f8ca6c45b..170dc4af7 100644 --- a/crates/switchyard-server/src/config.rs +++ b/crates/switchyard-server/src/config.rs @@ -12,8 +12,8 @@ use libsy::{ AdvisorGate, AdvisorGateConfig, Algorithm, ClassifierContractConfig, ClassifierResponseFormat, ClassifyTrigger, CustomClassifierConfig, CustomClassifierPolicy, EscalationJudgeConfig, GateTrigger, HandoffNoteConfig, LlmClassifierConfig, LlmFallback, LlmTaskClassifier, Noop, - Passthrough, PassthroughConfig, PassthroughSubagentConfig, PickerMode, Random, StageRouter, - StageRouterConfig, TargetPrompts, TaskClassifierConfig, + Passthrough, PassthroughConfig, PickerMode, Random, StageRouter, StageRouterConfig, + SubagentRouterConfig, TargetPrompts, TaskClassifierConfig, }; use serde::Deserialize; use serde_json::Value; @@ -1085,7 +1085,7 @@ fn build_algorithm( )) })?, ); - Some(PassthroughSubagentConfig { + Some(SubagentRouterConfig { targets: subagent_targets, classifier, default_target: subagent_default_target, @@ -1093,9 +1093,9 @@ fn build_algorithm( message_hash_fallback: config.message_hash_fallback, }) } else if let Some(SubagentRouteConfig::Passthrough { target }) = subagents { - Some(PassthroughSubagentConfig::fixed_target( - resolve_target_model_id(route_name, target, targets)?, - )) + Some(SubagentRouterConfig::fixed_target(resolve_target_model_id( + route_name, target, targets, + )?)) } else { None }; From 89ca716a97d0fb63b3ee15cd334af9bcc9cd5a99 Mon Sep 17 00:00:00 2001 From: ayushag Date: Thu, 20 Aug 2026 16:53:33 -0700 Subject: [PATCH 5/7] chore: extend subagent to stage router Signed-off-by: ayushag --- crates/switchyard-server/src/config.rs | 313 +++++++++++++------- docs/reference/toml_schema.md | 1 + docs/routing_algorithms/overview.md | 2 +- docs/routing_algorithms/subagent_routing.md | 25 +- 4 files changed, 227 insertions(+), 114 deletions(-) diff --git a/crates/switchyard-server/src/config.rs b/crates/switchyard-server/src/config.rs index 170dc4af7..3e6e58d7e 100644 --- a/crates/switchyard-server/src/config.rs +++ b/crates/switchyard-server/src/config.rs @@ -13,7 +13,7 @@ use libsy::{ ClassifyTrigger, CustomClassifierConfig, CustomClassifierPolicy, EscalationJudgeConfig, GateTrigger, HandoffNoteConfig, LlmClassifierConfig, LlmFallback, LlmTaskClassifier, Noop, Passthrough, PassthroughConfig, PickerMode, Random, StageRouter, StageRouterConfig, - SubagentRouterConfig, TargetPrompts, TaskClassifierConfig, + SubagentRouter, SubagentRouterConfig, TargetPrompts, TaskClassifierConfig, }; use serde::Deserialize; use serde_json::Value; @@ -455,6 +455,27 @@ enum SubagentRouteConfig { LlmClassifier(Box), } +impl SubagentRouteConfig { + fn routing_target_names(&self) -> Vec<&str> { + match self { + Self::Passthrough { target } => vec![target], + Self::LlmClassifier(classifier) => classifier + .targets + .iter() + .flatten() + .map(String::as_str) + .collect(), + } + } + + fn classifier_target_name(&self) -> Option<&str> { + match self { + Self::LlmClassifier(classifier) => Some(&classifier.classifier_target), + Self::Passthrough { .. } => None, + } + } +} + #[derive(Debug, Deserialize)] #[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] enum RouteConfig { @@ -529,6 +550,9 @@ enum RouteConfig { /// Capability judge consulted on turns the signals leave undecided. #[serde(default)] classifier: Option, + /// Optional routing applied only to delegated sub-agent work. + #[serde(default)] + subagents: Option, }, Advisor { id: ModelId, @@ -639,12 +663,8 @@ impl RouteConfig { target, subagents, .. } => { let mut names = vec![target.as_str()]; - match subagents { - Some(SubagentRouteConfig::Passthrough { target }) => names.push(target), - Some(SubagentRouteConfig::LlmClassifier(classifier)) => { - names.extend(classifier.targets.iter().flatten().map(String::as_str)); - } - None => {} + if let Some(subagents) = subagents { + names.extend(subagents.routing_target_names()); } names } @@ -677,8 +697,15 @@ impl RouteConfig { Self::StageRouter { capable_target, efficient_target, + subagents, .. - } => vec![capable_target, efficient_target], + } => { + let mut names = vec![capable_target.as_str(), efficient_target.as_str()]; + if let Some(subagents) = subagents { + names.extend(subagents.routing_target_names()); + } + names + } // The advisor is judge-only: reviews go through its own client, // so it is not a completion (or count_tokens) destination. Self::Advisor { @@ -696,13 +723,21 @@ impl RouteConfig { match self { Self::LlmClassifier { config, .. } => names.push(&config.classifier_target), Self::Passthrough { - subagents: Some(SubagentRouteConfig::LlmClassifier(classifier)), + subagents: Some(subagents), .. - } => names.push(&classifier.classifier_target), + } => names.extend(subagents.classifier_target_name()), Self::StageRouter { - classifier: Some(classifier), + classifier, + subagents, .. - } => names.push(&classifier.target), + } => { + if let Some(classifier) = classifier { + names.push(&classifier.target); + } + if let Some(subagents) = subagents { + names.extend(subagents.classifier_target_name()); + } + } Self::Advisor { advisor_target, .. } => names.push(advisor_target), _ => {} } @@ -1005,6 +1040,104 @@ const fn default_max_retries() -> u32 { DEFAULT_MAX_RETRIES } +// Resolve nested policy once so every supported parent builds the same child route. +fn build_subagent_router_config( + route_name: &str, + config: Option<&SubagentRouteConfig>, + targets: &BTreeMap, +) -> ServerResult> { + let Some(config) = config else { + return Ok(None); + }; + match config { + SubagentRouteConfig::Passthrough { target } => { + Ok(Some(SubagentRouterConfig::fixed_target( + resolve_target_model_id(route_name, target, targets)?, + ))) + } + SubagentRouteConfig::LlmClassifier(config) => { + let LlmClassifierModeConfig::Custom(config) = config.classifier_mode(route_name)? + else { + return Err(ServerError::new(format!( + "route {route_name}: subagents llm_classifier only supports mode custom" + ))); + }; + let judge_target = + resolve_target_model_id(route_name, &config.classifier_target, targets)?; + let resolved_targets = config + .targets + .iter() + .map(|name| { + resolve_target_model_id(route_name, name, targets) + .map(|target| (name.clone(), target)) + }) + .collect::>>()?; + let default_target = resolved_targets + .iter() + .find(|(name, _)| *name == config.default_target) + .map(|(_, target)| target.clone()) + .ok_or_else(|| { + ServerError::new(format!( + "route {route_name}: subagents llm_classifier default_target {:?} must be one of its configured targets", + config.default_target + )) + })?; + let response_schema = + serde_json::from_str(&config.response_schema).map_err(|error| { + ServerError::new(format!( + "route {route_name}: subagents llm_classifier response_schema is invalid JSON: {error}" + )) + })?; + let mut classifier_config = CustomClassifierConfig::new( + config.prompt, + response_schema, + config.policy.into_libsy(), + ); + classifier_config.recent_turn_window = config.recent_turn_window; + classifier_config.max_output_tokens = config.max_output_tokens; + let subagent_targets = resolved_targets + .iter() + .map(|(_, target)| target.clone()) + .collect(); + let classifier = Arc::new( + LlmTaskClassifier::new(LlmClassifierConfig::Custom { + judge_target, + targets: resolved_targets, + default_target: config.default_target, + config: classifier_config, + }) + .map_err(|error| { + ServerError::new(format!( + "route {route_name}: subagents llm_classifier: {error}" + )) + })?, + ); + Ok(Some(SubagentRouterConfig { + targets: subagent_targets, + classifier, + default_target, + classify_trigger: config.classify_trigger, + message_hash_fallback: config.message_hash_fallback, + })) + } + } +} + +fn attach_subagent_router( + route_name: &str, + parent: Arc, + config: Option<&SubagentRouteConfig>, + targets: &BTreeMap, +) -> ServerResult> { + let Some(config) = build_subagent_router_config(route_name, config, targets)? else { + return Ok(parent); + }; + let algorithm = SubagentRouter::new(parent, config).map_err(|error| { + ServerError::new(format!("route {route_name}: subagent routing: {error}")) + })?; + Ok(Arc::new(algorithm)) +} + fn build_algorithm( route_name: &str, config: &RouteConfig, @@ -1028,85 +1161,15 @@ fn build_algorithm( target, subagents, .. } => { let parent_target = resolve_target_model_id(route_name, target, targets)?; - let subagent = if let Some(SubagentRouteConfig::LlmClassifier(config)) = subagents { - let LlmClassifierModeConfig::Custom(config) = config.classifier_mode(route_name)? - else { - return Err(ServerError::new(format!( - "passthrough route {route_name}: subagents llm_classifier only supports mode custom" - ))); - }; - let judge_target = - resolve_target_model_id(route_name, &config.classifier_target, targets)?; - let resolved_targets = config - .targets - .iter() - .map(|name| { - resolve_target_model_id(route_name, name, targets) - .map(|target| (name.clone(), target)) - }) - .collect::>>()?; - let subagent_default_target = resolved_targets - .iter() - .find(|(name, _)| *name == config.default_target) - .map(|(_, target)| target.clone()) - .ok_or_else(|| { - ServerError::new(format!( - "passthrough route {route_name}: subagents llm_classifier default_target {:?} must be one of its configured targets", - config.default_target - )) - })?; - let response_schema = - serde_json::from_str(&config.response_schema).map_err(|error| { - ServerError::new(format!( - "passthrough route {route_name}: subagents llm_classifier response_schema is invalid JSON: {error}" - )) - })?; - let mut classifier_config = CustomClassifierConfig::new( - config.prompt.clone(), - response_schema, - config.policy.clone().into_libsy(), - ); - classifier_config.recent_turn_window = config.recent_turn_window; - classifier_config.max_output_tokens = config.max_output_tokens; - let subagent_targets = resolved_targets - .iter() - .map(|(_, target)| target.clone()) - .collect(); - let classifier = Arc::new( - LlmTaskClassifier::new(LlmClassifierConfig::Custom { - judge_target, - targets: resolved_targets, - default_target: config.default_target.clone(), - config: classifier_config, - }) - .map_err(|error| { - ServerError::new(format!( - "passthrough route {route_name}: subagents llm_classifier: {error}" - )) - })?, - ); - Some(SubagentRouterConfig { - targets: subagent_targets, - classifier, - default_target: subagent_default_target, - classify_trigger: config.classify_trigger, - message_hash_fallback: config.message_hash_fallback, - }) - } else if let Some(SubagentRouteConfig::Passthrough { target }) = subagents { - Some(SubagentRouterConfig::fixed_target(resolve_target_model_id( - route_name, target, targets, - )?)) - } else { - None - }; let algorithm = Passthrough::new(PassthroughConfig { parent_target, - subagent, + subagent: None, }) .map_err(|error| { ServerError::new(format!("passthrough route {route_name}: {error}")) })?; - Ok(Arc::new(algorithm)) + let parent: Arc = Arc::new(algorithm); + attach_subagent_router(route_name, parent, subagents.as_ref(), targets) } RouteConfig::LlmClassifier { config: classifier_config, @@ -1199,6 +1262,7 @@ fn build_algorithm( capable_system_prompt, efficient_system_prompt, classifier, + subagents, .. } => { if matches!(picker, PickerMode::CapableFirst) { @@ -1233,7 +1297,8 @@ fn build_algorithm( let algorithm = StageRouter::new(capable, efficient, config).map_err(|error| { ServerError::new(format!("stage_router route {route_name}: {error}")) })?; - Ok(Arc::new(algorithm)) + let parent: Arc = Arc::new(algorithm); + attach_subagent_router(route_name, parent, subagents.as_ref(), targets) } RouteConfig::Advisor { executor_target, @@ -1426,16 +1491,11 @@ target = "weak" } } - fn passthrough_with_subagent_llm_classifier(extra: &str) -> String { - let configured = VALID_CONFIG.replace( - "[routes.passthrough]\nid = \"switchyard/passthrough\"\ntype = \"passthrough\"\ntarget = \"weak\"", - r#"[routes.passthrough] -id = "switchyard/passthrough" -type = "passthrough" -target = "weak" - -[routes.passthrough.subagents] -type = "llm_classifier" + fn with_subagent_llm_classifier(config: &str, route: &str, extra: &str) -> String { + let mut configured = config.to_string(); + configured.push_str(&format!("\n[routes.{route}.subagents]\n")); + configured.push_str( + r#"type = "llm_classifier" mode = "custom" classifier_target = "classifier" targets = ["strong", "weak"] @@ -1445,16 +1505,33 @@ response_schema = '{"type":"object","properties":{"target":{"type":"string","enu policy = { type = "target_selector", selector = "/target" } classify_trigger = "new_session""#, ); - configured.replace( - "classify_trigger = \"new_session\"", - &format!("classify_trigger = \"new_session\"{extra}"), - ) + configured.push_str(extra); + configured + } + + fn with_subagent_passthrough(config: &str, route: &str) -> String { + format!("{config}\n[routes.{route}.subagents]\ntype = \"passthrough\"\ntarget = \"strong\"") } - fn passthrough_with_subagent_passthrough() -> String { - VALID_CONFIG.replace( - "[routes.passthrough]\nid = \"switchyard/passthrough\"\ntype = \"passthrough\"\ntarget = \"weak\"", - "[routes.passthrough]\nid = \"switchyard/passthrough\"\ntype = \"passthrough\"\ntarget = \"weak\"\n\n[routes.passthrough.subagents]\ntype = \"passthrough\"\ntarget = \"strong\"", + fn stage_config() -> String { + format!( + r#"{VALID_CONFIG} +[targets.stage_judge] +id = "stage-judge/model" +llm_client = "primary" + +[routes.stage] +id = "switchyard/stage" +type = "stage_router" +capable_target = "strong" +efficient_target = "weak" +picker = "efficient_first" +confidence_threshold = 1.0 + +[routes.stage.classifier] +target = "stage_judge" +base_threshold = 0.5 +"# ) } @@ -1475,10 +1552,24 @@ classify_trigger = "new_session""#, } #[test] - fn passthrough_accepts_subagent_gates() -> ServerResult<()> { + fn passthrough_and_stage_accept_subagent_routing() -> ServerResult<()> { + let stage = stage_config(); + let stage_with_classifier = with_subagent_llm_classifier(&stage, "stage", ""); + let parsed: ServerConfig = toml::from_str(&stage_with_classifier) + .map_err(|error| ServerError::new(format!("failed to parse stage config: {error}")))?; + let Some(stage_route) = parsed.routes.get("stage") else { + return Err(ServerError::new("stage route is missing")); + }; + let callable_targets = stage_route.callable_target_names(); + for expected in ["strong", "weak", "stage_judge", "classifier"] { + assert!(callable_targets.contains(&expected)); + } + for configured in [ - passthrough_with_subagent_llm_classifier(""), - passthrough_with_subagent_passthrough(), + with_subagent_llm_classifier(VALID_CONFIG, "passthrough", ""), + with_subagent_passthrough(VALID_CONFIG, "passthrough"), + stage_with_classifier, + with_subagent_passthrough(&stage, "stage"), ] { server_state_from_toml(&configured)?; } @@ -1705,11 +1796,15 @@ classifier_magic = true "message_hash_fallback requires classify_trigger = new_session", ), ( - passthrough_with_subagent_llm_classifier("\nmessage_hash_fallback = true"), + with_subagent_llm_classifier( + VALID_CONFIG, + "passthrough", + "\nmessage_hash_fallback = true", + ), "cannot use message_hash_fallback", ), ( - passthrough_with_subagent_llm_classifier("") + with_subagent_llm_classifier(VALID_CONFIG, "passthrough", "") .replace("mode = \"custom\"", "mode = \"capability\""), "mode capability cannot use custom classifier fields", ), diff --git a/docs/reference/toml_schema.md b/docs/reference/toml_schema.md index 88257c0c0..b607644e9 100644 --- a/docs/reference/toml_schema.md +++ b/docs/reference/toml_schema.md @@ -209,6 +209,7 @@ optional `handoff_notes` and `classifier` tables and for tuning. | `efficient_system_prompt` | No | unset | System prompt handed to the efficient tier. | | `classifier.classify_trigger` | No | `every_request` | When the judge runs. See the `llm_classifier` route. `new_session` has no effect here. | | `classifier.response_format_type` | No | `json_schema` | Structured-output mode for the optional classifier judge. Use `json_object` when the classifier provider does not support JSON Schema; Switchyard adds the schema to the prompt and validates the verdict locally. | +| `subagents` | No | unset | Nested `passthrough` or custom `llm_classifier` policy used only for delegated sub-agent work. See [Sub-Agent-Aware Routing](../routing_algorithms/subagent_routing.md). | ## Validation Errors diff --git a/docs/routing_algorithms/overview.md b/docs/routing_algorithms/overview.md index 17910281e..635959a4a 100644 --- a/docs/routing_algorithms/overview.md +++ b/docs/routing_algorithms/overview.md @@ -12,7 +12,7 @@ configuration and tuning. For the vocabulary these pages use, see | Strategy | Use it when | Route `type` | |---|---|---| -| [Sub-Agent-Aware Routing](subagent_routing.md) | Parent traffic should use one target while delegated sub-agents use a separate routing policy. | `passthrough` with `subagents` | +| [Sub-Agent-Aware Routing](subagent_routing.md) | Delegated sub-agents should use a separate routing policy from the parent agent. | `passthrough` or `stage_router` with `subagents` | | [Random Routing](random_routing.md) | You need a fixed traffic split for A/B tests, baselines, or cost experiments. | `random` | | [LLM Classifier Routing](llm_classifier_routing.md) | Request content should decide whether a turn needs the weak or strong tier. | `llm_classifier` | | [Stage-Router Routing](stage_router_routing.md) | Tool-result and agent-progress signals should route most turns without an extra classifier call. | `stage_router` | diff --git a/docs/routing_algorithms/subagent_routing.md b/docs/routing_algorithms/subagent_routing.md index 50380d05f..2533e80ea 100644 --- a/docs/routing_algorithms/subagent_routing.md +++ b/docs/routing_algorithms/subagent_routing.md @@ -1,8 +1,8 @@ # Sub-Agent-Aware Routing -Sub-agent-aware routing keeps parent-agent traffic on one target while routing -delegated sub-agent work across separate targets. Current support is available -through the `passthrough` route's optional `subagents` table. +Sub-agent-aware routing leaves parent-agent traffic with its configured routing +algorithm while routing delegated sub-agent work separately. It is available on +`passthrough` and `stage_router` routes through the optional `subagents` table. ```toml schema_version = 1 @@ -79,7 +79,24 @@ the prompt supplied by the parent and selects one configured target. With `classify_trigger = "new_session"`, Switchyard reuses that decision for later requests from the same `session + agent` identity. Use `every_request` to classify each delegated request. `user_turn` is not supported for sub-agent -routing. Harness-maintenance requests continue to the parent target. +routing. Harness-maintenance requests continue through the parent route. + +To use Stage Router for parent traffic, replace the `[routes.agent]` table in the +example with the following. The nested `[routes.agent.subagents]` classifier is +unchanged. + +```toml +[routes.agent] +id = "agent" +type = "stage_router" +capable_target = "reviewer" +efficient_target = "worker" +picker = "efficient_first" +confidence_threshold = 0.7 +context_window = 400000 +tool_calling = true +reasoning = true +``` Clients must still request the route ID (`agent` above). An explicit model name that is not registered as a route is rejected before sub-agent classification. From abacfa9553248ed772ec2b56410c1542cc10a0ab Mon Sep 17 00:00:00 2001 From: ayushag Date: Thu, 20 Aug 2026 16:59:51 -0700 Subject: [PATCH 6/7] chore: passthrough is back to being passthrough Signed-off-by: ayushag --- crates/libsy/src/algorithms/passthrough.rs | 96 ++++------------------ crates/libsy/src/algorithms/subagent.rs | 11 +-- crates/libsy/src/lib.rs | 2 +- crates/switchyard-server/src/config.rs | 12 +-- 4 files changed, 26 insertions(+), 95 deletions(-) diff --git a/crates/libsy/src/algorithms/passthrough.rs b/crates/libsy/src/algorithms/passthrough.rs index 9f0b38c54..bd2fa5994 100644 --- a/crates/libsy/src/algorithms/passthrough.rs +++ b/crates/libsy/src/algorithms/passthrough.rs @@ -1,55 +1,26 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Direct parent routing with an optional delegated-work route. +//! Single-target routing for direct model calls and integration diagnostics. use std::sync::Arc; use switchyard_protocol::{ModelId, Request}; -use super::fall_through::{DefaultTarget, FallThrough}; -use super::subagent::{SubagentRouter, SubagentRouterConfig}; use crate::core::algorithm::{Algorithm, Driver}; use crate::{Result, RoutingOutcome}; -/// Backwards-compatible name for [`SubagentRouterConfig`]. -pub use super::subagent::SubagentRouterConfig as PassthroughSubagentConfig; - -/// Routes parent traffic directly and optionally routes delegated sub-agent work. +/// Routing algorithm that always selects one configured target. pub struct Passthrough { - route: Arc, -} - -/// Complete construction settings for [`Passthrough`]. -pub struct PassthroughConfig { - /// Target used for parent and harness-maintenance traffic. - pub parent_target: ModelId, - /// Optional delegated-work routing. - pub subagent: Option, + target: ModelId, } impl Passthrough { - /// Creates direct parent routing, optionally with a decision gate for sub-agents. - /// - /// A `new_session` classifier retains its first decision by `session + agent`; an - /// `every_request` gate decides each delegated request independently. An abstaining gate uses - /// the child default. Root and harness-maintenance traffic continue to the parent target. - /// - /// # Errors - /// - /// Returns an error when the delegated-work routing configuration is invalid. - pub fn new(config: PassthroughConfig) -> Result { - let parent_target = config.parent_target; - let parent: Arc = Arc::new( - FallThrough::new(vec![parent_target.clone()]) - .with_name("passthrough") - .with_classifier(Arc::new(DefaultTarget::new(parent_target))), - ); - let route: Arc = match config.subagent { - Some(subagent) => Arc::new(SubagentRouter::new(parent, subagent)?), - None => parent, - }; - Ok(Self { route }) + /// Creates an algorithm that always selects `target`. + pub fn new(target: impl Into) -> Self { + Self { + target: target.into(), + } } } @@ -59,8 +30,13 @@ impl Algorithm for Passthrough { "passthrough" } - async fn route(self: Arc, driver: Driver, request: Request) -> Result { - self.route.clone().route(driver, request).await + async fn route(self: Arc, _driver: Driver, request: Request) -> Result { + tracing::info!(target = %self.target, "passthrough selected target"); + Ok(RoutingOutcome::route_to( + self.target.clone(), + Vec::new(), + request, + )) } } @@ -68,28 +44,10 @@ impl Algorithm for Passthrough { mod tests { use std::sync::Arc; - use super::{Passthrough, PassthroughConfig, PassthroughSubagentConfig}; + use super::Passthrough; use crate::core::algorithm::Algorithm; use crate::core::testing::{echo, test_drive}; - use switchyard_protocol::{Metadata, ModelId, Request, completion_text, text_request}; - - fn request(metadata: Option) -> Request { - Request { - llm_request: text_request(Some("auto".to_string()), "hi"), - raw_request: None, - metadata, - } - } - - fn child(agent_id: &str) -> Request { - request(Some(Metadata { - session_id: Some("session-1".to_string()), - agent_id: Some(agent_id.to_string()), - is_subagent: true, - is_delegated_work: true, - ..Metadata::default() - })) - } + use switchyard_protocol::{Request, completion_text, text_request}; #[tokio::test] async fn test_passthrough() -> crate::Result<()> { @@ -99,10 +57,7 @@ mod tests { raw_request: None, metadata: None, }; - let algorithm: Arc = Arc::new(Passthrough::new(PassthroughConfig { - parent_target: ModelId::from(MODEL_ID), - subagent: None, - })?); + let algorithm: Arc = Arc::new(Passthrough::new(MODEL_ID)); let (selected_model, response) = test_drive(algorithm, request, echo()).await?; assert_eq!( @@ -116,19 +71,4 @@ mod tests { assert_eq!(selected_model, MODEL_ID); Ok(()) } - - #[tokio::test] - async fn fixed_subagent_gate_routes_parent_and_child() -> crate::Result<()> { - let router = Arc::new(Passthrough::new(PassthroughConfig { - parent_target: ModelId::from("parent"), - subagent: Some(PassthroughSubagentConfig::fixed_target("worker")), - })?); - - let (parent, _) = test_drive(router.clone(), request(None), echo()).await?; - let (child, _) = test_drive(router, child("child-1"), echo()).await?; - - assert_eq!(parent, "parent"); - assert_eq!(child, "worker"); - Ok(()) - } } diff --git a/crates/libsy/src/algorithms/subagent.rs b/crates/libsy/src/algorithms/subagent.rs index 0d65d6f55..26fb4ba62 100644 --- a/crates/libsy/src/algorithms/subagent.rs +++ b/crates/libsy/src/algorithms/subagent.rs @@ -121,7 +121,7 @@ mod tests { }; use super::{SubagentRouter, SubagentRouterConfig}; - use crate::algorithms::passthrough::{Passthrough, PassthroughConfig}; + use crate::algorithms::passthrough::Passthrough; use crate::core::algorithm::Algorithm; use crate::core::classifier::{Classification, Classifier, Score}; use crate::core::testing::{echo, reply, test_drive}; @@ -175,16 +175,13 @@ mod tests { })) } - fn parent() -> crate::Result> { - Ok(Arc::new(Passthrough::new(PassthroughConfig { - parent_target: ModelId::from("parent"), - subagent: None, - })?)) + fn parent() -> Arc { + Arc::new(Passthrough::new("parent")) } fn configured(classifier: Arc>) -> crate::Result> { Ok(Arc::new(SubagentRouter::new( - parent()?, + parent(), SubagentRouterConfig { targets: vec![ModelId::from("worker"), ModelId::from("reviewer")], classifier, diff --git a/crates/libsy/src/lib.rs b/crates/libsy/src/lib.rs index 8610ef62b..8ae823689 100644 --- a/crates/libsy/src/lib.rs +++ b/crates/libsy/src/lib.rs @@ -20,7 +20,7 @@ pub use algorithms::llm_class::{ TaskClassifierConfig, }; pub use algorithms::noop::Noop; -pub use algorithms::passthrough::{Passthrough, PassthroughConfig, PassthroughSubagentConfig}; +pub use algorithms::passthrough::Passthrough; pub use algorithms::rand::{Random, RandomClassifier}; pub use algorithms::stage::{LlmFallback, StageRouter, StageRouterConfig}; pub use algorithms::subagent::{SubagentRouter, SubagentRouterConfig}; diff --git a/crates/switchyard-server/src/config.rs b/crates/switchyard-server/src/config.rs index 3e6e58d7e..906c3e668 100644 --- a/crates/switchyard-server/src/config.rs +++ b/crates/switchyard-server/src/config.rs @@ -12,8 +12,8 @@ use libsy::{ AdvisorGate, AdvisorGateConfig, Algorithm, ClassifierContractConfig, ClassifierResponseFormat, ClassifyTrigger, CustomClassifierConfig, CustomClassifierPolicy, EscalationJudgeConfig, GateTrigger, HandoffNoteConfig, LlmClassifierConfig, LlmFallback, LlmTaskClassifier, Noop, - Passthrough, PassthroughConfig, PickerMode, Random, StageRouter, StageRouterConfig, - SubagentRouter, SubagentRouterConfig, TargetPrompts, TaskClassifierConfig, + Passthrough, PickerMode, Random, StageRouter, StageRouterConfig, SubagentRouter, + SubagentRouterConfig, TargetPrompts, TaskClassifierConfig, }; use serde::Deserialize; use serde_json::Value; @@ -1161,13 +1161,7 @@ fn build_algorithm( target, subagents, .. } => { let parent_target = resolve_target_model_id(route_name, target, targets)?; - let algorithm = Passthrough::new(PassthroughConfig { - parent_target, - subagent: None, - }) - .map_err(|error| { - ServerError::new(format!("passthrough route {route_name}: {error}")) - })?; + let algorithm = Passthrough::new(parent_target); let parent: Arc = Arc::new(algorithm); attach_subagent_router(route_name, parent, subagents.as_ref(), targets) } From bd60861bd7590f8158e6069b1e86d3a956e26cab Mon Sep 17 00:00:00 2001 From: ayushag Date: Fri, 21 Aug 2026 08:16:10 -0700 Subject: [PATCH 7/7] fix: rust nits Signed-off-by: ayushag --- crates/libsy/src/algorithms/subagent.rs | 14 +++--- crates/switchyard-server/src/config.rs | 59 ++++++++++++------------- 2 files changed, 37 insertions(+), 36 deletions(-) diff --git a/crates/libsy/src/algorithms/subagent.rs b/crates/libsy/src/algorithms/subagent.rs index 26fb4ba62..bb918f05e 100644 --- a/crates/libsy/src/algorithms/subagent.rs +++ b/crates/libsy/src/algorithms/subagent.rs @@ -64,14 +64,16 @@ impl SubagentRouter { }); } - let mut subagent = FallThrough::new_with_state(config.targets).with_name("subagent"); - match config.classify_trigger { - ClassifyTrigger::EveryRequest => {} + let mut subagent = match config.classify_trigger { + ClassifyTrigger::EveryRequest => { + FallThrough::new_with_state(config.targets).with_name("subagent") + } ClassifyTrigger::NewSession => { let affinity = Arc::new(AffinityRouter::for_subagents()); - subagent = subagent + FallThrough::new_with_state(config.targets) + .with_name("subagent") .with_processor(affinity.clone()) - .with_classifier(affinity); + .with_classifier(affinity) } ClassifyTrigger::UserTurn => { return Err(LibsyError::AlgorithmError { @@ -79,7 +81,7 @@ impl SubagentRouter { .to_string(), }); } - } + }; subagent = subagent .with_classifier(Arc::new(SubagentGate::new(config.classifier))) .with_classifier(Arc::new(DefaultTarget::new(config.default_target))); diff --git a/crates/switchyard-server/src/config.rs b/crates/switchyard-server/src/config.rs index 906c3e668..77373959b 100644 --- a/crates/switchyard-server/src/config.rs +++ b/crates/switchyard-server/src/config.rs @@ -410,41 +410,26 @@ struct CustomClassifierRouteConfig { max_output_tokens: u64, } -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Debug, Default, Deserialize)] +#[serde(default, deny_unknown_fields)] struct LlmClassifierRouteConfig { classifier_target: String, - #[serde(default)] mode: Option, - #[serde(default)] strong_target: Option, - #[serde(default)] weak_target: Option, - #[serde(default)] base_threshold: Option, - #[serde(default)] threshold_step: Option, - #[serde(default)] classify_trigger: ClassifyTrigger, - #[serde(default)] message_hash_fallback: bool, - #[serde(default)] recent_turn_window: Option, - #[serde(default)] prompt: Option, - #[serde(default)] response_format_type: ClassifierResponseFormat, #[serde(default = "default_classifier_max_output_tokens")] max_output_tokens: u64, - #[serde(default)] escalation: Option, - #[serde(default)] targets: Option>, - #[serde(default)] default_target: Option, - #[serde(default)] response_schema: Option, - #[serde(default)] policy: Option, } @@ -1043,18 +1028,13 @@ const fn default_max_retries() -> u32 { // Resolve nested policy once so every supported parent builds the same child route. fn build_subagent_router_config( route_name: &str, - config: Option<&SubagentRouteConfig>, + config: &SubagentRouteConfig, targets: &BTreeMap, -) -> ServerResult> { - let Some(config) = config else { - return Ok(None); - }; +) -> ServerResult { match config { - SubagentRouteConfig::Passthrough { target } => { - Ok(Some(SubagentRouterConfig::fixed_target( - resolve_target_model_id(route_name, target, targets)?, - ))) - } + SubagentRouteConfig::Passthrough { target } => Ok(SubagentRouterConfig::fixed_target( + resolve_target_model_id(route_name, target, targets)?, + )), SubagentRouteConfig::LlmClassifier(config) => { let LlmClassifierModeConfig::Custom(config) = config.classifier_mode(route_name)? else { @@ -1112,13 +1092,13 @@ fn build_subagent_router_config( )) })?, ); - Ok(Some(SubagentRouterConfig { + Ok(SubagentRouterConfig { targets: subagent_targets, classifier, default_target, classify_trigger: config.classify_trigger, message_hash_fallback: config.message_hash_fallback, - })) + }) } } } @@ -1129,9 +1109,10 @@ fn attach_subagent_router( config: Option<&SubagentRouteConfig>, targets: &BTreeMap, ) -> ServerResult> { - let Some(config) = build_subagent_router_config(route_name, config, targets)? else { + let Some(config) = config else { return Ok(parent); }; + let config = build_subagent_router_config(route_name, config, targets)?; let algorithm = SubagentRouter::new(parent, config).map_err(|error| { ServerError::new(format!("route {route_name}: subagent routing: {error}")) })?; @@ -1761,6 +1742,24 @@ classifier_magic = true VALID_CONFIG.replace("base_threshold = 0.5", "base_threshold = 1.5"), "base_threshold must be between 0 and 1", ), + ( + VALID_CONFIG.replace("classifier_target = \"classifier\"\n", ""), + "route references unknown target", + ), + ( + VALID_CONFIG.replace( + "classifier_target = \"classifier\"", + "classifier_target = \"\"", + ), + "route references unknown target", + ), + ( + VALID_CONFIG.replace( + "classifier_target = \"classifier\"", + "classifier_target = \" \"", + ), + "route references unknown target", + ), ( VALID_CONFIG.replace( "base_threshold = 0.5",