From 82e6b4c9de3a0d97716d77acb777cbe6d049f605 Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Wed, 23 Sep 2026 08:47:47 -0700 Subject: [PATCH 1/2] feat(fleet): approved replacement routes for refused first requests A saved reviewer pin (xai/grok-4.6) answered its first request with "Authorization failed: You have run out of credits or need a Grok subscription." and the agent failed with no review. The only fallback was the pre-flight SessionFallback for unusable pins; nothing covered a provider refusing the request itself, and saved routes must not be silently swapped. `[subagents.roles.] replacements = ["provider/model", ...]` lets the operator approve up to three replacement routes. When the pin's first request fails with a typed route refusal (QuotaExhausted, AuthenticationError, AuthorizationError, ModelError) and the agent has run nothing yet (run_subagent's steps == 1 seam), the same request moves to the next approved route. The run keeps its role, grants, tool scope, workspace and budgets: only the request route changes, bound through the existing provider/model/protocol binders, and each route is tried once. The existing route receipt records the effective provider/model, `route_source = "role.replacement"`, and a fallback note with the original route, reason, provider message (redacted) and attempt. Not replaced: failures after any tool ran, content-policy / context-length / invalid-request errors, untyped messages that merely mention credits, Codewhale permission denials, exact Fleet members and task-level models. Entries must name their provider explicitly, so the destination that may receive the task is always operator-chosen; misconfiguration fails at spawn. Replacement authority is cleared for every other route source and never inherited by descendants. The observed Grok error is authorization-class (403), not QuotaExhausted. Evidence: 114 passed, 0 failed (13,142 skipped) across route_replacement, roster_routes, launch_receipt, fatal-provider, typed-retry and role-pin selections; then 5/5 route_replacement tests after adding the spawn refusal case. The end-to-end test runs a local 403 fixture with the observed text and an approved backup fixture: the pin is asked once, the backup completes the review, and the receipt names both routes. A pin without replacements still fails and never contacts the backup. TUI all-target/all-feature Clippy with CI flags and fmt passed; dead code budget unchanged at 279. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/tui/src/config.rs | 58 +++- crates/tui/src/tools/subagent/mod.rs | 239 +++++++++++++++- crates/tui/src/tools/subagent/tests.rs | 2 + .../tools/subagent/tests/route_replacement.rs | 269 ++++++++++++++++++ docs/SUBAGENTS.md | 20 ++ 5 files changed, 572 insertions(+), 16 deletions(-) create mode 100644 crates/tui/src/tools/subagent/tests/route_replacement.rs diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index ee3beb36b9..9dfa60e143 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -2492,6 +2492,24 @@ pub struct SubagentsConfig { #[serde(deny_unknown_fields)] pub struct SubagentRoleConfig { pub model: String, + /// Operator-approved `provider/model` routes, tried in order only when + /// this pin's first request is refused before the agent has done any + /// work (exhausted quota, rejected credentials or authorization, or an + /// unavailable model). Listing a route authorizes sending the agent's + /// task to that provider. Empty keeps the pin exact. + #[serde(default)] + pub replacements: Vec, +} + +fn parse_subagent_role_pin(value: &str) -> SubagentModelOverride { + let value = value.trim(); + match value.split_once('/') { + Some((provider, model)) => SubagentModelOverride { + provider: Some(provider.trim().to_string()), + model: model.trim().to_string(), + }, + None => value.into(), + } } /// One role override carried through Config, Engine, and child admission. @@ -8162,21 +8180,43 @@ impl Config { for (key, pin) in entries { // Keep blank explicit pins so admission rejects them rather // than silently inheriting a different route. - let value = pin.model.trim(); - let pin = match value.split_once('/') { - Some((provider, model)) => SubagentModelOverride { - provider: Some(provider.trim().to_string()), - model: model.trim().to_string(), - }, - None => value.into(), - }; - overrides.insert(canonical(key), pin); + overrides.insert(canonical(key), parse_subagent_role_pin(&pin.model)); } } overrides } + /// The operator-approved replacement routes declared beside the role pin + /// that [`Self::subagent_model_overrides`] resolved under `key`. A + /// canonical role key wins over a legacy alias, matching pin precedence. + pub fn subagent_route_replacements(&self, key: &str) -> Vec { + let Some(roles) = self.subagents.as_ref().and_then(|cfg| cfg.roles.as_ref()) else { + return Vec::new(); + }; + let canonical = |raw: &str| { + let raw = raw.trim().to_ascii_lowercase(); + if raw == "default" { + raw + } else { + crate::fleet::role::migrate_legacy_role_token(&raw) + .unwrap_or(&raw) + .to_string() + } + }; + roles + .iter() + .filter(|(raw, _)| canonical(raw) == key) + .max_by_key(|(raw, _)| (canonical(raw) == raw.trim().to_ascii_lowercase(), *raw)) + .map(|(_, pin)| { + pin.replacements + .iter() + .map(|route| parse_subagent_role_pin(route)) + .collect() + }) + .unwrap_or_default() + } + /// Parsed `[fleet]` table, or defaults when the table is absent /// (#fleet-roster cutover (v0.8.67)). #[must_use] diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index 851a9e0c2a..d410fbb7c7 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -2629,6 +2629,12 @@ pub struct SubAgentRuntime { pub reasoning_effort: Option, pub reasoning_effort_auto: bool, pub role_models: HashMap, + /// Operator-approved `provider/model` routes this child may move to when + /// its role pin's first request is refused before any work. Set only by + /// a role pin that declares them (`[subagents.roles.] + /// replacements`); every other route source, including exact Fleet + /// bindings and task-level models, keeps it empty and stays exact. + pub route_replacements: Vec, pub context: ToolContext, pub allow_shell: bool, /// When true, Suggest-level file writes auto-accept for write-capable roles @@ -2788,6 +2794,7 @@ impl SubAgentRuntime { reasoning_effort: None, reasoning_effort_auto: false, role_models: HashMap::new(), + route_replacements: Vec::new(), context, allow_shell, accept_edits: false, @@ -3101,6 +3108,8 @@ impl SubAgentRuntime { reasoning_effort: self.reasoning_effort.clone(), reasoning_effort_auto: self.reasoning_effort_auto, role_models: self.role_models.clone(), + // A descendant earns replacement authority only from its own pin. + route_replacements: Vec::new(), context: child_context, allow_shell: self.allow_shell, accept_edits: self.accept_edits, @@ -5499,6 +5508,29 @@ impl SubAgentManager { self.persist_state_debounced(); } + /// Persist a pre-work route replacement in the worker's existing route + /// receipt: the effective provider/model, `role.replacement` as the + /// source, and a note naming the original route, reason and attempts. + fn record_route_replacement( + &mut self, + worker_id: &str, + provider_id: String, + model_id: String, + note: String, + ) { + if let Some(record) = self.worker_records.get_mut(worker_id) { + record.spec.model.clone_from(&model_id); + if let Some(route) = record.spec.child_route.as_mut() { + route.provider_id = provider_id; + route.model_id = model_id; + route.route_source = SpawnRouteSource::RoleReplacement.as_str().to_string(); + route.fallback_note = Some(note); + } + record.updated_at_ms = epoch_millis_now(); + self.persist_state_debounced(); + } + } + fn mark_worker_unreported_usage(&mut self, worker_id: &str) { if let Some(record) = self.worker_records.get_mut(worker_id) { record.has_unreported_usage = true; @@ -13276,7 +13308,18 @@ async fn run_subagent( ) .await; - loop { + // Route replacement (operator-approved, pre-work only). `runtime` keeps + // owning role, grants, tools, scope and budgets; only requests read the + // replacement route. It is staged, then installed at the loop head. + let mut route_override: Option = None; + let mut staged_route_override: Option = None; + let mut replacements_tried = 0usize; + let mut skipped_replacements: Vec = Vec::new(); + + 'subagent: loop { + if let Some(next) = staged_route_override.take() { + route_override = Some(next); + } match subagent_loop_boundary(work_max_steps, steps, runtime.cancel_token.is_cancelled()) { // Cancellation must win even after the final allowed tool step. // Otherwise a turn-end park at that seam is mislabeled as step @@ -13397,9 +13440,10 @@ async fn run_subagent( // its own `work_update` calls returned, which are already in // `messages`. Nothing synthetic is appended per step. let mut request_messages = messages.clone(); - let request_route = runtime + let route_runtime = route_override.as_ref().unwrap_or(runtime); + let request_route = route_runtime .client - .effective_route_envelope(&runtime.model, chrono::Utc::now()); + .effective_route_envelope(&route_runtime.model, chrono::Utc::now()); let image_input = runtime .api_config .as_deref() @@ -13420,9 +13464,9 @@ async fn run_subagent( &request_route.model, ); let request = MessageRequest { - model: runtime.model.clone(), + model: route_runtime.model.clone(), messages: request_messages, - max_tokens: runtime + max_tokens: route_runtime .client .effective_max_output_tokens(&request_route.model), system: Some(request_system.clone()), @@ -13434,7 +13478,7 @@ async fn run_subagent( }, metadata: None, thinking: None, - reasoning_effort: runtime.reasoning_effort.clone(), + reasoning_effort: route_runtime.reasoning_effort.clone(), stream: Some(false), temperature: None, top_p: None, @@ -13508,7 +13552,7 @@ async fn run_subagent( break; } api = request_subagent_model_response_with_retries( - runtime, + route_runtime, &agent_id, steps, max_steps, @@ -13535,6 +13579,83 @@ async fn run_subagent( // request died with zero completed work — // fail plainly, exactly as before. if steps <= 1 { + // Nothing of this run has executed yet, + // so an operator-approved route may take + // the same request; never after work. + if let Some(why) = route_replacement_reason(&err) { + while let Some(route) = + runtime.route_replacements.get(replacements_tried) + { + replacements_tried += 1; + let to = format!( + "{}/{}", + route.provider.as_deref().unwrap_or_default(), + route.model + ); + match replacement_route_runtime(runtime, route) { + Ok(next) => { + let from = format!( + "{}/{}", + route_runtime.client.api_provider().as_str(), + route_runtime.model + ); + let detail: String = route_runtime + .client + .redact_model_bound_text(&format!("{err}")) + .chars() + .take(160) + .collect(); + let mut note = format!( + "{from} refused the first request before any work ({why}: {detail}); moved to approved replacement {to} (attempt {replacements_tried} of {})", + runtime.route_replacements.len() + ); + if !skipped_replacements.is_empty() { + note.push_str("; skipped "); + note.push_str(&skipped_replacements.join("; ")); + } + let note: String = note.chars().take(480).collect(); + let provider_id = next + .api_config + .as_ref() + .map(|config| { + config.provider_identity_for( + next.client.api_provider(), + ) + }) + .unwrap_or_else(|| { + next.client.api_provider().as_str().to_string() + }); + runtime.manager.write().await.record_route_replacement( + &agent_id, + provider_id, + next.model.clone(), + note.clone(), + ); + record_agent_progress( + runtime, + &agent_id, + AgentProgressEventMeta::new( + AgentWorkerStatus::Running, + ) + .with_step(0), + format!("Route replaced: {note}"), + ); + staged_route_override = Some(next); + steps = 0; + continue 'subagent; + } + Err(unavailable) => skipped_replacements.push( + format!("{to} ({})", unavailable.chars().take(120).collect::()), + ), + } + } + if !skipped_replacements.is_empty() { + return Err(err.context(format!( + "no approved replacement route could take the task: {}", + skipped_replacements.join("; ") + ))); + } + } return Err(err); } ( @@ -15381,6 +15502,9 @@ enum SpawnRouteSource { /// back to the session route loudly (receipt note names the pin and /// the reason) instead of failing (#5529 mode 2). SessionFallback, + /// The role pin's first request was refused before any work and the + /// child moved to an operator-approved replacement route. + RoleReplacement, } impl SpawnRouteSource { @@ -15393,6 +15517,7 @@ impl SpawnRouteSource { Self::RoleDefault => "role.default", Self::RunModel => "run.model", Self::SessionFallback => "session.fallback", + Self::RoleReplacement => "role.replacement", } } } @@ -15579,6 +15704,9 @@ async fn bind_spawn_model_route( // task-level pins stay exact — only saved-profile rot falls back. let mut member = member; let mut fallback_note = None; + // Replacement authority belongs to one role pin, never to a descendant + // that inherited this runtime or chose its route another way. + runtime.route_replacements.clear(); match bind_profile_provider(runtime, member)? { MemberProviderBind::Bound => {} MemberProviderBind::Unavailable { @@ -15630,6 +15758,7 @@ async fn bind_spawn_model_route( )?), source: SpawnRouteSource::RolePin, }; + runtime.route_replacements = configured_route_replacements(runtime, request)?; manual_pin = Some(pin); selection } else if let Some(model) = bind_shortlisted_task_model(runtime, request)? { @@ -16258,6 +16387,102 @@ fn configured_manual_spawn_model( Ok(Some(pin.clone())) } +/// Upper bound on declared replacement routes: each is tried at most once, so +/// this also bounds the extra first requests one refused pin can cost. +const MAX_ROUTE_REPLACEMENTS: usize = 3; + +/// The replacement routes declared beside the role pin this spawn resolved. +/// Misconfiguration fails loud at spawn, not at the failure it would cover. +fn configured_route_replacements( + runtime: &SubAgentRuntime, + request: &SpawnRequest, +) -> Result, ToolError> { + let Some(config) = runtime.api_config.as_deref() else { + return Ok(Vec::new()); + }; + let overrides = config.subagent_model_overrides(); + let Some((key, _)) = configured_role_model_override( + &overrides, + request.assignment.role.as_deref(), + &request.agent_type, + ) else { + return Ok(Vec::new()); + }; + let replacements = config.subagent_route_replacements(&key); + if replacements.len() > MAX_ROUTE_REPLACEMENTS { + return Err(ToolError::invalid_input(format!( + "subagents.roles.{key}.replacements lists {} routes; at most {MAX_ROUTE_REPLACEMENTS} are allowed", + replacements.len() + ))); + } + for route in &replacements { + let Some(provider) = route.provider.as_deref().filter(|p| !p.trim().is_empty()) else { + return Err(ToolError::invalid_input(format!( + "subagents.roles.{key}.replacements entries must name `provider/model`, so the provider that may receive the task is explicit; got {:?}", + route.model + ))); + }; + if route.model.trim().is_empty() + || route.model.trim().eq_ignore_ascii_case("auto") + || route.model.chars().any(char::is_control) + { + return Err(ToolError::invalid_input(format!( + "subagents.roles.{key}.replacements entry for {provider:?} must name one exact model" + ))); + } + config + .resolve_provider_pin_identity(provider) + .map_err(ToolError::invalid_input)?; + } + Ok(replacements) +} + +/// Why a first-request refusal may move a child to an approved replacement +/// route, or `None` when it must not. Typed only: an arbitrary message that +/// mentions credits is not quota evidence. Content-policy, context-length and +/// invalid-request refusals would recur on any route (or would shop content +/// to another provider), and Codewhale's own permission denials never reach +/// this seam as provider errors. +fn route_replacement_reason(error: &anyhow::Error) -> Option<&'static str> { + match error.downcast_ref::()? { + LlmError::QuotaExhausted(_) => Some("quota exhausted"), + LlmError::AuthenticationError(_) => Some("credentials rejected"), + LlmError::AuthorizationError(_) => Some("provider refused authorization"), + LlmError::ModelError(_) => Some("model unavailable"), + _ => None, + } +} + +/// Bind `base` to one approved replacement route. Only the request route +/// changes: role, grants, tool scope, budgets and workspace stay `base`'s. +fn replacement_route_runtime( + base: &SubAgentRuntime, + route: &SubagentModelOverride, +) -> Result { + let mut next = base.clone(); + let provider = route.provider.as_deref().unwrap_or_default(); + match try_bind_spawn_provider(&mut next, provider).map_err(|error| error.to_string())? { + MemberProviderBind::Bound => {} + MemberProviderBind::Unavailable { + provider_id, + reason, + } => return Err(format!("{provider_id} unavailable: {reason}")), + } + let model = normalize_bound_subagent_model(&route.model, "replacement", &next.client) + .map_err(|error| error.to_string())?; + let model = ensure_subagent_model_for_provider(&next, &ModelRoute::Fixed(model.clone()), model) + .map_err(|error| error.to_string())?; + if let Some(rebound) = next + .client + .rebound_for_model_protocol(next.api_config.as_deref(), &model) + .map_err(|error| format!("{error:#}"))? + { + next.client = rebound; + } + next.model = model; + Ok(next) +} + /// One alias/default precedence for manual Config pins and legacy role defaults. pub(crate) fn configured_role_model_override<'a>( overrides: &'a HashMap, diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index 553fec7d63..9293daa6bd 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -11,6 +11,7 @@ use tempfile::{Builder as TempDirBuilder, tempdir}; mod launch_receipt; mod roster_routes; +mod route_replacement; fn built_in_whale_name_that_cannot_be_generated_for(agent_id: &str) -> &'static str { WHALE_NICKNAMES @@ -14646,6 +14647,7 @@ pub(crate) fn stub_runtime() -> SubAgentRuntime { reasoning_effort: None, reasoning_effort_auto: false, role_models: std::collections::HashMap::new(), + route_replacements: Vec::new(), context, allow_shell: true, accept_edits: false, diff --git a/crates/tui/src/tools/subagent/tests/route_replacement.rs b/crates/tui/src/tools/subagent/tests/route_replacement.rs new file mode 100644 index 0000000000..28c2ed1a85 --- /dev/null +++ b/crates/tui/src/tools/subagent/tests/route_replacement.rs @@ -0,0 +1,269 @@ +//! Operator-approved route replacement at the first-request seam. +//! +//! The observed failure: a saved reviewer pin answered its first request with +//! `Authorization failed: You have run out of credits or need a Grok +//! subscription.` and no review was produced. +use super::*; + +const REFUSAL: &str = "You have run out of credits or need a Grok subscription."; + +/// A provider fixture that refuses every request with HTTP 403. +async fn refusing_chat_server() -> (String, Arc) { + let calls = Arc::new(AtomicUsize::new(0)); + let app = Router::new().route( + "/{*path}", + post({ + let calls = Arc::clone(&calls); + move |Json(_body): Json| { + let calls = Arc::clone(&calls); + async move { + calls.fetch_add(1, Ordering::SeqCst); + ( + StatusCode::FORBIDDEN, + Json(json!({"error": {"message": REFUSAL}})), + ) + .into_response() + } + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind refusing server"); + let addr = listener.local_addr().expect("refusing server addr"); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + (format!("http://{addr}/v1"), calls) +} + +fn write_replacement_config(path: &std::path::Path, pin_url: &str, backup_url: &str, pin: &str) { + std::fs::write( + path, + format!( + r#" +provider = "deepseek" +model = "deepseek-v4-flash" +api_key = "fixture-key" +base_url = "{backup_url}" + +[retry] +enabled = false +max_retries = 0 + +[providers.PinRoute] +kind = "openai-compatible" +api_key = "fixture-pin-key" +base_url = "{pin_url}" +model = "fixture-pin-model" + +[providers.BackupRoute] +kind = "openai-compatible" +api_key = "fixture-backup-key" +base_url = "{backup_url}" +model = "fixture-backup-model" + +{pin} +"# + ), + ) + .unwrap(); +} + +async fn reviewer_tool( + root: &std::path::Path, + pin: &str, +) -> ( + AgentTool, + ToolContext, + SharedSubAgentManager, + Arc, + Arc, +) { + let (backup, backup_calls, _) = delayed_chat_client(Duration::ZERO, "review done").await; + let (pin_url, pin_calls) = refusing_chat_server().await; + let config_path = root.join("config.toml"); + write_replacement_config(&config_path, &pin_url, backup.base_url(), pin); + let config = crate::config::Config::load(Some(config_path), None).unwrap(); + let client = CodewhaleClient::new(&config).unwrap(); + let manager = new_shared_subagent_manager(root.to_path_buf(), 2); + let context = ToolContext::new(root).with_state_namespace("route-replacement"); + let runtime = SubAgentRuntime::new( + client, + "deepseek-v4-flash".into(), + context.clone(), + false, + None, + manager.clone(), + ) + .with_api_config(config); + ( + AgentTool::new(manager.clone(), runtime), + context, + manager, + pin_calls, + backup_calls, + ) +} + +async fn run_reviewer(pin: &str) -> (Value, SubAgentResult, usize, usize) { + let root = tempdir().unwrap(); + let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("state")); + let _provider = crate::test_support::EnvVarGuard::set("CODEWHALE_PROVIDER", "deepseek"); + let _model = crate::test_support::EnvVarGuard::set("CODEWHALE_MODEL", "deepseek-v4-flash"); + let (tool, context, manager, pin_calls, backup_calls) = reviewer_tool(root.path(), pin).await; + let started = tool + .execute( + json!({"type": "reviewer", "prompt": "Review the change."}), + &context, + ) + .await + .unwrap(); + let meta = started.metadata.clone().unwrap(); + let id = meta["agent_id"].as_str().unwrap().to_string(); + let result = tokio::time::timeout(Duration::from_secs(10), async { + loop { + let result = manager.read().await.get_result(&id).expect("registered"); + if result.status != SubAgentStatus::Running { + return result; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("child settles"); + ( + meta, + result, + pin_calls.load(Ordering::SeqCst), + backup_calls.load(Ordering::SeqCst), + ) +} + +#[tokio::test] +async fn approved_replacement_takes_a_refused_first_request_and_is_receipted() { + let _env = crate::test_support::lock_test_env(); + let (meta, result, pin_calls, backup_calls) = run_reviewer( + r#" +[subagents.roles.reviewer] +model = "PinRoute/fixture-pin-model" +replacements = ["BackupRoute/fixture-backup-model"] +"#, + ) + .await; + assert_eq!(meta["child_route"]["provider_id"], "PinRoute"); + assert_eq!(pin_calls, 1, "the refused route is asked exactly once"); + assert!(backup_calls >= 1, "the approved route took the request"); + assert_eq!( + result.status, + SubAgentStatus::Completed, + "{:?}", + result.status + ); + assert_eq!(result.result.as_deref(), Some("review done")); + let route = result.child_route.expect("route receipt"); + assert_eq!(route.provider_id, "BackupRoute"); + assert_eq!(route.model_id, "fixture-backup-model"); + assert_eq!(route.route_source, "role.replacement"); + let note = route.fallback_note.expect("replacement note"); + for fact in [ + "fixture-pin-model", + "provider refused authorization", + "run out of credits", + "BackupRoute/fixture-backup-model", + "attempt 1 of 1", + ] { + assert!(note.contains(fact), "{fact} missing from {note}"); + } + assert!(!note.contains("fixture-backup-key") && !note.contains("fixture-pin-key")); +} + +#[tokio::test] +async fn a_pin_without_approved_replacements_stays_exact() { + let _env = crate::test_support::lock_test_env(); + let (_meta, result, pin_calls, backup_calls) = run_reviewer( + r#" +[subagents.roles.reviewer] +model = "PinRoute/fixture-pin-model" +"#, + ) + .await; + assert_eq!(pin_calls, 1); + assert_eq!(backup_calls, 0, "no provider was asked without approval"); + let SubAgentStatus::Failed(error) = &result.status else { + panic!("an exact refused pin fails: {:?}", result.status); + }; + assert!(error.contains("run out of credits"), "{error}"); + assert_eq!(result.child_route.unwrap().provider_id, "PinRoute"); +} + +#[test] +fn replacement_reasons_are_typed_never_message_matched() { + let refusal = |status| anyhow::Error::new(LlmError::from_http_response(status, REFUSAL)); + assert_eq!( + route_replacement_reason(&refusal(403)), + Some("provider refused authorization") + ); + assert_eq!( + route_replacement_reason(&refusal(401)), + Some("credentials rejected") + ); + // A credits-themed message without typed evidence is not a route refusal. + assert_eq!(route_replacement_reason(&anyhow!(REFUSAL)), None); + assert_eq!( + route_replacement_reason(&anyhow::Error::new(LlmError::ContentPolicyError( + "blocked".into() + ))), + None, + "content refusals are never shopped to another provider" + ); + assert_eq!( + route_replacement_reason(&anyhow::Error::new(LlmError::ContextLengthError( + "too long".into() + ))), + None + ); +} + +#[test] +fn replacements_must_name_their_provider() { + let config: crate::config::Config = toml::from_str( + r#" +[subagents.roles.reviewer] +model = "deepseek-v4-pro" +replacements = ["deepseek/deepseek-v4-flash", "bare-model"] +"#, + ) + .unwrap(); + let routes = config.subagent_route_replacements("reviewer"); + assert_eq!(routes.len(), 2); + assert_eq!(routes[0].provider.as_deref(), Some("deepseek")); + assert_eq!(routes[1].provider, None); + assert!(config.subagent_route_replacements("builder").is_empty()); +} + +#[tokio::test] +async fn a_replacement_without_an_explicit_provider_fails_at_spawn() { + let _env = crate::test_support::lock_test_env(); + let root = tempdir().unwrap(); + let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("state")); + let (tool, context, manager, pin_calls, backup_calls) = reviewer_tool( + root.path(), + r#" +[subagents.roles.reviewer] +model = "PinRoute/fixture-pin-model" +replacements = ["fixture-backup-model"] +"#, + ) + .await; + let error = tool + .execute(json!({"type": "reviewer", "prompt": "Review."}), &context) + .await + .unwrap_err(); + assert!(error.to_string().contains("provider/model"), "{error}"); + assert!(manager.read().await.agents.is_empty()); + assert_eq!( + pin_calls.load(Ordering::SeqCst) + backup_calls.load(Ordering::SeqCst), + 0 + ); +} diff --git a/docs/SUBAGENTS.md b/docs/SUBAGENTS.md index 253e3219c7..89fb265003 100644 --- a/docs/SUBAGENTS.md +++ b/docs/SUBAGENTS.md @@ -669,6 +669,26 @@ manual role pin. A type-only start also selects a unique saved role pin when there is no manual override; ambiguous saved roles fail instead of choosing one. Durable Fleet runs retain their selected member's frozen route. +A structured role pin may list approved replacement routes: + +```toml +[subagents.roles.reviewer] +model = "xai/grok-4.6" +replacements = ["deepseek/deepseek-v4-pro"] +``` + +When the pinned route refuses the agent's **first** request (exhausted quota, +rejected credentials or authorization, or an unavailable model), the agent +retries that same request on the next listed route, keeping its role, +permissions, tools, scope and budgets. Listing a route authorizes sending the +agent's task to that provider, so each entry must name `provider/model`; at +most three are allowed and each is tried once. The route receipt records the +effective route, `route_source = "role.replacement"`, and a note with the +original route, the reason and the attempt. Replacement never happens after +the agent has run a tool, never for content-policy, context-length or +invalid-request errors, never for Codewhale's own permission denials, and never +for exact Fleet members or task-level `model` choices, which stay exact. + Structured role pins accept `provider/model`, preserving the configured provider's exact identity and the complete model suffix. Unknown providers, empty pairs, and cross-provider `auto` choices fail before admission. A bare structured model From bb61d6e193f072a03465ec142f7e0953a0e735c1 Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Wed, 23 Sep 2026 09:10:00 -0700 Subject: [PATCH 2/2] test(fleet): write the replacement fixture config with tokio::fs The blocking-call ratchet counted the fixture's std::fs::write inside an async test path (Lint on #6438). Evidence: route_replacement 5 passed, 0 failed; blocking-call budget 577 sites, within budget. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/tools/subagent/tests/route_replacement.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/tui/src/tools/subagent/tests/route_replacement.rs b/crates/tui/src/tools/subagent/tests/route_replacement.rs index 28c2ed1a85..3936bed678 100644 --- a/crates/tui/src/tools/subagent/tests/route_replacement.rs +++ b/crates/tui/src/tools/subagent/tests/route_replacement.rs @@ -37,8 +37,13 @@ async fn refusing_chat_server() -> (String, Arc) { (format!("http://{addr}/v1"), calls) } -fn write_replacement_config(path: &std::path::Path, pin_url: &str, backup_url: &str, pin: &str) { - std::fs::write( +async fn write_replacement_config( + path: &std::path::Path, + pin_url: &str, + backup_url: &str, + pin: &str, +) { + tokio::fs::write( path, format!( r#" @@ -67,6 +72,7 @@ model = "fixture-backup-model" "# ), ) + .await .unwrap(); } @@ -83,7 +89,7 @@ async fn reviewer_tool( let (backup, backup_calls, _) = delayed_chat_client(Duration::ZERO, "review done").await; let (pin_url, pin_calls) = refusing_chat_server().await; let config_path = root.join("config.toml"); - write_replacement_config(&config_path, &pin_url, backup.base_url(), pin); + write_replacement_config(&config_path, &pin_url, backup.base_url(), pin).await; let config = crate::config::Config::load(Some(config_path), None).unwrap(); let client = CodewhaleClient::new(&config).unwrap(); let manager = new_shared_subagent_manager(root.to_path_buf(), 2);