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 6f9952f64..bd2fa5994 100644 --- a/crates/libsy/src/algorithms/passthrough.rs +++ b/crates/libsy/src/algorithms/passthrough.rs @@ -1,102 +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 classifier cascade. +//! 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::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 crate::core::algorithm::{Algorithm, Driver}; +use crate::{Result, RoutingOutcome}; -/// Routes parent traffic directly and optionally classifies delegated sub-agent work. +/// Routing algorithm that always selects one configured target. pub struct Passthrough { - parent_target: ModelId, - route: FallThrough, -} - -/// Runtime components for classifying and retaining delegated sub-agent work. -pub struct PassthroughSubagentConfig { - /// Targets the delegated-work classifier may select. - pub targets: Vec, - /// Classifier invoked for the first request from each identified child. - 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, -} - -/// 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, + target: ModelId, } 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. - /// - /// # Errors - /// - /// Returns an error when the configured child default is not a child target. - 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()]) - .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()))) - } - }; - - Ok(Self { - parent_target, - route, - }) + /// Creates an algorithm that always selects `target`. + pub fn new(target: impl Into) -> Self { + Self { + target: target.into(), + } } } @@ -106,100 +30,24 @@ impl Algorithm for Passthrough { "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) + 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, + )) } } #[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 super::Passthrough; 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)) - } - } - - 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 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, - }), - })?)) - } + use crate::core::testing::{echo, test_drive}; + use switchyard_protocol::{Request, completion_text, text_request}; #[tokio::test] async fn test_passthrough() -> crate::Result<()> { @@ -209,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!( @@ -226,112 +71,4 @@ mod tests { assert_eq!(selected_model, MODEL_ID); 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 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..bb918f05e --- /dev/null +++ b/crates/libsy/src/algorithms/subagent.rs @@ -0,0 +1,304 @@ +// 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 = match config.classify_trigger { + ClassifyTrigger::EveryRequest => { + FallThrough::new_with_state(config.targets).with_name("subagent") + } + ClassifyTrigger::NewSession => { + let affinity = Arc::new(AffinityRouter::for_subagents()); + FallThrough::new_with_state(config.targets) + .with_name("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; + 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() -> Arc { + Arc::new(Passthrough::new("parent")) + } + + 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..8ae823689 100644 --- a/crates/libsy/src/lib.rs +++ b/crates/libsy/src/lib.rs @@ -20,9 +20,10 @@ 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}; 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 feee52ca7..77373959b 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, PickerMode, Random, StageRouter, StageRouterConfig, SubagentRouter, + SubagentRouterConfig, TargetPrompts, TaskClassifierConfig, }; use serde::Deserialize; use serde_json::Value; @@ -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,14 +404,61 @@ 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, + max_output_tokens: u64, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct LlmClassifierRouteConfig { + classifier_target: String, + mode: Option, + strong_target: Option, + weak_target: Option, + base_threshold: Option, + threshold_step: Option, + classify_trigger: ClassifyTrigger, + message_hash_fallback: bool, + recent_turn_window: Option, + prompt: Option, + response_format_type: ClassifierResponseFormat, #[serde(default = "default_classifier_max_output_tokens")] max_output_tokens: u64, + escalation: Option, + targets: Option>, + default_target: Option, + response_schema: Option, + policy: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +enum SubagentRouteConfig { + Passthrough { target: String }, + 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)] @@ -449,7 +495,7 @@ enum RouteConfig { reasoning: Option, target: String, #[serde(default)] - subagent_classifier: Option, + subagents: Option, }, LlmClassifier { id: ModelId, @@ -459,39 +505,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, @@ -520,6 +535,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, @@ -627,45 +645,52 @@ 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)); + if let Some(subagents) = subagents { + names.extend(subagents.routing_target_names()); } 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, + 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 { @@ -681,17 +706,23 @@ 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 { - subagent_classifier: Some(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), _ => {} } @@ -743,9 +774,11 @@ impl RouteConfig { }, } } +} +impl LlmClassifierRouteConfig { fn classifier_mode(&self, route_name: &str) -> ServerResult { - let Self::LlmClassifier { + let Self { classifier_target, mode, strong_target, @@ -763,11 +796,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, @@ -996,6 +1025,100 @@ 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: &SubagentRouteConfig, + targets: &BTreeMap, +) -> ServerResult { + match config { + 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 { + 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(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) = 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}")) + })?; + Ok(Arc::new(algorithm)) +} + fn build_algorithm( route_name: &str, config: &RouteConfig, @@ -1016,86 +1139,20 @@ 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 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}: subagent_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}" - )) - })?; - 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}: subagent_classifier: {error}" - )) - })?, - ); - Some(PassthroughSubagentConfig { - targets: subagent_targets, - classifier, - default_target: subagent_default_target, - classify_trigger: config.classify_trigger, - message_hash_fallback: config.message_hash_fallback, - }) - } else { - None - }; - let algorithm = Passthrough::new(PassthroughConfig { - parent_target, - subagent, - }) - .map_err(|error| { - ServerError::new(format!("passthrough route {route_name}: {error}")) - })?; - Ok(Arc::new(algorithm)) + let algorithm = Passthrough::new(parent_target); + let parent: Arc = Arc::new(algorithm); + attach_subagent_router(route_name, parent, subagents.as_ref(), targets) } 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 = @@ -1180,6 +1237,7 @@ fn build_algorithm( capable_system_prompt, efficient_system_prompt, classifier, + subagents, .. } => { if matches!(picker, PickerMode::CapableFirst) { @@ -1214,7 +1272,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, @@ -1407,15 +1466,12 @@ target = "weak" } } - fn passthrough_with_subagent_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.subagent_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"] default_target = "weak" @@ -1424,9 +1480,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 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 +"# ) } @@ -1447,10 +1527,27 @@ classify_trigger = "new_session""#, } #[test] - fn passthrough_accepts_a_custom_subagent_classifier() -> ServerResult<()> { - let configured = passthrough_with_subagent_classifier(""); + 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)); + } - server_state_from_toml(&configured)?; + for configured in [ + 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)?; + } Ok(()) } @@ -1645,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", @@ -1674,9 +1789,18 @@ classifier_magic = true "message_hash_fallback requires classify_trigger = new_session", ), ( - passthrough_with_subagent_classifier("\nmessage_hash_fallback = true"), + with_subagent_llm_classifier( + VALID_CONFIG, + "passthrough", + "\nmessage_hash_fallback = true", + ), "cannot use message_hash_fallback", ), + ( + with_subagent_llm_classifier(VALID_CONFIG, "passthrough", "") + .replace("mode = \"custom\"", "mode = \"capability\""), + "mode capability cannot use custom classifier fields", + ), ( VALID_CONFIG.replace( "base_threshold = 0.5", diff --git a/docs/reference/toml_schema.md b/docs/reference/toml_schema.md index f5945acbd..b607644e9 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. Nested classifiers currently support only `mode = "custom"`. | ### `random` @@ -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 f7b971987..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 are classified across other targets. | `passthrough` with `subagent_classifier` | +| [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 d4e6add70..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 `subagent_classifier` 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 @@ -36,7 +36,9 @@ context_window = 400000 tool_calling = true reasoning = true -[routes.agent.subagent_classifier] +[routes.agent.subagents] +type = "llm_classifier" +mode = "custom" classifier_target = "classifier" targets = ["worker", "reviewer"] default_target = "worker" @@ -77,9 +79,35 @@ 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. `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" +```