From d1c124c28de46e39fd95fc35a7b9d491aeea5b77 Mon Sep 17 00:00:00 2001 From: Kalvin C Date: Wed, 9 Apr 2025 18:57:24 -0700 Subject: [PATCH] feat: add recipes, a custom goose agent configuration (#2115) --- Cargo.lock | 1 + crates/goose-cli/Cargo.toml | 1 + crates/goose-cli/src/cli.rs | 186 +++++++++++++----- crates/goose-cli/src/commands/bench.rs | 17 +- crates/goose-cli/src/commands/mod.rs | 1 + crates/goose-cli/src/commands/recipe.rs | 60 ++++++ crates/goose-cli/src/lib.rs | 1 + crates/goose-cli/src/recipe.rs | 70 +++++++ crates/goose-cli/src/session/builder.rs | 101 ++++++---- crates/goose-cli/src/session/completion.rs | 1 + crates/goose-cli/src/session/input.rs | 53 ++++++ crates/goose-cli/src/session/mod.rs | 189 +++++++++++++------ crates/goose/src/agents/agent.rs | 114 +++++++++++ crates/goose/src/agents/prompt_manager.rs | 6 + crates/goose/src/lib.rs | 1 + crates/goose/src/prompts/recipe.md | 17 ++ crates/goose/src/recipe/mod.rs | 210 +++++++++++++++++++++ 17 files changed, 884 insertions(+), 145 deletions(-) create mode 100644 crates/goose-cli/src/commands/recipe.rs create mode 100644 crates/goose-cli/src/recipe.rs create mode 100644 crates/goose/src/prompts/recipe.md create mode 100644 crates/goose/src/recipe/mod.rs diff --git a/Cargo.lock b/Cargo.lock index b1abd4f9..9994d177 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2404,6 +2404,7 @@ version = "1.0.17" dependencies = [ "anyhow", "async-trait", + "base64 0.22.1", "bat", "chrono", "clap", diff --git a/crates/goose-cli/Cargo.toml b/crates/goose-cli/Cargo.toml index ee4a4584..765834b7 100644 --- a/crates/goose-cli/Cargo.toml +++ b/crates/goose-cli/Cargo.toml @@ -50,6 +50,7 @@ tracing-appender = "0.2" once_cell = "1.20.2" shlex = "1.3.0" async-trait = "0.1.86" +base64 = "0.22.1" [target.'cfg(target_os = "windows")'.dependencies] winapi = { version = "0.3", features = ["wincred"] } diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 85355f5d..060595ea 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -1,16 +1,18 @@ use anyhow::Result; use clap::{Args, Parser, Subcommand}; -use goose::config::Config; +use goose::config::{Config, ExtensionConfig}; use crate::commands::bench::agent_generator; use crate::commands::configure::handle_configure; use crate::commands::info::handle_info; use crate::commands::mcp::run_server; +use crate::commands::recipe::{handle_deeplink, handle_validate}; use crate::commands::session::handle_session_list; use crate::logging::setup_logging; +use crate::recipe::load_recipe; use crate::session; -use crate::session::build_session; +use crate::session::{build_session, SessionBuilderConfig}; use goose_bench::bench_config::BenchRunConfig; use goose_bench::runners::bench_runner::BenchRunner; use goose_bench::runners::eval_runner::EvalRunner; @@ -115,6 +117,25 @@ pub enum BenchCommand { }, } +#[derive(Subcommand)] +enum RecipeCommand { + /// Validate a recipe file + #[command(about = "Validate a recipe file")] + Validate { + /// Path to the recipe file to validate + #[arg(help = "Path to the recipe file to validate")] + file: String, + }, + + /// Generate a deeplink for a recipe file + #[command(about = "Generate a deeplink for a recipe file")] + Deeplink { + /// Path to the recipe file + #[arg(help = "Path to the recipe file")] + file: String, + }, +} + #[derive(Subcommand)] enum Command { /// Configure Goose settings @@ -170,7 +191,7 @@ enum Command { long_help = "Add stdio extensions from full commands with environment variables. Can be specified multiple times. Format: 'ENV1=val1 ENV2=val2 command args...'", action = clap::ArgAction::Append )] - extension: Vec, + extensions: Vec, /// Add remote extensions with a URL #[arg( @@ -180,7 +201,7 @@ enum Command { long_help = "Add remote extensions from a URL. Can be specified multiple times. Format: 'url...'", action = clap::ArgAction::Append )] - remote_extension: Vec, + remote_extensions: Vec, /// Add builtin extensions by name #[arg( @@ -190,7 +211,7 @@ enum Command { long_help = "Add one or more builtin extensions that are bundled with goose by specifying their names, comma-separated", value_delimiter = ',' )] - builtin: Vec, + builtins: Vec, }, /// Execute commands from an instruction file @@ -202,7 +223,8 @@ enum Command { long, value_name = "FILE", help = "Path to instruction file containing commands. Use - for stdin.", - conflicts_with = "input_text" + conflicts_with = "input_text", + conflicts_with = "recipe" )] instructions: Option, @@ -213,10 +235,23 @@ enum Command { value_name = "TEXT", help = "Input text to provide to Goose directly", long_help = "Input text containing commands for Goose. Use this in lieu of the instructions argument.", - conflicts_with = "instructions" + conflicts_with = "instructions", + conflicts_with = "recipe" )] input_text: Option, + /// Path to recipe.yaml file + #[arg( + short = None, + long = "recipe", + value_name = "FILE", + help = "Path to recipe.yaml file", + long_help = "Path to a recipe.yaml file that defines a custom agent configuration", + conflicts_with = "instructions", + conflicts_with = "input_text" + )] + recipe: Option, + /// Continue in interactive mode after processing input #[arg( short = 's', @@ -255,7 +290,7 @@ enum Command { long_help = "Add stdio extensions from full commands with environment variables. Can be specified multiple times. Format: 'ENV1=val1 ENV2=val2 command args...'", action = clap::ArgAction::Append )] - extension: Vec, + extensions: Vec, /// Add remote extensions #[arg( @@ -265,7 +300,7 @@ enum Command { long_help = "Add remote extensions. Can be specified multiple times. Format: 'url...'", action = clap::ArgAction::Append )] - remote_extension: Vec, + remote_extensions: Vec, /// Add builtin extensions by name #[arg( @@ -275,7 +310,14 @@ enum Command { long_help = "Add one or more builtin extensions that are bundled with goose by specifying their names, comma-separated", value_delimiter = ',' )] - builtin: Vec, + builtins: Vec, + }, + + /// Recipe utilities for validation and deeplinking + #[command(about = "Recipe utilities for validation and deeplinking")] + Recipe { + #[command(subcommand)] + command: RecipeCommand, }, /// Update the Goose CLI version @@ -308,6 +350,12 @@ enum CliProviderVariant { Ollama, } +struct InputConfig { + contents: Option, + extensions_override: Option>, + additional_system_prompt: Option, +} + pub async fn cli() -> Result<()> { let cli = Cli::parse(); @@ -328,9 +376,9 @@ pub async fn cli() -> Result<()> { identifier, resume, debug, - extension, - remote_extension, - builtin, + extensions, + remote_extensions, + builtins, }) => { return match command { Some(SessionCommand::List { verbose, format }) => { @@ -339,14 +387,16 @@ pub async fn cli() -> Result<()> { } None => { // Run session command by default - let mut session = build_session( - identifier.map(extract_identifier), + let mut session = build_session(SessionBuilderConfig { + identifier: identifier.map(extract_identifier), resume, - extension, - remote_extension, - builtin, + extensions, + remote_extensions, + builtins, + extensions_override: None, + additional_system_prompt: None, debug, - ) + }) .await; setup_logging( session.session_file().file_stem().and_then(|s| s.to_str()), @@ -360,44 +410,74 @@ pub async fn cli() -> Result<()> { Some(Command::Run { instructions, input_text, + recipe, interactive, identifier, resume, debug, - extension, - remote_extension, - builtin, + extensions, + remote_extensions, + builtins, }) => { - let contents = match (instructions, input_text) { - (Some(file), _) if file == "-" => { - let mut stdin = String::new(); + let input_config = match (instructions, input_text, recipe) { + (Some(file), _, _) if file == "-" => { + let mut input = String::new(); std::io::stdin() - .read_to_string(&mut stdin) + .read_to_string(&mut input) .expect("Failed to read from stdin"); - stdin + + InputConfig { + contents: Some(input), + extensions_override: None, + additional_system_prompt: None, + } } - (Some(file), _) => std::fs::read_to_string(&file).unwrap_or_else(|err| { - eprintln!( - "Instruction file not found — did you mean to use goose run --text?\n{}", - err - ); - std::process::exit(1); - }), - (None, Some(text)) => text, - (None, None) => { - eprintln!("Error: Must provide either --instructions (-i) or --text (-t). Use -i - for stdin."); + (Some(file), _, _) => { + let contents = std::fs::read_to_string(&file).unwrap_or_else(|err| { + eprintln!( + "Instruction file not found — did you mean to use goose run --text?\n{}", + err + ); + std::process::exit(1); + }); + InputConfig { + contents: Some(contents), + extensions_override: None, + additional_system_prompt: None, + } + } + (_, Some(text), _) => InputConfig { + contents: Some(text), + extensions_override: None, + additional_system_prompt: None, + }, + (_, _, Some(file)) => { + let recipe = load_recipe(&file, true).unwrap_or_else(|err| { + eprintln!("{}: {}", console::style("Error").red().bold(), err); + std::process::exit(1); + }); + InputConfig { + contents: recipe.prompt, + extensions_override: recipe.extensions, + additional_system_prompt: Some(recipe.instructions), + } + } + (None, None, None) => { + eprintln!("Error: Must provide either --instructions (-i), --text (-t), or --recipe (-r). Use -i - for stdin."); std::process::exit(1); } }; - let mut session = build_session( - identifier.map(extract_identifier), + let mut session = build_session(SessionBuilderConfig { + identifier: identifier.map(extract_identifier), resume, - extension, - remote_extension, - builtin, + extensions, + remote_extensions, + builtins, + extensions_override: input_config.extensions_override, + additional_system_prompt: input_config.additional_system_prompt, debug, - ) + }) .await; setup_logging( @@ -406,9 +486,12 @@ pub async fn cli() -> Result<()> { )?; if interactive { - session.interactive(Some(contents)).await?; - } else { + session.interactive(input_config.contents).await?; + } else if let Some(contents) = input_config.contents { session.headless(contents).await?; + } else { + eprintln!("Error: no text provided for prompt in headless mode"); + std::process::exit(1); } return Ok(()); @@ -432,13 +515,24 @@ pub async fn cli() -> Result<()> { } return Ok(()); } + Some(Command::Recipe { command }) => { + match command { + RecipeCommand::Validate { file } => { + handle_validate(file)?; + } + RecipeCommand::Deeplink { file } => { + handle_deeplink(file)?; + } + } + return Ok(()); + } None => { return if !Config::global().exists() { let _ = handle_configure().await; Ok(()) } else { // Run session command by default - let mut session = build_session(None, false, vec![], vec![], vec![], false).await; + let mut session = build_session(SessionBuilderConfig::default()).await; setup_logging( session.session_file().file_stem().and_then(|s| s.to_str()), None, diff --git a/crates/goose-cli/src/commands/bench.rs b/crates/goose-cli/src/commands/bench.rs index 279b8d4f..8055ac9b 100644 --- a/crates/goose-cli/src/commands/bench.rs +++ b/crates/goose-cli/src/commands/bench.rs @@ -1,4 +1,5 @@ use crate::session::build_session; +use crate::session::SessionBuilderConfig; use crate::{logging, session, Session}; use async_trait::async_trait; use goose::message::Message; @@ -30,14 +31,16 @@ pub async fn agent_generator( ) -> BenchAgent { let identifier = Some(session::Identifier::Name(session_id)); - let base_session = build_session( + let base_session = build_session(SessionBuilderConfig { identifier, - false, - requirements.external, - requirements.remote, - requirements.builtin, - false, - ) + resume: false, + extensions: requirements.external, + remote_extensions: requirements.remote, + builtins: requirements.builtin, + extensions_override: None, + additional_system_prompt: None, + debug: false, + }) .await; // package session obj into benchmark-compatible struct diff --git a/crates/goose-cli/src/commands/mod.rs b/crates/goose-cli/src/commands/mod.rs index b185dd6c..30840daa 100644 --- a/crates/goose-cli/src/commands/mod.rs +++ b/crates/goose-cli/src/commands/mod.rs @@ -2,5 +2,6 @@ pub mod bench; pub mod configure; pub mod info; pub mod mcp; +pub mod recipe; pub mod session; pub mod update; diff --git a/crates/goose-cli/src/commands/recipe.rs b/crates/goose-cli/src/commands/recipe.rs new file mode 100644 index 00000000..388133ef --- /dev/null +++ b/crates/goose-cli/src/commands/recipe.rs @@ -0,0 +1,60 @@ +use anyhow::Result; +use base64::Engine; +use console::style; +use std::path::Path; + +use crate::recipe::load_recipe; + +/// Validates a recipe file +/// +/// # Arguments +/// +/// * `file_path` - Path to the recipe file to validate +/// +/// # Returns +/// +/// Result indicating success or failure +pub fn handle_validate>(file_path: P) -> Result<()> { + // Load and validate the recipe file + match load_recipe(&file_path, false) { + Ok(_) => { + println!("{} recipe file is valid", style("✓").green().bold()); + Ok(()) + } + Err(err) => { + println!("{} {}", style("✗").red().bold(), err); + Err(err) + } + } +} + +/// Generates a deeplink for a recipe file +/// +/// # Arguments +/// +/// * `file_path` - Path to the recipe file +/// +/// # Returns +/// +/// Result indicating success or failure +pub fn handle_deeplink>(file_path: P) -> Result<()> { + // Load the recipe file first to validate it + match load_recipe(&file_path, false) { + Ok(recipe) => { + if let Ok(recipe_json) = serde_json::to_string(&recipe) { + let deeplink = base64::engine::general_purpose::STANDARD.encode(recipe_json); + println!( + "{} Generated deeplink for: {}", + style("✓").green().bold(), + recipe.title + ); + println!("goose://recipe?config={}", deeplink); + } + Ok(()) + } + Err(err) => { + println!("{} {}", style("✗").red().bold(), err); + Err(err) + } + } +} diff --git a/crates/goose-cli/src/lib.rs b/crates/goose-cli/src/lib.rs index 1a2da1e8..1eb18f2e 100644 --- a/crates/goose-cli/src/lib.rs +++ b/crates/goose-cli/src/lib.rs @@ -3,6 +3,7 @@ use once_cell::sync::Lazy; pub mod cli; pub mod commands; pub mod logging; +pub mod recipe; pub mod session; // Re-export commonly used types diff --git a/crates/goose-cli/src/recipe.rs b/crates/goose-cli/src/recipe.rs new file mode 100644 index 00000000..5bebddac --- /dev/null +++ b/crates/goose-cli/src/recipe.rs @@ -0,0 +1,70 @@ +use anyhow::{Context, Result}; +use console::style; +use std::path::Path; + +use goose::recipe::Recipe; + +/// Loads and validates a recipe from a YAML or JSON file +/// +/// # Arguments +/// +/// * `path` - Path to the recipe file (YAML or JSON) +/// * `log` - whether to log information about the recipe or not +/// +/// # Returns +/// +/// The parsed recipe struct if successful +/// +/// # Errors +/// +/// Returns an error if: +/// - The file doesn't exist +/// - The file can't be read +/// - The YAML/JSON is invalid +/// - The required fields are missing +pub fn load_recipe>(path: P, log: bool) -> Result { + let path = path.as_ref(); + + // Check if file exists + if !path.exists() { + return Err(anyhow::anyhow!("recipe file not found: {}", path.display())); + } + // Read file content + let content = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read recipe file: {}", path.display()))?; + + // Determine file format based on extension and parse accordingly + let recipe: Recipe = if let Some(extension) = path.extension() { + match extension.to_str().unwrap_or("").to_lowercase().as_str() { + "json" => serde_json::from_str(&content) + .with_context(|| format!("Failed to parse JSON recipe file: {}", path.display()))?, + "yaml" => serde_yaml::from_str(&content) + .with_context(|| format!("Failed to parse YAML recipe file: {}", path.display()))?, + _ => { + return Err(anyhow::anyhow!( + "Unsupported file format for recipe file: {}. Expected .yaml or .json", + path.display() + )) + } + } + } else { + return Err(anyhow::anyhow!( + "File has no extension: {}. Expected .yaml or .json", + path.display() + )); + }; + + if log { + // Display information about the loaded recipe + println!( + "{} {}", + style("Loading recipe:").green().bold(), + style(&recipe.title).green() + ); + println!("{} {}", style("Description:").dim(), &recipe.description); + + println!(); // Add a blank line for spacing + } + + Ok(recipe) +} diff --git a/crates/goose-cli/src/session/builder.rs b/crates/goose-cli/src/session/builder.rs index 0c138ceb..3cd8fd64 100644 --- a/crates/goose-cli/src/session/builder.rs +++ b/crates/goose-cli/src/session/builder.rs @@ -1,7 +1,7 @@ use console::style; use goose::agents::extension::ExtensionError; use goose::agents::Agent; -use goose::config::{Config, ExtensionConfigManager}; +use goose::config::{Config, ExtensionConfig, ExtensionConfigManager}; use goose::session; use goose::session::Identifier; use mcp_client::transport::Error as McpClientError; @@ -10,14 +10,31 @@ use std::process; use super::output; use super::Session; -pub async fn build_session( - identifier: Option, - resume: bool, - extensions: Vec, - remote_extensions: Vec, - builtins: Vec, - debug: bool, -) -> Session { +/// Configuration for building a new Goose session +/// +/// This struct contains all the parameters needed to create a new session, +/// including session identification, extension configuration, and debug settings. +#[derive(Default, Clone, Debug)] +pub struct SessionBuilderConfig { + /// Optional identifier for the session (name or path) + pub identifier: Option, + /// Whether to resume an existing session + pub resume: bool, + /// List of stdio extension commands to add + pub extensions: Vec, + /// List of remote extension commands to add + pub remote_extensions: Vec, + /// List of builtin extension commands to add + pub builtins: Vec, + /// List of extensions to enable, enable only this set and ignore configured ones + pub extensions_override: Option>, + /// Any additional system prompt to append to the default + pub additional_system_prompt: Option, + /// Enable debug printing + pub debug: bool, +} + +pub async fn build_session(session_config: SessionBuilderConfig) -> Session { // Load config and get provider/model let config = Config::global(); @@ -36,8 +53,8 @@ pub async fn build_session( let mut agent = Agent::new(provider); // Handle session file resolution and resuming - let session_file = if resume { - if let Some(identifier) = identifier { + let session_file = if session_config.resume { + if let Some(identifier) = session_config.identifier { let session_file = session::get_path(identifier); if !session_file.exists() { output::render_error(&format!( @@ -60,7 +77,7 @@ pub async fn build_session( } } else { // Create new session with provided name/path or generated name - let id = match identifier { + let id = match session_config.identifier { Some(identifier) => identifier, None => Identifier::Name(session::generate_session_id()), }; @@ -69,7 +86,7 @@ pub async fn build_session( session::get_path(id) }; - if resume { + if session_config.resume { // Read the session metadata let metadata = session::read_metadata(&session_file).unwrap_or_else(|e| { output::render_error(&format!("Failed to read session metadata: {}", e)); @@ -92,34 +109,38 @@ pub async fn build_session( // Setup extensions for the agent // Extensions need to be added after the session is created because we change directory when resuming a session - for extension in ExtensionConfigManager::get_all().expect("should load extensions") { - if extension.enabled { - let config = extension.config.clone(); - agent - .add_extension(config.clone()) - .await - .unwrap_or_else(|e| { - let err = match e { - ExtensionError::Transport(McpClientError::StdioProcessError(inner)) => { - inner - } - _ => e.to_string(), - }; - println!("Failed to start extension: {}, {:?}", config.name(), err); - println!( - "Please check extension configuration for {}.", - config.name() - ); - process::exit(1); - }); + // If we get extensions_override, only run those extensions and none other + let extensions_to_run: Vec<_> = if let Some(extensions) = session_config.extensions_override { + extensions.into_iter().collect() + } else { + ExtensionConfigManager::get_all() + .expect("should load extensions") + .into_iter() + .filter(|ext| ext.enabled) + .map(|ext| ext.config) + .collect() + }; + + for extension in extensions_to_run { + if let Err(e) = agent.add_extension(extension.clone()).await { + let err = match e { + ExtensionError::Transport(McpClientError::StdioProcessError(inner)) => inner, + _ => e.to_string(), + }; + eprintln!("Failed to start extension: {}, {:?}", extension.name(), err); + eprintln!( + "Please check extension configuration for {}.", + extension.name() + ); + process::exit(1); } } // Create new session - let mut session = Session::new(agent, session_file.clone(), debug); + let mut session = Session::new(agent, session_file.clone(), session_config.debug); // Add extensions if provided - for extension_str in extensions { + for extension_str in session_config.extensions { if let Err(e) = session.add_extension(extension_str).await { eprintln!("Failed to start extension: {}", e); process::exit(1); @@ -127,7 +148,7 @@ pub async fn build_session( } // Add remote extensions if provided - for extension_str in remote_extensions { + for extension_str in session_config.remote_extensions { if let Err(e) = session.add_remote_extension(extension_str).await { eprintln!("Failed to start extension: {}", e); process::exit(1); @@ -135,7 +156,7 @@ pub async fn build_session( } // Add builtin extensions - for builtin in builtins { + for builtin in session_config.builtins { if let Err(e) = session.add_builtin(builtin).await { eprintln!("Failed to start builtin extension: {}", e); process::exit(1); @@ -148,6 +169,10 @@ pub async fn build_session( .extend_system_prompt(super::prompt::get_cli_prompt()) .await; + if let Some(additional_prompt) = session_config.additional_system_prompt { + session.agent.extend_system_prompt(additional_prompt).await; + } + // Only override system prompt if a system override exists let system_prompt_file: Option = config.get_param("GOOSE_SYSTEM_PROMPT_FILE_PATH").ok(); if let Some(ref path) = system_prompt_file { @@ -156,6 +181,6 @@ pub async fn build_session( session.agent.override_system_prompt(override_prompt).await; } - output::display_session_info(resume, &provider_name, &model, &session_file); + output::display_session_info(session_config.resume, &provider_name, &model, &session_file); session } diff --git a/crates/goose-cli/src/session/completion.rs b/crates/goose-cli/src/session/completion.rs index 38e428be..03bba0e9 100644 --- a/crates/goose-cli/src/session/completion.rs +++ b/crates/goose-cli/src/session/completion.rs @@ -129,6 +129,7 @@ impl GooseCompleter { "/prompts", "/prompt", "/mode", + "/recipe", ]; // Find commands that match the prefix diff --git a/crates/goose-cli/src/session/input.rs b/crates/goose-cli/src/session/input.rs index 65b3e50f..cdca703b 100644 --- a/crates/goose-cli/src/session/input.rs +++ b/crates/goose-cli/src/session/input.rs @@ -17,6 +17,7 @@ pub enum InputResult { GooseMode(String), Plan(PlanCommandOptions), EndPlan, + Recipe(Option), } #[derive(Debug)] @@ -89,6 +90,7 @@ fn handle_slash_command(input: &str) -> Option { const CMD_MODE: &str = "/mode "; const CMD_PLAN: &str = "/plan"; const CMD_ENDPLAN: &str = "/endplan"; + const CMD_RECIPE: &str = "/recipe"; match input { "/exit" | "/quit" => Some(InputResult::Exit), @@ -130,10 +132,36 @@ fn handle_slash_command(input: &str) -> Option { } s if s.starts_with(CMD_PLAN) => parse_plan_command(s[CMD_PLAN.len()..].trim().to_string()), s if s == CMD_ENDPLAN => Some(InputResult::EndPlan), + s if s.starts_with(CMD_RECIPE) => parse_recipe_command(s), _ => None, } } +fn parse_recipe_command(s: &str) -> Option { + const CMD_RECIPE: &str = "/recipe"; + + if s == CMD_RECIPE { + // No filepath provided, use default + return Some(InputResult::Recipe(None)); + } + + // Extract the filepath from the command + let filepath = s[CMD_RECIPE.len()..].trim(); + + if filepath.is_empty() { + return Some(InputResult::Recipe(None)); + } + + // Validate that the filepath ends with .yaml + if !filepath.to_lowercase().ends_with(".yaml") { + println!("{}", console::style("Filepath must end with .yaml").red()); + return Some(InputResult::Retry); + } + + // Return the filepath for validation in the handler + Some(InputResult::Recipe(Some(filepath.to_string()))) +} + fn parse_prompts_command(args: &str) -> Option { let parts: Vec = shlex::split(args).unwrap_or_default(); @@ -211,6 +239,8 @@ fn print_help() { The model is used based on $GOOSE_PLANNER_PROVIDER and $GOOSE_PLANNER_MODEL environment variables. If no model is set, the default model is used. /endplan - Exit plan mode and return to 'normal' goose mode. +/recipe [filepath] - Generate a recipe from the current conversation and save it to the specified filepath (must end with .yaml). + If no filepath is provided, it will be saved to ./recipe.yaml. /? or /help - Display this help message Navigation: @@ -421,4 +451,27 @@ mod tests { _ => panic!("Expected Plan"), } } + + #[test] + fn test_recipe_command() { + // Test recipe with no filepath + if let Some(InputResult::Recipe(filepath)) = handle_slash_command("/recipe") { + assert!(filepath.is_none()); + } else { + panic!("Expected Recipe"); + } + + // Test recipe with filepath + if let Some(InputResult::Recipe(filepath)) = + handle_slash_command("/recipe /path/to/file.yaml") + { + assert_eq!(filepath, Some("/path/to/file.yaml".to_string())); + } else { + panic!("Expected recipe with filepath"); + } + + // Test recipe with invalid extension + let result = handle_slash_command("/recipe /path/to/file.txt"); + assert!(matches!(result, Some(InputResult::Retry))); + } } diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 4e08bb15..a0238af4 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -5,14 +5,14 @@ mod output; mod prompt; mod thinking; -pub use builder::build_session; +pub use builder::{build_session, SessionBuilderConfig}; use goose::permission::permission_confirmation::PrincipalType; use goose::permission::Permission; use goose::permission::PermissionConfirmation; use goose::providers::base::Provider; pub use goose::session::Identifier; -use anyhow::Result; +use anyhow::{Context, Result}; use completion::GooseCompleter; use etcetera::choose_app_strategy; use etcetera::AppStrategy; @@ -21,6 +21,7 @@ use goose::agents::{Agent, SessionConfig}; use goose::config::Config; use goose::message::{Message, MessageContent}; use goose::session; +use input::InputResult; use mcp_core::handler::ToolError; use mcp_core::prompt::PromptMessage; @@ -466,64 +467,40 @@ impl Session { } input::InputResult::PromptCommand(opts) => { save_history(&mut editor); + self.handle_prompt_command(opts).await?; + } + InputResult::Recipe(filepath_opt) => { + println!("{}", console::style("Generating Recipe").green()); - // name is required - if opts.name.is_empty() { - output::render_error("Prompt name argument is required"); - continue; - } + output::show_thinking(); + let recipe = self.agent.create_recipe(self.messages.clone()).await; + output::hide_thinking(); - if opts.info { - match self.get_prompt_info(&opts.name).await? { - Some(info) => output::render_prompt_info(&info), - None => { - output::render_error(&format!("Prompt '{}' not found", opts.name)) - } - } - } else { - // Convert the arguments HashMap to a Value - let arguments = serde_json::to_value(opts.arguments) - .map_err(|e| anyhow::anyhow!("Failed to serialize arguments: {}", e))?; - - match self.get_prompt(&opts.name, arguments).await { - Ok(messages) => { - let start_len = self.messages.len(); - let mut valid = true; - for (i, prompt_message) in messages.into_iter().enumerate() { - let msg = Message::from(prompt_message); - // ensure we get a User - Assistant - User type pattern - let expected_role = if i % 2 == 0 { - mcp_core::Role::User - } else { - mcp_core::Role::Assistant - }; - - if msg.role != expected_role { - output::render_error(&format!( - "Expected {:?} message at position {}, but found {:?}", - expected_role, i, msg.role - )); - valid = false; - // get rid of everything we added to messages - self.messages.truncate(start_len); - break; - } - - if msg.role == mcp_core::Role::User { - output::render_message(&msg, self.debug); - } - self.messages.push(msg); - } - - if valid { - output::show_thinking(); - self.process_agent_response(true).await?; - output::hide_thinking(); + match recipe { + Ok(recipe) => { + // Use provided filepath or default + let filepath_str = filepath_opt.as_deref().unwrap_or("recipe.yaml"); + match self.save_recipe(&recipe, filepath_str) { + Ok(path) => println!( + "{}", + console::style(format!("Saved recipe to {}", path.display())) + .green() + ), + Err(e) => { + println!("{}", console::style(e).red()); } } - Err(e) => output::render_error(&e.to_string()), + } + Err(e) => { + println!( + "{}: {:?}", + console::style("Failed to generate recipe").red(), + e + ); } } + + continue; } } } @@ -855,6 +832,110 @@ impl Session { let metadata = self.get_metadata()?; Ok(metadata.total_tokens) } + + /// Handle prompt command execution + async fn handle_prompt_command(&mut self, opts: input::PromptCommandOptions) -> Result<()> { + // name is required + if opts.name.is_empty() { + output::render_error("Prompt name argument is required"); + return Ok(()); + } + + if opts.info { + match self.get_prompt_info(&opts.name).await? { + Some(info) => output::render_prompt_info(&info), + None => output::render_error(&format!("Prompt '{}' not found", opts.name)), + } + } else { + // Convert the arguments HashMap to a Value + let arguments = serde_json::to_value(opts.arguments) + .map_err(|e| anyhow::anyhow!("Failed to serialize arguments: {}", e))?; + + match self.get_prompt(&opts.name, arguments).await { + Ok(messages) => { + let start_len = self.messages.len(); + let mut valid = true; + for (i, prompt_message) in messages.into_iter().enumerate() { + let msg = Message::from(prompt_message); + // ensure we get a User - Assistant - User type pattern + let expected_role = if i % 2 == 0 { + mcp_core::Role::User + } else { + mcp_core::Role::Assistant + }; + + if msg.role != expected_role { + output::render_error(&format!( + "Expected {:?} message at position {}, but found {:?}", + expected_role, i, msg.role + )); + valid = false; + // get rid of everything we added to messages + self.messages.truncate(start_len); + break; + } + + if msg.role == mcp_core::Role::User { + output::render_message(&msg, self.debug); + } + self.messages.push(msg); + } + + if valid { + output::show_thinking(); + self.process_agent_response(true).await?; + output::hide_thinking(); + } + } + Err(e) => output::render_error(&e.to_string()), + } + } + + Ok(()) + } + + /// Save a recipe to a file + /// + /// # Arguments + /// * `recipe` - The recipe to save + /// * `filepath_str` - The path to save the recipe to + /// + /// # Returns + /// * `Result` - The path the recipe was saved to or an error message + fn save_recipe( + &self, + recipe: &goose::recipe::Recipe, + filepath_str: &str, + ) -> anyhow::Result { + let path_buf = PathBuf::from(filepath_str); + let mut path = path_buf.clone(); + + // Update the final path if it's relative + if path_buf.is_relative() { + // If the path is relative, resolve it relative to the current working directory + let cwd = std::env::current_dir().context("Failed to get current directory")?; + path = cwd.join(&path_buf); + } + + // Check if parent directory exists + if let Some(parent) = path.parent() { + if !parent.exists() { + return Err(anyhow::anyhow!( + "Directory '{}' does not exist", + parent.display() + )); + } + } + + // Try creating the file + let file = std::fs::File::create(path.as_path()) + .context(format!("Failed to create file '{}'", path.display()))?; + + // Write YAML + serde_yaml::to_writer(file, recipe).context("Failed to save recipe")?; + + Ok(path) + } } fn get_reasoner() -> Result, anyhow::Error> { diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 703a45ed..2a3584a6 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use anyhow::{anyhow, Result}; use futures::stream::BoxStream; +use regex::Regex; use serde_json::Value; use tokio::sync::{mpsc, Mutex}; use tracing::{debug, error, instrument, warn}; @@ -21,6 +22,7 @@ use crate::providers::errors::ProviderError; use crate::providers::toolshim::{ augment_message_with_tool_calls, modify_system_prompt_for_tool_json, OllamaInterpreter, }; +use crate::recipe::{Author, Recipe}; use crate::session; use crate::token_counter::TokenCounter; use crate::truncate::{truncate_messages, OldestFirstTruncation}; @@ -787,4 +789,116 @@ impl Agent { tracing::error!("Failed to send tool result: {}", e); } } + + pub async fn create_recipe(&self, mut messages: Vec) -> Result { + let mut extension_manager = self.extension_manager.lock().await; + let extensions_info = extension_manager.get_extensions_info().await; + let system_prompt = self + .prompt_manager + .build_system_prompt(extensions_info, self.frontend_instructions.clone()); + + let recipe_prompt = self.prompt_manager.get_recipe_prompt().await; + let tools = extension_manager.get_prefixed_tools().await?; + + messages.push(Message::user().with_text(recipe_prompt)); + + let (result, _usage) = self + .provider + .complete(&system_prompt, &messages, &tools) + .await?; + + let content = result.as_concat_text(); + + // the response may be contained in ```json ```, strip that before parsing json + let re = Regex::new(r"(?s)^```[^\n]*\n(.*?)\n```$").unwrap(); + let clean_content = re + .captures(&content) + .and_then(|caps| caps.get(1).map(|m| m.as_str())) + .unwrap_or(&content) + .trim() + .to_string(); + + // try to parse json response from the LLM + let (instructions, activities) = + if let Ok(json_content) = serde_json::from_str::(&clean_content) { + let instructions = json_content + .get("instructions") + .ok_or_else(|| anyhow!("Missing 'instructions' in json response"))? + .as_str() + .ok_or_else(|| anyhow!("instructions' is not a string"))? + .to_string(); + + let activities = json_content + .get("activities") + .ok_or_else(|| anyhow!("Missing 'activities' in json response"))? + .as_array() + .ok_or_else(|| anyhow!("'activities' is not an array'"))? + .iter() + .map(|act| { + act.as_str() + .map(|s| s.to_string()) + .ok_or(anyhow!("'activities' array element is not a string")) + }) + .collect::>()?; + + (instructions, activities) + } else { + // If we can't get valid JSON, try string parsing + // Use split_once to get the content after "Instructions:". + let after_instructions = content + .split_once("instructions:") + .map(|(_, rest)| rest) + .unwrap_or(&content); + + // Split once more to separate instructions from activities. + let (instructions_part, activities_text) = after_instructions + .split_once("activities:") + .unwrap_or((after_instructions, "")); + + let instructions = instructions_part + .trim_end_matches(|c: char| c.is_whitespace() || c == '#') + .trim() + .to_string(); + let activities_text = activities_text.trim(); + + // Regex to remove bullet markers or numbers with an optional dot. + let bullet_re = Regex::new(r"^[•\-\*\d]+\.?\s*").expect("Invalid regex"); + + // Process each line in the activities section. + let activities: Vec = activities_text + .lines() + .map(|line| bullet_re.replace(line, "").to_string()) + .map(|s| s.trim().to_string()) + .filter(|line| !line.is_empty()) + .collect(); + + (instructions, activities) + }; + + let extensions = ExtensionConfigManager::get_all().unwrap_or_default(); + let extension_configs: Vec<_> = extensions + .iter() + .filter(|e| e.enabled) + .map(|e| e.config.clone()) + .collect(); + + let author = Author { + contact: std::env::var("USER") + .or_else(|_| std::env::var("USERNAME")) + .ok(), + metadata: None, + }; + + let recipe = Recipe::builder() + .title("Custom recipe from chat") + .description("a custom recipe instance from this chat session") + .instructions(instructions) + .activities(activities) + .extensions(extension_configs) + .author(author) + .build() + .expect("valid recipe"); + + Ok(recipe) + } } diff --git a/crates/goose/src/agents/prompt_manager.rs b/crates/goose/src/agents/prompt_manager.rs index 7aad9516..76b511b9 100644 --- a/crates/goose/src/agents/prompt_manager.rs +++ b/crates/goose/src/agents/prompt_manager.rs @@ -92,4 +92,10 @@ impl PromptManager { ) } } + + /// Get the recipe prompt + pub async fn get_recipe_prompt(&self) -> String { + let context: HashMap<&str, Value> = HashMap::new(); + prompt_template::render_global_file("recipe.md", &context).expect("Prompt should render") + } } diff --git a/crates/goose/src/lib.rs b/crates/goose/src/lib.rs index b3519d0d..0008557e 100644 --- a/crates/goose/src/lib.rs +++ b/crates/goose/src/lib.rs @@ -6,6 +6,7 @@ pub mod model; pub mod permission; pub mod prompt_template; pub mod providers; +pub mod recipe; pub mod session; pub mod token_counter; pub mod tracing; diff --git a/crates/goose/src/prompts/recipe.md b/crates/goose/src/prompts/recipe.md new file mode 100644 index 00000000..c5bb9ca7 --- /dev/null +++ b/crates/goose/src/prompts/recipe.md @@ -0,0 +1,17 @@ +Based on our conversation so far, could you create: + +1. A concise set of instructions (1-2 paragraphs) that describe what you've been helping with. Make the instructions generic, and higher-level so that can be re-used across various similar tasks. Pay special attention if any output styles or formats are requested (and make it clear), and note any non standard tools used or required. + +2. A list of 3-5 example activities (as a few words each at most) that would be relevant to this topic + +Format your response in _VALID_ json, with one key being `instructions` which contains a string, and the other key `activities` as an array of strings. +For example, perhaps we have been discussing fruit and you might write: + +{ +"instructions": "Using web searches we find pictures of fruit, and always check what language to reply in.", +"activities": [ +"Show pics of apples", +"say a random fruit", +"share a fruit fact" +] +} diff --git a/crates/goose/src/recipe/mod.rs b/crates/goose/src/recipe/mod.rs new file mode 100644 index 00000000..5c697bf4 --- /dev/null +++ b/crates/goose/src/recipe/mod.rs @@ -0,0 +1,210 @@ +use crate::agents::extension::ExtensionConfig; +use serde::{Deserialize, Serialize}; + +fn default_version() -> String { + "1.0.0".to_string() +} + +/// A Recipe represents a personalized, user-generated agent configuration that defines +/// specific behaviors and capabilities within the Goose system. +/// +/// # Fields +/// +/// ## Required Fields +/// * `version` - Semantic version of the Recipe file format (defaults to "1.0.0") +/// * `title` - Short, descriptive name of the Recipe +/// * `description` - Detailed description explaining the Recipe's purpose and functionality +/// * `Instructions` - Instructions that defines the Recipe's behavior +/// +/// ## Optional Fields +/// * `prompt` - the initial prompt to the session to start with +/// * `extensions` - List of extension configurations required by the Recipe +/// * `context` - Supplementary context information for the Recipe +/// * `activities` - Activity labels that appear when loading the Recipe +/// * `author` - Information about the Recipe's creator and metadata +/// +/// # Example +/// +/// ``` +/// use goose::recipe::Recipe; +/// +/// // Using the builder pattern +/// let recipe = Recipe::builder() +/// .title("Example Agent") +/// .description("An example Recipe configuration") +/// .instructions("Act as a helpful assistant") +/// .build() +/// .expect("Missing required fields"); +/// +/// // Or using struct initialization +/// let recipe = Recipe { +/// version: "1.0.0".to_string(), +/// title: "Example Agent".to_string(), +/// description: "An example Recipe configuration".to_string(), +/// instructions: "Act as a helpful assistant".to_string(), +/// prompt: None, +/// extensions: None, +/// context: None, +/// activities: None, +/// author: None, +/// }; +/// ``` +#[derive(Serialize, Deserialize, Debug)] +pub struct Recipe { + // Required fields + #[serde(default = "default_version")] + pub version: String, // version of the file format, sem ver + + pub title: String, // short title of the recipe + + pub description: String, // a longer description of the recipe + + pub instructions: String, // the instructions for the model + + // Optional fields + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt: Option, // the prompt to start the session with + + #[serde(skip_serializing_if = "Option::is_none")] + pub extensions: Option>, // a list of extensions to enable + + #[serde(skip_serializing_if = "Option::is_none")] + pub context: Option>, // any additional context + + #[serde(skip_serializing_if = "Option::is_none")] + pub activities: Option>, // the activity pills that show up when loading the + + #[serde(skip_serializing_if = "Option::is_none")] + pub author: Option, // any additional author information +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct Author { + #[serde(skip_serializing_if = "Option::is_none")] + pub contact: Option, // creator/contact information of the recipe + + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, // any additional metadata for the author +} + +/// Builder for creating Recipe instances +pub struct RecipeBuilder { + // Required fields with default values + version: String, + title: Option, + description: Option, + instructions: Option, + + // Optional fields + prompt: Option, + extensions: Option>, + context: Option>, + activities: Option>, + author: Option, +} + +impl Recipe { + /// Creates a new RecipeBuilder to construct a Recipe instance + /// + /// # Example + /// + /// ``` + /// use goose::recipe::Recipe; + /// + /// let recipe = Recipe::builder() + /// .title("My Recipe") + /// .description("A helpful assistant") + /// .instructions("Act as a helpful assistant") + /// .build() + /// .expect("Failed to build Recipe: missing required fields"); + /// ``` + pub fn builder() -> RecipeBuilder { + RecipeBuilder { + version: default_version(), + title: None, + description: None, + instructions: None, + prompt: None, + extensions: None, + context: None, + activities: None, + author: None, + } + } +} + +impl RecipeBuilder { + /// Sets the version of the Recipe + pub fn version(mut self, version: impl Into) -> Self { + self.version = version.into(); + self + } + + /// Sets the title of the Recipe (required) + pub fn title(mut self, title: impl Into) -> Self { + self.title = Some(title.into()); + self + } + + /// Sets the description of the Recipe (required) + pub fn description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } + + /// Sets the instructions for the Recipe (required) + pub fn instructions(mut self, instructions: impl Into) -> Self { + self.instructions = Some(instructions.into()); + self + } + + pub fn prompt(mut self, prompt: impl Into) -> Self { + self.prompt = Some(prompt.into()); + self + } + + /// Sets the extensions for the Recipe + pub fn extensions(mut self, extensions: Vec) -> Self { + self.extensions = Some(extensions); + self + } + + /// Sets the context for the Recipe + pub fn context(mut self, context: Vec) -> Self { + self.context = Some(context); + self + } + + /// Sets the activities for the Recipe + pub fn activities(mut self, activities: Vec) -> Self { + self.activities = Some(activities); + self + } + + /// Sets the author information for the Recipe + pub fn author(mut self, author: Author) -> Self { + self.author = Some(author); + self + } + + /// Builds the Recipe instance + /// + /// Returns an error if any required fields are missing + pub fn build(self) -> Result { + let title = self.title.ok_or("Title is required")?; + let description = self.description.ok_or("Description is required")?; + let instructions = self.instructions.ok_or("Instructions are required")?; + + Ok(Recipe { + version: self.version, + title, + description, + instructions, + prompt: self.prompt, + extensions: self.extensions, + context: self.context, + activities: self.activities, + author: self.author, + }) + } +}