First attempt to add ACP support

This commit is contained in:
Ryan Ward
2026-07-31 11:09:32 -05:00
parent 1a0aac51b6
commit 7f4891ec7c
25 changed files with 1051 additions and 138 deletions
+47
View File
@@ -23,6 +23,53 @@ pub(crate) fn acp_selection_model_id(
}
}
pub(crate) fn acp_selection_identity(
agent_id: &str,
values: &std::collections::BTreeMap<String, serde_json::Value>,
) -> String {
let mut identity = acp_model_id(agent_id);
for (key, value) in values {
identity.push(':');
identity.push_str(key);
identity.push('=');
identity.push_str(&canonical_json_value(value));
}
identity
}
fn canonical_json_value(value: &serde_json::Value) -> String {
match value {
serde_json::Value::Null => "null".to_owned(),
serde_json::Value::Bool(value) => value.to_string(),
serde_json::Value::Number(value) => value.to_string(),
serde_json::Value::String(value) => serde_json::to_string(value).unwrap_or_default(),
serde_json::Value::Array(values) => format!(
"[{}]",
values
.iter()
.map(canonical_json_value)
.collect::<Vec<_>>()
.join(",")
),
serde_json::Value::Object(values) => {
let mut entries = values.iter().collect::<Vec<_>>();
entries.sort_by(|(left, _), (right, _)| left.cmp(right));
format!(
"{{{}}}",
entries
.into_iter()
.map(|(key, value)| format!(
"{}:{}",
serde_json::to_string(key).unwrap_or_default(),
canonical_json_value(value)
))
.collect::<Vec<_>>()
.join(",")
)
}
}
}
pub(crate) fn acp_launch_fingerprint(
agent_id: &str,
custom_command: &str,
+28
View File
@@ -27,6 +27,32 @@ fn acp_model_identity_names_the_agent_instead_of_a_provider_model() {
assert_eq!(acp_model_id("My-Agent"), "acp:my-agent");
}
#[test]
fn acp_selection_identity_uses_structured_values_in_key_order() {
let values = std::collections::BTreeMap::from([
("mode".to_owned(), serde_json::json!("sol")),
("model".to_owned(), serde_json::json!("gpt-5.4")),
]);
assert_eq!(
acp_selection_identity("Codex", &values),
r#"acp:codex:mode="sol":model="gpt-5.4""#
);
}
#[test]
fn acp_selection_identity_distinguishes_boolean_values() {
let enabled =
std::collections::BTreeMap::from([("thinking".to_owned(), serde_json::json!(true))]);
let disabled =
std::collections::BTreeMap::from([("thinking".to_owned(), serde_json::json!(false))]);
assert_ne!(
acp_selection_identity("Codex", &enabled),
acp_selection_identity("Codex", &disabled)
);
}
#[test]
fn launch_fingerprint_is_stable_and_sensitive_to_the_full_configuration() {
let executable = std::env::current_exe().unwrap();
@@ -93,6 +119,7 @@ fn persisted_sessions_require_the_same_launch_identity() {
agent_id: "custom".to_owned(),
launch_fingerprint: acp_launch_fingerprint("custom", command, &args),
session_id: Some("session-123".to_owned()),
config_values: Default::default(),
};
assert!(validate_acp_launch_identity(&backend, "custom", command, &launch).is_ok());
@@ -115,6 +142,7 @@ fn legacy_acp_sessions_fail_closed_without_a_launch_fingerprint() {
agent_id: "codex".to_owned(),
launch_fingerprint: String::new(),
session_id: Some("legacy-session".to_owned()),
config_values: Default::default(),
};
let launch = AcpAgentPreset::Codex.launch_config();
+3 -3
View File
@@ -12,11 +12,11 @@ mod runtime_model;
mod transport;
pub(crate) use launch::{
acp_launch_fingerprint, acp_model_id, acp_selection_model_id, resolve_acp_launch,
validate_acp_dispatch, validate_acp_launch_identity,
acp_launch_fingerprint, acp_model_id, acp_selection_identity, acp_selection_model_id,
resolve_acp_launch, validate_acp_dispatch, validate_acp_launch_identity,
};
pub(crate) use permissions::resolve_acp_permissions;
pub(crate) use runtime_model::AcpRuntimeModel;
pub(crate) use runtime_model::{AcpDiscoveryState, AcpRuntimeModel};
pub(crate) use transport::{
acp_output_stream, acp_startup_error_stream, galaxy_mcp_server, AcpSessionHandleSlot,
AcpSessionMetadata, AcpSteeringRequest, GalaxyMcpTarget,
+6 -1
View File
@@ -1,5 +1,6 @@
use galaxy_acp::{
AcpEvent, ContentBlock, SessionId, StopReason, TextContent, ToolCallId, ToolCallStatus,
AcpEvent, AgentCapabilities, ContentBlock, SessionId, StopReason, TextContent, ToolCallId,
ToolCallStatus,
};
use warp_multi_agent_api::{client_action, message, response_event};
@@ -16,6 +17,8 @@ fn initializes_the_existing_chat_exchange_and_persists_user_text() {
let events = translator
.translate(AcpEvent::SessionStarted {
session_id: SessionId::from("session"),
agent_info: None,
capabilities: AgentCapabilities::default(),
can_load: true,
can_steer: true,
})
@@ -246,6 +249,8 @@ fn steering_failure_surfaces_an_indeterminate_delivery_warning() {
translator
.translate(AcpEvent::SessionStarted {
session_id: SessionId::from("session"),
agent_info: None,
capabilities: AgentCapabilities::default(),
can_load: true,
can_steer: true,
})
+313 -25
View File
@@ -1,12 +1,31 @@
use std::collections::BTreeMap;
use chrono::Utc;
use galaxy_acp::{AcpLaunchConfig, AcpManagerConfig, AcpSessionManager};
use galaxyui::{Entity, ModelContext, SingletonEntity};
use settings::Setting;
use crate::settings::{
AISettings, AcpAgentSettings, AcpConfigOptionSettings, AcpConfigValueSettings,
};
use galaxy_acp::{AcpLaunchConfig, AcpManagerConfig, AcpSessionManager};
use galaxyui::{Entity, ModelContext, SingletonEntity};
/// Long-lived ACP process/session owner shared by all agent response streams.
pub(crate) struct AcpRuntimeModel {
managed: Option<ManagedRuntime>,
discovery: AcpDiscoveryState,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) enum AcpDiscoveryState {
#[default]
Idle,
Running,
Succeeded {
option_count: usize,
},
Failed {
message: String,
},
}
struct ManagedRuntime {
@@ -16,7 +35,37 @@ struct ManagedRuntime {
impl AcpRuntimeModel {
pub(crate) fn new(_ctx: &mut ModelContext<Self>) -> Self {
Self { managed: None }
Self {
managed: None,
discovery: AcpDiscoveryState::Idle,
}
}
pub(crate) fn discovery_state(&self) -> &AcpDiscoveryState {
&self.discovery
}
pub(crate) fn begin_discovery(&mut self, ctx: &mut ModelContext<Self>) {
self.discovery = AcpDiscoveryState::Running;
ctx.emit(AcpRuntimeModelEvent::DiscoveryChanged);
}
pub(crate) fn finish_discovery_success(
&mut self,
option_count: usize,
ctx: &mut ModelContext<Self>,
) {
self.discovery = AcpDiscoveryState::Succeeded { option_count };
ctx.emit(AcpRuntimeModelEvent::DiscoveryChanged);
}
pub(crate) fn finish_discovery_failure(
&mut self,
message: String,
ctx: &mut ModelContext<Self>,
) {
self.discovery = AcpDiscoveryState::Failed { message };
ctx.emit(AcpRuntimeModelEvent::DiscoveryChanged);
}
pub(crate) fn manager(
@@ -37,35 +86,72 @@ impl AcpRuntimeModel {
Ok(manager)
}
pub(crate) fn discovery_config(settings: &AISettings) -> Result<AcpManagerConfig, String> {
let agent_id = if settings.acp_agent_id.value().trim().is_empty() {
"codex"
} else {
settings.acp_agent_id.value().trim()
};
let launch = crate::ai::acp::resolve_acp_launch(
agent_id,
settings.acp_agent_command.value(),
settings.acp_agent_args.value(),
)?;
Ok(AcpManagerConfig::new(launch))
}
pub(crate) fn normalize_config_options(
options: Vec<galaxy_acp::SessionConfigOption>,
) -> Vec<AcpConfigOptionSettings> {
options
.into_iter()
.map(|option| {
let kind = match &option.kind {
galaxy_acp::SessionConfigOptionType::Select => "select",
galaxy_acp::SessionConfigOptionType::Boolean => "boolean",
}
.to_owned();
let current_value = serde_json::to_value(&option.current_value).unwrap_or_default();
let values = option
.options
.into_iter()
.map(|value| AcpConfigValueSettings {
value: serde_json::to_value(value.value).unwrap_or_default(),
name: value.name,
description: value.description,
})
.collect();
let (kind, current_value, values) = match option.kind {
galaxy_acp::SessionConfigOptionType::Select(select) => {
let values = match select.options {
galaxy_acp::SessionConfigSelectOptions::Ungrouped(values) => values,
galaxy_acp::SessionConfigSelectOptions::Grouped(groups) => {
groups.into_iter().flat_map(|group| group.options).collect()
}
_ => Vec::new(),
};
(
"select",
serde_json::Value::String(select.current_value.to_string()),
values
.into_iter()
.map(|value| AcpConfigValueSettings {
value: serde_json::Value::String(value.value.to_string()),
name: value.name,
description: value.description,
})
.collect(),
)
}
galaxy_acp::SessionConfigOptionType::Boolean(boolean) => (
"boolean",
serde_json::Value::Bool(boolean.current_value),
Vec::new(),
),
_ => ("unknown", serde_json::Value::Null, Vec::new()),
};
AcpConfigOptionSettings {
id: option.id,
id: option.id.to_string(),
name: option.name,
description: option.description,
category: option
.category
.map(|category| format!("{category:?}").to_lowercase()),
kind,
category: option.category.map(|category| match category {
galaxy_acp::SessionConfigOptionCategory::Mode => "mode".to_owned(),
galaxy_acp::SessionConfigOptionCategory::Model => "model".to_owned(),
galaxy_acp::SessionConfigOptionCategory::ModelConfig => {
"model_config".to_owned()
}
galaxy_acp::SessionConfigOptionCategory::ThoughtLevel => {
"thought_level".to_owned()
}
galaxy_acp::SessionConfigOptionCategory::Other(value) => value,
_ => "unknown".to_owned(),
}),
kind: kind.to_owned(),
current_value,
options: values,
}
@@ -73,10 +159,33 @@ impl AcpRuntimeModel {
.collect()
}
pub(crate) fn current_config_values(
options: &[AcpConfigOptionSettings],
) -> BTreeMap<String, serde_json::Value> {
options
.iter()
.filter_map(|option| {
(!option.current_value.is_null())
.then(|| (option.id.clone(), option.current_value.clone()))
})
.collect()
}
pub(crate) fn selection_values_for_model(
options: &[AcpConfigOptionSettings],
model_option_id: &str,
model_value: &serde_json::Value,
) -> BTreeMap<String, serde_json::Value> {
let mut values = Self::current_config_values(options);
values.insert(model_option_id.to_owned(), model_value.clone());
values
}
pub(crate) fn upsert_agent_settings(
settings: &mut AISettings,
agent_id: &str,
options: Vec<galaxy_acp::SessionConfigOption>,
ctx: &mut ModelContext<AISettings>,
) -> Result<(), String> {
let config_options = Self::normalize_config_options(options);
let mut agents = settings.acp_agents.value().clone();
@@ -94,16 +203,195 @@ impl AcpRuntimeModel {
icon_url: None,
capabilities: Vec::new(),
config_options,
discovery_timestamp: None,
discovery_source: None,
discovery_error: None,
});
}
settings
.acp_agents
.set_value(agents, &mut settings.context())
.set_value(agents, ctx)
.map_err(|error| error.to_string())
}
pub(crate) fn persist_runtime_options(
settings: &mut AISettings,
agent_id: &str,
options: &[galaxy_acp::SessionConfigOption],
ctx: &mut ModelContext<AISettings>,
) -> Result<(), String> {
Self::upsert_agent_settings(settings, agent_id, options.to_vec(), ctx)
}
pub(crate) fn mark_discovery_success(
settings: &mut AISettings,
agent_id: &str,
option_count: usize,
ctx: &mut ModelContext<AISettings>,
) -> Result<(), String> {
let mut agents = settings.acp_agents.value().clone();
if let Some(agent) = agents
.iter_mut()
.find(|agent| agent.id.eq_ignore_ascii_case(agent_id))
{
agent.discovery_timestamp = Some(Utc::now().to_rfc3339());
agent.discovery_source = Some(format!("runtime:{option_count}"));
agent.discovery_error = None;
}
settings
.acp_agents
.set_value(agents, ctx)
.map_err(|error| error.to_string())
}
pub(crate) fn mark_discovery_failure(
settings: &mut AISettings,
agent_id: &str,
error: String,
ctx: &mut ModelContext<AISettings>,
) -> Result<(), String> {
let mut agents = settings.acp_agents.value().clone();
if let Some(agent) = agents
.iter_mut()
.find(|agent| agent.id.eq_ignore_ascii_case(agent_id))
{
agent.discovery_timestamp = Some(Utc::now().to_rfc3339());
agent.discovery_error = Some(error);
}
settings
.acp_agents
.set_value(agents, ctx)
.map_err(|error| error.to_string())
}
}
impl Entity for AcpRuntimeModel {
type Event = ();
type Event = AcpRuntimeModelEvent;
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum AcpRuntimeModelEvent {
DiscoveryChanged,
}
impl SingletonEntity for AcpRuntimeModel {}
#[cfg(test)]
mod tests {
use galaxy_acp::{SessionConfigOption, SessionConfigOptionCategory, SessionConfigSelectOption};
use super::{AcpDiscoveryState, AcpRuntimeModel};
use crate::settings::{AcpAgentSettings, AcpConfigOptionSettings};
#[test]
fn normalize_config_options_preserves_values_and_order() {
let options = vec![
SessionConfigOption::select(
"model",
"Model",
"fast",
vec![
SessionConfigSelectOption::new("fast", "Fast"),
SessionConfigSelectOption::new("accurate", "Accurate"),
],
)
.category(SessionConfigOptionCategory::Model),
SessionConfigOption::boolean("thinking", "Thinking", true),
];
let normalized = AcpRuntimeModel::normalize_config_options(options);
assert_eq!(normalized.len(), 2);
assert_eq!(normalized[0].kind, "select");
assert_eq!(normalized[0].current_value, serde_json::json!("fast"));
assert_eq!(normalized[0].options[0].value, serde_json::json!("fast"));
assert_eq!(
normalized[0].options[1].value,
serde_json::json!("accurate")
);
assert_eq!(normalized[1].kind, "boolean");
assert_eq!(normalized[1].current_value, serde_json::json!(true));
}
#[test]
fn normalize_config_options_preserves_unknown_categories() {
let option = SessionConfigOption::select(
"custom",
"Custom",
"one",
Vec::<SessionConfigSelectOption>::new(),
)
.category(SessionConfigOptionCategory::Other("_custom".to_owned()));
let normalized = AcpRuntimeModel::normalize_config_options(vec![option]);
assert_eq!(normalized[0].category.as_deref(), Some("_custom"));
}
#[test]
fn selection_replaces_model_and_preserves_other_current_values() {
let options = vec![
AcpConfigOptionSettings {
id: "model".to_owned(),
name: "Model".to_owned(),
description: None,
category: Some("model".to_owned()),
kind: "select".to_owned(),
current_value: serde_json::json!("fast"),
options: Vec::new(),
},
AcpConfigOptionSettings {
id: "thinking".to_owned(),
name: "Thinking".to_owned(),
description: None,
category: None,
kind: "boolean".to_owned(),
current_value: serde_json::json!(true),
options: Vec::new(),
},
];
assert_eq!(
AcpRuntimeModel::selection_values_for_model(
&options,
"model",
&serde_json::json!("accurate")
),
std::collections::BTreeMap::from([
("model".to_owned(), serde_json::json!("accurate")),
("thinking".to_owned(), serde_json::json!(true)),
])
);
}
#[test]
fn discovery_state_has_explicit_running_success_and_failure_values() {
assert_eq!(AcpDiscoveryState::default(), AcpDiscoveryState::Idle);
assert_eq!(
AcpDiscoveryState::Succeeded { option_count: 2 },
AcpDiscoveryState::Succeeded { option_count: 2 }
);
assert_eq!(
AcpDiscoveryState::Failed {
message: "timeout".to_owned()
},
AcpDiscoveryState::Failed {
message: "timeout".to_owned()
}
);
}
#[test]
fn discovery_metadata_fields_are_optional_for_legacy_settings() {
let json = serde_json::json!({
"id": "codex",
"name": "Codex",
"config_options": []
});
let agent: AcpAgentSettings = serde_json::from_value(json).unwrap();
assert!(agent.discovery_timestamp.is_none());
assert!(agent.discovery_source.is_none());
assert!(agent.discovery_error.is_none());
}
}
+3 -2
View File
@@ -12,7 +12,7 @@ use galaxy_acp::{
SessionConfigOptionValue, SessionId, TextContent,
};
use super::launch::acp_selection_model_id;
use super::launch::acp_selection_identity;
use super::prompt::{prompt_content, GalaxyTerminalTools};
use super::response_translator::AcpResponseTranslator;
use crate::ai::agent::api::{self, RequestParams};
@@ -139,6 +139,7 @@ pub(crate) async fn acp_output_stream(
mcp_servers,
auto_approve_permissions,
permission_policy,
prompt_capabilities: Default::default(),
};
let (session, events) = match manager.run_turn(request) {
Ok(turn) => turn,
@@ -288,7 +289,7 @@ fn response_translator(
task_id,
params.tasks.is_empty(),
user_query,
acp_selection_model_id(&backend.agent_id, &backend.config_values),
acp_selection_identity(&backend.agent_id, &backend.config_values),
)
}
+3
View File
@@ -222,6 +222,7 @@ fn acp_session_id_is_updated_only_for_acp_conversations() {
agent_id: "codex-acp".to_string(),
launch_fingerprint: "launch-123".to_string(),
session_id: None,
config_values: Default::default(),
}),
);
assert!(acp_conversation.set_acp_session_id("session-123"));
@@ -231,6 +232,7 @@ fn acp_session_id_is_updated_only_for_acp_conversations() {
agent_id: "codex-acp".to_string(),
launch_fingerprint: "launch-123".to_string(),
session_id: Some("session-123".to_string()),
config_values: Default::default(),
})
);
@@ -248,6 +250,7 @@ fn restored_conversation_uses_persisted_acp_backend() {
agent_id: "codex-acp".to_string(),
launch_fingerprint: "launch-123".to_string(),
session_id: Some("session-123".to_string()),
config_values: Default::default(),
});
let conversation = restored_conversation(Some(AgentConversationData {
agent_backend: backend.clone(),
+39 -6
View File
@@ -274,7 +274,7 @@ fn acp_backend_model_id(backend: &AgentBackend) -> Option<LLMId> {
match backend {
AgentBackend::Provider => None,
AgentBackend::Acp(acp) => {
Some(format!("acp:{}", acp.agent_id.trim().to_ascii_lowercase()).into())
Some(crate::ai::acp::acp_selection_identity(&acp.agent_id, &acp.config_values).into())
}
}
}
@@ -3826,14 +3826,47 @@ impl BlocklistAIController {
};
let history_model = BlocklistAIHistoryModel::handle(ctx);
let Some(conversation) = history_model.as_ref(ctx).conversation(&conversation_id)
let Some((agent_id, new_exchange_ids, exchanges)) = history_model
.as_ref(ctx)
.conversation(&conversation_id)
.map(|conversation| {
(
match conversation.agent_backend() {
AgentBackend::Acp(acp) => Some(acp.agent_id.clone()),
AgentBackend::Provider => None,
},
conversation
.new_exchange_ids_for_response(&stream_id)
.collect::<Vec<_>>(),
conversation.clone(),
)
})
else {
log::warn!("Conversation not found.");
return;
};
let new_exchange_ids: Vec<_> = conversation
.new_exchange_ids_for_response(&stream_id)
.collect();
#[cfg(not(target_family = "wasm"))]
if let Some(metadata) = response_stream.as_ref(ctx).acp_session_metadata() {
if !metadata.config_options.is_empty() {
if let Some(agent_id) = &agent_id {
crate::settings::AISettings::handle(ctx).update(
ctx,
|settings, ctx| {
if let Err(error) =
crate::ai::acp::AcpRuntimeModel::persist_runtime_options(
settings,
agent_id,
&metadata.config_options,
ctx,
)
{
log::warn!("Failed to persist ACP runtime config: {error}");
}
},
);
}
}
}
log::info!(
"[bedrock-debug] AfterStreamFinished: stream_id={:?}, conversation_id={:?}, new_exchange_ids count={}",
stream_id, conversation_id, new_exchange_ids.len()
@@ -3843,7 +3876,7 @@ impl BlocklistAIController {
let mut actions_to_queue = vec![];
for new_exchange_id in new_exchange_ids {
let Some(exchange) = conversation.exchange_with_id(new_exchange_id) else {
let Some(exchange) = exchanges.exchange_with_id(new_exchange_id) else {
log::warn!("Exchange not found.");
return;
};
+5 -1
View File
@@ -67,8 +67,12 @@ fn acp_backend_model_identity_does_not_claim_a_provider_model() {
agent_id: " Codex ".to_owned(),
launch_fingerprint: "launch-123".to_owned(),
session_id: None,
config_values: std::collections::BTreeMap::from([(
"model".to_owned(),
serde_json::json!("fast"),
)]),
})),
Some(LLMId::from("acp:codex"))
Some(LLMId::from("acp:codex:model=\"fast\""))
);
}
+12 -9
View File
@@ -39,6 +39,8 @@ use crate::ai::agent::{
};
use crate::ai::artifacts::Artifact;
use crate::ai::document::ai_document_model::AIDocumentModel;
#[cfg(not(target_family = "wasm"))]
use crate::ai::llms::LLMPreferences;
use crate::input_suggestions::HistoryOrder;
use crate::persistence::model::{
AcpConversationData, AgentBackend, AgentConversation, AgentConversationData,
@@ -1225,15 +1227,16 @@ impl BlocklistAIHistoryModel {
.value()
.iter()
.find(|agent| agent.id.eq_ignore_ascii_case(agent_id))
.and_then(|agent| {
agent
.config_options
.iter()
.find(|option| option.category.as_deref() == Some("model"))
})
.map(|option| {
std::iter::once((option.id.clone(), option.current_value.clone()))
.collect()
.map(|agent| {
#[cfg(not(target_family = "wasm"))]
if let Some(selection) = LLMPreferences::as_ref(ctx)
.selected_acp_config_for_agent(&agent.name, ctx)
{
return selection;
}
crate::ai::acp::AcpRuntimeModel::current_config_values(
&agent.config_options,
)
})
.unwrap_or_default(),
})
@@ -81,6 +81,7 @@ fn acp_enabled_with_empty_command_selects_codex_backend() {
agent_id: "codex".to_string(),
launch_fingerprint: crate::ai::acp::acp_launch_fingerprint("codex", "", &[]),
session_id: None,
config_values: Default::default(),
})
);
});
+74 -26
View File
@@ -1,6 +1,6 @@
#![allow(dead_code)]
use std::collections::{HashMap, HashSet};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::sync::{Arc, OnceLock};
use ai::api_keys::ApiKeyManager;
@@ -16,6 +16,7 @@ use warp_multi_agent_api as api;
use super::custom_model_routers::{self, CustomModelRouter, ModelConfigError};
use super::execution_profiles::profiles::AIExecutionProfilesModel;
use crate::ai::acp::acp_selection_identity;
use crate::ai::bedrock::models::get_effective_models;
use crate::auth::auth_manager::{AuthManager, AuthManagerEvent};
use crate::auth::AuthStateProvider;
@@ -576,6 +577,8 @@ pub struct LLMPreferences {
/// Used as a short-lived fallback while the fetched list is persisted to settings.
#[cfg(not(target_family = "wasm"))]
fetched_openai_models: Vec<OpenAIModelConfig>,
#[cfg(not(target_family = "wasm"))]
acp_selections: HashMap<LLMId, BTreeMap<String, serde_json::Value>>,
}
impl LLMPreferences {
@@ -640,6 +643,8 @@ impl LLMPreferences {
| AISettingsChangedEvent::OpenAIApiKey { .. }
| AISettingsChangedEvent::OpenAIModels { .. }
| AISettingsChangedEvent::OpenAIProviders { .. }
| AISettingsChangedEvent::AcpAgents { .. }
| AISettingsChangedEvent::AcpAgentId { .. }
) {
me.inject_bedrock_models(ctx);
me.inject_openai_models(ctx);
@@ -671,6 +676,8 @@ impl LLMPreferences {
openai_provider_routing: HashMap::new(),
#[cfg(not(target_family = "wasm"))]
fetched_openai_models: Vec::new(),
#[cfg(not(target_family = "wasm"))]
acp_selections: HashMap::new(),
};
// Seed from any already-loaded local config (the async load emits
@@ -1037,56 +1044,97 @@ impl LLMPreferences {
#[cfg(not(target_family = "wasm"))]
fn inject_acp_models(&mut self, ctx: &AppContext) {
self.acp_selections.clear();
let settings = AISettings::as_ref(ctx);
for agent in settings.acp_agents.value() {
let model_option = agent
.config_options
.iter()
.find(|option| option.category.as_deref() == Some("model"));
let Some(model_option) = model_option else { continue };
let Some(model_option) = model_option else {
continue;
};
let secondary = agent.config_options.iter().filter(|option| {
matches!(option.category.as_deref(), Some("mode") | Some("thought_level"))
matches!(
option.category.as_deref(),
Some("mode") | Some("thought_level")
)
});
for value in &model_option.options {
let suffix = secondary
.clone()
.filter_map(|option| option.options.first().map(|v| v.name.clone()))
.filter_map(|option| {
option
.options
.iter()
.find(|value| value.value == option.current_value)
.or_else(|| option.options.first())
.map(|value| value.name.clone())
})
.collect::<Vec<_>>();
let display_name = if suffix.is_empty() {
value.name.clone()
} else {
format!("{} ({})", value.name, suffix.join(", "))
};
let id = format!("acp:{}:{}={}", agent.id, model_option.id, value.value);
let mut selection =
crate::ai::acp::AcpRuntimeModel::current_config_values(&agent.config_options);
selection.insert(model_option.id.clone(), value.value.clone());
let id = acp_selection_identity(&agent.id, &selection);
let llm_id = LLMId::from(id.as_str());
self.acp_selections.insert(llm_id.clone(), selection);
let info = LLMInfo {
id: LLMId::from(id.as_str()),
id: llm_id,
display_name,
base_model_name: value.name.clone(),
reasoning_level: None,
usage_metadata: LLMUsageMetadata {
request_multiplier: 1,
credit_multiplier: None,
},
description: Some(agent.name.clone()),
disable_reason: None,
vision_supported: false,
spec: None,
provider: LLMProvider::Unknown,
host_configs: HashMap::new(),
discount_percentage: None,
context_window: LLMContextWindow::default(),
};
self.models_by_feature.agent_mode.choices.push(info.clone());
self.models_by_feature.coding.choices.push(info.clone());
if let Some(ref mut cli) = self.models_by_feature.cli_agent {
cli.choices.push(info);
}
reasoning_level: None,
usage_metadata: LLMUsageMetadata {
request_multiplier: 1,
credit_multiplier: None,
},
description: Some(agent.name.clone()),
disable_reason: None,
vision_supported: false,
spec: None,
provider: LLMProvider::Unknown,
host_configs: HashMap::new(),
discount_percentage: None,
context_window: LLMContextWindow::default(),
};
self.models_by_feature.agent_mode.choices.push(info.clone());
self.models_by_feature.coding.choices.push(info.clone());
if let Some(ref mut cli) = self.models_by_feature.cli_agent {
cli.choices.push(info);
}
}
}
}
#[cfg(not(target_family = "wasm"))]
pub fn acp_selection_for_model(
&self,
model_id: &LLMId,
) -> Option<&BTreeMap<String, serde_json::Value>> {
self.acp_selections.get(model_id)
}
#[cfg(not(target_family = "wasm"))]
pub fn selected_acp_config_for_agent(
&self,
agent_name: &str,
ctx: &AppContext,
) -> Option<BTreeMap<String, serde_json::Value>> {
let profile = AIExecutionProfilesModel::as_ref(ctx).active_profile(None, ctx);
let model_id = profile.data().base_model.as_ref()?;
let model = self.models_by_feature.agent_mode.info_for_id(model_id)?;
model
.description
.as_deref()
.is_some_and(|name| name.eq_ignore_ascii_case(agent_name))
.then(|| self.acp_selections.get(model_id).cloned())
.flatten()
}
/// Ensures the default model ID in each feature's choices still points to
/// an existing entry. If the default was removed (e.g. provider disabled),
/// switch to the first remaining choice.