diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 5c9ea253b1..de2c57e450 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -31,7 +31,7 @@ use anyhow::{Context, Result}; use completion::GooseCompleter; use goose::agents::extension::{Envs, ExtensionConfig, PLATFORM_EXTENSIONS}; use goose::agents::types::RetryConfig; -use goose::agents::{Agent, SessionConfig, MANUAL_COMPACT_TRIGGERS}; +use goose::agents::{Agent, SessionConfig, COMPACT_TRIGGERS}; use goose::config::{Config, GooseMode}; use goose::providers::pricing::initialize_pricing_cache; use goose::session::SessionManager; @@ -704,7 +704,7 @@ impl CliSession { }; if should_summarize { - self.push_message(Message::user().with_text(MANUAL_COMPACT_TRIGGERS[0])); + self.push_message(Message::user().with_text(COMPACT_TRIGGERS[0])); output::show_thinking(); self.process_agent_response(true, CancellationToken::default()) .await?; diff --git a/crates/goose-server/src/routes/config_management.rs b/crates/goose-server/src/routes/config_management.rs index c70a1bb9db..3dc618cecd 100644 --- a/crates/goose-server/src/routes/config_management.rs +++ b/crates/goose-server/src/routes/config_management.rs @@ -18,7 +18,10 @@ use goose::providers::pricing::{ get_all_pricing, get_model_pricing, parse_model_id, refresh_pricing, }; use goose::providers::providers as get_providers; -use goose::{agents::ExtensionConfig, config::permission::PermissionLevel, slash_commands}; +use goose::{ + agents::execute_commands, agents::ExtensionConfig, config::permission::PermissionLevel, + slash_commands, +}; use http::StatusCode; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -437,11 +440,15 @@ pub async fn get_slash_commands() -> Result, StatusC command_type: CommandType::Recipe, }) .collect(); - commands.push(SlashCommand { - command: "compact".to_string(), - help: "Compact the current conversation to save tokens".to_string(), - command_type: CommandType::Builtin, - }); + + for cmd_def in execute_commands::list_commands() { + commands.push(SlashCommand { + command: cmd_def.name.to_string(), + help: cmd_def.description.to_string(), + command_type: CommandType::Builtin, + }); + } + Ok(Json(SlashCommandsResponse { commands })) } diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 9f63d057fb..a2373f290c 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -62,8 +62,6 @@ use tracing::{debug, error, info, instrument, warn}; const DEFAULT_MAX_TURNS: u32 = 1000; const COMPACTION_THINKING_TEXT: &str = "goose is compacting the conversation..."; -pub const MANUAL_COMPACT_TRIGGERS: &[&str] = - &["Please compact this conversation", "/compact", "/summarize"]; /// Context needed for the reply function pub struct ReplyContext { @@ -777,50 +775,70 @@ impl Agent { } let message_text = user_message.as_concat_text(); - let is_manual_compact = MANUAL_COMPACT_TRIGGERS.contains(&message_text.trim()); - let slash_command_recipe = if message_text.trim().starts_with('/') { + // Track custom slash command usage (don't track command name for privacy) + if message_text.trim().starts_with('/') { let command = message_text.split_whitespace().next(); - - // Check if it's a builtin command first - let is_builtin = command - .map(|cmd| MANUAL_COMPACT_TRIGGERS.contains(&cmd)) - .unwrap_or(false); - - if is_builtin { - None - } else { - // Try to resolve as recipe command - let recipe = command.and_then(crate::slash_commands::resolve_slash_command); - - // Track non-builtin slash command usage (don't track command name for privacy) - if recipe.is_some() { + if let Some(cmd) = command { + if crate::slash_commands::get_recipe_for_command(cmd).is_some() { crate::posthog::emit_custom_slash_command_used(); } - - recipe } - } else { - None - }; + } - if let Some(recipe) = slash_command_recipe { - let prompt = [recipe.instructions.as_deref(), recipe.prompt.as_deref()] - .into_iter() - .flatten() - .collect::>() - .join("\n\n"); - let prompt_message = Message::user() - .with_text(prompt) - .with_visibility(false, true); - SessionManager::add_message(&session_config.id, &prompt_message).await?; - SessionManager::add_message( - &session_config.id, - &user_message.with_visibility(true, false), - ) - .await?; - } else { - SessionManager::add_message(&session_config.id, &user_message).await?; + let command_result = self + .execute_command(&message_text, &session_config.id) + .await; + + match command_result { + Some(response) if response.role == rmcp::model::Role::Assistant => { + SessionManager::add_message( + &session_config.id, + &user_message.clone().with_visibility(true, false), + ) + .await?; + SessionManager::add_message( + &session_config.id, + &response.clone().with_visibility(true, false), + ) + .await?; + + // Check if this was a command that modifies conversation history + let modifies_history = crate::agents::execute_commands::COMPACT_TRIGGERS + .contains(&message_text.trim()) + || message_text.trim() == "/clear"; + + return Ok(Box::pin(async_stream::try_stream! { + yield AgentEvent::Message(user_message); + yield AgentEvent::Message(response); + + // After commands that modify history, notify UI that history was replaced + if modifies_history { + let updated_session = SessionManager::get_session(&session_config.id, true) + .await + .map_err(|e| anyhow!("Failed to fetch updated session: {}", e))?; + let updated_conversation = updated_session + .conversation + .ok_or_else(|| anyhow!("Session has no conversation after history modification"))?; + yield AgentEvent::HistoryReplaced(updated_conversation); + } + })); + } + Some(resolved_message) => { + SessionManager::add_message( + &session_config.id, + &user_message.clone().with_visibility(true, false), + ) + .await?; + SessionManager::add_message( + &session_config.id, + &resolved_message.clone().with_visibility(false, true), + ) + .await?; + } + None => { + SessionManager::add_message(&session_config.id, &user_message).await?; + } } let session = SessionManager::get_session(&session_config.id, true).await?; let conversation = session @@ -828,40 +846,37 @@ impl Agent { .clone() .ok_or_else(|| anyhow::anyhow!("Session {} has no conversation", session_config.id))?; - let needs_auto_compact = !is_manual_compact - && check_if_compaction_needed( - self.provider().await?.as_ref(), - &conversation, - None, - &session, - ) - .await?; + let needs_auto_compact = check_if_compaction_needed( + self.provider().await?.as_ref(), + &conversation, + None, + &session, + ) + .await?; let conversation_to_compact = conversation.clone(); Ok(Box::pin(async_stream::try_stream! { - let final_conversation = if !needs_auto_compact && !is_manual_compact { + let final_conversation = if !needs_auto_compact { conversation } else { - if !is_manual_compact { - let config = crate::config::Config::global(); - let threshold = config - .get_param::("GOOSE_AUTO_COMPACT_THRESHOLD") - .unwrap_or(DEFAULT_COMPACTION_THRESHOLD); - let threshold_percentage = (threshold * 100.0) as u32; + let config = Config::global(); + let threshold = config + .get_param::("GOOSE_AUTO_COMPACT_THRESHOLD") + .unwrap_or(DEFAULT_COMPACTION_THRESHOLD); + let threshold_percentage = (threshold * 100.0) as u32; - let inline_msg = format!( - "Exceeded auto-compact threshold of {}%. Performing auto-compaction...", - threshold_percentage - ); + let inline_msg = format!( + "Exceeded auto-compact threshold of {}%. Performing auto-compaction...", + threshold_percentage + ); - yield AgentEvent::Message( - Message::assistant().with_system_notification( - SystemNotificationType::InlineMessage, - inline_msg, - ) - ); - } + yield AgentEvent::Message( + Message::assistant().with_system_notification( + SystemNotificationType::InlineMessage, + inline_msg, + ) + ); yield AgentEvent::Message( Message::assistant().with_system_notification( @@ -870,7 +885,7 @@ impl Agent { ) ); - match compact_messages(self.provider().await?.as_ref(), &conversation_to_compact, is_manual_compact).await { + match compact_messages(self.provider().await?.as_ref(), &conversation_to_compact, false).await { Ok((compacted_conversation, summarization_usage)) => { SessionManager::replace_conversation(&session_config.id, &compacted_conversation).await?; Self::update_session_metrics(&session_config, &summarization_usage, true).await?; @@ -897,11 +912,9 @@ impl Agent { } }; - if !is_manual_compact { - let mut reply_stream = self.reply_internal(final_conversation, session_config, session, cancel_token).await?; - while let Some(event) = reply_stream.next().await { - yield event?; - } + let mut reply_stream = self.reply_internal(final_conversation, session_config, session, cancel_token).await?; + while let Some(event) = reply_stream.next().await { + yield event?; } })) } diff --git a/crates/goose/src/agents/execute_commands.rs b/crates/goose/src/agents/execute_commands.rs new file mode 100644 index 0000000000..7220bba503 --- /dev/null +++ b/crates/goose/src/agents/execute_commands.rs @@ -0,0 +1,315 @@ +use std::collections::HashMap; + +use anyhow::{anyhow, Result}; + +use crate::context_mgmt::compact_messages; +use crate::conversation::message::{Message, SystemNotificationType}; +use crate::recipe::build_recipe::build_recipe_from_template_with_positional_params; +use crate::session::SessionManager; + +use super::Agent; + +pub const COMPACT_TRIGGERS: &[&str] = + &["/compact", "Please compact this conversation", "/summarize"]; + +pub struct CommandDef { + pub name: &'static str, + pub description: &'static str, +} + +static COMMANDS: &[CommandDef] = &[ + CommandDef { + name: "prompts", + description: "List available prompts, optionally filtered by extension", + }, + CommandDef { + name: "prompt", + description: "Execute a prompt or show its info with --info", + }, + CommandDef { + name: "compact", + description: "Compact the conversation history", + }, + CommandDef { + name: "clear", + description: "Clear the conversation history", + }, +]; + +pub fn list_commands() -> &'static [CommandDef] { + COMMANDS +} + +impl Agent { + pub async fn execute_command(&self, message_text: &str, session_id: &str) -> Option { + let mut trimmed = message_text.trim().to_string(); + + if COMPACT_TRIGGERS.contains(&trimmed.as_str()) { + trimmed = COMPACT_TRIGGERS[0].to_string(); + } + + if !trimmed.starts_with('/') { + return None; + } + + let command_str = trimmed.strip_prefix('/').unwrap_or(&trimmed); + let (command, params) = command_str + .split_once(' ') + .map(|(cmd, p)| (cmd, p.trim())) + .unwrap_or((command_str, "")); + + let params: Vec<&str> = if params.is_empty() { + vec![] + } else { + params.split_whitespace().collect() + }; + + let result = match command { + "prompts" => self.handle_prompts_command(¶ms, session_id).await, + "prompt" => self.handle_prompt_command(¶ms, session_id).await, + "compact" => self.handle_compact_command(session_id).await, + "clear" => self.handle_clear_command(session_id).await, + _ => { + self.handle_recipe_command(command, ¶ms, session_id) + .await + } + }; + + match result { + Ok(msg) => msg, + Err(e) => { + Some(Message::assistant().with_text(format!("Error executing /{}: {}", command, e))) + } + } + } + + async fn handle_compact_command(&self, session_id: &str) -> Result> { + let session = SessionManager::get_session(session_id, true).await?; + let conversation = session + .conversation + .ok_or_else(|| anyhow!("Session has no conversation"))?; + + let (compacted_conversation, _usage) = compact_messages( + self.provider().await?.as_ref(), + &conversation, + true, // is_manual_compact + ) + .await?; + + SessionManager::replace_conversation(session_id, &compacted_conversation).await?; + + Ok(Some(Message::assistant().with_system_notification( + SystemNotificationType::InlineMessage, + "Compaction complete", + ))) + } + + async fn handle_clear_command(&self, session_id: &str) -> Result> { + use crate::conversation::Conversation; + + SessionManager::replace_conversation(session_id, &Conversation::default()).await?; + + SessionManager::update_session(session_id) + .total_tokens(Some(0)) + .input_tokens(Some(0)) + .output_tokens(Some(0)) + .apply() + .await?; + + Ok(Some(Message::assistant().with_system_notification( + SystemNotificationType::InlineMessage, + "Conversation cleared", + ))) + } + + async fn handle_prompts_command( + &self, + params: &[&str], + _session_id: &str, + ) -> Result> { + let extension_filter = params.first().map(|s| s.to_string()); + + let prompts = self.list_extension_prompts().await; + + if let Some(filter) = &extension_filter { + if !prompts.contains_key(filter) { + let error_msg = format!("Extension '{}' not found", filter); + return Ok(Some(Message::assistant().with_text(error_msg))); + } + } + + let filtered_prompts: HashMap> = prompts + .into_iter() + .filter(|(ext, _)| extension_filter.as_ref().is_none_or(|f| f == ext)) + .map(|(extension, prompt_list)| { + let names = prompt_list.into_iter().map(|p| p.name).collect(); + (extension, names) + }) + .collect(); + + let mut output = String::new(); + if filtered_prompts.is_empty() { + output.push_str("No prompts available.\n"); + } else { + output.push_str("Available prompts:\n\n"); + for (extension, prompt_names) in filtered_prompts { + output.push_str(&format!("**{}**:\n", extension)); + for name in prompt_names { + output.push_str(&format!(" - {}\n", name)); + } + output.push('\n'); + } + } + + Ok(Some(Message::assistant().with_text(output))) + } + + async fn handle_prompt_command( + &self, + params: &[&str], + session_id: &str, + ) -> Result> { + if params.is_empty() { + return Ok(Some( + Message::assistant().with_text("Prompt name argument is required"), + )); + } + + let prompt_name = params[0].to_string(); + let is_info = params.get(1).map(|s| *s == "--info").unwrap_or(false); + + if is_info { + let prompts = self.list_extension_prompts().await; + let mut prompt_info = None; + + for (extension, prompt_list) in prompts { + if let Some(prompt) = prompt_list.iter().find(|p| p.name == prompt_name) { + let mut output = format!("**Prompt: {}**\n\n", prompt.name); + if let Some(desc) = &prompt.description { + output.push_str(&format!("Description: {}\n\n", desc)); + } + output.push_str(&format!("Extension: {}\n\n", extension)); + + if let Some(args) = &prompt.arguments { + output.push_str("Arguments:\n"); + for arg in args { + output.push_str(&format!(" - {}", arg.name)); + if let Some(desc) = &arg.description { + output.push_str(&format!(": {}", desc)); + } + output.push('\n'); + } + } + + prompt_info = Some(output); + break; + } + } + + return Ok(Some(Message::assistant().with_text( + prompt_info.unwrap_or_else(|| format!("Prompt '{}' not found", prompt_name)), + ))); + } + + let mut arguments = HashMap::new(); + for param in params.iter().skip(1) { + if let Some((key, value)) = param.split_once('=') { + let value = value.trim_matches('"'); + arguments.insert(key.to_string(), value.to_string()); + } + } + + let arguments_value = serde_json::to_value(arguments) + .map_err(|e| anyhow!("Failed to serialize arguments: {}", e))?; + + match self.get_prompt(&prompt_name, arguments_value).await { + Ok(prompt_result) => { + for (i, prompt_message) in prompt_result.messages.into_iter().enumerate() { + let msg = Message::from(prompt_message); + + let expected_role = if i % 2 == 0 { + rmcp::model::Role::User + } else { + rmcp::model::Role::Assistant + }; + + if msg.role != expected_role { + let error_msg = format!( + "Expected {:?} message at position {}, but found {:?}", + expected_role, i, msg.role + ); + return Ok(Some(Message::assistant().with_text(error_msg))); + } + + SessionManager::add_message(session_id, &msg).await?; + } + + let last_message = SessionManager::get_session(session_id, true) + .await? + .conversation + .ok_or_else(|| anyhow!("No conversation found"))? + .messages() + .last() + .cloned() + .ok_or_else(|| anyhow!("No messages in conversation"))?; + + Ok(Some(last_message)) + } + Err(e) => Ok(Some( + Message::assistant().with_text(format!("Error getting prompt: {}", e)), + )), + } + } + + async fn handle_recipe_command( + &self, + command: &str, + params: &[&str], + _session_id: &str, + ) -> Result> { + let full_command = format!("/{}", command); + let recipe_path = match crate::slash_commands::get_recipe_for_command(&full_command) { + Some(path) => path, + None => return Ok(None), + }; + + if !recipe_path.exists() { + return Ok(None); + } + + let recipe_content = std::fs::read_to_string(&recipe_path) + .map_err(|e| anyhow!("Failed to read recipe file: {}", e))?; + + let recipe_dir = recipe_path + .parent() + .ok_or_else(|| anyhow!("Recipe path has no parent directory"))?; + + let param_values: Vec = params.iter().map(|s| s.to_string()).collect(); + + let recipe = match build_recipe_from_template_with_positional_params( + recipe_content, + recipe_dir, + param_values, + None:: Result>, + ) { + Ok(recipe) => recipe, + Err(crate::recipe::build_recipe::RecipeError::MissingParams { parameters }) => { + return Ok(Some(Message::assistant().with_text(format!( + "Recipe requires {} parameter(s): {}. Provided: {}", + parameters.len(), + parameters.join(", "), + params.len() + )))); + } + Err(e) => return Err(anyhow!("Failed to build recipe: {}", e)), + }; + + let prompt = [recipe.instructions.as_deref(), recipe.prompt.as_deref()] + .into_iter() + .flatten() + .collect::>() + .join("\n\n"); + + Ok(Some(Message::user().with_text(prompt))) + } +} diff --git a/crates/goose/src/agents/mod.rs b/crates/goose/src/agents/mod.rs index 62676edee1..ceddd2bd1f 100644 --- a/crates/goose/src/agents/mod.rs +++ b/crates/goose/src/agents/mod.rs @@ -1,6 +1,7 @@ mod agent; pub(crate) mod chatrecall_extension; pub(crate) mod code_execution_extension; +pub mod execute_commands; pub mod extension; pub mod extension_malware_check; pub mod extension_manager; @@ -27,7 +28,8 @@ mod tool_route_manager; mod tool_router_index_manager; pub mod types; -pub use agent::{Agent, AgentEvent, MANUAL_COMPACT_TRIGGERS}; +pub use agent::{Agent, AgentEvent}; +pub use execute_commands::COMPACT_TRIGGERS; pub use extension::ExtensionConfig; pub use extension_manager::ExtensionManager; pub use prompt_manager::PromptManager; diff --git a/crates/goose/src/recipe/build_recipe/mod.rs b/crates/goose/src/recipe/build_recipe/mod.rs index ea4305dd96..c42aeb8136 100644 --- a/crates/goose/src/recipe/build_recipe/mod.rs +++ b/crates/goose/src/recipe/build_recipe/mod.rs @@ -77,6 +77,41 @@ where Ok(recipe) } +pub fn build_recipe_from_template_with_positional_params( + recipe_content: String, + recipe_dir: &Path, + params: Vec, + user_prompt_fn: Option, +) -> Result +where + F: Fn(&str, &str) -> Result, +{ + let recipe_dir_str = recipe_dir.display().to_string(); + + let recipe_parameters = + validate_recipe_template_from_content(&recipe_content, Some(recipe_dir_str.clone())) + .map_err(|source| RecipeError::TemplateRendering { source })? + .parameters; + + let param_pairs: Vec<(String, String)> = if let Some(recipe_params) = &recipe_parameters { + if params.len() < recipe_params.len() { + let param_keys: Vec = recipe_params.iter().map(|p| p.key.clone()).collect(); + return Err(RecipeError::MissingParams { + parameters: param_keys, + }); + } + recipe_params + .iter() + .zip(params.iter()) + .map(|(rp, p)| (rp.key.clone(), p.clone())) + .collect() + } else { + vec![] + }; + + build_recipe_from_template(recipe_content, recipe_dir, param_pairs, user_prompt_fn) +} + pub fn apply_values_to_parameters( user_params: &[(String, String)], recipe_parameters: Option>, diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index ec8d6d974b..fd7e259e9b 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -61,7 +61,7 @@ const TOKEN_LIMIT_DEFAULT = 128000; // fallback for custom models that the backe const TOOLS_MAX_SUGGESTED = 60; // max number of tools before we show a warning // Manual compact trigger message - must match backend constant -const MANUAL_COMPACT_TRIGGER = 'Please compact this conversation'; +const MANUAL_COMPACT_TRIGGER = '/compact'; interface ModelLimit { pattern: string;