diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/modes/claw.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/claw.rs index 9a16ec1b8..ae39bc036 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/modes/claw.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/claw.rs @@ -17,6 +17,7 @@ impl ClawMode { Self { default_tools: vec![ "Task".to_string(), + "ListModels".to_string(), "AgentWait".to_string(), "Read".to_string(), "view_image".to_string(), @@ -95,6 +96,7 @@ mod tests { fn claw_mode_includes_init_miniapp_in_default_tools() { let tools = ClawMode::new().default_tools(); assert!(tools.contains(&"InitMiniApp".to_string())); + assert!(tools.contains(&"ListModels".to_string())); } #[test] diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/modes/cowork.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/cowork.rs index efdfea406..059e60b05 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/modes/cowork.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/cowork.rs @@ -31,6 +31,7 @@ impl CoworkMode { "AskUserQuestion".to_string(), "TodoWrite".to_string(), "Task".to_string(), + "ListModels".to_string(), "AgentWait".to_string(), "Skill".to_string(), // Discovery + editing @@ -109,5 +110,6 @@ mod tests { fn cowork_mode_includes_init_miniapp_in_default_tools() { let tools = CoworkMode::new().default_tools(); assert!(tools.contains(&"InitMiniApp".to_string())); + assert!(tools.contains(&"ListModels".to_string())); } } diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/modes/deep_research.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/deep_research.rs index 688f4557f..4b1374e23 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/modes/deep_research.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/deep_research.rs @@ -21,6 +21,7 @@ impl DeepResearchMode { Self { default_tools: vec![ "Task".to_string(), + "ListModels".to_string(), "AgentWait".to_string(), "WebSearch".to_string(), "WebFetch".to_string(), @@ -97,6 +98,7 @@ mod tests { tools.contains(&"Task".to_string()), "Task tool required for parallel sub-agent orchestration" ); + assert!(tools.contains(&"ListModels".to_string())); assert!(tools.contains(&"WebSearch".to_string())); assert!(tools.contains(&"WebFetch".to_string())); assert!(tools.contains(&"Write".to_string())); diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/modes/team.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/team.rs index 4cff88c59..3e3d4519c 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/modes/team.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/team.rs @@ -22,6 +22,7 @@ impl TeamMode { default_tools: vec![ "Skill".to_string(), "Task".to_string(), + "ListModels".to_string(), "AgentWait".to_string(), "Read".to_string(), "view_image".to_string(), @@ -95,5 +96,6 @@ mod tests { assert_eq!(agent.prompt_template_name(None), "team_mode"); assert!(!agent.is_readonly()); assert!(agent.default_tools().contains(&"Skill".to_string())); + assert!(agent.default_tools().contains(&"ListModels".to_string())); } } diff --git a/src/crates/assembly/core/src/agentic/agents/mod.rs b/src/crates/assembly/core/src/agentic/agents/mod.rs index 9ce36d8be..29cbe2ed1 100644 --- a/src/crates/assembly/core/src/agentic/agents/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/mod.rs @@ -107,6 +107,7 @@ fn append_provider_group_tools(tools: &mut Vec, provider_id: &'static st pub fn shared_coding_mode_tools() -> Vec { let mut tools = vec![ "Task".to_string(), + "ListModels".to_string(), "AgentWait".to_string(), "Read".to_string(), "view_image".to_string(), @@ -291,6 +292,7 @@ mod tests { fn shared_coding_mode_tools_include_plan_and_debug_specific_tools() { let tools = shared_coding_mode_tools(); + assert!(tools.contains(&"ListModels".to_string())); assert!(tools.contains(&"CreatePlan".to_string())); assert!(tools.contains(&"get_goal".to_string())); assert!(tools.contains(&"update_goal".to_string())); diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index 5cd83c2db..059f784ea 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -254,6 +254,9 @@ pub(crate) struct SubagentExecutionRequest { pub(crate) model_binding_policy: SessionModelBindingPolicy, pub(crate) workspace_path: Option, pub(crate) model_id: Option, + /// Explicitly select the current parent session's model instead of a + /// configured subagent default. + pub(crate) inherit_parent_model: bool, pub(crate) subagent_parent_info: SubagentParentInfo, pub(crate) context: HashMap, /// Execution policy for the child subagent session being launched. @@ -6416,11 +6419,18 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet async fn resolve_fresh_subagent_model_id( &self, explicit_model_id: Option<&str>, + inherit_parent_model: bool, agent_type: &str, workspace_path: &str, parent_session_id: &str, ) -> BitFunResult { let defaults = Self::agent_model_defaults().await; + if inherit_parent_model { + return normalize_model_selection( + &self.parent_model_selection(parent_session_id, &defaults)?, + ) + .await; + } let registry = get_agent_registry(); let configured_selection = registry .get_explicit_subagent_model_selection(agent_type, Some(Path::new(workspace_path))) @@ -6459,6 +6469,13 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .map(str::trim) .filter(|model_id| !model_id.is_empty()) .map(str::to_string); + let inherit_parent_model = request.inherit_parent_model; + if inherit_parent_model && model_id.is_some() { + return Err(BitFunError::Validation( + "A subagent model request cannot specify both a model ID and parent inheritance" + .to_string(), + )); + } let created_by = Some(format!( "session-{}", request.subagent_parent_info.session_id @@ -6492,8 +6509,20 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet background_allow_review_follow_up, ) .await?; - if let Some(model_id) = model_id.as_deref() { - let model_id = normalize_model_selection(model_id).await?; + let requested_model_id = if inherit_parent_model { + let defaults = Self::agent_model_defaults().await; + Some( + normalize_model_selection( + &self.parent_model_selection(&parent_session_id, &defaults)?, + ) + .await?, + ) + } else if let Some(model_id) = model_id.as_deref() { + Some(normalize_model_selection(model_id).await?) + } else { + None + }; + if let Some(model_id) = requested_model_id { let session_id = session.session_id.clone(); self.session_manager .update_session_model_id(&session_id, &model_id) @@ -6560,7 +6589,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet request.model_binding_policy, SessionModelBindingPolicy::ApprovedImmutable ) { - if model_id.is_some() { + if model_id.is_some() || inherit_parent_model { return Err(BitFunError::Validation( "An approved immutable subagent model cannot be overridden".to_string(), )); @@ -6577,6 +6606,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet } else { self.resolve_fresh_subagent_model_id( model_id.as_deref(), + inherit_parent_model, &agent_type, &workspace_path, &request.subagent_parent_info.session_id, @@ -6649,8 +6679,9 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .await?; } let defaults = Self::agent_model_defaults().await; - let parent_model_id = if model_id.is_none() - && matches!(&defaults.subagents.fork, SubagentModelSelection::Inherit) + let parent_model_id = if inherit_parent_model + || (model_id.is_none() + && matches!(&defaults.subagents.fork, SubagentModelSelection::Inherit)) { Some( trimmed_model_id(snapshot.session_model_id.as_deref()) @@ -6665,11 +6696,19 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet } else { None }; - let model_selection = resolve_subagent_model_selection( - model_id.as_deref(), - &defaults.subagents.fork, - parent_model_id.as_deref(), - )?; + let model_selection = if inherit_parent_model { + parent_model_id.ok_or_else(|| { + BitFunError::Validation( + "Fork parent session has no model selection".to_string(), + ) + })? + } else { + resolve_subagent_model_selection( + model_id.as_deref(), + &defaults.subagents.fork, + parent_model_id.as_deref(), + )? + }; let resolved_model_id = normalize_model_selection(&model_selection).await?; let mut session_config = snapshot.build_child_session_config(None); session_config.model_id = Some(resolved_model_id); @@ -9237,6 +9276,7 @@ mod tests { model_binding_policy: SessionModelBindingPolicy::Mutable, workspace_path: Some(workspace_path.to_string_lossy().into_owned()), model_id: None, + inherit_parent_model: false, subagent_parent_info: SubagentParentInfo { session_id: "parent-session".to_string(), dialog_turn_id: "parent-turn".to_string(), @@ -9289,6 +9329,7 @@ mod tests { model_binding_policy: SessionModelBindingPolicy::Mutable, workspace_path: None, model_id: None, + inherit_parent_model: false, subagent_parent_info: SubagentParentInfo { session_id: "parent-session".to_string(), dialog_turn_id: "parent-turn".to_string(), @@ -9383,6 +9424,7 @@ mod tests { model_binding_policy: SessionModelBindingPolicy::Mutable, workspace_path: None, model_id: Some("fast".to_string()), + inherit_parent_model: false, subagent_parent_info: SubagentParentInfo { session_id: parent_session.session_id.clone(), dialog_turn_id: "parent-turn".to_string(), @@ -9457,6 +9499,7 @@ mod tests { model_binding_policy: SessionModelBindingPolicy::Mutable, workspace_path: None, model_id: None, + inherit_parent_model: false, subagent_parent_info: SubagentParentInfo { session_id: parent_session.session_id.clone(), dialog_turn_id: "parent-turn".to_string(), @@ -9570,6 +9613,7 @@ mod tests { model_binding_policy: SessionModelBindingPolicy::Mutable, workspace_path: None, model_id: None, + inherit_parent_model: false, subagent_parent_info: SubagentParentInfo { session_id: parent_session.session_id.clone(), dialog_turn_id: "parent-turn".to_string(), @@ -10309,7 +10353,51 @@ mod tests { } #[tokio::test] - async fn reused_subagent_send_input_updates_requested_model() { + async fn fresh_subagent_request_can_explicitly_inherit_parent_model() { + let (coordinator, session_manager) = test_coordinator(); + let workspace_path = std::env::temp_dir().join(format!( + "bitfun-fresh-subagent-inherit-test-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&workspace_path).expect("workspace dir should exist"); + struct TempWorkspaceGuard(std::path::PathBuf); + impl Drop for TempWorkspaceGuard { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let _workspace_guard = TempWorkspaceGuard(workspace_path.clone()); + let parent_session = session_manager + .create_session( + "Parent".to_string(), + "agentic".to_string(), + SessionConfig { + model_id: Some("primary".to_string()), + workspace_path: Some(workspace_path.to_string_lossy().into_owned()), + ..Default::default() + }, + ) + .await + .expect("parent session should be created"); + + let model_id = coordinator + .resolve_fresh_subagent_model_id( + None, + true, + "Explore", + workspace_path + .to_str() + .expect("workspace path should be UTF-8"), + &parent_session.session_id, + ) + .await + .expect("fresh subagent request should inherit the parent model"); + + assert_eq!(model_id, "primary"); + } + + #[tokio::test] + async fn reused_subagent_send_input_updates_requested_and_inherited_model() { let (coordinator, session_manager) = test_coordinator(); let workspace_path = std::env::temp_dir().join(format!( "bitfun-reused-subagent-model-test-{}", @@ -10324,6 +10412,19 @@ mod tests { } let _workspace_guard = TempWorkspaceGuard(workspace_path.clone()); + let parent_session = session_manager + .create_session( + "Parent".to_string(), + "agentic".to_string(), + SessionConfig { + model_id: Some("primary".to_string()), + workspace_path: Some(workspace_path.to_string_lossy().into_owned()), + ..Default::default() + }, + ) + .await + .expect("parent session should be created"); + let subagent_session = coordinator .create_hidden_agent_session( None, @@ -10334,7 +10435,7 @@ mod tests { workspace_path: Some(workspace_path.to_string_lossy().into_owned()), ..Default::default() }, - Some("session-parent-session".to_string()), + Some(format!("session-{}", parent_session.session_id)), SessionKind::Subagent, ) .await @@ -10350,8 +10451,9 @@ mod tests { model_binding_policy: SessionModelBindingPolicy::Mutable, workspace_path: None, model_id: Some("fast".to_string()), + inherit_parent_model: false, subagent_parent_info: SubagentParentInfo { - session_id: "parent-session".to_string(), + session_id: parent_session.session_id.clone(), dialog_turn_id: "parent-turn".to_string(), tool_call_id: "task-tool".to_string(), }, @@ -10375,6 +10477,43 @@ mod tests { .as_deref(), Some("fast") ); + + let inherit_request = SubagentExecutionRequest { + task_description: "Continue with the parent model".to_string(), + context_mode: SubagentContextMode::Fresh, + target_session_id: Some(subagent_session.session_id.clone()), + subagent_type: None, + logical_subagent_type: None, + continuation_policy: SessionContinuationPolicy::Reusable, + model_binding_policy: SessionModelBindingPolicy::Mutable, + workspace_path: None, + model_id: None, + inherit_parent_model: true, + subagent_parent_info: SubagentParentInfo { + session_id: parent_session.session_id.clone(), + dialog_turn_id: "parent-turn".to_string(), + tool_call_id: "task-tool".to_string(), + }, + context: HashMap::new(), + delegation_policy: DelegationPolicy::top_level().spawn_child(), + external_generation_lease: None, + }; + + let prepared = coordinator + .prepare_subagent_execution_request(inherit_request) + .await + .expect("send_input request should inherit the parent model"); + + assert_eq!(prepared.session_config.model_id.as_deref(), Some("primary")); + assert_eq!( + session_manager + .get_session(&subagent_session.session_id) + .expect("subagent session should remain available") + .config + .model_id + .as_deref(), + Some("primary") + ); } #[tokio::test] @@ -10396,7 +10535,7 @@ mod tests { "Parent".to_string(), "agentic".to_string(), SessionConfig { - model_id: Some("parent-model".to_string()), + model_id: Some("primary".to_string()), workspace_path: Some(workspace_path.to_string_lossy().into_owned()), ..Default::default() }, @@ -10420,6 +10559,7 @@ mod tests { model_binding_policy: SessionModelBindingPolicy::Mutable, workspace_path: None, model_id: Some("fast".to_string()), + inherit_parent_model: false, subagent_parent_info: SubagentParentInfo { session_id: parent_session.session_id.clone(), dialog_turn_id: "parent-turn".to_string(), @@ -10449,6 +10589,34 @@ mod tests { .as_deref(), Some("fast") ); + + let inherit_request = SubagentExecutionRequest { + task_description: "Fork with the parent model".to_string(), + context_mode: SubagentContextMode::Fork, + target_session_id: None, + subagent_type: None, + logical_subagent_type: None, + continuation_policy: SessionContinuationPolicy::Reusable, + model_binding_policy: SessionModelBindingPolicy::Mutable, + workspace_path: None, + model_id: None, + inherit_parent_model: true, + subagent_parent_info: SubagentParentInfo { + session_id: parent_session.session_id.clone(), + dialog_turn_id: "parent-turn".to_string(), + tool_call_id: "task-tool".to_string(), + }, + context: HashMap::new(), + delegation_policy: DelegationPolicy::top_level().spawn_child(), + external_generation_lease: None, + }; + + let prepared = coordinator + .prepare_subagent_execution_request(inherit_request) + .await + .expect("fork request should inherit the parent model"); + + assert_eq!(prepared.session_config.model_id.as_deref(), Some("primary")); } #[tokio::test] diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/list_models_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/list_models_tool.rs new file mode 100644 index 000000000..8a48af80c --- /dev/null +++ b/src/crates/assembly/core/src/agentic/tools/implementations/list_models_tool.rs @@ -0,0 +1,360 @@ +//! ListModels tool implementation. + +use crate::agentic::tools::framework::{ + Tool, ToolExposure, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, +}; +use crate::service::config::global::GlobalConfigManager; +use crate::service::config::types::{AIConfig, AIModelConfig}; +use crate::util::errors::{BitFunError, BitFunResult}; +use async_trait::async_trait; +use serde_json::{json, Value}; + +/// Lists enabled BitFun model configurations, optionally filtered by a fuzzy query. +pub struct ListModelsTool; + +impl Default for ListModelsTool { + fn default() -> Self { + Self::new() + } +} + +impl ListModelsTool { + pub fn new() -> Self { + Self + } +} + +fn normalized_terms(query: &str) -> Vec { + query + .split(|character: char| !character.is_alphanumeric()) + .filter(|term| !term.is_empty()) + .map(|term| term.to_lowercase()) + .collect() +} + +fn normalized_field(value: &str) -> String { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn fuzzy_match_score(term: &str, field: &str) -> Option { + if field == term { + return Some(0); + } + if field.starts_with(term) { + return Some(100 + field.len().saturating_sub(term.len())); + } + if let Some(position) = field.find(term) { + return Some(1_000 + position); + } + + let mut next_index = 0; + let mut gaps = 0; + for character in term.chars() { + let Some(found) = field[next_index..].find(character) else { + return None; + }; + gaps += found; + next_index += found + character.len_utf8(); + } + + Some(10_000 + gaps) +} + +fn model_match_score(model: &AIModelConfig, terms: &[String]) -> Option { + if terms.is_empty() { + return Some(0); + } + + let fields = [ + normalized_field(&model.name), + normalized_field(&model.id), + normalized_field(&model.model_name), + ]; + + terms.iter().try_fold(0usize, |score, term| { + fields + .iter() + .filter_map(|field| fuzzy_match_score(term, field)) + .min() + .map(|term_score| score + term_score) + }) +} + +fn resolved_default_selector(query: Option<&str>) -> Option<&str> { + match query.map(str::trim) { + Some("primary") => Some("primary"), + Some("fast") => Some("fast"), + _ => None, + } +} + +fn escape_markdown_table_cell(value: &str) -> String { + value + .replace('\\', "\\\\") + .replace('|', "\\|") + .replace(['\r', '\n'], "
") +} + +fn build_list_models_result(config: &AIConfig, query: Option<&str>) -> (Value, String) { + let terms = query.map(normalized_terms).unwrap_or_default(); + let selected_model_id = resolved_default_selector(query) + .and_then(|selector| config.resolve_model_selection(selector)); + let mut matches = config + .models + .iter() + .enumerate() + .filter(|(_, model)| model.enabled) + .filter_map(|(index, model)| { + let score = match selected_model_id.as_deref() { + Some(model_id) => (model.id == model_id).then_some(0), + None => model_match_score(model, &terms), + }?; + Some((score, index, model)) + }) + .collect::>(); + matches.sort_by_key(|(score, index, _)| (*score, *index)); + + let models = matches + .into_iter() + .map(|(_, _, model)| { + json!({ + "provider_name": model.name, + "model_id": model.id, + "model_name": model.model_name, + }) + }) + .collect::>(); + + let assistant_result = if models.is_empty() { + match query.filter(|value| !value.trim().is_empty()) { + Some(query) => format!("No enabled BitFun models matched '{}'.", query.trim()), + None => "No enabled BitFun models are configured.".to_string(), + } + } else { + let rows = + models + .iter() + .map(|model| { + format!( + "| {} | {} | {} |", + escape_markdown_table_cell( + model["provider_name"].as_str().unwrap_or_default(), + ), + escape_markdown_table_cell(model["model_id"].as_str().unwrap_or_default()), + escape_markdown_table_cell( + model["model_name"].as_str().unwrap_or_default() + ), + ) + }) + .collect::>() + .join("\n"); + format!("| provider_name | model_id | model_name |\n| --- | --- | --- |\n{rows}") + }; + + ( + json!({ + "success": true, + "query": query.filter(|value| !value.trim().is_empty()), + "match_count": models.len(), + "models": models, + }), + assistant_result, + ) +} + +#[async_trait] +impl Tool for ListModelsTool { + fn name(&self) -> &str { + "ListModels" + } + + async fn description(&self) -> BitFunResult { + Ok("List enabled BitFun models as provider_name, model_id, and model_name. Use `query` to filter models; exact `primary` or `fast` resolves that default selector.".to_string()) + } + + fn short_description(&self) -> String { + "List enabled BitFun models.".to_string() + } + + fn default_exposure(&self) -> ToolExposure { + ToolExposure::Deferred + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Optional search term. Omit for all enabled models." + } + }, + "additionalProperties": false + }) + } + + fn is_readonly(&self) -> bool { + true + } + + fn is_concurrency_safe(&self, _input: Option<&Value>) -> bool { + true + } + + fn needs_permissions(&self, _input: Option<&Value>) -> bool { + false + } + + async fn validate_input( + &self, + input: &Value, + _context: Option<&ToolUseContext>, + ) -> ValidationResult { + if !input.is_object() { + return ValidationResult { + result: false, + message: Some("Input must be an object.".to_string()), + error_code: None, + meta: None, + }; + } + if input.get("query").is_some_and(|query| !query.is_string()) { + return ValidationResult { + result: false, + message: Some("query must be a string when provided.".to_string()), + error_code: None, + meta: None, + }; + } + + ValidationResult::default() + } + + fn render_tool_use_message(&self, input: &Value, _options: &ToolRenderOptions) -> String { + match input.get("query").and_then(Value::as_str) { + Some(query) if !query.trim().is_empty() => { + format!("Find enabled BitFun models matching {}", query.trim()) + } + _ => "List enabled BitFun models".to_string(), + } + } + + fn render_tool_result_message(&self, output: &Value) -> String { + let count = output + .get("match_count") + .and_then(Value::as_u64) + .unwrap_or_default(); + format!("Found {count} enabled model(s)") + } + + async fn call_impl( + &self, + input: &Value, + _context: &ToolUseContext, + ) -> BitFunResult> { + let service = GlobalConfigManager::get_service().await.map_err(|_| { + BitFunError::tool("BitFun model configuration is unavailable.".to_string()) + })?; + let config: AIConfig = service.get_config(Some("ai")).await.map_err(|_| { + BitFunError::tool("BitFun model configuration could not be loaded.".to_string()) + })?; + let query = input.get("query").and_then(Value::as_str); + let (data, result_for_assistant) = build_list_models_result(&config, query); + + Ok(vec![ToolResult::ok(data, Some(result_for_assistant))]) + } +} + +#[cfg(test)] +mod tests { + use super::build_list_models_result; + use crate::service::config::types::{AIConfig, AIModelConfig}; + + fn model( + id: &str, + provider: &str, + model_name: &str, + provider_name: &str, + enabled: bool, + ) -> AIModelConfig { + AIModelConfig { + id: id.to_string(), + provider: provider.to_string(), + model_name: model_name.to_string(), + name: provider_name.to_string(), + enabled, + ..Default::default() + } + } + + fn configured_models() -> AIConfig { + let mut config = AIConfig::default(); + config.models = vec![ + model("main-gpt", "openai", "gpt-5-mini", "OpenAI", true), + model( + "claude-fast", + "anthropic", + "claude-3-7-sonnet", + "Anthropic", + true, + ), + model("disabled-gpt", "openai", "gpt-5", "OpenAI", false), + ]; + config.default_models.primary = Some("main-gpt".to_string()); + config.default_models.fast = Some("claude-fast".to_string()); + config + } + + #[test] + fn filters_to_enabled_fuzzy_matches() { + let (data, assistant_result) = build_list_models_result(&configured_models(), Some("g5m")); + + let models = data["models"].as_array().expect("models array"); + assert_eq!(models.len(), 1); + assert_eq!(models[0]["model_id"], "main-gpt"); + assert_eq!(models[0]["provider_name"], "OpenAI"); + assert_eq!(models[0]["model_name"], "gpt-5-mini"); + assert!(assistant_result.contains("| provider_name | model_id | model_name |")); + assert!(assistant_result.contains("| OpenAI | main-gpt | gpt-5-mini |")); + } + + #[test] + fn resolves_primary_and_fast_only_for_exact_selector_queries() { + let config = configured_models(); + + let (primary, _) = build_list_models_result(&config, Some("primary")); + let (fast, _) = build_list_models_result(&config, Some("fast")); + let (non_exact, _) = build_list_models_result(&config, Some("Primary")); + + assert_eq!(primary["models"][0]["model_id"], "main-gpt"); + assert_eq!(fast["models"][0]["model_id"], "claude-fast"); + assert!(non_exact["models"].as_array().is_some_and(Vec::is_empty)); + } + + #[test] + fn matches_multiple_terms_across_model_fields() { + let (data, assistant_result) = + build_list_models_result(&configured_models(), Some("anth sonnet")); + + let models = data["models"].as_array().expect("models array"); + assert_eq!(models.len(), 1); + assert_eq!(models[0]["model_id"], "claude-fast"); + assert!(assistant_result.contains("Anthropic")); + assert!(!assistant_result.contains("GPT-5 Mini")); + } + + #[test] + fn empty_query_lists_all_enabled_models_without_disabled_entries() { + let (data, assistant_result) = build_list_models_result(&configured_models(), None); + + let models = data["models"].as_array().expect("models array"); + assert_eq!(models.len(), 2); + assert_eq!(data["match_count"], 2); + assert!(assistant_result.starts_with("| provider_name | model_id | model_name |")); + assert!(!assistant_result.contains("| OpenAI | disabled-gpt | gpt-5 |")); + } +} diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs b/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs index c056d1cb4..d2919354e 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs @@ -27,6 +27,7 @@ pub mod get_time_tool; pub mod git_tool; pub mod glob_tool; pub mod grep_tool; +pub mod list_models_tool; pub mod ls_tool; pub mod mcp_tools; pub mod miniapp_init_tool; @@ -69,6 +70,7 @@ pub use get_time_tool::GetTimeTool; pub use git_tool::GitTool; pub use glob_tool::GlobTool; pub use grep_tool::GrepTool; +pub use list_models_tool::ListModelsTool; pub use ls_tool::LSTool; pub use mcp_tools::{ GetMCPPromptTool, ListMCPPromptsTool, ListMCPResourcesTool, ReadMCPResourceTool, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/task/execution.rs b/src/crates/assembly/core/src/agentic/tools/implementations/task/execution.rs index b09bc7bbc..6f5528e8a 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/task/execution.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/task/execution.rs @@ -58,6 +58,7 @@ struct BackgroundTaskStartRequest<'a> { model_binding_policy: SessionModelBindingPolicy, effective_workspace_path: Option, model_id: Option, + inherit_parent_model: bool, subagent_context: Option>, prepared_prompt: String, timeout_seconds: Option, @@ -178,6 +179,7 @@ impl TaskTool { let context_mode = invocation.context_mode; let target_session_id = invocation.target_session_id.clone(); let model_id = invocation.model_id.clone(); + let inherit_parent_model = invocation.inherit_parent_model; let mut timeout_seconds = invocation.timeout_seconds; let run_in_background = invocation.run_in_background; let is_retry = invocation.is_retry; @@ -585,6 +587,7 @@ impl TaskTool { model_binding_policy, effective_workspace_path, model_id, + inherit_parent_model, subagent_context, prepared_prompt, timeout_seconds, @@ -607,6 +610,7 @@ impl TaskTool { model_binding_policy, effective_workspace_path, model_id, + inherit_parent_model, subagent_context, prepared_prompt, timeout_seconds, @@ -643,6 +647,7 @@ impl TaskTool { model_binding_policy, effective_workspace_path, model_id, + inherit_parent_model, subagent_context, prepared_prompt, timeout_seconds, @@ -668,6 +673,7 @@ impl TaskTool { model_binding_policy, workspace_path: effective_workspace_path, model_id, + inherit_parent_model, subagent_parent_info: parent_info, context: subagent_context.unwrap_or_default(), delegation_policy: context.delegation_policy().spawn_child(), @@ -705,6 +711,7 @@ impl TaskTool { model_binding_policy: SessionModelBindingPolicy, effective_workspace_path: Option, model_id: Option, + inherit_parent_model: bool, subagent_context: Option>, prepared_prompt: String, timeout_seconds: Option, @@ -736,7 +743,7 @@ impl TaskTool { }; let subagent_execution_started_at = Instant::now(); debug!( - "TaskTool awaiting subagent result: parent_session_id={}, dialog_turn_id={}, tool_call_id={}, context_mode={}, delegate_target={}, timeout_seconds={:?}, workspace_path={:?}, model_id={:?}", + "TaskTool awaiting subagent result: parent_session_id={}, dialog_turn_id={}, tool_call_id={}, context_mode={}, delegate_target={}, timeout_seconds={:?}, workspace_path={:?}, model_id={:?}, inherit_parent_model={}", session_id, dialog_turn_id, tool_call_id, @@ -744,7 +751,8 @@ impl TaskTool { delegate_target_label, timeout_seconds, effective_workspace_path, - model_id + model_id, + inherit_parent_model ); let execution_result = coordinator .execute_subagent( @@ -758,6 +766,7 @@ impl TaskTool { model_binding_policy, workspace_path: effective_workspace_path.clone(), model_id: model_id.clone(), + inherit_parent_model, subagent_parent_info: parent_info, context: subagent_context.clone().unwrap_or_default(), delegation_policy: context.delegation_policy().spawn_child(), diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/task/input.rs b/src/crates/assembly/core/src/agentic/tools/implementations/task/input.rs index def0aeb91..67a21e5dd 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/task/input.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/task/input.rs @@ -72,6 +72,7 @@ pub(super) struct TaskInvocation { pub(super) target_session_id: Option, pub(super) subagent_type: Option, pub(super) model_id: Option, + pub(super) inherit_parent_model: bool, pub(super) timeout_seconds: Option, pub(super) run_in_background: bool, pub(super) is_retry: bool, @@ -104,6 +105,8 @@ impl TaskTool { } } + let (model_id, inherit_parent_model) = Self::optional_model_id(input)?; + return Ok(TaskInvocation { action: TaskAction::Spawn, description: Self::string_field(input, "description", "DeepReview Task calls")?, @@ -111,7 +114,8 @@ impl TaskTool { context_mode: SubagentContextMode::Fresh, target_session_id: None, subagent_type: Self::string_field(input, "subagent_type", "DeepReview Task calls")?, - model_id: Self::optional_trimmed_string(input, "model_id")?, + model_id, + inherit_parent_model, timeout_seconds: Self::optional_timeout_seconds(input)?, run_in_background: false, is_retry: input.get("retry").and_then(Value::as_bool).unwrap_or(false), @@ -169,6 +173,8 @@ impl TaskTool { } } + let (model_id, inherit_parent_model) = Self::optional_model_id(input)?; + Ok(TaskInvocation { action, description, @@ -176,7 +182,8 @@ impl TaskTool { context_mode, target_session_id: None, subagent_type: Self::optional_trimmed_string(input, "subagent_type")?, - model_id: Self::optional_trimmed_string(input, "model_id")?, + model_id, + inherit_parent_model, timeout_seconds: None, run_in_background, is_retry: false, @@ -200,6 +207,8 @@ impl TaskTool { action, )?; + let (model_id, inherit_parent_model) = Self::optional_model_id(input)?; + Ok(TaskInvocation { action, description, @@ -207,7 +216,8 @@ impl TaskTool { context_mode: SubagentContextMode::Fresh, target_session_id, subagent_type: None, - model_id: Self::optional_trimmed_string(input, "model_id")?, + model_id, + inherit_parent_model, timeout_seconds: None, run_in_background, is_retry: false, @@ -240,6 +250,7 @@ impl TaskTool { target_session_id, subagent_type: None, model_id: None, + inherit_parent_model: false, timeout_seconds: None, run_in_background: false, is_retry: false, @@ -323,6 +334,13 @@ impl TaskTool { } } + fn optional_model_id(input: &Value) -> BitFunResult<(Option, bool)> { + match Self::optional_trimmed_string(input, "model_id")? { + Some(model_id) if model_id == "inherit" => Ok((None, true)), + model_id => Ok((model_id, false)), + } + } + fn optional_bool(input: &Value, field: &str) -> BitFunResult> { match input.get(field) { None => Ok(None), diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/task/schema.rs b/src/crates/assembly/core/src/agentic/tools/implementations/task/schema.rs index 86dd81c28..0cb26dad9 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/task/schema.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/task/schema.rs @@ -28,7 +28,7 @@ impl TaskTool { "model_id".to_string(), json!({ "type": "string", - "description": "Optional model ID for action='spawn' and action='send_input'. Set it only when the user specifies a particular model." + "description": "Optional model ID for action='spawn' and action='send_input'. Can be 'inherit', 'primary', 'fast', or a configured model ID." }), ); properties @@ -113,6 +113,12 @@ The two modes are mutually exclusive: do not provide `subagent_type` when `fork_ - true: Run the agent in the background without blocking you. The response includes a `background_task_id`; use AgentWait when you need one or more background results. Completed background tasks never automatically create a follow-up turn. - When an unfinished answer depends on background work, call AgentWait before ending the current turn. If the result is no longer needed, finish normally without waiting. +`model_id` usage: +- Set it only when the user requests a particular model. +- Omit it to use the subagent's configured model, which may differ from your model. +- Special values: `inherit` explicitly uses the same model as yours; `primary` and `fast` use the user's configured model slots. +- For a configured model, call ListModels first and use its returned `model_id`. + Usage notes: - Include a short description of what the agent will do for this round (for `spawn` and `send_input`). - Provide a clear prompt for `spawn` and `send_input` so the agent can work autonomously and return the information you need. @@ -125,7 +131,6 @@ Usage notes: Examples (assume "example-reviewer" is present in the agent listing): - Start a new specialized subagent: `{ "action": "spawn", "description": "Inspect parser flow", "subagent_type": "example-reviewer", "prompt": "Inspect the parser flow in src/parser.rs and report risks, key functions, and any missing tests." }` -- Start a background review and collect it later: `{ "action": "spawn", "description": "Review parser", "subagent_type": "example-reviewer", "prompt": "Review the parser and report findings.", "run_in_background": true }` - Start by forking the current context: `{ "action": "spawn", "description": "Check migration impact", "fork_context": true, "prompt": "Using the current context, check whether the migration affects config loading. Stay read-only and report the answer with file references." }` - Continue an existing subagent with a specific model: `{ "action": "send_input", "description": "Continue parser review", "session_id": "subagent-session-123", "model_id": "fast", "prompt": "Continue from your prior parser review and focus on the error recovery paths." }` - Cancel a background subagent: `{ "action": "cancel", "session_id": "subagent-session-123" }` diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/task/tests.rs b/src/crates/assembly/core/src/agentic/tools/implementations/task/tests.rs index abb05643d..cb793bdee 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/task/tests.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/task/tests.rs @@ -128,6 +128,24 @@ fn task_schema_accepts_optional_model_id() { .any(|value| value.as_str() == Some("model_id"))); } +#[test] +fn task_model_id_inherit_requests_parent_model_inheritance() { + let invocation = TaskTool::parse_invocation( + &json!({ + "action": "spawn", + "description": "Inspect parser", + "prompt": "Inspect the parser flow.", + "subagent_type": "Explore", + "model_id": "inherit" + }), + false, + ) + .expect("inherit should be accepted as a Task model selection"); + + assert_eq!(invocation.model_id, None); + assert!(invocation.inherit_parent_model); +} + #[tokio::test] async fn validate_input_accepts_review_background_for_agent_wait() { let validation = TaskTool::new() diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs index 8d2d6c099..97198290c 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs @@ -29,6 +29,7 @@ impl StaticToolProviderFactory for ProductConcreteToolFactory { "WriteStdin" => Some(Arc::new(WriteStdinTool::new())), "ExecControl" => Some(Arc::new(ExecControlTool::new())), "GetTime" => Some(Arc::new(GetTimeTool::new())), + "ListModels" => Some(Arc::new(ListModelsTool::new())), "Task" => Some(Arc::new(TaskTool::new())), "AgentWait" => Some(Arc::new(AgentWaitTool::new())), "LaunchReviewAgent" => Some(Arc::new(LaunchReviewAgentTool::new())), diff --git a/src/crates/assembly/core/src/agentic/tools/registry.rs b/src/crates/assembly/core/src/agentic/tools/registry.rs index 1afabff41..f0557cd60 100644 --- a/src/crates/assembly/core/src/agentic/tools/registry.rs +++ b/src/crates/assembly/core/src/agentic/tools/registry.rs @@ -465,6 +465,7 @@ mod tests { "WriteStdin", "ExecControl", "GetTime", + "ListModels", "Task", "AgentWait", "LaunchReviewAgent", @@ -644,6 +645,7 @@ mod tests { assert!(registry.is_tool_deferred("WebFetch")); assert!(registry.is_tool_deferred("GetFileDiff")); + assert!(registry.is_tool_deferred("ListModels")); assert!(!registry.is_tool_deferred("GetToolSpec")); assert!(registry.is_tool_deferred("Git")); assert!(registry.is_tool_deferred("ReviewPlatform")); @@ -657,6 +659,7 @@ mod tests { assert_eq!( registry.get_deferred_tool_names(), vec![ + "ListModels", "CreatePlan", "GetFileDiff", "SessionControl", @@ -699,6 +702,7 @@ mod tests { "Glob", "Grep", "GetTime", + "ListModels", "Skill", "AskUserQuestion", "TodoWrite", diff --git a/src/crates/execution/agent-runtime/src/custom_agent.rs b/src/crates/execution/agent-runtime/src/custom_agent.rs index 37a372e4e..56bdb13d8 100644 --- a/src/crates/execution/agent-runtime/src/custom_agent.rs +++ b/src/crates/execution/agent-runtime/src/custom_agent.rs @@ -18,6 +18,7 @@ pub const DEFAULT_CUSTOM_MODE_TOOLS: &[&str] = &[ "WriteStdin", "ExecControl", "Task", + "ListModels", "Skill", "WebSearch", "WebFetch", diff --git a/src/crates/execution/agent-runtime/tests/custom_agent_mode_contracts.rs b/src/crates/execution/agent-runtime/tests/custom_agent_mode_contracts.rs index f2ea543f2..7f6452426 100644 --- a/src/crates/execution/agent-runtime/tests/custom_agent_mode_contracts.rs +++ b/src/crates/execution/agent-runtime/tests/custom_agent_mode_contracts.rs @@ -70,6 +70,7 @@ fn custom_mode_defaults_generate_id_and_default_policy() { parsed.definition.tools, default_custom_agent_tools(CustomAgentKind::Mode) ); + assert!(parsed.definition.tools.contains(&"ListModels".to_string())); assert_eq!(parsed.definition.readonly, DEFAULT_CUSTOM_MODE_READONLY); assert_eq!(parsed.definition.model, DEFAULT_CUSTOM_MODE_MODEL); assert_eq!( diff --git a/src/crates/execution/tool-provider-groups/src/lib.rs b/src/crates/execution/tool-provider-groups/src/lib.rs index b512e4261..a2a1696ea 100644 --- a/src/crates/execution/tool-provider-groups/src/lib.rs +++ b/src/crates/execution/tool-provider-groups/src/lib.rs @@ -133,6 +133,7 @@ const PRODUCT_TOOL_PROVIDER_GROUP_PLAN: &[ToolProviderGroupPlan] = &[ "WriteStdin", "ExecControl", "GetTime", + "ListModels", ], }, ToolProviderGroupPlan { @@ -360,6 +361,7 @@ mod tests { "WriteStdin", "ExecControl", "GetTime", + "ListModels", "Task", "AgentWait", "LaunchReviewAgent", diff --git a/src/web-ui/src/app/scenes/agents/components/toolGroups.test.ts b/src/web-ui/src/app/scenes/agents/components/toolGroups.test.ts index 4d49fcd2a..c5d6a2261 100644 --- a/src/web-ui/src/app/scenes/agents/components/toolGroups.test.ts +++ b/src/web-ui/src/app/scenes/agents/components/toolGroups.test.ts @@ -19,6 +19,7 @@ const tools: GroupableTool[] = [ { name: 'Edit', description: 'Edit files', is_readonly: false }, { name: 'Write', description: 'Write files', is_readonly: false }, { name: 'Task', description: 'Delegate work to an agent', is_readonly: false }, + { name: 'ListModels', description: 'List enabled BitFun models', is_readonly: true }, { name: 'AgentWait', description: 'Wait for background agent results', is_readonly: true }, { name: 'Skill', description: 'Load a skill', is_readonly: true }, { name: 'analyze_image', description: 'Analyze an image', is_readonly: true }, @@ -49,11 +50,11 @@ describe('tool groups', () => { expect(editing?.tools.map((tool) => tool.name)).not.toContain('Read'); }); - it('groups AgentWait with delegation and skills', () => { + it('groups ListModels with delegation and skills', () => { const groups = resolveToolGroups(tools, [], t as never); expect(groups.find((group) => group.id === 'builtin:delegation')?.tools.map((tool) => tool.name)) - .toEqual(['AgentWait', 'Skill', 'Task']); + .toEqual(['AgentWait', 'ListModels', 'Skill', 'Task']); }); it('groups image, canvas, and computer automation tools by their user-facing purpose', () => { diff --git a/src/web-ui/src/app/scenes/agents/components/toolGroups.ts b/src/web-ui/src/app/scenes/agents/components/toolGroups.ts index 7f00bb9f9..3b488c6d2 100644 --- a/src/web-ui/src/app/scenes/agents/components/toolGroups.ts +++ b/src/web-ui/src/app/scenes/agents/components/toolGroups.ts @@ -48,7 +48,7 @@ const BUILTIN_TOOL_GROUPS: BuiltinToolGroupDefinition[] = [ { id: 'builtin:delegation', labelKey: 'agentsOverview.toolGroups.delegation', - toolNames: ['Task', 'AgentWait', 'Skill'], + toolNames: ['Task', 'ListModels', 'AgentWait', 'Skill'], }, { id: 'builtin:web', diff --git a/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx b/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx index cdbfb492b..b3ee1bcc0 100644 --- a/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx @@ -10,7 +10,7 @@ import { } from '../types'; import { configManager } from '../services/ConfigManager'; import { getCapabilitiesByCategory, resolveModelCategory } from '../services/modelCategory'; -import { PROVIDER_TEMPLATES, getModelDisplayName, getProviderDisplayName, getProviderTemplateId } from '../services/modelConfigs'; +import { allocateModelConfigId, PROVIDER_TEMPLATES, getModelDisplayName, getProviderDisplayName, getProviderTemplateId } from '../services/modelConfigs'; import { DEFAULT_REASONING_MODE, getEffectiveReasoningMode, supportsAnthropicAdaptive, supportsAnthropicReasoning, supportsAnthropicThinkingBudget, supportsDeepSeekReasoningEffort, supportsResponsesReasoning } from '../utils/reasoning'; import { aiApi, systemAPI } from '@/infrastructure/api'; import type { DiscoveredCliCredential } from '@/infrastructure/api/service-api/AIApi'; @@ -1133,9 +1133,19 @@ const AIModelConfig: React.FC = () => { const providerGroupModelIds = isProviderGroupEdit ? editingProviderModelIds : new Set(); - const configsToSave: AIModelConfigType[] = draftsToSave.map((draft, index) => { + const allocatedConfigIds = new Set( + aiModels + .map(model => model.id?.trim()) + .filter((id): id is string => Boolean(id)) + ); + const configsToSave: AIModelConfigType[] = draftsToSave.map((draft) => { + const id = editingConfig.id + || draft.configId + || allocateModelConfigId(draft.modelName, allocatedConfigIds); + allocatedConfigIds.add(id); + return { - id: editingConfig.id || draft.configId || `model_${Date.now()}_${index}`, + id, name: providerName, base_url: baseUrl, request_url: resolveRequestUrl( diff --git a/src/web-ui/src/infrastructure/config/services/modelConfigs.test.ts b/src/web-ui/src/infrastructure/config/services/modelConfigs.test.ts index ecad59713..dfea292d5 100644 --- a/src/web-ui/src/infrastructure/config/services/modelConfigs.test.ts +++ b/src/web-ui/src/infrastructure/config/services/modelConfigs.test.ts @@ -47,6 +47,16 @@ describe('modelConfigs', () => { })).toBe('settings/ai-model:providers.zhipu.name'); }); + it('allocates readable model config IDs with collision and selector handling', async () => { + const { allocateModelConfigId } = await import('./modelConfigs'); + + expect(allocateModelConfigId('gpt-5-mini', [])).toBe('gpt-5-mini'); + expect(allocateModelConfigId('gpt-5-mini', ['gpt-5-mini'])).toBe('gpt-5-mini-2'); + expect(allocateModelConfigId('gpt-5-mini', ['gpt-5-mini', 'gpt-5-mini-2'])).toBe('gpt-5-mini-3'); + expect(allocateModelConfigId('primary', [])).toBe('primary-2'); + expect(allocateModelConfigId('AUTO', [])).toBe('AUTO-2'); + }); + it('loads ai.models only when the model manager is actually used', async () => { configManagerMock.getConfig.mockResolvedValueOnce([ { diff --git a/src/web-ui/src/infrastructure/config/services/modelConfigs.ts b/src/web-ui/src/infrastructure/config/services/modelConfigs.ts index e9457b35a..c0500cd77 100644 --- a/src/web-ui/src/infrastructure/config/services/modelConfigs.ts +++ b/src/web-ui/src/infrastructure/config/services/modelConfigs.ts @@ -62,6 +62,35 @@ export function getModelDisplayName(config: ProviderConfigLike): string { return `${providerName}/${modelName}`; } +const RESERVED_MODEL_CONFIG_IDS = new Set(['primary', 'fast', 'auto', 'default']); + +/** Allocate a readable config ID for a newly created model. */ +export function allocateModelConfigId(modelName: string, existingIds: Iterable): string { + const base = modelName.trim(); + if (!base) { + throw new Error('Model name is required to allocate a model config ID.'); + } + + const occupiedIds = new Set( + Array.from(existingIds, (id) => id.trim()).filter(Boolean), + ); + const isAvailable = (candidate: string) => ( + !occupiedIds.has(candidate) + && !RESERVED_MODEL_CONFIG_IDS.has(candidate.toLowerCase()) + ); + + if (isAvailable(base)) { + return base; + } + + for (let suffix = 2; ; suffix += 1) { + const candidate = `${base}-${suffix}`; + if (isAvailable(candidate)) { + return candidate; + } + } +} + export const PROVIDER_TEMPLATES: Record = { openbitfun: { id: 'openbitfun',