ACP work
This commit is contained in:
@@ -0,0 +1,486 @@
|
||||
use std::collections::{BTreeMap, HashMap, VecDeque};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use base64::Engine as _;
|
||||
use futures::future::{BoxFuture, Fuse, FusedFuture as _};
|
||||
use futures::stream::FusedStream as _;
|
||||
use futures::{FutureExt as _, StreamExt as _};
|
||||
use galaxy_agent_core::{
|
||||
AgentError, AgentErrorKind, AgentEvent, AgentEventStream, AgentRuntime, ContentPart,
|
||||
MessageContent, RuntimeActivity, RuntimeActivityStatus, RuntimeCapabilities, RuntimeDescriptor,
|
||||
RuntimeKind, StopReason as AgentStopReason, TurnCommand, TurnControl, TurnRequest,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
AcpEvent, AcpPermissionPolicy, AcpRuntimeError, AcpSessionHandle, AcpSessionManager,
|
||||
AcpSteeringOutcome, AcpTurnRequest, ContentBlock, ImageContent, McpServer, SessionConfigOption,
|
||||
SessionConfigOptionValue, SessionId, StopReason, TextContent, ToolCallId, ToolCallStatus,
|
||||
};
|
||||
|
||||
/// Session state learned from the connected ACP agent.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct AcpRuntimeState {
|
||||
pub session_id: Option<String>,
|
||||
pub can_load: bool,
|
||||
pub can_steer: bool,
|
||||
pub config_options: Vec<SessionConfigOption>,
|
||||
}
|
||||
|
||||
pub type AcpRuntimeStateHandle = Arc<Mutex<AcpRuntimeState>>;
|
||||
|
||||
/// ACP-specific session inputs that are stable for one Galaxy turn.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AcpAgentRuntimeConfig {
|
||||
pub runtime_id: String,
|
||||
pub display_name: String,
|
||||
pub config_values: BTreeMap<String, SessionConfigOptionValue>,
|
||||
pub session_id: Option<SessionId>,
|
||||
pub cwd: PathBuf,
|
||||
pub additional_directories: Vec<PathBuf>,
|
||||
pub mcp_servers: Vec<McpServer>,
|
||||
pub auto_approve_permissions: bool,
|
||||
pub permission_policy: AcpPermissionPolicy,
|
||||
}
|
||||
|
||||
impl AcpAgentRuntimeConfig {
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
runtime_id: impl Into<String>,
|
||||
display_name: impl Into<String>,
|
||||
cwd: impl Into<PathBuf>,
|
||||
) -> Self {
|
||||
Self {
|
||||
runtime_id: runtime_id.into(),
|
||||
display_name: display_name.into(),
|
||||
config_values: BTreeMap::new(),
|
||||
session_id: None,
|
||||
cwd: cwd.into(),
|
||||
additional_directories: Vec::new(),
|
||||
mcp_servers: Vec::new(),
|
||||
auto_approve_permissions: false,
|
||||
permission_policy: AcpPermissionPolicy::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Adapts an official ACP client session to Galaxy's provider-neutral runtime contract.
|
||||
pub struct AcpAgentRuntime {
|
||||
manager: AcpSessionManager,
|
||||
config: AcpAgentRuntimeConfig,
|
||||
descriptor: RuntimeDescriptor,
|
||||
state: AcpRuntimeStateHandle,
|
||||
}
|
||||
|
||||
impl AcpAgentRuntime {
|
||||
#[must_use]
|
||||
pub fn new(manager: AcpSessionManager, config: AcpAgentRuntimeConfig) -> Self {
|
||||
let descriptor = RuntimeDescriptor {
|
||||
id: config.runtime_id.clone(),
|
||||
display_name: config.display_name.clone(),
|
||||
kind: RuntimeKind::Acp,
|
||||
capabilities: RuntimeCapabilities {
|
||||
model_selection: false,
|
||||
session_resume: true,
|
||||
steering: true,
|
||||
tool_permissions: true,
|
||||
},
|
||||
};
|
||||
Self {
|
||||
manager,
|
||||
config,
|
||||
descriptor,
|
||||
state: Arc::new(Mutex::new(AcpRuntimeState::default())),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_state(mut self, state: AcpRuntimeStateHandle) -> Self {
|
||||
self.state = state;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn state(&self) -> AcpRuntimeStateHandle {
|
||||
Arc::clone(&self.state)
|
||||
}
|
||||
|
||||
fn acp_turn_request(&self, request: TurnRequest) -> Result<AcpTurnRequest, AgentError> {
|
||||
if request.model.as_str() != self.descriptor.id {
|
||||
return Err(invalid_request(format!(
|
||||
"ACP runtime '{}' cannot serve selection '{}'",
|
||||
self.descriptor.id,
|
||||
request.model.as_str()
|
||||
)));
|
||||
}
|
||||
if !request.messages.is_empty() {
|
||||
return Err(invalid_request(
|
||||
"ACP sessions own conversation history; only the explicit current prompt is accepted",
|
||||
));
|
||||
}
|
||||
if request.max_output_tokens.is_some() {
|
||||
return Err(invalid_request(
|
||||
"ACP agents own their output limit; Galaxy cannot apply a provider token limit",
|
||||
));
|
||||
}
|
||||
let conversation_key = request
|
||||
.conversation_id
|
||||
.ok_or_else(|| invalid_request("ACP turns require a stable Galaxy conversation ID"))?;
|
||||
let prompt = request
|
||||
.prompt
|
||||
.ok_or_else(|| invalid_request("ACP turns require an explicit current prompt"))?;
|
||||
if request.system_prompt.is_some() {
|
||||
return Err(invalid_request(
|
||||
"ACP agents own their system prompt; Galaxy cannot send a provider system message",
|
||||
));
|
||||
}
|
||||
if !request.tools.is_empty() {
|
||||
return Err(invalid_request(
|
||||
"ACP tools must be attached as MCP servers, not provider tool definitions",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(AcpTurnRequest {
|
||||
config_values: self.config.config_values.clone(),
|
||||
conversation_key,
|
||||
session_id: self.config.session_id.clone(),
|
||||
cwd: self.config.cwd.clone(),
|
||||
additional_directories: self.config.additional_directories.clone(),
|
||||
prompt: prompt_content(prompt)?,
|
||||
mcp_servers: self.config.mcp_servers.clone(),
|
||||
auto_approve_permissions: self.config.auto_approve_permissions,
|
||||
permission_policy: self.config.permission_policy,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AgentRuntime for AcpAgentRuntime {
|
||||
fn descriptor(&self) -> &RuntimeDescriptor {
|
||||
&self.descriptor
|
||||
}
|
||||
|
||||
async fn start_turn(
|
||||
&self,
|
||||
request: TurnRequest,
|
||||
control: TurnControl,
|
||||
) -> Result<AgentEventStream, AgentError> {
|
||||
let request = self.acp_turn_request(request)?;
|
||||
let (session, events) = self
|
||||
.manager
|
||||
.run_turn(request)
|
||||
.map_err(agent_error_from_runtime)?;
|
||||
Ok(acp_event_stream(
|
||||
session,
|
||||
events,
|
||||
control,
|
||||
Arc::clone(&self.state),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
type SteeringResult = Result<AcpSteeringOutcome, AcpRuntimeError>;
|
||||
type PendingSteering = Fuse<BoxFuture<'static, SteeringResult>>;
|
||||
|
||||
fn pending_steering(session: AcpSessionHandle, model_text: String) -> PendingSteering {
|
||||
async move {
|
||||
session
|
||||
.steer(vec![ContentBlock::Text(TextContent::new(model_text))])
|
||||
.await
|
||||
}
|
||||
.boxed()
|
||||
.fuse()
|
||||
}
|
||||
|
||||
fn acp_event_stream(
|
||||
session: AcpSessionHandle,
|
||||
events: async_channel::Receiver<AcpEvent>,
|
||||
control: TurnControl,
|
||||
state: AcpRuntimeStateHandle,
|
||||
) -> AgentEventStream {
|
||||
let stream = async_stream::stream! {
|
||||
let mut events = Box::pin(events.fuse());
|
||||
let mut control_open = true;
|
||||
let mut steering_queue = VecDeque::new();
|
||||
let mut steering_result: PendingSteering = Fuse::terminated();
|
||||
let mut tool_titles = HashMap::new();
|
||||
|
||||
loop {
|
||||
let command = if control_open {
|
||||
control.receive().boxed().fuse()
|
||||
} else {
|
||||
Fuse::terminated()
|
||||
};
|
||||
futures::pin_mut!(command);
|
||||
futures::select_biased! {
|
||||
command = command => match command {
|
||||
Ok(TurnCommand::Cancel) => {
|
||||
if let Err(error) = session.cancel().await {
|
||||
log::warn!("Failed to cancel ACP turn cleanly: {error}");
|
||||
}
|
||||
yield Ok(AgentEvent::TurnStopped {
|
||||
reason: AgentStopReason::Cancelled,
|
||||
});
|
||||
return;
|
||||
}
|
||||
Ok(TurnCommand::Steer { display_text, model_text }) => {
|
||||
yield Ok(AgentEvent::UserInputAccepted { text: display_text });
|
||||
if steering_result.is_terminated() {
|
||||
steering_result = pending_steering(session.clone(), model_text);
|
||||
} else {
|
||||
steering_queue.push_back(model_text);
|
||||
}
|
||||
}
|
||||
Err(_) => control_open = false,
|
||||
},
|
||||
steering = steering_result => {
|
||||
match steering {
|
||||
Ok(AcpSteeringOutcome::Injected) => {}
|
||||
Ok(AcpSteeringOutcome::StartedNewTurn) => {
|
||||
yield Ok(AgentEvent::RuntimeNotice {
|
||||
message: "The ACP adapter started that steering message as a separate turn instead of injecting it into the active one. Galaxy terminated the adapter process immediately, but the turn may have begun acting; check the current terminal and file state before retrying.".to_owned(),
|
||||
});
|
||||
}
|
||||
Ok(AcpSteeringOutcome::Failed) => {
|
||||
yield Ok(AgentEvent::RuntimeNotice {
|
||||
message: steering_failure_notice(
|
||||
"the ACP agent could not inject it into the active turn",
|
||||
),
|
||||
});
|
||||
}
|
||||
Err(error) => {
|
||||
log::warn!("ACP live steering failed: {error}");
|
||||
yield Ok(AgentEvent::RuntimeNotice {
|
||||
message: steering_failure_notice(&error.to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
steering_result = Fuse::terminated();
|
||||
if let Some(model_text) = steering_queue.pop_front() {
|
||||
steering_result = pending_steering(session.clone(), model_text);
|
||||
} else if events.is_terminated() {
|
||||
yield Err(AgentError::new(
|
||||
AgentErrorKind::Protocol,
|
||||
"ACP event stream closed before the turn reported completion",
|
||||
));
|
||||
return;
|
||||
}
|
||||
},
|
||||
event = events.next() => {
|
||||
let Some(event) = event else {
|
||||
if steering_result.is_terminated() && steering_queue.is_empty() {
|
||||
yield Err(AgentError::new(
|
||||
AgentErrorKind::Protocol,
|
||||
"ACP event stream closed before the turn reported completion",
|
||||
));
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
};
|
||||
match map_event(event, &state, &mut tool_titles) {
|
||||
Ok(Some(event)) => {
|
||||
let stopped = matches!(event, AgentEvent::TurnStopped { .. });
|
||||
yield Ok(event);
|
||||
if stopped {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
yield Err(error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
Box::pin(stream)
|
||||
}
|
||||
|
||||
fn map_event(
|
||||
event: AcpEvent,
|
||||
state: &AcpRuntimeStateHandle,
|
||||
tool_titles: &mut HashMap<ToolCallId, String>,
|
||||
) -> Result<Option<AgentEvent>, AgentError> {
|
||||
let event = match event {
|
||||
AcpEvent::SessionStarted {
|
||||
session_id,
|
||||
can_load,
|
||||
can_steer,
|
||||
..
|
||||
} => {
|
||||
if let Ok(mut state) = state.lock() {
|
||||
state.session_id = Some(session_id.to_string());
|
||||
state.can_load = can_load;
|
||||
state.can_steer = can_steer;
|
||||
}
|
||||
Some(AgentEvent::TurnStarted {
|
||||
runtime_request_id: session_id.to_string(),
|
||||
})
|
||||
}
|
||||
AcpEvent::AgentText { text } => Some(AgentEvent::TextDelta { text }),
|
||||
AcpEvent::AgentThought { text } => Some(AgentEvent::ReasoningDelta { text }),
|
||||
AcpEvent::AgentContent { content, thought } => map_content(content, thought),
|
||||
// Initial prompt echoes can contain hidden Galaxy context. Steering is
|
||||
// emitted from the accepted command above, so no ACP echo is trusted.
|
||||
AcpEvent::UserContent { .. } => None,
|
||||
AcpEvent::ToolCall {
|
||||
id,
|
||||
title,
|
||||
status,
|
||||
output,
|
||||
} => {
|
||||
tool_titles.insert(id.clone(), title.clone());
|
||||
Some(AgentEvent::RuntimeActivityUpdated {
|
||||
activity: RuntimeActivity {
|
||||
id: id.to_string(),
|
||||
title,
|
||||
status: Some(activity_status(status)),
|
||||
output,
|
||||
},
|
||||
})
|
||||
}
|
||||
AcpEvent::ToolCallUpdate {
|
||||
id,
|
||||
title,
|
||||
status,
|
||||
output,
|
||||
} => {
|
||||
let title = title
|
||||
.or_else(|| tool_titles.get(&id).cloned())
|
||||
.unwrap_or_else(|| "tool".to_owned());
|
||||
tool_titles.insert(id.clone(), title.clone());
|
||||
Some(AgentEvent::RuntimeActivityUpdated {
|
||||
activity: RuntimeActivity {
|
||||
id: id.to_string(),
|
||||
title,
|
||||
status: status.map(activity_status),
|
||||
output,
|
||||
},
|
||||
})
|
||||
}
|
||||
AcpEvent::ConfigOptions { options } => {
|
||||
if let Ok(mut state) = state.lock() {
|
||||
state.config_options = options;
|
||||
}
|
||||
None
|
||||
}
|
||||
AcpEvent::Usage { used, size, .. } => Some(AgentEvent::ContextUsageUpdated {
|
||||
used_tokens: used,
|
||||
context_size: size,
|
||||
}),
|
||||
AcpEvent::PermissionRequested { request } => Some(AgentEvent::RuntimeNotice {
|
||||
message: format!(
|
||||
"\n\n> Permission requested for: {}\n",
|
||||
request.tool_call.fields.title.as_deref().unwrap_or("tool")
|
||||
),
|
||||
}),
|
||||
AcpEvent::PermissionResolved { decision, .. } => Some(AgentEvent::RuntimeNotice {
|
||||
message: format!("\n\n> Permission decision: {decision:?}\n"),
|
||||
}),
|
||||
AcpEvent::Finished { stop_reason } => Some(AgentEvent::TurnStopped {
|
||||
reason: stop_reason_from_acp(stop_reason),
|
||||
}),
|
||||
AcpEvent::Error { message } => {
|
||||
return Err(AgentError::new(AgentErrorKind::Protocol, message));
|
||||
}
|
||||
};
|
||||
Ok(event)
|
||||
}
|
||||
|
||||
fn map_content(content: ContentBlock, thought: bool) -> Option<AgentEvent> {
|
||||
let text = match content {
|
||||
ContentBlock::Text(text) => text.text,
|
||||
ContentBlock::Image(_) => "[Agent returned an image.]".to_owned(),
|
||||
ContentBlock::Audio(_) => "[Agent returned audio.]".to_owned(),
|
||||
ContentBlock::ResourceLink(resource) => format!("[Agent referenced {}.]", resource.name),
|
||||
ContentBlock::Resource(_) => "[Agent returned embedded resource content.]".to_owned(),
|
||||
_ => "[Agent returned unsupported content.]".to_owned(),
|
||||
};
|
||||
if thought {
|
||||
Some(AgentEvent::ReasoningDelta { text })
|
||||
} else {
|
||||
Some(AgentEvent::TextDelta { text })
|
||||
}
|
||||
}
|
||||
|
||||
fn activity_status(status: ToolCallStatus) -> RuntimeActivityStatus {
|
||||
match status {
|
||||
ToolCallStatus::Pending => RuntimeActivityStatus::Pending,
|
||||
ToolCallStatus::InProgress => RuntimeActivityStatus::InProgress,
|
||||
ToolCallStatus::Completed => RuntimeActivityStatus::Completed,
|
||||
ToolCallStatus::Failed => RuntimeActivityStatus::Failed,
|
||||
_ => RuntimeActivityStatus::Other("updated".to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
fn stop_reason_from_acp(reason: StopReason) -> AgentStopReason {
|
||||
match reason {
|
||||
StopReason::EndTurn => AgentStopReason::Completed,
|
||||
StopReason::Cancelled => AgentStopReason::Cancelled,
|
||||
StopReason::MaxTokens | StopReason::MaxTurnRequests => AgentStopReason::MaxTokens,
|
||||
StopReason::Refusal => AgentStopReason::Refusal,
|
||||
_ => AgentStopReason::Other(format!("{reason:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_content(content: MessageContent) -> Result<Vec<ContentBlock>, AgentError> {
|
||||
match content {
|
||||
MessageContent::Text(text) => Ok(vec![ContentBlock::Text(TextContent::new(text))]),
|
||||
MessageContent::MultiPart(parts) => parts.into_iter().map(prompt_part).collect(),
|
||||
MessageContent::ToolUse { .. } | MessageContent::ToolResult { .. } => Err(invalid_request(
|
||||
"ACP prompts cannot contain provider tool-use history",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_part(part: ContentPart) -> Result<ContentBlock, AgentError> {
|
||||
match part {
|
||||
ContentPart::Text(text) => Ok(ContentBlock::Text(TextContent::new(text))),
|
||||
ContentPart::Image { data, mime_type } => {
|
||||
let image = ImageContent::new(
|
||||
base64::engine::general_purpose::STANDARD.encode(data),
|
||||
mime_type,
|
||||
);
|
||||
Ok(ContentBlock::Image(image))
|
||||
}
|
||||
ContentPart::Reasoning { .. }
|
||||
| ContentPart::ToolUse { .. }
|
||||
| ContentPart::ToolResult { .. } => Err(invalid_request(
|
||||
"ACP prompts support only user text and image content",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn steering_failure_notice(error: &str) -> String {
|
||||
format!(
|
||||
"Galaxy couldn't confirm that live steering message: {error}. The agent may not have received it; check the current terminal and file state before retrying."
|
||||
)
|
||||
}
|
||||
|
||||
fn invalid_request(message: impl Into<String>) -> AgentError {
|
||||
AgentError::new(AgentErrorKind::InvalidRequest, message)
|
||||
}
|
||||
|
||||
fn agent_error_from_runtime(error: AcpRuntimeError) -> AgentError {
|
||||
let kind = match error {
|
||||
AcpRuntimeError::InvalidTurn(_) => AgentErrorKind::InvalidRequest,
|
||||
AcpRuntimeError::AuthenticationTimeout(_) => AgentErrorKind::Authentication,
|
||||
AcpRuntimeError::WorkerSpawn(_)
|
||||
| AcpRuntimeError::RuntimeClosed(_)
|
||||
| AcpRuntimeError::InitializationTimeout(_)
|
||||
| AcpRuntimeError::DiscoveryTimeout(_) => AgentErrorKind::Transport,
|
||||
AcpRuntimeError::ProcessTreeTeardownUnsupported => AgentErrorKind::Configuration,
|
||||
AcpRuntimeError::TurnNotActive
|
||||
| AcpRuntimeError::SteeringUnsupported
|
||||
| AcpRuntimeError::SteeringTimeout(_)
|
||||
| AcpRuntimeError::Protocol(_) => AgentErrorKind::Protocol,
|
||||
};
|
||||
AgentError::new(kind, error.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "agent_runtime_tests.rs"]
|
||||
mod tests;
|
||||
Reference in New Issue
Block a user