use base64::Engine as _; use galaxy_agent_core::{ContentPart, MessageContent}; use crate::ai::agent::api::RequestParams; use crate::ai::agent::{AIAgentAttachment, AIAgentContext, AIAgentInput, MarkdownActionResult}; const CONTEXT_HEADER: &str = "\n\n\n"; const CONTEXT_FOOTER: &str = "\n"; const SYSTEM_REQUEST_PLACEHOLDER: &str = "Handle the Galaxy system request in the hidden context below."; const MAX_RUNNING_COMMAND_OUTPUT_CHARS: usize = 32_000; #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub(super) struct GalaxyTerminalTools { pub(super) status: bool, pub(super) interrupt: bool, } /// Converts Galaxy's rich request input into ACP prompt content. /// /// The first text block contains the user's visible request. Context and global /// rules are appended in a clearly delimited block that is sent to the agent but /// is not copied into Galaxy's visible user bubble. Images stay as native ACP /// image blocks so adapters can forward them to multimodal models. pub(super) fn prompt_content( params: &RequestParams, terminal_tools: GalaxyTerminalTools, ) -> Result { let visible_query = params .input .iter() .rev() .find_map(AIAgentInput::display_query); let mut hidden_context = Vec::new(); let mut images = Vec::new(); for input in ¶ms.input { append_hidden_input(input, terminal_tools, &mut hidden_context)?; if let Some(context) = input.context() { for item in context { match item { AIAgentContext::Image(image) => { 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}")); } AIAgentContext::File(file) => { hidden_context.push(format!("File context:\n{}", serialize_context(file)?)); } AIAgentContext::Directory { pwd, .. } => { if let Some(pwd) = pwd { hidden_context.push(format!("Working directory: {pwd}")); } } AIAgentContext::ExecutionEnvironment(environment) => { hidden_context.push(format!( "Execution environment:\n{}", serialize_context(environment)? )) } AIAgentContext::CurrentTime { current_time } => { hidden_context.push(format!("Current time: {current_time}")); } AIAgentContext::Codebase { path, name } => { hidden_context.push(format!("Codebase: {name} ({path})")); } AIAgentContext::ProjectRules { .. } | AIAgentContext::Git { .. } | AIAgentContext::Repository { .. } | AIAgentContext::PullRequest { .. } | AIAgentContext::Skills { .. } | AIAgentContext::Block(_) => { hidden_context.push(format!( "Additional Galaxy context:\n{}", serialize_context(item)? )); } } } } if let AIAgentInput::UserQuery { referenced_attachments, .. } = input { for (name, attachment) in referenced_attachments { hidden_context.push(attachment_text(name, attachment)?); } } } if !params.global_rules.is_empty() { let rules = params .global_rules .iter() .map(|(name, content)| format!("Rule: {name}\n{content}")) .collect::>() .join("\n\n"); hidden_context.push(format!("Galaxy rules:\n{rules}")); } let mut text = visible_query.unwrap_or_else(|| SYSTEM_REQUEST_PLACEHOLDER.to_owned()); if !hidden_context.is_empty() { text.push_str(CONTEXT_HEADER); text.push_str(&hidden_context.join("\n\n")); text.push_str(CONTEXT_FOOTER); } params.redact_text_for_model(&mut text); 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( input: &AIAgentInput, terminal_tools: GalaxyTerminalTools, hidden_context: &mut Vec, ) -> Result<(), String> { match input { AIAgentInput::UserQuery { running_command: Some(command), .. } => { hidden_context.push(format!( "A command is running in the user's visible Galaxy terminal.\n\ Command: {}\n\ Galaxy block_id: {}\n\ Alternate screen: {}\n\ Current output:\n{}\n\n{}", command.command, command.block_id, command.is_alt_screen_active, tail_chars(&command.grid_contents, MAX_RUNNING_COMMAND_OUTPUT_CHARS), running_command_tool_guidance(terminal_tools), )); } AIAgentInput::UserQuery { .. } | AIAgentInput::CreateNewProject { .. } => {} AIAgentInput::CommandCompletionAssessment { prompt, completed_command, .. } => { hidden_context.push(format!( "A monitored command has completed.\n\ Command: {}\n\ Galaxy block_id: {}\n\ Final output:\n{}\n\n{}", completed_command.command, completed_command.block_id, tail_chars( &completed_command.grid_contents, MAX_RUNNING_COMMAND_OUTPUT_CHARS ), prompt, )); } AIAgentInput::AutoCodeDiffQuery { query, .. } => { hidden_context.push(format!( "Galaxy system request: create a code diff.\n{query}" )); } AIAgentInput::ResumeConversation { .. } => { hidden_context.push( "Galaxy system request: resume the current conversation and continue the task." .to_owned(), ); } AIAgentInput::InitProjectRules { .. } => { hidden_context.push( "Galaxy system request: initialize appropriate project rules for this workspace." .to_owned(), ); } AIAgentInput::CreateEnvironment { repo_paths, .. } => { hidden_context.push(format!( "Galaxy system request: create a development environment for these repositories:\n{}", repo_paths.join("\n") )); } AIAgentInput::TriggerPassiveSuggestion { attachments, trigger, .. } => { hidden_context.push(format!( "Galaxy background request: generate a concise useful suggestion for this event:\n{trigger:#?}" )); for (index, attachment) in attachments.iter().enumerate() { hidden_context.push(attachment_text( &format!("background attachment {}", index + 1), attachment, )?); } } AIAgentInput::CloneRepository { .. } => { // The display query already contains the requested repository URL. } AIAgentInput::CodeReview { review_comments, .. } => { hidden_context.push(format!( "Galaxy system request: address this code-review batch:\n{review_comments:#?}" )); } AIAgentInput::FetchReviewComments { repo_path, .. } => { hidden_context.push(format!( "Galaxy system request: fetch and address review comments for {repo_path}." )); } AIAgentInput::SummarizeConversation { prompt, .. } => { hidden_context.push(format!( "Galaxy system request: summarize the conversation for future continuation.{}", prompt .as_deref() .map(|prompt| format!("\nAdditional instructions: {prompt}")) .unwrap_or_default() )); } AIAgentInput::InvokeSkill { skill, user_query, .. } => { hidden_context.push(format!( "Galaxy skill instructions for {}:\n{}", skill.name, skill.content )); if let Some(user_query) = user_query { for (name, attachment) in &user_query.referenced_attachments { hidden_context.push(attachment_text(name, attachment)?); } } } AIAgentInput::StartFromAmbientRunPrompt { ambient_run_id, runtime_skill, attachments_dir, .. } => { hidden_context.push(format!( "Galaxy system request: continue ambient run {ambient_run_id}.{}{}", runtime_skill .as_ref() .map(|skill| format!("\nRuntime skill:\n{}", skill.content)) .unwrap_or_default(), attachments_dir .as_deref() .map(|path| format!("\nDownloaded attachment directory: {path}")) .unwrap_or_default(), )); } AIAgentInput::ActionResult { result, .. } => { hidden_context.push(format!( "Galaxy tool result for {}:\n{}", result.id, MarkdownActionResult(&result.result) )); } AIAgentInput::MessagesReceivedFromAgents { messages } => { hidden_context.push(format!( "Messages received from other Galaxy agents:\n{messages:#?}" )); } AIAgentInput::EventsFromAgents { events } => { hidden_context.push(format!( "Events received from other Galaxy agents:\n{events:#?}" )); } AIAgentInput::PassiveSuggestionResult { trigger, suggestion, .. } => { hidden_context.push(format!( "Galaxy passive-suggestion feedback.\nTrigger: {trigger:#?}\nResult: {suggestion:#?}" )); } AIAgentInput::OrchestrationConfigUpdate { plan_id, config, status, } => { hidden_context.push(format!( "Galaxy orchestration configuration changed for plan {plan_id}.\n\ Status: {status:#?}\nConfiguration: {config:#?}" )); } } Ok(()) } fn running_command_tool_guidance(terminal_tools: GalaxyTerminalTools) -> &'static str { match (terminal_tools.status, terminal_tools.interrupt) { (true, true) => { "Use `galaxy_terminal_status` to inspect this exact pane. For a deadline, call \ `galaxy_terminal_interrupt_at` once with this exact block_id and the target \ `running_for_ms`; Galaxy performs the wait outside the model loop and refuses to \ interrupt a replacement block. Use `galaxy_terminal_interrupt` only when an \ immediate stop is requested." } (true, false) => { "The read-only `galaxy_terminal_status` tool can inspect this exact pane, but no \ Galaxy terminal mutation tool is available under the active execution profile. Do \ not claim that you can stop this command or enforce a deadline." } (false, false) => { "Galaxy terminal control tools are unavailable for this turn. Do not claim that you \ can monitor, stop, or enforce a deadline on this existing command." } (false, true) => { "Galaxy exposed an inconsistent terminal tool configuration. Do not attempt to \ monitor or interrupt this existing command." } } } fn tail_chars(text: &str, max_chars: usize) -> &str { let Some((start, _)) = text.char_indices().rev().nth(max_chars.saturating_sub(1)) else { return text; }; &text[start..] } fn attachment_text(name: &str, attachment: &AIAgentAttachment) -> Result { let content = match attachment { AIAgentAttachment::PlainText(text) => text.clone(), AIAgentAttachment::DocumentContent { content, .. } => content.clone(), AIAgentAttachment::DiffHunk { diff_content, .. } => diff_content.clone(), AIAgentAttachment::FilePathReference { file_path, .. } => { format!("Local file reference: {file_path}") } AIAgentAttachment::DriveObject { .. } | AIAgentAttachment::DiffSet { .. } | AIAgentAttachment::Block(_) => serialize_context(attachment)?, }; Ok(format!("Attachment {name}:\n{content}")) } fn serialize_context(value: &impl serde::Serialize) -> Result { serde_json::to_string_pretty(value) .map_err(|error| format!("failed to serialize ACP prompt context: {error}")) } #[cfg(test)] #[path = "prompt_tests.rs"] mod tests;