From cc207bb3506e0188b7e047bfda65c166d038342b Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Wed, 18 Jun 2025 14:59:40 +1000 Subject: [PATCH 01/17] add sub recipe tools --- crates/goose-cli/src/cli.rs | 9 +++- crates/goose-cli/src/commands/bench.rs | 1 + crates/goose-cli/src/recipes/mod.rs | 1 + .../src/recipes/sub_recipe_command.rs | 32 +++++++++++++ crates/goose-cli/src/session/builder.rs | 9 +++- crates/goose/src/agents/agent.rs | 9 +++- crates/goose/src/agents/mod.rs | 1 + crates/goose/src/agents/recipe_tools/mod.rs | 1 + .../agents/recipe_tools/sub_recipe_tools.rs | 46 +++++++++++++++++++ crates/goose/src/agents/sub_recipe_manager.rs | 0 crates/goose/src/recipe/mod.rs | 23 ++++++++++ crates/goose/src/scheduler.rs | 1 + 12 files changed, 130 insertions(+), 3 deletions(-) create mode 100644 crates/goose-cli/src/recipes/sub_recipe_command.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/sub_recipe_manager.rs diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 8333362651d9..063d3faae09a 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -645,6 +645,7 @@ pub async fn cli() -> Result<()> { debug, max_tool_repetitions, interactive: true, // Session command is always interactive + sub_recipes: None, }) .await; setup_logging( @@ -689,7 +690,7 @@ pub async fn cli() -> Result<()> { params, explain, }) => { - let (input_config, session_settings) = match (instructions, input_text, recipe, explain) + let (input_config, session_settings, sub_recipes) = match (instructions, input_text, recipe, explain) { (Some(file), _, _, _) if file == "-" => { let mut input = String::new(); @@ -704,6 +705,7 @@ pub async fn cli() -> Result<()> { additional_system_prompt: system, }, None, + None, ) } (Some(file), _, _, _) => { @@ -721,6 +723,7 @@ pub async fn cli() -> Result<()> { additional_system_prompt: None, }, None, + None, ) } (_, Some(text), _, _) => ( @@ -730,6 +733,7 @@ pub async fn cli() -> Result<()> { additional_system_prompt: system, }, None, + None, ), (_, _, Some(recipe_name), explain) => { if explain { @@ -752,6 +756,7 @@ pub async fn cli() -> Result<()> { goose_model: s.goose_model, temperature: s.temperature, }), + recipe.sub_recipes, ) } (None, None, None, _) => { @@ -773,6 +778,7 @@ pub async fn cli() -> Result<()> { debug, max_tool_repetitions, interactive, // Use the interactive flag from the Run command + sub_recipes: sub_recipes, }) .await; @@ -889,6 +895,7 @@ pub async fn cli() -> Result<()> { debug: false, max_tool_repetitions: None, interactive: true, // Default case is always interactive + 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 01cbd6a037ae..6cfb3006ded8 100644 --- a/crates/goose-cli/src/commands/bench.rs +++ b/crates/goose-cli/src/commands/bench.rs @@ -44,6 +44,7 @@ pub async fn agent_generator( debug: false, max_tool_repetitions: None, interactive: false, // Benchmarking is non-interactive + sub_recipes: None, }) .await; diff --git a/crates/goose-cli/src/recipes/mod.rs b/crates/goose-cli/src/recipes/mod.rs index 9d1ac7021bac..248884410032 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 sub_recipe_command; diff --git a/crates/goose-cli/src/recipes/sub_recipe_command.rs b/crates/goose-cli/src/recipes/sub_recipe_command.rs new file mode 100644 index 000000000000..5722022c5c93 --- /dev/null +++ b/crates/goose-cli/src/recipes/sub_recipe_command.rs @@ -0,0 +1,32 @@ +use goose::{ + config::ExtensionConfig, + recipe::{Recipe, SubRecipe}, +}; + +pub fn create_sub_recipe_extensions(recipe: &Recipe) -> Vec { + let mut extensions: Vec = Vec::new(); + if let Some(sub_recipes) = &recipe.sub_recipes { + for sub_recipe in sub_recipes { + let extension = ExtensionConfig::Builtin { + name: format!("sub-recipe-{}", sub_recipe.name), + timeout: Some(300), + bundled: Some(true), + display_name: Some(format!("sub-recipe-{}", sub_recipe.name)), + }; + extensions.push(extension); + } + } + extensions +} + +pub fn create_sub_recipe_instructions(recipe: &Recipe) -> String { + let mut instructions = String::new(); + if let Some(sub_recipes) = &recipe.sub_recipes { + for sub_recipe in sub_recipes { + instructions.push_str(&format!( + "if {} is required to run, then use sub_recipe_run_{} tool directly to run the sub-recipe. The tool knows how to run it \n", + sub_recipe.name, sub_recipe.name)); + } + } + instructions +} \ No newline at end of file diff --git a/crates/goose-cli/src/session/builder.rs b/crates/goose-cli/src/session/builder.rs index 9a1eb880cdff..ac078e0c8ac1 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; @@ -42,6 +43,8 @@ pub struct SessionBuilderConfig { pub max_tool_repetitions: Option, /// Whether this session will be used interactively (affects debugging prompts) pub interactive: 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 @@ -170,6 +173,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) => { @@ -212,7 +218,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)] { @@ -486,6 +492,7 @@ mod tests { debug: true, max_tool_repetitions: Some(5), interactive: true, + 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 01417ac7723a..2cae447fdca0 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -16,7 +16,7 @@ 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::tool_monitor::{ToolCall, ToolMonitor}; use regex::Regex; use serde_json::Value; @@ -59,6 +59,7 @@ pub struct Agent { pub(super) tool_result_rx: ToolResultReceiver, pub(super) tool_monitor: Mutex>, pub(super) router_tool_selector: Mutex>>>, + pub(super) sub_recipes: Mutex>, } #[derive(Clone, Debug)] @@ -85,6 +86,7 @@ impl Agent { tool_result_rx: Arc::new(Mutex::new(tool_rx)), tool_monitor: Mutex::new(None), router_tool_selector: Mutex::new(None), + sub_recipes: Mutex::new(Vec::new()), } } @@ -103,6 +105,11 @@ impl Agent { monitor.reset(); } } + + pub async fn add_sub_recipes(&self, new_sub_recipes: Vec) { + let mut sub_recipes = self.sub_recipes.lock().await; + sub_recipes.extend(new_sub_recipes); + } } impl Default for Agent { diff --git a/crates/goose/src/agents/mod.rs b/crates/goose/src/agents/mod.rs index 24511ac64580..fd84c76ef092 100644 --- a/crates/goose/src/agents/mod.rs +++ b/crates/goose/src/agents/mod.rs @@ -2,6 +2,7 @@ mod agent; mod context; pub mod extension; pub mod extension_manager; +pub mod sub_recipe_manager; mod large_response_handler; pub mod platform_tools; pub mod prompt_manager; 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 000000000000..f44ff233fc2a --- /dev/null +++ b/crates/goose/src/agents/recipe_tools/mod.rs @@ -0,0 +1 @@ +pub mod sub_recipe_tools; \ No newline at end of file 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 000000000000..d91c63f9b2f2 --- /dev/null +++ b/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs @@ -0,0 +1,46 @@ +use mcp_core::tool::{Tool, ToolAnnotations}; +use serde_json::json; + +use crate::recipe::SubRecipe; + +pub const SUB_RECIPE_TOOL_NAME_PREFIX: &str = "subrecipe__run_sub_recipe"; + +pub const SUB_RECIPE_RUN_SCHEMA: &str = json!({ + "type": "object", + "properties": { + "params": { + "type": "array", + "description": "Parameters to override the existing parameters the sub-recipe", + "items": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "value": { "type": "string" } + } + } + } + } +}); + +pub fn create_sub_recipe_tool(sub_recipe: SubRecipe) -> Tool { + 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.".to_string(), + SUB_RECIPE_RUN_SCHEMA.to_string(), + Some(ToolAnnotations { + title: Some(format!("run sub recipe {}", sub_recipe.name)), + read_only_hint: true, + destructive_hint: false, + idempotent_hint: false, + open_world_hint: false, + }), + ) +} + +pub fn run_tool(sub_recipe: SubRecipe, params: Vec) -> Result { + let tool = create_sub_recipe_tool(sub_recipe); + let result = tool.call(params); + Ok(result) +} \ No newline at end of file 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 000000000000..e69de29bb2d1 diff --git a/crates/goose/src/recipe/mod.rs b/crates/goose/src/recipe/mod.rs index 06a1bc8bb67e..97c7889e6ab4 100644 --- a/crates/goose/src/recipe/mod.rs +++ b/crates/goose/src/recipe/mod.rs @@ -89,6 +89,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 +115,19 @@ pub struct Settings { pub temperature: Option, } +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct SubRecipe { + pub name: String, + pub path: String, + pub params: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct SubRecipeParams { + pub name: String, + pub value: String, +} + #[derive(Serialize, Deserialize, Debug)] #[serde(rename_all = "snake_case")] pub enum RecipeParameterRequirement { @@ -176,6 +192,7 @@ pub struct RecipeBuilder { activities: Option>, author: Option, parameters: Option>, + sub_recipes: Option>, } impl Recipe { @@ -206,6 +223,7 @@ impl Recipe { activities: None, author: None, parameters: None, + sub_recipes: None, } } } @@ -274,6 +292,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 +320,7 @@ impl RecipeBuilder { activities: self.activities, author: self.author, parameters: self.parameters, + sub_recipes: self.sub_recipes, }) } } diff --git a/crates/goose/src/scheduler.rs b/crates/goose/src/scheduler.rs index 1b4871183c2d..b25b30f0721f 100644 --- a/crates/goose/src/scheduler.rs +++ b/crates/goose/src/scheduler.rs @@ -1301,6 +1301,7 @@ mod tests { author: None, parameters: None, settings: None, + sub_recipes: None, }; let mut recipe_file = File::create(&recipe_filename)?; writeln!( From cc870fbe1cc3f69291d9a80f874eb9626e99d6ec Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Wed, 18 Jun 2025 17:36:30 +1000 Subject: [PATCH 02/17] added sub recipe manager in agent --- crates/goose-cli/src/cli.rs | 8 +- .../src/recipes/sub_recipe_command.rs | 2 +- crates/goose/src/agents/agent.rs | 39 +++++++--- crates/goose/src/agents/mod.rs | 3 +- crates/goose/src/agents/recipe_tools/mod.rs | 2 +- .../agents/recipe_tools/sub_recipe_tools.rs | 74 ++++++++++++------- crates/goose/src/agents/sub_recipe_manager.rs | 53 +++++++++++++ joke-of-the-day.yaml | 29 ++++++++ sub_recipe_simple.yaml | 35 +++++++++ weather.yaml | 26 +++++++ 10 files changed, 228 insertions(+), 43 deletions(-) create mode 100644 joke-of-the-day.yaml create mode 100644 sub_recipe_simple.yaml create mode 100644 weather.yaml diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 063d3faae09a..9ba46255c803 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -690,8 +690,12 @@ pub async fn cli() -> Result<()> { params, explain, }) => { - let (input_config, session_settings, sub_recipes) = match (instructions, input_text, recipe, explain) - { + let (input_config, session_settings, sub_recipes) = match ( + instructions, + input_text, + recipe, + explain, + ) { (Some(file), _, _, _) if file == "-" => { let mut input = String::new(); std::io::stdin() diff --git a/crates/goose-cli/src/recipes/sub_recipe_command.rs b/crates/goose-cli/src/recipes/sub_recipe_command.rs index 5722022c5c93..001c9c4690cc 100644 --- a/crates/goose-cli/src/recipes/sub_recipe_command.rs +++ b/crates/goose-cli/src/recipes/sub_recipe_command.rs @@ -29,4 +29,4 @@ pub fn create_sub_recipe_instructions(recipe: &Recipe) -> String { } } instructions -} \ No newline at end of file +} diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 2cae447fdca0..39c06f541566 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -10,6 +10,8 @@ use futures_util::stream; use futures_util::stream::StreamExt; use mcp_core::protocol::JsonRpcMessage; +use crate::agents::recipe_tools::sub_recipe_tools::SUB_RECIPE_TOOL_NAME_PREFIX; +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; @@ -50,6 +52,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, @@ -59,7 +62,6 @@ pub struct Agent { pub(super) tool_result_rx: ToolResultReceiver, pub(super) tool_monitor: Mutex>, pub(super) router_tool_selector: Mutex>>>, - pub(super) sub_recipes: Mutex>, } #[derive(Clone, Debug)] @@ -77,6 +79,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()), @@ -86,7 +89,6 @@ impl Agent { tool_result_rx: Arc::new(Mutex::new(tool_rx)), tool_monitor: Mutex::new(None), router_tool_selector: Mutex::new(None), - sub_recipes: Mutex::new(Vec::new()), } } @@ -105,11 +107,6 @@ impl Agent { monitor.reset(); } } - - pub async fn add_sub_recipes(&self, new_sub_recipes: Vec) { - let mut sub_recipes = self.sub_recipes.lock().await; - sub_recipes.extend(new_sub_recipes); - } } impl Default for Agent { @@ -189,6 +186,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(super) async fn dispatch_tool_call( @@ -231,7 +233,11 @@ 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) { + ToolCallResult::from(sub_recipe_manager.run_sub_recipe(&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 @@ -457,16 +463,25 @@ 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.extend([ + platform_tools::search_available_extensions_tool(), + platform_tools::manage_extensions_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 fd84c76ef092..6cc0331c365a 100644 --- a/crates/goose/src/agents/mod.rs +++ b/crates/goose/src/agents/mod.rs @@ -2,13 +2,14 @@ mod agent; mod context; pub mod extension; pub mod extension_manager; -pub mod sub_recipe_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; +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 index f44ff233fc2a..5f2f95fc8485 100644 --- a/crates/goose/src/agents/recipe_tools/mod.rs +++ b/crates/goose/src/agents/recipe_tools/mod.rs @@ -1 +1 @@ -pub mod sub_recipe_tools; \ No newline at end of file +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 index d91c63f9b2f2..9d6375c9ebfe 100644 --- a/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs +++ b/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs @@ -1,34 +1,20 @@ +use std::process::Command; + use mcp_core::tool::{Tool, ToolAnnotations}; -use serde_json::json; +use serde_json::{json, Value}; use crate::recipe::SubRecipe; pub const SUB_RECIPE_TOOL_NAME_PREFIX: &str = "subrecipe__run_sub_recipe"; -pub const SUB_RECIPE_RUN_SCHEMA: &str = json!({ - "type": "object", - "properties": { - "params": { - "type": "array", - "description": "Parameters to override the existing parameters the sub-recipe", - "items": { - "type": "object", - "properties": { - "name": { "type": "string" }, - "value": { "type": "string" } - } - } - } - } -}); - -pub fn create_sub_recipe_tool(sub_recipe: SubRecipe) -> Tool { +pub fn create_sub_recipe_tool(sub_recipe: &SubRecipe) -> Tool { 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.".to_string(), - SUB_RECIPE_RUN_SCHEMA.to_string(), + The sub recipe will be run with the provided parameters." + .to_string(), + get_sub_recipe_run_schema(), Some(ToolAnnotations { title: Some(format!("run sub recipe {}", sub_recipe.name)), read_only_hint: true, @@ -39,8 +25,44 @@ pub fn create_sub_recipe_tool(sub_recipe: SubRecipe) -> Tool { ) } -pub fn run_tool(sub_recipe: SubRecipe, params: Vec) -> Result { - let tool = create_sub_recipe_tool(sub_recipe); - let result = tool.call(params); - Ok(result) -} \ No newline at end of file +pub fn run_sub_recipe_tool(sub_recipe: &SubRecipe, _params: Value) -> Result { + let mut command = Command::new("goose"); + command + .arg("run") + .arg("--recipe") + .arg(&sub_recipe.path); + if let Some(params) = &sub_recipe.params { + for param in params { + command.arg("--params"); + command.arg(format!("{}={}", param.name, param.value)); + } + } + let output = command + .output() + .map_err(|e| format!("Failed to execute: {e}"))?; + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } else { + Err(String::from_utf8_lossy(&output.stderr).to_string()) + } +} + + +fn get_sub_recipe_run_schema() -> Value { + json!({ + "type": "object", + "properties": { + "params": { + "type": "array", + "description": "Parameters to override the existing parameters the sub-recipe", + "items": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "value": { "type": "string" } + } + } + } + } + }) +} diff --git a/crates/goose/src/agents/sub_recipe_manager.rs b/crates/goose/src/agents/sub_recipe_manager.rs index e69de29bb2d1..5b63bcf300a5 100644 --- a/crates/goose/src/agents/sub_recipe_manager.rs +++ b/crates/goose/src/agents/sub_recipe_manager.rs @@ -0,0 +1,53 @@ +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_tool, 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 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_name = sub_recipe.name.clone(); + let tool = create_sub_recipe_tool(&sub_recipe); + self.sub_recipe_tools.insert(sub_recipe_name.clone(), tool); + self.sub_recipes.insert(sub_recipe_name, sub_recipe); + } + } + + pub fn is_sub_recipe_tool(&self, tool_name: &str) -> bool { + tool_name.starts_with(SUB_RECIPE_TOOL_NAME_PREFIX) + } + + pub async fn run_sub_recipe(&self, tool_name: &str, _params: Value) -> Result, ToolError> { + 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 + )))?; + + let sub_recipe = self.sub_recipes + .get(sub_recipe_name) + .ok_or_else(|| ToolError::InvalidParameters(format!( + "Sub-recipe '{}' not found", sub_recipe_name + )))?; + + let output = run_sub_recipe_tool(sub_recipe, _params).map_err(|e| { + ToolError::ExecutionError(format!("Sub-recipe execution failed: {}", e)) + })?; + Ok(vec![Content::text(output)]) + } +} diff --git a/joke-of-the-day.yaml b/joke-of-the-day.yaml new file mode 100644 index 000000000000..b6a4e1192d81 --- /dev/null +++ b/joke-of-the-day.yaml @@ -0,0 +1,29 @@ +version: 1.0.0 +title: Joke of the day +author: + contact: douwe +description: Will tell you a joke fo the day based on the current day +instructions: Your job is to tell a joke of the day +extensions: +- type: builtin + name: developer + display_name: Developer + timeout: 300 + bundled: true +- args: + - mcp-server-fetch + bundled: null + cmd: uvx + description: '' + enabled: false + env_keys: [] + envs: {} + name: fetch + timeout: 300 + type: stdio +prompt: "{{date}} is provided and generate a joke on that day . Mention the day on the first line then an empty line and then the joke. Don't just say the date, but figure out if there's any cultural significance, like national shelves day" +parameters: +- key: date + input_type: string + requirement: required + description: date provided by the user diff --git a/sub_recipe_simple.yaml b/sub_recipe_simple.yaml new file mode 100644 index 000000000000..a340f221ad96 --- /dev/null +++ b/sub_recipe_simple.yaml @@ -0,0 +1,35 @@ +version: 1.0.0 +title: top recipes +author: + contact: douwe +description: Will do multiple things +instructions: Your job is to do multiple things +prompt: | + 1. use weather sub-recipe to get the weather for {{city}} + 3. use joke-of-the-day sub-recipe +sub_recipes: +- name: joke-of-the-day + path: "{{recipe_dir}}/joke-of-the-day.yaml" + params: + - name: date + value: "{{date}}" +- name: weather + path: "{{recipe_dir}}/weather.yaml" + params: + - name: city + value: "{{city}}" +extensions: +- type: builtin + name: developer + display_name: Developer + timeout: 300 + bundled: true +parameters: +- key: date + input_type: string + requirement: required + description: date provided by the user +- key: city + input_type: string + requirement: required + description: city provided by the user diff --git a/weather.yaml b/weather.yaml new file mode 100644 index 000000000000..9ddb6f0f1bf1 --- /dev/null +++ b/weather.yaml @@ -0,0 +1,26 @@ +version: 1.0.0 +title: Find weather +description: Get weather data for a city +instructions: You are a weather expert. You will be given a city and you will need to return the weather data for that city. +prompt: | + Get the weather for {{ city }} for today +parameters: + - key: city + input_type: string + requirement: required + description: city name +extensions: + - type: stdio + name: weather + cmd: uvx + args: + - mcp_weather@latest + timeout: 300 + description: "Weather data for trip planning" + - type: builtin + name: developer + display_name: Developer + timeout: 300 + bundled: true +author: + contact: dkatz \ No newline at end of file From ad67286327b2516915ee4070acef290282139fc1 Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Wed, 18 Jun 2025 20:16:35 +1000 Subject: [PATCH 03/17] added the parameter management --- crates/goose-cli/src/cli.rs | 2 +- crates/goose-cli/src/recipes/recipe.rs | 18 +-- .../src/recipes/sub_recipe_command.rs | 2 +- crates/goose/src/agents/agent.rs | 9 +- .../agents/recipe_tools/sub_recipe_tools.rs | 126 +++++++++++++----- crates/goose/src/agents/sub_recipe_manager.rs | 44 ++++-- crates/goose/src/recipe/mod.rs | 12 ++ joke-of-the-day.yaml | 2 +- sub_recipe_simple.yaml | 10 +- 9 files changed, 146 insertions(+), 79 deletions(-) diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 9ba46255c803..e831c05c630a 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -782,7 +782,7 @@ pub async fn cli() -> Result<()> { debug, max_tool_repetitions, interactive, // Use the interactive flag from the Run command - sub_recipes: sub_recipes, + sub_recipes, }) .await; diff --git a/crates/goose-cli/src/recipes/recipe.rs b/crates/goose-cli/src/recipes/recipe.rs index b90de236a06b..dcceed97038f 100644 --- a/crates/goose-cli/src/recipes/recipe.rs +++ b/crates/goose-cli/src/recipes/recipe.rs @@ -8,8 +8,6 @@ use crate::recipes::print_recipe::{ use crate::recipes::search_recipe::retrieve_recipe_file; use goose::recipe::{Recipe, RecipeParameter, RecipeParameterRequirement}; use minijinja::{Environment, Error, Template, UndefinedBehavior}; -use serde_json::Value as JsonValue; -use serde_yaml::Value as YamlValue; use std::collections::{HashMap, HashSet}; use std::path::PathBuf; @@ -47,7 +45,7 @@ pub fn load_recipe_as_template(recipe_name: &str, params: Vec<(String, String)>) let rendered_content = render_content_with_params(&recipe_file_content, ¶ms_for_template)?; - let recipe = parse_recipe_content(&rendered_content)?; + let recipe = Recipe::from_content(&rendered_content)?; // Display information about the loaded recipe println!( @@ -106,7 +104,7 @@ pub fn explain_recipe_with_parameters( } fn validate_recipe_file_parameters(recipe_file_content: &str) -> Result { - let recipe_from_recipe_file: Recipe = parse_recipe_content(recipe_file_content)?; + let recipe_from_recipe_file: Recipe = Recipe::from_content(recipe_file_content)?; validate_optional_parameters(&recipe_from_recipe_file)?; validate_parameters_in_template(&recipe_from_recipe_file.parameters, recipe_file_content)?; Ok(recipe_from_recipe_file) @@ -183,18 +181,6 @@ fn validate_optional_parameters(recipe: &Recipe) -> Result<()> { } } -fn parse_recipe_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 file format for recipe file. Expected .yaml or .json" - )) - } -} - fn extract_template_variables(template_str: &str) -> Result> { let mut env = Environment::new(); env.set_undefined_behavior(UndefinedBehavior::Strict); diff --git a/crates/goose-cli/src/recipes/sub_recipe_command.rs b/crates/goose-cli/src/recipes/sub_recipe_command.rs index 001c9c4690cc..cd4806f69d13 100644 --- a/crates/goose-cli/src/recipes/sub_recipe_command.rs +++ b/crates/goose-cli/src/recipes/sub_recipe_command.rs @@ -1,6 +1,6 @@ use goose::{ config::ExtensionConfig, - recipe::{Recipe, SubRecipe}, + recipe::Recipe, }; pub fn create_sub_recipe_extensions(recipe: &Recipe) -> Vec { diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 39c06f541566..5e9b71c92b9d 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -10,7 +10,6 @@ use futures_util::stream; use futures_util::stream::StreamExt; use mcp_core::protocol::JsonRpcMessage; -use crate::agents::recipe_tools::sub_recipe_tools::SUB_RECIPE_TOOL_NAME_PREFIX; use crate::agents::sub_recipe_manager::SubRecipeManager; use crate::config::{Config, ExtensionConfigManager, PermissionManager}; use crate::message::Message; @@ -234,9 +233,13 @@ impl Agent { let extension_manager = self.extension_manager.lock().await; let sub_recipe_manager = self.sub_recipe_manager.lock().await; - + let result: ToolCallResult = if sub_recipe_manager.is_sub_recipe_tool(&tool_call.name) { - ToolCallResult::from(sub_recipe_manager.run_sub_recipe(&tool_call.name, tool_call.arguments.clone()).await) + ToolCallResult::from( + sub_recipe_manager + .run_sub_recipe(&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( diff --git a/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs b/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs index 9d6375c9ebfe..4921bdc20890 100644 --- a/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs +++ b/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs @@ -1,20 +1,22 @@ -use std::process::Command; +use std::{collections::HashMap, fs, process::Command}; +use anyhow::Result; use mcp_core::tool::{Tool, ToolAnnotations}; -use serde_json::{json, Value}; +use serde_json::{json, Map, Value}; -use crate::recipe::SubRecipe; +use crate::recipe::{Recipe, RecipeParameter, RecipeParameterRequirement, SubRecipe}; pub const SUB_RECIPE_TOOL_NAME_PREFIX: &str = "subrecipe__run_sub_recipe"; 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." .to_string(), - get_sub_recipe_run_schema(), + input_schema, Some(ToolAnnotations { title: Some(format!("run sub recipe {}", sub_recipe.name)), read_only_hint: true, @@ -25,18 +27,94 @@ pub fn create_sub_recipe_tool(sub_recipe: &SubRecipe) -> Tool { ) } -pub fn run_sub_recipe_tool(sub_recipe: &SubRecipe, _params: Value) -> Result { - let mut command = Command::new("goose"); - command - .arg("run") - .arg("--recipe") - .arg(&sub_recipe.path); - if let Some(params) = &sub_recipe.params { - for param in params { - command.arg("--params"); - command.arg(format!("{}={}", param.name, param.value)); +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.params { + for param_with_value in params_with_value { + sub_recipe_params_map.insert( + param_with_value.name.clone(), + param_with_value.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 { + let mut description = param.description.clone(); + if sub_recipe_params_map.contains_key(¶m.key) { + description = format!("{}, currently the value is set to {}. If you want to change the value, please provide a new value.", description, sub_recipe_params_map.get(¶m.key).unwrap()); + } + properties.insert( + param.key.clone(), + json!({ + "type": param.input_type.to_string(), + "description": description, + }), + ); + if !matches!(param.requirement, RecipeParameterRequirement::Optional) { + required.push(param.key); + } + } + Ok(json!({ + "type": "object", + "properties": properties, + "required": required + })) + } else { + Ok(json!({ + "type": "object", + "properties": {} + })) + } +} + +pub fn call_sub_recipe_tool(sub_recipe: &SubRecipe, params: Value) -> Result { + println!("======= params: {:?}", params); + let mut sub_recipe_params = HashMap::::new(); + if let Some(params_with_value) = &sub_recipe.params { + for param_with_value in params_with_value { + sub_recipe_params.insert( + param_with_value.name.clone(), + param_with_value.value.clone(), + ); + } + } + println!( + "======= existing sub_recipe_params: {:?}", + sub_recipe_params + ); + if let Some(params_map) = params.as_object() { + for (key, value) in params_map { + println!("======= key: {:?}, value: {:?}", key, value); + sub_recipe_params.insert( + key.to_string(), + value.as_str().unwrap_or(&value.to_string()).to_string(), + ); + } + } + println!( + "======= overridden sub_recipe_params: {:?}", + sub_recipe_params + ); + let mut command = Command::new("goose"); + command.arg("run").arg("--recipe").arg(&sub_recipe.path); + for (key, value) in sub_recipe_params { + command.arg("--params"); + command.arg(format!("{}={}", key, value)); + } + println!("======= command: {:?}", command); let output = command .output() .map_err(|e| format!("Failed to execute: {e}"))?; @@ -46,23 +124,3 @@ pub fn run_sub_recipe_tool(sub_recipe: &SubRecipe, _params: Value) -> Result Value { - json!({ - "type": "object", - "properties": { - "params": { - "type": "array", - "description": "Parameters to override the existing parameters the sub-recipe", - "items": { - "type": "object", - "properties": { - "name": { "type": "string" }, - "value": { "type": "string" } - } - } - } - } - }) -} diff --git a/crates/goose/src/agents/sub_recipe_manager.rs b/crates/goose/src/agents/sub_recipe_manager.rs index 5b63bcf300a5..fbeebb0b7814 100644 --- a/crates/goose/src/agents/sub_recipe_manager.rs +++ b/crates/goose/src/agents/sub_recipe_manager.rs @@ -2,7 +2,12 @@ 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_tool, SUB_RECIPE_TOOL_NAME_PREFIX}, tool_execution::ToolCallResult}, recipe::SubRecipe}; +use crate::{ + agents::recipe_tools::sub_recipe_tools::{ + call_sub_recipe_tool, create_sub_recipe_tool, SUB_RECIPE_TOOL_NAME_PREFIX, + }, + recipe::SubRecipe, +}; #[derive(Debug, Clone)] pub struct SubRecipeManager { @@ -10,6 +15,12 @@ pub struct SubRecipeManager { pub sub_recipes: HashMap, } +impl Default for SubRecipeManager { + fn default() -> Self { + Self::new() + } +} + impl SubRecipeManager { pub fn new() -> Self { Self { @@ -23,7 +34,7 @@ impl SubRecipeManager { let sub_recipe_name = sub_recipe.name.clone(); let tool = create_sub_recipe_tool(&sub_recipe); self.sub_recipe_tools.insert(sub_recipe_name.clone(), tool); - self.sub_recipes.insert(sub_recipe_name, sub_recipe); + self.sub_recipes.insert(sub_recipe_name, sub_recipe); } } @@ -31,21 +42,26 @@ impl SubRecipeManager { tool_name.starts_with(SUB_RECIPE_TOOL_NAME_PREFIX) } - pub async fn run_sub_recipe(&self, tool_name: &str, _params: Value) -> Result, ToolError> { + pub async fn run_sub_recipe( + &self, + tool_name: &str, + params: Value, + ) -> Result, ToolError> { 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 - )))?; - - let sub_recipe = self.sub_recipes - .get(sub_recipe_name) - .ok_or_else(|| ToolError::InvalidParameters(format!( - "Sub-recipe '{}' not found", sub_recipe_name - )))?; - - let output = run_sub_recipe_tool(sub_recipe, _params).map_err(|e| { + .ok_or_else(|| { + ToolError::InvalidParameters(format!( + "Invalid sub-recipe tool name format: {}", + tool_name + )) + })?; + + let sub_recipe = self.sub_recipes.get(sub_recipe_name).ok_or_else(|| { + ToolError::InvalidParameters(format!("Sub-recipe '{}' not found", sub_recipe_name)) + })?; + + let output = call_sub_recipe_tool(sub_recipe, params).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 97c7889e6ab4..dcabe9bf1322 100644 --- a/crates/goose/src/recipe/mod.rs +++ b/crates/goose/src/recipe/mod.rs @@ -1,3 +1,4 @@ +use anyhow::Result; use std::fmt; use crate::agents::extension::ExtensionConfig; @@ -226,6 +227,17 @@ impl Recipe { 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." + )) + } + } } impl RecipeBuilder { diff --git a/joke-of-the-day.yaml b/joke-of-the-day.yaml index b6a4e1192d81..f2eb4d61a772 100644 --- a/joke-of-the-day.yaml +++ b/joke-of-the-day.yaml @@ -21,7 +21,7 @@ extensions: name: fetch timeout: 300 type: stdio -prompt: "{{date}} is provided and generate a joke on that day . Mention the day on the first line then an empty line and then the joke. Don't just say the date, but figure out if there's any cultural significance, like national shelves day" +prompt: "with provided date {{date}} generate a joke on that day . Mention the day on the first line then an empty line and then the joke. Don't just say the date, but figure out if there's any cultural significance, like national shelves day" parameters: - key: date input_type: string diff --git a/sub_recipe_simple.yaml b/sub_recipe_simple.yaml index a340f221ad96..55f5a68a84f1 100644 --- a/sub_recipe_simple.yaml +++ b/sub_recipe_simple.yaml @@ -5,8 +5,7 @@ author: description: Will do multiple things instructions: Your job is to do multiple things prompt: | - 1. use weather sub-recipe to get the weather for {{city}} - 3. use joke-of-the-day sub-recipe + 1. use weather sub-recipe to get the weather sub_recipes: - name: joke-of-the-day path: "{{recipe_dir}}/joke-of-the-day.yaml" @@ -15,9 +14,6 @@ sub_recipes: value: "{{date}}" - name: weather path: "{{recipe_dir}}/weather.yaml" - params: - - name: city - value: "{{city}}" extensions: - type: builtin name: developer @@ -29,7 +25,3 @@ parameters: input_type: string requirement: required description: date provided by the user -- key: city - input_type: string - requirement: required - description: city provided by the user From 285149a0972f340532eacdd35924e1e72cabfa15 Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Wed, 18 Jun 2025 20:48:52 +1000 Subject: [PATCH 04/17] print out the command output --- .../agents/recipe_tools/sub_recipe_tools.rs | 78 ++++++++++++++----- crates/goose/src/agents/sub_recipe_manager.rs | 8 +- sub_recipe_simple.yaml | 2 +- 3 files changed, 63 insertions(+), 25 deletions(-) diff --git a/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs b/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs index 4921bdc20890..f03a8b7d1085 100644 --- a/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs +++ b/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs @@ -1,8 +1,10 @@ -use std::{collections::HashMap, fs, process::Command}; +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}; @@ -80,8 +82,7 @@ fn get_input_schema(sub_recipe: &SubRecipe) -> Result { } } -pub fn call_sub_recipe_tool(sub_recipe: &SubRecipe, params: Value) -> Result { - println!("======= params: {:?}", params); +pub async fn call_sub_recipe_tool(sub_recipe: &SubRecipe, params: Value) -> Result { let mut sub_recipe_params = HashMap::::new(); if let Some(params_with_value) = &sub_recipe.params { for param_with_value in params_with_value { @@ -91,36 +92,71 @@ pub fn call_sub_recipe_tool(sub_recipe: &SubRecipe, params: Value) -> Result Date: Wed, 18 Jun 2025 21:13:53 +1000 Subject: [PATCH 05/17] refactoring and cleanup --- crates/goose-cli/src/recipes/mod.rs | 1 - .../src/recipes/sub_recipe_command.rs | 32 ------------------- .../agents/recipe_tools/sub_recipe_tools.rs | 19 ++++++++--- 3 files changed, 14 insertions(+), 38 deletions(-) delete mode 100644 crates/goose-cli/src/recipes/sub_recipe_command.rs diff --git a/crates/goose-cli/src/recipes/mod.rs b/crates/goose-cli/src/recipes/mod.rs index 248884410032..9d1ac7021bac 100644 --- a/crates/goose-cli/src/recipes/mod.rs +++ b/crates/goose-cli/src/recipes/mod.rs @@ -2,4 +2,3 @@ pub mod github_recipe; pub mod print_recipe; pub mod recipe; pub mod search_recipe; -pub mod sub_recipe_command; diff --git a/crates/goose-cli/src/recipes/sub_recipe_command.rs b/crates/goose-cli/src/recipes/sub_recipe_command.rs deleted file mode 100644 index cd4806f69d13..000000000000 --- a/crates/goose-cli/src/recipes/sub_recipe_command.rs +++ /dev/null @@ -1,32 +0,0 @@ -use goose::{ - config::ExtensionConfig, - recipe::Recipe, -}; - -pub fn create_sub_recipe_extensions(recipe: &Recipe) -> Vec { - let mut extensions: Vec = Vec::new(); - if let Some(sub_recipes) = &recipe.sub_recipes { - for sub_recipe in sub_recipes { - let extension = ExtensionConfig::Builtin { - name: format!("sub-recipe-{}", sub_recipe.name), - timeout: Some(300), - bundled: Some(true), - display_name: Some(format!("sub-recipe-{}", sub_recipe.name)), - }; - extensions.push(extension); - } - } - extensions -} - -pub fn create_sub_recipe_instructions(recipe: &Recipe) -> String { - let mut instructions = String::new(); - if let Some(sub_recipes) = &recipe.sub_recipes { - for sub_recipe in sub_recipes { - instructions.push_str(&format!( - "if {} is required to run, then use sub_recipe_run_{} tool directly to run the sub-recipe. The tool knows how to run it \n", - sub_recipe.name, sub_recipe.name)); - } - } - instructions -} diff --git a/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs b/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs index f03a8b7d1085..09a0435e812e 100644 --- a/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs +++ b/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs @@ -82,7 +82,10 @@ fn get_input_schema(sub_recipe: &SubRecipe) -> Result { } } -pub async fn call_sub_recipe_tool(sub_recipe: &SubRecipe, params: Value) -> Result { +fn prepare_command_params( + sub_recipe: &SubRecipe, + params: Value, +) -> Result> { let mut sub_recipe_params = HashMap::::new(); if let Some(params_with_value) = &sub_recipe.params { for param_with_value in params_with_value { @@ -100,10 +103,16 @@ pub async fn call_sub_recipe_tool(sub_recipe: &SubRecipe, params: Value) -> Resu ); } } + Ok(sub_recipe_params) +} + +pub async fn call_sub_recipe_tool(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 sub_recipe_params { + for (key, value) in command_params { command.arg("--params").arg(format!("{}={}", key, value)); } @@ -112,7 +121,7 @@ pub async fn call_sub_recipe_tool(sub_recipe: &SubRecipe, params: Value) -> Resu let mut child = command .spawn() - .map_err(|e| format!("Failed to spawn: {}", e))?; + .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"); @@ -149,7 +158,7 @@ pub async fn call_sub_recipe_tool(sub_recipe: &SubRecipe, params: Value) -> Resu let status = child .wait() .await - .map_err(|e| format!("Failed to wait for process: {}", e))?; + .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(); @@ -157,6 +166,6 @@ pub async fn call_sub_recipe_tool(sub_recipe: &SubRecipe, params: Value) -> Resu if status.success() { Ok(stdout_output) } else { - Err(format!("Command failed:\n{}", stderr_output)) + Err(anyhow::anyhow!("Command failed:\n{}", stderr_output)) } } From f6109626ccae277e939352cd5bfc0be65a407c4c Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Wed, 18 Jun 2025 21:30:19 +1000 Subject: [PATCH 06/17] rename functions --- crates/goose/src/agents/agent.rs | 2 +- .../src/agents/recipe_tools/sub_recipe_tools.rs | 2 +- crates/goose/src/agents/sub_recipe_manager.rs | 12 +++++------- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 5e9b71c92b9d..08973f3f3c77 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -237,7 +237,7 @@ impl Agent { let result: ToolCallResult = if sub_recipe_manager.is_sub_recipe_tool(&tool_call.name) { ToolCallResult::from( sub_recipe_manager - .run_sub_recipe(&tool_call.name, tool_call.arguments.clone()) + .call_sub_recipe_tool(&tool_call.name, tool_call.arguments.clone()) .await, ) } else if tool_call.name == PLATFORM_READ_RESOURCE_TOOL_NAME { diff --git a/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs b/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs index 09a0435e812e..3510096dfd4d 100644 --- a/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs +++ b/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs @@ -106,7 +106,7 @@ fn prepare_command_params( Ok(sub_recipe_params) } -pub async fn call_sub_recipe_tool(sub_recipe: &SubRecipe, params: Value) -> Result { +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"); diff --git a/crates/goose/src/agents/sub_recipe_manager.rs b/crates/goose/src/agents/sub_recipe_manager.rs index 25cc41e3664f..f4ec04eb56c2 100644 --- a/crates/goose/src/agents/sub_recipe_manager.rs +++ b/crates/goose/src/agents/sub_recipe_manager.rs @@ -4,7 +4,7 @@ use std::collections::HashMap; use crate::{ agents::recipe_tools::sub_recipe_tools::{ - call_sub_recipe_tool, create_sub_recipe_tool, SUB_RECIPE_TOOL_NAME_PREFIX, + create_sub_recipe_tool, run_sub_recipe, SUB_RECIPE_TOOL_NAME_PREFIX, }, recipe::SubRecipe, }; @@ -42,7 +42,7 @@ impl SubRecipeManager { tool_name.starts_with(SUB_RECIPE_TOOL_NAME_PREFIX) } - pub async fn run_sub_recipe( + pub async fn call_sub_recipe_tool( &self, tool_name: &str, params: Value, @@ -61,11 +61,9 @@ impl SubRecipeManager { ToolError::InvalidParameters(format!("Sub-recipe '{}' not found", sub_recipe_name)) })?; - let output = call_sub_recipe_tool(sub_recipe, params) - .await - .map_err(|e| { - ToolError::ExecutionError(format!("Sub-recipe execution failed: {}", e)) - })?; + 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)]) } } From 1db66e87f95b0ba60ed310a8a544e10adcf25588 Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Wed, 18 Jun 2025 22:05:36 +1000 Subject: [PATCH 07/17] fixed the type of the param value --- crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs b/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs index 3510096dfd4d..71120099b77e 100644 --- a/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs +++ b/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs @@ -103,6 +103,7 @@ fn prepare_command_params( ); } } + println!("======= sub_recipe_params: {:?}", sub_recipe_params); Ok(sub_recipe_params) } From 65923b319aef229b21c9d9c2dd2c9a99a4063ea7 Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Wed, 18 Jun 2025 22:29:19 +1000 Subject: [PATCH 08/17] changed the sub recipe attribute name --- .../agents/recipe_tools/sub_recipe_tools.rs | 18 +++++----- crates/goose/src/recipe/mod.rs | 33 ++++++++++++++++--- sub_recipe_simple.yaml | 15 ++++++--- weather.yaml | 6 +++- 4 files changed, 52 insertions(+), 20 deletions(-) diff --git a/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs b/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs index 71120099b77e..3c6f29f5c27d 100644 --- a/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs +++ b/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs @@ -40,15 +40,14 @@ fn get_sub_recipe_parameter_definition( fn get_input_schema(sub_recipe: &SubRecipe) -> Result { let mut sub_recipe_params_map = HashMap::::new(); - if let Some(params_with_value) = &sub_recipe.params { - for param_with_value in params_with_value { + if let Some(params_with_value) = &sub_recipe.values { + for (param_name, param_value) in params_with_value { sub_recipe_params_map.insert( - param_with_value.name.clone(), - param_with_value.value.clone(), + 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(); @@ -87,11 +86,11 @@ fn prepare_command_params( params: Value, ) -> Result> { let mut sub_recipe_params = HashMap::::new(); - if let Some(params_with_value) = &sub_recipe.params { - for param_with_value in params_with_value { + if let Some(params_with_value) = &sub_recipe.values { + for (param_name, param_value) in params_with_value { sub_recipe_params.insert( - param_with_value.name.clone(), - param_with_value.value.clone(), + param_name.clone(), + param_value.clone(), ); } } @@ -103,7 +102,6 @@ fn prepare_command_params( ); } } - println!("======= sub_recipe_params: {:?}", sub_recipe_params); Ok(sub_recipe_params) } diff --git a/crates/goose/src/recipe/mod.rs b/crates/goose/src/recipe/mod.rs index dcabe9bf1322..75cb4c90e4a3 100644 --- a/crates/goose/src/recipe/mod.rs +++ b/crates/goose/src/recipe/mod.rs @@ -1,8 +1,11 @@ use anyhow::Result; +use serde_json::Value; +use std::collections::HashMap; use std::fmt; use crate::agents::extension::ExtensionConfig; use serde::{Deserialize, Serialize}; +use serde::de::{Deserializer, Error as DeError}; fn default_version() -> String { "1.0.0".to_string() @@ -120,13 +123,33 @@ pub struct Settings { pub struct SubRecipe { pub name: String, pub path: String, - pub params: Option>, + #[serde(default, deserialize_with = "deserialize_value_map_as_string")] + pub values: Option>, } -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct SubRecipeParams { - pub name: String, - pub value: String, +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)] diff --git a/sub_recipe_simple.yaml b/sub_recipe_simple.yaml index a1d97c482113..657f24126dad 100644 --- a/sub_recipe_simple.yaml +++ b/sub_recipe_simple.yaml @@ -5,15 +5,18 @@ author: description: Will do multiple things instructions: Your job is to do multiple things prompt: | - 1. use weather sub-recipe to get the weather for sydney + 1. use weather sub-recipe to get the weather + 3. use joke-of-the-day sub-recipe sub_recipes: - name: joke-of-the-day path: "{{recipe_dir}}/joke-of-the-day.yaml" - params: - - name: date - value: "{{date}}" + values: + date: "{{date}}" - name: weather path: "{{recipe_dir}}/weather.yaml" + values: + city: "{{city}}" + forecast: true extensions: - type: builtin name: developer @@ -25,3 +28,7 @@ parameters: input_type: string requirement: required description: date provided by the user +- key: city + input_type: string + requirement: required + description: city provided by the user diff --git a/weather.yaml b/weather.yaml index 9ddb6f0f1bf1..30b9b19acb80 100644 --- a/weather.yaml +++ b/weather.yaml @@ -3,12 +3,16 @@ title: Find weather description: Get weather data for a city instructions: You are a weather expert. You will be given a city and you will need to return the weather data for that city. prompt: | - Get the weather for {{ city }} for today + Get the weather for {{ city }} for today. If {{ forecast }} is true, get the forecast for the city for next 3 days. parameters: - key: city input_type: string requirement: required description: city name + - key: forecast + input_type: boolean + requirement: required + description: whether to get the forecast for the city extensions: - type: stdio name: weather From 77ab90c034a30afd312064a2b63249f3394ddf13 Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Wed, 18 Jun 2025 22:31:27 +1000 Subject: [PATCH 09/17] cleanup --- .../goose/src/agents/recipe_tools/sub_recipe_tools.rs | 10 ++-------- crates/goose/src/recipe/mod.rs | 2 +- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs b/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs index 3c6f29f5c27d..8be85d8cb2a3 100644 --- a/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs +++ b/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs @@ -42,10 +42,7 @@ 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(), - ); + sub_recipe_params_map.insert(param_name.clone(), param_value.clone()); } } let parameter_definition = get_sub_recipe_parameter_definition(sub_recipe)?; @@ -88,10 +85,7 @@ fn prepare_command_params( 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(), - ); + sub_recipe_params.insert(param_name.clone(), param_value.clone()); } } if let Some(params_map) = params.as_object() { diff --git a/crates/goose/src/recipe/mod.rs b/crates/goose/src/recipe/mod.rs index 75cb4c90e4a3..8d74adff46b2 100644 --- a/crates/goose/src/recipe/mod.rs +++ b/crates/goose/src/recipe/mod.rs @@ -4,8 +4,8 @@ use std::collections::HashMap; use std::fmt; use crate::agents::extension::ExtensionConfig; +use serde::de::Deserializer; use serde::{Deserialize, Serialize}; -use serde::de::{Deserializer, Error as DeError}; fn default_version() -> String { "1.0.0".to_string() From 8d5d4ddaf4a085dd77d7ad21c38289f6364f6896 Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Wed, 18 Jun 2025 23:26:38 +1000 Subject: [PATCH 10/17] added test cases --- .../agents/recipe_tools/sub_recipe_tools.rs | 3 + .../recipe_tools/sub_recipe_tools/tests.rs | 165 +++++++++++++++ crates/goose/src/recipe/mod.rs | 198 ++++++++++++++++++ 3 files changed, 366 insertions(+) create mode 100644 crates/goose/src/agents/recipe_tools/sub_recipe_tools/tests.rs diff --git a/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs b/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs index 8be85d8cb2a3..cc06c14bd9dd 100644 --- a/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs +++ b/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs @@ -162,3 +162,6 @@ pub async fn run_sub_recipe(sub_recipe: &SubRecipe, params: Value) -> Result 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("key1".to_string(), "new_value".to_string()); + 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(&"new_value".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(), 2); + + let key1_prop = &properties["key1"]; + assert_eq!(key1_prop["type"], "string"); + assert!(key1_prop["description"] + .as_str() + .unwrap() + .contains("currently the value is set to value1")); + + 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(), 1); + assert_eq!(required[0], "key1"); + } + + #[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"); + let description = key1_prop["description"].as_str().unwrap(); + assert!(description.contains("A test parameter")); + assert!(!description.contains("currently the value is set to")); + assert_eq!(result["required"].as_array().unwrap().len(), 1); + } + } +} diff --git a/crates/goose/src/recipe/mod.rs b/crates/goose/src/recipe/mod.rs index 8d74adff46b2..9b93f7ec8254 100644 --- a/crates/goose/src/recipe/mod.rs +++ b/crates/goose/src/recipe/mod.rs @@ -359,3 +359,201 @@ impl RecipeBuilder { }) } } + +#[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"]); + } +} From 94308e099d9ab703e728ea68240d02c30d910039 Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Wed, 18 Jun 2025 23:31:44 +1000 Subject: [PATCH 11/17] removed test recipes --- joke-of-the-day.yaml | 29 ----------------------------- sub_recipe_simple.yaml | 34 ---------------------------------- weather.yaml | 30 ------------------------------ 3 files changed, 93 deletions(-) delete mode 100644 joke-of-the-day.yaml delete mode 100644 sub_recipe_simple.yaml delete mode 100644 weather.yaml diff --git a/joke-of-the-day.yaml b/joke-of-the-day.yaml deleted file mode 100644 index f2eb4d61a772..000000000000 --- a/joke-of-the-day.yaml +++ /dev/null @@ -1,29 +0,0 @@ -version: 1.0.0 -title: Joke of the day -author: - contact: douwe -description: Will tell you a joke fo the day based on the current day -instructions: Your job is to tell a joke of the day -extensions: -- type: builtin - name: developer - display_name: Developer - timeout: 300 - bundled: true -- args: - - mcp-server-fetch - bundled: null - cmd: uvx - description: '' - enabled: false - env_keys: [] - envs: {} - name: fetch - timeout: 300 - type: stdio -prompt: "with provided date {{date}} generate a joke on that day . Mention the day on the first line then an empty line and then the joke. Don't just say the date, but figure out if there's any cultural significance, like national shelves day" -parameters: -- key: date - input_type: string - requirement: required - description: date provided by the user diff --git a/sub_recipe_simple.yaml b/sub_recipe_simple.yaml deleted file mode 100644 index 657f24126dad..000000000000 --- a/sub_recipe_simple.yaml +++ /dev/null @@ -1,34 +0,0 @@ -version: 1.0.0 -title: top recipes -author: - contact: douwe -description: Will do multiple things -instructions: Your job is to do multiple things -prompt: | - 1. use weather sub-recipe to get the weather - 3. use joke-of-the-day sub-recipe -sub_recipes: -- name: joke-of-the-day - path: "{{recipe_dir}}/joke-of-the-day.yaml" - values: - date: "{{date}}" -- name: weather - path: "{{recipe_dir}}/weather.yaml" - values: - city: "{{city}}" - forecast: true -extensions: -- type: builtin - name: developer - display_name: Developer - timeout: 300 - bundled: true -parameters: -- key: date - input_type: string - requirement: required - description: date provided by the user -- key: city - input_type: string - requirement: required - description: city provided by the user diff --git a/weather.yaml b/weather.yaml deleted file mode 100644 index 30b9b19acb80..000000000000 --- a/weather.yaml +++ /dev/null @@ -1,30 +0,0 @@ -version: 1.0.0 -title: Find weather -description: Get weather data for a city -instructions: You are a weather expert. You will be given a city and you will need to return the weather data for that city. -prompt: | - Get the weather for {{ city }} for today. If {{ forecast }} is true, get the forecast for the city for next 3 days. -parameters: - - key: city - input_type: string - requirement: required - description: city name - - key: forecast - input_type: boolean - requirement: required - description: whether to get the forecast for the city -extensions: - - type: stdio - name: weather - cmd: uvx - args: - - mcp_weather@latest - timeout: 300 - description: "Weather data for trip planning" - - type: builtin - name: developer - display_name: Developer - timeout: 300 - bundled: true -author: - contact: dkatz \ No newline at end of file From 8592fb0cd69945265dd3678e11ec85e5a20ed725 Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Thu, 19 Jun 2025 00:21:59 +1000 Subject: [PATCH 12/17] not providing tool params for sub recipe tools if the value of the param is defined in the main recipe --- .../agents/recipe_tools/sub_recipe_tools.rs | 9 ++++----- .../recipe_tools/sub_recipe_tools/tests.rs | 20 +++++-------------- 2 files changed, 9 insertions(+), 20 deletions(-) diff --git a/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs b/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs index cc06c14bd9dd..ee09a4c3cb63 100644 --- a/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs +++ b/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs @@ -50,15 +50,14 @@ fn get_input_schema(sub_recipe: &SubRecipe) -> Result { let mut properties = Map::new(); let mut required = Vec::new(); for param in parameters { - let mut description = param.description.clone(); if sub_recipe_params_map.contains_key(¶m.key) { - description = format!("{}, currently the value is set to {}. If you want to change the value, please provide a new value.", description, sub_recipe_params_map.get(¶m.key).unwrap()); + continue; } properties.insert( param.key.clone(), json!({ "type": param.input_type.to_string(), - "description": description, + "description": param.description.clone(), }), ); if !matches!(param.requirement, RecipeParameterRequirement::Optional) { @@ -80,7 +79,7 @@ fn get_input_schema(sub_recipe: &SubRecipe) -> Result { fn prepare_command_params( sub_recipe: &SubRecipe, - params: Value, + params_from_tool_call: Value, ) -> Result> { let mut sub_recipe_params = HashMap::::new(); if let Some(params_with_value) = &sub_recipe.values { @@ -88,7 +87,7 @@ fn prepare_command_params( sub_recipe_params.insert(param_name.clone(), param_value.clone()); } } - if let Some(params_map) = params.as_object() { + if let Some(params_map) = params_from_tool_call.as_object() { for (key, value) in params_map { sub_recipe_params.insert( key.to_string(), 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 index a24f5e12d290..11ce390a6b3b 100644 --- a/crates/goose/src/agents/recipe_tools/sub_recipe_tools/tests.rs +++ b/crates/goose/src/agents/recipe_tools/sub_recipe_tools/tests.rs @@ -25,7 +25,6 @@ mod tests { #[test] fn test_prepare_command_params_basic() { let mut params = HashMap::new(); - params.insert("key1".to_string(), "new_value".to_string()); params.insert("key2".to_string(), "value2".to_string()); let sub_recipe = setup_sub_recipe(); @@ -33,7 +32,7 @@ mod tests { 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(&"new_value".to_string())); + assert_eq!(result.get("key1"), Some(&"value1".to_string())); assert_eq!(result.get("key2"), Some(&"value2".to_string())); } @@ -98,22 +97,14 @@ mod tests { assert!(result["properties"].is_object()); let properties = result["properties"].as_object().unwrap(); - assert_eq!(properties.len(), 2); - - let key1_prop = &properties["key1"]; - assert_eq!(key1_prop["type"], "string"); - assert!(key1_prop["description"] - .as_str() - .unwrap() - .contains("currently the value is set to value1")); + 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(), 1); - assert_eq!(required[0], "key1"); + assert_eq!(required.len(), 0); } #[test] @@ -156,10 +147,9 @@ mod tests { let key1_prop = &properties["key1"]; assert_eq!(key1_prop["type"], "string"); - let description = key1_prop["description"].as_str().unwrap(); - assert!(description.contains("A test parameter")); - assert!(!description.contains("currently the value is set to")); + assert_eq!(key1_prop["description"], "A test parameter"); assert_eq!(result["required"].as_array().unwrap().len(), 1); + assert_eq!(result["required"][0], "key1"); } } } From 6c26884aebbdb5c86d5d3e97d79a2fcc3233fa63 Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Fri, 20 Jun 2025 14:40:36 +1000 Subject: [PATCH 13/17] address pr comments --- crates/goose/src/agents/agent.rs | 8 ++--- .../agents/recipe_tools/sub_recipe_tools.rs | 8 ++--- crates/goose/src/agents/sub_recipe_manager.rs | 34 +++++++++++++------ 3 files changed, 30 insertions(+), 20 deletions(-) diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 08973f3f3c77..7847bf210c93 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -235,11 +235,9 @@ impl Agent { let sub_recipe_manager = self.sub_recipe_manager.lock().await; let result: ToolCallResult = if sub_recipe_manager.is_sub_recipe_tool(&tool_call.name) { - ToolCallResult::from( - sub_recipe_manager - .call_sub_recipe_tool(&tool_call.name, tool_call.arguments.clone()) - .await, - ) + 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( diff --git a/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs b/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs index ee09a4c3cb63..b5f2bdf500c8 100644 --- a/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs +++ b/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs @@ -8,7 +8,7 @@ use tokio::process::Command; use crate::recipe::{Recipe, RecipeParameter, RecipeParameterRequirement, SubRecipe}; -pub const SUB_RECIPE_TOOL_NAME_PREFIX: &str = "subrecipe__run_sub_recipe"; +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(); @@ -21,10 +21,10 @@ pub fn create_sub_recipe_tool(sub_recipe: &SubRecipe) -> Tool { input_schema, Some(ToolAnnotations { title: Some(format!("run sub recipe {}", sub_recipe.name)), - read_only_hint: true, - destructive_hint: false, + read_only_hint: false, + destructive_hint: true, idempotent_hint: false, - open_world_hint: false, + open_world_hint: true, }), ) } diff --git a/crates/goose/src/agents/sub_recipe_manager.rs b/crates/goose/src/agents/sub_recipe_manager.rs index f4ec04eb56c2..4f4259c95525 100644 --- a/crates/goose/src/agents/sub_recipe_manager.rs +++ b/crates/goose/src/agents/sub_recipe_manager.rs @@ -3,9 +3,9 @@ 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, - }, + agents::{recipe_tools::sub_recipe_tools::{ + create_sub_recipe_tool, run_sub_recipe, SUB_RECIPE_TOOL_NAME_PREFIX + }, tool_execution::ToolCallResult}, recipe::SubRecipe, }; @@ -31,23 +31,36 @@ impl SubRecipeManager { pub fn add_sub_recipe_tools(&mut self, sub_recipes_to_add: Vec) { for sub_recipe in sub_recipes_to_add { - let sub_recipe_name = sub_recipe.name.clone(); + 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_name.clone(), tool); - self.sub_recipes.insert(sub_recipe_name, 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 { - tool_name.starts_with(SUB_RECIPE_TOOL_NAME_PREFIX) + self.sub_recipe_tools.contains_key(tool_name) } - pub async fn call_sub_recipe_tool( + 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_name = tool_name + 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(|| { @@ -55,9 +68,8 @@ impl SubRecipeManager { "Invalid sub-recipe tool name format: {}", tool_name )) - })?; + }).unwrap(); - let sub_recipe = self.sub_recipes.get(sub_recipe_name).ok_or_else(|| { ToolError::InvalidParameters(format!("Sub-recipe '{}' not found", sub_recipe_name)) })?; From 559217f6b5ac9d9414ca69bc12a47abf9b6bf31c Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Fri, 20 Jun 2025 15:52:08 +1000 Subject: [PATCH 14/17] fixed the format --- crates/goose/src/agents/sub_recipe_manager.rs | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/crates/goose/src/agents/sub_recipe_manager.rs b/crates/goose/src/agents/sub_recipe_manager.rs index 4f4259c95525..3637c947c97c 100644 --- a/crates/goose/src/agents/sub_recipe_manager.rs +++ b/crates/goose/src/agents/sub_recipe_manager.rs @@ -3,9 +3,12 @@ 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}, + agents::{ + recipe_tools::sub_recipe_tools::{ + create_sub_recipe_tool, run_sub_recipe, SUB_RECIPE_TOOL_NAME_PREFIX, + }, + tool_execution::ToolCallResult, + }, recipe::SubRecipe, }; @@ -31,7 +34,11 @@ impl SubRecipeManager { 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 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); @@ -61,14 +68,15 @@ impl SubRecipeManager { ) -> 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(); + .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)) })?; From 69db1a3a3363fc245a53ba6ca3a5d16b83b4920e Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Mon, 23 Jun 2025 15:46:09 +1000 Subject: [PATCH 15/17] fixed merge conflicts --- crates/goose-cli/src/cli.rs | 9 ++++-- crates/goose-cli/src/recipes/recipe.rs | 29 +------------------ .../agents/recipe_tools/sub_recipe_tools.rs | 3 +- 3 files changed, 10 insertions(+), 31 deletions(-) diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index e92c6bd43766..1c0b20bb2723 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -724,8 +724,13 @@ pub async fn cli() -> Result<()> { scheduled_job_id, quiet, }) => { - let (input_config, session_settings, subrecipes) = match (instructions, input_text, recipe, explain, render_recipe) - { + let (input_config, session_settings, sub_recipes) = match ( + instructions, + input_text, + recipe, + explain, + render_recipe, + ) { (Some(file), _, _, _, _) if file == "-" => { let mut input = String::new(); std::io::stdin() diff --git a/crates/goose-cli/src/recipes/recipe.rs b/crates/goose-cli/src/recipes/recipe.rs index 5754ef9dbb94..530d38e701eb 100644 --- a/crates/goose-cli/src/recipes/recipe.rs +++ b/crates/goose-cli/src/recipes/recipe.rs @@ -7,8 +7,6 @@ use anyhow::Result; use console::style; use goose::recipe::{Recipe, RecipeParameter, RecipeParameterRequirement}; use minijinja::{Environment, Error, UndefinedBehavior}; -use serde_json::Value as JsonValue; -use serde_yaml::Value as YamlValue; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; @@ -81,32 +79,7 @@ pub fn explain_recipe_with_parameters( } fn extract_parameters_from_content(content: &str) -> Result>> { - let lines = content.lines(); - let mut params_block = String::new(); - let mut collecting = false; - - for line in lines { - if line.starts_with("parameters:") { - collecting = true; - } - if collecting { - if !line.is_empty() && !line.starts_with(' ') && !line.starts_with('\t') { - let parameters: Vec = serde_yaml::from_str(¶ms_block) - .map_err(|e| anyhow::anyhow!("Failed to parse parameters block: {}", e))?; - return Ok(Some(parameters)); - } - params_block.push_str(line); - params_block.push('\n'); - } - } - - // 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))?; + let recipe = Recipe::from_content(content)?; Ok(recipe.parameters) } diff --git a/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs b/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs index b5f2bdf500c8..2fd4f50434a9 100644 --- a/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs +++ b/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs @@ -16,7 +16,8 @@ pub fn create_sub_recipe_tool(sub_recipe: &SubRecipe) -> Tool { 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." + The sub recipe will be run with the provided parameters + and return the output of the sub recipe." .to_string(), input_schema, Some(ToolAnnotations { From 60427073ce0da57d4b36c1c1e8cec0b8b90ce135 Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Tue, 24 Jun 2025 15:45:57 +1000 Subject: [PATCH 16/17] add tests --- crates/goose-cli/src/commands/recipe.rs | 74 ++++++++++++++++++- crates/goose-cli/src/recipes/recipe.rs | 7 +- .../goose-cli/src/recipes/template_recipe.rs | 3 +- 3 files changed, 76 insertions(+), 8 deletions(-) diff --git a/crates/goose-cli/src/commands/recipe.rs b/crates/goose-cli/src/commands/recipe.rs index d4c700c27bb6..deadf0f0a840 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/recipe.rs b/crates/goose-cli/src/recipes/recipe.rs index b2aefb88c697..aa0b3685435e 100644 --- a/crates/goose-cli/src/recipes/recipe.rs +++ b/crates/goose-cli/src/recipes/recipe.rs @@ -4,8 +4,7 @@ use crate::recipes::print_recipe::{ }; use crate::recipes::search_recipe::retrieve_recipe_file; use crate::recipes::template_recipe::{ - parse_recipe_content, render_recipe_content_with_params, - render_recipe_content_without_all_variables, + parse_recipe_content, render_recipe_content_with_params, render_recipe_for_preview, }; use anyhow::Result; use console::style; @@ -76,7 +75,7 @@ pub fn load_recipe(recipe_name: &str) -> Result { .to_str() .ok_or_else(|| anyhow::anyhow!("Error getting recipe directory"))?; validate_recipe_parameters(&recipe_file_content, recipe_dir_str)?; - let recipe = render_recipe_content_without_all_variables( + let recipe = render_recipe_for_preview( &recipe_file_content, recipe_dir_str.to_string(), &HashMap::new(), @@ -96,7 +95,7 @@ pub fn explain_recipe_with_parameters( let (params_for_template, missing_params) = apply_values_to_parameters(¶ms, recipe_parameters, recipe_dir_str, false)?; - let recipe = render_recipe_content_without_all_variables( + let recipe = render_recipe_for_preview( &recipe_file_content, recipe_dir_str.to_string(), ¶ms_for_template, diff --git a/crates/goose-cli/src/recipes/template_recipe.rs b/crates/goose-cli/src/recipes/template_recipe.rs index 7558a934fc21..0fe659cb7754 100644 --- a/crates/goose-cli/src/recipes/template_recipe.rs +++ b/crates/goose-cli/src/recipes/template_recipe.rs @@ -94,7 +94,8 @@ pub fn parse_recipe_content( Ok((recipe, template_variables)) } -pub fn render_recipe_content_without_all_variables( +// render the recipe for validation, deeplink and explain, etc. +pub fn render_recipe_for_preview( content: &str, recipe_dir: String, params: &HashMap, From 9822f662b949de32670849bcefeb5cb8875117af Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Tue, 24 Jun 2025 16:03:18 +1000 Subject: [PATCH 17/17] fixed tests --- crates/goose-cli/src/recipes/recipe.rs | 2 +- .../goose-cli/src/recipes/template_recipe.rs | 28 +++++++++++-------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/crates/goose-cli/src/recipes/recipe.rs b/crates/goose-cli/src/recipes/recipe.rs index aa0b3685435e..c151f037c11f 100644 --- a/crates/goose-cli/src/recipes/recipe.rs +++ b/crates/goose-cli/src/recipes/recipe.rs @@ -90,7 +90,7 @@ pub fn explain_recipe_with_parameters( 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!("Invalid UTF-8 in recipe_dir"))?; + .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) = diff --git a/crates/goose-cli/src/recipes/template_recipe.rs b/crates/goose-cli/src/recipes/template_recipe.rs index 0fe659cb7754..2792f20ba662 100644 --- a/crates/goose-cli/src/recipes/template_recipe.rs +++ b/crates/goose-cli/src/recipes/template_recipe.rs @@ -84,8 +84,6 @@ pub fn parse_recipe_content( let (env, template_variables) = get_env_with_template_variables(content, recipe_dir, UndefinedBehavior::Lenient)?; let template = env.get_template(CURRENT_TEMPLATE_NAME).unwrap(); - // let mut ctx = preserve_vars(&template_variables).clone(); - // ctx.extend(params.clone()); let rendered_content = template .render(()) .map_err(|e| anyhow::anyhow!("Failed to parse the recipe {}", e))?; @@ -131,38 +129,44 @@ mod tests { 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 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 mut params = HashMap::new(); - params.insert("empty".to_string(), "".to_string()); + 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 mut params = HashMap::new(); - params.insert("greeting".to_string(), "Hi".to_string()); - params.insert("name".to_string(), "Alice".to_string()); + 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::new(); + 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::new(); + 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("Invalid template syntax")); + assert!(err.to_string().contains("unexpected end of input")); } } }