diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 7afc4f030c83..ecbe7de7509f 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -16,7 +16,9 @@ use crate::commands::schedule::{ }; use crate::commands::session::{handle_session_list, handle_session_remove}; use crate::logging::setup_logging; -use crate::recipes::recipe::{explain_recipe_with_parameters, load_recipe_as_template}; +use crate::recipes::recipe::{ + explain_recipe_with_parameters, load_recipe_as_template, subrecipe_extension, +}; use crate::session; use crate::session::{build_session, SessionBuilderConfig}; use goose_bench::bench_config::BenchRunConfig; @@ -703,9 +705,24 @@ pub async fn cli() -> Result<()> { eprintln!("{}: {}", console::style("Error").red().bold(), err); std::process::exit(1); }); + + let extensions = if let Some(subrecipes) = + recipe.subrecipes.as_ref().filter(|s| !s.is_empty()) + { + Some( + vec![ + recipe.extensions.unwrap_or_else(Vec::new), + vec![subrecipe_extension(subrecipes)], + ] + .concat(), + ) + } else { + recipe.extensions + }; + InputConfig { contents: recipe.prompt, - extensions_override: recipe.extensions, + extensions_override: extensions, additional_system_prompt: recipe.instructions, } } diff --git a/crates/goose-cli/src/recipes/recipe.rs b/crates/goose-cli/src/recipes/recipe.rs index b90de236a06b..cb57e8aa016b 100644 --- a/crates/goose-cli/src/recipes/recipe.rs +++ b/crates/goose-cli/src/recipes/recipe.rs @@ -1,17 +1,20 @@ use anyhow::Result; use console::style; +use goose::agents::extension::Envs; +use goose::config::ExtensionConfig; use crate::recipes::print_recipe::{ missing_parameters_command_line, print_parameters_with_values, print_recipe_explanation, print_required_parameters_for_template, }; use crate::recipes::search_recipe::retrieve_recipe_file; -use goose::recipe::{Recipe, RecipeParameter, RecipeParameterRequirement}; +use goose::recipe::{Recipe, RecipeParameter, RecipeParameterRequirement, SubRecipe}; 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; +use std::fs; +use std::path::{Path, PathBuf}; pub const BUILT_IN_RECIPE_DIR_PARAM: &str = "recipe_dir"; pub const RECIPE_FILE_EXTENSIONS: &[&str] = &["yaml", "json"]; @@ -260,6 +263,38 @@ fn render_content_with_params(content: &str, params: &HashMap) - }) } +pub fn subrecipe_extension(subrecipes: &Vec) -> ExtensionConfig { + let subrecipes: Vec = subrecipes + .iter() + .map(|sr| { + let abspath = match fs::canonicalize(Path::new(&sr.path)) { + Ok(path) => path.to_str().unwrap_or_default().to_string(), + Err(e) => { + eprintln!("Failed to canonicalize subrecipe path '{}': {}", sr.path, e); + sr.path.clone() + } + }; + SubRecipe { path: abspath } + }) + .collect(); + + let json = serde_json::to_string(&subrecipes).expect("Failed to serialize subrecipes"); + ExtensionConfig::Stdio { + name: String::from("subrecipes"), + cmd: String::from("uvx"), + args: vec![ + String::from("subrecipes-mcp"), + String::from("--subrecipes-json"), + json, + ], + envs: Envs::default(), + timeout: None, + env_keys: vec![], + description: Some(String::from("Execute sub-recipes using the provided tools")), + bundled: None, + } +} + #[cfg(test)] mod tests { use std::path::PathBuf; diff --git a/crates/goose/src/agents/extension_manager.rs b/crates/goose/src/agents/extension_manager.rs index aa8d117297e0..c159b943b4d0 100644 --- a/crates/goose/src/agents/extension_manager.rs +++ b/crates/goose/src/agents/extension_manager.rs @@ -258,6 +258,8 @@ impl ExtensionManager { .await .map_err(|e| ExtensionError::Initialization(config.clone(), e))?; + // dbg!(&init_result); + if let Some(instructions) = init_result.instructions { self.instructions .insert(sanitized_name.clone(), instructions); diff --git a/crates/goose/src/agents/reply_parts.rs b/crates/goose/src/agents/reply_parts.rs index 5b4b6d710a69..b1f0944cf5d4 100644 --- a/crates/goose/src/agents/reply_parts.rs +++ b/crates/goose/src/agents/reply_parts.rs @@ -63,6 +63,7 @@ impl Agent { Some(model_name), tool_selection_strategy, ); + // println!("Using system prompt: {}", system_prompt); // Handle toolshim if enabled let mut toolshim_tools = vec![]; diff --git a/crates/goose/src/recipe/mod.rs b/crates/goose/src/recipe/mod.rs index 510ba000a02c..a6a2bcbf7963 100644 --- a/crates/goose/src/recipe/mod.rs +++ b/crates/goose/src/recipe/mod.rs @@ -85,6 +85,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 subrecipes: Option>, // any sub-recipes that this recipe depends on } #[derive(Serialize, Deserialize, Debug)] @@ -144,6 +147,11 @@ pub struct RecipeParameter { pub default: Option, } +#[derive(Serialize, Deserialize, Debug)] +pub struct SubRecipe { + pub path: String, // path to the sub-recipe file +} + /// Builder for creating Recipe instances pub struct RecipeBuilder { // Required fields with default values @@ -159,6 +167,7 @@ pub struct RecipeBuilder { activities: Option>, author: Option, parameters: Option>, + subrecipes: Option>, } impl Recipe { @@ -188,6 +197,7 @@ impl Recipe { activities: None, author: None, parameters: None, + subrecipes: None, } } } @@ -252,6 +262,12 @@ impl RecipeBuilder { self } + /// Sets the sub-recipes for the Recipe + pub fn subrecipes(mut self, subrecipes: Vec) -> Self { + self.subrecipes = Some(subrecipes); + self + } + /// Builds the Recipe instance /// /// Returns an error if any required fields are missing @@ -274,6 +290,7 @@ impl RecipeBuilder { activities: self.activities, author: self.author, parameters: self.parameters, + subrecipes: self.subrecipes, }) } } diff --git a/subrecipes-mcp/.gitignore b/subrecipes-mcp/.gitignore new file mode 100644 index 000000000000..292dee58c652 --- /dev/null +++ b/subrecipes-mcp/.gitignore @@ -0,0 +1,2 @@ +build +subrecipes_mcp.egg-info/ diff --git a/subrecipes-mcp/README.md b/subrecipes-mcp/README.md new file mode 100644 index 000000000000..2501a55508c5 --- /dev/null +++ b/subrecipes-mcp/README.md @@ -0,0 +1,5 @@ +an MCP server that runs goose recipes, to be used in a prototype for subrecipe implementaiton. + +install using: + + uv tool install -e . diff --git a/subrecipes-mcp/main.py b/subrecipes-mcp/main.py new file mode 100644 index 000000000000..5f52d3d0f570 --- /dev/null +++ b/subrecipes-mcp/main.py @@ -0,0 +1,83 @@ +import argparse +import asyncio +import json +from typing import Awaitable, Callable +from pathlib import Path +from fastmcp import FastMCP, Context +from mcp import types + +server = FastMCP( + "Subrecipes 🚀", + instructions="This extension allows you to run subrecipes.", +) + +del server._mcp_server.request_handlers[ + types.ListResourcesRequest +] # Otherwise the Goose system prompt says this supports resources + + +def runner(subrecipe: dict) -> tuple[str, Callable[[Context], Awaitable[str]]]: + path = Path(subrecipe["path"]) + name = ( + "_".join(path.parts[-2:]) + .removesuffix(".yaml") + .removesuffix(".yml") + .removesuffix(".json") + )[:64] # Claude rejected a name for being longer than 64 characters + name = name.replace(".", "_").replace(" ", "_") + + async def tool_func(ctx: Context) -> str: + await ctx.info(f"Running subrecipe {name}") + + # shell out to the subrecipe and stream stdout/stderr + process = await asyncio.create_subprocess_exec( + "goose", + "run", + "--recipe", + path, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + + output = [] + + async def read_stream(stream, name): + while True: + line = await stream.readline() + if not line: + break + if line := line.decode("utf-8").rstrip(): + await ctx.log(f"{name}: {line}") + output.append(line) + + await asyncio.gather( + read_stream(process.stdout, "stdout"), + read_stream(process.stderr, "stderr"), + ) + + return_code = await process.wait() + return f"Subrecipe {name} finished with return code {return_code}" + + return name, tool_func + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--subrecipes-json", type=str, required=True) + args = parser.parse_args() + + subrecipes_json = json.loads(args.subrecipes_json) + + for subrecipe in subrecipes_json: + name, tool_func = runner(subrecipe) + server.tool( + tool_func, + name=name, + description=f"Run {name}", + ) + + server.run() + + +if __name__ == "__main__": + main() diff --git a/subrecipes-mcp/pyproject.toml b/subrecipes-mcp/pyproject.toml new file mode 100644 index 000000000000..c755ac8f84a0 --- /dev/null +++ b/subrecipes-mcp/pyproject.toml @@ -0,0 +1,11 @@ +[project] +name = "subrecipes-mcp" +version = "0.1.0" +description = "Add your description here" +requires-python = ">=3.13" +dependencies = [ + "fastmcp>=2.3.4", +] + +[project.scripts] +subrecipes-mcp = "main:main" diff --git a/subrecipes.yaml b/subrecipes.yaml new file mode 100644 index 000000000000..2b0c954ac27c --- /dev/null +++ b/subrecipes.yaml @@ -0,0 +1,25 @@ +version: 1.0.0 +title: "Goose Recipe Handler" +description: "A simple request router using sub-recipes" + +prompt: | + You are tasked with answering incoming requests and taking action. + Read the oldest file in 'inbox' and see if you can help. + + Use the tools available to you to process the request. + + Write replies to the 'oubox' folder, with the same name as the file you read from 'inbox'. + + If there is no appropriate action to take, just reply stating that you cannot help. + + If you can help, respond with the result of your action. + + Then "touch" the file in 'inbox' to mark it as processed. + +extensions: + - type: builtin # just to read the file + name: developer + +subrecipes: + - path: ../goose-recipes/joke-of-the-day/recipe.yaml + - path: ../goose-recipes/tutorial-trip-planner/recipe.yaml