More slash commands (#5858)

Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
Douwe Osinga
2025-12-16 19:26:49 -05:00
committed by GitHub
parent 07de82ba9b
commit fd9582a45f
7 changed files with 454 additions and 82 deletions
+2 -2
View File
@@ -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?;
@@ -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<Json<SlashCommandsResponse>, 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 }))
}
+85 -72
View File
@@ -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::<Vec<_>>()
.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::<f64>("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::<f64>("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?;
}
}))
}
+315
View File
@@ -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<Message> {
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(&params, session_id).await,
"prompt" => self.handle_prompt_command(&params, session_id).await,
"compact" => self.handle_compact_command(session_id).await,
"clear" => self.handle_clear_command(session_id).await,
_ => {
self.handle_recipe_command(command, &params, 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<Option<Message>> {
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<Option<Message>> {
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<Option<Message>> {
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<String, Vec<String>> = 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<Option<Message>> {
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<Option<Message>> {
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<String> = 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::<fn(&str, &str) -> Result<String>>,
) {
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::<Vec<_>>()
.join("\n\n");
Ok(Some(Message::user().with_text(prompt)))
}
}
+3 -1
View File
@@ -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;
@@ -77,6 +77,41 @@ where
Ok(recipe)
}
pub fn build_recipe_from_template_with_positional_params<F>(
recipe_content: String,
recipe_dir: &Path,
params: Vec<String>,
user_prompt_fn: Option<F>,
) -> Result<Recipe, RecipeError>
where
F: Fn(&str, &str) -> Result<String, anyhow::Error>,
{
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<String> = 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<F>(
user_params: &[(String, String)],
recipe_parameters: Option<Vec<RecipeParameter>>,
+1 -1
View File
@@ -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;