ACP work
This commit is contained in:
Generated
+4
@@ -5922,7 +5922,11 @@ dependencies = [
|
||||
"agent-client-protocol",
|
||||
"async-channel",
|
||||
"async-io",
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"futures",
|
||||
"galaxy_agent_core",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -18,6 +18,6 @@ pub(crate) use launch::{
|
||||
pub(crate) use permissions::resolve_acp_permissions;
|
||||
pub(crate) use runtime_model::{AcpDiscoveryState, AcpRuntimeModel};
|
||||
pub(crate) use transport::{
|
||||
acp_output_stream, acp_startup_error_stream, galaxy_mcp_server, AcpSessionHandleSlot,
|
||||
AcpSessionMetadata, AcpSteeringRequest, GalaxyMcpTarget,
|
||||
acp_output_stream, acp_startup_error_stream, galaxy_mcp_server, AcpSessionMetadata,
|
||||
AcpTurnControlSlot, GalaxyMcpTarget,
|
||||
};
|
||||
|
||||
+20
-11
@@ -1,4 +1,5 @@
|
||||
use galaxy_acp::{ContentBlock, ImageContent, TextContent};
|
||||
use base64::Engine as _;
|
||||
use galaxy_agent_core::{ContentPart, MessageContent};
|
||||
|
||||
use crate::ai::agent::api::RequestParams;
|
||||
use crate::ai::agent::{AIAgentAttachment, AIAgentContext, AIAgentInput, MarkdownActionResult};
|
||||
@@ -24,7 +25,7 @@ pub(super) struct GalaxyTerminalTools {
|
||||
pub(super) fn prompt_content(
|
||||
params: &RequestParams,
|
||||
terminal_tools: GalaxyTerminalTools,
|
||||
) -> Result<Vec<ContentBlock>, String> {
|
||||
) -> Result<MessageContent, String> {
|
||||
let visible_query = params
|
||||
.input
|
||||
.iter()
|
||||
@@ -41,12 +42,15 @@ pub(super) fn prompt_content(
|
||||
for item in context {
|
||||
match item {
|
||||
AIAgentContext::Image(image) => {
|
||||
let mut file_name = image.file_name.clone();
|
||||
params.redact_text_for_model(&mut file_name);
|
||||
images.push(ContentBlock::Image(
|
||||
ImageContent::new(image.data.clone(), image.mime_type.clone())
|
||||
.uri(format!("attachment://{file_name}")),
|
||||
));
|
||||
let data = base64::engine::general_purpose::STANDARD
|
||||
.decode(&image.data)
|
||||
.map_err(|error| {
|
||||
format!("failed to decode ACP image attachment: {error}")
|
||||
})?;
|
||||
images.push(ContentPart::Image {
|
||||
data,
|
||||
mime_type: image.mime_type.clone(),
|
||||
});
|
||||
}
|
||||
AIAgentContext::SelectedText(text) => {
|
||||
hidden_context.push(format!("Selected text:\n{text}"));
|
||||
@@ -115,9 +119,14 @@ pub(super) fn prompt_content(
|
||||
}
|
||||
params.redact_text_for_model(&mut text);
|
||||
|
||||
let mut prompt = vec![ContentBlock::Text(TextContent::new(text))];
|
||||
prompt.extend(images);
|
||||
Ok(prompt)
|
||||
if images.is_empty() {
|
||||
Ok(MessageContent::Text(text))
|
||||
} else {
|
||||
let mut parts = Vec::with_capacity(images.len() + 1);
|
||||
parts.push(ContentPart::Text(text));
|
||||
parts.extend(images);
|
||||
Ok(MessageContent::MultiPart(parts))
|
||||
}
|
||||
}
|
||||
|
||||
fn append_hidden_input(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use galaxy_acp::ContentBlock;
|
||||
use galaxy_agent_core::{ContentPart, MessageContent};
|
||||
use regex::Regex;
|
||||
use serial_test::serial;
|
||||
|
||||
@@ -42,6 +42,19 @@ fn user_query(query: &str, context: Vec<AIAgentContext>) -> AIAgentInput {
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_text(prompt: &MessageContent) -> &str {
|
||||
match prompt {
|
||||
MessageContent::Text(text) => text,
|
||||
MessageContent::MultiPart(parts) => match &parts[0] {
|
||||
ContentPart::Text(text) => text,
|
||||
_ => panic!("expected prompt text first"),
|
||||
},
|
||||
MessageContent::ToolUse { .. } | MessageContent::ToolResult { .. } => {
|
||||
panic!("expected user prompt")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_images_as_native_acp_content() {
|
||||
let mut params = RequestParams::new_for_test();
|
||||
@@ -56,17 +69,19 @@ fn keeps_images_as_native_acp_content() {
|
||||
)];
|
||||
|
||||
let prompt = prompt_content(¶ms, GalaxyTerminalTools::default()).expect("prompt");
|
||||
assert_eq!(prompt.len(), 2);
|
||||
let MessageContent::MultiPart(parts) = &prompt else {
|
||||
panic!("expected multipart prompt");
|
||||
};
|
||||
assert_eq!(parts.len(), 2);
|
||||
assert!(matches!(
|
||||
&prompt[0],
|
||||
ContentBlock::Text(text) if text.text == "What is in this image?"
|
||||
&parts[0],
|
||||
ContentPart::Text(text) if text == "What is in this image?"
|
||||
));
|
||||
assert!(matches!(
|
||||
&prompt[1],
|
||||
ContentBlock::Image(image)
|
||||
if image.data == "aW1hZ2U="
|
||||
&& image.mime_type == "image/png"
|
||||
&& image.uri.as_deref() == Some("attachment://screen.png")
|
||||
&parts[1],
|
||||
ContentPart::Image { data, mime_type }
|
||||
if data == b"image"
|
||||
&& mime_type == "image/png"
|
||||
));
|
||||
}
|
||||
|
||||
@@ -80,13 +95,11 @@ fn sends_rules_and_selected_text_without_changing_visible_query() {
|
||||
params.global_rules = vec![("Safety".to_owned(), "Run tests first.".to_owned())];
|
||||
|
||||
let prompt = prompt_content(¶ms, GalaxyTerminalTools::default()).expect("prompt");
|
||||
let ContentBlock::Text(text) = &prompt[0] else {
|
||||
panic!("expected text");
|
||||
};
|
||||
assert!(text.text.starts_with("Fix this"));
|
||||
assert!(text.text.contains("hidden_from_transcript"));
|
||||
assert!(text.text.contains("broken()"));
|
||||
assert!(text.text.contains("Run tests first."));
|
||||
let text = prompt_text(&prompt);
|
||||
assert!(text.starts_with("Fix this"));
|
||||
assert!(text.contains("hidden_from_transcript"));
|
||||
assert!(text.contains("broken()"));
|
||||
assert!(text.contains("Run tests first."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -98,12 +111,10 @@ fn hidden_system_requests_still_reach_the_agent_without_a_user_bubble() {
|
||||
}];
|
||||
|
||||
let prompt = prompt_content(¶ms, GalaxyTerminalTools::default()).expect("prompt");
|
||||
let ContentBlock::Text(text) = &prompt[0] else {
|
||||
panic!("expected text");
|
||||
};
|
||||
assert!(text.text.starts_with("Handle the Galaxy system request"));
|
||||
assert!(text.text.contains("Repair the failing unit test."));
|
||||
assert!(text.text.contains("hidden_from_transcript"));
|
||||
let text = prompt_text(&prompt);
|
||||
assert!(text.starts_with("Handle the Galaxy system request"));
|
||||
assert!(text.contains("Repair the failing unit test."));
|
||||
assert!(text.contains("hidden_from_transcript"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -135,16 +146,14 @@ fn running_command_identity_and_output_are_sent_as_hidden_context() {
|
||||
},
|
||||
)
|
||||
.expect("prompt");
|
||||
let ContentBlock::Text(text) = &prompt[0] else {
|
||||
panic!("expected text");
|
||||
};
|
||||
assert!(text.text.starts_with("Stop this after 75 seconds."));
|
||||
assert!(text.text.contains(block_id.as_str()));
|
||||
assert!(text.text.contains("elapsed: 41s"));
|
||||
assert!(text.text.contains("galaxy_terminal_status"));
|
||||
assert!(text.text.contains("running_for_ms"));
|
||||
assert!(text.text.contains("galaxy_terminal_interrupt_at"));
|
||||
assert!(text.text.contains("outside the model loop"));
|
||||
let text = prompt_text(&prompt);
|
||||
assert!(text.starts_with("Stop this after 75 seconds."));
|
||||
assert!(text.contains(block_id.as_str()));
|
||||
assert!(text.contains("elapsed: 41s"));
|
||||
assert!(text.contains("galaxy_terminal_status"));
|
||||
assert!(text.contains("running_for_ms"));
|
||||
assert!(text.contains("galaxy_terminal_interrupt_at"));
|
||||
assert!(text.contains("outside the model loop"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -176,12 +185,10 @@ fn running_command_prompt_does_not_advertise_unavailable_mutations() {
|
||||
},
|
||||
)
|
||||
.expect("prompt");
|
||||
let ContentBlock::Text(text) = &prompt[0] else {
|
||||
panic!("expected text");
|
||||
};
|
||||
assert!(text.text.contains("galaxy_terminal_status"));
|
||||
assert!(text.text.contains("no Galaxy terminal mutation tool"));
|
||||
assert!(!text.text.contains("galaxy_terminal_interrupt_at"));
|
||||
let text = prompt_text(&prompt);
|
||||
assert!(text.contains("galaxy_terminal_status"));
|
||||
assert!(text.contains("no Galaxy terminal mutation tool"));
|
||||
assert!(!text.contains("galaxy_terminal_interrupt_at"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -224,20 +231,19 @@ fn redacts_request_text_before_creating_acp_content_blocks() {
|
||||
}];
|
||||
|
||||
let prompt = prompt_content(¶ms, GalaxyTerminalTools::default()).expect("prompt");
|
||||
let ContentBlock::Text(text) = &prompt[0] else {
|
||||
panic!("expected text");
|
||||
let MessageContent::MultiPart(parts) = &prompt else {
|
||||
panic!("expected multipart prompt");
|
||||
};
|
||||
assert!(!text.text.contains(SECRET));
|
||||
assert!(text.text.contains("******************"));
|
||||
assert!(text.text.contains("Selected text:"));
|
||||
assert!(text.text.contains("Current output:"));
|
||||
assert!(text.text.contains("Attachment notes.txt:"));
|
||||
assert!(text.text.contains("Galaxy rules:"));
|
||||
let text = prompt_text(&prompt);
|
||||
assert!(!text.contains(SECRET));
|
||||
assert!(text.contains("******************"));
|
||||
assert!(text.contains("Selected text:"));
|
||||
assert!(text.contains("Current output:"));
|
||||
assert!(text.contains("Attachment notes.txt:"));
|
||||
assert!(text.contains("Galaxy rules:"));
|
||||
assert!(matches!(
|
||||
&prompt[1],
|
||||
ContentBlock::Image(image)
|
||||
if image.data == "aW1hZ2U="
|
||||
&& !image.uri.as_deref().unwrap_or_default().contains(SECRET)
|
||||
&parts[1],
|
||||
ContentPart::Image { data, .. } if data == b"image"
|
||||
));
|
||||
|
||||
// Prompt redaction must not mutate the local transcript copy.
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use galaxy_acp::{AcpEvent, ContentBlock, StopReason, ToolCallId, ToolCallStatus};
|
||||
use galaxy_agent_core::{AgentEvent, RuntimeActivity, RuntimeActivityStatus, StopReason};
|
||||
use uuid::Uuid;
|
||||
use warp_multi_agent_api::response_event::stream_finished;
|
||||
use warp_multi_agent_api::{self as api, ResponseEvent};
|
||||
use warp_multi_agent_api::{self as api, ClientAction, ResponseEvent};
|
||||
|
||||
use crate::ai::agent::runtime_activity;
|
||||
use crate::ai::bedrock::response_translator::{
|
||||
build_add_agent_output_message, build_append_text, build_create_task, build_stream_init,
|
||||
build_user_query_message,
|
||||
@@ -20,10 +21,11 @@ pub(super) struct AcpResponseTranslator {
|
||||
model_id: String,
|
||||
initialized: bool,
|
||||
message_id: Option<String>,
|
||||
tool_titles: HashMap<ToolCallId, String>,
|
||||
activity_message_ids: HashMap<String, String>,
|
||||
activities: HashMap<String, RuntimeActivity>,
|
||||
has_visible_output: bool,
|
||||
used_tokens: u64,
|
||||
context_size: u64,
|
||||
accept_next_user_content: bool,
|
||||
}
|
||||
|
||||
impl AcpResponseTranslator {
|
||||
@@ -41,169 +43,71 @@ impl AcpResponseTranslator {
|
||||
model_id,
|
||||
initialized: false,
|
||||
message_id: None,
|
||||
tool_titles: HashMap::new(),
|
||||
activity_message_ids: HashMap::new(),
|
||||
activities: HashMap::new(),
|
||||
has_visible_output: false,
|
||||
used_tokens: 0,
|
||||
context_size: 0,
|
||||
accept_next_user_content: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn translate(&mut self, event: AcpEvent) -> Result<Vec<ResponseEvent>, String> {
|
||||
pub(super) fn translate(&mut self, event: AgentEvent) -> Result<Vec<ResponseEvent>, String> {
|
||||
let mut events = Vec::new();
|
||||
match event {
|
||||
AcpEvent::SessionStarted { .. } => self.initialize(&mut events),
|
||||
AcpEvent::AgentText { text } => {
|
||||
AgentEvent::TurnStarted { .. } => self.initialize(&mut events),
|
||||
AgentEvent::TextDelta { text } => {
|
||||
self.initialize(&mut events);
|
||||
self.add_or_append(&text, &mut events);
|
||||
}
|
||||
// Reasoning is deliberately not copied into the plain assistant
|
||||
// transcript. ACP agents can still expose plans and tool progress.
|
||||
AcpEvent::AgentThought { .. } => {}
|
||||
AcpEvent::AgentContent { content, thought } => {
|
||||
if !thought {
|
||||
// The legacy transcript has no dedicated reasoning surface on this
|
||||
// path yet. The shared runtime event remains available for the UI
|
||||
// convergence phase instead of being flattened into answer text.
|
||||
AgentEvent::ReasoningDelta { .. } | AgentEvent::ReasoningCompleted { .. } => {}
|
||||
AgentEvent::RuntimeActivityUpdated { activity } => {
|
||||
self.initialize(&mut events);
|
||||
let description = 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)
|
||||
self.message_id = None;
|
||||
self.upsert_runtime_activity(activity, &mut events)?;
|
||||
}
|
||||
ContentBlock::Resource(_) => {
|
||||
"[Agent returned embedded resource content.]".to_owned()
|
||||
AgentEvent::ContextUsageUpdated {
|
||||
used_tokens,
|
||||
context_size,
|
||||
} => {
|
||||
self.used_tokens = used_tokens;
|
||||
self.context_size = context_size;
|
||||
}
|
||||
_ => "[Agent returned unsupported content.]".to_owned(),
|
||||
};
|
||||
self.add_or_append(&description, &mut events);
|
||||
}
|
||||
}
|
||||
AcpEvent::UserContent { content } => {
|
||||
// Some ACP adapters replay user-message chunks while loading a
|
||||
// session or echo Galaxy's initial prompt, which also contains
|
||||
// hidden context. Only content explicitly authorized by the
|
||||
// live-steering path may enter the visible transcript.
|
||||
if self.accept_next_user_content {
|
||||
self.accept_next_user_content = false;
|
||||
AgentEvent::UserInputAccepted { text } => {
|
||||
self.initialize(&mut events);
|
||||
if let ContentBlock::Text(text) = content {
|
||||
events.push(build_user_query_message(&self.task_id, &text.text));
|
||||
// Assistant output after steering belongs in a new chat
|
||||
// bubble, not the message that preceded the follow-up.
|
||||
events.push(build_user_query_message(&self.task_id, &text));
|
||||
self.message_id = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
AcpEvent::ToolCall {
|
||||
id,
|
||||
title,
|
||||
status,
|
||||
output,
|
||||
} => {
|
||||
AgentEvent::RuntimeNotice { message } => {
|
||||
self.initialize(&mut events);
|
||||
self.tool_titles.insert(id, title.clone());
|
||||
self.add_or_append(&tool_status_line(&title, status), &mut events);
|
||||
if let Some(output) = output {
|
||||
self.add_or_append(&tool_output_block(&output), &mut events);
|
||||
self.message_id = None;
|
||||
self.add_or_append(&message, &mut events);
|
||||
self.message_id = None;
|
||||
}
|
||||
}
|
||||
AcpEvent::ToolCallUpdate {
|
||||
id,
|
||||
title,
|
||||
status,
|
||||
output,
|
||||
} => {
|
||||
AgentEvent::TurnStopped { reason } => {
|
||||
self.initialize(&mut events);
|
||||
let title = title
|
||||
.or_else(|| self.tool_titles.get(&id).cloned())
|
||||
.unwrap_or_else(|| "tool".to_owned());
|
||||
self.tool_titles.insert(id, title.clone());
|
||||
if let Some(status) = status {
|
||||
self.add_or_append(&tool_status_line(&title, status), &mut events);
|
||||
}
|
||||
if let Some(output) = output {
|
||||
self.add_or_append(&tool_output_block(&output), &mut events);
|
||||
}
|
||||
}
|
||||
AcpEvent::Usage { used, size, .. } => {
|
||||
self.used_tokens = used;
|
||||
self.context_size = size;
|
||||
}
|
||||
AcpEvent::PermissionRequested { request } => {
|
||||
self.initialize(&mut events);
|
||||
self.add_or_append(
|
||||
&format!(
|
||||
"\n\n> Permission requested for: {}\n",
|
||||
request.tool_call.fields.title.as_deref().unwrap_or("tool")
|
||||
),
|
||||
&mut events,
|
||||
);
|
||||
}
|
||||
AcpEvent::PermissionResolved { decision, .. } => {
|
||||
self.initialize(&mut events);
|
||||
self.add_or_append(
|
||||
&format!("\n\n> Permission decision: {decision:?}\n"),
|
||||
&mut events,
|
||||
);
|
||||
}
|
||||
AcpEvent::Finished { stop_reason } => {
|
||||
self.initialize(&mut events);
|
||||
if self.message_id.is_none() && stop_reason != StopReason::Cancelled {
|
||||
if !self.has_visible_output && reason != StopReason::Cancelled {
|
||||
self.add_or_append(
|
||||
"> ACP agent completed without a text response.",
|
||||
&mut events,
|
||||
);
|
||||
}
|
||||
events.push(self.finished(stop_reason));
|
||||
events.push(self.finished(reason));
|
||||
}
|
||||
AgentEvent::Tool { .. } => {
|
||||
return Err(
|
||||
"ACP runtime attempted to hand agent-owned tool execution to Galaxy".to_owned(),
|
||||
);
|
||||
}
|
||||
AgentEvent::UsageUpdated { .. } => {
|
||||
return Err("ACP runtime reported provider-style request usage".to_owned());
|
||||
}
|
||||
AcpEvent::Error { message } => return Err(message),
|
||||
// ACP events are forward-compatible. Unknown events do not belong
|
||||
// in the user-visible transcript until Galaxy knows their meaning.
|
||||
_ => {}
|
||||
}
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
pub(super) fn translate_steered_user_content(
|
||||
&mut self,
|
||||
content: ContentBlock,
|
||||
) -> Result<Vec<ResponseEvent>, String> {
|
||||
self.accept_next_user_content = true;
|
||||
self.translate(AcpEvent::UserContent { content })
|
||||
}
|
||||
|
||||
pub(super) fn steering_failed(&mut self, error: &str) -> Vec<ResponseEvent> {
|
||||
let mut events = Vec::new();
|
||||
self.initialize(&mut events);
|
||||
self.message_id = None;
|
||||
self.add_or_append(
|
||||
&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."
|
||||
),
|
||||
&mut events,
|
||||
);
|
||||
// Any output still arriving from the original turn should not be
|
||||
// appended to Galaxy's steering-failure notice.
|
||||
self.message_id = None;
|
||||
events
|
||||
}
|
||||
|
||||
pub(super) fn steering_started_new_turn(&mut self) -> Vec<ResponseEvent> {
|
||||
let mut events = Vec::new();
|
||||
self.initialize(&mut events);
|
||||
self.message_id = None;
|
||||
self.add_or_append(
|
||||
"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.",
|
||||
&mut events,
|
||||
);
|
||||
self.message_id = None;
|
||||
events
|
||||
}
|
||||
|
||||
pub(super) fn startup_error(&mut self, error: &str) -> Vec<ResponseEvent> {
|
||||
let mut events = Vec::new();
|
||||
self.initialize(&mut events);
|
||||
@@ -236,6 +140,7 @@ impl AcpResponseTranslator {
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.has_visible_output = true;
|
||||
if let Some(message_id) = &self.message_id {
|
||||
events.push(build_append_text(&self.task_id, message_id, text));
|
||||
} else {
|
||||
@@ -249,18 +154,64 @@ impl AcpResponseTranslator {
|
||||
}
|
||||
}
|
||||
|
||||
fn upsert_runtime_activity(
|
||||
&mut self,
|
||||
activity: RuntimeActivity,
|
||||
events: &mut Vec<ResponseEvent>,
|
||||
) -> Result<(), String> {
|
||||
let activity_id = activity.id.clone();
|
||||
let merged_activity = self
|
||||
.activities
|
||||
.entry(activity_id.clone())
|
||||
.or_insert_with(|| activity.clone());
|
||||
if !activity.title.trim().is_empty() {
|
||||
merged_activity.title = activity.title;
|
||||
}
|
||||
if activity.status.is_some() {
|
||||
merged_activity.status = activity.status;
|
||||
}
|
||||
if activity.output.is_some() {
|
||||
merged_activity.output = activity.output;
|
||||
}
|
||||
|
||||
let server_message_data = runtime_activity::encode(merged_activity)
|
||||
.map_err(|error| format!("failed to encode ACP runtime activity: {error}"))?;
|
||||
let fallback_text = runtime_activity_fallback_text(merged_activity);
|
||||
if let Some(message_id) = self.activity_message_ids.get(&activity_id) {
|
||||
events.push(build_update_runtime_activity_message(
|
||||
&self.task_id,
|
||||
message_id,
|
||||
&fallback_text,
|
||||
&server_message_data,
|
||||
));
|
||||
} else {
|
||||
let message_id = Uuid::new_v4().to_string();
|
||||
events.push(build_add_runtime_activity_message(
|
||||
&self.task_id,
|
||||
&message_id,
|
||||
&fallback_text,
|
||||
&server_message_data,
|
||||
));
|
||||
self.activity_message_ids.insert(activity_id, message_id);
|
||||
}
|
||||
self.has_visible_output = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finished(&self, stop_reason: StopReason) -> ResponseEvent {
|
||||
let reason = match stop_reason {
|
||||
StopReason::EndTurn | StopReason::Cancelled => {
|
||||
StopReason::Completed | StopReason::Cancelled => {
|
||||
stream_finished::Reason::Done(stream_finished::Done {})
|
||||
}
|
||||
StopReason::MaxTokens | StopReason::MaxTurnRequests => {
|
||||
StopReason::MaxTokens => {
|
||||
stream_finished::Reason::MaxTokenLimit(stream_finished::ReachedMaxTokenLimit {})
|
||||
}
|
||||
StopReason::Refusal => stream_finished::Reason::Other(stream_finished::Other {}),
|
||||
// ACP marks this enum non-exhaustive so newer agents can add stop reasons
|
||||
// without breaking older clients.
|
||||
_ => stream_finished::Reason::Other(stream_finished::Other {}),
|
||||
StopReason::ContextWindowExceeded => stream_finished::Reason::ContextWindowExceeded(
|
||||
stream_finished::ContextWindowExceeded {},
|
||||
),
|
||||
StopReason::Refusal | StopReason::ToolLoopLimit | StopReason::Other(_) => {
|
||||
stream_finished::Reason::Other(stream_finished::Other {})
|
||||
}
|
||||
};
|
||||
let used_tokens = u32::try_from(self.used_tokens).unwrap_or(u32::MAX);
|
||||
let context_usage = if self.context_size == 0 {
|
||||
@@ -307,27 +258,98 @@ impl AcpResponseTranslator {
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_status_line(title: &str, status: ToolCallStatus) -> String {
|
||||
let status = match status {
|
||||
ToolCallStatus::Pending => "waiting",
|
||||
ToolCallStatus::InProgress => "running",
|
||||
ToolCallStatus::Completed => "completed",
|
||||
ToolCallStatus::Failed => "failed",
|
||||
// ACP marks this enum non-exhaustive. Preserve a useful transcript if a
|
||||
// newer agent reports a status this client does not recognize yet.
|
||||
_ => "updated",
|
||||
fn runtime_activity_fallback_text(activity: &RuntimeActivity) -> String {
|
||||
let title = &activity.title;
|
||||
let status = activity.status.as_ref().map(|status| match status {
|
||||
RuntimeActivityStatus::Pending => "waiting",
|
||||
RuntimeActivityStatus::InProgress => "running",
|
||||
RuntimeActivityStatus::Completed => "completed",
|
||||
RuntimeActivityStatus::Failed => "failed",
|
||||
RuntimeActivityStatus::Other(_) => "updated",
|
||||
});
|
||||
let mut text = match status {
|
||||
Some(status) => format!("> **{title}** — {status}"),
|
||||
None => format!("> **{title}**"),
|
||||
};
|
||||
format!("\n\n> **{title}** — {status}\n")
|
||||
if let Some(output) = &activity.output {
|
||||
text.push_str("\n\n");
|
||||
for line in output.lines() {
|
||||
text.push_str(" ");
|
||||
text.push_str(line);
|
||||
text.push('\n');
|
||||
}
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
fn tool_output_block(output: &str) -> String {
|
||||
let mut block = String::from("\n");
|
||||
for line in output.lines() {
|
||||
block.push_str(" ");
|
||||
block.push_str(line);
|
||||
block.push('\n');
|
||||
fn build_add_runtime_activity_message(
|
||||
task_id: &str,
|
||||
message_id: &str,
|
||||
fallback_text: &str,
|
||||
server_message_data: &str,
|
||||
) -> ResponseEvent {
|
||||
let message = runtime_activity_message(task_id, message_id, fallback_text, server_message_data);
|
||||
runtime_activity_client_action(api::client_action::Action::AddMessagesToTask(
|
||||
api::client_action::AddMessagesToTask {
|
||||
task_id: task_id.to_owned(),
|
||||
messages: vec![message],
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn build_update_runtime_activity_message(
|
||||
task_id: &str,
|
||||
message_id: &str,
|
||||
fallback_text: &str,
|
||||
server_message_data: &str,
|
||||
) -> ResponseEvent {
|
||||
let message = runtime_activity_message(task_id, message_id, fallback_text, server_message_data);
|
||||
runtime_activity_client_action(api::client_action::Action::UpdateTaskMessage(
|
||||
api::client_action::UpdateTaskMessage {
|
||||
task_id: task_id.to_owned(),
|
||||
message: Some(message),
|
||||
mask: Some(prost_types::FieldMask {
|
||||
paths: vec![
|
||||
"agent_output.text".to_owned(),
|
||||
"server_message_data".to_owned(),
|
||||
],
|
||||
}),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn runtime_activity_message(
|
||||
task_id: &str,
|
||||
message_id: &str,
|
||||
fallback_text: &str,
|
||||
server_message_data: &str,
|
||||
) -> api::Message {
|
||||
api::Message {
|
||||
id: message_id.to_owned(),
|
||||
task_id: task_id.to_owned(),
|
||||
request_id: String::new(),
|
||||
timestamp: None,
|
||||
server_message_data: server_message_data.to_owned(),
|
||||
citations: Vec::new(),
|
||||
fetched_memories: Vec::new(),
|
||||
message: Some(api::message::Message::AgentOutput(
|
||||
api::message::AgentOutput {
|
||||
text: fallback_text.to_owned(),
|
||||
},
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime_activity_client_action(action: api::client_action::Action) -> ResponseEvent {
|
||||
ResponseEvent {
|
||||
r#type: Some(api::response_event::Type::ClientActions(
|
||||
api::response_event::ClientActions {
|
||||
actions: vec![ClientAction {
|
||||
action: Some(action),
|
||||
}],
|
||||
},
|
||||
)),
|
||||
}
|
||||
block
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
use galaxy_acp::{
|
||||
AcpEvent, AgentCapabilities, ContentBlock, SessionId, StopReason, TextContent, ToolCallId,
|
||||
ToolCallStatus,
|
||||
};
|
||||
use galaxy_agent_core::{AgentEvent, RuntimeActivity, RuntimeActivityStatus, StopReason};
|
||||
use warp_multi_agent_api::{client_action, message, response_event};
|
||||
|
||||
use super::AcpResponseTranslator;
|
||||
use crate::ai::agent::runtime_activity;
|
||||
|
||||
#[test]
|
||||
fn initializes_the_existing_chat_exchange_and_persists_user_text() {
|
||||
@@ -15,12 +13,8 @@ fn initializes_the_existing_chat_exchange_and_persists_user_text() {
|
||||
"acp:codex".to_owned(),
|
||||
);
|
||||
let events = translator
|
||||
.translate(AcpEvent::SessionStarted {
|
||||
session_id: SessionId::from("session"),
|
||||
agent_info: None,
|
||||
capabilities: AgentCapabilities::default(),
|
||||
can_load: true,
|
||||
can_steer: true,
|
||||
.translate(AgentEvent::TurnStarted {
|
||||
runtime_request_id: "session".to_owned(),
|
||||
})
|
||||
.expect("translate");
|
||||
|
||||
@@ -43,12 +37,12 @@ fn streams_agent_text_as_add_then_append() {
|
||||
let mut translator =
|
||||
AcpResponseTranslator::new("task".to_owned(), false, None, "acp:codex".to_owned());
|
||||
let first = translator
|
||||
.translate(AcpEvent::AgentText {
|
||||
.translate(AgentEvent::TextDelta {
|
||||
text: "one".to_owned(),
|
||||
})
|
||||
.expect("first");
|
||||
let second = translator
|
||||
.translate(AcpEvent::AgentText {
|
||||
.translate(AgentEvent::TextDelta {
|
||||
text: " two".to_owned(),
|
||||
})
|
||||
.expect("second");
|
||||
@@ -70,15 +64,17 @@ fn streams_agent_text_as_add_then_append() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_acp_tool_progress_as_text_not_an_executable_galaxy_action() {
|
||||
fn renders_acp_tool_progress_as_structured_non_executable_activity() {
|
||||
let mut translator =
|
||||
AcpResponseTranslator::new("task".to_owned(), false, None, "acp:codex".to_owned());
|
||||
let events = translator
|
||||
.translate(AcpEvent::ToolCall {
|
||||
id: ToolCallId::from("tool-1"),
|
||||
.translate(AgentEvent::RuntimeActivityUpdated {
|
||||
activity: RuntimeActivity {
|
||||
id: "tool-1".to_owned(),
|
||||
title: "Read file".to_owned(),
|
||||
status: ToolCallStatus::InProgress,
|
||||
status: Some(RuntimeActivityStatus::InProgress),
|
||||
output: None,
|
||||
},
|
||||
})
|
||||
.expect("tool");
|
||||
let Some(response_event::Type::ClientActions(actions)) = &events[1].r#type else {
|
||||
@@ -91,6 +87,15 @@ fn renders_acp_tool_progress_as_text_not_an_executable_galaxy_action() {
|
||||
add.messages[0].message,
|
||||
Some(message::Message::AgentOutput(_))
|
||||
));
|
||||
assert_eq!(
|
||||
runtime_activity::decode(&add.messages[0].server_message_data),
|
||||
Some(RuntimeActivity {
|
||||
id: "tool-1".to_owned(),
|
||||
title: "Read file".to_owned(),
|
||||
status: Some(RuntimeActivityStatus::InProgress),
|
||||
output: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -98,15 +103,14 @@ fn maps_usage_and_successful_completion() {
|
||||
let mut translator =
|
||||
AcpResponseTranslator::new("task".to_owned(), false, None, "acp:codex".to_owned());
|
||||
translator
|
||||
.translate(AcpEvent::Usage {
|
||||
used: 25,
|
||||
size: 100,
|
||||
cost: None,
|
||||
.translate(AgentEvent::ContextUsageUpdated {
|
||||
used_tokens: 25,
|
||||
context_size: 100,
|
||||
})
|
||||
.expect("usage");
|
||||
let events = translator
|
||||
.translate(AcpEvent::Finished {
|
||||
stop_reason: StopReason::EndTurn,
|
||||
.translate(AgentEvent::TurnStopped {
|
||||
reason: StopReason::Completed,
|
||||
})
|
||||
.expect("finished");
|
||||
let Some(finished) = events.iter().find_map(|event| {
|
||||
@@ -129,37 +133,84 @@ fn maps_usage_and_successful_completion() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_bounded_tool_output_in_the_agent_transcript() {
|
||||
fn updates_the_existing_activity_card_with_bounded_tool_output() {
|
||||
let mut translator =
|
||||
AcpResponseTranslator::new("task".to_owned(), false, None, "acp:codex".to_owned());
|
||||
let events = translator
|
||||
.translate(AcpEvent::ToolCall {
|
||||
id: ToolCallId::from("tool-1"),
|
||||
let started = translator
|
||||
.translate(AgentEvent::RuntimeActivityUpdated {
|
||||
activity: RuntimeActivity {
|
||||
id: "tool-1".to_owned(),
|
||||
title: "Run tests".to_owned(),
|
||||
status: ToolCallStatus::Completed,
|
||||
status: Some(RuntimeActivityStatus::InProgress),
|
||||
output: None,
|
||||
},
|
||||
})
|
||||
.expect("started");
|
||||
let Some(response_event::Type::ClientActions(start_actions)) = &started[1].r#type else {
|
||||
panic!("expected initial activity action");
|
||||
};
|
||||
let Some(client_action::Action::AddMessagesToTask(add)) = &start_actions.actions[0].action
|
||||
else {
|
||||
panic!("expected initial activity message");
|
||||
};
|
||||
let message_id = add.messages[0].id.clone();
|
||||
|
||||
let events = translator
|
||||
.translate(AgentEvent::RuntimeActivityUpdated {
|
||||
activity: RuntimeActivity {
|
||||
id: "tool-1".to_owned(),
|
||||
title: "Run tests".to_owned(),
|
||||
status: Some(RuntimeActivityStatus::Completed),
|
||||
output: Some("test one ... ok\ntest two ... ok".to_owned()),
|
||||
},
|
||||
})
|
||||
.expect("tool");
|
||||
|
||||
let Some(response_event::Type::ClientActions(status_actions)) = &events[1].r#type else {
|
||||
panic!("expected status action");
|
||||
let Some(response_event::Type::ClientActions(update_actions)) = &events[0].r#type else {
|
||||
panic!("expected update action");
|
||||
};
|
||||
let Some(client_action::Action::AddMessagesToTask(add_status)) =
|
||||
&status_actions.actions[0].action
|
||||
let Some(client_action::Action::UpdateTaskMessage(update)) = &update_actions.actions[0].action
|
||||
else {
|
||||
panic!("expected status message");
|
||||
panic!("expected in-place activity update");
|
||||
};
|
||||
let Some(message::Message::AgentOutput(status)) = &add_status.messages[0].message else {
|
||||
panic!("expected agent output");
|
||||
};
|
||||
assert!(status.text.contains("Run tests"));
|
||||
let updated_message = update.message.as_ref().expect("updated message");
|
||||
assert_eq!(updated_message.id, message_id);
|
||||
assert_eq!(
|
||||
runtime_activity::decode(&updated_message.server_message_data),
|
||||
Some(RuntimeActivity {
|
||||
id: "tool-1".to_owned(),
|
||||
title: "Run tests".to_owned(),
|
||||
status: Some(RuntimeActivityStatus::Completed),
|
||||
output: Some("test one ... ok\ntest two ... ok".to_owned()),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
let Some(response_event::Type::ClientActions(output_actions)) = &events[2].r#type else {
|
||||
panic!("expected output action");
|
||||
};
|
||||
#[test]
|
||||
fn activity_only_turn_does_not_claim_the_agent_returned_no_output() {
|
||||
let mut translator =
|
||||
AcpResponseTranslator::new("task".to_owned(), false, None, "acp:codex".to_owned());
|
||||
translator
|
||||
.translate(AgentEvent::RuntimeActivityUpdated {
|
||||
activity: RuntimeActivity {
|
||||
id: "tool-1".to_owned(),
|
||||
title: "Inspect repository".to_owned(),
|
||||
status: Some(RuntimeActivityStatus::Completed),
|
||||
output: Some("done".to_owned()),
|
||||
},
|
||||
})
|
||||
.expect("activity");
|
||||
|
||||
let events = translator
|
||||
.translate(AgentEvent::TurnStopped {
|
||||
reason: StopReason::Completed,
|
||||
})
|
||||
.expect("finished");
|
||||
|
||||
assert_eq!(events.len(), 1);
|
||||
assert!(matches!(
|
||||
output_actions.actions[0].action,
|
||||
Some(client_action::Action::AppendToMessageContent(_))
|
||||
events[0].r#type,
|
||||
Some(response_event::Type::Finished(_))
|
||||
));
|
||||
}
|
||||
|
||||
@@ -168,8 +219,8 @@ fn successful_turn_without_agent_output_is_still_visible() {
|
||||
let mut translator =
|
||||
AcpResponseTranslator::new("task".to_owned(), false, None, "acp:codex".to_owned());
|
||||
let events = translator
|
||||
.translate(AcpEvent::Finished {
|
||||
stop_reason: StopReason::EndTurn,
|
||||
.translate(AgentEvent::TurnStopped {
|
||||
reason: StopReason::Completed,
|
||||
})
|
||||
.expect("finished");
|
||||
|
||||
@@ -188,15 +239,13 @@ fn successful_turn_without_agent_output_is_still_visible() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suppresses_unsolicited_user_content_so_initial_hidden_context_cannot_leak() {
|
||||
fn reasoning_is_not_flattened_into_the_plain_answer_transcript() {
|
||||
let mut translator =
|
||||
AcpResponseTranslator::new("task".to_owned(), false, None, "acp:codex".to_owned());
|
||||
|
||||
let events = translator
|
||||
.translate(AcpEvent::UserContent {
|
||||
content: ContentBlock::Text(TextContent::new(
|
||||
"hidden initial prompt and system context",
|
||||
)),
|
||||
.translate(AgentEvent::ReasoningDelta {
|
||||
text: "private chain of thought".to_owned(),
|
||||
})
|
||||
.expect("translate");
|
||||
|
||||
@@ -208,13 +257,15 @@ fn live_steering_adds_a_user_bubble_and_starts_a_new_assistant_bubble() {
|
||||
let mut translator =
|
||||
AcpResponseTranslator::new("task".to_owned(), false, None, "acp:codex".to_owned());
|
||||
translator
|
||||
.translate(AcpEvent::AgentText {
|
||||
.translate(AgentEvent::TextDelta {
|
||||
text: "original response".to_owned(),
|
||||
})
|
||||
.expect("initial output");
|
||||
|
||||
let steered = translator
|
||||
.translate_steered_user_content(ContentBlock::Text(TextContent::new("stop at 75s")))
|
||||
.translate(AgentEvent::UserInputAccepted {
|
||||
text: "stop at 75s".to_owned(),
|
||||
})
|
||||
.expect("steering");
|
||||
let Some(response_event::Type::ClientActions(user_actions)) = &steered[0].r#type else {
|
||||
panic!("expected user client action");
|
||||
@@ -229,7 +280,7 @@ fn live_steering_adds_a_user_bubble_and_starts_a_new_assistant_bubble() {
|
||||
));
|
||||
|
||||
let resumed = translator
|
||||
.translate(AcpEvent::AgentText {
|
||||
.translate(AgentEvent::TextDelta {
|
||||
text: "steered response".to_owned(),
|
||||
})
|
||||
.expect("resumed output");
|
||||
@@ -247,16 +298,16 @@ fn steering_failure_surfaces_an_indeterminate_delivery_warning() {
|
||||
let mut translator =
|
||||
AcpResponseTranslator::new("task".to_owned(), false, None, "acp:codex".to_owned());
|
||||
translator
|
||||
.translate(AcpEvent::SessionStarted {
|
||||
session_id: SessionId::from("session"),
|
||||
agent_info: None,
|
||||
capabilities: AgentCapabilities::default(),
|
||||
can_load: true,
|
||||
can_steer: true,
|
||||
.translate(AgentEvent::TurnStarted {
|
||||
runtime_request_id: "session".to_owned(),
|
||||
})
|
||||
.expect("initialize");
|
||||
|
||||
let events = translator.steering_failed("turn is no longer active");
|
||||
let events = translator
|
||||
.translate(AgentEvent::RuntimeNotice {
|
||||
message: "Galaxy couldn't confirm that live steering message: turn is no longer active. The agent may not have received it; check the current terminal and file state before retrying.".to_owned(),
|
||||
})
|
||||
.expect("notice");
|
||||
|
||||
assert_eq!(events.len(), 1);
|
||||
let Some(response_event::Type::ClientActions(error_actions)) = &events[0].r#type else {
|
||||
@@ -279,7 +330,11 @@ fn implicit_steering_turn_warning_does_not_recommend_a_blind_retry() {
|
||||
let mut translator =
|
||||
AcpResponseTranslator::new("task".to_owned(), false, None, "acp:codex".to_owned());
|
||||
|
||||
let events = translator.steering_started_new_turn();
|
||||
let events = translator
|
||||
.translate(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(),
|
||||
})
|
||||
.expect("notice");
|
||||
|
||||
let text =
|
||||
events
|
||||
|
||||
+52
-161
@@ -1,15 +1,15 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use futures::channel::oneshot;
|
||||
use futures::future::{BoxFuture, Fuse, FusedFuture as _};
|
||||
use futures::stream::FusedStream as _;
|
||||
use futures::{FutureExt as _, StreamExt as _};
|
||||
use galaxy_acp::{
|
||||
AcpEvent, AcpPermissionPolicy, AcpRuntimeError, AcpSessionHandle, AcpSessionManager,
|
||||
AcpSteeringOutcome, AcpTurnRequest, ContentBlock, McpServer, McpServerStdio,
|
||||
SessionConfigOptionValue, SessionId, TextContent,
|
||||
AcpAgentRuntime, AcpAgentRuntimeConfig, AcpPermissionPolicy, AcpRuntimeState,
|
||||
AcpRuntimeStateHandle, AcpSessionManager, McpServer, McpServerStdio, SessionConfigOptionValue,
|
||||
SessionId,
|
||||
};
|
||||
use galaxy_agent_core::{
|
||||
turn_control, AgentRuntime as _, TurnCommand, TurnCommandSender, TurnRequest,
|
||||
};
|
||||
|
||||
use super::launch::acp_selection_identity;
|
||||
@@ -20,19 +20,7 @@ use crate::ai::agent::EntrypointType;
|
||||
use crate::persistence::model::AcpConversationData;
|
||||
use crate::server::server_api::AIApiError;
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct AcpSessionMetadata {
|
||||
pub(crate) session_id: Option<String>,
|
||||
pub(crate) can_load: bool,
|
||||
pub(crate) can_steer: bool,
|
||||
pub(crate) config_options: Vec<galaxy_acp::SessionConfigOption>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct AcpSteeringRequest {
|
||||
display_text: String,
|
||||
model_text: String,
|
||||
}
|
||||
pub(crate) type AcpSessionMetadata = AcpRuntimeState;
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct GalaxyMcpTarget {
|
||||
@@ -41,46 +29,25 @@ pub(crate) struct GalaxyMcpTarget {
|
||||
pub(crate) pane_id: String,
|
||||
}
|
||||
|
||||
impl AcpSteeringRequest {
|
||||
pub(crate) fn text(display_text: String, model_text: String) -> Self {
|
||||
Self {
|
||||
display_text,
|
||||
model_text,
|
||||
}
|
||||
}
|
||||
pub(crate) type AcpTurnControlSlot = Arc<Mutex<Option<TurnCommandSender>>>;
|
||||
|
||||
struct AcpTurnControlGuard {
|
||||
slot: AcpTurnControlSlot,
|
||||
}
|
||||
|
||||
type SteeringResult = Result<AcpSteeringOutcome, AcpRuntimeError>;
|
||||
type PendingSteering = Fuse<BoxFuture<'static, SteeringResult>>;
|
||||
|
||||
fn pending_steering(session: AcpSessionHandle, steering: AcpSteeringRequest) -> PendingSteering {
|
||||
async move {
|
||||
let content = ContentBlock::Text(TextContent::new(steering.model_text));
|
||||
session.steer(vec![content]).await
|
||||
}
|
||||
.boxed()
|
||||
.fuse()
|
||||
}
|
||||
|
||||
pub(crate) type AcpSessionHandleSlot = Arc<Mutex<Option<AcpSessionHandle>>>;
|
||||
|
||||
struct AcpSessionHandleGuard {
|
||||
slot: AcpSessionHandleSlot,
|
||||
}
|
||||
|
||||
impl AcpSessionHandleGuard {
|
||||
fn new(slot: AcpSessionHandleSlot, session: AcpSessionHandle) -> Self {
|
||||
if let Ok(mut active_session) = slot.lock() {
|
||||
*active_session = Some(session);
|
||||
impl AcpTurnControlGuard {
|
||||
fn new(slot: AcpTurnControlSlot, control: TurnCommandSender) -> Self {
|
||||
if let Ok(mut active_control) = slot.lock() {
|
||||
*active_control = Some(control);
|
||||
}
|
||||
Self { slot }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AcpSessionHandleGuard {
|
||||
impl Drop for AcpTurnControlGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Ok(mut active_session) = self.slot.lock() {
|
||||
*active_session = None;
|
||||
if let Ok(mut active_control) = self.slot.lock() {
|
||||
*active_control = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,9 +62,8 @@ pub(crate) async fn acp_output_stream(
|
||||
galaxy_terminal_interrupt_available: bool,
|
||||
permission_policy: AcpPermissionPolicy,
|
||||
auto_approve_permissions: bool,
|
||||
session_metadata: Arc<Mutex<AcpSessionMetadata>>,
|
||||
session_handle: AcpSessionHandleSlot,
|
||||
steering_rx: async_channel::Receiver<AcpSteeringRequest>,
|
||||
session_metadata: AcpRuntimeStateHandle,
|
||||
turn_control_slot: AcpTurnControlSlot,
|
||||
cancellation_rx: oneshot::Receiver<()>,
|
||||
) -> api::ResponseStream {
|
||||
let mut translator = response_translator(¶ms, &backend);
|
||||
@@ -121,132 +87,57 @@ pub(crate) async fn acp_output_stream(
|
||||
if let Some(server) = galaxy_mcp_server {
|
||||
mcp_servers.push(server);
|
||||
}
|
||||
let request = AcpTurnRequest {
|
||||
config_values: backend
|
||||
let runtime_id = acp_selection_identity(&backend.agent_id, &backend.config_values);
|
||||
let mut runtime_config =
|
||||
AcpAgentRuntimeConfig::new(runtime_id.clone(), backend.agent_id.clone(), cwd);
|
||||
runtime_config.config_values = backend
|
||||
.config_values
|
||||
.into_iter()
|
||||
.iter()
|
||||
.filter_map(|(key, value)| {
|
||||
serde_json::from_value::<SessionConfigOptionValue>(value)
|
||||
serde_json::from_value::<SessionConfigOptionValue>(value.clone())
|
||||
.ok()
|
||||
.map(|value| (key, value))
|
||||
.map(|value| (key.clone(), value))
|
||||
})
|
||||
.collect(),
|
||||
conversation_key: conversation_id,
|
||||
session_id: backend.session_id.map(SessionId::from),
|
||||
cwd,
|
||||
additional_directories: Vec::new(),
|
||||
prompt,
|
||||
mcp_servers,
|
||||
auto_approve_permissions,
|
||||
permission_policy,
|
||||
prompt_capabilities: Default::default(),
|
||||
};
|
||||
let (session, events) = match manager.run_turn(request) {
|
||||
Ok(turn) => turn,
|
||||
.collect();
|
||||
runtime_config.session_id = backend.session_id.clone().map(SessionId::from);
|
||||
runtime_config.mcp_servers = mcp_servers;
|
||||
runtime_config.auto_approve_permissions = auto_approve_permissions;
|
||||
runtime_config.permission_policy = permission_policy;
|
||||
let runtime = AcpAgentRuntime::new(manager, runtime_config).with_state(session_metadata);
|
||||
let mut request = TurnRequest::new(runtime_id, Vec::new()).with_prompt(prompt);
|
||||
request.conversation_id = Some(conversation_id);
|
||||
let (control_sender, control) = turn_control();
|
||||
let events = match runtime.start_turn(request, control).await {
|
||||
Ok(events) => events,
|
||||
Err(error) => return translated_startup_error_stream(translator, &error.to_string()),
|
||||
};
|
||||
let session_handle_guard = AcpSessionHandleGuard::new(session_handle, session.clone());
|
||||
let turn_control_guard = AcpTurnControlGuard::new(turn_control_slot, control_sender.clone());
|
||||
|
||||
let stream = async_stream::stream! {
|
||||
let _session_handle_guard = session_handle_guard;
|
||||
let _turn_control_guard = turn_control_guard;
|
||||
let mut cancellation_rx = cancellation_rx.fuse();
|
||||
let mut events = Box::pin(events.fuse());
|
||||
let mut steering_rx = Box::pin(steering_rx.fuse());
|
||||
let mut steering_queue = VecDeque::new();
|
||||
let mut steering_result: PendingSteering = Fuse::terminated();
|
||||
let mut events = events.fuse();
|
||||
loop {
|
||||
futures::select_biased! {
|
||||
_ = cancellation_rx => {
|
||||
if let Err(error) = session.cancel().await {
|
||||
log::warn!("Failed to cancel ACP turn cleanly: {error}");
|
||||
}
|
||||
break;
|
||||
}
|
||||
steering = steering_rx.next() => {
|
||||
let Some(steering) = steering else {
|
||||
continue;
|
||||
};
|
||||
let content = ContentBlock::Text(TextContent::new(
|
||||
steering.display_text.clone(),
|
||||
));
|
||||
match translator.translate_steered_user_content(content) {
|
||||
Ok(response_events) => {
|
||||
for response_event in response_events {
|
||||
yield Ok(api::StreamEvent::Response(response_event));
|
||||
}
|
||||
}
|
||||
Err(message) => {
|
||||
yield Err(Arc::new(AIApiError::Stream {
|
||||
stream_type: "acp",
|
||||
source: anyhow::anyhow!(message),
|
||||
}));
|
||||
break;
|
||||
}
|
||||
}
|
||||
if steering_result.is_terminated() {
|
||||
steering_result = pending_steering(session.clone(), steering);
|
||||
} else {
|
||||
steering_queue.push_back(steering);
|
||||
}
|
||||
}
|
||||
steering = steering_result => {
|
||||
match steering {
|
||||
Ok(AcpSteeringOutcome::Injected) => {
|
||||
// The user message was rendered as soon as Galaxy
|
||||
// accepted it; keep consuming agent events without
|
||||
// holding the transcript behind the steering RPC.
|
||||
}
|
||||
Ok(AcpSteeringOutcome::StartedNewTurn) => {
|
||||
for response_event in translator.steering_started_new_turn() {
|
||||
yield Ok(api::StreamEvent::Response(response_event));
|
||||
}
|
||||
}
|
||||
Ok(AcpSteeringOutcome::Failed) => {
|
||||
for response_event in translator.steering_failed(
|
||||
"the ACP agent could not inject it into the active turn",
|
||||
) {
|
||||
yield Ok(api::StreamEvent::Response(response_event));
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
log::warn!("ACP live steering failed: {error}");
|
||||
for response_event in translator.steering_failed(&error.to_string()) {
|
||||
yield Ok(api::StreamEvent::Response(response_event));
|
||||
}
|
||||
}
|
||||
}
|
||||
steering_result = Fuse::terminated();
|
||||
if let Some(steering) = steering_queue.pop_front() {
|
||||
steering_result = pending_steering(session.clone(), steering);
|
||||
} else if events.is_terminated() {
|
||||
break;
|
||||
if let Err(error) = control_sender.try_send(TurnCommand::Cancel) {
|
||||
log::warn!("Failed to queue ACP cancellation: {error}");
|
||||
}
|
||||
}
|
||||
event = events.next() => {
|
||||
let Some(event) = event else {
|
||||
if steering_result.is_terminated() && steering_queue.is_empty() {
|
||||
break;
|
||||
};
|
||||
let event = match event {
|
||||
Ok(event) => event,
|
||||
Err(error) => {
|
||||
yield Err(Arc::new(AIApiError::Stream {
|
||||
stream_type: "acp",
|
||||
source: anyhow::anyhow!(error),
|
||||
}));
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
};
|
||||
if let AcpEvent::SessionStarted {
|
||||
session_id,
|
||||
can_load,
|
||||
can_steer,
|
||||
..
|
||||
} = &event
|
||||
{
|
||||
if let Ok(mut metadata) = session_metadata.lock() {
|
||||
metadata.session_id = Some(session_id.to_string());
|
||||
metadata.can_load = *can_load;
|
||||
metadata.can_steer = *can_steer;
|
||||
}
|
||||
}
|
||||
if let AcpEvent::ConfigOptions { options } = &event {
|
||||
if let Ok(mut metadata) = session_metadata.lock() {
|
||||
metadata.config_options = options.clone();
|
||||
}
|
||||
}
|
||||
match translator.translate(event) {
|
||||
Ok(response_events) => {
|
||||
for response_event in response_events {
|
||||
|
||||
@@ -21,11 +21,12 @@ use crate::ai::agent::task::TaskId;
|
||||
use crate::ai::agent::todos::AIAgentTodoList;
|
||||
use crate::ai::agent::util::parse_markdown_into_text_and_code_sections;
|
||||
use crate::ai::agent::{
|
||||
AIAgentAction, AIAgentActionType, AIAgentAttachment, AIAgentCitation, AIAgentInput,
|
||||
AIAgentOutputMessage, AIAgentText, AIAgentTodo, ArtifactCreatedData, CloneRepositoryURL,
|
||||
MessageId, RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest,
|
||||
StartAgentExecutionMode, SubagentCall, SubagentType, SuggestedAgentModeWorkflow, SuggestedRule,
|
||||
Suggestions, SummarizationType, TodoOperation, UserQueryMode, WebFetchStatus, WebSearchStatus,
|
||||
runtime_activity, AIAgentAction, AIAgentActionType, AIAgentAttachment, AIAgentCitation,
|
||||
AIAgentInput, AIAgentOutputMessage, AIAgentText, AIAgentTodo, ArtifactCreatedData,
|
||||
CloneRepositoryURL, MessageId, RunAgentsAgentRunConfig, RunAgentsExecutionMode,
|
||||
RunAgentsRequest, StartAgentExecutionMode, SubagentCall, SubagentType,
|
||||
SuggestedAgentModeWorkflow, SuggestedRule, Suggestions, SummarizationType, TodoOperation,
|
||||
UserQueryMode, WebFetchStatus, WebSearchStatus,
|
||||
};
|
||||
use crate::ai::artifact_download::sanitized_basename;
|
||||
use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentVersion};
|
||||
@@ -272,10 +273,18 @@ impl ConvertAPIMessageToClientOutputMessage for api::Message {
|
||||
.collect::<Result<Vec<AIAgentCitation>, UnknownCitationTypeError>>()?;
|
||||
|
||||
match message {
|
||||
api::message::Message::AgentOutput(output) => Ok(MaybeAIAgentOutputMessage::Message(
|
||||
api::message::Message::AgentOutput(output) => {
|
||||
let message = if let Some(activity) =
|
||||
runtime_activity::decode(&self.server_message_data)
|
||||
{
|
||||
AIAgentOutputMessage::runtime_activity(MessageId::new(self.id), activity)
|
||||
} else {
|
||||
AIAgentOutputMessage::text(MessageId::new(self.id), output.into())
|
||||
.with_citations(citations),
|
||||
)),
|
||||
};
|
||||
Ok(MaybeAIAgentOutputMessage::Message(
|
||||
message.with_citations(citations),
|
||||
))
|
||||
}
|
||||
api::message::Message::AgentReasoning(reasoning) => {
|
||||
let duration = reasoning
|
||||
.finished_duration
|
||||
|
||||
@@ -2,6 +2,7 @@ use std::path::PathBuf;
|
||||
|
||||
use ai::agent::action::AskUserQuestionType;
|
||||
use ai::skills::{SkillPathOrigin, SkillReference};
|
||||
use galaxy_agent_core::{RuntimeActivity, RuntimeActivityStatus};
|
||||
use warp_multi_agent_api as api;
|
||||
use warp_util::local_or_remote_path::LocalOrRemotePath;
|
||||
|
||||
@@ -11,7 +12,8 @@ use super::{
|
||||
};
|
||||
use crate::ai::agent::task::TaskId;
|
||||
use crate::ai::agent::{
|
||||
AIAgentActionType, AIAgentOutputMessageType, LifecycleEventType, StartAgentExecutionMode,
|
||||
runtime_activity, AIAgentActionType, AIAgentOutputMessageType, LifecycleEventType,
|
||||
StartAgentExecutionMode,
|
||||
};
|
||||
|
||||
fn start_agent_tool_call_message(
|
||||
@@ -665,3 +667,45 @@ fn transfer_control_tool_call_converts_to_action_message() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_runtime_activity_converts_to_display_only_output() {
|
||||
let activity = RuntimeActivity {
|
||||
id: "tool-1".to_owned(),
|
||||
title: "List directories".to_owned(),
|
||||
status: Some(RuntimeActivityStatus::Completed),
|
||||
output: Some("payments\nrecords_v2".to_owned()),
|
||||
};
|
||||
let task_id = TaskId::new("task".to_owned());
|
||||
let message = api::Message {
|
||||
fetched_memories: Vec::new(),
|
||||
id: "message".to_owned(),
|
||||
task_id: "task".to_owned(),
|
||||
server_message_data: runtime_activity::encode(&activity).expect("metadata"),
|
||||
citations: Vec::new(),
|
||||
message: Some(api::message::Message::AgentOutput(
|
||||
api::message::AgentOutput {
|
||||
text: "fallback text".to_owned(),
|
||||
},
|
||||
)),
|
||||
request_id: "request".to_owned(),
|
||||
timestamp: None,
|
||||
};
|
||||
|
||||
let converted = message
|
||||
.to_client_output_message(ConversionParams {
|
||||
task_id: &task_id,
|
||||
current_todo_list: None,
|
||||
active_code_review: None,
|
||||
skill_path_origin: &SkillPathOrigin::Local,
|
||||
})
|
||||
.expect("conversion");
|
||||
|
||||
let MaybeAIAgentOutputMessage::Message(output) = converted else {
|
||||
panic!("expected display output");
|
||||
};
|
||||
assert_eq!(
|
||||
output.message,
|
||||
AIAgentOutputMessageType::RuntimeActivity(activity)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -244,7 +244,7 @@ pub async fn generate_multi_agent_output(
|
||||
let err = Arc::new(crate::server::server_api::AIApiError::Stream {
|
||||
stream_type: "none",
|
||||
source: anyhow::anyhow!(
|
||||
"No AI provider configured. Enable Bedrock or OpenAI/LiteLLM in settings."
|
||||
"No AI runtime configured. Enable an agent runtime or model provider in settings."
|
||||
),
|
||||
});
|
||||
let (tx, rx) = async_channel::unbounded();
|
||||
|
||||
@@ -705,6 +705,27 @@ impl AIConversation {
|
||||
&self.agent_backend
|
||||
}
|
||||
|
||||
/// Updates the backend of a conversation that has not produced agent output.
|
||||
///
|
||||
/// Provider failures without output are safe to retry through a newly enabled runtime. Once
|
||||
/// any exchange has produced output, the backend remains stable so provider-native and
|
||||
/// ACP-owned histories are never mixed.
|
||||
pub(crate) fn set_agent_backend_if_no_output(&mut self, agent_backend: AgentBackend) -> bool {
|
||||
let can_change_backend = self.all_exchanges().iter().all(|exchange| {
|
||||
matches!(
|
||||
&exchange.output_status,
|
||||
AIAgentOutputStatus::Finished {
|
||||
finished_output: FinishedAIAgentOutput::Error { output: None, .. }
|
||||
}
|
||||
)
|
||||
});
|
||||
if !can_change_backend && self.agent_backend != agent_backend {
|
||||
return false;
|
||||
}
|
||||
self.agent_backend = agent_backend;
|
||||
true
|
||||
}
|
||||
|
||||
/// Records a resumable ACP session ID.
|
||||
///
|
||||
/// Returns `false` when called for a native provider conversation.
|
||||
|
||||
@@ -106,6 +106,15 @@ fn restored_conversation_with_queries(queries: &[&str]) -> AIConversation {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_backend_does_not_change_after_successful_output() {
|
||||
let mut conversation = restored_conversation_with_queries(&["Review this repository"]);
|
||||
|
||||
assert!(!conversation
|
||||
.set_agent_backend_if_no_output(AgentBackend::Acp(AcpConversationData::default())));
|
||||
assert_eq!(conversation.agent_backend(), &AgentBackend::Provider);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_user_query_returns_latest_non_empty_user_query() {
|
||||
let conversation =
|
||||
|
||||
@@ -7,6 +7,7 @@ pub(crate) mod comment;
|
||||
pub(crate) mod icons;
|
||||
pub(crate) mod linearization;
|
||||
pub(crate) mod redaction;
|
||||
pub(crate) mod runtime_activity;
|
||||
pub(crate) mod task;
|
||||
mod task_store;
|
||||
pub(super) mod telemetry;
|
||||
@@ -27,6 +28,7 @@ use ai::skills::ParsedSkill;
|
||||
use chrono::{DateTime, Local, TimeDelta};
|
||||
use comment::ReviewComment;
|
||||
use derivative::Derivative;
|
||||
use galaxy_agent_core::RuntimeActivity;
|
||||
use galaxy_core::channel::ChannelState;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use markdown_parser::{parse_markdown, FormattedTable, FormattedText, FormattedTextInline};
|
||||
@@ -619,6 +621,13 @@ impl AIAgentOutput {
|
||||
}
|
||||
}
|
||||
}
|
||||
AIAgentOutputMessageType::RuntimeActivity(activity) => {
|
||||
result.push(activity.title.clone());
|
||||
if let Some(output) = &activity.output {
|
||||
result.push(output.clone());
|
||||
}
|
||||
last_was_action = true;
|
||||
}
|
||||
AIAgentOutputMessageType::TodoOperation(operation) => {
|
||||
result.push(format!("{operation}"));
|
||||
last_was_action = false;
|
||||
@@ -1805,6 +1814,10 @@ pub enum AIAgentOutputMessageType {
|
||||
token_count: Option<u32>,
|
||||
},
|
||||
Subagent(SubagentCall),
|
||||
/// Display-only activity executed and owned by an external agent runtime.
|
||||
/// Unlike [`AIAgentOutputMessageType::Action`], Galaxy must never dispatch
|
||||
/// this activity through its action executor.
|
||||
RuntimeActivity(RuntimeActivity),
|
||||
Action(AIAgentAction),
|
||||
TodoOperation(TodoOperation),
|
||||
WebSearch(WebSearchStatus),
|
||||
@@ -1972,6 +1985,12 @@ impl Display for AIAgentOutputMessage {
|
||||
}
|
||||
}
|
||||
AIAgentOutputMessageType::Action(action) => write!(f, "Action: {action}")?,
|
||||
AIAgentOutputMessageType::RuntimeActivity(activity) => {
|
||||
write!(f, "Runtime activity: {}", activity.title)?;
|
||||
if let Some(output) = &activity.output {
|
||||
write!(f, "\n{output}")?;
|
||||
}
|
||||
}
|
||||
AIAgentOutputMessageType::TodoOperation(todo) => write!(f, "Todo: {todo}")?,
|
||||
AIAgentOutputMessageType::Subagent(subagent) => write!(f, "Subagent: {subagent}")?,
|
||||
AIAgentOutputMessageType::WebSearch(status) => match status {
|
||||
@@ -2044,6 +2063,14 @@ impl AIAgentOutputMessage {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn runtime_activity(id: MessageId, activity: RuntimeActivity) -> Self {
|
||||
Self {
|
||||
id,
|
||||
message: AIAgentOutputMessageType::RuntimeActivity(activity),
|
||||
citations: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn text(id: MessageId, text: AIAgentText) -> Self {
|
||||
Self {
|
||||
id,
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
use galaxy_agent_core::RuntimeActivity;
|
||||
|
||||
const SERVER_MESSAGE_DATA_PREFIX: &str = "galaxy:runtime-activity:v1:";
|
||||
|
||||
pub(crate) fn encode(activity: &RuntimeActivity) -> Result<String, serde_json::Error> {
|
||||
serde_json::to_string(activity).map(|json| format!("{SERVER_MESSAGE_DATA_PREFIX}{json}"))
|
||||
}
|
||||
|
||||
pub(crate) fn decode(server_message_data: &str) -> Option<RuntimeActivity> {
|
||||
let json = server_message_data.strip_prefix(SERVER_MESSAGE_DATA_PREFIX)?;
|
||||
serde_json::from_str(json).ok()
|
||||
}
|
||||
@@ -432,6 +432,12 @@ pub mod text {
|
||||
AIAgentActionType::RunAgents(_) => (),
|
||||
AIAgentActionType::WaitForEvents { .. } => (),
|
||||
},
|
||||
AIAgentOutputMessageType::RuntimeActivity(activity) => {
|
||||
writeln!(w, "{}", activity.title)?;
|
||||
if let Some(output) = &activity.output {
|
||||
writeln!(w, "{output}")?;
|
||||
}
|
||||
}
|
||||
AIAgentOutputMessageType::TodoOperation(operation) => match operation {
|
||||
TodoOperation::UpdateTodos { todos } => {
|
||||
writeln!(w, "Updated TODO list:")?;
|
||||
@@ -1144,7 +1150,8 @@ pub mod json {
|
||||
})
|
||||
}
|
||||
AIAgentOutputMessageType::MessagesReceivedFromAgents { .. }
|
||||
| AIAgentOutputMessageType::EventsFromAgents { .. } => None,
|
||||
| AIAgentOutputMessageType::EventsFromAgents { .. }
|
||||
| AIAgentOutputMessageType::RuntimeActivity(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ use base64::Engine as _;
|
||||
use chrono::Duration;
|
||||
use cli_controller::{CLISubagentController, CLISubagentEvent};
|
||||
use find::FindState;
|
||||
use galaxy_agent_core::RuntimeActivityStatus;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_core::ui::theme::color::internal_colors;
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
@@ -818,6 +819,27 @@ impl CollapsibleElementState {
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_runtime_activity(&mut self, is_streaming: bool, is_finished: bool, has_output: bool) {
|
||||
if is_streaming
|
||||
&& has_output
|
||||
&& !self.user_toggled_while_streaming
|
||||
&& matches!(self.expansion_state, CollapsibleExpansionState::Collapsed)
|
||||
{
|
||||
self.expand();
|
||||
}
|
||||
|
||||
self.sync_finished_state(is_finished);
|
||||
if is_finished {
|
||||
if let CollapsibleExpansionState::Expanded {
|
||||
scroll_pinned_to_bottom,
|
||||
..
|
||||
} = &mut self.expansion_state
|
||||
{
|
||||
*scroll_pinned_to_bottom = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies orchestration message display behavior after streaming finishes.
|
||||
fn finish_orchestration_message(&mut self, display_mode: OrchestrationMessageDisplayMode) {
|
||||
let should_auto_collapse = self.should_auto_collapse_on_finish();
|
||||
@@ -2323,6 +2345,32 @@ impl AIBlock {
|
||||
|
||||
// Register element state for reasoning messages and track summarization timing.
|
||||
for message in &output.messages {
|
||||
if let AIAgentOutputMessageType::RuntimeActivity(activity) = &message.message {
|
||||
let is_streaming = matches!(
|
||||
activity.status,
|
||||
Some(RuntimeActivityStatus::Pending | RuntimeActivityStatus::InProgress)
|
||||
);
|
||||
let is_finished = matches!(
|
||||
activity.status,
|
||||
Some(RuntimeActivityStatus::Completed | RuntimeActivityStatus::Failed)
|
||||
);
|
||||
let has_output = activity
|
||||
.output
|
||||
.as_deref()
|
||||
.is_some_and(|output| !output.is_empty());
|
||||
let state = self
|
||||
.collapsible_block_states
|
||||
.entry(message.id.clone())
|
||||
.or_insert_with(|| {
|
||||
if is_streaming && has_output {
|
||||
CollapsibleElementState::default()
|
||||
} else {
|
||||
CollapsibleElementState::collapsed()
|
||||
}
|
||||
});
|
||||
state.sync_runtime_activity(is_streaming, is_finished, has_output);
|
||||
}
|
||||
|
||||
if let AIAgentOutputMessageType::Reasoning {
|
||||
finished_duration, ..
|
||||
} = &message.message
|
||||
@@ -2608,6 +2656,7 @@ impl AIBlock {
|
||||
| AIAgentOutputMessageType::Reasoning { .. }
|
||||
| AIAgentOutputMessageType::Summarization { .. }
|
||||
| AIAgentOutputMessageType::Subagent(_)
|
||||
| AIAgentOutputMessageType::RuntimeActivity(_)
|
||||
| AIAgentOutputMessageType::Action(_)
|
||||
| AIAgentOutputMessageType::TodoOperation(_)
|
||||
| AIAgentOutputMessageType::WebSearch(_)
|
||||
|
||||
@@ -1641,7 +1641,9 @@ fn should_retain_task_output_message(
|
||||
|| (is_latest_exchange
|
||||
&& matches!(
|
||||
message,
|
||||
AIAgentOutputMessageType::Action(_) | AIAgentOutputMessageType::WebSearch(_)
|
||||
AIAgentOutputMessageType::Action(_)
|
||||
| AIAgentOutputMessageType::RuntimeActivity(_)
|
||||
| AIAgentOutputMessageType::WebSearch(_)
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use galaxy_agent_core::RuntimeActivity;
|
||||
use galaxy_terminal::model::escape_sequences;
|
||||
|
||||
use super::{get_action_icon, get_action_loading_text, should_retain_task_output_message};
|
||||
@@ -58,4 +59,13 @@ fn transcript_retains_prior_text_but_only_latest_tool_activity() {
|
||||
});
|
||||
assert!(!should_retain_task_output_message(&poll, false));
|
||||
assert!(should_retain_task_output_message(&poll, true));
|
||||
|
||||
let runtime_activity = AIAgentOutputMessageType::RuntimeActivity(RuntimeActivity {
|
||||
id: "acp-tool".to_owned(),
|
||||
title: "Inspect repository".to_owned(),
|
||||
status: None,
|
||||
output: None,
|
||||
});
|
||||
assert!(!should_retain_task_output_message(&runtime_activity, false));
|
||||
assert!(should_retain_task_output_message(&runtime_activity, true));
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ use ai::agent::action::{
|
||||
};
|
||||
use ai::agent::file_locations::group_file_contexts_for_display;
|
||||
use ai::skills::{ParsedSkill, SkillReference};
|
||||
use galaxy_agent_core::{RuntimeActivity, RuntimeActivityStatus};
|
||||
use galaxy_core::channel::ChannelState;
|
||||
use galaxy_core::ui::theme::color::internal_colors;
|
||||
use galaxy_util::local_or_remote_path::LocalOrRemotePath;
|
||||
@@ -400,6 +401,21 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box<dyn Element> {
|
||||
} if !are_all_text_sections_empty(sections) => {
|
||||
text_section_index += sections.len();
|
||||
}
|
||||
AIAgentOutputMessageType::RuntimeActivity(activity) => {
|
||||
if !matches!(
|
||||
activity.status,
|
||||
Some(RuntimeActivityStatus::Completed)
|
||||
| Some(RuntimeActivityStatus::Failed)
|
||||
) {
|
||||
should_render_footer = false;
|
||||
should_render_suggestions = false;
|
||||
}
|
||||
if let Some(rendered_activity) =
|
||||
render_runtime_activity(output_message, activity, props, app)
|
||||
{
|
||||
output_items.add_child(rendered_activity);
|
||||
}
|
||||
}
|
||||
AIAgentOutputMessageType::Action(AIAgentAction {
|
||||
action: AIAgentActionType::RequestCommandOutput { .. },
|
||||
id,
|
||||
@@ -1262,6 +1278,119 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box<dyn Element> {
|
||||
output_items.finish()
|
||||
}
|
||||
|
||||
fn render_runtime_activity(
|
||||
output_message: &AIAgentOutputMessage,
|
||||
activity: &RuntimeActivity,
|
||||
props: Props,
|
||||
app: &AppContext,
|
||||
) -> Option<Box<dyn Element>> {
|
||||
let state = props.collapsible_block_states.get(&output_message.id)?;
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let text_color = blended_colors::text_main(theme, theme.background());
|
||||
let output = activity
|
||||
.output
|
||||
.as_deref()
|
||||
.filter(|output| !output.is_empty());
|
||||
let is_expanded = matches!(
|
||||
state.expansion_state,
|
||||
CollapsibleExpansionState::Expanded { .. }
|
||||
);
|
||||
let mut content = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
|
||||
|
||||
let title = Text::new(
|
||||
activity.title.clone(),
|
||||
appearance.monospace_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.with_color(text_color)
|
||||
.with_selectable(false)
|
||||
.finish();
|
||||
if output.is_some() {
|
||||
let chevron = if is_expanded {
|
||||
Icon::ChevronDown
|
||||
} else {
|
||||
Icon::ChevronRight
|
||||
};
|
||||
let icon_sz = icon_size(app);
|
||||
let message_id = output_message.id.clone();
|
||||
let mouse_state = state.expansion_toggle_mouse_state.clone();
|
||||
let header = Hoverable::new(mouse_state, move |_| {
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(Shrinkable::new(1., title).finish())
|
||||
.with_child(
|
||||
Container::new(
|
||||
ConstrainedBox::new(chevron.to_galaxyui_icon(text_color.into()).finish())
|
||||
.with_width(icon_sz)
|
||||
.with_height(icon_sz)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_left(6.)
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(AIBlockAction::ToggleCollapsibleBlockExpanded(
|
||||
message_id.clone(),
|
||||
));
|
||||
});
|
||||
content.add_child(
|
||||
Flex::row()
|
||||
.with_child(Shrinkable::new(1., header.finish()).finish())
|
||||
.finish(),
|
||||
);
|
||||
} else {
|
||||
content.add_child(title);
|
||||
}
|
||||
|
||||
if let Some(output) = output {
|
||||
let body = render_requested_action_body_text(
|
||||
output.into(),
|
||||
appearance.monospace_font_family(),
|
||||
app,
|
||||
)
|
||||
.finish();
|
||||
let is_streaming = matches!(
|
||||
activity.status,
|
||||
Some(RuntimeActivityStatus::Pending | RuntimeActivityStatus::InProgress)
|
||||
);
|
||||
if let Some(scrollable) = render_scrollable_collapsible_content(
|
||||
&output_message.id,
|
||||
state,
|
||||
body,
|
||||
is_streaming,
|
||||
320.,
|
||||
) {
|
||||
content.add_child(Container::new(scrollable).with_margin_top(12.).finish());
|
||||
}
|
||||
}
|
||||
|
||||
let icon = match activity.status.as_ref() {
|
||||
Some(RuntimeActivityStatus::Completed) => {
|
||||
inline_action_icons::green_check_icon(appearance).finish()
|
||||
}
|
||||
Some(RuntimeActivityStatus::Failed) => inline_action_icons::red_x_icon(appearance).finish(),
|
||||
Some(RuntimeActivityStatus::Pending)
|
||||
| Some(RuntimeActivityStatus::InProgress)
|
||||
| Some(RuntimeActivityStatus::Other(_))
|
||||
| None => galaxyui::elements::Icon::new(
|
||||
Icon::ClockRefresh.into(),
|
||||
internal_colors::neutral_5(appearance.theme()),
|
||||
)
|
||||
.finish(),
|
||||
};
|
||||
|
||||
Some(
|
||||
RenderableAction::new_with_element(content.finish(), app)
|
||||
.with_icon(icon)
|
||||
.render(app)
|
||||
.finish(),
|
||||
)
|
||||
}
|
||||
|
||||
fn should_render_stopped_output(props: Props, app: &AppContext) -> bool {
|
||||
if FeatureFlag::AgentView.is_enabled() {
|
||||
return false;
|
||||
|
||||
@@ -103,6 +103,54 @@ fn collapsed_initializer_starts_collapsed() {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_runtime_activity_stays_collapsed_until_opened() {
|
||||
let mut state = CollapsibleElementState::collapsed();
|
||||
|
||||
state.sync_runtime_activity(false, true, true);
|
||||
|
||||
assert!(matches!(
|
||||
state.expansion_state,
|
||||
CollapsibleExpansionState::Collapsed
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_runtime_activity_expands_when_output_arrives() {
|
||||
let mut state = CollapsibleElementState::collapsed();
|
||||
|
||||
state.sync_runtime_activity(true, false, true);
|
||||
assert!(matches!(
|
||||
state.expansion_state,
|
||||
CollapsibleExpansionState::Expanded {
|
||||
is_finished: false,
|
||||
scroll_pinned_to_bottom: true
|
||||
}
|
||||
));
|
||||
|
||||
state.sync_runtime_activity(false, true, true);
|
||||
assert!(matches!(
|
||||
state.expansion_state,
|
||||
CollapsibleExpansionState::Expanded {
|
||||
is_finished: true,
|
||||
scroll_pinned_to_bottom: false
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manually_collapsed_streaming_runtime_activity_stays_collapsed() {
|
||||
let mut state = CollapsibleElementState::default();
|
||||
state.toggle_expansion();
|
||||
|
||||
state.sync_runtime_activity(true, false, true);
|
||||
|
||||
assert!(matches!(
|
||||
state.expansion_state,
|
||||
CollapsibleExpansionState::Collapsed
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orchestration_show_and_collapse_collapses_after_finish() {
|
||||
let mut state = default_collapsible_state_for_orchestration_message(
|
||||
|
||||
@@ -754,6 +754,10 @@ impl BlocklistAIController {
|
||||
} => (conversation_id, task_id),
|
||||
};
|
||||
|
||||
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
|
||||
history.refresh_conversation_backend_without_output(conversation_id, ctx);
|
||||
});
|
||||
|
||||
let active_conversation_id =
|
||||
BlocklistAIHistoryModel::as_ref(ctx).active_conversation_id(self.terminal_surface_id);
|
||||
let is_same_conversation_running_command_monitor = match &input_query.input_query {
|
||||
|
||||
@@ -12,6 +12,8 @@ use anyhow::anyhow;
|
||||
use chrono::{DateTime, Local, TimeDelta};
|
||||
use futures::channel::oneshot;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use galaxy_agent_core::TurnCommand;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxyui::{Entity, ModelContext, SingletonEntity};
|
||||
use settings::Setting;
|
||||
@@ -22,7 +24,7 @@ use warp_multi_agent_api::response_event;
|
||||
use crate::ai::acp::{
|
||||
acp_output_stream, acp_startup_error_stream, galaxy_mcp_server, resolve_acp_launch,
|
||||
resolve_acp_permissions, validate_acp_dispatch, validate_acp_launch_identity, AcpRuntimeModel,
|
||||
AcpSessionHandleSlot, AcpSessionMetadata, AcpSteeringRequest, GalaxyMcpTarget,
|
||||
AcpSessionMetadata, AcpTurnControlSlot, GalaxyMcpTarget,
|
||||
};
|
||||
use crate::ai::agent::api::{self, ConvertToAPITypeError};
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
@@ -108,8 +110,7 @@ impl ResponseStreamId {
|
||||
struct AcpRequestControl {
|
||||
cancellation_rx: oneshot::Receiver<()>,
|
||||
session_metadata: Arc<Mutex<AcpSessionMetadata>>,
|
||||
session_handle: AcpSessionHandleSlot,
|
||||
steering_rx: async_channel::Receiver<AcpSteeringRequest>,
|
||||
turn_control: AcpTurnControlSlot,
|
||||
}
|
||||
|
||||
/// Model wrapping an agent API response stream.
|
||||
@@ -125,9 +126,7 @@ pub struct ResponseStream {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
acp_session_metadata: Arc<Mutex<AcpSessionMetadata>>,
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
acp_session_handle: AcpSessionHandleSlot,
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
acp_steering_tx: async_channel::Sender<AcpSteeringRequest>,
|
||||
acp_turn_control: AcpTurnControlSlot,
|
||||
params: api::RequestParams,
|
||||
retry_count: usize,
|
||||
/// One-time fallback from the profile's thinking model to its coding model.
|
||||
@@ -198,9 +197,7 @@ impl ResponseStream {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
acp_session_metadata: Arc::new(Mutex::new(AcpSessionMetadata::default())),
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
acp_session_handle: Arc::new(Mutex::new(None)),
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
acp_steering_tx: async_channel::unbounded().0,
|
||||
acp_turn_control: Arc::new(Mutex::new(None)),
|
||||
params: api::RequestParams::new_for_test(),
|
||||
retry_count: 0,
|
||||
coding_model_fallback_attempted: false,
|
||||
@@ -328,8 +325,7 @@ impl ResponseStream {
|
||||
let AcpRequestControl {
|
||||
cancellation_rx,
|
||||
session_metadata,
|
||||
session_handle,
|
||||
steering_rx,
|
||||
turn_control,
|
||||
} = control;
|
||||
let profile = BlocklistAIPermissions::as_ref(ctx)
|
||||
.active_permissions_profile(ctx, params.terminal_view_id);
|
||||
@@ -398,8 +394,7 @@ impl ResponseStream {
|
||||
permissions.policy,
|
||||
permissions.auto_approve_protocol_requests,
|
||||
session_metadata,
|
||||
session_handle,
|
||||
steering_rx,
|
||||
turn_control,
|
||||
cancellation_rx,
|
||||
)
|
||||
.await
|
||||
@@ -447,9 +442,7 @@ impl ResponseStream {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
let acp_session_metadata = Arc::new(Mutex::new(AcpSessionMetadata::default()));
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
let acp_session_handle = Arc::new(Mutex::new(None));
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
let (acp_steering_tx, acp_steering_rx) = async_channel::unbounded();
|
||||
let acp_turn_control = Arc::new(Mutex::new(None));
|
||||
match &agent_backend {
|
||||
AgentBackend::Provider => {
|
||||
let provider_config = Self::resolve_provider_config(params.model.as_str(), ctx);
|
||||
@@ -474,8 +467,7 @@ impl ResponseStream {
|
||||
AcpRequestControl {
|
||||
cancellation_rx,
|
||||
session_metadata: acp_session_metadata.clone(),
|
||||
session_handle: acp_session_handle.clone(),
|
||||
steering_rx: acp_steering_rx,
|
||||
turn_control: acp_turn_control.clone(),
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
@@ -501,9 +493,7 @@ impl ResponseStream {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
acp_session_metadata,
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
acp_session_handle,
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
acp_steering_tx,
|
||||
acp_turn_control,
|
||||
params: params.clone(),
|
||||
start_time,
|
||||
time_to_latest_event: TimeDelta::seconds(0),
|
||||
@@ -550,18 +540,23 @@ impl ResponseStream {
|
||||
|| !self
|
||||
.acp_session_metadata()
|
||||
.is_some_and(|metadata| metadata.can_steer)
|
||||
|| !self
|
||||
.acp_session_handle
|
||||
.lock()
|
||||
.is_ok_and(|session| session.is_some())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let mut model_text = display_text.clone();
|
||||
self.params.redact_text_for_model(&mut model_text);
|
||||
self.acp_steering_tx
|
||||
.try_send(AcpSteeringRequest::text(display_text, model_text))
|
||||
self.acp_turn_control
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|control| control.clone())
|
||||
.is_some_and(|control| {
|
||||
control
|
||||
.try_send(TurnCommand::Steer {
|
||||
display_text,
|
||||
model_text,
|
||||
})
|
||||
.is_ok()
|
||||
})
|
||||
}
|
||||
#[cfg(target_family = "wasm")]
|
||||
{
|
||||
|
||||
@@ -1182,28 +1182,24 @@ impl BlocklistAIHistoryModel {
|
||||
});
|
||||
}
|
||||
|
||||
/// Starts a new conversation in the given terminal surface's history, effectively marking the
|
||||
/// existing conversation (if any) as completed.
|
||||
///
|
||||
/// Returns the ID of the created conversation.
|
||||
///
|
||||
/// Conversation completion is inferred if the conversation in question is _not_ the last
|
||||
/// element in the `conversations` vector.
|
||||
pub fn start_new_conversation(
|
||||
&mut self,
|
||||
terminal_surface_id: EntityId,
|
||||
is_autoexecute_override: bool,
|
||||
fn configured_agent_backend(
|
||||
is_viewing_shared_session: bool,
|
||||
is_cli_agent_transcript: bool,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> AIConversationId {
|
||||
let agent_backend = if !is_viewing_shared_session
|
||||
&& !is_cli_agent_transcript
|
||||
&& cfg!(unix)
|
||||
&& FeatureFlag::AgentClientProtocol.is_enabled()
|
||||
ctx: &AppContext,
|
||||
) -> AgentBackend {
|
||||
if is_viewing_shared_session
|
||||
|| is_cli_agent_transcript
|
||||
|| !cfg!(unix)
|
||||
|| !FeatureFlag::AgentClientProtocol.is_enabled()
|
||||
{
|
||||
return AgentBackend::Provider;
|
||||
}
|
||||
|
||||
let settings = AISettings::as_ref(ctx);
|
||||
if *settings.acp_enabled.value() {
|
||||
if !*settings.acp_enabled.value() {
|
||||
return AgentBackend::Provider;
|
||||
}
|
||||
|
||||
let configured_agent_id = settings.acp_agent_id.value().trim();
|
||||
let agent_id = if configured_agent_id.is_empty() {
|
||||
"codex"
|
||||
@@ -1229,23 +1225,60 @@ impl BlocklistAIHistoryModel {
|
||||
.find(|agent| agent.id.eq_ignore_ascii_case(agent_id))
|
||||
.map(|agent| {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
if let Some(selection) = LLMPreferences::as_ref(ctx)
|
||||
.selected_acp_config_for_agent(&agent.name, ctx)
|
||||
if let Some(selection) =
|
||||
LLMPreferences::as_ref(ctx).selected_acp_config_for_agent(&agent.name, ctx)
|
||||
{
|
||||
return selection;
|
||||
}
|
||||
crate::ai::acp::AcpRuntimeModel::current_config_values(
|
||||
&agent.config_options,
|
||||
)
|
||||
crate::ai::acp::AcpRuntimeModel::current_config_values(&agent.config_options)
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
} else {
|
||||
AgentBackend::Provider
|
||||
}
|
||||
} else {
|
||||
AgentBackend::Provider
|
||||
|
||||
/// Reconciles a conversation without agent output with the currently enabled local runtime.
|
||||
///
|
||||
/// Agent views can create their initial conversation before the user changes runtime settings,
|
||||
/// and a provider-less attempt can leave behind an error-only exchange. Refreshing here lets
|
||||
/// either case use ACP without mixing successful provider output into an ACP-owned history.
|
||||
pub(crate) fn refresh_conversation_backend_without_output(
|
||||
&mut self,
|
||||
conversation_id: AIConversationId,
|
||||
ctx: &AppContext,
|
||||
) {
|
||||
let Some(conversation) = self.conversation(&conversation_id) else {
|
||||
return;
|
||||
};
|
||||
let agent_backend = Self::configured_agent_backend(
|
||||
conversation.is_viewing_shared_session(),
|
||||
conversation.is_cli_agent_transcript(),
|
||||
ctx,
|
||||
);
|
||||
if conversation.agent_backend() == &agent_backend {
|
||||
return;
|
||||
}
|
||||
if let Some(conversation) = self.conversation_mut(&conversation_id) {
|
||||
conversation.set_agent_backend_if_no_output(agent_backend);
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts a new conversation in the given terminal surface's history, effectively marking the
|
||||
/// existing conversation (if any) as completed.
|
||||
///
|
||||
/// Returns the ID of the created conversation.
|
||||
///
|
||||
/// Conversation completion is inferred if the conversation in question is _not_ the last
|
||||
/// element in the `conversations` vector.
|
||||
pub fn start_new_conversation(
|
||||
&mut self,
|
||||
terminal_surface_id: EntityId,
|
||||
is_autoexecute_override: bool,
|
||||
is_viewing_shared_session: bool,
|
||||
is_cli_agent_transcript: bool,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> AIConversationId {
|
||||
let agent_backend =
|
||||
Self::configured_agent_backend(is_viewing_shared_session, is_cli_agent_transcript, ctx);
|
||||
let mut new_conversation = AIConversation::new_with_agent_backend(
|
||||
is_viewing_shared_session,
|
||||
is_cli_agent_transcript,
|
||||
|
||||
@@ -88,6 +88,82 @@ fn acp_enabled_with_empty_command_selects_codex_backend() {
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabling_acp_refreshes_a_provider_conversation_with_only_failed_output() {
|
||||
let _acp_flag = FeatureFlag::AgentClientProtocol.override_enabled(true);
|
||||
App::test((), |mut app| async move {
|
||||
initialize_history_persistence_for_tests(&mut app);
|
||||
AISettings::handle(&app).update(&mut app, |settings, ctx| {
|
||||
settings
|
||||
.acp_enabled
|
||||
.set_value(false, ctx)
|
||||
.expect("ACP setting should update");
|
||||
});
|
||||
|
||||
let terminal_view_id = EntityId::new();
|
||||
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||
let conversation_id = history_model.update(&mut app, |model, ctx| {
|
||||
model.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
history_model.read(&app, |model, _| {
|
||||
assert_eq!(
|
||||
model
|
||||
.conversation(&conversation_id)
|
||||
.expect("conversation should exist")
|
||||
.agent_backend(),
|
||||
&AgentBackend::Provider
|
||||
);
|
||||
});
|
||||
history_model.update(&mut app, |model, _| {
|
||||
let now = Local::now();
|
||||
model
|
||||
.conversation_mut(&conversation_id)
|
||||
.expect("conversation should exist")
|
||||
.append_root_exchange_for_test(AIAgentExchange {
|
||||
id: AIAgentExchangeId::new(),
|
||||
input: Vec::new(),
|
||||
output_status: AIAgentOutputStatus::Finished {
|
||||
finished_output: FinishedAIAgentOutput::Error {
|
||||
output: None,
|
||||
error: RenderableAIError::other("No AI provider configured", true),
|
||||
},
|
||||
},
|
||||
added_message_ids: HashSet::new(),
|
||||
start_time: now,
|
||||
finish_time: Some(now),
|
||||
time_to_first_token_ms: None,
|
||||
working_directory: None,
|
||||
model_id: LLMId::from("none"),
|
||||
request_cost: None,
|
||||
coding_model_id: LLMId::from("none"),
|
||||
cli_agent_model_id: LLMId::from("none"),
|
||||
computer_use_model_id: LLMId::from("none"),
|
||||
response_initiator: None,
|
||||
});
|
||||
});
|
||||
|
||||
AISettings::handle(&app).update(&mut app, |settings, ctx| {
|
||||
settings
|
||||
.acp_enabled
|
||||
.set_value(true, ctx)
|
||||
.expect("ACP setting should update");
|
||||
});
|
||||
history_model.update(&mut app, |model, ctx| {
|
||||
model.refresh_conversation_backend_without_output(conversation_id, ctx);
|
||||
});
|
||||
|
||||
history_model.read(&app, |model, _| {
|
||||
assert!(matches!(
|
||||
model
|
||||
.conversation(&conversation_id)
|
||||
.expect("conversation should exist")
|
||||
.agent_backend(),
|
||||
AgentBackend::Acp(_)
|
||||
));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Helper function to create a PersistedAIInput for testing
|
||||
fn create_persisted_query(
|
||||
query_text: &str,
|
||||
|
||||
@@ -469,6 +469,7 @@ impl OrchestrationEventService {
|
||||
| AIAgentOutputMessageType::Reasoning { .. }
|
||||
| AIAgentOutputMessageType::Summarization { .. }
|
||||
| AIAgentOutputMessageType::Subagent(_)
|
||||
| AIAgentOutputMessageType::RuntimeActivity(_)
|
||||
| AIAgentOutputMessageType::Action(_)
|
||||
| AIAgentOutputMessageType::TodoOperation(_)
|
||||
| AIAgentOutputMessageType::WebSearch(_)
|
||||
|
||||
+15
-2
@@ -421,7 +421,7 @@ impl AvailableLLMs {
|
||||
request_multiplier: 1,
|
||||
credit_multiplier: None,
|
||||
},
|
||||
description: Some("Enable Bedrock or OpenAI/LiteLLM in settings".to_string()),
|
||||
description: Some("Enable an AI runtime in Models settings".to_string()),
|
||||
disable_reason: Some(DisableReason::Unavailable),
|
||||
vision_supported: false,
|
||||
spec: None,
|
||||
@@ -524,7 +524,7 @@ impl Default for ModelsByFeature {
|
||||
request_multiplier: 1,
|
||||
credit_multiplier: None,
|
||||
},
|
||||
description: Some("Enable Bedrock or OpenAI/LiteLLM in settings".to_string()),
|
||||
description: Some("Enable an AI runtime in Models settings".to_string()),
|
||||
disable_reason: None,
|
||||
vision_supported: false,
|
||||
spec: None,
|
||||
@@ -639,6 +639,7 @@ impl LLMPreferences {
|
||||
event,
|
||||
AISettingsChangedEvent::BedrockEnabled { .. }
|
||||
| AISettingsChangedEvent::OpenAIEnabled { .. }
|
||||
| AISettingsChangedEvent::AcpEnabled { .. }
|
||||
| AISettingsChangedEvent::OpenAIBaseUrl { .. }
|
||||
| AISettingsChangedEvent::OpenAIApiKey { .. }
|
||||
| AISettingsChangedEvent::OpenAIModels { .. }
|
||||
@@ -1047,8 +1048,20 @@ impl LLMPreferences {
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn inject_acp_models(&mut self, ctx: &AppContext) {
|
||||
let previous_acp_model_ids = self.acp_selections.keys().cloned().collect::<HashSet<_>>();
|
||||
let remove_previous_acp_models = |choices: &mut Vec<LLMInfo>| {
|
||||
choices.retain(|model| !previous_acp_model_ids.contains(&model.id));
|
||||
};
|
||||
remove_previous_acp_models(&mut self.models_by_feature.agent_mode.choices);
|
||||
remove_previous_acp_models(&mut self.models_by_feature.coding.choices);
|
||||
if let Some(cli) = &mut self.models_by_feature.cli_agent {
|
||||
remove_previous_acp_models(&mut cli.choices);
|
||||
}
|
||||
self.acp_selections.clear();
|
||||
let settings = AISettings::as_ref(ctx);
|
||||
if !*settings.acp_enabled.value() {
|
||||
return;
|
||||
}
|
||||
for agent in settings.acp_agents.value() {
|
||||
let model_option = agent
|
||||
.config_options
|
||||
|
||||
@@ -10,7 +10,9 @@ use crate::network::NetworkStatus;
|
||||
use crate::server::cloud_objects::update_manager::UpdateManager;
|
||||
use crate::server::server_api::ServerApiProvider;
|
||||
use crate::server::sync_queue::SyncQueue;
|
||||
use crate::settings::OpenAIModelConfig;
|
||||
use crate::settings::{
|
||||
AcpAgentSettings, AcpConfigOptionSettings, AcpConfigValueSettings, OpenAIModelConfig,
|
||||
};
|
||||
use crate::test_util::settings::initialize_settings_for_tests;
|
||||
use crate::workspaces::team_tester::TeamTesterStatus;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
@@ -189,6 +191,90 @@ fn codex_models_reject_system_messages_even_with_stale_true_metadata() {
|
||||
assert!(!model.supports_system_messages());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_models_are_injected_only_while_acp_is_enabled() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_settings_for_tests(&mut app);
|
||||
AISettings::handle(&app).update(&mut app, |settings, ctx| {
|
||||
settings
|
||||
.acp_enabled
|
||||
.set_value(true, ctx)
|
||||
.expect("ACP setting should update");
|
||||
settings
|
||||
.acp_agents
|
||||
.set_value(
|
||||
vec![AcpAgentSettings {
|
||||
id: "codex".to_owned(),
|
||||
name: "Codex".to_owned(),
|
||||
version: None,
|
||||
description: None,
|
||||
icon_url: None,
|
||||
capabilities: Vec::new(),
|
||||
config_options: vec![AcpConfigOptionSettings {
|
||||
id: "model".to_owned(),
|
||||
name: "Model".to_owned(),
|
||||
description: None,
|
||||
category: Some("model".to_owned()),
|
||||
kind: "select".to_owned(),
|
||||
current_value: serde_json::json!("gpt-test"),
|
||||
options: vec![AcpConfigValueSettings {
|
||||
value: serde_json::json!("gpt-test"),
|
||||
name: "GPT Test".to_owned(),
|
||||
description: None,
|
||||
}],
|
||||
}],
|
||||
discovery_timestamp: None,
|
||||
discovery_source: None,
|
||||
discovery_error: None,
|
||||
}],
|
||||
ctx,
|
||||
)
|
||||
.expect("ACP agents should update");
|
||||
});
|
||||
|
||||
let mut preferences = LLMPreferences {
|
||||
models_by_feature: ModelsByFeature::default(),
|
||||
last_update: None,
|
||||
base_llm_for_terminal_view: HashMap::new(),
|
||||
custom_llms: Vec::new(),
|
||||
custom_model_routers: Vec::new(),
|
||||
openai_provider_routing: HashMap::new(),
|
||||
fetched_openai_models: Vec::new(),
|
||||
acp_selections: HashMap::new(),
|
||||
};
|
||||
app.read(|ctx| preferences.inject_acp_models(ctx));
|
||||
app.read(|ctx| preferences.inject_acp_models(ctx));
|
||||
|
||||
assert_eq!(preferences.acp_selections.len(), 1);
|
||||
assert_eq!(
|
||||
preferences
|
||||
.models_by_feature
|
||||
.agent_mode
|
||||
.choices
|
||||
.iter()
|
||||
.filter(|model| model.id.as_str().starts_with("acp:"))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
|
||||
AISettings::handle(&app).update(&mut app, |settings, ctx| {
|
||||
settings
|
||||
.acp_enabled
|
||||
.set_value(false, ctx)
|
||||
.expect("ACP setting should update");
|
||||
});
|
||||
app.read(|ctx| preferences.inject_acp_models(ctx));
|
||||
|
||||
assert!(preferences.acp_selections.is_empty());
|
||||
assert!(preferences
|
||||
.models_by_feature
|
||||
.agent_mode
|
||||
.choices
|
||||
.iter()
|
||||
.all(|model| !model.id.as_str().starts_with("acp:")));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_discovery_enables_rig_for_new_models_and_keeps_manual_models() {
|
||||
let manual = openai_model("manual-model");
|
||||
|
||||
@@ -305,6 +305,16 @@ where
|
||||
), stream_type));
|
||||
return;
|
||||
}
|
||||
AgentEvent::RuntimeActivityUpdated { .. }
|
||||
| AgentEvent::ContextUsageUpdated { .. }
|
||||
| AgentEvent::UserInputAccepted { .. }
|
||||
| AgentEvent::RuntimeNotice { .. } => {
|
||||
yield Err(agent_error(AgentError::new(
|
||||
galaxy_agent_core::AgentErrorKind::Protocol,
|
||||
"the provider runtime emitted a session-runtime event",
|
||||
), stream_type));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+14
-3
@@ -2091,9 +2091,20 @@ impl AISettings {
|
||||
|
||||
pub fn is_any_ai_enabled(&self, app: &AppContext) -> bool {
|
||||
// Galaxy does not require Warp authentication for AI.
|
||||
// AI is enabled as long as the user hasn't explicitly disabled it
|
||||
// and there's no org policy blocking it.
|
||||
*self.is_any_ai_enabled && !self.is_ai_disabled_due_to_remote_session_org_policy(app)
|
||||
// AI is enabled only when the user hasn't explicitly disabled it, at least one local
|
||||
// runtime is enabled, and there's no org policy blocking it.
|
||||
*self.is_any_ai_enabled
|
||||
&& self.has_enabled_ai_runtime()
|
||||
&& !self.is_ai_disabled_due_to_remote_session_org_policy(app)
|
||||
}
|
||||
|
||||
/// Returns whether Galaxy has a local model provider or agent runtime enabled.
|
||||
pub fn has_enabled_ai_runtime(&self) -> bool {
|
||||
*self.bedrock_enabled.value()
|
||||
|| *self.openai_enabled.value()
|
||||
|| (cfg!(unix)
|
||||
&& FeatureFlag::AgentClientProtocol.is_enabled()
|
||||
&& *self.acp_enabled.value())
|
||||
}
|
||||
|
||||
pub fn default_session_mode(&self, app: &AppContext) -> DefaultSessionMode {
|
||||
|
||||
@@ -422,6 +422,47 @@ fn orchestration_is_enabled_when_ai_is_enabled() {
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ai_is_disabled_without_an_enabled_runtime() {
|
||||
let _acp_flag = FeatureFlag::AgentClientProtocol.override_enabled(true);
|
||||
App::test((), |mut app| async move {
|
||||
initialize_settings_for_tests(&mut app);
|
||||
add_ai_enablement_dependencies_for_test(&mut app);
|
||||
|
||||
AISettings::handle(&app).update(&mut app, |settings, ctx| {
|
||||
settings
|
||||
.bedrock_enabled
|
||||
.set_value(false, ctx)
|
||||
.expect("Bedrock setting should update");
|
||||
settings
|
||||
.openai_enabled
|
||||
.set_value(false, ctx)
|
||||
.expect("OpenAI setting should update");
|
||||
settings
|
||||
.acp_enabled
|
||||
.set_value(false, ctx)
|
||||
.expect("ACP setting should update");
|
||||
});
|
||||
|
||||
AISettings::handle(&app).read(&app, |settings, ctx| {
|
||||
assert!(!settings.has_enabled_ai_runtime());
|
||||
assert!(!settings.is_any_ai_enabled(ctx));
|
||||
});
|
||||
|
||||
AISettings::handle(&app).update(&mut app, |settings, ctx| {
|
||||
settings
|
||||
.acp_enabled
|
||||
.set_value(true, ctx)
|
||||
.expect("ACP setting should update");
|
||||
});
|
||||
|
||||
AISettings::handle(&app).read(&app, |settings, ctx| {
|
||||
assert!(settings.has_enabled_ai_runtime());
|
||||
assert!(settings.is_any_ai_enabled(ctx));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_display_quota_reset_banner_with_empty_history() {
|
||||
App::test((), |mut app| async move {
|
||||
|
||||
@@ -1973,7 +1973,7 @@ impl AISettingsPageView {
|
||||
widgets.push(Box::new(CloudHandoffWidget::default()));
|
||||
widgets.push(Box::new(CLIAgentWidget::default()));
|
||||
if cfg!(unix) && FeatureFlag::AgentClientProtocol.is_enabled() {
|
||||
widgets.push(Box::new(ACPSettingsWidget::new(ctx)));
|
||||
widgets.push(Box::new(AgentRuntimeSettingsWidget::new(ctx)));
|
||||
}
|
||||
widgets.push(Box::new(AgentAttributionWidget::default()));
|
||||
widgets.push(Box::new(OtherAIWidget::default()));
|
||||
@@ -2011,9 +2011,6 @@ impl AISettingsPageView {
|
||||
widgets.push(Box::new(VoiceWidget::default()));
|
||||
}
|
||||
widgets.push(Box::new(CloudHandoffWidget::default()));
|
||||
if cfg!(unix) && FeatureFlag::AgentClientProtocol.is_enabled() {
|
||||
widgets.push(Box::new(ACPSettingsWidget::new(ctx)));
|
||||
}
|
||||
if FeatureFlag::CustomModelRouters.is_enabled() {
|
||||
widgets.push(Box::new(CustomModelRoutersWidget));
|
||||
}
|
||||
@@ -2038,6 +2035,9 @@ impl AISettingsPageView {
|
||||
widgets.push(Box::new(ModelsOverviewWidget));
|
||||
widgets.push(Box::new(OpenAISettingsWidget::new(ctx)));
|
||||
widgets.push(Box::new(BedrockSettingsWidget::new(ctx)));
|
||||
if cfg!(unix) && FeatureFlag::AgentClientProtocol.is_enabled() {
|
||||
widgets.push(Box::new(AgentRuntimeSettingsWidget::new(ctx)));
|
||||
}
|
||||
let title: Option<&str> = None;
|
||||
return (PageType::new_uncategorized(widgets, title), None);
|
||||
}
|
||||
@@ -7268,7 +7268,7 @@ impl SettingsWidget for ModelsOverviewWidget {
|
||||
type View = AISettingsPageView;
|
||||
|
||||
fn search_terms(&self) -> &str {
|
||||
"models providers rig litellm openai compatible ollama lm studio bedrock"
|
||||
"models providers runtimes rig litellm openai compatible ollama lm studio bedrock acp agent client protocol"
|
||||
}
|
||||
|
||||
fn render(
|
||||
@@ -7286,18 +7286,19 @@ impl SettingsWidget for ModelsOverviewWidget {
|
||||
.map(|provider| provider.models.len())
|
||||
.sum::<usize>();
|
||||
let bedrock_model_count = settings.bedrock_models.value().len();
|
||||
let agent_runtime_count = usize::from(*settings.acp_enabled.value());
|
||||
|
||||
Flex::column()
|
||||
.with_spacing(8.)
|
||||
.with_child(build_sub_header(appearance, "Models", None).finish())
|
||||
.with_child(render_ai_setting_description(
|
||||
"Configure the model providers available to Galaxy. OpenAI-compatible endpoints and opted-in Bedrock models share the same Rig conversation, tool, and UI runtime; Bedrock's compatibility path remains available during validation.",
|
||||
"Configure Galaxy's direct model providers and agent runtimes in one place. OpenAI-compatible endpoints and Bedrock models run through Rig. ACP coding agents use the same Galaxy runtime boundary while retaining their own model, login, session, and tool loop.",
|
||||
true,
|
||||
app,
|
||||
))
|
||||
.with_child(render_ai_setting_description(
|
||||
format!(
|
||||
"{endpoint_count} OpenAI-compatible provider(s) with {endpoint_model_count} model(s); {bedrock_model_count} Bedrock model(s)."
|
||||
"{endpoint_count} OpenAI-compatible provider(s) with {endpoint_model_count} model(s); {bedrock_model_count} Bedrock model(s); {agent_runtime_count} enabled agent runtime(s)."
|
||||
),
|
||||
true,
|
||||
app,
|
||||
@@ -7811,7 +7812,7 @@ impl SettingsWidget for BedrockSettingsWidget {
|
||||
}
|
||||
}
|
||||
|
||||
struct ACPSettingsWidget {
|
||||
struct AgentRuntimeSettingsWidget {
|
||||
enabled_toggle: SwitchStateHandle,
|
||||
agent_id_editor: ViewHandle<EditorView>,
|
||||
command_editor: ViewHandle<EditorView>,
|
||||
@@ -7820,7 +7821,7 @@ struct ACPSettingsWidget {
|
||||
refresh_button: ViewHandle<ActionButton>,
|
||||
}
|
||||
|
||||
impl ACPSettingsWidget {
|
||||
impl AgentRuntimeSettingsWidget {
|
||||
fn new(ctx: &mut ViewContext<<Self as SettingsWidget>::View>) -> Self {
|
||||
let settings = AISettings::as_ref(ctx);
|
||||
let is_enabled = *settings.acp_enabled.value();
|
||||
@@ -7988,11 +7989,11 @@ impl ACPSettingsWidget {
|
||||
}
|
||||
}
|
||||
|
||||
impl SettingsWidget for ACPSettingsWidget {
|
||||
impl SettingsWidget for AgentRuntimeSettingsWidget {
|
||||
type View = AISettingsPageView;
|
||||
|
||||
fn search_terms(&self) -> &str {
|
||||
"acp agent client protocol codex opencode subscription local agent"
|
||||
"agent runtime acp agent client protocol codex opencode subscription local"
|
||||
}
|
||||
|
||||
fn should_render(&self, _app: &AppContext) -> bool {
|
||||
@@ -8014,9 +8015,14 @@ impl SettingsWidget for ACPSettingsWidget {
|
||||
.discovery_state()
|
||||
.clone();
|
||||
|
||||
column.add_child(build_sub_header(appearance, "Agent Client Protocol", None).finish());
|
||||
column.add_child(build_sub_header(appearance, "Agent runtimes", None).finish());
|
||||
column.add_child(render_ai_setting_description(
|
||||
"Connect session-oriented coding agents through the Agent Client Protocol. Agent runtimes are not model providers: Galaxy supplies the workspace UI and safety boundary while the agent owns its model loop and session.",
|
||||
true,
|
||||
app,
|
||||
));
|
||||
column.add_child(render_ai_setting_toggle::<AcpEnabled>(
|
||||
"Use an ACP agent for new conversations",
|
||||
"Enable ACP agent runtime",
|
||||
AISettingsPageAction::ToggleAcpEnabled,
|
||||
is_enabled,
|
||||
true,
|
||||
|
||||
@@ -363,9 +363,14 @@ impl FromStr for SettingsSection {
|
||||
"MCP servers" | "AgentMCPServers" => Ok(Self::AgentMCPServers),
|
||||
"Knowledge" => Ok(Self::Knowledge),
|
||||
"Third party CLI agents" | "ThirdPartyCLIAgents" => Ok(Self::ThirdPartyCLIAgents),
|
||||
"Models" | "AWS Bedrock" | "Bedrock" | "OpenAI / LiteLLM" | "OpenAI" => {
|
||||
Ok(Self::Models)
|
||||
}
|
||||
"Models"
|
||||
| "AWS Bedrock"
|
||||
| "Bedrock"
|
||||
| "OpenAI / LiteLLM"
|
||||
| "OpenAI"
|
||||
| "Agent runtimes"
|
||||
| "Agent Client Protocol"
|
||||
| "ACP" => Ok(Self::Models),
|
||||
"Indexing and projects" | "CodeIndexing" => Ok(Self::CodeIndexing),
|
||||
"Editor and Code Review" | "EditorAndCodeReview" => Ok(Self::EditorAndCodeReview),
|
||||
"Experiments" => Ok(Self::Experiments),
|
||||
|
||||
@@ -114,6 +114,9 @@ fn legacy_settings_names_remain_parseable() {
|
||||
("Bedrock", SettingsSection::Models),
|
||||
("OpenAI / LiteLLM", SettingsSection::Models),
|
||||
("OpenAI", SettingsSection::Models),
|
||||
("Agent runtimes", SettingsSection::Models),
|
||||
("Agent Client Protocol", SettingsSection::Models),
|
||||
("ACP", SettingsSection::Models),
|
||||
("CodeIndexing", SettingsSection::CodeIndexing),
|
||||
("EditorAndCodeReview", SettingsSection::EditorAndCodeReview),
|
||||
] {
|
||||
|
||||
@@ -113,6 +113,9 @@ impl SlashCommandDataSource {
|
||||
if matches!(
|
||||
event,
|
||||
AISettingsChangedEvent::IsAnyAIEnabled { .. }
|
||||
| AISettingsChangedEvent::BedrockEnabled { .. }
|
||||
| AISettingsChangedEvent::OpenAIEnabled { .. }
|
||||
| AISettingsChangedEvent::AcpEnabled { .. }
|
||||
| AISettingsChangedEvent::ShouldForceDisableCloudHandoff { .. }
|
||||
) {
|
||||
me.recompute_active_commands(ctx);
|
||||
|
||||
@@ -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