Add ACP agent backend and terminal controls

This commit is contained in:
2026-07-30 07:25:11 -05:00
parent dbfa8bcd48
commit ad24374f6d
84 changed files with 12151 additions and 157 deletions
@@ -7,6 +7,7 @@ use super::response_stream::{ResponseStream, ResponseStreamId};
use super::BlocklistAIController;
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::CancellationReason;
use crate::ai::llms::LLMId;
use crate::BlocklistAIHistoryModel;
pub(super) struct PendingResponseStreams {
@@ -51,6 +52,29 @@ impl PendingResponseStreams {
.collect()
}
/// Attempts to inject a plain-text follow-up into the active ACP turn.
///
/// Returning `None` leaves the caller free to use the normal
/// cancel-and-queue path without dropping the user's message.
pub fn try_steer_acp_stream_for_conversation(
&self,
conversation_id: AIConversationId,
display_text: String,
app: &AppContext,
) -> Option<(ResponseStreamId, LLMId)> {
let history_model = BlocklistAIHistoryModel::as_ref(app);
let conversation = history_model.conversation(&conversation_id)?;
let (stream_id, stream) = self
.streams
.iter()
.find(|(stream_id, _)| conversation.is_processing_response_stream(stream_id))?;
let model_id = stream.as_ref(app).llm_id().clone();
stream
.as_ref(app)
.try_steer_acp(display_text)
.then(|| (stream_id.clone(), model_id))
}
pub fn register_new_stream(
&mut self,
stream_id: ResponseStreamId,
@@ -3,25 +3,46 @@
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::Arc;
#[cfg(not(target_family = "wasm"))]
use std::sync::Mutex;
#[cfg(not(target_family = "wasm"))]
use ::local_control::remote_command::is_potential_remote_ssh_command;
use anyhow::anyhow;
use chrono::{DateTime, Local, TimeDelta};
use futures::channel::oneshot;
#[cfg(not(target_family = "wasm"))]
use galaxy_core::features::FeatureFlag;
use galaxyui::{Entity, ModelContext, SingletonEntity};
use settings::Setting;
use uuid::Uuid;
use warp_multi_agent_api::response_event;
#[cfg(not(target_family = "wasm"))]
use crate::ai::acp::{
acp_output_stream, acp_startup_error_stream, galaxy_mcp_server, resolve_acp_launch,
resolve_acp_permissions, validate_acp_dispatch, validate_acp_launch_identity, AcpRuntimeModel,
AcpSessionHandleSlot, AcpSessionMetadata, AcpSteeringRequest, GalaxyMcpTarget,
};
use crate::ai::agent::api::{self, generate_multi_agent_output, ConvertToAPITypeError};
use crate::ai::agent::conversation::AIConversationId;
#[cfg(not(target_family = "wasm"))]
use crate::ai::agent::AIAgentInput;
use crate::ai::agent::{AIIdentifiers, CancellationReason};
use crate::ai::bedrock::client::BedrockClientConfig;
use crate::ai::llms::LLMPreferences;
#[cfg(not(target_family = "wasm"))]
use crate::ai::blocklist::BlocklistAIPermissions;
use crate::ai::llms::{LLMId, LLMPreferences};
use crate::ai::openai::client::OpenAIClientConfig;
use crate::ai::provider::ProviderConfig;
use crate::network::NetworkStatus;
#[cfg(not(target_family = "wasm"))]
use crate::pane_group::PaneGroup;
use crate::persistence::model::AgentBackend;
use crate::server::server_api::AIApiError;
use crate::{report_error, send_telemetry_from_ctx, AISettings};
#[cfg(not(target_family = "wasm"))]
use crate::settings::LocalControlSettings;
use crate::{report_error, send_telemetry_from_ctx, AISettings, BlocklistAIHistoryModel};
/// Maximum number of times a single MAA request is re-sent before the failure is
/// surfaced.
@@ -82,6 +103,14 @@ impl ResponseStreamId {
}
}
#[cfg(not(target_family = "wasm"))]
struct AcpRequestControl {
cancellation_rx: oneshot::Receiver<()>,
session_metadata: Arc<Mutex<AcpSessionMetadata>>,
session_handle: AcpSessionHandleSlot,
steering_rx: async_channel::Receiver<AcpSteeringRequest>,
}
/// Model wrapping an agent API response stream.
///
/// Emits events when the output corresponding to the stream is updated, typically after receiving
@@ -91,6 +120,13 @@ impl ResponseStreamId {
/// received yet, ensuring we don't retry after the AI has started executing actions.
pub struct ResponseStream {
id: ResponseStreamId,
agent_backend: AgentBackend,
#[cfg(not(target_family = "wasm"))]
acp_session_metadata: Arc<Mutex<AcpSessionMetadata>>,
#[cfg(not(target_family = "wasm"))]
acp_session_handle: AcpSessionHandleSlot,
#[cfg(not(target_family = "wasm"))]
acp_steering_tx: async_channel::Sender<AcpSteeringRequest>,
params: api::RequestParams,
retry_count: usize,
/// One-time fallback from the profile's thinking model to its coding model.
@@ -157,6 +193,13 @@ impl ResponseStream {
let (cancellation_tx, _rx) = oneshot::channel();
Self {
id,
agent_backend: AgentBackend::Provider,
#[cfg(not(target_family = "wasm"))]
acp_session_metadata: Arc::new(Mutex::new(AcpSessionMetadata::default())),
#[cfg(not(target_family = "wasm"))]
acp_session_handle: Arc::new(Mutex::new(None)),
#[cfg(not(target_family = "wasm"))]
acp_steering_tx: async_channel::unbounded().0,
params: api::RequestParams::new_for_test(),
retry_count: 0,
coding_model_fallback_attempted: false,
@@ -223,9 +266,155 @@ impl ResponseStream {
ProviderConfig::None
}
#[cfg(not(target_family = "wasm"))]
fn resolve_acp_manager(
backend: &crate::persistence::model::AcpConversationData,
ctx: &mut ModelContext<Self>,
) -> Result<galaxy_acp::AcpSessionManager, String> {
use galaxy_acp::AcpManagerConfig;
let settings = AISettings::as_ref(ctx);
let configured_agent_id = settings.acp_agent_id.value().trim();
let configured_agent_id = if configured_agent_id.is_empty() {
"codex"
} else {
configured_agent_id
};
let launch = resolve_acp_launch(
configured_agent_id,
settings.acp_agent_command.value(),
settings.acp_agent_args.value(),
)?;
validate_acp_launch_identity(
backend,
configured_agent_id,
settings.acp_agent_command.value(),
&launch,
)?;
let config = AcpManagerConfig::new(launch);
AcpRuntimeModel::handle(ctx).update(ctx, |runtime, _| runtime.manager(config))
}
#[cfg(not(target_family = "wasm"))]
fn spawn_acp_request(
backend: crate::persistence::model::AcpConversationData,
params: api::RequestParams,
conversation_key: String,
request_id: Uuid,
control: AcpRequestControl,
ctx: &mut ModelContext<Self>,
) {
let AcpRequestControl {
cancellation_rx,
session_metadata,
session_handle,
steering_rx,
} = control;
let profile = BlocklistAIPermissions::as_ref(ctx)
.active_permissions_profile(ctx, params.terminal_view_id);
let permissions = resolve_acp_permissions(&profile);
let targets_remote_terminal = params.session_context.is_remote()
|| params.input.iter().any(|input| {
let AIAgentInput::UserQuery {
running_command: Some(command),
..
} = input
else {
return false;
};
is_interactive_remote_command(&command.command)
});
let manager = validate_acp_dispatch(
FeatureFlag::AgentClientProtocol.is_enabled(),
*AISettings::as_ref(ctx).acp_enabled.value(),
targets_remote_terminal,
)
.and_then(|()| Self::resolve_acp_manager(&backend, ctx));
let galaxy_mcp_server = if cfg!(unix)
&& manager.is_ok()
&& permissions.expose_galaxy_tools
&& FeatureFlag::GalaxyControlCli.is_enabled()
&& LocalControlSettings::as_ref(ctx).is_enabled()
{
match params
.terminal_view_id
.and_then(|terminal_view_id| terminal_pane_id(terminal_view_id, ctx))
{
Some(target) => match galaxy_mcp_server(
&target,
permissions.allow_terminal_execute,
permissions.allow_terminal_interrupt,
) {
Ok(server) => Some(server),
Err(error) => {
log::warn!("Galaxy MCP tools are unavailable for ACP: {error}");
None
}
},
None => {
log::warn!(
"Galaxy MCP tools are unavailable for ACP because the originating terminal pane could not be resolved"
);
None
}
}
} else {
None
};
let galaxy_terminal_interrupt_available =
galaxy_mcp_server.is_some() && permissions.allow_terminal_interrupt;
let _ = ctx.spawn(
async move {
let stream = match manager {
Ok(manager) => {
acp_output_stream(
manager,
params,
conversation_key,
backend,
galaxy_mcp_server,
galaxy_terminal_interrupt_available,
permissions.policy,
permissions.auto_approve_protocol_requests,
session_metadata,
session_handle,
steering_rx,
cancellation_rx,
)
.await
}
Err(message) => acp_startup_error_stream(&params, &backend, &message),
};
Ok::<_, ConvertToAPITypeError>(stream)
},
move |me, stream, ctx| {
me.handle_response_stream_result(request_id, stream, ctx);
},
);
}
fn spawn_provider_request(
params: api::RequestParams,
provider_config: ProviderConfig,
request_id: Uuid,
cancellation_rx: oneshot::Receiver<()>,
ctx: &mut ModelContext<Self>,
) {
let _ =
ctx.spawn(
async move {
generate_multi_agent_output(provider_config, params, cancellation_rx).await
},
move |me, stream, ctx| {
me.handle_response_stream_result(request_id, stream, ctx);
},
);
}
pub fn new(
params: api::RequestParams,
ai_identifiers: AIIdentifiers,
agent_backend: AgentBackend,
can_attempt_resume_on_error: bool,
ctx: &mut ModelContext<Self>,
) -> Self {
@@ -233,18 +422,66 @@ impl ResponseStream {
let start_time = Local::now();
let request_id = Uuid::new_v4();
let provider_config = Self::resolve_provider_config(params.model.as_str(), ctx);
let params_clone = params.clone();
let _ = ctx.spawn(
async move {
generate_multi_agent_output(provider_config, params_clone, cancellation_rx).await
},
move |me, stream, ctx| {
me.handle_response_stream_result(request_id, stream, ctx);
},
);
#[cfg(not(target_family = "wasm"))]
let acp_session_metadata = Arc::new(Mutex::new(AcpSessionMetadata::default()));
#[cfg(not(target_family = "wasm"))]
let acp_session_handle = Arc::new(Mutex::new(None));
#[cfg(not(target_family = "wasm"))]
let (acp_steering_tx, acp_steering_rx) = async_channel::unbounded();
match &agent_backend {
AgentBackend::Provider => {
let provider_config = Self::resolve_provider_config(params.model.as_str(), ctx);
Self::spawn_provider_request(
params.clone(),
provider_config,
request_id,
cancellation_rx,
ctx,
);
}
AgentBackend::Acp(backend) => {
#[cfg(not(target_family = "wasm"))]
Self::spawn_acp_request(
backend.clone(),
params.clone(),
ai_identifiers
.client_conversation_id
.map(|id| format!("{id:?}"))
.unwrap_or_else(|| Uuid::new_v4().to_string()),
request_id,
AcpRequestControl {
cancellation_rx,
session_metadata: acp_session_metadata.clone(),
session_handle: acp_session_handle.clone(),
steering_rx: acp_steering_rx,
},
ctx,
);
#[cfg(target_family = "wasm")]
{
let error = Arc::new(AIApiError::Stream {
stream_type: "acp",
source: anyhow!("ACP is unavailable in the web client"),
});
let stream = Box::pin(futures::stream::once(async move { Err(error) }));
let _ = ctx.spawn(
async move { Ok::<_, ConvertToAPITypeError>(stream) },
move |me, stream, ctx| {
me.handle_response_stream_result(request_id, stream, ctx);
},
);
}
}
}
Self {
id: ResponseStreamId(Uuid::new_v4().to_string()),
agent_backend,
#[cfg(not(target_family = "wasm"))]
acp_session_metadata,
#[cfg(not(target_family = "wasm"))]
acp_session_handle,
#[cfg(not(target_family = "wasm"))]
acp_steering_tx,
params: params.clone(),
start_time,
time_to_latest_event: TimeDelta::seconds(0),
@@ -267,6 +504,50 @@ impl ResponseStream {
&self.id
}
pub fn is_acp(&self) -> bool {
matches!(self.agent_backend, AgentBackend::Acp(_))
}
#[cfg(not(target_family = "wasm"))]
pub(crate) fn acp_session_metadata(&self) -> Option<AcpSessionMetadata> {
self.is_acp()
.then(|| {
self.acp_session_metadata
.lock()
.ok()
.map(|state| state.clone())
})
.flatten()
}
pub(super) fn try_steer_acp(&self, display_text: String) -> bool {
#[cfg(not(target_family = "wasm"))]
{
if !self.is_acp()
|| self.current_request_id.is_none()
|| !self
.acp_session_metadata()
.is_some_and(|metadata| metadata.can_steer)
|| !self
.acp_session_handle
.lock()
.is_ok_and(|session| session.is_some())
{
return false;
}
let mut model_text = display_text.clone();
self.params.redact_text_for_model(&mut model_text);
self.acp_steering_tx
.try_send(AcpSteeringRequest::text(display_text, model_text))
.is_ok()
}
#[cfg(target_family = "wasm")]
{
let _ = display_text;
false
}
}
pub fn bedrock_messages_sent(
&self,
) -> &std::sync::Arc<std::sync::Mutex<Vec<crate::ai::bedrock::convert::ConversationMessage>>>
@@ -279,6 +560,10 @@ impl ResponseStream {
self.params.model.as_str()
}
pub(super) fn llm_id(&self) -> &LLMId {
&self.params.model
}
/// Returns true if we should attempt to resume the conversation after the stream finishes.
pub fn should_resume_conversation_after_stream_finished(&self) -> bool {
self.should_resume_conversation_after_stream_finished
@@ -334,7 +619,8 @@ impl ResponseStream {
&self,
error: &Arc<crate::server::server_api::AIApiError>,
) -> bool {
if self.coding_model_fallback_attempted || self.has_received_client_actions {
if self.is_acp() || self.coding_model_fallback_attempted || self.has_received_client_actions
{
return false;
}
let coding_model = self.params.coding_model.as_str();
@@ -507,7 +793,7 @@ impl ResponseStream {
let is_online = NetworkStatus::as_ref(ctx).is_online();
match recovery_action(
self.has_received_client_actions,
e.is_recoverable(),
e.is_recoverable() && !self.is_acp(),
self.retry_count < MAX_RETRIES,
self.can_attempt_resume_on_error,
is_online,
@@ -580,7 +866,7 @@ impl ResponseStream {
let is_online = NetworkStatus::as_ref(ctx).is_online();
match recovery_action(
self.has_received_client_actions,
unexpected_eof.is_recoverable(),
unexpected_eof.is_recoverable() && !self.is_acp(),
self.retry_count < MAX_RETRIES,
self.can_attempt_resume_on_error,
is_online,
@@ -694,6 +980,38 @@ impl ResponseStream {
}
}
#[cfg(not(target_family = "wasm"))]
fn is_interactive_remote_command(command: &str) -> bool {
is_potential_remote_ssh_command(command)
}
#[cfg(not(target_family = "wasm"))]
fn terminal_pane_id(
terminal_view_id: galaxyui::EntityId,
ctx: &ModelContext<ResponseStream>,
) -> Option<GalaxyMcpTarget> {
let window_ids = ctx.window_ids().collect::<Vec<_>>();
for window_id in window_ids {
let Some(pane_groups) = ctx.views_of_type::<PaneGroup>(window_id) else {
continue;
};
for pane_group in pane_groups {
let tab_id = pane_group.id().to_string();
if let Some(pane_id) = pane_group
.as_ref(ctx)
.find_pane_id_for_terminal_view(terminal_view_id, ctx)
{
return Some(GalaxyMcpTarget {
window_id: window_id.to_string(),
tab_id,
pane_id: pane_id.to_string(),
});
}
}
}
None
}
#[derive(Debug)]
pub struct Consumable<T> {
value: Rc<RefCell<Option<T>>>,
@@ -1,4 +1,4 @@
use super::{recovery_action, RecoveryAction};
use super::{is_interactive_remote_command, recovery_action, RecoveryAction};
// Argument order: has_received_client_actions, is_recoverable, has_retry_budget,
// can_attempt_resume_on_error, is_online.
@@ -82,3 +82,33 @@ fn non_recoverable_post_action_failure_is_terminal() {
RecoveryAction::Fail
);
}
#[test]
fn raw_interactive_ssh_is_treated_as_remote_for_acp() {
for command in [
"ssh user@example.com",
"command ssh -p 2222 user@example.com",
" /usr/bin/ssh user@example.com",
"GALAXY_TEST=1 ssh user@example.com",
"env GALAXY_TEST=1 ssh user@example.com",
"/usr/bin/env -- GALAXY_TEST=1 /usr/bin/ssh user@example.com",
"sudo -u root ssh user@example.com",
"cd /tmp && ssh user@example.com",
"bash -lc 'ssh user@example.com'",
"gcloud compute ssh --zone us-central1-a instance",
"ssh user@example.com uname -a",
"ssh -T git@example.com",
] {
assert!(is_interactive_remote_command(command), "{command}");
}
for command in [
"cargo test",
"echo /usr/bin/ssh user@example.com",
"GALAXY_TEST=/usr/bin/ssh cargo test",
"env GALAXY_TEST=1 cargo test",
"/usr/bin/ssh-add user@example.com",
"bash -lc 'echo ssh user@example.com'",
] {
assert!(!is_interactive_remote_command(command), "{command}");
}
}