feat: created sub recipe tools (#2982)

This commit is contained in:
Lifei Zhou
2025-06-25 09:29:26 +10:00
committed by GitHub
parent 566796f767
commit 9e6247d9ed
18 changed files with 1329 additions and 426 deletions

View File

@@ -53,7 +53,7 @@ shlex = "1.3.0"
async-trait = "0.1.86"
base64 = "0.22.1"
regex = "1.11.1"
minijinja = { version = "2.8.0", features = ["loader"] }
minijinja = { version = "2.10.2", features = ["loader"] }
nix = { version = "0.30.1", features = ["process", "signal"] }
tar = "0.4"
# Web server dependencies

View File

@@ -675,6 +675,7 @@ pub async fn cli() -> Result<()> {
scheduled_job_id: None,
interactive: true,
quiet: false,
sub_recipes: None,
})
.await;
setup_logging(
@@ -723,7 +724,7 @@ pub async fn cli() -> Result<()> {
scheduled_job_id,
quiet,
}) => {
let (input_config, session_settings) = match (
let (input_config, session_settings, sub_recipes) = match (
instructions,
input_text,
recipe,
@@ -743,6 +744,7 @@ pub async fn cli() -> Result<()> {
additional_system_prompt: system,
},
None,
None,
)
}
(Some(file), _, _, _, _) => {
@@ -760,6 +762,7 @@ pub async fn cli() -> Result<()> {
additional_system_prompt: None,
},
None,
None,
)
}
(_, Some(text), _, _, _) => (
@@ -769,6 +772,7 @@ pub async fn cli() -> Result<()> {
additional_system_prompt: system,
},
None,
None,
),
(_, _, Some(recipe_name), explain, render_recipe) => {
if explain {
@@ -800,6 +804,7 @@ pub async fn cli() -> Result<()> {
goose_model: s.goose_model,
temperature: s.temperature,
}),
recipe.sub_recipes,
)
}
(None, None, None, _, _) => {
@@ -823,6 +828,7 @@ pub async fn cli() -> Result<()> {
scheduled_job_id,
interactive, // Use the interactive flag from the Run command
quiet,
sub_recipes,
})
.await;
@@ -941,6 +947,7 @@ pub async fn cli() -> Result<()> {
scheduled_job_id: None,
interactive: true, // Default case is always interactive
quiet: false,
sub_recipes: None,
})
.await;
setup_logging(

View File

@@ -46,6 +46,7 @@ pub async fn agent_generator(
interactive: false, // Benchmarking is non-interactive
scheduled_job_id: None,
quiet: false,
sub_recipes: None,
})
.await;

View File

@@ -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<String> {
// 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());
}
}

View File

@@ -2,3 +2,4 @@ pub mod github_recipe;
pub mod print_recipe;
pub mod recipe;
pub mod search_recipe;
pub mod template_recipe;

View File

@@ -3,12 +3,13 @@ use crate::recipes::print_recipe::{
print_required_parameters_for_template,
};
use crate::recipes::search_recipe::retrieve_recipe_file;
use crate::recipes::template_recipe::{
parse_recipe_content, render_recipe_content_with_params, render_recipe_for_preview,
};
use anyhow::Result;
use console::style;
use goose::recipe::{Recipe, RecipeParameter, RecipeParameterRequirement};
use minijinja::{Environment, Error, UndefinedBehavior};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
pub const BUILT_IN_RECIPE_DIR_PARAM: &str = "recipe_dir";
pub const RECIPE_FILE_EXTENSIONS: &[&str] = &["yaml", "json"];
@@ -18,13 +19,13 @@ pub fn load_recipe_content_as_template(
params: Vec<(String, String)>,
) -> Result<String> {
let (recipe_file_content, recipe_parent_dir) = retrieve_recipe_file(recipe_name)?;
let recipe_parameters = extract_parameters_from_content(&recipe_file_content)?;
validate_optional_parameters(&recipe_parameters)?;
validate_parameters_in_template(&recipe_parameters, &recipe_file_content)?;
let recipe_dir_str = recipe_parent_dir
.to_str()
.ok_or_else(|| anyhow::anyhow!("Error getting recipe directory"))?;
let recipe_parameters = validate_recipe_parameters(&recipe_file_content, recipe_dir_str)?;
let (params_for_template, missing_params) =
apply_values_to_parameters(&params, recipe_parameters, recipe_parent_dir, true)?;
apply_values_to_parameters(&params, recipe_parameters, recipe_dir_str, true)?;
if !missing_params.is_empty() {
return Err(anyhow::anyhow!(
@@ -33,12 +34,24 @@ pub fn load_recipe_content_as_template(
));
}
render_content_with_params(&recipe_file_content, &params_for_template)
render_recipe_content_with_params(&recipe_file_content, &params_for_template)
}
fn validate_recipe_parameters(
recipe_file_content: &str,
recipe_dir_str: &str,
) -> Result<Option<Vec<RecipeParameter>>> {
let (raw_recipe, template_variables) =
parse_recipe_content(recipe_file_content, recipe_dir_str.to_string())?;
let recipe_parameters = raw_recipe.parameters;
validate_optional_parameters(&recipe_parameters)?;
validate_parameters_in_template(&recipe_parameters, &template_variables)?;
Ok(recipe_parameters)
}
pub fn load_recipe_as_template(recipe_name: &str, params: Vec<(String, String)>) -> Result<Recipe> {
let rendered_content = load_recipe_content_as_template(recipe_name, params.clone())?;
let recipe = parse_recipe_content(&rendered_content)?;
let recipe = Recipe::from_content(&rendered_content)?;
// Display information about the loaded recipe
println!(
@@ -57,9 +70,17 @@ pub fn load_recipe_as_template(recipe_name: &str, params: Vec<(String, String)>)
}
pub fn load_recipe(recipe_name: &str) -> Result<Recipe> {
let (recipe_file_content, _) = retrieve_recipe_file(recipe_name)?;
validate_recipe_file_parameters(&recipe_file_content)
let (recipe_file_content, recipe_parent_dir) = retrieve_recipe_file(recipe_name)?;
let recipe_dir_str = recipe_parent_dir
.to_str()
.ok_or_else(|| anyhow::anyhow!("Error getting recipe directory"))?;
validate_recipe_parameters(&recipe_file_content, recipe_dir_str)?;
let recipe = render_recipe_for_preview(
&recipe_file_content,
recipe_dir_str.to_string(),
&HashMap::new(),
)?;
Ok(recipe)
}
pub fn explain_recipe_with_parameters(
@@ -67,32 +88,29 @@ pub fn explain_recipe_with_parameters(
params: Vec<(String, String)>,
) -> Result<()> {
let (recipe_file_content, recipe_parent_dir) = retrieve_recipe_file(recipe_name)?;
let recipe_dir_str = recipe_parent_dir
.to_str()
.ok_or_else(|| anyhow::anyhow!("Error getting recipe directory"))?;
let recipe_parameters = validate_recipe_parameters(&recipe_file_content, recipe_dir_str)?;
let raw_recipe = validate_recipe_file_parameters(&recipe_file_content)?;
print_recipe_explanation(&raw_recipe);
let recipe_parameters = raw_recipe.parameters;
let (params_for_template, missing_params) =
apply_values_to_parameters(&params, recipe_parameters, recipe_parent_dir, false)?;
apply_values_to_parameters(&params, recipe_parameters, recipe_dir_str, false)?;
let recipe = render_recipe_for_preview(
&recipe_file_content,
recipe_dir_str.to_string(),
&params_for_template,
)?;
print_recipe_explanation(&recipe);
print_required_parameters_for_template(params_for_template, missing_params);
Ok(())
}
fn extract_parameters_from_content(content: &str) -> Result<Option<Vec<RecipeParameter>>> {
// 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::<serde_yaml::Value>(content).is_err() {
return Ok(None);
}
let recipe: Recipe = serde_yaml::from_str(content)
.map_err(|e| anyhow::anyhow!("Valid YAML but invalid Recipe structure: {}", e))?;
Ok(recipe.parameters)
}
fn validate_parameters_in_template(
recipe_parameters: &Option<Vec<RecipeParameter>>,
recipe_file_content: &str,
template_variables: &HashSet<String>,
) -> Result<()> {
let mut template_variables = extract_template_variables(recipe_file_content)?;
let mut template_variables = template_variables.clone();
template_variables.remove(BUILT_IN_RECIPE_DIR_PARAM);
let param_keys: HashSet<String> = recipe_parameters
@@ -158,38 +176,16 @@ fn validate_optional_parameters(parameters: &Option<Vec<RecipeParameter>>) -> Re
}
}
fn parse_recipe_content(content: &str) -> Result<Recipe> {
if content.trim().is_empty() {
return Err(anyhow::anyhow!("Recipe content is empty"));
}
serde_yaml::from_str(content)
.map_err(|e| anyhow::anyhow!("Failed to parse recipe content: {}", e))
}
fn extract_template_variables(template_str: &str) -> Result<HashSet<String>> {
let mut env = Environment::new();
env.set_undefined_behavior(UndefinedBehavior::Strict);
let template = env
.template_from_str(template_str)
.map_err(|e: Error| anyhow::anyhow!("Invalid template syntax: {}", e.to_string()))?;
Ok(template.undeclared_variables(true))
}
fn apply_values_to_parameters(
user_params: &[(String, String)],
recipe_parameters: Option<Vec<RecipeParameter>>,
recipe_parent_dir: PathBuf,
recipe_parent_dir: &str,
enable_user_prompt: bool,
) -> Result<(HashMap<String, String>, Vec<String>)> {
let mut param_map: HashMap<String, String> = user_params.iter().cloned().collect();
let recipe_parent_dir_str = recipe_parent_dir
.to_str()
.ok_or_else(|| anyhow::anyhow!("Invalid UTF-8 in recipe_dir"))?;
param_map.insert(
BUILT_IN_RECIPE_DIR_PARAM.to_string(),
recipe_parent_dir_str.to_string(),
recipe_parent_dir.to_string(),
);
let mut missing_params: Vec<String> = Vec::new();
for param in recipe_parameters.unwrap_or_default() {
@@ -214,362 +210,5 @@ fn apply_values_to_parameters(
Ok((param_map, missing_params))
}
fn render_content_with_params(content: &str, params: &HashMap<String, String>) -> Result<String> {
let mut env = minijinja::Environment::new();
env.set_undefined_behavior(UndefinedBehavior::Strict);
if let Some(recipe_dir) = params.get("recipe_dir") {
let recipe_dir = recipe_dir.clone();
env.set_loader(move |name| {
let path = Path::new(&recipe_dir).join(name);
match std::fs::read_to_string(&path) {
Ok(content) => Ok(Some(content)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(minijinja::Error::new(
minijinja::ErrorKind::InvalidOperation,
"could not read template",
)
.with_source(e)),
}
});
}
let template = env
.template_from_str(content)
.map_err(|e| anyhow::anyhow!("Invalid template syntax: {}", e))?;
template
.render(params)
.map_err(|e| anyhow::anyhow!("Failed to render the recipe {}", e))
}
fn validate_recipe_file_parameters(recipe_file_content: &str) -> Result<Recipe> {
let recipe_from_recipe_file: Recipe = parse_recipe_content(recipe_file_content)?;
let parameters = extract_parameters_from_content(recipe_file_content)?;
validate_optional_parameters(&parameters)?;
validate_parameters_in_template(&parameters, recipe_file_content)?;
Ok(recipe_from_recipe_file)
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use goose::recipe::{RecipeParameterInputType, RecipeParameterRequirement};
use tempfile::TempDir;
use super::*;
fn setup_recipe_file(instructions_and_parameters: &str) -> (TempDir, PathBuf) {
let recipe_content = format!(
r#"{{
"version": "1.0.0",
"title": "Test Recipe",
"description": "A test recipe",
{}
}}"#,
instructions_and_parameters
);
let temp_dir = tempfile::tempdir().unwrap();
let recipe_path: std::path::PathBuf = temp_dir.path().join("test_recipe.json");
std::fs::write(&recipe_path, recipe_content).unwrap();
(temp_dir, recipe_path)
}
#[test]
fn test_render_content_with_params() {
// Test basic parameter substitution
let content = "Hello {{ name }}!";
let mut params = HashMap::new();
params.insert("name".to_string(), "World".to_string());
let result = render_content_with_params(content, &params).unwrap();
assert_eq!(result, "Hello World!");
// Test empty parameter substitution
let content = "Hello {{ empty }}!";
let mut params = HashMap::new();
params.insert("empty".to_string(), "".to_string());
let result = render_content_with_params(content, &params).unwrap();
assert_eq!(result, "Hello !");
// Test multiple parameters
let content = "{{ greeting }} {{ name }}!";
let mut params = HashMap::new();
params.insert("greeting".to_string(), "Hi".to_string());
params.insert("name".to_string(), "Alice".to_string());
let result = render_content_with_params(content, &params).unwrap();
assert_eq!(result, "Hi Alice!");
// Test missing parameter results in error
let content = "Hello {{ missing }}!";
let params = HashMap::new();
let err = render_content_with_params(content, &params).unwrap_err();
let error_msg = err.to_string();
assert!(error_msg.contains("Failed to render the recipe"));
// Test invalid template syntax results in error
let content = "Hello {{ unclosed";
let params = HashMap::new();
let err = render_content_with_params(content, &params).unwrap_err();
assert!(err.to_string().contains("Invalid template syntax"));
}
#[test]
fn test_load_recipe_as_template_success() {
let instructions_and_parameters = r#"
"instructions": "Test instructions with {{ my_name }}",
"parameters": [
{
"key": "my_name",
"input_type": "string",
"requirement": "required",
"description": "A test parameter"
}
]"#;
let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters);
let params = vec![("my_name".to_string(), "value".to_string())];
let recipe = load_recipe_as_template(recipe_path.to_str().unwrap(), params).unwrap();
assert_eq!(recipe.title, "Test Recipe");
assert_eq!(recipe.description, "A test recipe");
assert_eq!(recipe.instructions.unwrap(), "Test instructions with value");
// Verify parameters match recipe definition
assert_eq!(recipe.parameters.as_ref().unwrap().len(), 1);
let param = &recipe.parameters.as_ref().unwrap()[0];
assert_eq!(param.key, "my_name");
assert!(matches!(param.input_type, RecipeParameterInputType::String));
assert!(matches!(
param.requirement,
RecipeParameterRequirement::Required
));
assert_eq!(param.description, "A test parameter");
}
#[test]
fn test_load_recipe_as_template_success_variable_in_prompt() {
let instructions_and_parameters = r#"
"instructions": "Test instructions",
"prompt": "My prompt {{ my_name }}",
"parameters": [
{
"key": "my_name",
"input_type": "string",
"requirement": "required",
"description": "A test parameter"
}
]"#;
let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters);
let params = vec![("my_name".to_string(), "value".to_string())];
let recipe = load_recipe_as_template(recipe_path.to_str().unwrap(), params).unwrap();
assert_eq!(recipe.title, "Test Recipe");
assert_eq!(recipe.description, "A test recipe");
assert_eq!(recipe.instructions.unwrap(), "Test instructions");
assert_eq!(recipe.prompt.unwrap(), "My prompt value");
let param = &recipe.parameters.as_ref().unwrap()[0];
assert_eq!(param.key, "my_name");
assert!(matches!(param.input_type, RecipeParameterInputType::String));
assert!(matches!(
param.requirement,
RecipeParameterRequirement::Required
));
assert_eq!(param.description, "A test parameter");
}
#[test]
fn test_load_recipe_as_template_wrong_parameters_in_recipe_file() {
let instructions_and_parameters = r#"
"instructions": "Test instructions with {{ expected_param1 }} {{ expected_param2 }}",
"parameters": [
{
"key": "wrong_param_key",
"input_type": "string",
"requirement": "required",
"description": "A test parameter"
}
]"#;
let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters);
let load_recipe_result = load_recipe_as_template(recipe_path.to_str().unwrap(), Vec::new());
assert!(load_recipe_result.is_err());
let err = load_recipe_result.unwrap_err();
println!("{}", err.to_string());
assert!(err
.to_string()
.contains("Unnecessary parameter definitions: wrong_param_key."));
assert!(err
.to_string()
.contains("Missing definitions for parameters in the recipe file:"));
assert!(err.to_string().contains("expected_param1"));
assert!(err.to_string().contains("expected_param2"));
}
#[test]
fn test_load_recipe_as_template_with_default_values_in_recipe_file() {
let instructions_and_parameters = r#"
"instructions": "Test instructions with {{ param_with_default }} {{ param_without_default }}",
"parameters": [
{
"key": "param_with_default",
"input_type": "string",
"requirement": "optional",
"default": "my_default_value",
"description": "A test parameter"
},
{
"key": "param_without_default",
"input_type": "string",
"requirement": "required",
"description": "A test parameter"
}
]"#;
let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters);
let params = vec![("param_without_default".to_string(), "value1".to_string())];
let recipe = load_recipe_as_template(recipe_path.to_str().unwrap(), params).unwrap();
assert_eq!(recipe.title, "Test Recipe");
assert_eq!(recipe.description, "A test recipe");
assert_eq!(
recipe.instructions.unwrap(),
"Test instructions with my_default_value value1"
);
}
#[test]
fn test_load_recipe_as_template_optional_parameters_with_empty_default_values_in_recipe_file() {
let instructions_and_parameters = r#"
"instructions": "Test instructions with {{ optional_param }}",
"parameters": [
{
"key": "optional_param",
"input_type": "string",
"requirement": "optional",
"description": "A test parameter",
"default": "",
}
]"#;
let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters);
let recipe = load_recipe_as_template(recipe_path.to_str().unwrap(), Vec::new()).unwrap();
assert_eq!(recipe.title, "Test Recipe");
assert_eq!(recipe.description, "A test recipe");
assert_eq!(recipe.instructions.unwrap(), "Test instructions with ");
}
#[test]
fn test_load_recipe_as_template_optional_parameters_without_default_values_in_recipe_file() {
let instructions_and_parameters = r#"
"instructions": "Test instructions with {{ optional_param }}",
"parameters": [
{
"key": "optional_param",
"input_type": "string",
"requirement": "optional",
"description": "A test parameter"
}
]"#;
let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters);
let load_recipe_result = load_recipe_as_template(recipe_path.to_str().unwrap(), Vec::new());
assert!(load_recipe_result.is_err());
let err = load_recipe_result.unwrap_err();
println!("{}", err.to_string());
assert!(err.to_string().to_lowercase().contains("missing"));
}
#[test]
fn test_load_recipe_as_template_wrong_input_type_in_recipe_file() {
let instructions_and_parameters = r#"
"instructions": "Test instructions with {{ param }}",
"parameters": [
{
"key": "param",
"input_type": "some_invalid_type",
"requirement": "required",
"description": "A test parameter"
}
]"#;
let params = vec![("param".to_string(), "value".to_string())];
let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters);
let load_recipe_result = load_recipe_as_template(recipe_path.to_str().unwrap(), params);
assert!(load_recipe_result.is_err());
let err = load_recipe_result.unwrap_err();
let err_msg = err.to_string();
eprint!("Error: {}", err_msg);
assert!(err_msg.contains("unknown variant `some_invalid_type`"));
}
#[test]
fn test_load_recipe_as_template_success_without_parameters() {
let instructions_and_parameters = r#"
"instructions": "Test instructions"
"#;
let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters);
let recipe = load_recipe_as_template(recipe_path.to_str().unwrap(), Vec::new()).unwrap();
assert_eq!(recipe.instructions.unwrap(), "Test instructions");
assert!(recipe.parameters.is_none());
}
#[test]
fn test_template_inheritance() {
let temp_dir = tempfile::tempdir().unwrap();
let temp_path = temp_dir.path();
let parent_content = r#"
version: 1.0.0
title: Parent
description: Parent recipe
prompt: |
show me the news for day: {{ date }}
{% block prompt -%}
What is the capital of France?
{%- endblock %}
parameters:
- key: date
input_type: string
requirement: required
description: date specified by the user
"#;
let parent_path = temp_path.join("parent.yaml");
std::fs::write(&parent_path, parent_content).unwrap();
let child_content = r#"
{% extends "parent.yaml" -%}
{% block prompt -%}
What is the capital of Germany?
{%- endblock %}
"#;
let child_path = temp_path.join("child.yaml");
std::fs::write(&child_path, child_content).unwrap();
let params = vec![("date".to_string(), "today".to_string())];
let parent_result = load_recipe_as_template(parent_path.to_str().unwrap(), params.clone());
assert!(parent_result.is_ok());
let parent_recipe = parent_result.unwrap();
assert_eq!(parent_recipe.description, "Parent recipe");
assert_eq!(
parent_recipe.prompt.unwrap(),
"show me the news for day: today\nWhat is the capital of France?\n"
);
assert_eq!(parent_recipe.parameters.as_ref().unwrap().len(), 1);
assert_eq!(parent_recipe.parameters.as_ref().unwrap()[0].key, "date");
let child_result = load_recipe_as_template(child_path.to_str().unwrap(), params);
assert!(child_result.is_ok());
let child_recipe = child_result.unwrap();
assert_eq!(child_recipe.title, "Parent");
assert_eq!(child_recipe.description, "Parent recipe");
assert_eq!(
child_recipe.prompt.unwrap().trim(),
"show me the news for day: today\nWhat is the capital of Germany?"
);
}
}
mod tests;

View File

@@ -0,0 +1,315 @@
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use goose::recipe::{RecipeParameterInputType, RecipeParameterRequirement};
use tempfile::TempDir;
use crate::recipes::recipe::load_recipe_as_template;
fn setup_recipe_file(instructions_and_parameters: &str) -> (TempDir, PathBuf) {
let recipe_content = format!(
r#"{{
"version": "1.0.0",
"title": "Test Recipe",
"description": "A test recipe",
{}
}}"#,
instructions_and_parameters
);
let temp_dir = tempfile::tempdir().unwrap();
let recipe_path: std::path::PathBuf = temp_dir.path().join("test_recipe.json");
std::fs::write(&recipe_path, recipe_content).unwrap();
(temp_dir, recipe_path)
}
mod load_recipe_as_template_tests {
use super::*;
#[test]
fn test_load_recipe_as_template_success() {
let instructions_and_parameters = r#"
"instructions": "Test instructions with {{ my_name }}",
"parameters": [
{
"key": "my_name",
"input_type": "string",
"requirement": "required",
"description": "A test parameter"
}
]"#;
let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters);
let params = vec![("my_name".to_string(), "value".to_string())];
let recipe = load_recipe_as_template(recipe_path.to_str().unwrap(), params).unwrap();
assert_eq!(recipe.title, "Test Recipe");
assert_eq!(recipe.description, "A test recipe");
assert_eq!(recipe.instructions.unwrap(), "Test instructions with value");
// Verify parameters match recipe definition
assert_eq!(recipe.parameters.as_ref().unwrap().len(), 1);
let param = &recipe.parameters.as_ref().unwrap()[0];
assert_eq!(param.key, "my_name");
assert!(matches!(param.input_type, RecipeParameterInputType::String));
assert!(matches!(
param.requirement,
RecipeParameterRequirement::Required
));
assert_eq!(param.description, "A test parameter");
}
#[test]
fn test_load_recipe_as_template_success_variable_in_prompt() {
let instructions_and_parameters = r#"
"instructions": "Test instructions",
"prompt": "My prompt {{ my_name }}",
"parameters": [
{
"key": "my_name",
"input_type": "string",
"requirement": "required",
"description": "A test parameter"
}
]"#;
let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters);
let params = vec![("my_name".to_string(), "value".to_string())];
let recipe = load_recipe_as_template(recipe_path.to_str().unwrap(), params).unwrap();
assert_eq!(recipe.title, "Test Recipe");
assert_eq!(recipe.description, "A test recipe");
assert_eq!(recipe.instructions.unwrap(), "Test instructions");
assert_eq!(recipe.prompt.unwrap(), "My prompt value");
let param = &recipe.parameters.as_ref().unwrap()[0];
assert_eq!(param.key, "my_name");
assert!(matches!(param.input_type, RecipeParameterInputType::String));
assert!(matches!(
param.requirement,
RecipeParameterRequirement::Required
));
assert_eq!(param.description, "A test parameter");
}
#[test]
fn test_load_recipe_as_template_wrong_parameters_in_recipe_file() {
let instructions_and_parameters = r#"
"instructions": "Test instructions with {{ expected_param1 }} {{ expected_param2 }}",
"parameters": [
{
"key": "wrong_param_key",
"input_type": "string",
"requirement": "required",
"description": "A test parameter"
}
]"#;
let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters);
let load_recipe_result =
load_recipe_as_template(recipe_path.to_str().unwrap(), Vec::new());
assert!(load_recipe_result.is_err());
let err = load_recipe_result.unwrap_err();
println!("{}", err.to_string());
assert!(err
.to_string()
.contains("Unnecessary parameter definitions: wrong_param_key."));
assert!(err
.to_string()
.contains("Missing definitions for parameters in the recipe file:"));
assert!(err.to_string().contains("expected_param1"));
assert!(err.to_string().contains("expected_param2"));
}
#[test]
fn test_load_recipe_as_template_with_default_values_in_recipe_file() {
let instructions_and_parameters = r#"
"instructions": "Test instructions with {{ param_with_default }} {{ param_without_default }}",
"parameters": [
{
"key": "param_with_default",
"input_type": "string",
"requirement": "optional",
"default": "my_default_value",
"description": "A test parameter"
},
{
"key": "param_without_default",
"input_type": "string",
"requirement": "required",
"description": "A test parameter"
}
]"#;
let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters);
let params = vec![("param_without_default".to_string(), "value1".to_string())];
let recipe = load_recipe_as_template(recipe_path.to_str().unwrap(), params).unwrap();
assert_eq!(recipe.title, "Test Recipe");
assert_eq!(recipe.description, "A test recipe");
assert_eq!(
recipe.instructions.unwrap(),
"Test instructions with my_default_value value1"
);
}
#[test]
fn test_load_recipe_as_template_optional_parameters_with_empty_default_values_in_recipe_file(
) {
let instructions_and_parameters = r#"
"instructions": "Test instructions with {{ optional_param }}",
"parameters": [
{
"key": "optional_param",
"input_type": "string",
"requirement": "optional",
"description": "A test parameter",
"default": "",
}
]"#;
let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters);
let recipe =
load_recipe_as_template(recipe_path.to_str().unwrap(), Vec::new()).unwrap();
assert_eq!(recipe.title, "Test Recipe");
assert_eq!(recipe.description, "A test recipe");
assert_eq!(recipe.instructions.unwrap(), "Test instructions with ");
}
#[test]
fn test_load_recipe_as_template_optional_parameters_without_default_values_in_recipe_file()
{
let instructions_and_parameters = r#"
"instructions": "Test instructions with {{ optional_param }}",
"parameters": [
{
"key": "optional_param",
"input_type": "string",
"requirement": "optional",
"description": "A test parameter"
}
]"#;
let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters);
let load_recipe_result =
load_recipe_as_template(recipe_path.to_str().unwrap(), Vec::new());
assert!(load_recipe_result.is_err());
let err = load_recipe_result.unwrap_err();
println!("{}", err.to_string());
assert!(err.to_string().to_lowercase().contains("missing"));
}
#[test]
fn test_load_recipe_as_template_wrong_input_type_in_recipe_file() {
let instructions_and_parameters = r#"
"instructions": "Test instructions with {{ param }}",
"parameters": [
{
"key": "param",
"input_type": "some_invalid_type",
"requirement": "required",
"description": "A test parameter"
}
]"#;
let params = vec![("param".to_string(), "value".to_string())];
let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters);
let load_recipe_result = load_recipe_as_template(recipe_path.to_str().unwrap(), params);
assert!(load_recipe_result.is_err());
let err = load_recipe_result.unwrap_err();
let err_msg = err.to_string();
eprint!("Error: {}", err_msg);
assert!(err_msg.contains("unknown variant `some_invalid_type`"));
}
#[test]
fn test_load_recipe_as_template_success_without_parameters() {
let instructions_and_parameters = r#"
"instructions": "Test instructions"
"#;
let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters);
let recipe =
load_recipe_as_template(recipe_path.to_str().unwrap(), Vec::new()).unwrap();
assert_eq!(recipe.instructions.unwrap(), "Test instructions");
assert!(recipe.parameters.is_none());
}
#[test]
fn test_template_inheritance() {
let temp_dir = tempfile::tempdir().unwrap();
let temp_path = temp_dir.path();
let parent_content = r#"
version: 1.0.0
title: Parent
description: Parent recipe
prompt: |
show me the news for day: {{ date }}
{% block prompt -%}
What is the capital of France?
{%- endblock %}
{% if is_enabled %}
Feature is enabled.
{% else %}
Feature is disabled.
{% endif %}
parameters:
- key: date
input_type: string
requirement: required
description: date specified by the user
- key: is_enabled
input_type: boolean
requirement: required
description: whether the feature is enabled
"#;
let parent_path = temp_path.join("parent.yaml");
std::fs::write(&parent_path, parent_content).unwrap();
let child_content = r#"
{% extends "parent.yaml" -%}
{% block prompt -%}
What is the capital of Germany?
{%- endblock %}
"#;
let child_path = temp_path.join("child.yaml");
std::fs::write(&child_path, child_content).unwrap();
let params = vec![
("date".to_string(), "today".to_string()),
("is_enabled".to_string(), "true".to_string()),
];
let parent_result =
load_recipe_as_template(parent_path.to_str().unwrap(), params.clone());
assert!(parent_result.is_ok());
let parent_recipe = parent_result.unwrap();
assert_eq!(parent_recipe.description, "Parent recipe");
assert_eq!(
parent_recipe.prompt.unwrap(),
"show me the news for day: today\nWhat is the capital of France?\n\n Feature is enabled.\n"
);
assert_eq!(parent_recipe.parameters.as_ref().unwrap().len(), 2);
assert_eq!(parent_recipe.parameters.as_ref().unwrap()[0].key, "date");
assert_eq!(
parent_recipe.parameters.as_ref().unwrap()[1].key,
"is_enabled"
);
let child_result = load_recipe_as_template(child_path.to_str().unwrap(), params);
assert!(child_result.is_ok());
let child_recipe = child_result.unwrap();
assert_eq!(child_recipe.title, "Parent");
assert_eq!(child_recipe.description, "Parent recipe");
assert_eq!(
child_recipe.prompt.unwrap().trim(),
"show me the news for day: today\nWhat is the capital of Germany?\n\n Feature is enabled."
);
assert_eq!(child_recipe.parameters.as_ref().unwrap().len(), 2);
assert_eq!(child_recipe.parameters.as_ref().unwrap()[0].key, "date");
assert_eq!(
child_recipe.parameters.as_ref().unwrap()[1].key,
"is_enabled"
);
}
}
}

