Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions crates/goose-cli/src/commands/schedule.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.",
Expand Down
4 changes: 2 additions & 2 deletions crates/goose-cli/src/session/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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?;
Expand Down
11 changes: 10 additions & 1 deletion crates/goose-server/src/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -441,6 +448,7 @@ derive_utoipa!(Icon as IconSchema);
ExtensionConfig,
ConfigKey,
Envs,
RecipeManifest,
ToolSchema,
ToolAnnotationsSchema,
ToolInfo,
Expand Down Expand Up @@ -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,
Expand Down
49 changes: 45 additions & 4 deletions crates/goose-server/src/routes/config_management.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<SlashCommand>,
}

#[utoipa::path(
post,
path = "/config/upsert",
Expand Down Expand Up @@ -390,6 +407,30 @@ pub async fn get_provider_models(
}
}

#[utoipa::path(
get,
path = "/config/slash_commands",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like this might be more natural to have its own set of routes vs being under config. I think of config_managment.rs routes as just about changing settings, but with this change slash commands are becoming a feature of their own. Similar to how recipes have their own route file.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe? this does just write something into the settings though. it is more similar to extensions and providers; sure they are their own features, but in terms of routes here we expose how to change it in the config file.

recipes are stored in their own folder(s).

responses(
(status = 200, description = "Slash commands retrieved successfully", body = SlashCommandsResponse)
)
)]
pub async fn get_slash_commands() -> Result<Json<SlashCommandsResponse>, 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,
});
Comment on lines +426 to +430
Copy link

Copilot AI Nov 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing builtin slash commands in the API response. The get_slash_commands endpoint only includes /compact but not /summarize, even though both are defined in MANUAL_COMPACT_TRIGGERS. This inconsistency could confuse users about which builtin commands are available.

Copilot uses AI. Check for mistakes.
Ok(Json(SlashCommandsResponse { commands }))
}

#[derive(Serialize, ToSchema)]
pub struct PricingData {
pub provider: String,
Expand All @@ -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<bool>,
pub configured_only: bool,
Copy link

Copilot AI Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed from Option<bool> to bool, but this is a breaking change for API consumers who may not provide this field. Should remain Option<bool> with a default, or use #[serde(default)] to provide backwards compatibility.

Suggested change
pub configured_only: bool,
#[serde(default)]
pub configured_only: Option<bool>,

Copilot uses AI. Check for mistakes.
}

#[utoipa::path(
Expand All @@ -423,7 +463,7 @@ pub struct PricingQuery {
pub async fn get_pricing(
Json(query): Json<PricingQuery>,
) -> Result<Json<PricingResponse>, 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 {
Expand Down Expand Up @@ -792,6 +832,7 @@ pub fn routes(state: Arc<AppState>) -> 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))
Expand Down
135 changes: 107 additions & 28 deletions crates/goose-server/src/routes/recipe.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;

use axum::extract::rejection::JsonRejection;
Expand All @@ -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;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -114,22 +115,26 @@ 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,
}

#[derive(Debug, Serialize, ToSchema)]
pub struct ListRecipeResponse {
recipe_manifest_responses: Vec<RecipeManifestResponse>,
manifests: Vec<RecipeManifest>,
}

#[derive(Debug, Deserialize, ToSchema)]
pub struct ScheduleRecipeRequest {
id: String,
cron_schedule: Option<String>,
}

#[derive(Debug, Deserialize, ToSchema)]
pub struct SetSlashCommandRequest {
id: String,
slash_command: Option<String>,
}

#[utoipa::path(
Expand Down Expand Up @@ -281,26 +286,36 @@ async fn scan_recipe(
async fn list_recipes(
State(state): State<Arc<AppState>>,
) -> Result<Json<ListRecipeResponse>, 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::<Vec<RecipeManifestResponse>>();
.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(
Expand Down Expand Up @@ -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<Arc<AppState>>,
Json(request): Json<ScheduleRecipeRequest>,
) -> Result<StatusCode, StatusCode> {
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<Arc<AppState>>,
Json(request): Json<SetSlashCommandRequest>,
) -> Result<StatusCode, StatusCode> {
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",
Expand Down Expand Up @@ -447,6 +524,8 @@ pub fn routes(state: Arc<AppState>) -> 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)
Expand Down
Loading
Loading