From 2a40dcfd770302bbb8483a21f0e757efcd981911 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Thu, 20 Nov 2025 18:12:00 +0100 Subject: [PATCH] acp: Support specifying settings for extensions (#43177) This allows you to specify default_model and default_mode for ACP extensions, e.g. ``` "auggie": { "default_model": "gpt-5", "default_mode": "default", "type": "extension" }, ``` Release Notes: - Added support for specifying settings for ACP extensions (`default_mode`, `default_model`) --- crates/agent_servers/src/custom.rs | 36 +++-- crates/agent_ui/src/agent_configuration.rs | 2 +- crates/agent_ui/src/agent_panel.rs | 41 +----- crates/agent_ui/src/agent_ui.rs | 18 +-- crates/migrator/src/migrations.rs | 6 + .../src/migrations/m_2025_11_20/settings.rs | 76 ++++++++++ crates/migrator/src/migrator.rs | 65 +++++++++ crates/project/src/agent_server_store.rs | 132 +++++++++++++----- .../remote_server/src/remote_editing_tests.rs | 1 + crates/settings/src/settings_content/agent.rs | 53 ++++--- 10 files changed, 317 insertions(+), 113 deletions(-) create mode 100644 crates/migrator/src/migrations/m_2025_11_20/settings.rs diff --git a/crates/agent_servers/src/custom.rs b/crates/agent_servers/src/custom.rs index b417e2bdf30a7ed6b9e2ab4baa6211cee2a9a890..e7625c2cc06095c9a24a2537e4e83bced26d73f3 100644 --- a/crates/agent_servers/src/custom.rs +++ b/crates/agent_servers/src/custom.rs @@ -44,19 +44,27 @@ impl crate::AgentServer for CustomAgentServer { settings .as_ref() - .and_then(|s| s.default_mode.clone().map(|m| acp::SessionModeId(m.into()))) + .and_then(|s| s.default_mode().map(|m| acp::SessionModeId(m.into()))) } fn set_default_mode(&self, mode_id: Option, fs: Arc, cx: &mut App) { let name = self.name(); update_settings_file(fs, cx, move |settings, _| { - if let Some(settings) = settings + let settings = settings .agent_servers .get_or_insert_default() .custom - .get_mut(&name) - { - settings.default_mode = mode_id.map(|m| m.to_string()) + .entry(name.clone()) + .or_insert_with(|| settings::CustomAgentServerSettings::Extension { + default_model: None, + default_mode: None, + }); + + match settings { + settings::CustomAgentServerSettings::Custom { default_mode, .. } + | settings::CustomAgentServerSettings::Extension { default_mode, .. } => { + *default_mode = mode_id.map(|m| m.to_string()); + } } }); } @@ -72,19 +80,27 @@ impl crate::AgentServer for CustomAgentServer { settings .as_ref() - .and_then(|s| s.default_model.clone().map(|m| acp::ModelId(m.into()))) + .and_then(|s| s.default_model().map(|m| acp::ModelId(m.into()))) } fn set_default_model(&self, model_id: Option, fs: Arc, cx: &mut App) { let name = self.name(); update_settings_file(fs, cx, move |settings, _| { - if let Some(settings) = settings + let settings = settings .agent_servers .get_or_insert_default() .custom - .get_mut(&name) - { - settings.default_model = model_id.map(|m| m.to_string()) + .entry(name.clone()) + .or_insert_with(|| settings::CustomAgentServerSettings::Extension { + default_model: None, + default_mode: None, + }); + + match settings { + settings::CustomAgentServerSettings::Custom { default_model, .. } + | settings::CustomAgentServerSettings::Extension { default_model, .. } => { + *default_model = model_id.map(|m| m.to_string()); + } } }); } diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index 45ba29a595b59f4a1c329d46e43030a1b9c7ed14..ef6b90ad89e2e038e96d8864d4c2ce0ecf333d6e 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -1343,7 +1343,7 @@ async fn open_new_agent_servers_entry_in_settings_editor( .custom .insert( server_name, - settings::CustomAgentServerSettings { + settings::CustomAgentServerSettings::Custom { path: "path_to_executable".into(), args: vec![], env: Some(HashMap::default()), diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index dfc4d27e1153c61dc07c00a807c958f69db77b5a..3cbedfbe198cf826a2e82e1f42f1a0d794da49e6 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -8,9 +8,7 @@ use agent::{ContextServerRegistry, DbThreadMetadata, HistoryEntry, HistoryStore} use db::kvp::{Dismissable, KEY_VALUE_STORE}; use project::{ ExternalAgentServerName, - agent_server_store::{ - AgentServerCommand, AllAgentServersSettings, CLAUDE_CODE_NAME, CODEX_NAME, GEMINI_NAME, - }, + agent_server_store::{CLAUDE_CODE_NAME, CODEX_NAME, GEMINI_NAME}, }; use serde::{Deserialize, Serialize}; use settings::{ @@ -35,9 +33,7 @@ use crate::{ ExpandMessageEditor, acp::{AcpThreadHistory, ThreadHistoryEvent}, }; -use crate::{ - ExternalAgent, NewExternalAgentThread, NewNativeAgentThreadFromSummary, placeholder_command, -}; +use crate::{ExternalAgent, NewExternalAgentThread, NewNativeAgentThreadFromSummary}; use crate::{ManageProfiles, context_store::ContextStore}; use agent_settings::AgentSettings; use ai_onboarding::AgentPanelOnboarding; @@ -61,7 +57,7 @@ use project::{Project, ProjectPath, Worktree}; use prompt_store::{PromptBuilder, PromptStore, UserPromptId}; use rules_library::{RulesLibrary, open_rules_library}; use search::{BufferSearchBar, buffer_search}; -use settings::{Settings, SettingsStore, update_settings_file}; +use settings::{Settings, update_settings_file}; use theme::ThemeSettings; use ui::utils::WithRemSize; use ui::{ @@ -248,7 +244,6 @@ pub enum AgentType { Codex, Custom { name: SharedString, - command: AgentServerCommand, }, } @@ -280,7 +275,7 @@ impl From for AgentType { ExternalAgent::Gemini => Self::Gemini, ExternalAgent::ClaudeCode => Self::ClaudeCode, ExternalAgent::Codex => Self::Codex, - ExternalAgent::Custom { name, command } => Self::Custom { name, command }, + ExternalAgent::Custom { name } => Self::Custom { name }, ExternalAgent::NativeAgent => Self::NativeAgent, } } @@ -1459,8 +1454,8 @@ impl AgentPanel { self.serialize(cx); self.external_thread(Some(crate::ExternalAgent::Codex), None, None, window, cx) } - AgentType::Custom { name, command } => self.external_thread( - Some(crate::ExternalAgent::Custom { name, command }), + AgentType::Custom { name } => self.external_thread( + Some(crate::ExternalAgent::Custom { name }), None, None, window, @@ -2085,22 +2080,11 @@ impl AgentPanel { .cloned() .collect::>(); - let custom_settings = cx - .global::() - .get::(None) - .custom - .clone(); - for agent_name in agent_names { let icon_path = agent_server_store.agent_icon(&agent_name); let mut entry = ContextMenuEntry::new(agent_name.clone()); - let command = custom_settings - .get(&agent_name.0) - .map(|settings| settings.command.clone()) - .unwrap_or(placeholder_command()); - if let Some(icon_path) = icon_path { entry = entry.custom_icon_svg(icon_path); } else { @@ -2110,7 +2094,6 @@ impl AgentPanel { .when( is_agent_selected(AgentType::Custom { name: agent_name.0.clone(), - command: command.clone(), }), |this| { this.action(Box::new(NewExternalAgentThread { agent: None })) @@ -2121,7 +2104,6 @@ impl AgentPanel { .handler({ let workspace = workspace.clone(); let agent_name = agent_name.clone(); - let custom_settings = custom_settings.clone(); move |window, cx| { if let Some(workspace) = workspace.upgrade() { workspace.update(cx, |workspace, cx| { @@ -2134,17 +2116,6 @@ impl AgentPanel { name: agent_name .clone() .into(), - command: custom_settings - .get(&agent_name.0) - .map(|settings| { - settings - .command - .clone() - }) - .unwrap_or( - placeholder_command( - ), - ), }, window, cx, diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 6396b68cbc5f805466618bd460f9ed46ce05d086..e06364988c1b49ab8877e40571393e02c252b47b 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -38,7 +38,6 @@ use language_model::{ ConfiguredModel, LanguageModel, LanguageModelId, LanguageModelProviderId, LanguageModelRegistry, }; use project::DisableAiSettings; -use project::agent_server_store::AgentServerCommand; use prompt_store::PromptBuilder; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -162,18 +161,7 @@ pub enum ExternalAgent { ClaudeCode, Codex, NativeAgent, - Custom { - name: SharedString, - command: AgentServerCommand, - }, -} - -fn placeholder_command() -> AgentServerCommand { - AgentServerCommand { - path: "/placeholder".into(), - args: vec![], - env: None, - } + Custom { name: SharedString }, } impl ExternalAgent { @@ -197,9 +185,7 @@ impl ExternalAgent { Self::ClaudeCode => Rc::new(agent_servers::ClaudeCode), Self::Codex => Rc::new(agent_servers::Codex), Self::NativeAgent => Rc::new(agent::NativeAgentServer::new(fs, history)), - Self::Custom { name, command: _ } => { - Rc::new(agent_servers::CustomAgentServer::new(name.clone())) - } + Self::Custom { name } => Rc::new(agent_servers::CustomAgentServer::new(name.clone())), } } } diff --git a/crates/migrator/src/migrations.rs b/crates/migrator/src/migrations.rs index 2587e7a30829d4fa0e0832b91ab0294a86abc97e..8a481c734f9efcce4f6342789df6ff1d7fc01562 100644 --- a/crates/migrator/src/migrations.rs +++ b/crates/migrator/src/migrations.rs @@ -141,3 +141,9 @@ pub(crate) mod m_2025_11_12 { pub(crate) use settings::SETTINGS_PATTERNS; } + +pub(crate) mod m_2025_11_20 { + mod settings; + + pub(crate) use settings::SETTINGS_PATTERNS; +} diff --git a/crates/migrator/src/migrations/m_2025_11_20/settings.rs b/crates/migrator/src/migrations/m_2025_11_20/settings.rs new file mode 100644 index 0000000000000000000000000000000000000000..db56fb04d476a3557b224d362c99ced388dacdf0 --- /dev/null +++ b/crates/migrator/src/migrations/m_2025_11_20/settings.rs @@ -0,0 +1,76 @@ +use std::ops::Range; + +use tree_sitter::{Query, QueryMatch}; + +use crate::MigrationPatterns; + +pub const SETTINGS_PATTERNS: MigrationPatterns = &[( + SETTINGS_AGENT_SERVERS_CUSTOM_PATTERN, + migrate_custom_agent_settings, +)]; + +const SETTINGS_AGENT_SERVERS_CUSTOM_PATTERN: &str = r#"(document + (object + (pair + key: (string (string_content) @agent-servers) + value: (object + (pair + key: (string (string_content) @server-name) + value: (object) @server-settings + ) + ) + ) + ) + (#eq? @agent-servers "agent_servers") +)"#; + +fn migrate_custom_agent_settings( + contents: &str, + mat: &QueryMatch, + query: &Query, +) -> Option<(Range, String)> { + let server_name_index = query.capture_index_for_name("server-name")?; + let server_name = mat.nodes_for_capture_index(server_name_index).next()?; + let server_name_text = &contents[server_name.byte_range()]; + + if matches!(server_name_text, "gemini" | "claude" | "codex") { + return None; + } + + let server_settings_index = query.capture_index_for_name("server-settings")?; + let server_settings = mat.nodes_for_capture_index(server_settings_index).next()?; + + let mut column = None; + + // Parse the server settings to check what keys it contains + let mut cursor = server_settings.walk(); + for child in server_settings.children(&mut cursor) { + if child.kind() == "pair" { + if let Some(key_node) = child.child_by_field_name("key") { + if let (None, Some(quote_content)) = (column, key_node.child(0)) { + column = Some(quote_content.start_position().column); + } + if let Some(string_content) = key_node.child(1) { + let key = &contents[string_content.byte_range()]; + match key { + // If it already has a type key, don't modify it + "type" => return None, + _ => {} + } + } + } + } + } + + // Insert the type key at the beginning of the object + let start = server_settings.start_byte() + 1; + let indent = " ".repeat(column.unwrap_or(12)); + + Some(( + start..start, + format!( + r#" +{indent}"type": "custom","# + ), + )) +} diff --git a/crates/migrator/src/migrator.rs b/crates/migrator/src/migrator.rs index 74b73114cae81b57e5d0dc4227bafcd2cca31d10..fd30bf24982d2625e4f40669aa2e0142b8634186 100644 --- a/crates/migrator/src/migrator.rs +++ b/crates/migrator/src/migrator.rs @@ -219,6 +219,10 @@ pub fn migrate_settings(text: &str) -> Result> { migrations::m_2025_11_12::SETTINGS_PATTERNS, &SETTINGS_QUERY_2025_11_12, ), + MigrationType::TreeSitter( + migrations::m_2025_11_20::SETTINGS_PATTERNS, + &SETTINGS_QUERY_2025_11_20, + ), ]; run_migrations(text, migrations) } @@ -341,6 +345,10 @@ define_query!( SETTINGS_QUERY_2025_11_12, migrations::m_2025_11_12::SETTINGS_PATTERNS ); +define_query!( + SETTINGS_QUERY_2025_11_20, + migrations::m_2025_11_20::SETTINGS_PATTERNS +); // custom query static EDIT_PREDICTION_SETTINGS_MIGRATION_QUERY: LazyLock = LazyLock::new(|| { @@ -1192,6 +1200,63 @@ mod tests { ); } + #[test] + fn test_custom_agent_server_settings_migration() { + assert_migrate_settings_with_migrations( + &[MigrationType::TreeSitter( + migrations::m_2025_11_20::SETTINGS_PATTERNS, + &SETTINGS_QUERY_2025_11_20, + )], + r#"{ + "agent_servers": { + "gemini": { + "default_model": "gemini-1.5-pro" + }, + "claude": {}, + "codex": {}, + "my-custom-agent": { + "command": "/path/to/agent", + "args": ["--foo"], + "default_model": "my-model" + }, + "already-migrated-agent": { + "type": "custom", + "command": "/path/to/agent" + }, + "future-extension-agent": { + "type": "extension", + "default_model": "ext-model" + } + } +}"#, + Some( + r#"{ + "agent_servers": { + "gemini": { + "default_model": "gemini-1.5-pro" + }, + "claude": {}, + "codex": {}, + "my-custom-agent": { + "type": "custom", + "command": "/path/to/agent", + "args": ["--foo"], + "default_model": "my-model" + }, + "already-migrated-agent": { + "type": "custom", + "command": "/path/to/agent" + }, + "future-extension-agent": { + "type": "extension", + "default_model": "ext-model" + } + } +}"#, + ), + ); + } + #[test] fn test_remove_version_fields() { assert_migrate_settings( diff --git a/crates/project/src/agent_server_store.rs b/crates/project/src/agent_server_store.rs index 944eb593185bd5016e397d1417ed834da3ee73ef..d6bd83531eda515e6c2841c65d51619da82e9ae4 100644 --- a/crates/project/src/agent_server_store.rs +++ b/crates/project/src/agent_server_store.rs @@ -469,15 +469,21 @@ impl AgentServerStore { }), ); self.external_agents - .extend(new_settings.custom.iter().map(|(name, settings)| { - ( - ExternalAgentServerName(name.clone()), - Box::new(LocalCustomAgent { - command: settings.command.clone(), - project_environment: project_environment.clone(), - }) as Box, - ) - })); + .extend( + new_settings + .custom + .iter() + .filter_map(|(name, settings)| match settings { + CustomAgentServerSettings::Custom { command, .. } => Some(( + ExternalAgentServerName(name.clone()), + Box::new(LocalCustomAgent { + command: command.clone(), + project_environment: project_environment.clone(), + }) as Box, + )), + CustomAgentServerSettings::Extension { .. } => None, + }), + ); self.external_agents.extend(extension_agents.iter().map( |(agent_name, ext_id, targets, env, icon_path)| { let name = ExternalAgentServerName(agent_name.clone().into()); @@ -1817,32 +1823,88 @@ impl From for BuiltinAgentServerSettings { } #[derive(Clone, JsonSchema, Debug, PartialEq)] -pub struct CustomAgentServerSettings { - pub command: AgentServerCommand, - /// The default mode to use for this agent. - /// - /// Note: Not only all agents support modes. - /// - /// Default: None - pub default_mode: Option, - /// The default model to use for this agent. - /// - /// This should be the model ID as reported by the agent. - /// - /// Default: None - pub default_model: Option, +pub enum CustomAgentServerSettings { + Custom { + command: AgentServerCommand, + /// The default mode to use for this agent. + /// + /// Note: Not only all agents support modes. + /// + /// Default: None + default_mode: Option, + /// The default model to use for this agent. + /// + /// This should be the model ID as reported by the agent. + /// + /// Default: None + default_model: Option, + }, + Extension { + /// The default mode to use for this agent. + /// + /// Note: Not only all agents support modes. + /// + /// Default: None + default_mode: Option, + /// The default model to use for this agent. + /// + /// This should be the model ID as reported by the agent. + /// + /// Default: None + default_model: Option, + }, +} + +impl CustomAgentServerSettings { + pub fn command(&self) -> Option<&AgentServerCommand> { + match self { + CustomAgentServerSettings::Custom { command, .. } => Some(command), + CustomAgentServerSettings::Extension { .. } => None, + } + } + + pub fn default_mode(&self) -> Option<&str> { + match self { + CustomAgentServerSettings::Custom { default_mode, .. } + | CustomAgentServerSettings::Extension { default_mode, .. } => default_mode.as_deref(), + } + } + + pub fn default_model(&self) -> Option<&str> { + match self { + CustomAgentServerSettings::Custom { default_model, .. } + | CustomAgentServerSettings::Extension { default_model, .. } => { + default_model.as_deref() + } + } + } } impl From for CustomAgentServerSettings { fn from(value: settings::CustomAgentServerSettings) -> Self { - CustomAgentServerSettings { - command: AgentServerCommand { - path: PathBuf::from(shellexpand::tilde(&value.path.to_string_lossy()).as_ref()), - args: value.args, - env: value.env, + match value { + settings::CustomAgentServerSettings::Custom { + path, + args, + env, + default_mode, + default_model, + } => CustomAgentServerSettings::Custom { + command: AgentServerCommand { + path: PathBuf::from(shellexpand::tilde(&path.to_string_lossy()).as_ref()), + args, + env, + }, + default_mode, + default_model, + }, + settings::CustomAgentServerSettings::Extension { + default_mode, + default_model, + } => CustomAgentServerSettings::Extension { + default_mode, + default_model, }, - default_mode: value.default_mode, - default_model: value.default_model, } } } @@ -2176,7 +2238,7 @@ mod extension_agent_tests { "Tilde should be expanded for builtin agent path" ); - let settings = settings::CustomAgentServerSettings { + let settings = settings::CustomAgentServerSettings::Custom { path: PathBuf::from("~/custom/agent"), args: vec!["serve".into()], env: None, @@ -2184,10 +2246,14 @@ mod extension_agent_tests { default_model: None, }; - let CustomAgentServerSettings { + let converted: CustomAgentServerSettings = settings.into(); + let CustomAgentServerSettings::Custom { command: AgentServerCommand { path, .. }, .. - } = settings.into(); + } = converted + else { + panic!("Expected Custom variant"); + }; assert!( !path.to_string_lossy().starts_with("~"), diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index 4ff0c57d5571c5fb9e16df18078514e16ea12867..1cb63b8cd01e201c5fb2a212a2643cfdf481642a 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/crates/remote_server/src/remote_editing_tests.rs @@ -1838,6 +1838,7 @@ async fn test_remote_external_agent_server( &json!({ "agent_servers": { "foo": { + "type": "custom", "command": "foo-cli", "args": ["--flag"], "env": { diff --git a/crates/settings/src/settings_content/agent.rs b/crates/settings/src/settings_content/agent.rs index 59b5a4e0f516387ce6316cd31376bb45c2c5cb94..c6ed2fc4a8980ff56153c6da69c03f3c3b7bf9c7 100644 --- a/crates/settings/src/settings_content/agent.rs +++ b/crates/settings/src/settings_content/agent.rs @@ -342,22 +342,39 @@ pub struct BuiltinAgentServerSettings { #[skip_serializing_none] #[derive(Deserialize, Serialize, Clone, JsonSchema, MergeFrom, Debug, PartialEq)] -pub struct CustomAgentServerSettings { - #[serde(rename = "command")] - pub path: PathBuf, - #[serde(default)] - pub args: Vec, - pub env: Option>, - /// The default mode to use for this agent. - /// - /// Note: Not only all agents support modes. - /// - /// Default: None - pub default_mode: Option, - /// The default model to use for this agent. - /// - /// This should be the model ID as reported by the agent. - /// - /// Default: None - pub default_model: Option, +#[serde(tag = "type", rename_all = "snake_case")] +pub enum CustomAgentServerSettings { + Custom { + #[serde(rename = "command")] + path: PathBuf, + #[serde(default)] + args: Vec, + env: Option>, + /// The default mode to use for this agent. + /// + /// Note: Not only all agents support modes. + /// + /// Default: None + default_mode: Option, + /// The default model to use for this agent. + /// + /// This should be the model ID as reported by the agent. + /// + /// Default: None + default_model: Option, + }, + Extension { + /// The default mode to use for this agent. + /// + /// Note: Not only all agents support modes. + /// + /// Default: None + default_mode: Option, + /// The default model to use for this agent. + /// + /// This should be the model ID as reported by the agent. + /// + /// Default: None + default_model: Option, + }, }