From 9e6247d9ed0930a0c65c8a47574e2a9ccbe06fc6 Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Wed, 25 Jun 2025 09:29:26 +1000 Subject: [PATCH] feat: created sub recipe tools (#2982) --- Cargo.lock | 4 +- crates/goose-cli/Cargo.toml | 2 +- crates/goose-cli/src/cli.rs | 9 +- crates/goose-cli/src/commands/bench.rs | 1 + crates/goose-cli/src/commands/recipe.rs | 74 ++- crates/goose-cli/src/recipes/mod.rs | 1 + crates/goose-cli/src/recipes/recipe.rs | 459 ++---------------- crates/goose-cli/src/recipes/recipe/tests.rs | 315 ++++++++++++ .../goose-cli/src/recipes/template_recipe.rs | 172 +++++++ crates/goose-cli/src/session/builder.rs | 9 +- crates/goose/src/agents/agent.rs | 37 +- crates/goose/src/agents/mod.rs | 3 +- crates/goose/src/agents/recipe_tools/mod.rs | 1 + .../agents/recipe_tools/sub_recipe_tools.rs | 167 +++++++ .../recipe_tools/sub_recipe_tools/tests.rs | 155 ++++++ crates/goose/src/agents/sub_recipe_manager.rs | 89 ++++ crates/goose/src/recipe/mod.rs | 256 ++++++++++ crates/goose/src/scheduler.rs | 1 + 18 files changed, 1329 insertions(+), 426 deletions(-) create mode 100644 crates/goose-cli/src/recipes/recipe/tests.rs create mode 100644 crates/goose-cli/src/recipes/template_recipe.rs create mode 100644 crates/goose/src/agents/recipe_tools/mod.rs create mode 100644 crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs create mode 100644 crates/goose/src/agents/recipe_tools/sub_recipe_tools/tests.rs create mode 100644 crates/goose/src/agents/sub_recipe_manager.rs diff --git a/Cargo.lock b/Cargo.lock index c03cea02bb..66eecd729f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5477,9 +5477,9 @@ dependencies = [ [[package]] name = "minijinja" -version = "2.8.0" +version = "2.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e36f1329330bb1614c94b78632b9ce45dd7d761f3304a1bed07b2990a7c5097" +checksum = "dd72e8b4e42274540edabec853f607c015c73436159b06c39c7af85a20433155" dependencies = [ "memo-map", "self_cell", diff --git a/crates/goose-cli/Cargo.toml b/crates/goose-cli/Cargo.toml index 23dcc934d1..5e8915083e 100644 --- a/crates/goose-cli/Cargo.toml +++ b/crates/goose-cli/Cargo.toml @@ -53,7 +53,7 @@ shlex = "1.3.0" async-trait = "0.1.86" base64 = "0.22.1" regex = "1.11.1" -minijinja = { version = "2.8.0", features = ["loader"] } +minijinja = { version = "2.10.2", features = ["loader"] } nix = { version = "0.30.1", features = ["process", "signal"] } tar = "0.4" # Web server dependencies diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 6b4ea2e932..1c0b20bb27 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -675,6 +675,7 @@ pub async fn cli() -> Result<()> { scheduled_job_id: None, interactive: true, quiet: false, + sub_recipes: None, }) .await; setup_logging( @@ -723,7 +724,7 @@ pub async fn cli() -> Result<()> { scheduled_job_id, quiet, }) => { - let (input_config, session_settings) = match ( + let (input_config, session_settings, sub_recipes) = match ( instructions, input_text, recipe, @@ -743,6 +744,7 @@ pub async fn cli() -> Result<()> { additional_system_prompt: system, }, None, + None, ) } (Some(file), _, _, _, _) => { @@ -760,6 +762,7 @@ pub async fn cli() -> Result<()> { additional_system_prompt: None, }, None, + None, ) } (_, Some(text), _, _, _) => ( @@ -769,6 +772,7 @@ pub async fn cli() -> Result<()> { additional_system_prompt: system, }, None, + None, ), (_, _, Some(recipe_name), explain, render_recipe) => { if explain { @@ -800,6 +804,7 @@ pub async fn cli() -> Result<()> { goose_model: s.goose_model, temperature: s.temperature, }), + recipe.sub_recipes, ) } (None, None, None, _, _) => { @@ -823,6 +828,7 @@ pub async fn cli() -> Result<()> { scheduled_job_id, interactive, // Use the interactive flag from the Run command quiet, + sub_recipes, }) .await; @@ -941,6 +947,7 @@ pub async fn cli() -> Result<()> { scheduled_job_id: None, interactive: true, // Default case is always interactive quiet: false, + sub_recipes: None, }) .await; setup_logging( diff --git a/crates/goose-cli/src/commands/bench.rs b/crates/goose-cli/src/commands/bench.rs index cf3d992e46..83d4856263 100644 --- a/crates/goose-cli/src/commands/bench.rs +++ b/crates/goose-cli/src/commands/bench.rs @@ -46,6 +46,7 @@ pub async fn agent_generator( interactive: false, // Benchmarking is non-interactive scheduled_job_id: None, quiet: false, + sub_recipes: None, }) .await; diff --git a/crates/goose-cli/src/commands/recipe.rs b/crates/goose-cli/src/commands/recipe.rs index d4c700c27b..deadf0f0a8 100644 --- a/crates/goose-cli/src/commands/recipe.rs +++ b/crates/goose-cli/src/commands/recipe.rs @@ -36,10 +36,11 @@ pub fn handle_validate(recipe_name: &str) -> Result<()> { /// # Returns /// /// Result indicating success or failure -pub fn handle_deeplink(recipe_name: &str) -> Result<()> { +pub fn handle_deeplink(recipe_name: &str) -> Result { // Load the recipe file first to validate it match load_recipe(recipe_name) { Ok(recipe) => { + let mut full_url = String::new(); if let Ok(recipe_json) = serde_json::to_string(&recipe) { let deeplink = base64::engine::general_purpose::STANDARD.encode(recipe_json); println!( @@ -48,9 +49,10 @@ pub fn handle_deeplink(recipe_name: &str) -> Result<()> { recipe.title ); let url_safe = urlencoding::encode(&deeplink); - println!("goose://recipe?config={}", url_safe); + full_url = format!("goose://recipe?config={}", url_safe); + println!("{}", full_url); } - Ok(()) + Ok(full_url) } Err(err) => { println!("{} {}", style("✗").red().bold(), err); @@ -58,3 +60,69 @@ pub fn handle_deeplink(recipe_name: &str) -> Result<()> { } } } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn create_test_recipe_file(dir: &TempDir, filename: &str, content: &str) -> String { + let file_path = dir.path().join(filename); + fs::write(&file_path, content).expect("Failed to write test recipe file"); + file_path.to_string_lossy().into_owned() + } + + const VALID_RECIPE_CONTENT: &str = r#" +title: "Test Recipe" +description: "A test recipe for deeplink generation" +prompt: "Test prompt content" +instructions: "Test instructions" +"#; + + const INVALID_RECIPE_CONTENT: &str = r#" +title: "Test Recipe" +description: "A test recipe for deeplink generation" +prompt: "Test prompt content {{ name }}" +instructions: "Test instructions" +"#; + + #[test] + fn test_handle_deeplink_valid_recipe() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let recipe_path = + create_test_recipe_file(&temp_dir, "test_recipe.yaml", VALID_RECIPE_CONTENT); + + let result = handle_deeplink(&recipe_path); + assert!(result.is_ok()); + assert!(result.unwrap().contains("goose://recipe?config=eyJ2ZXJzaW9uIjoiMS4wLjAiLCJ0aXRsZSI6IlRlc3QgUmVjaXBlIiwiZGVzY3JpcHRpb24iOiJBIHRlc3QgcmVjaXBlIGZvciBkZWVwbGluayBnZW5lcmF0aW9uIiwiaW5zdHJ1Y3Rpb25zIjoiVGVzdCBpbnN0cnVjdGlvbnMiLCJwcm9tcHQiOiJUZXN0IHByb21wdCBjb250ZW50In0%3D")); + } + + #[test] + fn test_handle_deeplink_invalid_recipe() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let recipe_path = + create_test_recipe_file(&temp_dir, "test_recipe.yaml", INVALID_RECIPE_CONTENT); + let result = handle_deeplink(&recipe_path); + assert!(result.is_err()); + } + + #[test] + fn test_handle_validation_valid_recipe() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let recipe_path = + create_test_recipe_file(&temp_dir, "test_recipe.yaml", VALID_RECIPE_CONTENT); + + let result = handle_validate(&recipe_path); + assert!(result.is_ok()); + } + + #[test] + fn test_handle_validation_invalid_recipe() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let recipe_path = + create_test_recipe_file(&temp_dir, "test_recipe.yaml", INVALID_RECIPE_CONTENT); + let result = handle_validate(&recipe_path); + assert!(result.is_err()); + } +} diff --git a/crates/goose-cli/src/recipes/mod.rs b/crates/goose-cli/src/recipes/mod.rs index 9d1ac7021b..4d63c7892f 100644 --- a/crates/goose-cli/src/recipes/mod.rs +++ b/crates/goose-cli/src/recipes/mod.rs @@ -2,3 +2,4 @@ pub mod github_recipe; pub mod print_recipe; pub mod recipe; pub mod search_recipe; +pub mod template_recipe; diff --git a/crates/goose-cli/src/recipes/recipe.rs b/crates/goose-cli/src/recipes/recipe.rs index e1c7717b82..c151f037c1 100644 --- a/crates/goose-cli/src/recipes/recipe.rs +++ b/crates/goose-cli/src/recipes/recipe.rs @@ -3,12 +3,13 @@ use crate::recipes::print_recipe::{ print_required_parameters_for_template, }; use crate::recipes::search_recipe::retrieve_recipe_file; +use crate::recipes::template_recipe::{ + parse_recipe_content, render_recipe_content_with_params, render_recipe_for_preview, +}; use anyhow::Result; use console::style; use goose::recipe::{Recipe, RecipeParameter, RecipeParameterRequirement}; -use minijinja::{Environment, Error, UndefinedBehavior}; use std::collections::{HashMap, HashSet}; -use std::path::{Path, PathBuf}; pub const BUILT_IN_RECIPE_DIR_PARAM: &str = "recipe_dir"; pub const RECIPE_FILE_EXTENSIONS: &[&str] = &["yaml", "json"]; @@ -18,13 +19,13 @@ pub fn load_recipe_content_as_template( params: Vec<(String, String)>, ) -> Result { let (recipe_file_content, recipe_parent_dir) = retrieve_recipe_file(recipe_name)?; - let recipe_parameters = extract_parameters_from_content(&recipe_file_content)?; - - validate_optional_parameters(&recipe_parameters)?; - validate_parameters_in_template(&recipe_parameters, &recipe_file_content)?; + let recipe_dir_str = recipe_parent_dir + .to_str() + .ok_or_else(|| anyhow::anyhow!("Error getting recipe directory"))?; + let recipe_parameters = validate_recipe_parameters(&recipe_file_content, recipe_dir_str)?; let (params_for_template, missing_params) = - apply_values_to_parameters(¶ms, recipe_parameters, recipe_parent_dir, true)?; + apply_values_to_parameters(¶ms, recipe_parameters, recipe_dir_str, true)?; if !missing_params.is_empty() { return Err(anyhow::anyhow!( @@ -33,12 +34,24 @@ pub fn load_recipe_content_as_template( )); } - render_content_with_params(&recipe_file_content, ¶ms_for_template) + render_recipe_content_with_params(&recipe_file_content, ¶ms_for_template) +} + +fn validate_recipe_parameters( + recipe_file_content: &str, + recipe_dir_str: &str, +) -> Result>> { + let (raw_recipe, template_variables) = + parse_recipe_content(recipe_file_content, recipe_dir_str.to_string())?; + let recipe_parameters = raw_recipe.parameters; + validate_optional_parameters(&recipe_parameters)?; + validate_parameters_in_template(&recipe_parameters, &template_variables)?; + Ok(recipe_parameters) } pub fn load_recipe_as_template(recipe_name: &str, params: Vec<(String, String)>) -> Result { let rendered_content = load_recipe_content_as_template(recipe_name, params.clone())?; - let recipe = parse_recipe_content(&rendered_content)?; + let recipe = Recipe::from_content(&rendered_content)?; // Display information about the loaded recipe println!( @@ -57,9 +70,17 @@ pub fn load_recipe_as_template(recipe_name: &str, params: Vec<(String, String)>) } pub fn load_recipe(recipe_name: &str) -> Result { - let (recipe_file_content, _) = retrieve_recipe_file(recipe_name)?; - - validate_recipe_file_parameters(&recipe_file_content) + let (recipe_file_content, recipe_parent_dir) = retrieve_recipe_file(recipe_name)?; + let recipe_dir_str = recipe_parent_dir + .to_str() + .ok_or_else(|| anyhow::anyhow!("Error getting recipe directory"))?; + validate_recipe_parameters(&recipe_file_content, recipe_dir_str)?; + let recipe = render_recipe_for_preview( + &recipe_file_content, + recipe_dir_str.to_string(), + &HashMap::new(), + )?; + Ok(recipe) } pub fn explain_recipe_with_parameters( @@ -67,32 +88,29 @@ pub fn explain_recipe_with_parameters( params: Vec<(String, String)>, ) -> Result<()> { let (recipe_file_content, recipe_parent_dir) = retrieve_recipe_file(recipe_name)?; + let recipe_dir_str = recipe_parent_dir + .to_str() + .ok_or_else(|| anyhow::anyhow!("Error getting recipe directory"))?; + let recipe_parameters = validate_recipe_parameters(&recipe_file_content, recipe_dir_str)?; - let raw_recipe = validate_recipe_file_parameters(&recipe_file_content)?; - print_recipe_explanation(&raw_recipe); - let recipe_parameters = raw_recipe.parameters; let (params_for_template, missing_params) = - apply_values_to_parameters(¶ms, recipe_parameters, recipe_parent_dir, false)?; + apply_values_to_parameters(¶ms, recipe_parameters, recipe_dir_str, false)?; + let recipe = render_recipe_for_preview( + &recipe_file_content, + recipe_dir_str.to_string(), + ¶ms_for_template, + )?; + print_recipe_explanation(&recipe); print_required_parameters_for_template(params_for_template, missing_params); Ok(()) } -fn extract_parameters_from_content(content: &str) -> Result>> { - // If we didn't find a parameter block it might be because it is defined in json style or some such: - if serde_yaml::from_str::(content).is_err() { - return Ok(None); - } - let recipe: Recipe = serde_yaml::from_str(content) - .map_err(|e| anyhow::anyhow!("Valid YAML but invalid Recipe structure: {}", e))?; - Ok(recipe.parameters) -} - fn validate_parameters_in_template( recipe_parameters: &Option>, - recipe_file_content: &str, + template_variables: &HashSet, ) -> Result<()> { - let mut template_variables = extract_template_variables(recipe_file_content)?; + let mut template_variables = template_variables.clone(); template_variables.remove(BUILT_IN_RECIPE_DIR_PARAM); let param_keys: HashSet = recipe_parameters @@ -158,38 +176,16 @@ fn validate_optional_parameters(parameters: &Option>) -> Re } } -fn parse_recipe_content(content: &str) -> Result { - if content.trim().is_empty() { - return Err(anyhow::anyhow!("Recipe content is empty")); - } - serde_yaml::from_str(content) - .map_err(|e| anyhow::anyhow!("Failed to parse recipe content: {}", e)) -} - -fn extract_template_variables(template_str: &str) -> Result> { - let mut env = Environment::new(); - env.set_undefined_behavior(UndefinedBehavior::Strict); - - let template = env - .template_from_str(template_str) - .map_err(|e: Error| anyhow::anyhow!("Invalid template syntax: {}", e.to_string()))?; - - Ok(template.undeclared_variables(true)) -} - fn apply_values_to_parameters( user_params: &[(String, String)], recipe_parameters: Option>, - recipe_parent_dir: PathBuf, + recipe_parent_dir: &str, enable_user_prompt: bool, ) -> Result<(HashMap, Vec)> { let mut param_map: HashMap = user_params.iter().cloned().collect(); - let recipe_parent_dir_str = recipe_parent_dir - .to_str() - .ok_or_else(|| anyhow::anyhow!("Invalid UTF-8 in recipe_dir"))?; param_map.insert( BUILT_IN_RECIPE_DIR_PARAM.to_string(), - recipe_parent_dir_str.to_string(), + recipe_parent_dir.to_string(), ); let mut missing_params: Vec = Vec::new(); for param in recipe_parameters.unwrap_or_default() { @@ -214,362 +210,5 @@ fn apply_values_to_parameters( Ok((param_map, missing_params)) } -fn render_content_with_params(content: &str, params: &HashMap) -> Result { - let mut env = minijinja::Environment::new(); - env.set_undefined_behavior(UndefinedBehavior::Strict); - - if let Some(recipe_dir) = params.get("recipe_dir") { - let recipe_dir = recipe_dir.clone(); - env.set_loader(move |name| { - let path = Path::new(&recipe_dir).join(name); - match std::fs::read_to_string(&path) { - Ok(content) => Ok(Some(content)), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(e) => Err(minijinja::Error::new( - minijinja::ErrorKind::InvalidOperation, - "could not read template", - ) - .with_source(e)), - } - }); - } - - let template = env - .template_from_str(content) - .map_err(|e| anyhow::anyhow!("Invalid template syntax: {}", e))?; - - template - .render(params) - .map_err(|e| anyhow::anyhow!("Failed to render the recipe {}", e)) -} - -fn validate_recipe_file_parameters(recipe_file_content: &str) -> Result { - let recipe_from_recipe_file: Recipe = parse_recipe_content(recipe_file_content)?; - let parameters = extract_parameters_from_content(recipe_file_content)?; - validate_optional_parameters(¶meters)?; - validate_parameters_in_template(¶meters, recipe_file_content)?; - Ok(recipe_from_recipe_file) -} - #[cfg(test)] -mod tests { - use std::path::PathBuf; - - use goose::recipe::{RecipeParameterInputType, RecipeParameterRequirement}; - use tempfile::TempDir; - - use super::*; - - fn setup_recipe_file(instructions_and_parameters: &str) -> (TempDir, PathBuf) { - let recipe_content = format!( - r#"{{ - "version": "1.0.0", - "title": "Test Recipe", - "description": "A test recipe", - {} - }}"#, - instructions_and_parameters - ); - let temp_dir = tempfile::tempdir().unwrap(); - let recipe_path: std::path::PathBuf = temp_dir.path().join("test_recipe.json"); - - std::fs::write(&recipe_path, recipe_content).unwrap(); - (temp_dir, recipe_path) - } - - #[test] - fn test_render_content_with_params() { - // Test basic parameter substitution - let content = "Hello {{ name }}!"; - let mut params = HashMap::new(); - params.insert("name".to_string(), "World".to_string()); - let result = render_content_with_params(content, ¶ms).unwrap(); - assert_eq!(result, "Hello World!"); - - // Test empty parameter substitution - let content = "Hello {{ empty }}!"; - let mut params = HashMap::new(); - params.insert("empty".to_string(), "".to_string()); - let result = render_content_with_params(content, ¶ms).unwrap(); - assert_eq!(result, "Hello !"); - - // Test multiple parameters - let content = "{{ greeting }} {{ name }}!"; - let mut params = HashMap::new(); - params.insert("greeting".to_string(), "Hi".to_string()); - params.insert("name".to_string(), "Alice".to_string()); - let result = render_content_with_params(content, ¶ms).unwrap(); - assert_eq!(result, "Hi Alice!"); - - // Test missing parameter results in error - let content = "Hello {{ missing }}!"; - let params = HashMap::new(); - let err = render_content_with_params(content, ¶ms).unwrap_err(); - let error_msg = err.to_string(); - assert!(error_msg.contains("Failed to render the recipe")); - - // Test invalid template syntax results in error - let content = "Hello {{ unclosed"; - let params = HashMap::new(); - let err = render_content_with_params(content, ¶ms).unwrap_err(); - assert!(err.to_string().contains("Invalid template syntax")); - } - - #[test] - fn test_load_recipe_as_template_success() { - let instructions_and_parameters = r#" - "instructions": "Test instructions with {{ my_name }}", - "parameters": [ - { - "key": "my_name", - "input_type": "string", - "requirement": "required", - "description": "A test parameter" - } - ]"#; - - let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters); - - let params = vec![("my_name".to_string(), "value".to_string())]; - let recipe = load_recipe_as_template(recipe_path.to_str().unwrap(), params).unwrap(); - - assert_eq!(recipe.title, "Test Recipe"); - assert_eq!(recipe.description, "A test recipe"); - assert_eq!(recipe.instructions.unwrap(), "Test instructions with value"); - // Verify parameters match recipe definition - assert_eq!(recipe.parameters.as_ref().unwrap().len(), 1); - let param = &recipe.parameters.as_ref().unwrap()[0]; - assert_eq!(param.key, "my_name"); - assert!(matches!(param.input_type, RecipeParameterInputType::String)); - assert!(matches!( - param.requirement, - RecipeParameterRequirement::Required - )); - assert_eq!(param.description, "A test parameter"); - } - - #[test] - fn test_load_recipe_as_template_success_variable_in_prompt() { - let instructions_and_parameters = r#" - "instructions": "Test instructions", - "prompt": "My prompt {{ my_name }}", - "parameters": [ - { - "key": "my_name", - "input_type": "string", - "requirement": "required", - "description": "A test parameter" - } - ]"#; - - let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters); - - let params = vec![("my_name".to_string(), "value".to_string())]; - let recipe = load_recipe_as_template(recipe_path.to_str().unwrap(), params).unwrap(); - - assert_eq!(recipe.title, "Test Recipe"); - assert_eq!(recipe.description, "A test recipe"); - assert_eq!(recipe.instructions.unwrap(), "Test instructions"); - assert_eq!(recipe.prompt.unwrap(), "My prompt value"); - let param = &recipe.parameters.as_ref().unwrap()[0]; - assert_eq!(param.key, "my_name"); - assert!(matches!(param.input_type, RecipeParameterInputType::String)); - assert!(matches!( - param.requirement, - RecipeParameterRequirement::Required - )); - assert_eq!(param.description, "A test parameter"); - } - - #[test] - fn test_load_recipe_as_template_wrong_parameters_in_recipe_file() { - let instructions_and_parameters = r#" - "instructions": "Test instructions with {{ expected_param1 }} {{ expected_param2 }}", - "parameters": [ - { - "key": "wrong_param_key", - "input_type": "string", - "requirement": "required", - "description": "A test parameter" - } - ]"#; - let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters); - - let load_recipe_result = load_recipe_as_template(recipe_path.to_str().unwrap(), Vec::new()); - assert!(load_recipe_result.is_err()); - let err = load_recipe_result.unwrap_err(); - println!("{}", err.to_string()); - assert!(err - .to_string() - .contains("Unnecessary parameter definitions: wrong_param_key.")); - assert!(err - .to_string() - .contains("Missing definitions for parameters in the recipe file:")); - assert!(err.to_string().contains("expected_param1")); - assert!(err.to_string().contains("expected_param2")); - } - - #[test] - fn test_load_recipe_as_template_with_default_values_in_recipe_file() { - let instructions_and_parameters = r#" - "instructions": "Test instructions with {{ param_with_default }} {{ param_without_default }}", - "parameters": [ - { - "key": "param_with_default", - "input_type": "string", - "requirement": "optional", - "default": "my_default_value", - "description": "A test parameter" - }, - { - "key": "param_without_default", - "input_type": "string", - "requirement": "required", - "description": "A test parameter" - } - ]"#; - let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters); - let params = vec![("param_without_default".to_string(), "value1".to_string())]; - - let recipe = load_recipe_as_template(recipe_path.to_str().unwrap(), params).unwrap(); - - assert_eq!(recipe.title, "Test Recipe"); - assert_eq!(recipe.description, "A test recipe"); - assert_eq!( - recipe.instructions.unwrap(), - "Test instructions with my_default_value value1" - ); - } - - #[test] - fn test_load_recipe_as_template_optional_parameters_with_empty_default_values_in_recipe_file() { - let instructions_and_parameters = r#" - "instructions": "Test instructions with {{ optional_param }}", - "parameters": [ - { - "key": "optional_param", - "input_type": "string", - "requirement": "optional", - "description": "A test parameter", - "default": "", - } - ]"#; - let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters); - - let recipe = load_recipe_as_template(recipe_path.to_str().unwrap(), Vec::new()).unwrap(); - assert_eq!(recipe.title, "Test Recipe"); - assert_eq!(recipe.description, "A test recipe"); - assert_eq!(recipe.instructions.unwrap(), "Test instructions with "); - } - - #[test] - fn test_load_recipe_as_template_optional_parameters_without_default_values_in_recipe_file() { - let instructions_and_parameters = r#" - "instructions": "Test instructions with {{ optional_param }}", - "parameters": [ - { - "key": "optional_param", - "input_type": "string", - "requirement": "optional", - "description": "A test parameter" - } - ]"#; - let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters); - - let load_recipe_result = load_recipe_as_template(recipe_path.to_str().unwrap(), Vec::new()); - assert!(load_recipe_result.is_err()); - let err = load_recipe_result.unwrap_err(); - println!("{}", err.to_string()); - assert!(err.to_string().to_lowercase().contains("missing")); - } - - #[test] - fn test_load_recipe_as_template_wrong_input_type_in_recipe_file() { - let instructions_and_parameters = r#" - "instructions": "Test instructions with {{ param }}", - "parameters": [ - { - "key": "param", - "input_type": "some_invalid_type", - "requirement": "required", - "description": "A test parameter" - } - ]"#; - let params = vec![("param".to_string(), "value".to_string())]; - let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters); - - let load_recipe_result = load_recipe_as_template(recipe_path.to_str().unwrap(), params); - assert!(load_recipe_result.is_err()); - let err = load_recipe_result.unwrap_err(); - let err_msg = err.to_string(); - eprint!("Error: {}", err_msg); - assert!(err_msg.contains("unknown variant `some_invalid_type`")); - } - - #[test] - fn test_load_recipe_as_template_success_without_parameters() { - let instructions_and_parameters = r#" - "instructions": "Test instructions" - "#; - let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters); - - let recipe = load_recipe_as_template(recipe_path.to_str().unwrap(), Vec::new()).unwrap(); - assert_eq!(recipe.instructions.unwrap(), "Test instructions"); - assert!(recipe.parameters.is_none()); - } - - #[test] - fn test_template_inheritance() { - let temp_dir = tempfile::tempdir().unwrap(); - let temp_path = temp_dir.path(); - let parent_content = r#" - version: 1.0.0 - title: Parent - description: Parent recipe - prompt: | - show me the news for day: {{ date }} - {% block prompt -%} - What is the capital of France? - {%- endblock %} - parameters: - - key: date - input_type: string - requirement: required - description: date specified by the user - "#; - - let parent_path = temp_path.join("parent.yaml"); - std::fs::write(&parent_path, parent_content).unwrap(); - let child_content = r#" - {% extends "parent.yaml" -%} - {% block prompt -%} - What is the capital of Germany? - {%- endblock %} - "#; - let child_path = temp_path.join("child.yaml"); - std::fs::write(&child_path, child_content).unwrap(); - - let params = vec![("date".to_string(), "today".to_string())]; - let parent_result = load_recipe_as_template(parent_path.to_str().unwrap(), params.clone()); - assert!(parent_result.is_ok()); - let parent_recipe = parent_result.unwrap(); - assert_eq!(parent_recipe.description, "Parent recipe"); - assert_eq!( - parent_recipe.prompt.unwrap(), - "show me the news for day: today\nWhat is the capital of France?\n" - ); - assert_eq!(parent_recipe.parameters.as_ref().unwrap().len(), 1); - assert_eq!(parent_recipe.parameters.as_ref().unwrap()[0].key, "date"); - - let child_result = load_recipe_as_template(child_path.to_str().unwrap(), params); - - assert!(child_result.is_ok()); - let child_recipe = child_result.unwrap(); - assert_eq!(child_recipe.title, "Parent"); - assert_eq!(child_recipe.description, "Parent recipe"); - assert_eq!( - child_recipe.prompt.unwrap().trim(), - "show me the news for day: today\nWhat is the capital of Germany?" - ); - } -} +mod tests; diff --git a/crates/goose-cli/src/recipes/recipe/tests.rs b/crates/goose-cli/src/recipes/recipe/tests.rs new file mode 100644 index 0000000000..4b27601ffd --- /dev/null +++ b/crates/goose-cli/src/recipes/recipe/tests.rs @@ -0,0 +1,315 @@ +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use goose::recipe::{RecipeParameterInputType, RecipeParameterRequirement}; + use tempfile::TempDir; + + use crate::recipes::recipe::load_recipe_as_template; + + fn setup_recipe_file(instructions_and_parameters: &str) -> (TempDir, PathBuf) { + let recipe_content = format!( + r#"{{ + "version": "1.0.0", + "title": "Test Recipe", + "description": "A test recipe", + {} + }}"#, + instructions_and_parameters + ); + let temp_dir = tempfile::tempdir().unwrap(); + let recipe_path: std::path::PathBuf = temp_dir.path().join("test_recipe.json"); + + std::fs::write(&recipe_path, recipe_content).unwrap(); + (temp_dir, recipe_path) + } + + mod load_recipe_as_template_tests { + use super::*; + #[test] + fn test_load_recipe_as_template_success() { + let instructions_and_parameters = r#" + "instructions": "Test instructions with {{ my_name }}", + "parameters": [ + { + "key": "my_name", + "input_type": "string", + "requirement": "required", + "description": "A test parameter" + } + ]"#; + + let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters); + + let params = vec![("my_name".to_string(), "value".to_string())]; + let recipe = load_recipe_as_template(recipe_path.to_str().unwrap(), params).unwrap(); + + assert_eq!(recipe.title, "Test Recipe"); + assert_eq!(recipe.description, "A test recipe"); + assert_eq!(recipe.instructions.unwrap(), "Test instructions with value"); + // Verify parameters match recipe definition + assert_eq!(recipe.parameters.as_ref().unwrap().len(), 1); + let param = &recipe.parameters.as_ref().unwrap()[0]; + assert_eq!(param.key, "my_name"); + assert!(matches!(param.input_type, RecipeParameterInputType::String)); + assert!(matches!( + param.requirement, + RecipeParameterRequirement::Required + )); + assert_eq!(param.description, "A test parameter"); + } + + #[test] + fn test_load_recipe_as_template_success_variable_in_prompt() { + let instructions_and_parameters = r#" + "instructions": "Test instructions", + "prompt": "My prompt {{ my_name }}", + "parameters": [ + { + "key": "my_name", + "input_type": "string", + "requirement": "required", + "description": "A test parameter" + } + ]"#; + + let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters); + + let params = vec![("my_name".to_string(), "value".to_string())]; + let recipe = load_recipe_as_template(recipe_path.to_str().unwrap(), params).unwrap(); + + assert_eq!(recipe.title, "Test Recipe"); + assert_eq!(recipe.description, "A test recipe"); + assert_eq!(recipe.instructions.unwrap(), "Test instructions"); + assert_eq!(recipe.prompt.unwrap(), "My prompt value"); + let param = &recipe.parameters.as_ref().unwrap()[0]; + assert_eq!(param.key, "my_name"); + assert!(matches!(param.input_type, RecipeParameterInputType::String)); + assert!(matches!( + param.requirement, + RecipeParameterRequirement::Required + )); + assert_eq!(param.description, "A test parameter"); + } + + #[test] + fn test_load_recipe_as_template_wrong_parameters_in_recipe_file() { + let instructions_and_parameters = r#" + "instructions": "Test instructions with {{ expected_param1 }} {{ expected_param2 }}", + "parameters": [ + { + "key": "wrong_param_key", + "input_type": "string", + "requirement": "required", + "description": "A test parameter" + } + ]"#; + let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters); + + let load_recipe_result = + load_recipe_as_template(recipe_path.to_str().unwrap(), Vec::new()); + assert!(load_recipe_result.is_err()); + let err = load_recipe_result.unwrap_err(); + println!("{}", err.to_string()); + assert!(err + .to_string() + .contains("Unnecessary parameter definitions: wrong_param_key.")); + assert!(err + .to_string() + .contains("Missing definitions for parameters in the recipe file:")); + assert!(err.to_string().contains("expected_param1")); + assert!(err.to_string().contains("expected_param2")); + } + + #[test] + fn test_load_recipe_as_template_with_default_values_in_recipe_file() { + let instructions_and_parameters = r#" + "instructions": "Test instructions with {{ param_with_default }} {{ param_without_default }}", + "parameters": [ + { + "key": "param_with_default", + "input_type": "string", + "requirement": "optional", + "default": "my_default_value", + "description": "A test parameter" + }, + { + "key": "param_without_default", + "input_type": "string", + "requirement": "required", + "description": "A test parameter" + } + ]"#; + let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters); + let params = vec![("param_without_default".to_string(), "value1".to_string())]; + + let recipe = load_recipe_as_template(recipe_path.to_str().unwrap(), params).unwrap(); + + assert_eq!(recipe.title, "Test Recipe"); + assert_eq!(recipe.description, "A test recipe"); + assert_eq!( + recipe.instructions.unwrap(), + "Test instructions with my_default_value value1" + ); + } + + #[test] + fn test_load_recipe_as_template_optional_parameters_with_empty_default_values_in_recipe_file( + ) { + let instructions_and_parameters = r#" + "instructions": "Test instructions with {{ optional_param }}", + "parameters": [ + { + "key": "optional_param", + "input_type": "string", + "requirement": "optional", + "description": "A test parameter", + "default": "", + } + ]"#; + let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters); + + let recipe = + load_recipe_as_template(recipe_path.to_str().unwrap(), Vec::new()).unwrap(); + assert_eq!(recipe.title, "Test Recipe"); + assert_eq!(recipe.description, "A test recipe"); + assert_eq!(recipe.instructions.unwrap(), "Test instructions with "); + } + + #[test] + fn test_load_recipe_as_template_optional_parameters_without_default_values_in_recipe_file() + { + let instructions_and_parameters = r#" + "instructions": "Test instructions with {{ optional_param }}", + "parameters": [ + { + "key": "optional_param", + "input_type": "string", + "requirement": "optional", + "description": "A test parameter" + } + ]"#; + let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters); + + let load_recipe_result = + load_recipe_as_template(recipe_path.to_str().unwrap(), Vec::new()); + assert!(load_recipe_result.is_err()); + let err = load_recipe_result.unwrap_err(); + println!("{}", err.to_string()); + assert!(err.to_string().to_lowercase().contains("missing")); + } + + #[test] + fn test_load_recipe_as_template_wrong_input_type_in_recipe_file() { + let instructions_and_parameters = r#" + "instructions": "Test instructions with {{ param }}", + "parameters": [ + { + "key": "param", + "input_type": "some_invalid_type", + "requirement": "required", + "description": "A test parameter" + } + ]"#; + let params = vec![("param".to_string(), "value".to_string())]; + let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters); + + let load_recipe_result = load_recipe_as_template(recipe_path.to_str().unwrap(), params); + assert!(load_recipe_result.is_err()); + let err = load_recipe_result.unwrap_err(); + let err_msg = err.to_string(); + eprint!("Error: {}", err_msg); + assert!(err_msg.contains("unknown variant `some_invalid_type`")); + } + + #[test] + fn test_load_recipe_as_template_success_without_parameters() { + let instructions_and_parameters = r#" + "instructions": "Test instructions" + "#; + let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters); + + let recipe = + load_recipe_as_template(recipe_path.to_str().unwrap(), Vec::new()).unwrap(); + assert_eq!(recipe.instructions.unwrap(), "Test instructions"); + assert!(recipe.parameters.is_none()); + } + + #[test] + fn test_template_inheritance() { + let temp_dir = tempfile::tempdir().unwrap(); + let temp_path = temp_dir.path(); + let parent_content = r#" + version: 1.0.0 + title: Parent + description: Parent recipe + prompt: | + show me the news for day: {{ date }} + {% block prompt -%} + What is the capital of France? + {%- endblock %} + {% if is_enabled %} + Feature is enabled. + {% else %} + Feature is disabled. + {% endif %} + parameters: + - key: date + input_type: string + requirement: required + description: date specified by the user + - key: is_enabled + input_type: boolean + requirement: required + description: whether the feature is enabled + "#; + + let parent_path = temp_path.join("parent.yaml"); + std::fs::write(&parent_path, parent_content).unwrap(); + let child_content = r#" + {% extends "parent.yaml" -%} + {% block prompt -%} + What is the capital of Germany? + {%- endblock %} + "#; + let child_path = temp_path.join("child.yaml"); + std::fs::write(&child_path, child_content).unwrap(); + + let params = vec![ + ("date".to_string(), "today".to_string()), + ("is_enabled".to_string(), "true".to_string()), + ]; + let parent_result = + load_recipe_as_template(parent_path.to_str().unwrap(), params.clone()); + assert!(parent_result.is_ok()); + let parent_recipe = parent_result.unwrap(); + assert_eq!(parent_recipe.description, "Parent recipe"); + assert_eq!( + parent_recipe.prompt.unwrap(), + "show me the news for day: today\nWhat is the capital of France?\n\n Feature is enabled.\n" + ); + assert_eq!(parent_recipe.parameters.as_ref().unwrap().len(), 2); + assert_eq!(parent_recipe.parameters.as_ref().unwrap()[0].key, "date"); + assert_eq!( + parent_recipe.parameters.as_ref().unwrap()[1].key, + "is_enabled" + ); + + let child_result = load_recipe_as_template(child_path.to_str().unwrap(), params); + assert!(child_result.is_ok()); + let child_recipe = child_result.unwrap(); + assert_eq!(child_recipe.title, "Parent"); + assert_eq!(child_recipe.description, "Parent recipe"); + assert_eq!( + child_recipe.prompt.unwrap().trim(), + "show me the news for day: today\nWhat is the capital of Germany?\n\n Feature is enabled." + ); + assert_eq!(child_recipe.parameters.as_ref().unwrap().len(), 2); + assert_eq!(child_recipe.parameters.as_ref().unwrap()[0].key, "date"); + assert_eq!( + child_recipe.parameters.as_ref().unwrap()[1].key, + "is_enabled" + ); + } + } +} diff --git a/crates/goose-cli/src/recipes/template_recipe.rs b/crates/goose-cli/src/recipes/template_recipe.rs new file mode 100644 index 0000000000..2792f20ba6 --- /dev/null +++ b/crates/goose-cli/src/recipes/template_recipe.rs @@ -0,0 +1,172 @@ +use std::{ + collections::{HashMap, HashSet}, + path::Path, +}; + +use anyhow::Result; +use goose::recipe::Recipe; +use minijinja::{Environment, UndefinedBehavior}; + +use crate::recipes::recipe::BUILT_IN_RECIPE_DIR_PARAM; + +const CURRENT_TEMPLATE_NAME: &str = "current_template"; + +pub fn render_recipe_content_with_params( + content: &str, + params: &HashMap, +) -> Result { + let env = add_template_in_env( + content, + params.get(BUILT_IN_RECIPE_DIR_PARAM).unwrap().clone(), + UndefinedBehavior::Strict, + )?; + let template = env.get_template(CURRENT_TEMPLATE_NAME).unwrap(); + let rendered_content = template + .render(params) + .map_err(|e| anyhow::anyhow!("Failed to render the recipe {}", e))?; + Ok(rendered_content) +} + +pub fn render_recipe_silent_when_variables_are_provided( + content: &str, + params: &HashMap, +) -> Result { + let mut env = minijinja::Environment::new(); + env.set_undefined_behavior(UndefinedBehavior::Lenient); + let template = env.template_from_str(content)?; + let rendered_content = template.render(params)?; + Ok(rendered_content) +} + +fn add_template_in_env( + content: &str, + recipe_dir: String, + undefined_behavior: UndefinedBehavior, +) -> Result { + let mut env = minijinja::Environment::new(); + env.set_undefined_behavior(undefined_behavior); + env.set_loader(move |name| { + let path = Path::new(recipe_dir.as_str()).join(name); + match std::fs::read_to_string(&path) { + Ok(content) => Ok(Some(content)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(minijinja::Error::new( + minijinja::ErrorKind::InvalidOperation, + "could not read template", + ) + .with_source(e)), + } + }); + + env.add_template(CURRENT_TEMPLATE_NAME, content)?; + Ok(env) +} + +fn get_env_with_template_variables( + content: &str, + recipe_dir: String, + undefined_behavior: UndefinedBehavior, +) -> Result<(Environment, HashSet)> { + let env = add_template_in_env(content, recipe_dir, undefined_behavior)?; + let template = env.get_template(CURRENT_TEMPLATE_NAME).unwrap(); + let state = template.eval_to_state(())?; + let mut template_variables = HashSet::new(); + for (_, template) in state.env().templates() { + template_variables.extend(template.undeclared_variables(true)); + } + Ok((env, template_variables)) +} + +pub fn parse_recipe_content( + content: &str, + recipe_dir: String, +) -> Result<(Recipe, HashSet)> { + let (env, template_variables) = + get_env_with_template_variables(content, recipe_dir, UndefinedBehavior::Lenient)?; + let template = env.get_template(CURRENT_TEMPLATE_NAME).unwrap(); + let rendered_content = template + .render(()) + .map_err(|e| anyhow::anyhow!("Failed to parse the recipe {}", e))?; + let recipe = Recipe::from_content(&rendered_content)?; + // return recipe (without loading any variables) and the variable names that are in the recipe + Ok((recipe, template_variables)) +} + +// render the recipe for validation, deeplink and explain, etc. +pub fn render_recipe_for_preview( + content: &str, + recipe_dir: String, + params: &HashMap, +) -> Result { + let (env, template_variables) = + get_env_with_template_variables(content, recipe_dir, UndefinedBehavior::Lenient)?; + let template = env.get_template(CURRENT_TEMPLATE_NAME).unwrap(); + // if the variables are not provided, the template will be rendered with the variables, otherwise it will keep the variables as is + let mut ctx = preserve_vars(&template_variables).clone(); + ctx.extend(params.clone()); + let rendered_content = template + .render(ctx) + .map_err(|e| anyhow::anyhow!("Failed to parse the recipe {}", e))?; + Recipe::from_content(&rendered_content) +} + +fn preserve_vars(variables: &HashSet) -> HashMap { + let mut context = HashMap::::new(); + for template_var in variables { + context.insert(template_var.clone(), format!("{{{{ {} }}}}", template_var)); + } + context +} + +#[cfg(test)] +mod tests { + mod render_content_with_params_tests { + use std::collections::HashMap; + + use crate::recipes::template_recipe::render_recipe_content_with_params; + + #[test] + fn test_render_content_with_params() { + // Test basic parameter substitution + let content = "Hello {{ name }}!"; + let params = HashMap::from([ + ("recipe_dir".to_string(), "some_dir".to_string()), + ("name".to_string(), "World".to_string()), + ]); + let result = render_recipe_content_with_params(content, ¶ms).unwrap(); + assert_eq!(result, "Hello World!"); + + // Test empty parameter substitution + let content = "Hello {{ empty }}!"; + let params = HashMap::from([ + ("recipe_dir".to_string(), "some_dir".to_string()), + ("empty".to_string(), "".to_string()), + ]); + let result = render_recipe_content_with_params(content, ¶ms).unwrap(); + assert_eq!(result, "Hello !"); + + // Test multiple parameters + let content = "{{ greeting }} {{ name }}!"; + let params = HashMap::from([ + ("recipe_dir".to_string(), "some_dir".to_string()), + ("greeting".to_string(), "Hi".to_string()), + ("name".to_string(), "Alice".to_string()), + ]); + let result = render_recipe_content_with_params(content, ¶ms).unwrap(); + assert_eq!(result, "Hi Alice!"); + + // Test missing parameter results in error + let content = "Hello {{ missing }}!"; + let params = HashMap::from([("recipe_dir".to_string(), "some_dir".to_string())]); + let err = render_recipe_content_with_params(content, ¶ms).unwrap_err(); + let error_msg = err.to_string(); + assert!(error_msg.contains("Failed to render the recipe")); + + // Test invalid template syntax results in error + let content = "Hello {{ unclosed"; + let params = HashMap::from([("recipe_dir".to_string(), "some_dir".to_string())]); + let err = render_recipe_content_with_params(content, ¶ms).unwrap_err(); + assert!(err.to_string().contains("unexpected end of input")); + } + } +} diff --git a/crates/goose-cli/src/session/builder.rs b/crates/goose-cli/src/session/builder.rs index 15397ab113..32002eff7a 100644 --- a/crates/goose-cli/src/session/builder.rs +++ b/crates/goose-cli/src/session/builder.rs @@ -3,6 +3,7 @@ use goose::agents::extension::ExtensionError; use goose::agents::Agent; use goose::config::{Config, ExtensionConfig, ExtensionConfigManager}; use goose::providers::create; +use goose::recipe::SubRecipe; use goose::session; use goose::session::Identifier; use mcp_client::transport::Error as McpClientError; @@ -46,6 +47,8 @@ pub struct SessionBuilderConfig { pub interactive: bool, /// Quiet mode - suppress non-response output pub quiet: bool, + /// Sub-recipes to add to the session + pub sub_recipes: Option>, } /// Offers to help debug an extension failure by creating a minimal debugging session @@ -174,6 +177,9 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> Session { // Create the agent let agent: Agent = Agent::new(); + if let Some(sub_recipes) = session_config.sub_recipes { + agent.add_sub_recipes(sub_recipes).await; + } let new_provider = match create(&provider_name, model_config) { Ok(provider) => provider, Err(e) => { @@ -216,7 +222,7 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> Session { } // Handle session file resolution and resuming - let session_file = if session_config.no_session { + let session_file: std::path::PathBuf = if session_config.no_session { // Use a temporary path that won't be written to #[cfg(unix)] { @@ -500,6 +506,7 @@ mod tests { scheduled_job_id: None, interactive: true, quiet: false, + sub_recipes: None, }; assert_eq!(config.extensions.len(), 1); diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index e5d1167e74..2c36da7561 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -10,13 +10,14 @@ use futures_util::stream; use futures_util::stream::StreamExt; use mcp_core::protocol::JsonRpcMessage; +use crate::agents::sub_recipe_manager::SubRecipeManager; use crate::config::{Config, ExtensionConfigManager, PermissionManager}; use crate::message::Message; use crate::permission::permission_judge::check_tool_permissions; use crate::permission::PermissionConfirmation; use crate::providers::base::Provider; use crate::providers::errors::ProviderError; -use crate::recipe::{Author, Recipe, Settings}; +use crate::recipe::{Author, Recipe, Settings, SubRecipe}; use crate::scheduler_trait::SchedulerTrait; use crate::tool_monitor::{ToolCall, ToolMonitor}; use regex::Regex; @@ -52,6 +53,7 @@ use super::tool_execution::{ToolCallResult, CHAT_MODE_TOOL_SKIPPED_RESPONSE, DEC pub struct Agent { pub(super) provider: Mutex>>, pub(super) extension_manager: Mutex, + pub(super) sub_recipe_manager: Mutex, pub(super) frontend_tools: Mutex>, pub(super) frontend_instructions: Mutex>, pub(super) prompt_manager: Mutex, @@ -80,6 +82,7 @@ impl Agent { Self { provider: Mutex::new(None), extension_manager: Mutex::new(ExtensionManager::new()), + sub_recipe_manager: Mutex::new(SubRecipeManager::new()), frontend_tools: Mutex::new(HashMap::new()), frontend_instructions: Mutex::new(None), prompt_manager: Mutex::new(PromptManager::new()), @@ -193,6 +196,11 @@ impl Agent { Ok(tools) } + pub async fn add_sub_recipes(&self, sub_recipes: Vec) { + let mut sub_recipe_manager = self.sub_recipe_manager.lock().await; + sub_recipe_manager.add_sub_recipe_tools(sub_recipes); + } + /// Dispatch a single tool call to the appropriate client #[instrument(skip(self, tool_call, request_id), fields(input, output))] pub async fn dispatch_tool_call( @@ -242,7 +250,13 @@ impl Agent { } let extension_manager = self.extension_manager.lock().await; - let result: ToolCallResult = if tool_call.name == PLATFORM_READ_RESOURCE_TOOL_NAME { + let sub_recipe_manager = self.sub_recipe_manager.lock().await; + + let result: ToolCallResult = if sub_recipe_manager.is_sub_recipe_tool(&tool_call.name) { + sub_recipe_manager + .dispatch_sub_recipe_tool_call(&tool_call.name, tool_call.arguments.clone()) + .await + } else if tool_call.name == PLATFORM_READ_RESOURCE_TOOL_NAME { // Check if the tool is read_resource and handle it separately ToolCallResult::from( extension_manager @@ -465,17 +479,26 @@ impl Agent { if extension_name.is_none() || extension_name.as_deref() == Some("platform") { // Add platform tools - prefixed_tools.push(platform_tools::search_available_extensions_tool()); - prefixed_tools.push(platform_tools::manage_extensions_tool()); - prefixed_tools.push(platform_tools::manage_schedule_tool()); + prefixed_tools.extend([ + platform_tools::search_available_extensions_tool(), + platform_tools::manage_extensions_tool(), + platform_tools::manage_schedule_tool(), + ]); // Add resource tools if supported if extension_manager.supports_resources() { - prefixed_tools.push(platform_tools::read_resource_tool()); - prefixed_tools.push(platform_tools::list_resources_tool()); + prefixed_tools.extend([ + platform_tools::read_resource_tool(), + platform_tools::list_resources_tool(), + ]); } } + if extension_name.is_none() { + let sub_recipe_manager = self.sub_recipe_manager.lock().await; + prefixed_tools.extend(sub_recipe_manager.sub_recipe_tools.values().cloned()); + } + prefixed_tools } diff --git a/crates/goose/src/agents/mod.rs b/crates/goose/src/agents/mod.rs index bfd51217ae..158a7c2c58 100644 --- a/crates/goose/src/agents/mod.rs +++ b/crates/goose/src/agents/mod.rs @@ -5,11 +5,12 @@ pub mod extension_manager; mod large_response_handler; pub mod platform_tools; pub mod prompt_manager; +mod recipe_tools; mod reply_parts; mod router_tool_selector; mod router_tools; mod schedule_tool; - +pub mod sub_recipe_manager; mod tool_execution; mod tool_router_index_manager; pub(crate) mod tool_vectordb; diff --git a/crates/goose/src/agents/recipe_tools/mod.rs b/crates/goose/src/agents/recipe_tools/mod.rs new file mode 100644 index 0000000000..5f2f95fc84 --- /dev/null +++ b/crates/goose/src/agents/recipe_tools/mod.rs @@ -0,0 +1 @@ +pub mod sub_recipe_tools; diff --git a/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs b/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs new file mode 100644 index 0000000000..2fd4f50434 --- /dev/null +++ b/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs @@ -0,0 +1,167 @@ +use std::{collections::HashMap, fs}; + +use anyhow::Result; +use mcp_core::tool::{Tool, ToolAnnotations}; +use serde_json::{json, Map, Value}; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::Command; + +use crate::recipe::{Recipe, RecipeParameter, RecipeParameterRequirement, SubRecipe}; + +pub const SUB_RECIPE_TOOL_NAME_PREFIX: &str = "subrecipe__run_"; + +pub fn create_sub_recipe_tool(sub_recipe: &SubRecipe) -> Tool { + let input_schema = get_input_schema(sub_recipe).unwrap(); + Tool::new( + format!("{}_{}", SUB_RECIPE_TOOL_NAME_PREFIX, sub_recipe.name), + "Run a sub recipe. + Use this tool when you need to run a sub-recipe. + The sub recipe will be run with the provided parameters + and return the output of the sub recipe." + .to_string(), + input_schema, + Some(ToolAnnotations { + title: Some(format!("run sub recipe {}", sub_recipe.name)), + read_only_hint: false, + destructive_hint: true, + idempotent_hint: false, + open_world_hint: true, + }), + ) +} + +fn get_sub_recipe_parameter_definition( + sub_recipe: &SubRecipe, +) -> Result>> { + let content = fs::read_to_string(sub_recipe.path.clone()) + .map_err(|e| anyhow::anyhow!("Failed to read recipe file {}: {}", sub_recipe.path, e))?; + let recipe = Recipe::from_content(&content)?; + Ok(recipe.parameters) +} + +fn get_input_schema(sub_recipe: &SubRecipe) -> Result { + let mut sub_recipe_params_map = HashMap::::new(); + if let Some(params_with_value) = &sub_recipe.values { + for (param_name, param_value) in params_with_value { + sub_recipe_params_map.insert(param_name.clone(), param_value.clone()); + } + } + let parameter_definition = get_sub_recipe_parameter_definition(sub_recipe)?; + if let Some(parameters) = parameter_definition { + let mut properties = Map::new(); + let mut required = Vec::new(); + for param in parameters { + if sub_recipe_params_map.contains_key(¶m.key) { + continue; + } + properties.insert( + param.key.clone(), + json!({ + "type": param.input_type.to_string(), + "description": param.description.clone(), + }), + ); + if !matches!(param.requirement, RecipeParameterRequirement::Optional) { + required.push(param.key); + } + } + Ok(json!({ + "type": "object", + "properties": properties, + "required": required + })) + } else { + Ok(json!({ + "type": "object", + "properties": {} + })) + } +} + +fn prepare_command_params( + sub_recipe: &SubRecipe, + params_from_tool_call: Value, +) -> Result> { + let mut sub_recipe_params = HashMap::::new(); + if let Some(params_with_value) = &sub_recipe.values { + for (param_name, param_value) in params_with_value { + sub_recipe_params.insert(param_name.clone(), param_value.clone()); + } + } + if let Some(params_map) = params_from_tool_call.as_object() { + for (key, value) in params_map { + sub_recipe_params.insert( + key.to_string(), + value.as_str().unwrap_or(&value.to_string()).to_string(), + ); + } + } + Ok(sub_recipe_params) +} + +pub async fn run_sub_recipe(sub_recipe: &SubRecipe, params: Value) -> Result { + let command_params = prepare_command_params(sub_recipe, params)?; + + let mut command = Command::new("goose"); + command.arg("run").arg("--recipe").arg(&sub_recipe.path); + + for (key, value) in command_params { + command.arg("--params").arg(format!("{}={}", key, value)); + } + + command.stdout(std::process::Stdio::piped()); + command.stderr(std::process::Stdio::piped()); + + let mut child = command + .spawn() + .map_err(|e| anyhow::anyhow!("Failed to spawn: {}", e))?; + + let stdout = child.stdout.take().expect("Failed to capture stdout"); + let stderr = child.stderr.take().expect("Failed to capture stderr"); + + let mut stdout_reader = BufReader::new(stdout).lines(); + let mut stderr_reader = BufReader::new(stderr).lines(); + let stdout_sub_recipe_name = sub_recipe.name.clone(); + let stderr_sub_recipe_name = sub_recipe.name.clone(); + + // Spawn background tasks to read from stdout and stderr + let stdout_task = tokio::spawn(async move { + let mut buffer = String::new(); + while let Ok(Some(line)) = stdout_reader.next_line().await { + println!("[sub-recipe {}] {}", stdout_sub_recipe_name, line); + buffer.push_str(&line); + buffer.push('\n'); + } + buffer + }); + + let stderr_task = tokio::spawn(async move { + let mut buffer = String::new(); + while let Ok(Some(line)) = stderr_reader.next_line().await { + eprintln!( + "[stderr for sub-recipe {}] {}", + stderr_sub_recipe_name, line + ); + buffer.push_str(&line); + buffer.push('\n'); + } + buffer + }); + + let status = child + .wait() + .await + .map_err(|e| anyhow::anyhow!("Failed to wait for process: {}", e))?; + + let stdout_output = stdout_task.await.unwrap(); + let stderr_output = stderr_task.await.unwrap(); + + if status.success() { + Ok(stdout_output) + } else { + Err(anyhow::anyhow!("Command failed:\n{}", stderr_output)) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/goose/src/agents/recipe_tools/sub_recipe_tools/tests.rs b/crates/goose/src/agents/recipe_tools/sub_recipe_tools/tests.rs new file mode 100644 index 0000000000..11ce390a6b --- /dev/null +++ b/crates/goose/src/agents/recipe_tools/sub_recipe_tools/tests.rs @@ -0,0 +1,155 @@ +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use crate::recipe::SubRecipe; + + fn setup_sub_recipe() -> SubRecipe { + let sub_recipe = SubRecipe { + name: "test_sub_recipe".to_string(), + path: "test_sub_recipe.yaml".to_string(), + values: Some(HashMap::from([("key1".to_string(), "value1".to_string())])), + }; + sub_recipe + } + mod prepare_command_params_tests { + use std::collections::HashMap; + + use crate::{ + agents::recipe_tools::sub_recipe_tools::{ + prepare_command_params, tests::tests::setup_sub_recipe, + }, + recipe::SubRecipe, + }; + + #[test] + fn test_prepare_command_params_basic() { + let mut params = HashMap::new(); + params.insert("key2".to_string(), "value2".to_string()); + + let sub_recipe = setup_sub_recipe(); + + let params_value = serde_json::to_value(params).unwrap(); + let result = prepare_command_params(&sub_recipe, params_value).unwrap(); + assert_eq!(result.len(), 2); + assert_eq!(result.get("key1"), Some(&"value1".to_string())); + assert_eq!(result.get("key2"), Some(&"value2".to_string())); + } + + #[test] + fn test_prepare_command_params_empty() { + let sub_recipe = SubRecipe { + name: "test_sub_recipe".to_string(), + path: "test_sub_recipe.yaml".to_string(), + values: None, + }; + let params: HashMap = HashMap::new(); + let params_value = serde_json::to_value(params).unwrap(); + let result = prepare_command_params(&sub_recipe, params_value).unwrap(); + assert_eq!(result.len(), 0); + } + } + + mod get_input_schema_tests { + use crate::{ + agents::recipe_tools::sub_recipe_tools::{ + get_input_schema, tests::tests::setup_sub_recipe, + }, + recipe::SubRecipe, + }; + + #[test] + fn test_get_input_schema_with_parameters() { + let sub_recipe = setup_sub_recipe(); + + let sub_recipe_file_content = r#"{ + "version": "1.0.0", + "title": "Test Recipe", + "description": "A test recipe", + "prompt": "Test prompt", + "parameters": [ + { + "key": "key1", + "input_type": "string", + "requirement": "required", + "description": "A test parameter" + }, + { + "key": "key2", + "input_type": "number", + "requirement": "optional", + "description": "An optional parameter" + } + ] + }"#; + + let temp_dir = tempfile::tempdir().unwrap(); + let temp_file = temp_dir.path().join("test_sub_recipe.yaml"); + std::fs::write(&temp_file, sub_recipe_file_content).unwrap(); + + let mut sub_recipe = sub_recipe; + sub_recipe.path = temp_file.to_string_lossy().to_string(); + + let result = get_input_schema(&sub_recipe).unwrap(); + + // Verify the schema structure + assert_eq!(result["type"], "object"); + assert!(result["properties"].is_object()); + + let properties = result["properties"].as_object().unwrap(); + assert_eq!(properties.len(), 1); + + let key2_prop = &properties["key2"]; + assert_eq!(key2_prop["type"], "number"); + assert_eq!(key2_prop["description"], "An optional parameter"); + + let required = result["required"].as_array().unwrap(); + assert_eq!(required.len(), 0); + } + + #[test] + fn test_get_input_schema_no_parameters_values() { + let sub_recipe = SubRecipe { + name: "test_sub_recipe".to_string(), + path: "test_sub_recipe.yaml".to_string(), + values: None, + }; + + let sub_recipe_file_content = r#"{ + "version": "1.0.0", + "title": "Test Recipe", + "description": "A test recipe", + "prompt": "Test prompt", + "parameters": [ + { + "key": "key1", + "input_type": "string", + "requirement": "required", + "description": "A test parameter" + } + ] + }"#; + + let temp_dir = tempfile::tempdir().unwrap(); + let temp_file = temp_dir.path().join("test_sub_recipe.yaml"); + std::fs::write(&temp_file, sub_recipe_file_content).unwrap(); + + let mut sub_recipe = sub_recipe; + sub_recipe.path = temp_file.to_string_lossy().to_string(); + + let result = get_input_schema(&sub_recipe).unwrap(); + + assert_eq!(result["type"], "object"); + assert!(result["properties"].is_object()); + + let properties = result["properties"].as_object().unwrap(); + assert_eq!(properties.len(), 1); + + let key1_prop = &properties["key1"]; + assert_eq!(key1_prop["type"], "string"); + assert_eq!(key1_prop["description"], "A test parameter"); + assert_eq!(result["required"].as_array().unwrap().len(), 1); + assert_eq!(result["required"][0], "key1"); + } + } +} diff --git a/crates/goose/src/agents/sub_recipe_manager.rs b/crates/goose/src/agents/sub_recipe_manager.rs new file mode 100644 index 0000000000..3637c947c9 --- /dev/null +++ b/crates/goose/src/agents/sub_recipe_manager.rs @@ -0,0 +1,89 @@ +use mcp_core::{Content, Tool, ToolError}; +use serde_json::Value; +use std::collections::HashMap; + +use crate::{ + agents::{ + recipe_tools::sub_recipe_tools::{ + create_sub_recipe_tool, run_sub_recipe, SUB_RECIPE_TOOL_NAME_PREFIX, + }, + tool_execution::ToolCallResult, + }, + recipe::SubRecipe, +}; + +#[derive(Debug, Clone)] +pub struct SubRecipeManager { + pub sub_recipe_tools: HashMap, + pub sub_recipes: HashMap, +} + +impl Default for SubRecipeManager { + fn default() -> Self { + Self::new() + } +} + +impl SubRecipeManager { + pub fn new() -> Self { + Self { + sub_recipe_tools: HashMap::new(), + sub_recipes: HashMap::new(), + } + } + + pub fn add_sub_recipe_tools(&mut self, sub_recipes_to_add: Vec) { + for sub_recipe in sub_recipes_to_add { + let sub_recipe_key = format!( + "{}_{}", + SUB_RECIPE_TOOL_NAME_PREFIX, + sub_recipe.name.clone() + ); + let tool = create_sub_recipe_tool(&sub_recipe); + self.sub_recipe_tools.insert(sub_recipe_key.clone(), tool); + self.sub_recipes.insert(sub_recipe_key.clone(), sub_recipe); + } + } + + pub fn is_sub_recipe_tool(&self, tool_name: &str) -> bool { + self.sub_recipe_tools.contains_key(tool_name) + } + + pub async fn dispatch_sub_recipe_tool_call( + &self, + tool_name: &str, + params: Value, + ) -> ToolCallResult { + let result = self.call_sub_recipe_tool(tool_name, params).await; + match result { + Ok(call_result) => ToolCallResult::from(Ok(call_result)), + Err(e) => ToolCallResult::from(Err(ToolError::ExecutionError(e.to_string()))), + } + } + + async fn call_sub_recipe_tool( + &self, + tool_name: &str, + params: Value, + ) -> Result, ToolError> { + let sub_recipe = self.sub_recipes.get(tool_name).ok_or_else(|| { + let sub_recipe_name = tool_name + .strip_prefix(SUB_RECIPE_TOOL_NAME_PREFIX) + .and_then(|s| s.strip_prefix("_")) + .ok_or_else(|| { + ToolError::InvalidParameters(format!( + "Invalid sub-recipe tool name format: {}", + tool_name + )) + }) + .unwrap(); + + ToolError::InvalidParameters(format!("Sub-recipe '{}' not found", sub_recipe_name)) + })?; + + let output = run_sub_recipe(sub_recipe, params).await.map_err(|e| { + ToolError::ExecutionError(format!("Sub-recipe execution failed: {}", e)) + })?; + Ok(vec![Content::text(output)]) + } +} diff --git a/crates/goose/src/recipe/mod.rs b/crates/goose/src/recipe/mod.rs index 06a1bc8bb6..9b93f7ec82 100644 --- a/crates/goose/src/recipe/mod.rs +++ b/crates/goose/src/recipe/mod.rs @@ -1,6 +1,10 @@ +use anyhow::Result; +use serde_json::Value; +use std::collections::HashMap; use std::fmt; use crate::agents::extension::ExtensionConfig; +use serde::de::Deserializer; use serde::{Deserialize, Serialize}; fn default_version() -> String { @@ -89,6 +93,9 @@ pub struct Recipe { #[serde(skip_serializing_if = "Option::is_none")] pub parameters: Option>, // any additional parameters for the recipe + + #[serde(skip_serializing_if = "Option::is_none")] + pub sub_recipes: Option>, // sub-recipes for the recipe } #[derive(Serialize, Deserialize, Debug)] @@ -112,6 +119,39 @@ pub struct Settings { pub temperature: Option, } +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct SubRecipe { + pub name: String, + pub path: String, + #[serde(default, deserialize_with = "deserialize_value_map_as_string")] + pub values: Option>, +} + +fn deserialize_value_map_as_string<'de, D>( + deserializer: D, +) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + // First, try to deserialize a map of values + let opt_raw: Option> = Option::deserialize(deserializer)?; + + match opt_raw { + Some(raw_map) => { + let mut result = HashMap::new(); + for (k, v) in raw_map { + let s = match v { + Value::String(s) => s, + _ => serde_json::to_string(&v).map_err(serde::de::Error::custom)?, + }; + result.insert(k, s); + } + Ok(Some(result)) + } + None => Ok(None), + } +} + #[derive(Serialize, Deserialize, Debug)] #[serde(rename_all = "snake_case")] pub enum RecipeParameterRequirement { @@ -176,6 +216,7 @@ pub struct RecipeBuilder { activities: Option>, author: Option, parameters: Option>, + sub_recipes: Option>, } impl Recipe { @@ -206,6 +247,18 @@ impl Recipe { activities: None, author: None, parameters: None, + sub_recipes: None, + } + } + pub fn from_content(content: &str) -> Result { + if serde_json::from_str::(content).is_ok() { + Ok(serde_json::from_str(content)?) + } else if serde_yaml::from_str::(content).is_ok() { + Ok(serde_yaml::from_str(content)?) + } else { + Err(anyhow::anyhow!( + "Unsupported format. Expected JSON or YAML." + )) } } } @@ -274,6 +327,10 @@ impl RecipeBuilder { self.parameters = Some(parameters); self } + pub fn sub_recipes(mut self, sub_recipes: Vec) -> Self { + self.sub_recipes = Some(sub_recipes); + self + } /// Builds the Recipe instance /// @@ -298,6 +355,205 @@ impl RecipeBuilder { activities: self.activities, author: self.author, parameters: self.parameters, + sub_recipes: self.sub_recipes, }) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_from_content_with_json() { + let content = r#"{ + "version": "1.0.0", + "title": "Test Recipe", + "description": "A test recipe", + "prompt": "Test prompt", + "instructions": "Test instructions", + "extensions": [ + { + "type": "stdio", + "name": "test_extension", + "cmd": "test_cmd", + "args": ["arg1", "arg2"], + "timeout": 300, + "description": "Test extension" + } + ], + "parameters": [ + { + "key": "test_param", + "input_type": "string", + "requirement": "required", + "description": "A test parameter" + } + ], + "sub_recipes": [ + { + "name": "test_sub_recipe", + "path": "test_sub_recipe.yaml", + "values": { + "sub_recipe_param": "sub_recipe_value" + } + } + ] + }"#; + + let recipe = Recipe::from_content(content).unwrap(); + assert_eq!(recipe.version, "1.0.0"); + assert_eq!(recipe.title, "Test Recipe"); + assert_eq!(recipe.description, "A test recipe"); + assert_eq!(recipe.instructions, Some("Test instructions".to_string())); + assert_eq!(recipe.prompt, Some("Test prompt".to_string())); + + assert!(recipe.extensions.is_some()); + let extensions = recipe.extensions.unwrap(); + assert_eq!(extensions.len(), 1); + + assert!(recipe.parameters.is_some()); + let parameters = recipe.parameters.unwrap(); + assert_eq!(parameters.len(), 1); + assert_eq!(parameters[0].key, "test_param"); + assert!(matches!( + parameters[0].input_type, + RecipeParameterInputType::String + )); + assert!(matches!( + parameters[0].requirement, + RecipeParameterRequirement::Required + )); + + assert!(recipe.sub_recipes.is_some()); + let sub_recipes = recipe.sub_recipes.unwrap(); + assert_eq!(sub_recipes.len(), 1); + assert_eq!(sub_recipes[0].name, "test_sub_recipe"); + assert_eq!(sub_recipes[0].path, "test_sub_recipe.yaml"); + assert_eq!( + sub_recipes[0].values, + Some(HashMap::from([( + "sub_recipe_param".to_string(), + "sub_recipe_value".to_string() + )])) + ); + } + + #[test] + fn test_from_content_with_yaml() { + let content = r#"version: 1.0.0 +title: Test Recipe +description: A test recipe +prompt: Test prompt +instructions: Test instructions +extensions: + - type: stdio + name: test_extension + cmd: test_cmd + args: [arg1, arg2] + timeout: 300 + description: Test extension +parameters: + - key: test_param + input_type: string + requirement: required + description: A test parameter +sub_recipes: + - name: test_sub_recipe + path: test_sub_recipe.yaml + values: + sub_recipe_param: sub_recipe_value"#; + + let recipe = Recipe::from_content(content).unwrap(); + assert_eq!(recipe.version, "1.0.0"); + assert_eq!(recipe.title, "Test Recipe"); + assert_eq!(recipe.description, "A test recipe"); + assert_eq!(recipe.instructions, Some("Test instructions".to_string())); + assert_eq!(recipe.prompt, Some("Test prompt".to_string())); + + assert!(recipe.extensions.is_some()); + let extensions = recipe.extensions.unwrap(); + assert_eq!(extensions.len(), 1); + + assert!(recipe.parameters.is_some()); + let parameters = recipe.parameters.unwrap(); + assert_eq!(parameters.len(), 1); + assert_eq!(parameters[0].key, "test_param"); + assert!(matches!( + parameters[0].input_type, + RecipeParameterInputType::String + )); + assert!(matches!( + parameters[0].requirement, + RecipeParameterRequirement::Required + )); + + assert!(recipe.sub_recipes.is_some()); + let sub_recipes = recipe.sub_recipes.unwrap(); + assert_eq!(sub_recipes.len(), 1); + assert_eq!(sub_recipes[0].name, "test_sub_recipe"); + assert_eq!(sub_recipes[0].path, "test_sub_recipe.yaml"); + assert_eq!( + sub_recipes[0].values, + Some(HashMap::from([( + "sub_recipe_param".to_string(), + "sub_recipe_value".to_string() + )])) + ); + } + + #[test] + fn test_from_content_invalid_json() { + let content = "{ invalid json }"; + + let result = Recipe::from_content(content); + assert!(result.is_err()); + } + + #[test] + fn test_from_content_missing_required_fields() { + let content = r#"{ + "version": "1.0.0", + "description": "A test recipe" + }"#; + + let result = Recipe::from_content(content); + assert!(result.is_err()); + } + + #[test] + fn test_from_content_with_author() { + let content = r#"{ + "version": "1.0.0", + "title": "Test Recipe", + "description": "A test recipe", + "instructions": "Test instructions", + "author": { + "contact": "test@example.com" + } + }"#; + + let recipe = Recipe::from_content(content).unwrap(); + + assert!(recipe.author.is_some()); + let author = recipe.author.unwrap(); + assert_eq!(author.contact, Some("test@example.com".to_string())); + } + + #[test] + fn test_from_content_with_activities() { + let content = r#"{ + "version": "1.0.0", + "title": "Test Recipe", + "description": "A test recipe", + "instructions": "Test instructions", + "activities": ["activity1", "activity2"] + }"#; + + let recipe = Recipe::from_content(content).unwrap(); + + assert!(recipe.activities.is_some()); + let activities = recipe.activities.unwrap(); + assert_eq!(activities, vec!["activity1", "activity2"]); + } +} diff --git a/crates/goose/src/scheduler.rs b/crates/goose/src/scheduler.rs index b9779eb8c1..86564c1e61 100644 --- a/crates/goose/src/scheduler.rs +++ b/crates/goose/src/scheduler.rs @@ -1374,6 +1374,7 @@ mod tests { author: None, parameters: None, settings: None, + sub_recipes: None, }; let mut recipe_file = File::create(&recipe_filename)?; writeln!(