Slash commands (#5718)

Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
Douwe Osinga
2025-11-20 21:14:15 +01:00
committed by GitHub
parent 6e511d1e8a
commit bdf68bb4f5
28 changed files with 1675 additions and 1048 deletions
+10 -1
View File
@@ -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,
@@ -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<SlashCommand>,
}
#[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<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,
});
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<bool>,
pub configured_only: bool,
}
#[utoipa::path(
@@ -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 {
@@ -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))
+107 -28
View File
@@ -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<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(
@@ -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(
@@ -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",
@@ -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)
+13 -6
View File
@@ -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<String>,
pub slash_command: Option<String>,
}
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<Vec<RecipeManifestWithPath>> {
pub fn get_all_recipes_manifests() -> Result<Vec<RecipeManifest>> {
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<Vec<RecipeManifestWithPath>> {
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);
}
+20 -47
View File
@@ -88,10 +88,7 @@ async fn create_schedule(
State(state): State<Arc<AppState>>,
Json(req): Json<CreateScheduleRequest>,
) -> Result<Json<ScheduledJob>, 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<Arc<AppState>>,
) -> Result<Json<ListSchedulesResponse>, 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<Arc<AppState>>,
Path(id): Path<String>,
) -> Result<StatusCode, StatusCode> {
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<Arc<AppState>>,
Path(id): Path<String>,
) -> Result<Json<RunNowResponse>, 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<String>, // Renamed to avoid confusion with session_id
Query(query_params): Query<SessionsQuery>,
) -> Result<Json<Vec<SessionDisplayInfo>>, 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<Arc<AppState>>,
Path(id): Path<String>,
) -> Result<StatusCode, StatusCode> {
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<Arc<AppState>>,
Path(id): Path<String>,
) -> Result<StatusCode, StatusCode> {
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<String>,
Json(req): Json<UpdateScheduleRequest>,
) -> Result<Json<ScheduledJob>, 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<Arc<AppState>>,
Path(id): Path<String>,
) -> Result<Json<KillJobResponse>, 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<Arc<AppState>>,
Path(id): Path<String>,
) -> Result<Json<InspectJobResponse>, 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) => {
+2 -2
View File
@@ -26,8 +26,8 @@ impl AppState {
}))
}
pub async fn scheduler(&self) -> Result<Arc<dyn SchedulerTrait>, anyhow::Error> {
self.agent_manager.scheduler().await
pub fn scheduler(&self) -> Arc<dyn SchedulerTrait> {
self.agent_manager.scheduler()
}
pub async fn set_recipe_file_hash_map(&self, hash_map: HashMap<String, PathBuf>) {