diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 3a84dc1fe..81fcc422b 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -1758,6 +1758,12 @@ pub struct SubagentsConfig { /// provider names such as `deepseek`, `zai`, `openrouter`, or `anthropic`. #[serde(default)] pub providers: Option>, + /// Explicit per-role/type provider routes (#3965). Keys accept the same + /// role/type names as `models`. A matching sub-agent spawns on the named + /// provider instead of inheriting the parent session's active provider; + /// roles without a route keep the legacy inherit behavior. + #[serde(default)] + pub routes: Option>, } /// Provider-specific sub-agent limit overrides. @@ -1784,6 +1790,19 @@ pub struct SubagentProviderConfig { pub heartbeat_timeout_secs: Option, } +/// One `[subagents.routes.]` entry (#3965): pins a sub-agent role or +/// type to a specific provider, and optionally a model on that provider. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct SubagentRouteConfig { + /// Built-in provider id (`deepseek`, `ollama`, …) or the name of a + /// `[providers.]` custom entry (e.g. an LM Studio endpoint). + pub provider: String, + /// Optional model on that provider. Unset uses the provider's saved or + /// default model. + #[serde(default)] + pub model: Option, +} + /// `[auto]` table — knobs for the `--model auto` / `/model auto` router. /// /// `cost_saving` (#1207): when `true`, the auto-mode router prefers @@ -3966,6 +3985,37 @@ impl Config { overrides } + /// Raw per-role/type sub-agent provider routes from `[subagents.routes]` + /// (#3965). Keys are normalized to lowercase; provider names are validated + /// at spawn time so a bad route fails that spawn, not the session. + #[must_use] + pub fn subagent_provider_routes(&self) -> HashMap { + let mut routes = HashMap::new(); + let Some(configured) = self.subagents.as_ref().and_then(|cfg| cfg.routes.as_ref()) else { + return routes; + }; + for (key, route) in configured { + let key = key.trim().to_ascii_lowercase(); + let provider = route.provider.trim(); + if key.is_empty() || provider.is_empty() { + continue; + } + routes.insert( + key, + SubagentRouteConfig { + provider: provider.to_string(), + model: route + .model + .as_deref() + .map(str::trim) + .filter(|model| !model.is_empty()) + .map(str::to_string), + }, + ); + } + routes + } + /// Return the configured DeepSeek reasoning-effort tier, if any. #[must_use] pub fn reasoning_effort(&self) -> Option<&str> { diff --git a/crates/tui/src/config/tests.rs b/crates/tui/src/config/tests.rs index 54823b156..5f1b2605c 100644 --- a/crates/tui/src/config/tests.rs +++ b/crates/tui/src/config/tests.rs @@ -1885,6 +1885,36 @@ fn max_subagents_clamps_subagents_max_concurrent() { assert_eq!(high.max_subagents(), MAX_SUBAGENTS); } +#[test] +fn subagent_provider_routes_parse_and_normalize() { + // #3965: [subagents.routes.] assigns a role/type to an explicit + // provider (and optional model), independent of the active provider. + let config: Config = toml::from_str( + r#" + provider = "deepseek" + + [subagents.routes.Explore] + provider = "lm-studio" + model = " qwen-2.5-7b " + + [subagents.routes.general] + provider = "deepseek" + "#, + ) + .expect("routes table parses"); + + let routes = config.subagent_provider_routes(); + let explore = routes.get("explore").expect("key is lowercased"); + assert_eq!(explore.provider, "lm-studio"); + assert_eq!(explore.model.as_deref(), Some("qwen-2.5-7b")); + let general = routes.get("general").expect("general route present"); + assert_eq!(general.provider, "deepseek"); + assert_eq!(general.model, None); + + // No [subagents.routes] → empty map (sub-agents inherit the parent). + assert!(Config::default().subagent_provider_routes().is_empty()); +} + #[test] fn subagents_enabled_reports_disable_precedence() { assert!(Config::default().subagents_enabled()); diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 90cd43492..f1955deca 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -763,6 +763,22 @@ impl Engine { format!("{message}\n\n{hint}") } + /// Bundle `[subagents.routes]` with the current API config for sub-agent + /// runtimes (#3965). Rebuilt at each runtime construction so provider or + /// route changes made mid-session are picked up by the next spawn. + fn subagent_provider_routing( + &self, + ) -> Option> { + let routes = self.api_config.subagent_provider_routes(); + if routes.is_empty() { + return None; + } + Some(Arc::new(crate::tools::subagent::SubagentProviderRouting { + routes, + config: self.api_config.clone(), + })) + } + fn activate_runtime_route(&mut self, provider: ApiProvider, model: &str) -> Result<(), String> { if self.api_provider == provider && self @@ -1458,6 +1474,7 @@ impl Engine { Arc::clone(&self.subagent_manager), ) .with_role_models(self.config.subagent_model_overrides.clone()) + .with_provider_routing(self.subagent_provider_routing()) .with_auto_model(self.session.auto_model) .with_reasoning_effort( self.session.reasoning_effort.clone(), @@ -2510,6 +2527,7 @@ impl Engine { Arc::clone(&self.subagent_manager), ) .with_role_models(self.config.subagent_model_overrides.clone()) + .with_provider_routing(self.subagent_provider_routing()) .with_auto_model(self.session.auto_model) .with_reasoning_effort( self.session.reasoning_effort.clone(), diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index 131a8f4b2..d477a59cb 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -1429,6 +1429,19 @@ pub struct SubAgentForkContext { pub structured_state_block: Option, } +/// Explicit per-role/type provider routing for sub-agents (#3965). Bundles +/// the raw `[subagents.routes]` map with the `Config` snapshot needed to +/// construct a client for a routed provider at spawn time. Shared via `Arc` +/// so per-child runtime clones stay cheap. +pub struct SubagentProviderRouting { + /// Lowercased role/type keys → route, from + /// `Config::subagent_provider_routes`. + pub routes: HashMap, + /// Config snapshot used to resolve provider endpoints/auth for routed + /// clients. + pub config: crate::config::Config, +} + /// Runtime configuration for spawning sub-agents. /// /// Carries everything a child needs to (a) build its own tool registry — @@ -1444,6 +1457,10 @@ pub struct SubAgentRuntime { pub reasoning_effort: Option, pub reasoning_effort_auto: bool, pub role_models: HashMap, + /// Explicit per-role/type provider routes (#3965). `None` when no + /// `[subagents.routes]` are configured; matching roles then inherit the + /// parent's client as before. + pub provider_routing: Option>, pub context: ToolContext, pub allow_shell: bool, /// Native Agent-mode tool surface inherited from the parent turn. Carries @@ -1532,6 +1549,7 @@ impl SubAgentRuntime { reasoning_effort: None, reasoning_effort_auto: false, role_models: HashMap::new(), + provider_routing: None, context, allow_shell, agent_tool_surface_options: AgentToolSurfaceOptions::new( @@ -1657,6 +1675,15 @@ impl SubAgentRuntime { self } + /// Attach explicit per-role/type provider routes (#3965). Route provider + /// names are validated at spawn time so bad config fails that spawn with + /// a clear error instead of poisoning engine construction. + #[must_use] + pub fn with_provider_routing(mut self, routing: Option>) -> Self { + self.provider_routing = routing; + self + } + /// Preserve whether the parent session is using per-turn model routing. #[must_use] pub fn with_auto_model(mut self, auto_model: bool) -> Self { @@ -1709,6 +1736,7 @@ impl SubAgentRuntime { reasoning_effort: self.reasoning_effort.clone(), reasoning_effort_auto: self.reasoning_effort_auto, role_models: self.role_models.clone(), + provider_routing: self.provider_routing.clone(), context: child_context, allow_shell: self.allow_shell, agent_tool_surface_options: self.agent_tool_surface_options.clone(), @@ -3888,17 +3916,33 @@ async fn spawn_subagent_from_input( if let Some(workspace) = child_workspace { child_runtime.context.workspace = workspace; } - let configured_model = match spawn_request.model.clone() { - Some(model) => Some(normalize_requested_subagent_model( - &model, - "model", - runtime.client.api_provider(), - )?), - None => configured_model_for_role_or_type( - &runtime, - spawn_request.assignment.role.as_deref(), - &spawn_request.agent_type, - )?, + let provider_route = configured_provider_route_for_role_or_type( + &runtime, + spawn_request.assignment.role.as_deref(), + &spawn_request.agent_type, + ); + let configured_model = if let Some((route, base_config)) = provider_route { + // #3965: explicit per-role provider routing. The per-call `model` + // still wins over the route's configured model; either selector is + // validated against the routed provider by the route resolver. + let selector = spawn_request.model.as_deref().or(route.model.as_deref()); + let (client, model) = + subagent_client_for_provider_route(base_config, &route.provider, selector)?; + child_runtime.client = client; + Some(model) + } else { + match spawn_request.model.clone() { + Some(model) => Some(normalize_requested_subagent_model( + &model, + "model", + runtime.client.api_provider(), + )?), + None => configured_model_for_role_or_type( + &runtime, + spawn_request.assignment.role.as_deref(), + &spawn_request.agent_type, + )?, + } }; let (effective_prompt, _resident_conflict) = if let Some(ref file_path) = spawn_request.resident_file { @@ -3930,8 +3974,11 @@ async fn spawn_subagent_from_input( (spawn_request.prompt, None) }; + // Resolve against `child_runtime`: identical to `runtime` on the inherit + // path, but carries the routed client when a provider route applied so + // reasoning-effort rules see the provider the child will actually use. let route = resolve_subagent_assignment_route( - &runtime, + &child_runtime, configured_model, &effective_prompt, &spawn_request.agent_type, @@ -5809,6 +5856,69 @@ pub(crate) fn configured_model_for_role_or_type( Ok(None) } +/// Look up an explicit `[subagents.routes]` provider route for a spawn, +/// using the same role → type → `default` key precedence as +/// `configured_model_for_role_or_type` (#3965). +pub(crate) fn configured_provider_route_for_role_or_type<'a>( + runtime: &'a SubAgentRuntime, + role: Option<&str>, + agent_type: &SubAgentType, +) -> Option<( + &'a crate::config::SubagentRouteConfig, + &'a crate::config::Config, +)> { + let routing = runtime.provider_routing.as_deref()?; + let mut keys = Vec::new(); + if let Some(role) = role.map(str::trim).filter(|role| !role.is_empty()) { + keys.push(role.to_ascii_lowercase()); + } + keys.push(agent_type.as_str().to_string()); + keys.push("default".to_string()); + + keys.into_iter() + .find_map(|key| routing.routes.get(&key)) + .map(|route| (route, &routing.config)) +} + +/// Build a client bound to an explicitly routed provider (#3965). The route +/// resolver validates the model against the target provider and binds the +/// endpoint/auth from that provider's config, so a routed sub-agent talks to +/// its own provider regardless of the parent session's active one. +pub(crate) fn subagent_client_for_provider_route( + base_config: &crate::config::Config, + provider_name: &str, + model_selector: Option<&str>, +) -> Result<(DeepSeekClient, String), ToolError> { + let mut route_config = base_config.clone(); + route_config.provider = Some(provider_name.to_string()); + let provider = route_config.api_provider(); + // An unknown provider name silently falls back to DeepSeek inside + // api_provider(); reject it here so a typo'd route fails loudly instead + // of misrouting to the wrong provider. + if crate::config::ApiProvider::parse(provider_name).is_none() + && provider != crate::config::ApiProvider::Custom + { + return Err(ToolError::invalid_input(format!( + "subagents route provider '{provider_name}' is not a built-in provider or a \ + configured [providers.{provider_name}] custom entry" + ))); + } + let route = + crate::route_runtime::resolve_runtime_route(&route_config, provider, model_selector) + .map_err(|reason| { + ToolError::invalid_input(format!( + "subagents route provider '{provider_name}' failed to resolve: {reason}" + )) + })?; + let client = + DeepSeekClient::from_candidate(&route.config, &route.candidate).map_err(|err| { + ToolError::execution_failed(format!( + "subagents route provider '{provider_name}' client failed to initialize: {err}" + )) + })?; + Ok((client, route.model)) +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct SubAgentResolvedRoute { pub(crate) model_route: ModelRoute, diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index 19d05fd37..bda02b2f4 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -4008,6 +4008,7 @@ fn stub_runtime() -> SubAgentRuntime { reasoning_effort: None, reasoning_effort_auto: false, role_models: std::collections::HashMap::new(), + provider_routing: None, context, allow_shell: true, agent_tool_surface_options: AgentToolSurfaceOptions::new(ShellPolicy::Full), @@ -4865,6 +4866,125 @@ fn role_model_validation_accepts_provider_native_ids() { assert_eq!(model.as_deref(), Some("kimi-k2.5")); } +// ---- #3965 per-role explicit provider routing ---- + +fn lm_studio_route_config() -> crate::config::Config { + let _ = rustls::crypto::ring::default_provider().install_default(); + let mut custom = std::collections::HashMap::new(); + custom.insert( + "lm-studio".to_string(), + crate::config::ProviderConfig { + kind: Some("openai-compatible".to_string()), + base_url: Some("http://localhost:1234/v1".to_string()), + api_key: Some("lm-studio".to_string()), + ..Default::default() + }, + ); + crate::config::Config { + provider: Some("deepseek".to_string()), + api_key: Some("test-key".to_string()), + providers: Some(crate::config::ProvidersConfig { + custom, + ..Default::default() + }), + ..Default::default() + } +} + +fn runtime_with_explore_route() -> SubAgentRuntime { + let mut runtime = stub_runtime(); + let mut routes = std::collections::HashMap::new(); + routes.insert( + "explore".to_string(), + crate::config::SubagentRouteConfig { + provider: "lm-studio".to_string(), + model: Some("qwen-2.5-7b".to_string()), + }, + ); + runtime.provider_routing = Some(Arc::new(SubagentProviderRouting { + routes, + config: lm_studio_route_config(), + })); + runtime +} + +#[test] +fn role_provider_route_builds_client_on_routed_provider() { + // AC (#3965): [subagents.routes.explore] provider = "lm-studio" builds the + // child's client against the LM Studio custom endpoint — with the route's + // model — while the parent session stays on DeepSeek. + let runtime = runtime_with_explore_route(); + + let (route, base_config) = configured_provider_route_for_role_or_type( + &runtime, + Some("Explore"), + &SubAgentType::General, + ) + .expect("role has an explicit provider route (case-insensitive key)"); + assert_eq!(route.provider, "lm-studio"); + + let (client, model) = + subagent_client_for_provider_route(base_config, &route.provider, route.model.as_deref()) + .expect("routed client builds against the custom endpoint"); + assert_eq!(client.api_provider(), crate::config::ApiProvider::Custom); + assert_eq!(client.base_url(), "http://localhost:1234/v1"); + assert_eq!(model, "qwen-2.5-7b"); + assert_eq!( + runtime.client.api_provider(), + crate::config::ApiProvider::Deepseek, + "parent client is untouched by the child's route" + ); +} + +#[test] +fn role_without_provider_route_inherits_parent() { + // Fallback AC (#3965): roles without a route keep the legacy behavior — + // no route resolves, so the spawn path inherits the parent's client. + let runtime = runtime_with_explore_route(); + assert!( + configured_provider_route_for_role_or_type( + &runtime, + Some("general"), + &SubAgentType::General, + ) + .is_none() + ); + + let unrouted = stub_runtime(); + assert!( + configured_provider_route_for_role_or_type( + &unrouted, + Some("explore"), + &SubAgentType::Explore, + ) + .is_none() + ); +} + +#[test] +fn provider_route_with_unknown_provider_fails_clearly() { + // Graceful-error AC (#3965): a route naming a provider that is neither + // built-in nor a configured [providers.] entry must fail the spawn + // with a clear error instead of silently falling back to DeepSeek. + let err = match subagent_client_for_provider_route( + &lm_studio_route_config(), + "no-such-provider", + None, + ) { + Ok(_) => panic!("unknown provider must be rejected"), + Err(err) => err, + }; + let msg = err.to_string(); + assert!( + msg.contains("no-such-provider"), + "names the bad provider: {msg}" + ); + assert!( + msg.contains("[providers.no-such-provider]"), + "points at the custom-provider config path: {msg}" + ); +} + #[test] fn role_model_validation_stays_strict_on_official_deepseek() { let mut runtime = stub_runtime(); diff --git a/docs/SUBAGENTS.md b/docs/SUBAGENTS.md index 01c3dd973..358486608 100644 --- a/docs/SUBAGENTS.md +++ b/docs/SUBAGENTS.md @@ -331,6 +331,39 @@ OpenRouter, Novita, SiliconFlow, SGLang, vLLM) route between that pair; providers without a known cheap tier (e.g. Ollama, Moonshot) skip the network router and keep children on the session model. +## Per-role provider routes (#3965) + +`[subagents.routes.]` pins a role (or type — same keys as +`[subagents.models]`, case-insensitive) to an explicit provider, and +optionally a model on that provider. A matching spawn builds its own client +against that provider's endpoint/auth, regardless of the parent session's +active provider. Roles without a route inherit the parent as before. + +`provider` accepts any built-in provider id (`deepseek`, `ollama`, …) or the +name of a `[providers.]` custom entry — which is how LM Studio's +OpenAI-compatible endpoint plugs in: + +```toml +[providers.lm-studio] +kind = "openai-compatible" +base_url = "http://localhost:1234/v1" +api_key = "lm-studio" # LM Studio ignores it, but a value must exist + +[subagents.routes.explore] +provider = "lm-studio" # local for cheap, fast operations +model = "qwen-2.5-7b" + +[subagents.routes.general] +provider = "deepseek" # cloud for complex reasoning +model = "deepseek-v4-flash" +``` + +A per-call explicit `model` on `agent` still wins over the route's `model`; +both are validated against the routed provider. A route naming a provider +that is neither built-in nor configured fails that spawn with a clear error +(no silent DeepSeek fallback). When a route applies, it takes precedence over +the `[subagents.models]` map for that role. + ## Per-step API timeout (#1806, #1808) Each sub-agent step wraps its DeepSeek `create_message` call in a