Files
galaxy/crates/acp/src/runtime.rs
T
Ryan Ward 1a0aac51b6 Document process monitoring handoff
Add ACP discovery and configuration support
2026-07-30 11:53:33 -05:00

2309 lines
79 KiB
Rust

use std::collections::{HashMap, HashSet, VecDeque};
use std::future::Future;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use std::{io, thread};
use agent_client_protocol::schema::v1::{
AuthMethod, AuthMethodId, AuthenticateRequest, CancelNotification, ClientCapabilities,
ContentBlock, Implementation, InitializeRequest, InitializeResponse, LoadSessionRequest,
McpServer, Meta, NewSessionRequest, PromptRequest, PromptResponse, RequestPermissionOutcome,
RequestPermissionRequest, RequestPermissionResponse, SessionConfigOption,
SessionConfigOptionValue, SessionId, SessionNotification, SessionUpdate,
SetSessionConfigOptionRequest, StopReason, TextContent, ToolCallContent,
};
use agent_client_protocol::schema::ProtocolVersion;
use agent_client_protocol::{
AcpAgent, Agent, ConnectionTo, JsonRpcRequest, JsonRpcResponse, Responder,
};
use async_channel::{Receiver, Sender};
use futures::channel::oneshot;
use futures::future::{self, Either, FutureExt as _};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::config::{AcpLaunchConfig, AcpManagerConfig};
use crate::events::AcpEvent;
use crate::permissions::{
outcome_for_decision, AcpPermissionPolicy, PermissionContext, PermissionDecision,
SharedPermissionHandler,
};
static NEXT_TURN_ID: AtomicU64 = AtomicU64::new(1);
static NEXT_PERMISSION_ID: AtomicU64 = AtomicU64::new(1);
const MAX_VISIBLE_TOOL_OUTPUT_BYTES: usize = 32 * 1024;
const TOOL_OUTPUT_TRUNCATION_MARKER: &str = "\n… [tool output truncated by Galaxy]";
const STEERING_TIMEOUT: Duration = Duration::from_secs(5);
/// Errors returned by the ACP runtime's public control surface.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum AcpRuntimeError {
/// The background runtime could not be started.
#[error("failed to spawn ACP runtime worker: {0}")]
WorkerSpawn(#[source] io::Error),
/// The platform cannot yet guarantee termination of the agent's complete
/// process tree.
#[error("ACP is unavailable because this platform cannot safely terminate the full agent process tree")]
ProcessTreeTeardownUnsupported,
/// The manager's connection has closed.
#[error("ACP runtime is closed: {0}")]
RuntimeClosed(String),
/// A turn request was invalid.
#[error("invalid ACP turn request: {0}")]
InvalidTurn(String),
/// The requested turn is no longer active.
#[error("the ACP turn is no longer active")]
TurnNotActive,
/// The connected agent does not support interactive steering.
#[error("the ACP agent does not support _session/steering")]
SteeringUnsupported,
/// The agent did not acknowledge a live steering request in time.
#[error("the ACP agent did not acknowledge live steering within {0:?}")]
SteeringTimeout(Duration),
/// The peer returned or caused a protocol error.
#[error("ACP protocol error: {0}")]
Protocol(String),
/// Process startup or ACP initialization did not complete in time.
#[error("ACP agent did not initialize within {0:?}")]
InitializationTimeout(Duration),
/// Agent-owned authentication did not complete in time.
#[error("ACP agent did not authenticate within {0:?}")]
AuthenticationTimeout(Duration),
}
/// Result of the Codex `_session/steering` extension.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum AcpSteeringOutcome {
/// Input was injected into the active turn.
Injected,
/// The adapter accepted the input as a new turn.
StartedNewTurn,
/// The adapter could not apply the input.
Failed,
}
/// One prompt turn to run on an ACP session.
#[derive(Clone, Debug, PartialEq)]
pub struct AcpTurnRequest {
/// Selected ACP session configuration values keyed by agent-provided option ID.
pub config_values: std::collections::BTreeMap<String, SessionConfigOptionValue>,
/// Stable Galaxy-side key used to serialize turns for one conversation.
pub conversation_key: String,
/// Existing agent session to load. `None` creates a new session.
pub session_id: Option<SessionId>,
/// Absolute working directory for the session.
pub cwd: PathBuf,
/// Additional absolute workspace roots.
pub additional_directories: Vec<PathBuf>,
/// User content sent to the agent.
pub prompt: Vec<ContentBlock>,
/// MCP servers made available for this session.
pub mcp_servers: Vec<McpServer>,
/// Whether Galaxy's autonomous execution mode explicitly permits automatic
/// approval for this turn.
pub auto_approve_permissions: bool,
/// Category permissions inherited from Galaxy's active execution profile.
pub permission_policy: AcpPermissionPolicy,
}
impl AcpTurnRequest {
/// Creates a turn from structured ACP content.
#[must_use]
pub fn new(
conversation_key: impl Into<String>,
cwd: impl Into<PathBuf>,
prompt: Vec<ContentBlock>,
) -> Self {
Self {
config_values: std::collections::BTreeMap::new(),
conversation_key: conversation_key.into(),
session_id: None,
cwd: cwd.into(),
additional_directories: Vec::new(),
prompt,
mcp_servers: Vec::new(),
auto_approve_permissions: false,
permission_policy: AcpPermissionPolicy::default(),
}
}
/// Creates a plain-text turn.
#[must_use]
pub fn text(
conversation_key: impl Into<String>,
cwd: impl Into<PathBuf>,
text: impl Into<String>,
) -> Self {
Self::new(
conversation_key,
cwd,
vec![ContentBlock::Text(TextContent::new(text))],
)
}
/// Loads an existing ACP session before running the turn.
#[must_use]
pub fn session_id(mut self, session_id: impl Into<SessionId>) -> Self {
self.session_id = Some(session_id.into());
self
}
/// Adds additional workspace roots.
#[must_use]
pub fn additional_directories(mut self, directories: Vec<PathBuf>) -> Self {
self.additional_directories = directories;
self
}
/// Adds MCP server configuration.
#[must_use]
pub fn mcp_servers(mut self, servers: Vec<McpServer>) -> Self {
self.mcp_servers = servers;
self
}
/// Enables or disables explicit autonomous approval for this turn.
#[must_use]
pub fn auto_approve_permissions(mut self, auto_approve: bool) -> Self {
self.auto_approve_permissions = auto_approve;
self
}
/// Applies Galaxy's category permissions to this turn.
#[must_use]
pub fn permission_policy(mut self, policy: AcpPermissionPolicy) -> Self {
self.permission_policy = policy;
self
}
fn validate(&self) -> Result<(), AcpRuntimeError> {
if self.conversation_key.trim().is_empty() {
return Err(AcpRuntimeError::InvalidTurn(
"conversation_key cannot be empty".to_owned(),
));
}
if !self.cwd.is_absolute() {
return Err(AcpRuntimeError::InvalidTurn(
"cwd must be an absolute path".to_owned(),
));
}
if let Some(path) = self
.additional_directories
.iter()
.find(|path| !path.is_absolute())
{
return Err(AcpRuntimeError::InvalidTurn(format!(
"additional directory must be absolute: {}",
path.display()
)));
}
if self.prompt.is_empty() {
return Err(AcpRuntimeError::InvalidTurn(
"prompt must contain at least one content block".to_owned(),
));
}
for server in &self.mcp_servers {
if let McpServer::Stdio(server) = server {
if !server.command.is_absolute() {
return Err(AcpRuntimeError::InvalidTurn(format!(
"MCP stdio command must be absolute: {}",
server.command.display()
)));
}
}
}
Ok(())
}
}
/// Control handle for a queued or active turn.
#[derive(Clone)]
pub struct AcpSessionHandle {
manager: AcpSessionManager,
conversation_key: String,
turn_id: u64,
}
impl std::fmt::Debug for AcpSessionHandle {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("AcpSessionHandle")
.field("conversation_key", &self.conversation_key)
.field("turn_id", &self.turn_id)
.finish_non_exhaustive()
}
}
impl AcpSessionHandle {
/// Stable Galaxy conversation key.
#[must_use]
pub fn conversation_key(&self) -> &str {
&self.conversation_key
}
/// Cancels this queued or active turn.
///
/// Success means the cancellation was accepted by the runtime. The
/// per-turn event receiver reports the eventual `Finished` or teardown
/// error.
pub async fn cancel(&self) -> Result<(), AcpRuntimeError> {
let (ack_tx, ack_rx) = oneshot::channel();
self.manager
.send(Command::Cancel {
conversation_key: self.conversation_key.clone(),
turn_id: self.turn_id,
ack: ack_tx,
})
.await?;
ack_rx.await.map_err(|_| self.manager.closed_error())?
}
/// Steers the active turn through the optional Codex ACP extension.
///
/// This never falls back to a concurrent `session/prompt`; callers receive
/// [`AcpRuntimeError::SteeringUnsupported`] and can choose an explicit
/// cancel-and-queue fallback.
pub async fn steer(
&self,
prompt: Vec<ContentBlock>,
) -> Result<AcpSteeringOutcome, AcpRuntimeError> {
if prompt.is_empty() {
return Err(AcpRuntimeError::InvalidTurn(
"steering prompt must contain at least one content block".to_owned(),
));
}
let (ack_tx, ack_rx) = oneshot::channel();
self.manager
.send(Command::Steer {
conversation_key: self.conversation_key.clone(),
turn_id: self.turn_id,
prompt,
ack: ack_tx,
})
.await?;
ack_rx.await.map_err(|_| self.manager.closed_error())?
}
}
/// Reusable manager for one ACP agent subprocess.
#[derive(Clone)]
pub struct AcpSessionManager {
inner: Arc<ManagerInner>,
}
impl std::fmt::Debug for AcpSessionManager {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("AcpSessionManager")
.field("launch", &self.inner.launch)
.field("alive", &self.inner.alive.load(Ordering::Acquire))
.finish_non_exhaustive()
}
}
struct ManagerInner {
command_tx: Sender<Command>,
agent_info: Mutex<Option<Implementation>>,
agent_capabilities: Mutex<Option<agent_client_protocol::schema::v1::AgentCapabilities>>,
launch: AcpLaunchConfig,
alive: AtomicBool,
terminal_error: Mutex<Option<String>>,
}
impl Drop for ManagerInner {
fn drop(&mut self) {
let _ = self.command_tx.try_send(Command::Shutdown);
}
}
impl AcpSessionManager {
/// Spawns the background ACP runtime and its configured agent subprocess.
///
/// Process creation and ACP initialization happen on the worker so this
/// method does not block the Galaxy UI thread.
pub fn spawn(config: AcpManagerConfig) -> Result<Self, AcpRuntimeError> {
validate_process_tree_teardown(cfg!(unix))?;
let (command_tx, command_rx) = async_channel::unbounded();
let inner = Arc::new(ManagerInner {
command_tx: command_tx.clone(),
agent_info: Mutex::new(None),
agent_capabilities: Mutex::new(None),
launch: config.launch.clone(),
alive: AtomicBool::new(true),
terminal_error: Mutex::new(None),
});
let worker_state = Arc::downgrade(&inner);
let manager_for_worker = Arc::clone(&inner);
thread::Builder::new()
.name("galaxy-acp-runtime".to_owned())
.spawn(move || {
let result = futures::executor::block_on(run_connection_supervised(
config,
command_rx.clone(),
command_tx,
manager_for_worker,
));
let terminal_error = result
.err()
.map_or_else(|| "runtime shut down".to_owned(), |error| error.to_string());
if let Some(worker_state) = worker_state.upgrade() {
worker_state.alive.store(false, Ordering::Release);
if let Ok(mut error) = worker_state.terminal_error.lock() {
*error = Some(terminal_error.clone());
}
}
fail_queued_commands(&command_rx, &terminal_error);
})
.map_err(AcpRuntimeError::WorkerSpawn)?;
Ok(Self { inner })
}
/// Comparable launch settings for manager restart decisions.
#[must_use]
pub fn launch_config(&self) -> &AcpLaunchConfig {
&self.inner.launch
}
/// Returns the implementation metadata advertised during initialization.
#[must_use]
pub fn agent_info(&self) -> Option<Implementation> {
self.inner
.agent_info
.lock()
.ok()
.and_then(|info| info.clone())
}
/// Returns the capabilities advertised during initialization.
#[must_use]
pub fn agent_capabilities(
&self,
) -> Option<agent_client_protocol::schema::v1::AgentCapabilities> {
self.inner
.agent_capabilities
.lock()
.ok()
.and_then(|capabilities| capabilities.clone())
}
/// Discovers the current configuration options by creating a temporary ACP session.
pub async fn discover_config_options(
&self,
cwd: PathBuf,
mcp_servers: Vec<McpServer>,
) -> Result<Vec<SessionConfigOption>, AcpRuntimeError> {
self.ensure_alive()?;
let (result_tx, result_rx) = oneshot::channel();
self.inner
.command_tx
.send(Command::Discover {
cwd,
mcp_servers,
result: result_tx,
})
.await
.map_err(|_| self.closed_error())?;
result_rx.await.map_err(|_| self.closed_error())?
}
/// Whether the background worker and its ACP process are still available.
///
/// This becomes `false` after protocol failure, normal shutdown, or a
/// cancellation watchdog teardown. Callers can use it to recreate their
/// runtime singleton before starting the next turn.
#[must_use]
pub fn is_alive(&self) -> bool {
self.inner.alive.load(Ordering::Acquire)
}
/// Queues a turn and returns its control handle and isolated event stream.
///
/// Turns sharing `conversation_key` are executed in FIFO order.
pub fn run_turn(
&self,
request: AcpTurnRequest,
) -> Result<(AcpSessionHandle, Receiver<AcpEvent>), AcpRuntimeError> {
request.validate()?;
self.ensure_alive()?;
let turn_id = NEXT_TURN_ID.fetch_add(1, Ordering::Relaxed);
let conversation_key = request.conversation_key.clone();
let (events_tx, events_rx) = async_channel::unbounded();
self.inner
.command_tx
.try_send(Command::RunTurn(PendingTurn {
turn_id,
request,
events: events_tx,
}))
.map_err(|_| self.closed_error())?;
Ok((
AcpSessionHandle {
manager: self.clone(),
conversation_key,
turn_id,
},
events_rx,
))
}
async fn send(&self, command: Command) -> Result<(), AcpRuntimeError> {
self.ensure_alive()?;
self.inner
.command_tx
.send(command)
.await
.map_err(|_| self.closed_error())
}
fn ensure_alive(&self) -> Result<(), AcpRuntimeError> {
if self.inner.alive.load(Ordering::Acquire) {
Ok(())
} else {
Err(self.closed_error())
}
}
fn closed_error(&self) -> AcpRuntimeError {
let message = self
.inner
.terminal_error
.lock()
.ok()
.and_then(|error| error.clone())
.unwrap_or_else(|| "worker is unavailable".to_owned());
AcpRuntimeError::RuntimeClosed(message)
}
}
fn validate_process_tree_teardown(supported: bool) -> Result<(), AcpRuntimeError> {
if supported {
return Ok(());
}
// Remove this gate once the pinned ACP SDK retains a Windows Job Object
// that kills every launcher descendant when its connection is dropped.
Err(AcpRuntimeError::ProcessTreeTeardownUnsupported)
}
#[derive(Clone)]
struct EventRoute {
turn_id: u64,
events: Sender<AcpEvent>,
auto_approve: bool,
permission_policy: AcpPermissionPolicy,
}
#[derive(Default)]
struct EventRouter {
sessions: Mutex<HashMap<SessionId, EventRoute>>,
pending_permissions: Mutex<HashMap<SessionId, HashMap<u64, Sender<()>>>>,
replay_suppressed: Mutex<HashSet<SessionId>>,
}
impl EventRouter {
fn set_route(&self, session_id: SessionId, route: EventRoute) {
if let Ok(mut sessions) = self.sessions.lock() {
sessions.insert(session_id, route);
}
}
fn remove_route(&self, session_id: &SessionId, turn_id: u64) {
if let Ok(mut sessions) = self.sessions.lock() {
if sessions
.get(session_id)
.is_some_and(|route| route.turn_id == turn_id)
{
sessions.remove(session_id);
}
}
}
fn route_for(&self, session_id: &SessionId) -> Option<EventRoute> {
self.sessions
.lock()
.ok()
.and_then(|sessions| sessions.get(session_id).cloned())
}
fn emit(&self, session_id: &SessionId, event: AcpEvent) {
if self.is_replay_suppressed(session_id) {
return;
}
if let Some(route) = self.route_for(session_id) {
let _ = route.events.try_send(event);
}
}
fn suppress_replay(&self, session_id: SessionId) {
if let Ok(mut suppressed) = self.replay_suppressed.lock() {
suppressed.insert(session_id);
}
}
fn finish_replay(&self, session_id: &SessionId) {
if let Ok(mut suppressed) = self.replay_suppressed.lock() {
suppressed.remove(session_id);
}
}
fn is_replay_suppressed(&self, session_id: &SessionId) -> bool {
self.replay_suppressed
.lock()
.is_ok_and(|suppressed| suppressed.contains(session_id))
}
fn register_permission(&self, session_id: SessionId, cancellation: Sender<()>) -> u64 {
let permission_id = NEXT_PERMISSION_ID.fetch_add(1, Ordering::Relaxed);
if let Ok(mut pending) = self.pending_permissions.lock() {
pending
.entry(session_id)
.or_default()
.insert(permission_id, cancellation);
}
permission_id
}
fn unregister_permission(&self, session_id: &SessionId, permission_id: u64) {
if let Ok(mut pending) = self.pending_permissions.lock() {
if let Some(session) = pending.get_mut(session_id) {
session.remove(&permission_id);
if session.is_empty() {
pending.remove(session_id);
}
}
}
}
fn cancel_permissions(&self, session_id: &SessionId) {
let cancellations = self
.pending_permissions
.lock()
.ok()
.and_then(|mut pending| pending.remove(session_id))
.unwrap_or_default();
for cancellation in cancellations.into_values() {
let _ = cancellation.try_send(());
}
}
fn on_session_notification(&self, notification: SessionNotification) {
let Some(event) = event_from_session_update(notification.update) else {
return;
};
self.emit(&notification.session_id, event);
}
}
#[derive(Clone, Debug, PartialEq)]
struct SessionSpec {
cwd: PathBuf,
additional_directories: Vec<PathBuf>,
mcp_servers: Vec<McpServer>,
config_values: std::collections::BTreeMap<String, SessionConfigOptionValue>,
}
impl From<&AcpTurnRequest> for SessionSpec {
fn from(request: &AcpTurnRequest) -> Self {
Self {
cwd: request.cwd.clone(),
additional_directories: request.additional_directories.clone(),
mcp_servers: request.mcp_servers.clone(),
config_values: request.config_values.clone(),
}
}
}
struct PendingTurn {
turn_id: u64,
request: AcpTurnRequest,
events: Sender<AcpEvent>,
}
struct ActiveTurn {
pending: PendingTurn,
phase: TurnPhase,
cancel_requested: bool,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum TurnPhase {
Opening,
Prompting,
}
struct ConversationState {
spec: SessionSpec,
session_id: Option<SessionId>,
ready: bool,
active: Option<ActiveTurn>,
queued: VecDeque<PendingTurn>,
}
impl ConversationState {
fn new(turn: PendingTurn) -> Self {
Self {
spec: SessionSpec::from(&turn.request),
session_id: None,
ready: false,
active: Some(ActiveTurn {
pending: turn,
phase: TurnPhase::Opening,
cancel_requested: false,
}),
queued: VecDeque::new(),
}
}
fn activate(&mut self, mut turn: PendingTurn) {
debug_assert!(self.active.is_none());
let next_spec = SessionSpec::from(&turn.request);
if self.spec != next_spec {
// ACP v1 cannot mutate a session's cwd or MCP configuration after
// creation. Rotate to a fresh session instead of rejecting a
// normal Galaxy context change. A persisted session ID is only a
// restoration hint for the first turn; it must not reload the old
// spec here.
self.spec = next_spec;
self.session_id = None;
self.ready = false;
turn.request.session_id = None;
}
self.active = Some(ActiveTurn {
pending: turn,
phase: if self.ready {
TurnPhase::Prompting
} else {
TurnPhase::Opening
},
cancel_requested: false,
});
}
fn activate_next(&mut self) -> bool {
let Some(next) = self.queued.pop_front() else {
return false;
};
self.activate(next);
true
}
}
enum Command {
RunTurn(PendingTurn),
Discover {
cwd: PathBuf,
mcp_servers: Vec<McpServer>,
result: oneshot::Sender<Result<Vec<SessionConfigOption>, AcpRuntimeError>>,
},
SessionOpened {
conversation_key: String,
turn_id: u64,
requested_session_id: Option<SessionId>,
result: Result<SessionId, agent_client_protocol::Error>,
},
PromptFinished {
conversation_key: String,
turn_id: u64,
result: Result<PromptResponse, agent_client_protocol::Error>,
},
Cancel {
conversation_key: String,
turn_id: u64,
ack: oneshot::Sender<Result<(), AcpRuntimeError>>,
},
Steer {
conversation_key: String,
turn_id: u64,
prompt: Vec<ContentBlock>,
ack: oneshot::Sender<Result<AcpSteeringOutcome, AcpRuntimeError>>,
},
AbortUntrackedSteering {
conversation_key: String,
turn_id: u64,
result: Result<AcpSteeringOutcome, AcpRuntimeError>,
ack: oneshot::Sender<Result<AcpSteeringOutcome, AcpRuntimeError>>,
},
ForceTeardown {
conversation_key: String,
turn_id: u64,
},
ConnectionClosed,
Shutdown,
}
struct RuntimeActor {
connection: ConnectionTo<Agent>,
command_rx: Receiver<Command>,
command_tx: Sender<Command>,
router: Arc<EventRouter>,
conversations: HashMap<String, ConversationState>,
can_load: bool,
can_steer: bool,
agent_info: Option<Implementation>,
agent_capabilities: agent_client_protocol::schema::v1::AgentCapabilities,
cancellation_grace_period: std::time::Duration,
}
enum ActorControl {
Continue,
Stop,
}
impl RuntimeActor {
async fn run(mut self) -> Result<(), AcpRuntimeError> {
while let Ok(command) = self.command_rx.recv().await {
let control = match self.handle_command(command) {
Ok(control) => control,
Err(error) => {
self.fail_all(&error.to_string());
return Err(error);
}
};
if matches!(control, ActorControl::Stop) {
break;
}
}
Ok(())
}
fn handle_command(&mut self, command: Command) -> Result<ActorControl, AcpRuntimeError> {
match command {
Command::RunTurn(turn) => self.queue_turn(turn)?,
Command::Discover {
cwd,
mcp_servers,
result,
} => {
self.spawn_discovery(cwd, mcp_servers, result)?;
}
Command::SessionOpened {
conversation_key,
turn_id,
requested_session_id,
result,
} => self.session_opened(
&conversation_key,
turn_id,
requested_session_id.as_ref(),
result,
)?,
Command::PromptFinished {
conversation_key,
turn_id,
result,
} => self.prompt_finished(&conversation_key, turn_id, result)?,
Command::Cancel {
conversation_key,
turn_id,
ack,
} => {
let result = self.cancel_turn(&conversation_key, turn_id);
let should_stop = result
.as_ref()
.is_err_and(|error| matches!(error, AcpRuntimeError::Protocol(_)));
let _ = ack.send(result);
if should_stop {
return Ok(ActorControl::Stop);
}
}
Command::Steer {
conversation_key,
turn_id,
prompt,
ack,
} => self.steer_turn(&conversation_key, turn_id, prompt, ack)?,
Command::AbortUntrackedSteering {
conversation_key,
turn_id,
result,
ack,
} => {
self.fail_all(&format!(
"ACP process was terminated immediately after steering for conversation {conversation_key:?}, turn {turn_id}, created activity Galaxy could not safely track"
));
let _ = ack.send(result);
return Ok(ActorControl::Stop);
}
Command::ForceTeardown {
conversation_key,
turn_id,
} => {
if self.force_teardown_if_still_active(&conversation_key, turn_id) {
return Ok(ActorControl::Stop);
}
}
Command::ConnectionClosed => {
self.fail_all("ACP agent closed its protocol stream");
return Ok(ActorControl::Stop);
}
Command::Shutdown => return Ok(ActorControl::Stop),
}
Ok(ActorControl::Continue)
}
fn queue_turn(&mut self, turn: PendingTurn) -> Result<(), AcpRuntimeError> {
let key = turn.request.conversation_key.clone();
if let Some(state) = self.conversations.get_mut(&key) {
if state.active.is_some() {
state.queued.push_back(turn);
return Ok(());
}
state.activate(turn);
} else {
self.conversations
.insert(key.clone(), ConversationState::new(turn));
}
self.start_active_turn(&key)
}
fn spawn_discovery(
&self,
cwd: PathBuf,
mcp_servers: Vec<McpServer>,
result: oneshot::Sender<Result<Vec<SessionConfigOption>, AcpRuntimeError>>,
) -> Result<(), AcpRuntimeError> {
let connection = self.connection.clone();
self.connection
.spawn(async move {
let response = connection
.send_request(NewSessionRequest::new(cwd).mcp_servers(mcp_servers))
.block_task()
.await;
let result_value = match response {
Ok(response) => Ok(response.config_options.unwrap_or_default()),
Err(error) => Err(AcpRuntimeError::Protocol(error.to_string())),
};
let _ = result.send(result_value);
Ok(())
})
.map_err(|error| AcpRuntimeError::Protocol(error.to_string()))?;
Ok(())
}
fn start_active_turn(&mut self, conversation_key: &str) -> Result<(), AcpRuntimeError> {
let (ready, session_id, turn_id, request, events) = {
let state = self
.conversations
.get_mut(conversation_key)
.ok_or(AcpRuntimeError::TurnNotActive)?;
let active = state
.active
.as_mut()
.ok_or(AcpRuntimeError::TurnNotActive)?;
active.phase = if state.ready {
TurnPhase::Prompting
} else {
TurnPhase::Opening
};
(
state.ready,
state.session_id.clone(),
active.pending.turn_id,
active.pending.request.clone(),
active.pending.events.clone(),
)
};
if ready {
let session_id = session_id
.ok_or_else(|| AcpRuntimeError::Protocol("ready session has no id".to_owned()))?;
self.router.set_route(
session_id.clone(),
EventRoute {
turn_id,
events: events.clone(),
auto_approve: request.auto_approve_permissions,
permission_policy: request.permission_policy,
},
);
emit_session_started(
&events,
session_id.clone(),
self.can_load,
self.can_steer,
self.agent_info.clone(),
self.agent_capabilities.clone(),
);
self.spawn_prompt(
conversation_key.to_owned(),
turn_id,
session_id,
request.prompt,
)
} else {
self.spawn_open_session(conversation_key.to_owned(), turn_id, &request)
}
}
fn spawn_open_session(
&self,
conversation_key: String,
turn_id: u64,
request: &AcpTurnRequest,
) -> Result<(), AcpRuntimeError> {
let requested_session_id = request.session_id.clone();
let requested_session_id =
match restorable_session_id(request.session_id.as_ref(), self.can_load) {
Ok(session_id) => session_id,
Err(error) => {
self.command_tx
.try_send(Command::SessionOpened {
conversation_key,
turn_id,
requested_session_id,
result: Err(error),
})
.map_err(|error| AcpRuntimeError::Protocol(error.to_string()))?;
return Ok(());
}
};
if let Some(session_id) = requested_session_id.as_ref() {
// `session/load` replays the agent's prior transcript through
// `session/update`. Galaxy already restored that transcript from
// its own persistence, so keep those notifications off the active
// turn's event stream.
self.router.suppress_replay(session_id.clone());
}
let connection = self.connection.clone();
let completion_tx = self.command_tx.clone();
let router = Arc::clone(&self.router);
let cwd = request.cwd.clone();
let additional_directories = request.additional_directories.clone();
let mcp_servers = request.mcp_servers.clone();
let config_values = request.config_values.clone();
let suppressed_session_id = requested_session_id.clone();
let replay_session_id = requested_session_id.clone();
let spawn_result = self.connection.spawn(async move {
let load_connection = connection.clone();
let load_cwd = cwd.clone();
let load_additional_directories = additional_directories.clone();
let load_mcp_servers = mcp_servers.clone();
let result = open_session(
requested_session_id.clone(),
move |session_id| async move {
load_connection
.send_request(
LoadSessionRequest::new(session_id, load_cwd)
.additional_directories(load_additional_directories)
.mcp_servers(load_mcp_servers),
)
.block_task()
.await
.map(|_| ())
},
move || async move {
let response = connection
.send_request(
NewSessionRequest::new(cwd)
.additional_directories(additional_directories)
.mcp_servers(mcp_servers),
)
.block_task()
.await?;
for (config_id, value) in config_values {
connection
.send_request(SetSessionConfigOptionRequest::new(
response.session_id.clone(),
config_id,
value,
))
.block_task()
.await?;
}
Ok(response.session_id)
},
)
.await;
if let Some(session_id) = replay_session_id.as_ref() {
router.finish_replay(session_id);
}
let _ = completion_tx
.send(Command::SessionOpened {
conversation_key,
turn_id,
requested_session_id,
result,
})
.await;
Ok(())
});
if let Err(error) = spawn_result {
if let Some(session_id) = suppressed_session_id.as_ref() {
self.router.finish_replay(session_id);
}
return Err(AcpRuntimeError::Protocol(error.to_string()));
}
Ok(())
}
fn session_opened(
&mut self,
conversation_key: &str,
turn_id: u64,
requested_session_id: Option<&SessionId>,
result: Result<SessionId, agent_client_protocol::Error>,
) -> Result<(), AcpRuntimeError> {
let is_current = self
.conversations
.get(conversation_key)
.and_then(|state| state.active.as_ref())
.is_some_and(|active| active.pending.turn_id == turn_id);
if !is_current {
return Ok(());
}
match result {
Ok(session_id) => {
if let Some(requested_session_id) = requested_session_id {
if requested_session_id != &session_id {
self.router.remove_route(requested_session_id, turn_id);
}
}
let (events, auto_approve, permission_policy, cancelled) = {
let state = self
.conversations
.get_mut(conversation_key)
.ok_or(AcpRuntimeError::TurnNotActive)?;
state.session_id = Some(session_id.clone());
state.ready = true;
let active = state
.active
.as_mut()
.ok_or(AcpRuntimeError::TurnNotActive)?;
active.phase = TurnPhase::Prompting;
(
active.pending.events.clone(),
active.pending.request.auto_approve_permissions,
active.pending.request.permission_policy,
active.cancel_requested,
)
};
self.router.set_route(
session_id.clone(),
EventRoute {
turn_id,
events: events.clone(),
auto_approve,
permission_policy,
},
);
emit_session_started(
&events,
session_id.clone(),
self.can_load,
self.can_steer,
self.agent_info.clone(),
self.agent_capabilities.clone(),
);
if cancelled {
let _ = events.try_send(AcpEvent::Finished {
stop_reason: StopReason::Cancelled,
});
self.finish_active_and_start_next(conversation_key)?;
} else {
let prompt = self
.conversations
.get(conversation_key)
.and_then(|state| state.active.as_ref())
.map(|active| active.pending.request.prompt.clone())
.ok_or(AcpRuntimeError::TurnNotActive)?;
self.spawn_prompt(conversation_key.to_owned(), turn_id, session_id, prompt)?;
}
}
Err(error) => {
if let Some(session_id) = requested_session_id {
self.router.remove_route(session_id, turn_id);
}
self.fail_conversation(conversation_key, &error.to_string());
}
}
Ok(())
}
fn spawn_prompt(
&self,
conversation_key: String,
turn_id: u64,
session_id: SessionId,
prompt: Vec<ContentBlock>,
) -> Result<(), AcpRuntimeError> {
let connection = self.connection.clone();
let completion_tx = self.command_tx.clone();
self.connection
.spawn(async move {
let result = connection
.send_request(PromptRequest::new(session_id, prompt))
.block_task()
.await;
let _ = completion_tx
.send(Command::PromptFinished {
conversation_key,
turn_id,
result,
})
.await;
Ok(())
})
.map_err(|error| AcpRuntimeError::Protocol(error.to_string()))
}
fn prompt_finished(
&mut self,
conversation_key: &str,
turn_id: u64,
result: Result<PromptResponse, agent_client_protocol::Error>,
) -> Result<(), AcpRuntimeError> {
let Some(state) = self.conversations.get(conversation_key) else {
return Ok(());
};
let Some(active) = state.active.as_ref() else {
return Ok(());
};
if active.pending.turn_id != turn_id {
return Ok(());
}
match result {
Ok(response) => {
let _ = active.pending.events.try_send(AcpEvent::Finished {
stop_reason: response.stop_reason,
});
}
Err(error) => emit_error(&active.pending.events, &error.to_string()),
}
self.finish_active_and_start_next(conversation_key)
}
fn finish_active_and_start_next(
&mut self,
conversation_key: &str,
) -> Result<(), AcpRuntimeError> {
let (session_id, finished_turn, next) = {
let state = self
.conversations
.get_mut(conversation_key)
.ok_or(AcpRuntimeError::TurnNotActive)?;
let session_id = state.session_id.clone();
let finished_turn = state
.active
.take()
.map(|active| active.pending.turn_id)
.ok_or(AcpRuntimeError::TurnNotActive)?;
let has_next = state.activate_next();
(session_id, finished_turn, has_next)
};
if let Some(session_id) = session_id.as_ref() {
self.router.remove_route(session_id, finished_turn);
}
if next {
self.start_active_turn(conversation_key)?;
}
Ok(())
}
fn cancel_turn(&mut self, conversation_key: &str, turn_id: u64) -> Result<(), AcpRuntimeError> {
let Some(state) = self.conversations.get_mut(conversation_key) else {
return Ok(());
};
if state
.active
.as_ref()
.is_some_and(|active| active.pending.turn_id == turn_id)
{
let Some(active) = state.active.as_mut() else {
return Ok(());
};
if active.cancel_requested {
return Ok(());
}
active.cancel_requested = true;
let phase = active.phase;
if phase == TurnPhase::Prompting {
let session_id = state.session_id.clone().ok_or_else(|| {
AcpRuntimeError::Protocol("active turn has no session id".to_owned())
})?;
self.router.cancel_permissions(&session_id);
self.connection
.send_notification(CancelNotification::new(session_id))
.map_err(|error| AcpRuntimeError::Protocol(error.to_string()))?;
}
self.spawn_cancellation_watchdog(conversation_key.to_owned(), turn_id)?;
return Ok(());
}
if let Some(index) = state.queued.iter().position(|turn| turn.turn_id == turn_id) {
if let Some(turn) = state.queued.remove(index) {
let _ = turn.events.try_send(AcpEvent::Finished {
stop_reason: StopReason::Cancelled,
});
}
}
Ok(())
}
fn spawn_cancellation_watchdog(
&self,
conversation_key: String,
turn_id: u64,
) -> Result<(), AcpRuntimeError> {
let completion_tx = self.command_tx.clone();
let grace = self.cancellation_grace_period;
self.connection
.spawn(async move {
async_io::Timer::after(grace).await;
let _ = completion_tx
.send(Command::ForceTeardown {
conversation_key,
turn_id,
})
.await;
Ok(())
})
.map_err(|error| AcpRuntimeError::Protocol(error.to_string()))
}
fn steer_turn(
&self,
conversation_key: &str,
turn_id: u64,
prompt: Vec<ContentBlock>,
ack: oneshot::Sender<Result<AcpSteeringOutcome, AcpRuntimeError>>,
) -> Result<(), AcpRuntimeError> {
if !self.can_steer {
let _ = ack.send(Err(AcpRuntimeError::SteeringUnsupported));
return Ok(());
}
let Some(state) = self.conversations.get(conversation_key) else {
let _ = ack.send(Err(AcpRuntimeError::TurnNotActive));
return Ok(());
};
let is_active = state.active.as_ref().is_some_and(|active| {
active.pending.turn_id == turn_id && active.phase == TurnPhase::Prompting
});
if !is_active {
let _ = ack.send(Err(AcpRuntimeError::TurnNotActive));
return Ok(());
}
let Some(session_id) = state.session_id.clone() else {
let _ = ack.send(Err(AcpRuntimeError::TurnNotActive));
return Ok(());
};
let connection = self.connection.clone();
let command_tx = self.command_tx.clone();
let conversation_key = conversation_key.to_owned();
self.connection
.spawn(async move {
let steering = Box::pin(
connection
.send_request(SteeringRequest { session_id, prompt })
.block_task(),
);
let result = match future::select(
steering,
Box::pin(async_io::Timer::after(STEERING_TIMEOUT)),
)
.await
{
Either::Left((response, _)) => match response {
Ok(response) => {
let outcome = response.outcome;
if steering_became_untracked(outcome) {
let command = Command::AbortUntrackedSteering {
conversation_key,
turn_id,
result: Ok(outcome),
ack,
};
if let Err(error) = command_tx.send(command).await {
if let Command::AbortUntrackedSteering { ack, .. } =
error.into_inner()
{
let _ = ack.send(Err(AcpRuntimeError::RuntimeClosed(
"ACP runtime closed while containing an untracked steering turn"
.to_owned(),
)));
}
}
return Ok(());
}
Ok(outcome)
}
Err(error) => Err(AcpRuntimeError::Protocol(error.to_string())),
},
Either::Right((_, _)) => {
// A timed-out extension request has an indeterminate
// outcome. The actor acknowledges the timeout and then
// immediately drops the connection and process group;
// a cancellation grace period would leave a ghost turn
// free to act without Galaxy tracking it.
let command = Command::AbortUntrackedSteering {
conversation_key,
turn_id,
result: Err(AcpRuntimeError::SteeringTimeout(STEERING_TIMEOUT)),
ack,
};
if let Err(error) = command_tx.send(command).await {
if let Command::AbortUntrackedSteering { ack, .. } = error.into_inner() {
let _ = ack.send(Err(AcpRuntimeError::RuntimeClosed(
"ACP runtime closed while containing a timed-out steering request"
.to_owned(),
)));
}
}
return Ok(());
}
};
let _ = ack.send(result);
Ok(())
})
.map_err(|error| AcpRuntimeError::Protocol(error.to_string()))
}
fn force_teardown_if_still_active(&mut self, conversation_key: &str, turn_id: u64) -> bool {
let Some(state) = self.conversations.get(conversation_key) else {
return false;
};
let Some(active) = state.active.as_ref() else {
return false;
};
if active.pending.turn_id != turn_id || !active.cancel_requested {
return false;
}
emit_error(
&active.pending.events,
"agent did not acknowledge cancellation; its ACP process was terminated",
);
let _ = active.pending.events.try_send(AcpEvent::Finished {
stop_reason: StopReason::Cancelled,
});
self.fail_other_than(
conversation_key,
turn_id,
"ACP process was terminated after a cancellation timeout",
);
true
}
fn fail_conversation(&mut self, conversation_key: &str, message: &str) {
let Some(mut state) = self.conversations.remove(conversation_key) else {
return;
};
if let Some(active) = state.active.take() {
emit_error(&active.pending.events, message);
if let Some(session_id) = state.session_id.as_ref() {
self.router.remove_route(session_id, active.pending.turn_id);
}
}
for turn in state.queued {
emit_error(&turn.events, message);
}
}
fn fail_other_than(&self, conversation_key: &str, turn_id: u64, message: &str) {
for (key, state) in &self.conversations {
if let Some(active) = state.active.as_ref() {
if key != conversation_key || active.pending.turn_id != turn_id {
emit_error(&active.pending.events, message);
}
}
for turn in &state.queued {
emit_error(&turn.events, message);
}
}
}
fn fail_all(&self, message: &str) {
fail_conversations(&self.conversations, message);
}
}
fn fail_conversations(conversations: &HashMap<String, ConversationState>, message: &str) {
for state in conversations.values() {
if let Some(active) = state.active.as_ref() {
emit_error(&active.pending.events, message);
}
for turn in &state.queued {
emit_error(&turn.events, message);
}
}
}
async fn run_connection_supervised(
config: AcpManagerConfig,
command_rx: Receiver<Command>,
command_tx: Sender<Command>,
manager: Arc<ManagerInner>,
) -> Result<(), AcpRuntimeError> {
let initialization_timeout = config.initialization_timeout;
let authentication_timeout = config.authentication_timeout;
let (initialized_tx, initialized_rx) = oneshot::channel();
let (authenticated_tx, authenticated_rx) = oneshot::channel();
supervise_connection_readiness(
run_connection(
config,
command_rx,
command_tx,
initialized_tx,
authenticated_tx,
manager,
),
initialized_rx,
authenticated_rx,
initialization_timeout,
authentication_timeout,
)
.await
}
async fn supervise_connection_readiness<ConnectionFuture>(
connection: ConnectionFuture,
initialized: oneshot::Receiver<()>,
authenticated: oneshot::Receiver<()>,
initialization_timeout: Duration,
authentication_timeout: Duration,
) -> Result<(), AcpRuntimeError>
where
ConnectionFuture: Future<Output = Result<(), AcpRuntimeError>>,
{
let connection = Box::pin(connection);
let initialization = Box::pin(async move {
match future::select(initialized, async_io::Timer::after(initialization_timeout)).await {
Either::Left((Ok(()), _)) => Ok(()),
Either::Left((Err(_), _)) => Err(AcpRuntimeError::Protocol(
"ACP connection closed before initialization completed".to_owned(),
)),
Either::Right((_, _)) => Err(AcpRuntimeError::InitializationTimeout(
initialization_timeout,
)),
}
});
let connection = match future::select(connection, initialization).await {
Either::Left((result, _)) => return result,
Either::Right((Ok(()), connection)) => connection,
Either::Right((Err(error), _)) => return Err(error),
};
let authentication = Box::pin(async move {
match future::select(
authenticated,
async_io::Timer::after(authentication_timeout),
)
.await
{
Either::Left((Ok(()), _)) => Ok(()),
Either::Left((Err(_), _)) => Err(AcpRuntimeError::Protocol(
"ACP connection closed before authentication completed".to_owned(),
)),
Either::Right((_, _)) => Err(AcpRuntimeError::AuthenticationTimeout(
authentication_timeout,
)),
}
});
match future::select(connection, authentication).await {
Either::Left((result, _)) => result,
Either::Right((Ok(()), connection)) => connection.await,
Either::Right((Err(error), _)) => Err(error),
}
}
async fn run_connection(
config: AcpManagerConfig,
command_rx: Receiver<Command>,
command_tx: Sender<Command>,
initialized: oneshot::Sender<()>,
authenticated: oneshot::Sender<()>,
manager: Arc<ManagerInner>,
) -> Result<(), AcpRuntimeError> {
let router = Arc::new(EventRouter::default());
let permission_handler = Arc::clone(&config.permission_handler);
let agent = AcpAgent::new(config.launch.to_agent_config());
let notification_router = Arc::clone(&router);
let permission_router = Arc::clone(&router);
let close_tx = command_tx.clone();
agent_client_protocol::Client
.builder()
.name("galaxy-acp")
.on_receive_notification(
async move |notification: SessionNotification, _connection| {
notification_router.on_session_notification(notification);
Ok(())
},
agent_client_protocol::on_receive_notification!(),
)
.on_receive_request(
move |request: RequestPermissionRequest,
responder: Responder<RequestPermissionResponse>,
connection: ConnectionTo<Agent>| {
handle_permission_request(
request,
responder,
connection,
Arc::clone(&permission_router),
Arc::clone(&permission_handler),
)
},
agent_client_protocol::on_receive_request!(),
)
.on_close(async move |_connection| {
let _ = close_tx.send(Command::ConnectionClosed).await;
Ok(())
})
.connect_with(agent, async move |connection: ConnectionTo<Agent>| {
let mut meta = serde_json::Map::new();
meta.insert("terminal_output".to_owned(), serde_json::Value::Bool(true));
let response = connection
.send_request(
InitializeRequest::new(ProtocolVersion::V1)
.client_capabilities(ClientCapabilities::new().meta(meta))
.client_info(
Implementation::new(config.client_name, config.client_version)
.title("Galaxy"),
),
)
.block_task()
.await?;
if response.protocol_version != ProtocolVersion::V1 {
return Err(agent_client_protocol::Error::new(
-32003,
format!(
"agent negotiated unsupported ACP version {}",
response.protocol_version
),
));
}
let _ = initialized.send(());
if let Ok(mut agent_info) = manager.agent_info.lock() {
*agent_info = response.agent_info.clone();
}
if let Ok(mut capabilities) = manager.agent_capabilities.lock() {
*capabilities = Some(response.agent_capabilities.clone());
}
if let Some(request) = authentication_request(
&response.auth_methods,
config.launch.preferred_auth_method.as_ref(),
)
.map_err(|error| agent_client_protocol::Error::new(-32003, error.to_string()))?
{
connection.send_request(request).block_task().await?;
}
let _ = authenticated.send(());
RuntimeActor {
connection,
command_rx,
command_tx,
router,
conversations: HashMap::new(),
can_load: response.agent_capabilities.load_session,
can_steer: supports_steering(&response),
agent_info: response.agent_info,
agent_capabilities: response.agent_capabilities,
cancellation_grace_period: config.cancellation_grace_period,
}
.run()
.await
.map_err(|error| agent_client_protocol::Error::new(-32003, error.to_string()))
})
.await
.map_err(|error| AcpRuntimeError::Protocol(error.to_string()))
}
fn authentication_request(
advertised_methods: &[AuthMethod],
preferred_method: Option<&AuthMethodId>,
) -> Result<Option<AuthenticateRequest>, AcpRuntimeError> {
let Some(first_method) = advertised_methods.first() else {
return Ok(None);
};
let method = if let Some(preferred_method) = preferred_method {
advertised_methods
.iter()
.find(|method| method.id() == preferred_method)
.ok_or_else(|| {
let advertised = advertised_methods
.iter()
.map(|method| method.id().to_string())
.collect::<Vec<_>>()
.join(", ");
AcpRuntimeError::Protocol(format!(
"preferred authentication method {preferred_method} was not advertised by the ACP agent; advertised methods: {advertised}"
))
})?
} else {
first_method
};
Ok(Some(AuthenticateRequest::new(method.id().clone())))
}
async fn handle_permission_request(
request: RequestPermissionRequest,
responder: Responder<RequestPermissionResponse>,
connection: ConnectionTo<Agent>,
router: Arc<EventRouter>,
handler: SharedPermissionHandler,
) -> Result<(), agent_client_protocol::Error> {
let session_id = request.session_id.clone();
let route = router.route_for(&session_id);
if let Some(route) = route.as_ref() {
let _ = route.events.try_send(AcpEvent::PermissionRequested {
request: request.clone(),
});
}
let (auto_approve, policy) = route
.as_ref()
.map_or((false, AcpPermissionPolicy::default()), |route| {
(route.auto_approve, route.permission_policy)
});
let (cancel_tx, cancel_rx) = async_channel::bounded(1);
let permission_id = router.register_permission(session_id.clone(), cancel_tx);
connection.spawn(async move {
let decision = handler
.decide(PermissionContext {
request: request.clone(),
auto_approve,
policy,
})
.fuse();
let cancelled = cancel_rx.recv().fuse();
futures::pin_mut!(decision, cancelled);
let decision = match future::select(decision, cancelled).await {
Either::Left((decision, _)) => decision,
Either::Right((_, _)) => PermissionDecision::Cancel,
};
router.unregister_permission(&session_id, permission_id);
router.emit(
&session_id,
AcpEvent::PermissionResolved {
session_id: session_id.clone(),
decision: decision.clone(),
},
);
let outcome: RequestPermissionOutcome = outcome_for_decision(&request, decision);
responder.respond(RequestPermissionResponse::new(outcome))
})
}
fn event_from_session_update(update: SessionUpdate) -> Option<AcpEvent> {
match update {
SessionUpdate::AgentMessageChunk(chunk) => Some(event_from_content(chunk.content, false)),
SessionUpdate::AgentThoughtChunk(chunk) => Some(event_from_content(chunk.content, true)),
SessionUpdate::ToolCall(tool_call) => {
let output = visible_tool_output(
&tool_call.content,
tool_call.raw_output.as_ref(),
tool_call.meta.as_ref(),
);
Some(AcpEvent::ToolCall {
id: tool_call.tool_call_id,
title: tool_call.title,
status: tool_call.status,
output,
})
}
SessionUpdate::ToolCallUpdate(update) => {
let output = visible_tool_output(
update.fields.content.as_deref().unwrap_or_default(),
update.fields.raw_output.as_ref(),
update.meta.as_ref(),
);
Some(AcpEvent::ToolCallUpdate {
id: update.tool_call_id,
title: update.fields.title,
status: update.fields.status,
output,
})
}
SessionUpdate::ConfigOptionUpdate(update) => Some(AcpEvent::ConfigOptions {
options: update.config_options,
}),
SessionUpdate::UsageUpdate(usage) => Some(AcpEvent::Usage {
used: usage.used,
size: usage.size,
cost: usage.cost,
}),
SessionUpdate::UserMessageChunk(chunk) => Some(AcpEvent::UserContent {
content: chunk.content,
}),
SessionUpdate::Plan(_)
| SessionUpdate::AvailableCommandsUpdate(_)
| SessionUpdate::CurrentModeUpdate(_)
| SessionUpdate::SessionInfoUpdate(_) => None,
// `SessionUpdate` is non-exhaustive so newer stable protocol updates
// remain forward-compatible and can be added to the visible surface.
_ => None,
}
}
fn visible_tool_output(
content: &[ToolCallContent],
raw_output: Option<&serde_json::Value>,
meta: Option<&Meta>,
) -> Option<String> {
let mut output = VisibleToolOutput::default();
if append_streaming_output_meta(&mut output, meta) {
append_terminal_exit_meta(&mut output, meta);
return output.finish();
}
if append_terminal_exit_meta(&mut output, meta) {
return output.finish();
}
if append_tool_content(&mut output, content) {
return output.finish();
}
append_raw_tool_output(&mut output, raw_output);
if output.is_empty() {
append_terminal_info_meta(&mut output, meta);
}
output.finish()
}
fn append_streaming_output_meta(output: &mut VisibleToolOutput, meta: Option<&Meta>) -> bool {
let Some(meta) = meta else {
return false;
};
for key in [
"terminal_output",
"terminal_output_delta",
"mcp_output_delta",
] {
let Some(value) = meta.get(key) else {
continue;
};
let Some(data) = value.get("data").and_then(serde_json::Value::as_str) else {
continue;
};
output.append(data);
if value.get("truncated").and_then(serde_json::Value::as_bool) == Some(true) {
output.append_line("[output truncated by ACP agent]");
}
return !output.is_empty();
}
false
}
fn append_terminal_exit_meta(output: &mut VisibleToolOutput, meta: Option<&Meta>) -> bool {
let Some(exit) = meta.and_then(|meta| meta.get("terminal_exit")) else {
return false;
};
let Some(exit) = exit.as_object() else {
return false;
};
let exit_code = exit
.get("exit_code")
.or_else(|| exit.get("exitCode"))
.and_then(json_scalar_text);
let signal = exit.get("signal").and_then(json_scalar_text);
let status = match (exit_code, signal) {
(Some(exit_code), Some(signal)) => {
format!("[terminal exited: code {exit_code}, signal {signal}]")
}
(Some(exit_code), None) => format!("[terminal exited: code {exit_code}]"),
(None, Some(signal)) => format!("[terminal exited: signal {signal}]"),
(None, None) => "[terminal exited]".to_owned(),
};
output.append(&status);
true
}
fn append_terminal_info_meta(output: &mut VisibleToolOutput, meta: Option<&Meta>) -> bool {
let Some(info) = meta
.and_then(|meta| meta.get("terminal_info"))
.and_then(serde_json::Value::as_object)
else {
return false;
};
let terminal_id = info
.get("terminal_id")
.or_else(|| info.get("terminalId"))
.and_then(serde_json::Value::as_str);
let cwd = info.get("cwd").and_then(serde_json::Value::as_str);
let label = match (terminal_id, cwd) {
(Some(terminal_id), Some(cwd)) => format!("[terminal: {terminal_id}; cwd: {cwd}]"),
(Some(terminal_id), None) => format!("[terminal: {terminal_id}]"),
(None, Some(cwd)) => format!("[terminal cwd: {cwd}]"),
(None, None) => return false,
};
output.append(&label);
true
}
fn append_tool_content(output: &mut VisibleToolOutput, content: &[ToolCallContent]) -> bool {
for item in content {
match item {
ToolCallContent::Content(content) => match &content.content {
ContentBlock::Text(text) => output.append_line(&text.text),
ContentBlock::Image(image) => {
output.append_line(&format!("[image: {}]", image.mime_type));
}
ContentBlock::Audio(audio) => {
output.append_line(&format!("[audio: {}]", audio.mime_type));
}
ContentBlock::ResourceLink(resource) => {
output.append_line(&format!("[resource: {} ({})]", resource.name, resource.uri));
}
ContentBlock::Resource(resource) => match &resource.resource {
agent_client_protocol::schema::v1::EmbeddedResourceResource::TextResourceContents(
text,
) => output.append_line(&text.text),
agent_client_protocol::schema::v1::EmbeddedResourceResource::BlobResourceContents(
blob,
) => output.append_line(&format!("[binary resource: {}]", blob.uri)),
// ACP content is non-exhaustive. Unknown future content remains
// hidden until Galaxy has a safe textual representation for it.
_ => {}
},
// ACP content is non-exhaustive. Unknown future content remains
// hidden until Galaxy has a safe textual representation for it.
_ => {}
},
ToolCallContent::Diff(diff) => {
output.append_line(&format!("[diff: {}]", diff.path.display()));
}
ToolCallContent::Terminal(terminal) => {
output.append_line(&format!("[terminal: {}]", terminal.terminal_id));
}
// ACP tool content is non-exhaustive. Unknown future variants remain
// hidden until Galaxy has a safe textual representation for them.
_ => {}
}
}
!output.is_empty()
}
fn append_raw_tool_output(output: &mut VisibleToolOutput, raw_output: Option<&serde_json::Value>) {
let Some(raw_output) = raw_output else {
return;
};
if let Some(text) = raw_output.as_str() {
output.append(text);
return;
}
if let Some(object) = raw_output.as_object() {
let rendered = [
"formatted_output",
"formattedOutput",
"output",
"error",
"message",
]
.into_iter()
.find_map(|key| object.get(key).and_then(serde_json::Value::as_str));
if let Some(rendered) = rendered {
output.append(rendered);
if raw_output_was_truncated(object) {
output.append_line("[output truncated by ACP agent]");
}
append_raw_exit_status(output, object);
return;
}
}
let (json, truncated) = bounded_pretty_json(raw_output);
output.append(&json);
if truncated {
output.mark_truncated();
}
}
fn raw_output_was_truncated(object: &serde_json::Map<String, serde_json::Value>) -> bool {
object
.get("truncated")
.and_then(serde_json::Value::as_bool)
.or_else(|| {
object
.get("metadata")
.and_then(serde_json::Value::as_object)
.and_then(|metadata| metadata.get("truncated"))
.and_then(serde_json::Value::as_bool)
})
== Some(true)
}
fn append_raw_exit_status(
output: &mut VisibleToolOutput,
object: &serde_json::Map<String, serde_json::Value>,
) {
let nested = object
.get("exit_status")
.or_else(|| object.get("exitStatus"))
.and_then(serde_json::Value::as_object);
let exit_code = object
.get("exit_code")
.or_else(|| object.get("exitCode"))
.and_then(json_scalar_text)
.or_else(|| {
nested.and_then(|status| {
status
.get("exit_code")
.or_else(|| status.get("exitCode"))
.and_then(json_scalar_text)
})
});
let signal = object
.get("signal")
.and_then(json_scalar_text)
.or_else(|| nested.and_then(|status| status.get("signal").and_then(json_scalar_text)));
let status = match (exit_code, signal) {
(Some(exit_code), Some(signal)) => {
Some(format!("[exit code: {exit_code}; signal: {signal}]"))
}
(Some(exit_code), None) => Some(format!("[exit code: {exit_code}]")),
(None, Some(signal)) => Some(format!("[signal: {signal}]")),
(None, None) => None,
};
if let Some(status) = status {
output.append_line(&status);
}
}
fn json_scalar_text(value: &serde_json::Value) -> Option<String> {
match value {
serde_json::Value::String(value) => Some(value.clone()),
serde_json::Value::Number(value) => Some(value.to_string()),
serde_json::Value::Bool(value) => Some(value.to_string()),
serde_json::Value::Null | serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
None
}
}
}
fn bounded_pretty_json(value: &serde_json::Value) -> (String, bool) {
let mut writer = BoundedJsonWriter::default();
if serde_json::to_writer_pretty(&mut writer, value).is_err() {
return (String::new(), false);
}
let valid_len = match std::str::from_utf8(&writer.bytes) {
Ok(_) => writer.bytes.len(),
Err(error) => error.valid_up_to(),
};
writer.bytes.truncate(valid_len);
(
String::from_utf8(writer.bytes).unwrap_or_default(),
writer.truncated,
)
}
#[derive(Default)]
struct BoundedJsonWriter {
bytes: Vec<u8>,
truncated: bool,
}
impl io::Write for BoundedJsonWriter {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
let remaining = MAX_VISIBLE_TOOL_OUTPUT_BYTES.saturating_sub(self.bytes.len());
let copied = remaining.min(bytes.len());
self.bytes.extend_from_slice(&bytes[..copied]);
self.truncated |= copied < bytes.len();
Ok(bytes.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[derive(Default)]
struct VisibleToolOutput {
text: String,
truncated: bool,
}
impl VisibleToolOutput {
fn append_line(&mut self, value: &str) {
if self.truncated || value.is_empty() {
return;
}
if !self.text.is_empty() && !self.text.ends_with('\n') {
self.append("\n");
}
self.append(value);
}
fn append(&mut self, value: &str) {
if self.truncated || value.is_empty() {
return;
}
let payload_limit =
MAX_VISIBLE_TOOL_OUTPUT_BYTES.saturating_sub(TOOL_OUTPUT_TRUNCATION_MARKER.len());
let mut chars = value.chars().peekable();
while let Some(character) = chars.next() {
if character == '\u{1b}' {
skip_escape_sequence(&mut chars);
continue;
}
if character == '\u{009b}' {
skip_control_sequence(&mut chars);
continue;
}
if character == '\u{009d}' {
skip_operating_system_command(&mut chars);
continue;
}
let character = if character == '\r' {
if chars.peek() == Some(&'\n') {
continue;
}
'\n'
} else {
character
};
if !is_safe_display_character(character) {
continue;
}
if self.text.len() + character.len_utf8() > payload_limit {
self.truncated = true;
break;
}
self.text.push(character);
}
}
fn mark_truncated(&mut self) {
self.truncated = true;
}
fn is_empty(&self) -> bool {
self.text.is_empty()
}
fn finish(mut self) -> Option<String> {
if self.truncated {
self.text.push_str(TOOL_OUTPUT_TRUNCATION_MARKER);
}
(!self.text.is_empty()).then_some(self.text)
}
}
fn skip_escape_sequence(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) {
match chars.peek() {
Some('[') => {
chars.next();
skip_control_sequence(chars);
}
Some(']') => {
chars.next();
skip_operating_system_command(chars);
}
Some(_) => {
chars.next();
}
None => {}
}
}
fn skip_control_sequence(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) {
for character in chars.by_ref() {
if ('\u{40}'..='\u{7e}').contains(&character) {
break;
}
}
}
fn skip_operating_system_command(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) {
while let Some(character) = chars.next() {
if character == '\u{07}' {
break;
}
if character == '\u{1b}' && chars.peek() == Some(&'\\') {
chars.next();
break;
}
}
}
fn is_safe_display_character(character: char) -> bool {
if matches!(character, '\n' | '\t') {
return true;
}
if character.is_control() {
return false;
}
!matches!(
character,
'\u{061c}'
| '\u{200e}'
| '\u{200f}'
| '\u{202a}'..='\u{202e}'
| '\u{2066}'..='\u{2069}'
)
}
fn event_from_content(content: ContentBlock, thought: bool) -> AcpEvent {
match content {
ContentBlock::Text(text) if thought => AcpEvent::AgentThought { text: text.text },
ContentBlock::Text(text) => AcpEvent::AgentText { text: text.text },
content => AcpEvent::AgentContent { content, thought },
}
}
fn supports_steering(response: &InitializeResponse) -> bool {
response
.meta
.as_ref()
.and_then(|meta| meta.get("steering"))
.and_then(serde_json::Value::as_object)
.and_then(|steering| steering.get("supported"))
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
}
fn steering_became_untracked(outcome: AcpSteeringOutcome) -> bool {
matches!(outcome, AcpSteeringOutcome::StartedNewTurn)
}
fn restorable_session_id(
requested_session_id: Option<&SessionId>,
can_load: bool,
) -> Result<Option<SessionId>, agent_client_protocol::Error> {
match (requested_session_id, can_load) {
(Some(session_id), true) => Ok(Some(session_id.clone())),
(Some(_), false) => Err(agent_client_protocol::Error::new(
-32004,
"the ACP agent cannot restore this persisted conversation because it does not advertise session/load; start a new ACP conversation",
)),
(None, true | false) => Ok(None),
}
}
async fn open_session<Load, LoadFuture, Create, CreateFuture>(
requested_session_id: Option<SessionId>,
load: Load,
create: Create,
) -> Result<SessionId, agent_client_protocol::Error>
where
Load: FnOnce(SessionId) -> LoadFuture,
LoadFuture: Future<Output = Result<(), agent_client_protocol::Error>>,
Create: FnOnce() -> CreateFuture,
CreateFuture: Future<Output = Result<SessionId, agent_client_protocol::Error>>,
{
if let Some(session_id) = requested_session_id {
load(session_id.clone()).await?;
return Ok(session_id);
}
create().await
}
fn emit_session_started(
events: &Sender<AcpEvent>,
session_id: SessionId,
can_load: bool,
can_steer: bool,
agent_info: Option<Implementation>,
capabilities: agent_client_protocol::schema::v1::AgentCapabilities,
) {
let _ = events.try_send(AcpEvent::SessionStarted {
session_id,
agent_info,
capabilities,
can_load,
can_steer,
});
}
fn emit_error(events: &Sender<AcpEvent>, message: &str) {
let _ = events.try_send(AcpEvent::Error {
message: message.to_owned(),
});
}
fn fail_queued_commands(command_rx: &Receiver<Command>, message: &str) {
while let Ok(command) = command_rx.try_recv() {
match command {
Command::RunTurn(turn) => emit_error(&turn.events, message),
Command::Cancel { ack, .. } => {
let _ = ack.send(Err(AcpRuntimeError::RuntimeClosed(message.to_owned())));
}
Command::Steer { ack, .. } => {
let _ = ack.send(Err(AcpRuntimeError::RuntimeClosed(message.to_owned())));
}
Command::AbortUntrackedSteering { ack, .. } => {
let _ = ack.send(Err(AcpRuntimeError::RuntimeClosed(message.to_owned())));
}
Command::SessionOpened { .. }
| Command::PromptFinished { .. }
| Command::ForceTeardown { .. }
| Command::ConnectionClosed
| Command::Shutdown
| Command::Discover { .. } => {}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)]
#[request(method = "_session/steering", response = SteeringResponse)]
#[serde(rename_all = "camelCase")]
struct SteeringRequest {
session_id: SessionId,
prompt: Vec<ContentBlock>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)]
#[serde(rename_all = "camelCase")]
struct SteeringResponse {
outcome: AcpSteeringOutcome,
}
impl Serialize for AcpSteeringOutcome {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let value = match self {
Self::Injected => "injected",
Self::StartedNewTurn => "startedNewTurn",
Self::Failed => "failed",
};
serializer.serialize_str(value)
}
}
impl<'de> Deserialize<'de> for AcpSteeringOutcome {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
match String::deserialize(deserializer)?.as_str() {
"injected" => Ok(Self::Injected),
"startedNewTurn" => Ok(Self::StartedNewTurn),
"failed" => Ok(Self::Failed),
value => Err(serde::de::Error::unknown_variant(
value,
&["injected", "startedNewTurn", "failed"],
)),
}
}
}
#[cfg(test)]
#[path = "runtime_tests.rs"]
mod tests;