Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions crates/tui/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1758,6 +1758,12 @@ pub struct SubagentsConfig {
/// provider names such as `deepseek`, `zai`, `openrouter`, or `anthropic`.
#[serde(default)]
pub providers: Option<HashMap<String, SubagentProviderConfig>>,
/// 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<HashMap<String, SubagentRouteConfig>>,
}

/// Provider-specific sub-agent limit overrides.
Expand All @@ -1784,6 +1790,19 @@ pub struct SubagentProviderConfig {
pub heartbeat_timeout_secs: Option<u64>,
}

/// One `[subagents.routes.<role>]` 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.<name>]` 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<String>,
}

/// `[auto]` table — knobs for the `--model auto` / `/model auto` router.
///
/// `cost_saving` (#1207): when `true`, the auto-mode router prefers
Expand Down Expand Up @@ -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<String, SubagentRouteConfig> {
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> {
Expand Down
30 changes: 30 additions & 0 deletions crates/tui/src/config/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.<role>] 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());
Expand Down
18 changes: 18 additions & 0 deletions crates/tui/src/core/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Arc<crate::tools::subagent::SubagentProviderRouting>> {
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
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down
134 changes: 122 additions & 12 deletions crates/tui/src/tools/subagent/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1429,6 +1429,19 @@ pub struct SubAgentForkContext {
pub structured_state_block: Option<String>,
}

/// 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<String, crate::config::SubagentRouteConfig>,
/// 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 —
Expand All @@ -1444,6 +1457,10 @@ pub struct SubAgentRuntime {
pub reasoning_effort: Option<String>,
pub reasoning_effort_auto: bool,
pub role_models: HashMap<String, String>,
/// 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<Arc<SubagentProviderRouting>>,
pub context: ToolContext,
pub allow_shell: bool,
/// Native Agent-mode tool surface inherited from the parent turn. Carries
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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<Arc<SubagentProviderRouting>>) -> 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 {
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading