Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions crates/goose-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
}
}
Expand Down
39 changes: 37 additions & 2 deletions crates/goose-cli/src/recipes/recipe.rs
Original file line number Diff line number Diff line change
@@ -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"];
Expand Down Expand Up @@ -260,6 +263,38 @@ fn render_content_with_params(content: &str, params: &HashMap<String, String>) -
})
}

pub fn subrecipe_extension(subrecipes: &Vec<SubRecipe>) -> ExtensionConfig {
let subrecipes: Vec<SubRecipe> = 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;
Expand Down
2 changes: 2 additions & 0 deletions crates/goose/src/agents/extension_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions crates/goose/src/agents/reply_parts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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![];
Expand Down
17 changes: 17 additions & 0 deletions crates/goose/src/recipe/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ pub struct Recipe {

#[serde(skip_serializing_if = "Option::is_none")]
pub parameters: Option<Vec<RecipeParameter>>, // any additional parameters for the recipe

#[serde(skip_serializing_if = "Option::is_none")]
pub subrecipes: Option<Vec<SubRecipe>>, // any sub-recipes that this recipe depends on
}

#[derive(Serialize, Deserialize, Debug)]
Expand Down Expand Up @@ -144,6 +147,11 @@ pub struct RecipeParameter {
pub default: Option<String>,
}

#[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
Expand All @@ -159,6 +167,7 @@ pub struct RecipeBuilder {
activities: Option<Vec<String>>,
author: Option<Author>,
parameters: Option<Vec<RecipeParameter>>,
subrecipes: Option<Vec<SubRecipe>>,
}

impl Recipe {
Expand Down Expand Up @@ -188,6 +197,7 @@ impl Recipe {
activities: None,
author: None,
parameters: None,
subrecipes: None,
}
}
}
Expand Down Expand Up @@ -252,6 +262,12 @@ impl RecipeBuilder {
self
}

/// Sets the sub-recipes for the Recipe
pub fn subrecipes(mut self, subrecipes: Vec<SubRecipe>) -> Self {
self.subrecipes = Some(subrecipes);
self
}

/// Builds the Recipe instance
///
/// Returns an error if any required fields are missing
Expand All @@ -274,6 +290,7 @@ impl RecipeBuilder {
activities: self.activities,
author: self.author,
parameters: self.parameters,
subrecipes: self.subrecipes,
})
}
}
2 changes: 2 additions & 0 deletions subrecipes-mcp/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
build
subrecipes_mcp.egg-info/
5 changes: 5 additions & 0 deletions subrecipes-mcp/README.md
Original file line number Diff line number Diff line change
@@ -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 .
83 changes: 83 additions & 0 deletions subrecipes-mcp/main.py
Original file line number Diff line number Diff line change
@@ -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]]]:
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lifeizhou-ap my thinking is that we turn these into tools added and handled by Goose itself, without the MCP layer at all. Then we can start with an implementation where Goose still shells out to itself. And that leaves the door open to Goose running the subrecipe in its own separate agent in-process

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()
11 changes: 11 additions & 0 deletions subrecipes-mcp/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
25 changes: 25 additions & 0 deletions subrecipes.yaml
Original file line number Diff line number Diff line change
@@ -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
Loading