View File

@@ -0,0 +1,172 @@
use std::{
collections::{HashMap, HashSet},
path::Path,
};
use anyhow::Result;
use goose::recipe::Recipe;
use minijinja::{Environment, UndefinedBehavior};
use crate::recipes::recipe::BUILT_IN_RECIPE_DIR_PARAM;
const CURRENT_TEMPLATE_NAME: &str = "current_template";
pub fn render_recipe_content_with_params(
content: &str,
params: &HashMap<String, String>,
) -> Result<String> {
let env = add_template_in_env(
content,
params.get(BUILT_IN_RECIPE_DIR_PARAM).unwrap().clone(),
UndefinedBehavior::Strict,
)?;
let template = env.get_template(CURRENT_TEMPLATE_NAME).unwrap();
let rendered_content = template
.render(params)
.map_err(|e| anyhow::anyhow!("Failed to render the recipe {}", e))?;
Ok(rendered_content)
}
pub fn render_recipe_silent_when_variables_are_provided(
content: &str,
params: &HashMap<String, String>,
) -> Result<String> {
let mut env = minijinja::Environment::new();
env.set_undefined_behavior(UndefinedBehavior::Lenient);
let template = env.template_from_str(content)?;
let rendered_content = template.render(params)?;
Ok(rendered_content)
}
fn add_template_in_env(
content: &str,
recipe_dir: String,
undefined_behavior: UndefinedBehavior,
) -> Result<Environment> {
let mut env = minijinja::Environment::new();
env.set_undefined_behavior(undefined_behavior);
env.set_loader(move |name| {
let path = Path::new(recipe_dir.as_str()).join(name);
match std::fs::read_to_string(&path) {
Ok(content) => Ok(Some(content)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(minijinja::Error::new(
minijinja::ErrorKind::InvalidOperation,
"could not read template",
)
.with_source(e)),
}
});
env.add_template(CURRENT_TEMPLATE_NAME, content)?;
Ok(env)
}
fn get_env_with_template_variables(
content: &str,
recipe_dir: String,
undefined_behavior: UndefinedBehavior,
) -> Result<(Environment, HashSet<String>)> {
let env = add_template_in_env(content, recipe_dir, undefined_behavior)?;
let template = env.get_template(CURRENT_TEMPLATE_NAME).unwrap();
let state = template.eval_to_state(())?;
let mut template_variables = HashSet::new();
for (_, template) in state.env().templates() {
template_variables.extend(template.undeclared_variables(true));
}
Ok((env, template_variables))
}
pub fn parse_recipe_content(
content: &str,
recipe_dir: String,
) -> Result<(Recipe, HashSet<String>)> {
let (env, template_variables) =
get_env_with_template_variables(content, recipe_dir, UndefinedBehavior::Lenient)?;
let template = env.get_template(CURRENT_TEMPLATE_NAME).unwrap();
let rendered_content = template
.render(())
.map_err(|e| anyhow::anyhow!("Failed to parse the recipe {}", e))?;
let recipe = Recipe::from_content(&rendered_content)?;
// return recipe (without loading any variables) and the variable names that are in the recipe
Ok((recipe, template_variables))
}
// render the recipe for validation, deeplink and explain, etc.
pub fn render_recipe_for_preview(
content: &str,
recipe_dir: String,
params: &HashMap<String, String>,
) -> Result<Recipe> {
let (env, template_variables) =
get_env_with_template_variables(content, recipe_dir, UndefinedBehavior::Lenient)?;
let template = env.get_template(CURRENT_TEMPLATE_NAME).unwrap();
// if the variables are not provided, the template will be rendered with the variables, otherwise it will keep the variables as is
let mut ctx = preserve_vars(&template_variables).clone();
ctx.extend(params.clone());
let rendered_content = template
.render(ctx)
.map_err(|e| anyhow::anyhow!("Failed to parse the recipe {}", e))?;
Recipe::from_content(&rendered_content)
}
fn preserve_vars(variables: &HashSet<String>) -> HashMap<String, String> {
let mut context = HashMap::<String, String>::new();
for template_var in variables {
context.insert(template_var.clone(), format!("{{{{ {} }}}}", template_var));
}
context
}
#[cfg(test)]
mod tests {
mod render_content_with_params_tests {
use std::collections::HashMap;
use crate::recipes::template_recipe::render_recipe_content_with_params;
#[test]
fn test_render_content_with_params() {
// Test basic parameter substitution
let content = "Hello {{ name }}!";
let params = HashMap::from([
("recipe_dir".to_string(), "some_dir".to_string()),
("name".to_string(), "World".to_string()),
]);
let result = render_recipe_content_with_params(content, &params).unwrap();
assert_eq!(result, "Hello World!");
// Test empty parameter substitution
let content = "Hello {{ empty }}!";
let params = HashMap::from([
("recipe_dir".to_string(), "some_dir".to_string()),
("empty".to_string(), "".to_string()),
]);
let result = render_recipe_content_with_params(content, &params).unwrap();
assert_eq!(result, "Hello !");
// Test multiple parameters
let content = "{{ greeting }} {{ name }}!";
let params = HashMap::from([
("recipe_dir".to_string(), "some_dir".to_string()),
("greeting".to_string(), "Hi".to_string()),
("name".to_string(), "Alice".to_string()),
]);
let result = render_recipe_content_with_params(content, &params).unwrap();
assert_eq!(result, "Hi Alice!");
// Test missing parameter results in error
let content = "Hello {{ missing }}!";
let params = HashMap::from([("recipe_dir".to_string(), "some_dir".to_string())]);
let err = render_recipe_content_with_params(content, &params).unwrap_err();
let error_msg = err.to_string();
assert!(error_msg.contains("Failed to render the recipe"));
// Test invalid template syntax results in error
let content = "Hello {{ unclosed";
let params = HashMap::from([("recipe_dir".to_string(), "some_dir".to_string())]);
let err = render_recipe_content_with_params(content, &params).unwrap_err();
assert!(err.to_string().contains("unexpected end of input"));
}
}
}

View File

@@ -3,6 +3,7 @@ use goose::agents::extension::ExtensionError;
use goose::agents::Agent;
use goose::config::{Config, ExtensionConfig, ExtensionConfigManager};
use goose::providers::create;
use goose::recipe::SubRecipe;
use goose::session;
use goose::session::Identifier;
use mcp_client::transport::Error as McpClientError;
@@ -46,6 +47,8 @@ pub struct SessionBuilderConfig {
pub interactive: bool,
/// Quiet mode - suppress non-response output
pub quiet: bool,
/// Sub-recipes to add to the session
pub sub_recipes: Option<Vec<SubRecipe>>,
}
/// Offers to help debug an extension failure by creating a minimal debugging session
@@ -174,6 +177,9 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> Session {
// Create the agent
let agent: Agent = Agent::new();
if let Some(sub_recipes) = session_config.sub_recipes {
agent.add_sub_recipes(sub_recipes).await;
}
let new_provider = match create(&provider_name, model_config) {
Ok(provider) => provider,
Err(e) => {
@@ -216,7 +222,7 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> Session {
}
// Handle session file resolution and resuming
let session_file = if session_config.no_session {
let session_file: std::path::PathBuf = if session_config.no_session {
// Use a temporary path that won't be written to
#[cfg(unix)]
{
@@ -500,6 +506,7 @@ mod tests {
scheduled_job_id: None,
interactive: true,
quiet: false,
sub_recipes: None,
};
assert_eq!(config.extensions.len(), 1);