acp: Support specifying settings for extensions (#43177)

Bennet Bo Fenner created

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`)

Change summary

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 
crates/migrator/src/migrations/m_2025_11_20/settings.rs |  76 ++++++
crates/migrator/src/migrator.rs                         |  65 +++++
crates/project/src/agent_server_store.rs                | 132 ++++++++--
crates/remote_server/src/remote_editing_tests.rs        |   1 
crates/settings/src/settings_content/agent.rs           |  53 ++-
10 files changed, 317 insertions(+), 113 deletions(-)

Detailed changes

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<acp::SessionModeId>, fs: Arc<dyn Fs>, 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<acp::ModelId>, fs: Arc<dyn Fs>, 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());
+                }
             }
         });
     }

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()),

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<ExternalAgent> 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::<Vec<_>>();
 
-                                let custom_settings = cx
-                                    .global::<SettingsStore>()
-                                    .get::<AllAgentServersSettings>(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,

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())),
         }
     }
 }

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;
+}

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<usize>, 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","#
+        ),
+    ))
+}

crates/migrator/src/migrator.rs 🔗

@@ -219,6 +219,10 @@ pub fn migrate_settings(text: &str) -> Result<Option<String>> {
             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<Query> = 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(

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<dyn ExternalAgentServer>,
-                )
-            }));
+            .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<dyn ExternalAgentServer>,
+                        )),
+                        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<AgentServerCommand> 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<String>,
-    /// 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<String>,
+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<String>,
+        /// The default model to use for this agent.
+        ///
+        /// This should be the model ID as reported by the agent.
+        ///
+        /// Default: None
+        default_model: Option<String>,
+    },
+    Extension {
+        /// The default mode to use for this agent.
+        ///
+        /// Note: Not only all agents support modes.
+        ///
+        /// Default: None
+        default_mode: Option<String>,
+        /// The default model to use for this agent.
+        ///
+        /// This should be the model ID as reported by the agent.
+        ///
+        /// Default: None
+        default_model: Option<String>,
+    },
+}
+
+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<settings::CustomAgentServerSettings> 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("~"),

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<String>,
-    pub env: Option<HashMap<String, String>>,
-    /// The default mode to use for this agent.
-    ///
-    /// Note: Not only all agents support modes.
-    ///
-    /// Default: None
-    pub default_mode: Option<String>,
-    /// 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<String>,
+#[serde(tag = "type", rename_all = "snake_case")]
+pub enum CustomAgentServerSettings {
+    Custom {
+        #[serde(rename = "command")]
+        path: PathBuf,
+        #[serde(default)]
+        args: Vec<String>,
+        env: Option<HashMap<String, String>>,
+        /// The default mode to use for this agent.
+        ///
+        /// Note: Not only all agents support modes.
+        ///
+        /// Default: None
+        default_mode: Option<String>,
+        /// The default model to use for this agent.
+        ///
+        /// This should be the model ID as reported by the agent.
+        ///
+        /// Default: None
+        default_model: Option<String>,
+    },
+    Extension {
+        /// The default mode to use for this agent.
+        ///
+        /// Note: Not only all agents support modes.
+        ///
+        /// Default: None
+        default_mode: Option<String>,
+        /// The default model to use for this agent.
+        ///
+        /// This should be the model ID as reported by the agent.
+        ///
+        /// Default: None
+        default_model: Option<String>,
+    },
 }