ACP work
This commit is contained in:
@@ -7,8 +7,12 @@ publish = false
|
||||
[dependencies]
|
||||
agent-client-protocol.workspace = true
|
||||
async-channel.workspace = true
|
||||
async-stream.workspace = true
|
||||
async-trait.workspace = true
|
||||
base64.workspace = true
|
||||
async-io.workspace = true
|
||||
futures.workspace = true
|
||||
galaxy_agent_core.workspace = true
|
||||
log.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,127 @@
|
||||
use agent_client_protocol::schema::v1::{
|
||||
AgentCapabilities, ContentBlock, SessionId, TextContent, ToolCallId, ToolCallStatus,
|
||||
};
|
||||
use galaxy_agent_core::{
|
||||
AgentEvent, ContentPart, MessageContent, RuntimeActivity, RuntimeActivityStatus,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shared_prompt_converts_once_to_official_acp_content() {
|
||||
let prompt = MessageContent::MultiPart(vec![
|
||||
ContentPart::Text("describe this".to_owned()),
|
||||
ContentPart::Image {
|
||||
data: b"image".to_vec(),
|
||||
mime_type: "image/png".to_owned(),
|
||||
},
|
||||
]);
|
||||
|
||||
let content = prompt_content(prompt).expect("prompt conversion");
|
||||
|
||||
assert!(matches!(
|
||||
&content[0],
|
||||
ContentBlock::Text(text) if text.text == "describe this"
|
||||
));
|
||||
assert!(matches!(
|
||||
&content[1],
|
||||
ContentBlock::Image(image)
|
||||
if image.data == "aW1hZ2U="
|
||||
&& image.mime_type == "image/png"
|
||||
&& image.uri.is_none()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_tool_history_is_rejected_as_an_acp_prompt() {
|
||||
let error = prompt_content(MessageContent::ToolUse {
|
||||
tool_use_id: "tool-1".to_owned(),
|
||||
name: "shell".to_owned(),
|
||||
input: serde_json::json!({}),
|
||||
})
|
||||
.expect_err("tool history must be rejected");
|
||||
|
||||
assert_eq!(error.kind, AgentErrorKind::InvalidRequest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_start_updates_state_before_emitting_turn_started() {
|
||||
let state = Arc::new(Mutex::new(AcpRuntimeState::default()));
|
||||
let mut tool_titles = HashMap::new();
|
||||
|
||||
let event = map_event(
|
||||
AcpEvent::SessionStarted {
|
||||
session_id: SessionId::new("session-42"),
|
||||
agent_info: None,
|
||||
capabilities: AgentCapabilities::default(),
|
||||
can_load: true,
|
||||
can_steer: true,
|
||||
},
|
||||
&state,
|
||||
&mut tool_titles,
|
||||
)
|
||||
.expect("event mapping");
|
||||
|
||||
assert_eq!(
|
||||
event,
|
||||
Some(AgentEvent::TurnStarted {
|
||||
runtime_request_id: "session-42".to_owned(),
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
*state.lock().expect("state"),
|
||||
AcpRuntimeState {
|
||||
session_id: Some("session-42".to_owned()),
|
||||
can_load: true,
|
||||
can_steer: true,
|
||||
config_options: Vec::new(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_tool_progress_is_runtime_activity_not_a_host_tool_proposal() {
|
||||
let state = Arc::new(Mutex::new(AcpRuntimeState::default()));
|
||||
let mut tool_titles = HashMap::new();
|
||||
|
||||
let event = map_event(
|
||||
AcpEvent::ToolCall {
|
||||
id: ToolCallId::new("tool-1"),
|
||||
title: "Run tests".to_owned(),
|
||||
status: ToolCallStatus::InProgress,
|
||||
output: Some("test_a ... ok".to_owned()),
|
||||
},
|
||||
&state,
|
||||
&mut tool_titles,
|
||||
)
|
||||
.expect("event mapping");
|
||||
|
||||
assert_eq!(
|
||||
event,
|
||||
Some(AgentEvent::RuntimeActivityUpdated {
|
||||
activity: RuntimeActivity {
|
||||
id: "tool-1".to_owned(),
|
||||
title: "Run tests".to_owned(),
|
||||
status: Some(RuntimeActivityStatus::InProgress),
|
||||
output: Some("test_a ... ok".to_owned()),
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsolicited_user_echo_is_not_exposed_as_visible_input() {
|
||||
let state = Arc::new(Mutex::new(AcpRuntimeState::default()));
|
||||
let mut tool_titles = HashMap::new();
|
||||
|
||||
let event = map_event(
|
||||
AcpEvent::UserContent {
|
||||
content: ContentBlock::Text(TextContent::new("hidden Galaxy context")),
|
||||
},
|
||||
&state,
|
||||
&mut tool_titles,
|
||||
)
|
||||
.expect("event mapping");
|
||||
|
||||
assert_eq!(event, None);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
//! Runtime support for driving ACP agents from Galaxy.
|
||||
//!
|
||||
//! This crate deliberately contains no Galaxy UI or application-model code. It
|
||||
//! owns the ACP subprocess and translates the stable ACP v1 stream into a small
|
||||
//! event surface that the app can consume.
|
||||
//! owns the official SDK connection and subprocess, then exposes ACP sessions
|
||||
//! through `galaxy_agent_core::AgentRuntime`. Low-level ACP events remain an
|
||||
//! internal protocol boundary rather than leaking into application UI code.
|
||||
|
||||
mod agent_runtime;
|
||||
mod config;
|
||||
mod events;
|
||||
mod permissions;
|
||||
@@ -15,6 +17,9 @@ pub use agent_client_protocol::schema::v1::{
|
||||
SessionConfigOptionCategory, SessionConfigOptionValue, SessionConfigSelectOption,
|
||||
SessionConfigSelectOptions, SessionId, StopReason, TextContent, ToolCallId, ToolCallStatus,
|
||||
};
|
||||
pub use agent_runtime::{
|
||||
AcpAgentRuntime, AcpAgentRuntimeConfig, AcpRuntimeState, AcpRuntimeStateHandle,
|
||||
};
|
||||
pub use config::{
|
||||
AcpAgentPreset, AcpLaunchConfig, AcpManagerConfig, CODEX_ACP_NPM_VERSION, OPENCODE_NPM_VERSION,
|
||||
};
|
||||
|
||||
+72
-25
@@ -112,8 +112,6 @@ pub struct AcpTurnRequest {
|
||||
pub auto_approve_permissions: bool,
|
||||
/// Category permissions inherited from Galaxy's active execution profile.
|
||||
pub permission_policy: AcpPermissionPolicy,
|
||||
/// Prompt capabilities advertised by the agent.
|
||||
pub prompt_capabilities: agent_client_protocol::schema::v1::PromptCapabilities,
|
||||
}
|
||||
|
||||
impl AcpTurnRequest {
|
||||
@@ -134,7 +132,6 @@ impl AcpTurnRequest {
|
||||
mcp_servers: Vec::new(),
|
||||
auto_approve_permissions: false,
|
||||
permission_policy: AcpPermissionPolicy::default(),
|
||||
prompt_capabilities: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,26 +220,6 @@ impl AcpTurnRequest {
|
||||
}
|
||||
}
|
||||
}
|
||||
for content in &self.prompt {
|
||||
match content {
|
||||
ContentBlock::Image(_) if !self.prompt_capabilities.image => {
|
||||
return Err(AcpRuntimeError::InvalidTurn(
|
||||
"agent does not advertise image prompt support".to_owned(),
|
||||
));
|
||||
}
|
||||
ContentBlock::Audio(_) if !self.prompt_capabilities.audio => {
|
||||
return Err(AcpRuntimeError::InvalidTurn(
|
||||
"agent does not advertise audio prompt support".to_owned(),
|
||||
));
|
||||
}
|
||||
ContentBlock::Resource(_) if !self.prompt_capabilities.embedded_context => {
|
||||
return Err(AcpRuntimeError::InvalidTurn(
|
||||
"agent does not advertise embedded context support".to_owned(),
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -980,6 +957,11 @@ impl RuntimeActor {
|
||||
)
|
||||
};
|
||||
|
||||
validate_prompt_capabilities(
|
||||
&request.prompt,
|
||||
&self.agent_capabilities.prompt_capabilities,
|
||||
)?;
|
||||
|
||||
if ready {
|
||||
let session_id = session_id
|
||||
.ok_or_else(|| AcpRuntimeError::Protocol("ready session has no id".to_owned()))?;
|
||||
@@ -1525,6 +1507,45 @@ impl RuntimeActor {
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_prompt_capabilities(
|
||||
prompt: &[ContentBlock],
|
||||
capabilities: &agent_client_protocol::schema::v1::PromptCapabilities,
|
||||
) -> Result<(), AcpRuntimeError> {
|
||||
for content in prompt {
|
||||
match content {
|
||||
ContentBlock::Image(_) => require_prompt_capability(
|
||||
capabilities.image,
|
||||
"agent does not advertise image prompt support",
|
||||
)?,
|
||||
ContentBlock::Audio(_) => require_prompt_capability(
|
||||
capabilities.audio,
|
||||
"agent does not advertise audio prompt support",
|
||||
)?,
|
||||
ContentBlock::Resource(_) => require_prompt_capability(
|
||||
capabilities.embedded_context,
|
||||
"agent does not advertise embedded context support",
|
||||
)?,
|
||||
ContentBlock::Text(_) | ContentBlock::ResourceLink(_) => {}
|
||||
// ACP content is forward-compatible. New prompt types must be
|
||||
// rejected until the SDK exposes a matching capability bit.
|
||||
_ => {
|
||||
return Err(AcpRuntimeError::InvalidTurn(
|
||||
"agent prompt contains an unsupported ACP content type".to_owned(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require_prompt_capability(supported: bool, message: &str) -> Result<(), AcpRuntimeError> {
|
||||
if supported {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AcpRuntimeError::InvalidTurn(message.to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
fn fail_conversations(conversations: &HashMap<String, ConversationState>, message: &str) {
|
||||
for state in conversations.values() {
|
||||
if let Some(active) = state.active.as_ref() {
|
||||
@@ -1811,6 +1832,7 @@ fn event_from_session_update(update: SessionUpdate) -> Option<AcpEvent> {
|
||||
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 title = visible_tool_title(&tool_call.title, tool_call.raw_input.as_ref());
|
||||
let output = visible_tool_output(
|
||||
&tool_call.content,
|
||||
tool_call.raw_output.as_ref(),
|
||||
@@ -1818,12 +1840,18 @@ fn event_from_session_update(update: SessionUpdate) -> Option<AcpEvent> {
|
||||
);
|
||||
Some(AcpEvent::ToolCall {
|
||||
id: tool_call.tool_call_id,
|
||||
title: tool_call.title,
|
||||
title,
|
||||
status: tool_call.status,
|
||||
output,
|
||||
})
|
||||
}
|
||||
SessionUpdate::ToolCallUpdate(update) => {
|
||||
let title = update
|
||||
.fields
|
||||
.raw_input
|
||||
.as_ref()
|
||||
.and_then(command_from_raw_input)
|
||||
.or(update.fields.title);
|
||||
let output = visible_tool_output(
|
||||
update.fields.content.as_deref().unwrap_or_default(),
|
||||
update.fields.raw_output.as_ref(),
|
||||
@@ -1831,7 +1859,7 @@ fn event_from_session_update(update: SessionUpdate) -> Option<AcpEvent> {
|
||||
);
|
||||
Some(AcpEvent::ToolCallUpdate {
|
||||
id: update.tool_call_id,
|
||||
title: update.fields.title,
|
||||
title,
|
||||
status: update.fields.status,
|
||||
output,
|
||||
})
|
||||
@@ -1857,6 +1885,25 @@ fn event_from_session_update(update: SessionUpdate) -> Option<AcpEvent> {
|
||||
}
|
||||
}
|
||||
|
||||
fn visible_tool_title(title: &str, raw_input: Option<&serde_json::Value>) -> String {
|
||||
raw_input
|
||||
.and_then(command_from_raw_input)
|
||||
.unwrap_or_else(|| title.to_owned())
|
||||
}
|
||||
|
||||
fn command_from_raw_input(raw_input: &serde_json::Value) -> Option<String> {
|
||||
let object = raw_input.as_object()?;
|
||||
let command = ["command", "cmd", "shell_command", "shellCommand"]
|
||||
.into_iter()
|
||||
.find_map(|key| object.get(key).and_then(serde_json::Value::as_str))?;
|
||||
let mut visible = VisibleToolOutput::default();
|
||||
visible.append(command.trim());
|
||||
visible
|
||||
.finish()
|
||||
.map(|command| command.lines().collect::<Vec<_>>().join(" "))
|
||||
.filter(|command| !command.is_empty())
|
||||
}
|
||||
|
||||
fn visible_tool_output(
|
||||
content: &[ToolCallContent],
|
||||
raw_output: Option<&serde_json::Value>,
|
||||
|
||||
@@ -7,10 +7,10 @@ use std::task::{Context, Poll};
|
||||
use std::time::Duration;
|
||||
|
||||
use agent_client_protocol::schema::v1::{
|
||||
AuthMethod, AuthMethodAgent, AuthMethodId, ContentBlock, ContentChunk, InitializeResponse,
|
||||
McpServer, McpServerStdio, SessionConfigOption, SessionConfigOptionValue,
|
||||
SessionConfigSelectOption, SessionId, SessionUpdate, TextContent, ToolCall, ToolCallStatus,
|
||||
ToolCallUpdate, ToolCallUpdateFields, UsageUpdate,
|
||||
AuthMethod, AuthMethodAgent, AuthMethodId, ContentBlock, ContentChunk, ImageContent,
|
||||
InitializeResponse, McpServer, McpServerStdio, PromptCapabilities, SessionConfigOption,
|
||||
SessionConfigOptionValue, SessionConfigSelectOption, SessionId, SessionUpdate, TextContent,
|
||||
ToolCall, ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, UsageUpdate,
|
||||
};
|
||||
use agent_client_protocol::schema::ProtocolVersion;
|
||||
|
||||
@@ -100,6 +100,21 @@ fn turn_validation_rejects_relative_mcp_commands() {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_media_uses_capabilities_advertised_by_the_connected_agent() {
|
||||
let prompt = vec![ContentBlock::Image(ImageContent::new(
|
||||
"aW1hZ2U=",
|
||||
"image/png",
|
||||
))];
|
||||
|
||||
let error = validate_prompt_capabilities(&prompt, &PromptCapabilities::default())
|
||||
.expect_err("image capability is required");
|
||||
assert!(error.to_string().contains("image prompt support"));
|
||||
|
||||
validate_prompt_capabilities(&prompt, &PromptCapabilities::new().image(true))
|
||||
.expect("advertised image support");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_session_restore_is_rejected_when_not_advertised() {
|
||||
let requested = SessionId::new("persisted");
|
||||
@@ -312,6 +327,24 @@ fn tool_and_usage_updates_are_visible_events() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_command_from_raw_input_becomes_the_visible_title() {
|
||||
let tool = event_from_session_update(SessionUpdate::ToolCall(
|
||||
ToolCall::new("tool-1", "shell")
|
||||
.raw_input(serde_json::json!({ "cmd": "git status --short" })),
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
tool,
|
||||
Some(AcpEvent::ToolCall {
|
||||
id: "tool-1".into(),
|
||||
title: "git status --short".to_owned(),
|
||||
status: ToolCallStatus::Pending,
|
||||
output: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_content_is_sanitized_before_becoming_visible_output() {
|
||||
let tool = event_from_session_update(SessionUpdate::ToolCall(
|
||||
|
||||
@@ -34,7 +34,10 @@ pub struct RuntimeDescriptor {
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum TurnCommand {
|
||||
Cancel,
|
||||
Steer { text: String },
|
||||
Steer {
|
||||
display_text: String,
|
||||
model_text: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
|
||||
@@ -103,7 +103,8 @@ fn turn_control_delivers_cancel_and_steering_in_order() {
|
||||
|
||||
sender
|
||||
.send(TurnCommand::Steer {
|
||||
text: "focus on tests".to_string(),
|
||||
display_text: "focus on tests".to_string(),
|
||||
model_text: "focus on tests".to_string(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -112,7 +113,8 @@ fn turn_control_delivers_cancel_and_steering_in_order() {
|
||||
assert_eq!(
|
||||
control.receive().await.unwrap(),
|
||||
TurnCommand::Steer {
|
||||
text: "focus on tests".to_string(),
|
||||
display_text: "focus on tests".to_string(),
|
||||
model_text: "focus on tests".to_string(),
|
||||
}
|
||||
);
|
||||
assert_eq!(control.receive().await.unwrap(), TurnCommand::Cancel);
|
||||
|
||||
@@ -99,6 +99,13 @@ pub struct TurnRequest {
|
||||
pub conversation_id: Option<String>,
|
||||
pub model: ModelId,
|
||||
pub system_prompt: Option<String>,
|
||||
/// Current runtime input when the runtime owns conversation history.
|
||||
///
|
||||
/// Provider runtimes normally consume `messages`, while session-oriented
|
||||
/// runtimes such as ACP consume this single prompt and retain their own
|
||||
/// history. Keeping the distinction explicit prevents applications from
|
||||
/// serializing a prompt into provider history and translating it back.
|
||||
pub prompt: Option<MessageContent>,
|
||||
pub messages: Vec<ConversationMessage>,
|
||||
pub tools: Vec<ToolDefinition>,
|
||||
pub max_output_tokens: Option<u64>,
|
||||
@@ -111,12 +118,18 @@ impl TurnRequest {
|
||||
conversation_id: None,
|
||||
model: model.into(),
|
||||
system_prompt: None,
|
||||
prompt: None,
|
||||
messages,
|
||||
tools: Vec::new(),
|
||||
max_output_tokens: None,
|
||||
metadata: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_prompt(mut self, prompt: MessageContent) -> Self {
|
||||
self.prompt = Some(prompt);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -233,6 +246,23 @@ pub enum StopReason {
|
||||
Other(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum RuntimeActivityStatus {
|
||||
Pending,
|
||||
InProgress,
|
||||
Completed,
|
||||
Failed,
|
||||
Other(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RuntimeActivity {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub status: Option<RuntimeActivityStatus>,
|
||||
pub output: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub enum AgentEvent {
|
||||
TurnStarted {
|
||||
@@ -254,6 +284,23 @@ pub enum AgentEvent {
|
||||
UsageUpdated {
|
||||
usage: Usage,
|
||||
},
|
||||
/// Progress owned by a session runtime rather than a Galaxy-executed tool.
|
||||
RuntimeActivityUpdated {
|
||||
activity: RuntimeActivity,
|
||||
},
|
||||
/// Current context occupancy reported by a session runtime.
|
||||
ContextUsageUpdated {
|
||||
used_tokens: u64,
|
||||
context_size: u64,
|
||||
},
|
||||
/// Visible user input accepted while a turn was already running.
|
||||
UserInputAccepted {
|
||||
text: String,
|
||||
},
|
||||
/// A displayable runtime lifecycle or permission notice.
|
||||
RuntimeNotice {
|
||||
message: String,
|
||||
},
|
||||
TurnStopped {
|
||||
reason: StopReason,
|
||||
},
|
||||
|
||||
@@ -91,3 +91,15 @@ fn denied_results_are_errors_but_cancelled_results_are_distinct() {
|
||||
assert!(denied.is_error());
|
||||
assert!(!cancelled.is_error());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_runtime_prompt_is_distinct_from_provider_history() {
|
||||
let request = TurnRequest::new("acp:codex", Vec::new())
|
||||
.with_prompt(MessageContent::Text("inspect the workspace".to_owned()));
|
||||
|
||||
assert_eq!(
|
||||
request.prompt,
|
||||
Some(MessageContent::Text("inspect the workspace".to_owned()))
|
||||
);
|
||||
assert!(request.messages.is_empty());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user