Document process monitoring handoff

Add ACP discovery and configuration support
This commit is contained in:
Ryan Ward
2026-07-30 11:53:33 -05:00
parent ad24374f6d
commit 1a0aac51b6
18 changed files with 1280 additions and 664 deletions
+16
View File
@@ -7,6 +7,22 @@ pub(crate) fn acp_model_id(agent_id: &str) -> String {
format!("acp:{}", agent_id.trim().to_ascii_lowercase())
}
pub(crate) fn acp_selection_model_id(
agent_id: &str,
values: &std::collections::BTreeMap<String, serde_json::Value>,
) -> String {
let suffix = values
.iter()
.map(|(key, value)| format!("{key}={value}"))
.collect::<Vec<_>>()
.join(";");
if suffix.is_empty() {
acp_model_id(agent_id)
} else {
format!("{}:{suffix}", acp_model_id(agent_id))
}
}
pub(crate) fn acp_launch_fingerprint(
agent_id: &str,
custom_command: &str,
+2 -2
View File
@@ -12,8 +12,8 @@ mod runtime_model;
mod transport;
pub(crate) use launch::{
acp_launch_fingerprint, acp_model_id, resolve_acp_launch, validate_acp_dispatch,
validate_acp_launch_identity,
acp_launch_fingerprint, acp_model_id, 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;
+67
View File
@@ -1,3 +1,6 @@
use crate::settings::{
AISettings, AcpAgentSettings, AcpConfigOptionSettings, AcpConfigValueSettings,
};
use galaxy_acp::{AcpLaunchConfig, AcpManagerConfig, AcpSessionManager};
use galaxyui::{Entity, ModelContext, SingletonEntity};
@@ -33,6 +36,70 @@ impl AcpRuntimeModel {
});
Ok(manager)
}
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();
AcpConfigOptionSettings {
id: option.id,
name: option.name,
description: option.description,
category: option
.category
.map(|category| format!("{category:?}").to_lowercase()),
kind,
current_value,
options: values,
}
})
.collect()
}
pub(crate) fn upsert_agent_settings(
settings: &mut AISettings,
agent_id: &str,
options: Vec<galaxy_acp::SessionConfigOption>,
) -> Result<(), String> {
let config_options = Self::normalize_config_options(options);
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.config_options = config_options;
} else {
agents.push(AcpAgentSettings {
id: agent_id.to_owned(),
name: agent_id.to_owned(),
version: None,
description: None,
icon_url: None,
capabilities: Vec::new(),
config_options,
});
}
settings
.acp_agents
.set_value(agents, &mut settings.context())
}
}
impl Entity for AcpRuntimeModel {
+20 -4
View File
@@ -8,11 +8,11 @@ use futures::stream::FusedStream as _;
use futures::{FutureExt as _, StreamExt as _};
use galaxy_acp::{
AcpEvent, AcpPermissionPolicy, AcpRuntimeError, AcpSessionHandle, AcpSessionManager,
AcpSteeringOutcome, AcpTurnRequest, ContentBlock, McpServer, McpServerStdio, SessionId,
TextContent,
AcpSteeringOutcome, AcpTurnRequest, ContentBlock, McpServer, McpServerStdio,
SessionConfigOptionValue, SessionId, TextContent,
};
use super::launch::acp_model_id;
use super::launch::acp_selection_model_id;
use super::prompt::{prompt_content, GalaxyTerminalTools};
use super::response_translator::AcpResponseTranslator;
use crate::ai::agent::api::{self, RequestParams};
@@ -25,6 +25,7 @@ pub(crate) struct AcpSessionMetadata {
pub(crate) session_id: Option<String>,
pub(crate) can_load: bool,
pub(crate) can_steer: bool,
pub(crate) config_options: Vec<galaxy_acp::SessionConfigOption>,
}
#[derive(Clone, Debug)]
@@ -121,6 +122,15 @@ pub(crate) async fn acp_output_stream(
mcp_servers.push(server);
}
let request = AcpTurnRequest {
config_values: backend
.config_values
.into_iter()
.filter_map(|(key, value)| {
serde_json::from_value::<SessionConfigOptionValue>(value)
.ok()
.map(|value| (key, value))
})
.collect(),
conversation_key: conversation_id,
session_id: backend.session_id.map(SessionId::from),
cwd,
@@ -222,6 +232,7 @@ pub(crate) async fn acp_output_stream(
session_id,
can_load,
can_steer,
..
} = &event
{
if let Ok(mut metadata) = session_metadata.lock() {
@@ -230,6 +241,11 @@ pub(crate) async fn acp_output_stream(
metadata.can_steer = *can_steer;
}
}
if let AcpEvent::ConfigOptions { options } = &event {
if let Ok(mut metadata) = session_metadata.lock() {
metadata.config_options = options.clone();
}
}
match translator.translate(event) {
Ok(response_events) => {
for response_event in response_events {
@@ -272,7 +288,7 @@ fn response_translator(
task_id,
params.tasks.is_empty(),
user_query,
acp_model_id(&backend.agent_id),
acp_selection_model_id(&backend.agent_id, &backend.config_values),
)
}
+16
View File
@@ -1220,6 +1220,22 @@ impl BlocklistAIHistoryModel {
agent_id: agent_id.to_string(),
launch_fingerprint,
session_id: None,
config_values: settings
.acp_agents
.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()
})
.unwrap_or_default(),
})
} else {
AgentBackend::Provider
+54 -1
View File
@@ -21,7 +21,7 @@ use crate::auth::auth_manager::{AuthManager, AuthManagerEvent};
use crate::auth::AuthStateProvider;
use crate::network::{NetworkStatus, NetworkStatusEvent, NetworkStatusKind};
use crate::server::server_api::ServerApiProvider;
use crate::settings::{BedrockModelConfig, OpenAIModelConfig};
use crate::settings::{AcpAgentSettings, BedrockModelConfig, OpenAIModelConfig};
use crate::user_config::{WarpConfig, WarpConfigUpdateEvent};
use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent};
use crate::{report_error, AISettings};
@@ -922,6 +922,7 @@ impl LLMPreferences {
self.openai_provider_routing.clear();
let settings = AISettings::as_ref(ctx);
self.inject_acp_models(ctx);
if !*settings.openai_enabled.value() {
return;
}
@@ -1034,6 +1035,58 @@ impl LLMPreferences {
log::info!("[openai/litellm] Injected {total_injected} model(s) into available choices");
}
#[cfg(not(target_family = "wasm"))]
fn inject_acp_models(&mut self, ctx: &AppContext) {
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 secondary = agent.config_options.iter().filter(|option| {
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()))
.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 info = LLMInfo {
id: LLMId::from(id.as_str()),
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);
}
}
}
}
}
/// 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.
+55
View File
@@ -901,6 +901,50 @@ pub struct OpenAIProviderConfig {
impl settings_value::SettingsValue for OpenAIProviderConfig {}
/// Cached metadata and runtime session options for an ACP agent.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
pub struct AcpAgentSettings {
pub id: String,
pub name: String,
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub icon_url: Option<String>,
#[serde(default)]
pub capabilities: Vec<String>,
#[serde(default)]
pub config_options: Vec<AcpConfigOptionSettings>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
pub struct AcpConfigOptionSettings {
pub id: String,
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub category: Option<String>,
pub kind: String,
#[serde(default)]
pub current_value: serde_json::Value,
#[serde(default)]
pub options: Vec<AcpConfigValueSettings>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
pub struct AcpConfigValueSettings {
pub value: serde_json::Value,
pub name: String,
#[serde(default)]
pub description: Option<String>,
}
impl settings_value::SettingsValue for AcpAgentSettings {}
// Nested ACP discovery data is intentionally persisted as one setting so refreshes are atomic.
define_settings_group!(AISettings, settings: [
// If `false`, all AI features are disabled.
is_any_ai_enabled: IsAnyAIEnabled {
@@ -1270,6 +1314,17 @@ define_settings_group!(AISettings, settings: [
description: "Arguments passed to the local Agent Client Protocol agent executable.",
feature_flag: FeatureFlag::AgentClientProtocol,
}
// Cached ACP registry and runtime discovery data. Values are refreshed when the agent is queried.
acp_agents: AcpAgents {
type: Vec<AcpAgentSettings>,
default: Vec::new(),
supported_platforms: SupportedPlatforms::OR(SupportedPlatforms::MAC.into(), SupportedPlatforms::LINUX.into()),
sync_to_cloud: SyncToCloud::Never,
private: false,
toml_path: "ai.acp.agents",
description: "Cached ACP agent metadata, capabilities, and session configuration options.",
feature_flag: FeatureFlag::AgentClientProtocol,
}
// Whether to use locally loaded AWS credentials for Bedrock-enabled requests.
bedrock_enabled: BedrockEnabled {
type: bool,
+11
View File
@@ -7824,6 +7824,17 @@ impl SettingsWidget for ACPSettingsWidget {
is_enabled,
app,
));
let discovered = settings.acp_agents.value();
if let Some(agent) = discovered
.iter()
.find(|agent| agent.id.eq_ignore_ascii_case(settings.acp_agent_id.value()))
{
column.add_child(render_ai_setting_description(
&format!("Discovered {} ACP configuration option(s) for {}. Options are refreshed from the running agent and cached in settings.toml.", agent.config_options.len(), agent.name),
is_enabled,
app,
));
}
column.finish()
}
}