diff --git a/crates/goose-cli/src/commands/schedule.rs b/crates/goose-cli/src/commands/schedule.rs index dda6583e487a..d67ffcc6bcc2 100644 --- a/crates/goose-cli/src/commands/schedule.rs +++ b/crates/goose-cli/src/commands/schedule.rs @@ -94,7 +94,7 @@ pub async fn handle_schedule_add( .await .context("Failed to initialize scheduler")?; - match scheduler.add_scheduled_job(job).await { + match scheduler.add_scheduled_job(job, true).await { Ok(_) => { // The scheduler has copied the recipe to its internal directory. // We can reconstruct the likely path for display if needed, or adjust success message. @@ -175,7 +175,7 @@ pub async fn handle_schedule_remove(schedule_id: String) -> Result<()> { .await .context("Failed to initialize scheduler")?; - match scheduler.remove_scheduled_job(&schedule_id).await { + match scheduler.remove_scheduled_job(&schedule_id, true).await { Ok(_) => { println!( "Scheduled job '{}' and its associated recipe removed.", diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 70e9c8121adb..c3fb5cede007 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -30,7 +30,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_TRIGGER}; +use goose::agents::{Agent, SessionConfig, MANUAL_COMPACT_TRIGGERS}; use goose::config::{Config, GooseMode}; use goose::providers::pricing::initialize_pricing_cache; use goose::session::SessionManager; @@ -703,7 +703,7 @@ impl CliSession { }; if should_summarize { - self.push_message(Message::user().with_text(MANUAL_COMPACT_TRIGGER)); + self.push_message(Message::user().with_text(MANUAL_COMPACT_TRIGGERS[0])); output::show_thinking(); self.process_agent_response(true, CancellationToken::default()) .await?; diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index a75841f9cffe..48530f100e4c 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -23,6 +23,7 @@ use goose::conversation::message::{ ToolConfirmationRequest, ToolRequest, ToolResponse, }; +use crate::routes::recipe_utils::RecipeManifest; use crate::routes::reply::MessageEvent; use utoipa::openapi::schema::{ AdditionalProperties, AnyOfBuilder, ArrayBuilder, ObjectBuilder, OneOfBuilder, Schema, @@ -340,6 +341,7 @@ derive_utoipa!(Icon as IconSchema); super::routes::config_management::read_all_config, super::routes::config_management::providers, super::routes::config_management::get_provider_models, + super::routes::config_management::get_slash_commands, super::routes::config_management::upsert_permissions, super::routes::config_management::create_custom_provider, super::routes::config_management::get_custom_provider, @@ -381,6 +383,8 @@ derive_utoipa!(Icon as IconSchema); super::routes::recipe::scan_recipe, super::routes::recipe::list_recipes, super::routes::recipe::delete_recipe, + super::routes::recipe::schedule_recipe, + super::routes::recipe::set_recipe_slash_command, super::routes::recipe::save_recipe, super::routes::recipe::parse_recipe, super::routes::setup::start_openrouter_setup, @@ -392,6 +396,9 @@ derive_utoipa!(Icon as IconSchema); super::routes::config_management::ConfigResponse, super::routes::config_management::ProvidersResponse, super::routes::config_management::ProviderDetails, + super::routes::config_management::SlashCommandsResponse, + super::routes::config_management::SlashCommand, + super::routes::config_management::CommandType, super::routes::config_management::ExtensionResponse, super::routes::config_management::ExtensionQuery, super::routes::config_management::ToolPermission, @@ -441,6 +448,7 @@ derive_utoipa!(Icon as IconSchema); ExtensionConfig, ConfigKey, Envs, + RecipeManifest, ToolSchema, ToolAnnotationsSchema, ToolInfo, @@ -471,8 +479,9 @@ derive_utoipa!(Icon as IconSchema); super::routes::recipe::DecodeRecipeResponse, super::routes::recipe::ScanRecipeRequest, super::routes::recipe::ScanRecipeResponse, - super::routes::recipe::RecipeManifestResponse, super::routes::recipe::ListRecipeResponse, + super::routes::recipe::ScheduleRecipeRequest, + super::routes::recipe::SetSlashCommandRequest, super::routes::recipe::DeleteRecipeRequest, super::routes::recipe::SaveRecipeRequest, super::routes::recipe::SaveRecipeResponse, diff --git a/crates/goose-server/src/routes/config_management.rs b/crates/goose-server/src/routes/config_management.rs index 98f1ef0b4f0b..7130b4647d5d 100644 --- a/crates/goose-server/src/routes/config_management.rs +++ b/crates/goose-server/src/routes/config_management.rs @@ -17,7 +17,7 @@ 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}; +use goose::{agents::ExtensionConfig, config::permission::PermissionLevel, slash_commands}; use http::StatusCode; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -113,6 +113,23 @@ pub enum ConfigValueResponse { MaskedValue(MaskedSecret), } +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub enum CommandType { + Builtin, + Recipe, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct SlashCommand { + pub command: String, + pub help: String, + pub command_type: CommandType, +} +#[derive(Serialize, ToSchema)] +pub struct SlashCommandsResponse { + pub commands: Vec, +} + #[utoipa::path( post, path = "/config/upsert", @@ -390,6 +407,30 @@ pub async fn get_provider_models( } } +#[utoipa::path( + get, + path = "/config/slash_commands", + responses( + (status = 200, description = "Slash commands retrieved successfully", body = SlashCommandsResponse) + ) +)] +pub async fn get_slash_commands() -> Result, StatusCode> { + let mut commands: Vec<_> = slash_commands::list_commands() + .iter() + .map(|command| SlashCommand { + command: command.command.clone(), + help: command.recipe_path.clone(), + 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, + }); + Ok(Json(SlashCommandsResponse { commands })) +} + #[derive(Serialize, ToSchema)] pub struct PricingData { pub provider: String, @@ -408,8 +449,7 @@ pub struct PricingResponse { #[derive(Deserialize, ToSchema)] pub struct PricingQuery { - /// If true, only return pricing for configured providers. If false, return all. - pub configured_only: Option, + pub configured_only: bool, } #[utoipa::path( @@ -423,7 +463,7 @@ pub struct PricingQuery { pub async fn get_pricing( Json(query): Json, ) -> Result, StatusCode> { - let configured_only = query.configured_only.unwrap_or(true); + let configured_only = query.configured_only; // If refresh requested (configured_only = false), refresh the cache if !configured_only { @@ -792,6 +832,7 @@ pub fn routes(state: Arc) -> Router { .route("/config/extensions/{name}", delete(remove_extension)) .route("/config/providers", get(providers)) .route("/config/providers/{name}/models", get(get_provider_models)) + .route("/config/slash_commands", get(get_slash_commands)) .route("/config/pricing", post(get_pricing)) .route("/config/init", post(init_config)) .route("/config/backup", post(backup_config)) diff --git a/crates/goose-server/src/routes/recipe.rs b/crates/goose-server/src/routes/recipe.rs index 1f840e8cf8ae..f45ee72ae37a 100644 --- a/crates/goose-server/src/routes/recipe.rs +++ b/crates/goose-server/src/routes/recipe.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; use std::fs; +use std::path::PathBuf; use std::sync::Arc; use axum::extract::rejection::JsonRejection; @@ -8,8 +9,8 @@ use axum::{extract::State, http::StatusCode, routing::post, Json, Router}; use goose::recipe::local_recipes; use goose::recipe::validate_recipe::validate_recipe_template_from_content; use goose::recipe::Recipe; -use goose::recipe_deeplink; use goose::session::SessionManager; +use goose::{recipe_deeplink, slash_commands}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -39,7 +40,7 @@ fn clean_data_error(err: &axum::extract::rejection::JsonDataError) -> String { use crate::routes::errors::ErrorResponse; use crate::routes::recipe_utils::{ get_all_recipes_manifests, get_recipe_file_path_by_id, short_id_from_path, validate_recipe, - RecipeValidationError, + RecipeManifest, RecipeValidationError, }; use crate::state::AppState; @@ -114,14 +115,6 @@ pub struct ParseRecipeResponse { pub recipe: Recipe, } -#[derive(Debug, Serialize, ToSchema)] -pub struct RecipeManifestResponse { - recipe: Recipe, - #[serde(rename = "lastModified")] - last_modified: String, - id: String, -} - #[derive(Debug, Deserialize, ToSchema)] pub struct DeleteRecipeRequest { id: String, @@ -129,7 +122,19 @@ pub struct DeleteRecipeRequest { #[derive(Debug, Serialize, ToSchema)] pub struct ListRecipeResponse { - recipe_manifest_responses: Vec, + manifests: Vec, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct ScheduleRecipeRequest { + id: String, + cron_schedule: Option, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct SetSlashCommandRequest { + id: String, + slash_command: Option, } #[utoipa::path( @@ -281,26 +286,36 @@ async fn scan_recipe( async fn list_recipes( State(state): State>, ) -> Result, StatusCode> { - let recipe_manifest_with_paths = get_all_recipes_manifests().unwrap_or_default(); - let mut recipe_file_hash_map = HashMap::new(); - let recipe_manifest_responses = recipe_manifest_with_paths + let mut manifests = get_all_recipes_manifests().unwrap_or_default(); + let recipe_file_hash_map: HashMap<_, _> = manifests .iter() - .map(|recipe_manifest_with_path| { - let id = &recipe_manifest_with_path.id; - let file_path = recipe_manifest_with_path.file_path.clone(); - recipe_file_hash_map.insert(id.clone(), file_path); - RecipeManifestResponse { - recipe: recipe_manifest_with_path.recipe.clone(), - id: id.clone(), - last_modified: recipe_manifest_with_path.last_modified.clone(), - } - }) - .collect::>(); + .map(|m| (m.id.clone(), m.file_path.clone())) + .collect(); state.set_recipe_file_hash_map(recipe_file_hash_map).await; - Ok(Json(ListRecipeResponse { - recipe_manifest_responses, - })) + let scheduler = state.scheduler(); + let scheduled_jobs = scheduler.list_scheduled_jobs().await; + let schedule_map: HashMap<_, _> = scheduled_jobs + .into_iter() + .map(|j| (PathBuf::from(j.source), j.cron)) + .collect(); + + let all_commands = slash_commands::list_commands(); + let slash_map: HashMap<_, _> = all_commands + .into_iter() + .map(|sc| (PathBuf::from(sc.recipe_path), sc.command)) + .collect(); + + for manifest in &mut manifests { + if let Some(cron) = schedule_map.get(&manifest.file_path) { + manifest.schedule_cron = Some(cron.clone()); + } + if let Some(command) = slash_map.get(&manifest.file_path) { + manifest.slash_command = Some(command.clone()); + } + } + + Ok(Json(ListRecipeResponse { manifests })) } #[utoipa::path( @@ -331,6 +346,68 @@ async fn delete_recipe( StatusCode::NO_CONTENT } +#[utoipa::path( + post, + path = "/recipes/schedule", + request_body = ScheduleRecipeRequest, + responses( + (status = 200, description = "Recipe scheduled successfully"), + (status = 404, description = "Recipe not found"), + (status = 500, description = "Internal server error") + ), + tag = "Recipe Management" +)] +async fn schedule_recipe( + State(state): State>, + Json(request): Json, +) -> Result { + let file_path = match get_recipe_file_path_by_id(state.as_ref(), &request.id).await { + Ok(path) => path, + Err(err) => return Err(err.status), + }; + + let scheduler = state.scheduler(); + match scheduler + .schedule_recipe(file_path, request.cron_schedule) + .await + { + Ok(_) => Ok(StatusCode::OK), + Err(e) => { + tracing::error!("Failed to schedule recipe: {}", e); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} + +#[utoipa::path( + post, + path = "/recipes/slash-command", + request_body = SetSlashCommandRequest, + responses( + (status = 200, description = "Slash command set successfully"), + (status = 404, description = "Recipe not found"), + (status = 500, description = "Internal server error") + ), + tag = "Recipe Management" +)] +async fn set_recipe_slash_command( + State(state): State>, + Json(request): Json, +) -> Result { + let file_path = match get_recipe_file_path_by_id(state.as_ref(), &request.id).await { + Ok(path) => path, + Err(err) => return Err(err.status), + }; + + match slash_commands::set_recipe_slash_command(file_path, request.slash_command) { + Ok(_) => Ok(StatusCode::OK), + Err(e) => { + tracing::error!("Failed to set slash command: {}", e); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} + #[utoipa::path( post, path = "/recipes/save", @@ -447,6 +524,8 @@ pub fn routes(state: Arc) -> Router { .route("/recipes/scan", post(scan_recipe)) .route("/recipes/list", get(list_recipes)) .route("/recipes/delete", post(delete_recipe)) + .route("/recipes/schedule", post(schedule_recipe)) + .route("/recipes/slash-command", post(set_recipe_slash_command)) .route("/recipes/save", post(save_recipe)) .route("/recipes/parse", post(parse_recipe)) .with_state(state) diff --git a/crates/goose-server/src/routes/recipe_utils.rs b/crates/goose-server/src/routes/recipe_utils.rs index 13e088802e51..c1c7f45ca61f 100644 --- a/crates/goose-server/src/routes/recipe_utils.rs +++ b/crates/goose-server/src/routes/recipe_utils.rs @@ -5,30 +5,35 @@ use std::hash::{Hash, Hasher}; use std::path::PathBuf; use std::sync::Arc; -use anyhow::Result; -use axum::http::StatusCode; - use crate::routes::errors::ErrorResponse; use crate::state::AppState; +use anyhow::Result; +use axum::http::StatusCode; use goose::agents::Agent; use goose::prompt_template::render_global_file; use goose::recipe::build_recipe::{build_recipe_from_template, RecipeError}; use goose::recipe::local_recipes::{get_recipe_library_dir, list_local_recipes}; use goose::recipe::validate_recipe::validate_recipe_template_from_content; use goose::recipe::Recipe; +use serde::Serialize; use serde_json::Value; use tracing::error; +use utoipa::ToSchema; pub struct RecipeValidationError { pub status: StatusCode, pub message: String, } -pub struct RecipeManifestWithPath { +#[derive(Debug, Serialize, ToSchema)] +pub struct RecipeManifest { pub id: String, pub recipe: Recipe, + #[schema(value_type = String)] pub file_path: PathBuf, pub last_modified: String, + pub schedule_cron: Option, + pub slash_command: Option, } pub fn short_id_from_path(path: &str) -> String { @@ -38,7 +43,7 @@ pub fn short_id_from_path(path: &str) -> String { format!("{:016x}", h) } -pub fn get_all_recipes_manifests() -> Result> { +pub fn get_all_recipes_manifests() -> Result> { let recipes_with_path = list_local_recipes()?; let mut recipe_manifests_with_path = Vec::new(); for (file_path, recipe) in recipes_with_path { @@ -48,11 +53,13 @@ pub fn get_all_recipes_manifests() -> Result> { continue; }; - let manifest_with_path = RecipeManifestWithPath { + let manifest_with_path = RecipeManifest { id: short_id_from_path(file_path.to_string_lossy().as_ref()), recipe, file_path, last_modified, + schedule_cron: None, + slash_command: None, }; recipe_manifests_with_path.push(manifest_with_path); } diff --git a/crates/goose-server/src/routes/schedule.rs b/crates/goose-server/src/routes/schedule.rs index 843be4d3a422..8c7b9bc5f398 100644 --- a/crates/goose-server/src/routes/schedule.rs +++ b/crates/goose-server/src/routes/schedule.rs @@ -88,10 +88,7 @@ async fn create_schedule( State(state): State>, Json(req): Json, ) -> Result, StatusCode> { - let scheduler = state - .scheduler() - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let scheduler = state.scheduler(); tracing::info!( "Server: Calling scheduler.add_scheduled_job() for job '{}'", @@ -108,7 +105,7 @@ async fn create_schedule( process_start_time: None, }; scheduler - .add_scheduled_job(job.clone()) + .add_scheduled_job(job.clone(), true) .await .map_err(|e| { eprintln!("Error creating schedule: {:?}", e); // Log error @@ -136,10 +133,7 @@ async fn create_schedule( async fn list_schedules( State(state): State>, ) -> Result, StatusCode> { - let scheduler = state - .scheduler() - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let scheduler = state.scheduler(); tracing::info!("Server: Calling scheduler.list_scheduled_jobs()"); let jobs = scheduler.list_scheduled_jobs().await; @@ -164,17 +158,17 @@ async fn delete_schedule( State(state): State>, Path(id): Path, ) -> Result { - let scheduler = state - .scheduler() + let scheduler = state.scheduler(); + scheduler + .remove_scheduled_job(&id, true) .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - scheduler.remove_scheduled_job(&id).await.map_err(|e| { - eprintln!("Error deleting schedule '{}': {:?}", id, e); - match e { - goose::scheduler::SchedulerError::JobNotFound(_) => StatusCode::NOT_FOUND, - _ => StatusCode::INTERNAL_SERVER_ERROR, - } - })?; + .map_err(|e| { + eprintln!("Error deleting schedule '{}': {:?}", id, e); + match e { + goose::scheduler::SchedulerError::JobNotFound(_) => StatusCode::NOT_FOUND, + _ => StatusCode::INTERNAL_SERVER_ERROR, + } + })?; Ok(StatusCode::NO_CONTENT) } @@ -196,10 +190,7 @@ async fn run_now_handler( State(state): State>, Path(id): Path, ) -> Result, StatusCode> { - let scheduler = state - .scheduler() - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let scheduler = state.scheduler(); let (recipe_display_name, recipe_version_opt) = if let Some(job) = scheduler .list_scheduled_jobs() @@ -291,10 +282,7 @@ async fn sessions_handler( Path(schedule_id_param): Path, // Renamed to avoid confusion with session_id Query(query_params): Query, ) -> Result>, StatusCode> { - let scheduler = state - .scheduler() - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let scheduler = state.scheduler(); match scheduler .sessions(&schedule_id_param, query_params.limit) @@ -349,10 +337,7 @@ async fn pause_schedule( State(state): State>, Path(id): Path, ) -> Result { - let scheduler = state - .scheduler() - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let scheduler = state.scheduler(); scheduler.pause_schedule(&id).await.map_err(|e| { eprintln!("Error pausing schedule '{}': {:?}", id, e); @@ -383,10 +368,7 @@ async fn unpause_schedule( State(state): State>, Path(id): Path, ) -> Result { - let scheduler = state - .scheduler() - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let scheduler = state.scheduler(); scheduler.unpause_schedule(&id).await.map_err(|e| { eprintln!("Error unpausing schedule '{}': {:?}", id, e); @@ -419,10 +401,7 @@ async fn update_schedule( Path(id): Path, Json(req): Json, ) -> Result, StatusCode> { - let scheduler = state - .scheduler() - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let scheduler = state.scheduler(); scheduler .update_schedule(&id, req.cron) @@ -459,10 +438,7 @@ pub async fn kill_running_job( State(state): State>, Path(id): Path, ) -> Result, StatusCode> { - let scheduler = state - .scheduler() - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let scheduler = state.scheduler(); scheduler.kill_running_job(&id).await.map_err(|e| { eprintln!("Error killing running job '{}': {:?}", id, e); @@ -496,10 +472,7 @@ pub async fn inspect_running_job( State(state): State>, Path(id): Path, ) -> Result, StatusCode> { - let scheduler = state - .scheduler() - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let scheduler = state.scheduler(); match scheduler.get_running_job_info(&id).await { Ok(info) => { diff --git a/crates/goose-server/src/state.rs b/crates/goose-server/src/state.rs index 274da82939b3..2a976dcb3d2f 100644 --- a/crates/goose-server/src/state.rs +++ b/crates/goose-server/src/state.rs @@ -26,8 +26,8 @@ impl AppState { })) } - pub async fn scheduler(&self) -> Result, anyhow::Error> { - self.agent_manager.scheduler().await + pub fn scheduler(&self) -> Arc { + self.agent_manager.scheduler() } pub async fn set_recipe_file_hash_map(&self, hash_map: HashMap) { diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index ed28ea8f1463..a094a3c07cca 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -59,14 +59,15 @@ use super::final_output_tool::FinalOutputTool; use super::platform_tools; use super::tool_execution::{ToolCallResult, CHAT_MODE_TOOL_SKIPPED_RESPONSE, DECLINED_RESPONSE}; use crate::agents::subagent_task_config::TaskConfig; -use crate::conversation::message::{Message, MessageContent, SystemNotificationType, ToolRequest}; +use crate::conversation::message::{Message, SystemNotificationType, ToolRequest}; use crate::scheduler_trait::SchedulerTrait; use crate::session::extension_data::{EnabledExtensionsState, ExtensionState}; use crate::session::{Session, SessionManager}; const DEFAULT_MAX_TURNS: u32 = 1000; const COMPACTION_THINKING_TEXT: &str = "goose is compacting the conversation..."; -pub const MANUAL_COMPACT_TRIGGER: &str = "Please compact this conversation"; +pub const MANUAL_COMPACT_TRIGGERS: &[&str] = + &["Please compact this conversation", "/compact", "/summarize"]; /// Context needed for the reply function pub struct ReplyContext { @@ -776,15 +777,35 @@ impl Agent { session_config: SessionConfig, cancel_token: Option, ) -> Result>> { - let is_manual_compact = user_message.content.iter().any(|c| { - if let MessageContent::Text(text) = c { - text.text.trim() == MANUAL_COMPACT_TRIGGER - } else { - false - } - }); + 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('/') { + let command = message_text.split_whitespace().next(); + command.and_then(crate::slash_commands::resolve_slash_command) + } 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?; + } - SessionManager::add_message(&session_config.id, &user_message).await?; let session = SessionManager::get_session(&session_config.id, true).await?; let conversation = session diff --git a/crates/goose/src/agents/mod.rs b/crates/goose/src/agents/mod.rs index 0a17c4b853ac..7bdaa18afa48 100644 --- a/crates/goose/src/agents/mod.rs +++ b/crates/goose/src/agents/mod.rs @@ -26,7 +26,7 @@ mod tool_route_manager; mod tool_router_index_manager; pub mod types; -pub use agent::{Agent, AgentEvent, MANUAL_COMPACT_TRIGGER}; +pub use agent::{Agent, AgentEvent, MANUAL_COMPACT_TRIGGERS}; pub use extension::ExtensionConfig; pub use extension_manager::ExtensionManager; pub use prompt_manager::PromptManager; diff --git a/crates/goose/src/agents/schedule_tool.rs b/crates/goose/src/agents/schedule_tool.rs index 45fd7866787a..fe23265bf6c8 100644 --- a/crates/goose/src/agents/schedule_tool.rs +++ b/crates/goose/src/agents/schedule_tool.rs @@ -164,7 +164,7 @@ impl Agent { process_start_time: None, }; - match scheduler.add_scheduled_job(job).await { + match scheduler.add_scheduled_job(job, true).await { Ok(()) => Ok(vec![Content::text(format!( "Successfully created scheduled job '{}' for recipe '{}' with cron expression '{}' in {} mode", job_id, recipe_path, cron_expression, execution_mode @@ -284,7 +284,7 @@ impl Agent { ) })?; - match scheduler.remove_scheduled_job(job_id).await { + match scheduler.remove_scheduled_job(job_id, true).await { Ok(()) => Ok(vec![Content::text(format!( "Successfully deleted job '{}'", job_id diff --git a/crates/goose/src/execution/manager.rs b/crates/goose/src/execution/manager.rs index c222be2d42da..66511a3f39c3 100644 --- a/crates/goose/src/execution/manager.rs +++ b/crates/goose/src/execution/manager.rs @@ -59,8 +59,8 @@ impl AgentManager { .cloned() } - pub async fn scheduler(&self) -> Result> { - Ok(Arc::clone(&self.scheduler)) + pub fn scheduler(&self) -> Arc { + Arc::clone(&self.scheduler) } pub async fn set_default_provider(&self, provider: Arc) { diff --git a/crates/goose/src/lib.rs b/crates/goose/src/lib.rs index 17ffb2b00c68..c2fb2b76e8e7 100644 --- a/crates/goose/src/lib.rs +++ b/crates/goose/src/lib.rs @@ -18,6 +18,7 @@ pub mod scheduler_trait; pub mod security; pub mod session; pub mod session_context; +pub mod slash_commands; pub mod subprocess; pub mod token_counter; pub mod tool_inspection; diff --git a/crates/goose/src/recipe/local_recipes.rs b/crates/goose/src/recipe/local_recipes.rs index 9413d268f712..67b8673efd39 100644 --- a/crates/goose/src/recipe/local_recipes.rs +++ b/crates/goose/src/recipe/local_recipes.rs @@ -15,7 +15,7 @@ pub fn get_recipe_library_dir(is_global: bool) -> PathBuf { if is_global { Paths::config_dir().join("recipes") } else { - std::env::current_dir().unwrap().join(".goose/recipes") + env::current_dir().unwrap().join(".goose/recipes") } } diff --git a/crates/goose/src/scheduler.rs b/crates/goose/src/scheduler.rs index 045922074a84..cc2a29724868 100644 --- a/crates/goose/src/scheduler.rs +++ b/crates/goose/src/scheduler.rs @@ -269,6 +269,7 @@ impl Scheduler { pub async fn add_scheduled_job( &self, original_job_spec: ScheduledJob, + make_copy: bool, ) -> Result<(), SchedulerError> { { let jobs_guard = self.jobs.lock().await; @@ -277,29 +278,30 @@ impl Scheduler { } } - let original_recipe_path = Path::new(&original_job_spec.source); - if !original_recipe_path.is_file() { - return Err(SchedulerError::RecipeLoadError(format!( - "Recipe file not found: {}", - original_job_spec.source - ))); - } - - let scheduled_recipes_dir = get_default_scheduled_recipes_dir()?; - let original_extension = original_recipe_path - .extension() - .and_then(|ext| ext.to_str()) - .unwrap_or("yaml"); + let mut stored_job = original_job_spec; + if make_copy { + let original_recipe_path = Path::new(&stored_job.source); + if !original_recipe_path.is_file() { + return Err(SchedulerError::RecipeLoadError(format!( + "Recipe file not found: {}", + stored_job.source + ))); + } - let destination_filename = format!("{}.{}", original_job_spec.id, original_extension); - let destination_recipe_path = scheduled_recipes_dir.join(destination_filename); + let scheduled_recipes_dir = get_default_scheduled_recipes_dir()?; + let original_extension = original_recipe_path + .extension() + .and_then(|ext| ext.to_str()) + .unwrap_or("yaml"); - fs::copy(original_recipe_path, &destination_recipe_path)?; + let destination_filename = format!("{}.{}", stored_job.id, original_extension); + let destination_recipe_path = scheduled_recipes_dir.join(destination_filename); - let mut stored_job = original_job_spec; - stored_job.source = destination_recipe_path.to_string_lossy().into_owned(); - stored_job.current_session_id = None; - stored_job.process_start_time = None; + fs::copy(original_recipe_path, &destination_recipe_path)?; + stored_job.source = destination_recipe_path.to_string_lossy().into_owned(); + stored_job.current_session_id = None; + stored_job.process_start_time = None; + } let cron_task = self.create_cron_task(stored_job.clone())?; @@ -318,6 +320,69 @@ impl Scheduler { Ok(()) } + pub async fn schedule_recipe( + &self, + recipe_path: PathBuf, + cron_schedule: Option, + ) -> Result<(), SchedulerError> { + let recipe_path_str = recipe_path.to_string_lossy().to_string(); + + let existing_job_id = { + let jobs_guard = self.jobs.lock().await; + jobs_guard + .iter() + .find(|(_, (_, job))| job.source == recipe_path_str) + .map(|(id, _)| id.clone()) + }; + + match cron_schedule { + Some(cron) => { + if let Some(job_id) = existing_job_id { + self.update_schedule(&job_id, cron).await + } else { + let job_id = self.generate_unique_job_id(&recipe_path).await; + let job = ScheduledJob { + id: job_id, + source: recipe_path_str, + cron, + last_run: None, + currently_running: false, + paused: false, + current_session_id: None, + process_start_time: None, + }; + self.add_scheduled_job(job, false).await + } + } + None => { + if let Some(job_id) = existing_job_id { + self.remove_scheduled_job(&job_id, false).await + } else { + Ok(()) + } + } + } + } + + async fn generate_unique_job_id(&self, path: &Path) -> String { + let base_id = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unnamed") + .to_string(); + + let jobs_guard = self.jobs.lock().await; + let mut id = base_id.clone(); + let mut counter = 1; + + while jobs_guard.contains_key(&id) { + id = format!("{}_{}", base_id, counter); + counter += 1; + } + + id + } + async fn load_jobs_from_storage(self: &Arc) { if !self.storage_path.exists() { return; @@ -395,7 +460,11 @@ impl Scheduler { .collect() } - pub async fn remove_scheduled_job(&self, id: &str) -> Result<(), SchedulerError> { + pub async fn remove_scheduled_job( + &self, + id: &str, + remove_recipe: bool, + ) -> Result<(), SchedulerError> { let (job_uuid, recipe_path) = { let mut jobs_guard = self.jobs.lock().await; match jobs_guard.remove(id) { @@ -409,9 +478,11 @@ impl Scheduler { .await .map_err(|e| SchedulerError::SchedulerInternalError(e.to_string()))?; - let path = Path::new(&recipe_path); - if path.exists() { - fs::remove_file(path)?; + if remove_recipe { + let path = Path::new(&recipe_path); + if path.exists() { + fs::remove_file(path)?; + } } persist_jobs(&self.storage_path, &self.jobs).await?; @@ -733,16 +804,32 @@ async fn execute_job( #[async_trait] impl SchedulerTrait for Scheduler { - async fn add_scheduled_job(&self, job: ScheduledJob) -> Result<(), SchedulerError> { - self.add_scheduled_job(job).await + async fn add_scheduled_job( + &self, + job: ScheduledJob, + make_copy: bool, + ) -> Result<(), SchedulerError> { + self.add_scheduled_job(job, make_copy).await + } + + async fn schedule_recipe( + &self, + recipe_path: PathBuf, + cron_schedule: Option, + ) -> Result<(), SchedulerError> { + self.schedule_recipe(recipe_path, cron_schedule).await } async fn list_scheduled_jobs(&self) -> Vec { self.list_scheduled_jobs().await } - async fn remove_scheduled_job(&self, id: &str) -> Result<(), SchedulerError> { - self.remove_scheduled_job(id).await + async fn remove_scheduled_job( + &self, + id: &str, + remove_recipe: bool, + ) -> Result<(), SchedulerError> { + self.remove_scheduled_job(id, remove_recipe).await } async fn pause_schedule(&self, id: &str) -> Result<(), SchedulerError> { @@ -815,7 +902,7 @@ mod tests { process_start_time: None, }; - scheduler.add_scheduled_job(job).await.unwrap(); + scheduler.add_scheduled_job(job, true).await.unwrap(); sleep(Duration::from_millis(1500)).await; let jobs = scheduler.list_scheduled_jobs().await; @@ -840,7 +927,7 @@ mod tests { process_start_time: None, }; - scheduler.add_scheduled_job(job).await.unwrap(); + scheduler.add_scheduled_job(job, true).await.unwrap(); scheduler.pause_schedule("paused_job").await.unwrap(); sleep(Duration::from_millis(1500)).await; diff --git a/crates/goose/src/scheduler_trait.rs b/crates/goose/src/scheduler_trait.rs index 019dcf2c7ea0..8122cab7f28f 100644 --- a/crates/goose/src/scheduler_trait.rs +++ b/crates/goose/src/scheduler_trait.rs @@ -1,14 +1,28 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; +use std::path::PathBuf; use crate::scheduler::{ScheduledJob, SchedulerError}; use crate::session::Session; #[async_trait] pub trait SchedulerTrait: Send + Sync { - async fn add_scheduled_job(&self, job: ScheduledJob) -> Result<(), SchedulerError>; + async fn add_scheduled_job( + &self, + job: ScheduledJob, + copy_recipe: bool, + ) -> Result<(), SchedulerError>; + async fn schedule_recipe( + &self, + recipe_path: PathBuf, + cron_schedule: Option, + ) -> anyhow::Result<(), SchedulerError>; async fn list_scheduled_jobs(&self) -> Vec; - async fn remove_scheduled_job(&self, id: &str) -> Result<(), SchedulerError>; + async fn remove_scheduled_job( + &self, + id: &str, + remove_recipe: bool, + ) -> Result<(), SchedulerError>; async fn pause_schedule(&self, id: &str) -> Result<(), SchedulerError>; async fn unpause_schedule(&self, id: &str) -> Result<(), SchedulerError>; async fn run_now(&self, id: &str) -> Result; diff --git a/crates/goose/src/slash_commands.rs b/crates/goose/src/slash_commands.rs new file mode 100644 index 000000000000..5e7065db0016 --- /dev/null +++ b/crates/goose/src/slash_commands.rs @@ -0,0 +1,74 @@ +use std::path::PathBuf; + +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use tracing::warn; + +use crate::config::Config; +use crate::recipe::Recipe; + +const SLASH_COMMANDS_CONFIG_KEY: &str = "slash_commands"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SlashCommandMapping { + pub command: String, + pub recipe_path: String, +} + +pub fn list_commands() -> Vec { + Config::global() + .get_param(SLASH_COMMANDS_CONFIG_KEY) + .unwrap_or_else(|err| { + warn!( + "Failed to load {}: {}. Falling back to empty list.", + SLASH_COMMANDS_CONFIG_KEY, err + ); + Vec::new() + }) +} + +fn save_slash_commands(commands: Vec) -> Result<()> { + Config::global() + .set_param(SLASH_COMMANDS_CONFIG_KEY, &commands) + .map_err(|e| anyhow::anyhow!("Failed to save slash commands: {}", e)) +} + +pub fn set_recipe_slash_command(recipe_path: PathBuf, command: Option) -> Result<()> { + let recipe_path_str = recipe_path.to_string_lossy().to_string(); + + let mut commands = list_commands(); + commands.retain(|mapping| mapping.recipe_path != recipe_path_str); + + if let Some(cmd) = command { + let normalized_cmd = cmd.trim_start_matches('/').to_lowercase(); + if !normalized_cmd.is_empty() { + commands.push(SlashCommandMapping { + command: normalized_cmd, + recipe_path: recipe_path_str, + }); + } + } + + save_slash_commands(commands) +} + +pub fn get_recipe_for_command(command: &str) -> Option { + let normalized = command.trim_start_matches('/').to_lowercase(); + let commands = list_commands(); + commands + .into_iter() + .find(|mapping| mapping.command == normalized) + .map(|mapping| PathBuf::from(mapping.recipe_path)) +} + +pub fn resolve_slash_command(command: &str) -> Option { + let recipe_path = get_recipe_for_command(command)?; + + if !recipe_path.exists() { + return None; + } + let recipe_content = std::fs::read_to_string(&recipe_path).ok()?; + let recipe = Recipe::from_content(&recipe_content).ok()?; + + Some(recipe) +} diff --git a/crates/goose/tests/agent.rs b/crates/goose/tests/agent.rs index 5a4389a4dea6..73b9eb92cdda 100644 --- a/crates/goose/tests/agent.rs +++ b/crates/goose/tests/agent.rs @@ -18,6 +18,7 @@ mod tests { use goose::scheduler::{ScheduledJob, SchedulerError}; use goose::scheduler_trait::SchedulerTrait; use goose::session::Session; + use std::path::PathBuf; use std::sync::Arc; struct MockScheduler { @@ -34,18 +35,34 @@ mod tests { #[async_trait] impl SchedulerTrait for MockScheduler { - async fn add_scheduled_job(&self, job: ScheduledJob) -> Result<(), SchedulerError> { + async fn add_scheduled_job( + &self, + job: ScheduledJob, + _copy: bool, + ) -> Result<(), SchedulerError> { let mut jobs = self.jobs.lock().await; jobs.push(job); Ok(()) } + async fn schedule_recipe( + &self, + _recipe_path: PathBuf, + _cron_schedule: Option, + ) -> Result<(), SchedulerError> { + Ok(()) + } + async fn list_scheduled_jobs(&self) -> Vec { let jobs = self.jobs.lock().await; jobs.clone() } - async fn remove_scheduled_job(&self, id: &str) -> Result<(), SchedulerError> { + async fn remove_scheduled_job( + &self, + id: &str, + _remove: bool, + ) -> Result<(), SchedulerError> { let mut jobs = self.jobs.lock().await; if let Some(pos) = jobs.iter().position(|job| job.id == id) { jobs.remove(pos); diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 8bc8dd6034f6..9b08fe21f178 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -878,6 +878,26 @@ "responses": {} } }, + "/config/slash_commands": { + "get": { + "tags": [ + "super::routes::config_management" + ], + "operationId": "get_slash_commands", + "responses": { + "200": { + "description": "Slash commands retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SlashCommandsResponse" + } + } + } + } + } + } + }, "/config/upsert": { "post": { "tags": [ @@ -1372,6 +1392,64 @@ } } }, + "/recipes/schedule": { + "post": { + "tags": [ + "Recipe Management" + ], + "operationId": "schedule_recipe", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScheduleRecipeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Recipe scheduled successfully" + }, + "404": { + "description": "Recipe not found" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/recipes/slash-command": { + "post": { + "tags": [ + "Recipe Management" + ], + "operationId": "set_recipe_slash_command", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetSlashCommandRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Slash command set successfully" + }, + "404": { + "description": "Recipe not found" + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/reply": { "post": { "tags": [ @@ -2227,6 +2305,13 @@ } } }, + "CommandType": { + "type": "string", + "enum": [ + "Builtin", + "Recipe" + ] + }, "ConfigKey": { "type": "object", "description": "Configuration key metadata for provider setup", @@ -3077,13 +3162,13 @@ "ListRecipeResponse": { "type": "object", "required": [ - "recipe_manifest_responses" + "manifests" ], "properties": { - "recipe_manifest_responses": { + "manifests": { "type": "array", "items": { - "$ref": "#/components/schemas/RecipeManifestResponse" + "$ref": "#/components/schemas/RecipeManifest" } } } @@ -3897,22 +3982,34 @@ } } }, - "RecipeManifestResponse": { + "RecipeManifest": { "type": "object", "required": [ + "id", "recipe", - "lastModified", - "id" + "file_path", + "last_modified" ], "properties": { + "file_path": { + "type": "string" + }, "id": { "type": "string" }, - "lastModified": { + "last_modified": { "type": "string" }, "recipe": { "$ref": "#/components/schemas/Recipe" + }, + "schedule_cron": { + "type": "string", + "nullable": true + }, + "slash_command": { + "type": "string", + "nullable": true } } }, @@ -4177,6 +4274,21 @@ } } }, + "ScheduleRecipeRequest": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "cron_schedule": { + "type": "string", + "nullable": true + }, + "id": { + "type": "string" + } + } + }, "ScheduledJob": { "type": "object", "required": [ @@ -4447,6 +4559,21 @@ } } }, + "SetSlashCommandRequest": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string" + }, + "slash_command": { + "type": "string", + "nullable": true + } + } + }, "Settings": { "type": "object", "properties": { @@ -4480,6 +4607,39 @@ } } }, + "SlashCommand": { + "type": "object", + "required": [ + "command", + "help", + "command_type" + ], + "properties": { + "command": { + "type": "string" + }, + "command_type": { + "$ref": "#/components/schemas/CommandType" + }, + "help": { + "type": "string" + } + } + }, + "SlashCommandsResponse": { + "type": "object", + "required": [ + "commands" + ], + "properties": { + "commands": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SlashCommand" + } + } + } + }, "StartAgentRequest": { "type": "object", "required": [ diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts index f263e5b5a1c6..3aea76ba0c08 100644 --- a/ui/desktop/src/api/sdk.gen.ts +++ b/ui/desktop/src/api/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, CheckProviderData, ConfirmPermissionData, ConfirmPermissionErrors, ConfirmPermissionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetToolsData, GetToolsErrors, GetToolsResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StatusData, StatusResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateRouterToolSelectorData, UpdateRouterToolSelectorErrors, UpdateRouterToolSelectorResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen'; +import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, CheckProviderData, ConfirmPermissionData, ConfirmPermissionErrors, ConfirmPermissionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StatusData, StatusResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateRouterToolSelectorData, UpdateRouterToolSelectorErrors, UpdateRouterToolSelectorResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen'; export type Options = Options2 & { /** @@ -260,6 +260,13 @@ export const setConfigProvider = (options: }); }; +export const getSlashCommands = (options?: Options) => { + return (options?.client ?? client).get({ + url: '/config/slash_commands', + ...options + }); +}; + export const upsertConfig = (options: Options) => { return (options.client ?? client).post({ url: '/config/upsert', @@ -401,6 +408,28 @@ export const scanRecipe = (options: Option }); }; +export const scheduleRecipe = (options: Options) => { + return (options.client ?? client).post({ + url: '/recipes/schedule', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); +}; + +export const setRecipeSlashCommand = (options: Options) => { + return (options.client ?? client).post({ + url: '/recipes/slash-command', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); +}; + export const reply = (options: Options) => { return (options.client ?? client).sse.post({ url: '/reply', diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 075081d48c20..a298881790f1 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -36,6 +36,8 @@ export type CheckProviderRequest = { provider: string; }; +export type CommandType = 'Builtin' | 'Recipe'; + /** * Configuration key metadata for provider setup */ @@ -322,7 +324,7 @@ export type KillJobResponse = { }; export type ListRecipeResponse = { - recipe_manifest_responses: Array; + manifests: Array; }; export type ListSchedulesResponse = { @@ -564,10 +566,13 @@ export type Recipe = { version?: string; }; -export type RecipeManifestResponse = { +export type RecipeManifest = { + file_path: string; id: string; - lastModified: string; + last_modified: string; recipe: Recipe; + schedule_cron?: string | null; + slash_command?: string | null; }; export type RecipeParameter = { @@ -666,6 +671,11 @@ export type ScanRecipeResponse = { has_security_warnings: boolean; }; +export type ScheduleRecipeRequest = { + cron_schedule?: string | null; + id: string; +}; + export type ScheduledJob = { cron: string; current_session_id?: string | null; @@ -739,6 +749,11 @@ export type SetProviderRequest = { provider: string; }; +export type SetSlashCommandRequest = { + id: string; + slash_command?: string | null; +}; + export type Settings = { goose_model?: string | null; goose_provider?: string | null; @@ -750,6 +765,16 @@ export type SetupResponse = { success: boolean; }; +export type SlashCommand = { + command: string; + command_type: CommandType; + help: string; +}; + +export type SlashCommandsResponse = { + commands: Array; +}; + export type StartAgentRequest = { recipe?: Recipe | null; recipe_deeplink?: string | null; @@ -1604,6 +1629,22 @@ export type SetConfigProviderData = { url: '/config/set_provider'; }; +export type GetSlashCommandsData = { + body?: never; + path?: never; + query?: never; + url: '/config/slash_commands'; +}; + +export type GetSlashCommandsResponses = { + /** + * Slash commands retrieved successfully + */ + 200: SlashCommandsResponse; +}; + +export type GetSlashCommandsResponse = GetSlashCommandsResponses[keyof GetSlashCommandsResponses]; + export type UpsertConfigData = { body: UpsertConfigQuery; path?: never; @@ -1965,6 +2006,56 @@ export type ScanRecipeResponses = { export type ScanRecipeResponse2 = ScanRecipeResponses[keyof ScanRecipeResponses]; +export type ScheduleRecipeData = { + body: ScheduleRecipeRequest; + path?: never; + query?: never; + url: '/recipes/schedule'; +}; + +export type ScheduleRecipeErrors = { + /** + * Recipe not found + */ + 404: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type ScheduleRecipeResponses = { + /** + * Recipe scheduled successfully + */ + 200: unknown; +}; + +export type SetRecipeSlashCommandData = { + body: SetSlashCommandRequest; + path?: never; + query?: never; + url: '/recipes/slash-command'; +}; + +export type SetRecipeSlashCommandErrors = { + /** + * Recipe not found + */ + 404: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type SetRecipeSlashCommandResponses = { + /** + * Slash command set successfully + */ + 200: unknown; +}; + export type ReplyData = { body: ChatRequest; path?: never; diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index 63da447e0ce2..16d7f9087d76 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -18,7 +18,7 @@ import { useModelAndProvider } from './ModelAndProviderContext'; import { useWhisper } from '../hooks/useWhisper'; import { WaveformVisualizer } from './WaveformVisualizer'; import { toastError } from '../toasts'; -import MentionPopover, { FileItemWithMatch } from './MentionPopover'; +import MentionPopover, { DisplayItemWithMatch } from './MentionPopover'; import { useDictationSettings } from '../hooks/useDictationSettings'; import { COST_TRACKING_ENABLED, VOICE_DICTATION_ELEVENLABS_ENABLED } from '../updates'; import { CostTracker } from './bottom_menu/CostTracker'; @@ -215,15 +215,17 @@ export default function ChatInput({ query: string; mentionStart: number; selectedIndex: number; + isSlashCommand: boolean; }>({ isOpen: false, position: { x: 0, y: 0 }, query: '', mentionStart: -1, selectedIndex: 0, + isSlashCommand: false, }); const mentionPopoverRef = useRef<{ - getDisplayFiles: () => FileItemWithMatch[]; + getDisplayFiles: () => DisplayItemWithMatch[]; selectFile: (index: number) => void; }>(null); @@ -563,13 +565,17 @@ export default function ChatInput({ setDisplayValue(val); updateValue(val); setHasUserTyped(true); - checkForMention(val, cursorPosition, evt.target); + checkForMentionOrSlash(val, cursorPosition, evt.target); }; - const checkForMention = (text: string, cursorPosition: number, textArea: HTMLTextAreaElement) => { - // Find the last @ before the cursor + const checkForMentionOrSlash = ( + text: string, + cursorPosition: number, + textArea: HTMLTextAreaElement + ) => { + const isSlashCommand = text.startsWith('/'); const beforeCursor = text.slice(0, cursorPosition); - const lastAtIndex = beforeCursor.lastIndexOf('@'); + const lastAtIndex = isSlashCommand ? 0 : beforeCursor.lastIndexOf('@'); if (lastAtIndex === -1) { // No @ found, close mention popover @@ -597,6 +603,7 @@ export default function ChatInput({ query: afterAt, mentionStart: lastAtIndex, selectedIndex: 0, // Reset selection when query changes + isSlashCommand, // filteredFiles will be populated by the MentionPopover component })); }; @@ -1024,13 +1031,13 @@ export default function ChatInput({ } }; - const handleMentionFileSelect = (filePath: string) => { + const handleMentionItemSelect = (itemText: string) => { // Replace the @ mention with the file path const beforeMention = displayValue.slice(0, mentionPopover.mentionStart); const afterMention = displayValue.slice( mentionPopover.mentionStart + 1 + mentionPopover.query.length ); - const newValue = `${beforeMention}${filePath}${afterMention}`; + const newValue = `${beforeMention}${itemText}${afterMention}`; setDisplayValue(newValue); setValue(newValue); @@ -1040,7 +1047,7 @@ export default function ChatInput({ // Set cursor position after the inserted file path setTimeout(() => { if (textAreaRef.current) { - const newCursorPosition = beforeMention.length + filePath.length; + const newCursorPosition = beforeMention.length + itemText.length; textAreaRef.current.setSelectionRange(newCursorPosition, newCursorPosition); } }, 0); @@ -1453,7 +1460,6 @@ export default function ChatInput({ {/* Directory path */}
-
-
- - setIsScheduleModalOpen(false)} - recipe={getCurrentRecipe()} - onCreateSchedule={(deepLink) => { - // Navigate to schedules view with the deep link in state - setView('schedules', { pendingScheduleDeepLink: deepLink }); - setIsScheduleModalOpen(false); - }} - /> ); } diff --git a/ui/desktop/src/components/recipes/RecipesView.tsx b/ui/desktop/src/components/recipes/RecipesView.tsx index a0183c9c4aeb..eea66b0b4557 100644 --- a/ui/desktop/src/components/recipes/RecipesView.tsx +++ b/ui/desktop/src/components/recipes/RecipesView.tsx @@ -1,6 +1,16 @@ import { useState, useEffect } from 'react'; import { listSavedRecipes, convertToLocaleDateString } from '../../recipe/recipe_management'; -import { FileText, Edit, Trash2, Play, Calendar, AlertCircle, Link } from 'lucide-react'; +import { + FileText, + Edit, + Trash2, + Play, + Calendar, + AlertCircle, + Link, + Clock, + Terminal, +} from 'lucide-react'; import { ScrollArea } from '../ui/scroll-area'; import { Card } from '../ui/card'; import { Button } from '../ui/button'; @@ -8,46 +18,58 @@ import { Skeleton } from '../ui/skeleton'; import { MainPanelLayout } from '../Layout/MainPanelLayout'; import { toastSuccess } from '../../toasts'; import { useEscapeKey } from '../../hooks/useEscapeKey'; -import { deleteRecipe, RecipeManifestResponse, startAgent } from '../../api'; +import { + deleteRecipe, + RecipeManifest, + startAgent, + scheduleRecipe, + setRecipeSlashCommand, +} from '../../api'; import ImportRecipeForm, { ImportRecipeButton } from './ImportRecipeForm'; import CreateEditRecipeModal from './CreateEditRecipeModal'; import { generateDeepLink, Recipe } from '../../recipe'; -import { ScheduleFromRecipeModal } from '../schedule/ScheduleFromRecipeModal'; import { useNavigation } from '../../hooks/useNavigation'; +import { CronPicker } from '../schedule/CronPicker'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../ui/dialog'; +import cronstrue from 'cronstrue'; export default function RecipesView() { const setView = useNavigation(); - const [savedRecipes, setSavedRecipes] = useState([]); + const [savedRecipes, setSavedRecipes] = useState([]); const [loading, setLoading] = useState(true); const [showSkeleton, setShowSkeleton] = useState(true); const [error, setError] = useState(null); - const [selectedRecipe, setSelectedRecipe] = useState(null); + const [selectedRecipe, setSelectedRecipe] = useState(null); const [showEditor, setShowEditor] = useState(false); const [showContent, setShowContent] = useState(false); - // Form dialog states const [showCreateDialog, setShowCreateDialog] = useState(false); const [showImportDialog, setShowImportDialog] = useState(false); - const [showScheduleModal, setShowScheduleModal] = useState(false); - const [selectedRecipeForSchedule, setSelectedRecipeForSchedule] = useState(null); + + const [showScheduleDialog, setShowScheduleDialog] = useState(false); + const [scheduleRecipeManifest, setScheduleRecipeManifest] = useState(null); + const [scheduleCron, setScheduleCron] = useState(''); + + const [showSlashCommandDialog, setShowSlashCommandDialog] = useState(false); + const [slashCommandRecipeManifest, setSlashCommandRecipeManifest] = + useState(null); + const [slashCommand, setSlashCommand] = useState(''); + const [scheduleValid, setScheduleIsValid] = useState(true); useEffect(() => { loadSavedRecipes(); }, []); - // Handle Esc key for editor modal useEscapeKey(showEditor, () => setShowEditor(false)); - // Minimum loading time to prevent skeleton flash useEffect(() => { if (!loading && showSkeleton) { const timer = setTimeout(() => { setShowSkeleton(false); - // Add a small delay before showing content for fade-in effect setTimeout(() => { setShowContent(true); }, 50); - }, 300); // Show skeleton for at least 300ms + }, 300); return () => clearTimeout(timer); } @@ -91,25 +113,15 @@ export default function RecipesView() { } } else { try { - // onLoadRecipe is not working for loading recipes. It looks correct - // but the instructions are not flowing through to the server. - // Needs a fix but commenting out to get prod back up and running. - // - // if (onLoadRecipe) { - // // Use the callback to navigate within the same window - // onLoadRecipe(savedRecipe.recipe); - // } else { - // Fallback to creating a new window (for backwards compatibility) window.electron.createChatWindow( - undefined, // query - undefined, // dir - undefined, // version - undefined, // resumeSessionId - recipe, // recipe config - undefined, // view type, - recipeId // recipe id + undefined, + undefined, + undefined, + undefined, + recipe, + undefined, + recipeId ); - // } } catch (err) { console.error('Failed to load recipe:', err); setError(err instanceof Error ? err.message : 'Failed to load recipe'); @@ -117,8 +129,7 @@ export default function RecipesView() { } }; - const handleDeleteRecipe = async (recipeManifest: RecipeManifestResponse) => { - // TODO: Use Electron's dialog API for confirmation + const handleDeleteRecipe = async (recipeManifest: RecipeManifest) => { const result = await window.electron.showMessageBox({ type: 'warning', buttons: ['Cancel', 'Delete'], @@ -145,7 +156,7 @@ export default function RecipesView() { } }; - const handleEditRecipe = async (recipeManifest: RecipeManifestResponse) => { + const handleEditRecipe = async (recipeManifest: RecipeManifest) => { setSelectedRecipe(recipeManifest); setShowEditor(true); }; @@ -153,13 +164,12 @@ export default function RecipesView() { const handleEditorClose = (wasSaved?: boolean) => { setShowEditor(false); setSelectedRecipe(null); - // Only reload recipes if a recipe was actually saved/updated if (wasSaved) { loadSavedRecipes(); } }; - const handleCopyDeeplink = async (recipeManifest: RecipeManifestResponse) => { + const handleCopyDeeplink = async (recipeManifest: RecipeManifest) => { try { const deeplink = await generateDeepLink(recipeManifest.recipe); await navigator.clipboard.writeText(deeplink); @@ -176,25 +186,132 @@ export default function RecipesView() { } }; - const handleScheduleRecipe = (recipe: Recipe) => { - setSelectedRecipeForSchedule(recipe); - setShowScheduleModal(true); + const handleOpenScheduleDialog = (recipeManifest: RecipeManifest) => { + setScheduleRecipeManifest(recipeManifest); + setScheduleCron(recipeManifest.schedule_cron || '0 0 14 * * *'); + setShowScheduleDialog(true); + }; + + const handleSaveSchedule = async () => { + if (!scheduleRecipeManifest) return; + + try { + await scheduleRecipe({ + body: { + id: scheduleRecipeManifest.id, + cron_schedule: scheduleCron, + }, + }); + + toastSuccess({ + title: 'Schedule saved', + msg: `Recipe will run ${getReadableCron(scheduleCron)}`, + }); + + setShowScheduleDialog(false); + setScheduleRecipeManifest(null); + await loadSavedRecipes(); + } catch (error) { + console.error('Failed to save schedule:', error); + setError(error instanceof Error ? error.message : 'Failed to save schedule'); + } + }; + + const handleRemoveSchedule = async () => { + if (!scheduleRecipeManifest) return; + + try { + await scheduleRecipe({ + body: { + id: scheduleRecipeManifest.id, + cron_schedule: null, + }, + }); + + toastSuccess({ + title: 'Schedule removed', + msg: 'Recipe will no longer run automatically', + }); + + setShowScheduleDialog(false); + setScheduleRecipeManifest(null); + await loadSavedRecipes(); + } catch (error) { + console.error('Failed to remove schedule:', error); + setError(error instanceof Error ? error.message : 'Failed to remove schedule'); + } + }; + + const handleOpenSlashCommandDialog = (recipeManifest: RecipeManifest) => { + setSlashCommandRecipeManifest(recipeManifest); + setSlashCommand(recipeManifest.slash_command || ''); + setShowSlashCommandDialog(true); + }; + + const handleSaveSlashCommand = async () => { + if (!slashCommandRecipeManifest) return; + + try { + await setRecipeSlashCommand({ + body: { + id: slashCommandRecipeManifest.id, + slash_command: slashCommand || null, + }, + }); + + toastSuccess({ + title: 'Slash command saved', + msg: slashCommand ? `Use /${slashCommand} to run this recipe` : 'Slash command removed', + }); + + setShowSlashCommandDialog(false); + setSlashCommandRecipeManifest(null); + await loadSavedRecipes(); + } catch (error) { + console.error('Failed to save slash command:', error); + setError(error instanceof Error ? error.message : 'Failed to save slash command'); + } }; - const handleCreateScheduleFromRecipe = async (deepLink: string) => { - // Navigate to schedules view with the deep link in state - setView('schedules', { pendingScheduleDeepLink: deepLink }); + const handleRemoveSlashCommand = async () => { + if (!slashCommandRecipeManifest) return; + + try { + await setRecipeSlashCommand({ + body: { + id: slashCommandRecipeManifest.id, + slash_command: null, + }, + }); + + toastSuccess({ + title: 'Slash command removed', + msg: 'Recipe slash command has been removed', + }); + + setShowSlashCommandDialog(false); + setSlashCommandRecipeManifest(null); + await loadSavedRecipes(); + } catch (error) { + console.error('Failed to remove slash command:', error); + setError(error instanceof Error ? error.message : 'Failed to remove slash command'); + } + }; - setShowScheduleModal(false); - setSelectedRecipeForSchedule(null); + const getReadableCron = (cron: string): string => { + try { + const cronWithoutSeconds = cron.split(' ').slice(1).join(' '); + return cronstrue.toString(cronWithoutSeconds).toLowerCase(); + } catch { + return cron; + } }; - // Render a recipe item const RecipeItem = ({ recipeManifestResponse, - recipeManifestResponse: { recipe, lastModified }, + recipeManifestResponse: { recipe, last_modified: lastModified, schedule_cron, slash_command }, }: { - recipeManifestResponse: RecipeManifestResponse; + recipeManifestResponse: RecipeManifest; }) => (
@@ -203,12 +320,42 @@ export default function RecipesView() {

{recipe.title}

{recipe.description}

-
- - {convertToLocaleDateString(lastModified)} +
+
+ + {convertToLocaleDateString(lastModified)} +
+ {(schedule_cron || slash_command) && ( +
+ {schedule_cron && ( +
+ + Runs {getReadableCron(schedule_cron)} +
+ )} + {slash_command && ( +
+ /{slash_command} +
+ )} +
+ )}
+ +
+ )} + + +
+ + + + )} + + {showSlashCommandDialog && slashCommandRecipeManifest && ( + + + + Slash Command + +
+
+

+ Set a slash command to quickly run this recipe from any chat +

+
+ / + setSlashCommand(e.target.value)} + placeholder="command-name" + className="flex-1 px-3 py-2 border rounded text-sm" + /> +
+ {slashCommand && ( +

+ Use /{slashCommand} in any chat to run this recipe +

+ )} +
+ +
+ {slashCommandRecipeManifest.slash_command && ( + + )} + + +
+
+
+
)} ); diff --git a/ui/desktop/src/recipe/recipe_management.ts b/ui/desktop/src/recipe/recipe_management.ts index fd0f844c7b1f..8129a119a4df 100644 --- a/ui/desktop/src/recipe/recipe_management.ts +++ b/ui/desktop/src/recipe/recipe_management.ts @@ -1,4 +1,4 @@ -import { Recipe, saveRecipe as saveRecipeApi, listRecipes, RecipeManifestResponse } from '../api'; +import { Recipe, saveRecipe as saveRecipeApi, listRecipes, RecipeManifest } from '../api'; export const saveRecipe = async (recipe: Recipe, recipeId?: string | null): Promise => { try { @@ -19,10 +19,10 @@ export const saveRecipe = async (recipe: Recipe, recipeId?: string | null): Prom } }; -export const listSavedRecipes = async (): Promise => { +export const listSavedRecipes = async (): Promise => { try { const listRecipeResponse = await listRecipes(); - return listRecipeResponse?.data?.recipe_manifest_responses ?? []; + return listRecipeResponse?.data?.manifests ?? []; } catch (error) { console.warn('Failed to list saved recipes:', error); return [];