mirror of
https://github.com/aljazceru/goose.git
synced 2025-12-18 22:54:24 +01:00
feat: created sub recipe tools (#2982)
This commit is contained in:
4
Cargo.lock
generated
4
Cargo.lock
generated
@@ -5477,9 +5477,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "minijinja"
|
name = "minijinja"
|
||||||
version = "2.8.0"
|
version = "2.10.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6e36f1329330bb1614c94b78632b9ce45dd7d761f3304a1bed07b2990a7c5097"
|
checksum = "dd72e8b4e42274540edabec853f607c015c73436159b06c39c7af85a20433155"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"memo-map",
|
"memo-map",
|
||||||
"self_cell",
|
"self_cell",
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ shlex = "1.3.0"
|
|||||||
async-trait = "0.1.86"
|
async-trait = "0.1.86"
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
regex = "1.11.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"] }
|
nix = { version = "0.30.1", features = ["process", "signal"] }
|
||||||
tar = "0.4"
|
tar = "0.4"
|
||||||
# Web server dependencies
|
# Web server dependencies
|
||||||
|
|||||||
@@ -675,6 +675,7 @@ pub async fn cli() -> Result<()> {
|
|||||||
scheduled_job_id: None,
|
scheduled_job_id: None,
|
||||||
interactive: true,
|
interactive: true,
|
||||||
quiet: false,
|
quiet: false,
|
||||||
|
sub_recipes: None,
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
setup_logging(
|
setup_logging(
|
||||||
@@ -723,7 +724,7 @@ pub async fn cli() -> Result<()> {
|
|||||||
scheduled_job_id,
|
scheduled_job_id,
|
||||||
quiet,
|
quiet,
|
||||||
}) => {
|
}) => {
|
||||||
let (input_config, session_settings) = match (
|
let (input_config, session_settings, sub_recipes) = match (
|
||||||
instructions,
|
instructions,
|
||||||
input_text,
|
input_text,
|
||||||
recipe,
|
recipe,
|
||||||
@@ -743,6 +744,7 @@ pub async fn cli() -> Result<()> {
|
|||||||
additional_system_prompt: system,
|
additional_system_prompt: system,
|
||||||
},
|
},
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
(Some(file), _, _, _, _) => {
|
(Some(file), _, _, _, _) => {
|
||||||
@@ -760,6 +762,7 @@ pub async fn cli() -> Result<()> {
|
|||||||
additional_system_prompt: None,
|
additional_system_prompt: None,
|
||||||
},
|
},
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
(_, Some(text), _, _, _) => (
|
(_, Some(text), _, _, _) => (
|
||||||
@@ -769,6 +772,7 @@ pub async fn cli() -> Result<()> {
|
|||||||
additional_system_prompt: system,
|
additional_system_prompt: system,
|
||||||
},
|
},
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
),
|
),
|
||||||
(_, _, Some(recipe_name), explain, render_recipe) => {
|
(_, _, Some(recipe_name), explain, render_recipe) => {
|
||||||
if explain {
|
if explain {
|
||||||
@@ -800,6 +804,7 @@ pub async fn cli() -> Result<()> {
|
|||||||
goose_model: s.goose_model,
|
goose_model: s.goose_model,
|
||||||
temperature: s.temperature,
|
temperature: s.temperature,
|
||||||
}),
|
}),
|
||||||
|
recipe.sub_recipes,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
(None, None, None, _, _) => {
|
(None, None, None, _, _) => {
|
||||||
@@ -823,6 +828,7 @@ pub async fn cli() -> Result<()> {
|
|||||||
scheduled_job_id,
|
scheduled_job_id,
|
||||||
interactive, // Use the interactive flag from the Run command
|
interactive, // Use the interactive flag from the Run command
|
||||||
quiet,
|
quiet,
|
||||||
|
sub_recipes,
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -941,6 +947,7 @@ pub async fn cli() -> Result<()> {
|
|||||||
scheduled_job_id: None,
|
scheduled_job_id: None,
|
||||||
interactive: true, // Default case is always interactive
|
interactive: true, // Default case is always interactive
|
||||||
quiet: false,
|
quiet: false,
|
||||||
|
sub_recipes: None,
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
setup_logging(
|
setup_logging(
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ pub async fn agent_generator(
|
|||||||
interactive: false, // Benchmarking is non-interactive
|
interactive: false, // Benchmarking is non-interactive
|
||||||
scheduled_job_id: None,
|
scheduled_job_id: None,
|
||||||
quiet: false,
|
quiet: false,
|
||||||
|
sub_recipes: None,
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|||||||
@@ -36,10 +36,11 @@ pub fn handle_validate(recipe_name: &str) -> Result<()> {
|
|||||||
/// # Returns
|
/// # Returns
|
||||||
///
|
///
|
||||||
/// Result indicating success or failure
|
/// 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
|
// Load the recipe file first to validate it
|
||||||
match load_recipe(recipe_name) {
|
match load_recipe(recipe_name) {
|
||||||
Ok(recipe) => {
|
Ok(recipe) => {
|
||||||
|
let mut full_url = String::new();
|
||||||
if let Ok(recipe_json) = serde_json::to_string(&recipe) {
|
if let Ok(recipe_json) = serde_json::to_string(&recipe) {
|
||||||
let deeplink = base64::engine::general_purpose::STANDARD.encode(recipe_json);
|
let deeplink = base64::engine::general_purpose::STANDARD.encode(recipe_json);
|
||||||
println!(
|
println!(
|
||||||
@@ -48,9 +49,10 @@ pub fn handle_deeplink(recipe_name: &str) -> Result<()> {
|
|||||||
recipe.title
|
recipe.title
|
||||||
);
|
);
|
||||||
let url_safe = urlencoding::encode(&deeplink);
|
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) => {
|
Err(err) => {
|
||||||
println!("{} {}", style("✗").red().bold(), 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ pub mod github_recipe;
|
|||||||
pub mod print_recipe;
|
pub mod print_recipe;
|
||||||
pub mod recipe;
|
pub mod recipe;
|
||||||
pub mod search_recipe;
|
pub mod search_recipe;
|
||||||
|
pub mod template_recipe;
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ use crate::recipes::print_recipe::{
|
|||||||
print_required_parameters_for_template,
|
print_required_parameters_for_template,
|
||||||
};
|
};
|
||||||
use crate::recipes::search_recipe::retrieve_recipe_file;
|
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 anyhow::Result;
|
||||||
use console::style;
|
use console::style;
|
||||||
use goose::recipe::{Recipe, RecipeParameter, RecipeParameterRequirement};
|
use goose::recipe::{Recipe, RecipeParameter, RecipeParameterRequirement};
|
||||||
use minijinja::{Environment, Error, UndefinedBehavior};
|
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
pub const BUILT_IN_RECIPE_DIR_PARAM: &str = "recipe_dir";
|
pub const BUILT_IN_RECIPE_DIR_PARAM: &str = "recipe_dir";
|
||||||
pub const RECIPE_FILE_EXTENSIONS: &[&str] = &["yaml", "json"];
|
pub const RECIPE_FILE_EXTENSIONS: &[&str] = &["yaml", "json"];
|
||||||
@@ -18,13 +19,13 @@ pub fn load_recipe_content_as_template(
|
|||||||
params: Vec<(String, String)>,
|
params: Vec<(String, String)>,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
let (recipe_file_content, recipe_parent_dir) = retrieve_recipe_file(recipe_name)?;
|
let (recipe_file_content, recipe_parent_dir) = retrieve_recipe_file(recipe_name)?;
|
||||||
let recipe_parameters = extract_parameters_from_content(&recipe_file_content)?;
|
let recipe_dir_str = recipe_parent_dir
|
||||||
|
.to_str()
|
||||||
validate_optional_parameters(&recipe_parameters)?;
|
.ok_or_else(|| anyhow::anyhow!("Error getting recipe directory"))?;
|
||||||
validate_parameters_in_template(&recipe_parameters, &recipe_file_content)?;
|
let recipe_parameters = validate_recipe_parameters(&recipe_file_content, recipe_dir_str)?;
|
||||||
|
|
||||||
let (params_for_template, missing_params) =
|
let (params_for_template, missing_params) =
|
||||||
apply_values_to_parameters(¶ms, recipe_parameters, recipe_parent_dir, true)?;
|
apply_values_to_parameters(¶ms, recipe_parameters, recipe_dir_str, true)?;
|
||||||
|
|
||||||
if !missing_params.is_empty() {
|
if !missing_params.is_empty() {
|
||||||
return Err(anyhow::anyhow!(
|
return Err(anyhow::anyhow!(
|
||||||
@@ -33,12 +34,24 @@ pub fn load_recipe_content_as_template(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
render_content_with_params(&recipe_file_content, ¶ms_for_template)
|
render_recipe_content_with_params(&recipe_file_content, ¶ms_for_template)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_recipe_parameters(
|
||||||
|
recipe_file_content: &str,
|
||||||
|
recipe_dir_str: &str,
|
||||||
|
) -> Result<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> {
|
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 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
|
// Display information about the loaded recipe
|
||||||
println!(
|
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> {
|
pub fn load_recipe(recipe_name: &str) -> Result<Recipe> {
|
||||||
let (recipe_file_content, _) = retrieve_recipe_file(recipe_name)?;
|
let (recipe_file_content, recipe_parent_dir) = retrieve_recipe_file(recipe_name)?;
|
||||||
|
let recipe_dir_str = recipe_parent_dir
|
||||||
validate_recipe_file_parameters(&recipe_file_content)
|
.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(
|
pub fn explain_recipe_with_parameters(
|
||||||
@@ -67,32 +88,29 @@ pub fn explain_recipe_with_parameters(
|
|||||||
params: Vec<(String, String)>,
|
params: Vec<(String, String)>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let (recipe_file_content, recipe_parent_dir) = retrieve_recipe_file(recipe_name)?;
|
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) =
|
let (params_for_template, missing_params) =
|
||||||
apply_values_to_parameters(¶ms, recipe_parameters, recipe_parent_dir, false)?;
|
apply_values_to_parameters(¶ms, recipe_parameters, recipe_dir_str, false)?;
|
||||||
|
let recipe = render_recipe_for_preview(
|
||||||
|
&recipe_file_content,
|
||||||
|
recipe_dir_str.to_string(),
|
||||||
|
¶ms_for_template,
|
||||||
|
)?;
|
||||||
|
print_recipe_explanation(&recipe);
|
||||||
print_required_parameters_for_template(params_for_template, missing_params);
|
print_required_parameters_for_template(params_for_template, missing_params);
|
||||||
|
|
||||||
Ok(())
|
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(
|
fn validate_parameters_in_template(
|
||||||
recipe_parameters: &Option<Vec<RecipeParameter>>,
|
recipe_parameters: &Option<Vec<RecipeParameter>>,
|
||||||
recipe_file_content: &str,
|
template_variables: &HashSet<String>,
|
||||||
) -> Result<()> {
|
) -> 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);
|
template_variables.remove(BUILT_IN_RECIPE_DIR_PARAM);
|
||||||
|
|
||||||
let param_keys: HashSet<String> = recipe_parameters
|
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(
|
fn apply_values_to_parameters(
|
||||||
user_params: &[(String, String)],
|
user_params: &[(String, String)],
|
||||||
recipe_parameters: Option<Vec<RecipeParameter>>,
|
recipe_parameters: Option<Vec<RecipeParameter>>,
|
||||||
recipe_parent_dir: PathBuf,
|
recipe_parent_dir: &str,
|
||||||
enable_user_prompt: bool,
|
enable_user_prompt: bool,
|
||||||
) -> Result<(HashMap<String, String>, Vec<String>)> {
|
) -> Result<(HashMap<String, String>, Vec<String>)> {
|
||||||
let mut param_map: HashMap<String, String> = user_params.iter().cloned().collect();
|
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(
|
param_map.insert(
|
||||||
BUILT_IN_RECIPE_DIR_PARAM.to_string(),
|
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();
|
let mut missing_params: Vec<String> = Vec::new();
|
||||||
for param in recipe_parameters.unwrap_or_default() {
|
for param in recipe_parameters.unwrap_or_default() {
|
||||||
@@ -214,362 +210,5 @@ fn apply_values_to_parameters(
|
|||||||
Ok((param_map, missing_params))
|
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(¶meters)?;
|
|
||||||
validate_parameters_in_template(¶meters, recipe_file_content)?;
|
|
||||||
Ok(recipe_from_recipe_file)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests;
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use goose::recipe::{RecipeParameterInputType, RecipeParameterRequirement};
|
|
||||||
use tempfile::TempDir;
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn setup_recipe_file(instructions_and_parameters: &str) -> (TempDir, PathBuf) {
|
|
||||||
let recipe_content = format!(
|
|
||||||
r#"{{
|
|
||||||
"version": "1.0.0",
|
|
||||||
"title": "Test Recipe",
|
|
||||||
"description": "A test recipe",
|
|
||||||
{}
|
|
||||||
}}"#,
|
|
||||||
instructions_and_parameters
|
|
||||||
);
|
|
||||||
let temp_dir = tempfile::tempdir().unwrap();
|
|
||||||
let recipe_path: std::path::PathBuf = temp_dir.path().join("test_recipe.json");
|
|
||||||
|
|
||||||
std::fs::write(&recipe_path, recipe_content).unwrap();
|
|
||||||
(temp_dir, recipe_path)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_render_content_with_params() {
|
|
||||||
// Test basic parameter substitution
|
|
||||||
let content = "Hello {{ name }}!";
|
|
||||||
let mut params = HashMap::new();
|
|
||||||
params.insert("name".to_string(), "World".to_string());
|
|
||||||
let result = render_content_with_params(content, ¶ms).unwrap();
|
|
||||||
assert_eq!(result, "Hello World!");
|
|
||||||
|
|
||||||
// Test empty parameter substitution
|
|
||||||
let content = "Hello {{ empty }}!";
|
|
||||||
let mut params = HashMap::new();
|
|
||||||
params.insert("empty".to_string(), "".to_string());
|
|
||||||
let result = render_content_with_params(content, ¶ms).unwrap();
|
|
||||||
assert_eq!(result, "Hello !");
|
|
||||||
|
|
||||||
// Test multiple parameters
|
|
||||||
let content = "{{ greeting }} {{ name }}!";
|
|
||||||
let mut params = HashMap::new();
|
|
||||||
params.insert("greeting".to_string(), "Hi".to_string());
|
|
||||||
params.insert("name".to_string(), "Alice".to_string());
|
|
||||||
let result = render_content_with_params(content, ¶ms).unwrap();
|
|
||||||
assert_eq!(result, "Hi Alice!");
|
|
||||||
|
|
||||||
// Test missing parameter results in error
|
|
||||||
let content = "Hello {{ missing }}!";
|
|
||||||
let params = HashMap::new();
|
|
||||||
let err = render_content_with_params(content, ¶ms).unwrap_err();
|
|
||||||
let error_msg = err.to_string();
|
|
||||||
assert!(error_msg.contains("Failed to render the recipe"));
|
|
||||||
|
|
||||||
// Test invalid template syntax results in error
|
|
||||||
let content = "Hello {{ unclosed";
|
|
||||||
let params = HashMap::new();
|
|
||||||
let err = render_content_with_params(content, ¶ms).unwrap_err();
|
|
||||||
assert!(err.to_string().contains("Invalid template syntax"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_load_recipe_as_template_success() {
|
|
||||||
let instructions_and_parameters = r#"
|
|
||||||
"instructions": "Test instructions with {{ my_name }}",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"key": "my_name",
|
|
||||||
"input_type": "string",
|
|
||||||
"requirement": "required",
|
|
||||||
"description": "A test parameter"
|
|
||||||
}
|
|
||||||
]"#;
|
|
||||||
|
|
||||||
let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters);
|
|
||||||
|
|
||||||
let params = vec![("my_name".to_string(), "value".to_string())];
|
|
||||||
let recipe = load_recipe_as_template(recipe_path.to_str().unwrap(), params).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(recipe.title, "Test Recipe");
|
|
||||||
assert_eq!(recipe.description, "A test recipe");
|
|
||||||
assert_eq!(recipe.instructions.unwrap(), "Test instructions with value");
|
|
||||||
// Verify parameters match recipe definition
|
|
||||||
assert_eq!(recipe.parameters.as_ref().unwrap().len(), 1);
|
|
||||||
let param = &recipe.parameters.as_ref().unwrap()[0];
|
|
||||||
assert_eq!(param.key, "my_name");
|
|
||||||
assert!(matches!(param.input_type, RecipeParameterInputType::String));
|
|
||||||
assert!(matches!(
|
|
||||||
param.requirement,
|
|
||||||
RecipeParameterRequirement::Required
|
|
||||||
));
|
|
||||||
assert_eq!(param.description, "A test parameter");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_load_recipe_as_template_success_variable_in_prompt() {
|
|
||||||
let instructions_and_parameters = r#"
|
|
||||||
"instructions": "Test instructions",
|
|
||||||
"prompt": "My prompt {{ my_name }}",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"key": "my_name",
|
|
||||||
"input_type": "string",
|
|
||||||
"requirement": "required",
|
|
||||||
"description": "A test parameter"
|
|
||||||
}
|
|
||||||
]"#;
|
|
||||||
|
|
||||||
let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters);
|
|
||||||
|
|
||||||
let params = vec![("my_name".to_string(), "value".to_string())];
|
|
||||||
let recipe = load_recipe_as_template(recipe_path.to_str().unwrap(), params).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(recipe.title, "Test Recipe");
|
|
||||||
assert_eq!(recipe.description, "A test recipe");
|
|
||||||
assert_eq!(recipe.instructions.unwrap(), "Test instructions");
|
|
||||||
assert_eq!(recipe.prompt.unwrap(), "My prompt value");
|
|
||||||
let param = &recipe.parameters.as_ref().unwrap()[0];
|
|
||||||
assert_eq!(param.key, "my_name");
|
|
||||||
assert!(matches!(param.input_type, RecipeParameterInputType::String));
|
|
||||||
assert!(matches!(
|
|
||||||
param.requirement,
|
|
||||||
RecipeParameterRequirement::Required
|
|
||||||
));
|
|
||||||
assert_eq!(param.description, "A test parameter");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_load_recipe_as_template_wrong_parameters_in_recipe_file() {
|
|
||||||
let instructions_and_parameters = r#"
|
|
||||||
"instructions": "Test instructions with {{ expected_param1 }} {{ expected_param2 }}",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"key": "wrong_param_key",
|
|
||||||
"input_type": "string",
|
|
||||||
"requirement": "required",
|
|
||||||
"description": "A test parameter"
|
|
||||||
}
|
|
||||||
]"#;
|
|
||||||
let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters);
|
|
||||||
|
|
||||||
let load_recipe_result = load_recipe_as_template(recipe_path.to_str().unwrap(), Vec::new());
|
|
||||||
assert!(load_recipe_result.is_err());
|
|
||||||
let err = load_recipe_result.unwrap_err();
|
|
||||||
println!("{}", err.to_string());
|
|
||||||
assert!(err
|
|
||||||
.to_string()
|
|
||||||
.contains("Unnecessary parameter definitions: wrong_param_key."));
|
|
||||||
assert!(err
|
|
||||||
.to_string()
|
|
||||||
.contains("Missing definitions for parameters in the recipe file:"));
|
|
||||||
assert!(err.to_string().contains("expected_param1"));
|
|
||||||
assert!(err.to_string().contains("expected_param2"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_load_recipe_as_template_with_default_values_in_recipe_file() {
|
|
||||||
let instructions_and_parameters = r#"
|
|
||||||
"instructions": "Test instructions with {{ param_with_default }} {{ param_without_default }}",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"key": "param_with_default",
|
|
||||||
"input_type": "string",
|
|
||||||
"requirement": "optional",
|
|
||||||
"default": "my_default_value",
|
|
||||||
"description": "A test parameter"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "param_without_default",
|
|
||||||
"input_type": "string",
|
|
||||||
"requirement": "required",
|
|
||||||
"description": "A test parameter"
|
|
||||||
}
|
|
||||||
]"#;
|
|
||||||
let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters);
|
|
||||||
let params = vec![("param_without_default".to_string(), "value1".to_string())];
|
|
||||||
|
|
||||||
let recipe = load_recipe_as_template(recipe_path.to_str().unwrap(), params).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(recipe.title, "Test Recipe");
|
|
||||||
assert_eq!(recipe.description, "A test recipe");
|
|
||||||
assert_eq!(
|
|
||||||
recipe.instructions.unwrap(),
|
|
||||||
"Test instructions with my_default_value value1"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_load_recipe_as_template_optional_parameters_with_empty_default_values_in_recipe_file() {
|
|
||||||
let instructions_and_parameters = r#"
|
|
||||||
"instructions": "Test instructions with {{ optional_param }}",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"key": "optional_param",
|
|
||||||
"input_type": "string",
|
|
||||||
"requirement": "optional",
|
|
||||||
"description": "A test parameter",
|
|
||||||
"default": "",
|
|
||||||
}
|
|
||||||
]"#;
|
|
||||||
let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters);
|
|
||||||
|
|
||||||
let recipe = load_recipe_as_template(recipe_path.to_str().unwrap(), Vec::new()).unwrap();
|
|
||||||
assert_eq!(recipe.title, "Test Recipe");
|
|
||||||
assert_eq!(recipe.description, "A test recipe");
|
|
||||||
assert_eq!(recipe.instructions.unwrap(), "Test instructions with ");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_load_recipe_as_template_optional_parameters_without_default_values_in_recipe_file() {
|
|
||||||
let instructions_and_parameters = r#"
|
|
||||||
"instructions": "Test instructions with {{ optional_param }}",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"key": "optional_param",
|
|
||||||
"input_type": "string",
|
|
||||||
"requirement": "optional",
|
|
||||||
"description": "A test parameter"
|
|
||||||
}
|
|
||||||
]"#;
|
|
||||||
let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters);
|
|
||||||
|
|
||||||
let load_recipe_result = load_recipe_as_template(recipe_path.to_str().unwrap(), Vec::new());
|
|
||||||
assert!(load_recipe_result.is_err());
|
|
||||||
let err = load_recipe_result.unwrap_err();
|
|
||||||
println!("{}", err.to_string());
|
|
||||||
assert!(err.to_string().to_lowercase().contains("missing"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_load_recipe_as_template_wrong_input_type_in_recipe_file() {
|
|
||||||
let instructions_and_parameters = r#"
|
|
||||||
"instructions": "Test instructions with {{ param }}",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"key": "param",
|
|
||||||
"input_type": "some_invalid_type",
|
|
||||||
"requirement": "required",
|
|
||||||
"description": "A test parameter"
|
|
||||||
}
|
|
||||||
]"#;
|
|
||||||
let params = vec![("param".to_string(), "value".to_string())];
|
|
||||||
let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters);
|
|
||||||
|
|
||||||
let load_recipe_result = load_recipe_as_template(recipe_path.to_str().unwrap(), params);
|
|
||||||
assert!(load_recipe_result.is_err());
|
|
||||||
let err = load_recipe_result.unwrap_err();
|
|
||||||
let err_msg = err.to_string();
|
|
||||||
eprint!("Error: {}", err_msg);
|
|
||||||
assert!(err_msg.contains("unknown variant `some_invalid_type`"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_load_recipe_as_template_success_without_parameters() {
|
|
||||||
let instructions_and_parameters = r#"
|
|
||||||
"instructions": "Test instructions"
|
|
||||||
"#;
|
|
||||||
let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters);
|
|
||||||
|
|
||||||
let recipe = load_recipe_as_template(recipe_path.to_str().unwrap(), Vec::new()).unwrap();
|
|
||||||
assert_eq!(recipe.instructions.unwrap(), "Test instructions");
|
|
||||||
assert!(recipe.parameters.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_template_inheritance() {
|
|
||||||
let temp_dir = tempfile::tempdir().unwrap();
|
|
||||||
let temp_path = temp_dir.path();
|
|
||||||
let parent_content = r#"
|
|
||||||
version: 1.0.0
|
|
||||||
title: Parent
|
|
||||||
description: Parent recipe
|
|
||||||
prompt: |
|
|
||||||
show me the news for day: {{ date }}
|
|
||||||
{% block prompt -%}
|
|
||||||
What is the capital of France?
|
|
||||||
{%- endblock %}
|
|
||||||
parameters:
|
|
||||||
- key: date
|
|
||||||
input_type: string
|
|
||||||
requirement: required
|
|
||||||
description: date specified by the user
|
|
||||||
"#;
|
|
||||||
|
|
||||||
let parent_path = temp_path.join("parent.yaml");
|
|
||||||
std::fs::write(&parent_path, parent_content).unwrap();
|
|
||||||
let child_content = r#"
|
|
||||||
{% extends "parent.yaml" -%}
|
|
||||||
{% block prompt -%}
|
|
||||||
What is the capital of Germany?
|
|
||||||
{%- endblock %}
|
|
||||||
"#;
|
|
||||||
let child_path = temp_path.join("child.yaml");
|
|
||||||
std::fs::write(&child_path, child_content).unwrap();
|
|
||||||
|
|
||||||
let params = vec![("date".to_string(), "today".to_string())];
|
|
||||||
let parent_result = load_recipe_as_template(parent_path.to_str().unwrap(), params.clone());
|
|
||||||
assert!(parent_result.is_ok());
|
|
||||||
let parent_recipe = parent_result.unwrap();
|
|
||||||
assert_eq!(parent_recipe.description, "Parent recipe");
|
|
||||||
assert_eq!(
|
|
||||||
parent_recipe.prompt.unwrap(),
|
|
||||||
"show me the news for day: today\nWhat is the capital of France?\n"
|
|
||||||
);
|
|
||||||
assert_eq!(parent_recipe.parameters.as_ref().unwrap().len(), 1);
|
|
||||||
assert_eq!(parent_recipe.parameters.as_ref().unwrap()[0].key, "date");
|
|
||||||
|
|
||||||
let child_result = load_recipe_as_template(child_path.to_str().unwrap(), params);
|
|
||||||
|
|
||||||
assert!(child_result.is_ok());
|
|
||||||
let child_recipe = child_result.unwrap();
|
|
||||||
assert_eq!(child_recipe.title, "Parent");
|
|
||||||
assert_eq!(child_recipe.description, "Parent recipe");
|
|
||||||
assert_eq!(
|
|
||||||
child_recipe.prompt.unwrap().trim(),
|
|
||||||
"show me the news for day: today\nWhat is the capital of Germany?"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
315
crates/goose-cli/src/recipes/recipe/tests.rs
Normal file
315
crates/goose-cli/src/recipes/recipe/tests.rs
Normal 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
172
crates/goose-cli/src/recipes/template_recipe.rs
Normal file
172
crates/goose-cli/src/recipes/template_recipe.rs
Normal 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, ¶ms).unwrap();
|
||||||
|
assert_eq!(result, "Hello World!");
|
||||||
|
|
||||||
|
// Test empty parameter substitution
|
||||||
|
let content = "Hello {{ empty }}!";
|
||||||
|
let params = HashMap::from([
|
||||||
|
("recipe_dir".to_string(), "some_dir".to_string()),
|
||||||
|
("empty".to_string(), "".to_string()),
|
||||||
|
]);
|
||||||
|
let result = render_recipe_content_with_params(content, ¶ms).unwrap();
|
||||||
|
assert_eq!(result, "Hello !");
|
||||||
|
|
||||||
|
// Test multiple parameters
|
||||||
|
let content = "{{ greeting }} {{ name }}!";
|
||||||
|
let params = HashMap::from([
|
||||||
|
("recipe_dir".to_string(), "some_dir".to_string()),
|
||||||
|
("greeting".to_string(), "Hi".to_string()),
|
||||||
|
("name".to_string(), "Alice".to_string()),
|
||||||
|
]);
|
||||||
|
let result = render_recipe_content_with_params(content, ¶ms).unwrap();
|
||||||
|
assert_eq!(result, "Hi Alice!");
|
||||||
|
|
||||||
|
// Test missing parameter results in error
|
||||||
|
let content = "Hello {{ missing }}!";
|
||||||
|
let params = HashMap::from([("recipe_dir".to_string(), "some_dir".to_string())]);
|
||||||
|
let err = render_recipe_content_with_params(content, ¶ms).unwrap_err();
|
||||||
|
let error_msg = err.to_string();
|
||||||
|
assert!(error_msg.contains("Failed to render the recipe"));
|
||||||
|
|
||||||
|
// Test invalid template syntax results in error
|
||||||
|
let content = "Hello {{ unclosed";
|
||||||
|
let params = HashMap::from([("recipe_dir".to_string(), "some_dir".to_string())]);
|
||||||
|
let err = render_recipe_content_with_params(content, ¶ms).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("unexpected end of input"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ use goose::agents::extension::ExtensionError;
|
|||||||
use goose::agents::Agent;
|
use goose::agents::Agent;
|
||||||
use goose::config::{Config, ExtensionConfig, ExtensionConfigManager};
|
use goose::config::{Config, ExtensionConfig, ExtensionConfigManager};
|
||||||
use goose::providers::create;
|
use goose::providers::create;
|
||||||
|
use goose::recipe::SubRecipe;
|
||||||
use goose::session;
|
use goose::session;
|
||||||
use goose::session::Identifier;
|
use goose::session::Identifier;
|
||||||
use mcp_client::transport::Error as McpClientError;
|
use mcp_client::transport::Error as McpClientError;
|
||||||
@@ -46,6 +47,8 @@ pub struct SessionBuilderConfig {
|
|||||||
pub interactive: bool,
|
pub interactive: bool,
|
||||||
/// Quiet mode - suppress non-response output
|
/// Quiet mode - suppress non-response output
|
||||||
pub quiet: bool,
|
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
|
/// 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
|
// Create the agent
|
||||||
let agent: Agent = Agent::new();
|
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) {
|
let new_provider = match create(&provider_name, model_config) {
|
||||||
Ok(provider) => provider,
|
Ok(provider) => provider,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -216,7 +222,7 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle session file resolution and resuming
|
// 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
|
// Use a temporary path that won't be written to
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
@@ -500,6 +506,7 @@ mod tests {
|
|||||||
scheduled_job_id: None,
|
scheduled_job_id: None,
|
||||||
interactive: true,
|
interactive: true,
|
||||||
quiet: false,
|
quiet: false,
|
||||||
|
sub_recipes: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
assert_eq!(config.extensions.len(), 1);
|
assert_eq!(config.extensions.len(), 1);
|
||||||
|
|||||||
@@ -10,13 +10,14 @@ use futures_util::stream;
|
|||||||
use futures_util::stream::StreamExt;
|
use futures_util::stream::StreamExt;
|
||||||
use mcp_core::protocol::JsonRpcMessage;
|
use mcp_core::protocol::JsonRpcMessage;
|
||||||
|
|
||||||
|
use crate::agents::sub_recipe_manager::SubRecipeManager;
|
||||||
use crate::config::{Config, ExtensionConfigManager, PermissionManager};
|
use crate::config::{Config, ExtensionConfigManager, PermissionManager};
|
||||||
use crate::message::Message;
|
use crate::message::Message;
|
||||||
use crate::permission::permission_judge::check_tool_permissions;
|
use crate::permission::permission_judge::check_tool_permissions;
|
||||||
use crate::permission::PermissionConfirmation;
|
use crate::permission::PermissionConfirmation;
|
||||||
use crate::providers::base::Provider;
|
use crate::providers::base::Provider;
|
||||||
use crate::providers::errors::ProviderError;
|
use crate::providers::errors::ProviderError;
|
||||||
use crate::recipe::{Author, Recipe, Settings};
|
use crate::recipe::{Author, Recipe, Settings, SubRecipe};
|
||||||
use crate::scheduler_trait::SchedulerTrait;
|
use crate::scheduler_trait::SchedulerTrait;
|
||||||
use crate::tool_monitor::{ToolCall, ToolMonitor};
|
use crate::tool_monitor::{ToolCall, ToolMonitor};
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
@@ -52,6 +53,7 @@ use super::tool_execution::{ToolCallResult, CHAT_MODE_TOOL_SKIPPED_RESPONSE, DEC
|
|||||||
pub struct Agent {
|
pub struct Agent {
|
||||||
pub(super) provider: Mutex<Option<Arc<dyn Provider>>>,
|
pub(super) provider: Mutex<Option<Arc<dyn Provider>>>,
|
||||||
pub(super) extension_manager: Mutex<ExtensionManager>,
|
pub(super) extension_manager: Mutex<ExtensionManager>,
|
||||||
|
pub(super) sub_recipe_manager: Mutex<SubRecipeManager>,
|
||||||
pub(super) frontend_tools: Mutex<HashMap<String, FrontendTool>>,
|
pub(super) frontend_tools: Mutex<HashMap<String, FrontendTool>>,
|
||||||
pub(super) frontend_instructions: Mutex<Option<String>>,
|
pub(super) frontend_instructions: Mutex<Option<String>>,
|
||||||
pub(super) prompt_manager: Mutex<PromptManager>,
|
pub(super) prompt_manager: Mutex<PromptManager>,
|
||||||
@@ -80,6 +82,7 @@ impl Agent {
|
|||||||
Self {
|
Self {
|
||||||
provider: Mutex::new(None),
|
provider: Mutex::new(None),
|
||||||
extension_manager: Mutex::new(ExtensionManager::new()),
|
extension_manager: Mutex::new(ExtensionManager::new()),
|
||||||
|
sub_recipe_manager: Mutex::new(SubRecipeManager::new()),
|
||||||
frontend_tools: Mutex::new(HashMap::new()),
|
frontend_tools: Mutex::new(HashMap::new()),
|
||||||
frontend_instructions: Mutex::new(None),
|
frontend_instructions: Mutex::new(None),
|
||||||
prompt_manager: Mutex::new(PromptManager::new()),
|
prompt_manager: Mutex::new(PromptManager::new()),
|
||||||
@@ -193,6 +196,11 @@ impl Agent {
|
|||||||
Ok(tools)
|
Ok(tools)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn add_sub_recipes(&self, sub_recipes: Vec<SubRecipe>) {
|
||||||
|
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
|
/// Dispatch a single tool call to the appropriate client
|
||||||
#[instrument(skip(self, tool_call, request_id), fields(input, output))]
|
#[instrument(skip(self, tool_call, request_id), fields(input, output))]
|
||||||
pub async fn dispatch_tool_call(
|
pub async fn dispatch_tool_call(
|
||||||
@@ -242,7 +250,13 @@ impl Agent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let extension_manager = self.extension_manager.lock().await;
|
let extension_manager = self.extension_manager.lock().await;
|
||||||
let result: ToolCallResult = if tool_call.name == PLATFORM_READ_RESOURCE_TOOL_NAME {
|
let sub_recipe_manager = self.sub_recipe_manager.lock().await;
|
||||||
|
|
||||||
|
let result: ToolCallResult = if sub_recipe_manager.is_sub_recipe_tool(&tool_call.name) {
|
||||||
|
sub_recipe_manager
|
||||||
|
.dispatch_sub_recipe_tool_call(&tool_call.name, tool_call.arguments.clone())
|
||||||
|
.await
|
||||||
|
} else if tool_call.name == PLATFORM_READ_RESOURCE_TOOL_NAME {
|
||||||
// Check if the tool is read_resource and handle it separately
|
// Check if the tool is read_resource and handle it separately
|
||||||
ToolCallResult::from(
|
ToolCallResult::from(
|
||||||
extension_manager
|
extension_manager
|
||||||
@@ -465,17 +479,26 @@ impl Agent {
|
|||||||
|
|
||||||
if extension_name.is_none() || extension_name.as_deref() == Some("platform") {
|
if extension_name.is_none() || extension_name.as_deref() == Some("platform") {
|
||||||
// Add platform tools
|
// Add platform tools
|
||||||
prefixed_tools.push(platform_tools::search_available_extensions_tool());
|
prefixed_tools.extend([
|
||||||
prefixed_tools.push(platform_tools::manage_extensions_tool());
|
platform_tools::search_available_extensions_tool(),
|
||||||
prefixed_tools.push(platform_tools::manage_schedule_tool());
|
platform_tools::manage_extensions_tool(),
|
||||||
|
platform_tools::manage_schedule_tool(),
|
||||||
|
]);
|
||||||
|
|
||||||
// Add resource tools if supported
|
// Add resource tools if supported
|
||||||
if extension_manager.supports_resources() {
|
if extension_manager.supports_resources() {
|
||||||
prefixed_tools.push(platform_tools::read_resource_tool());
|
prefixed_tools.extend([
|
||||||
prefixed_tools.push(platform_tools::list_resources_tool());
|
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
|
prefixed_tools
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ pub mod extension_manager;
|
|||||||
mod large_response_handler;
|
mod large_response_handler;
|
||||||
pub mod platform_tools;
|
pub mod platform_tools;
|
||||||
pub mod prompt_manager;
|
pub mod prompt_manager;
|
||||||
|
mod recipe_tools;
|
||||||
mod reply_parts;
|
mod reply_parts;
|
||||||
mod router_tool_selector;
|
mod router_tool_selector;
|
||||||
mod router_tools;
|
mod router_tools;
|
||||||
mod schedule_tool;
|
mod schedule_tool;
|
||||||
|
pub mod sub_recipe_manager;
|
||||||
mod tool_execution;
|
mod tool_execution;
|
||||||
mod tool_router_index_manager;
|
mod tool_router_index_manager;
|
||||||
pub(crate) mod tool_vectordb;
|
pub(crate) mod tool_vectordb;
|
||||||
|
|||||||
1
crates/goose/src/agents/recipe_tools/mod.rs
Normal file
1
crates/goose/src/agents/recipe_tools/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub mod sub_recipe_tools;
|
||||||
167
crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs
Normal file
167
crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
use std::{collections::HashMap, fs};
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use mcp_core::tool::{Tool, ToolAnnotations};
|
||||||
|
use serde_json::{json, Map, Value};
|
||||||
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
|
use tokio::process::Command;
|
||||||
|
|
||||||
|
use crate::recipe::{Recipe, RecipeParameter, RecipeParameterRequirement, SubRecipe};
|
||||||
|
|
||||||
|
pub const SUB_RECIPE_TOOL_NAME_PREFIX: &str = "subrecipe__run_";
|
||||||
|
|
||||||
|
pub fn create_sub_recipe_tool(sub_recipe: &SubRecipe) -> Tool {
|
||||||
|
let input_schema = get_input_schema(sub_recipe).unwrap();
|
||||||
|
Tool::new(
|
||||||
|
format!("{}_{}", SUB_RECIPE_TOOL_NAME_PREFIX, sub_recipe.name),
|
||||||
|
"Run a sub recipe.
|
||||||
|
Use this tool when you need to run a sub-recipe.
|
||||||
|
The sub recipe will be run with the provided parameters
|
||||||
|
and return the output of the sub recipe."
|
||||||
|
.to_string(),
|
||||||
|
input_schema,
|
||||||
|
Some(ToolAnnotations {
|
||||||
|
title: Some(format!("run sub recipe {}", sub_recipe.name)),
|
||||||
|
read_only_hint: false,
|
||||||
|
destructive_hint: true,
|
||||||
|
idempotent_hint: false,
|
||||||
|
open_world_hint: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_sub_recipe_parameter_definition(
|
||||||
|
sub_recipe: &SubRecipe,
|
||||||
|
) -> Result<Option<Vec<RecipeParameter>>> {
|
||||||
|
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<Value> {
|
||||||
|
let mut sub_recipe_params_map = HashMap::<String, String>::new();
|
||||||
|
if let Some(params_with_value) = &sub_recipe.values {
|
||||||
|
for (param_name, param_value) in params_with_value {
|
||||||
|
sub_recipe_params_map.insert(param_name.clone(), param_value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let parameter_definition = get_sub_recipe_parameter_definition(sub_recipe)?;
|
||||||
|
if let Some(parameters) = parameter_definition {
|
||||||
|
let mut properties = Map::new();
|
||||||
|
let mut required = Vec::new();
|
||||||
|
for param in parameters {
|
||||||
|
if sub_recipe_params_map.contains_key(¶m.key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
properties.insert(
|
||||||
|
param.key.clone(),
|
||||||
|
json!({
|
||||||
|
"type": param.input_type.to_string(),
|
||||||
|
"description": param.description.clone(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if !matches!(param.requirement, RecipeParameterRequirement::Optional) {
|
||||||
|
required.push(param.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": properties,
|
||||||
|
"required": required
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
Ok(json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepare_command_params(
|
||||||
|
sub_recipe: &SubRecipe,
|
||||||
|
params_from_tool_call: Value,
|
||||||
|
) -> Result<HashMap<String, String>> {
|
||||||
|
let mut sub_recipe_params = HashMap::<String, String>::new();
|
||||||
|
if let Some(params_with_value) = &sub_recipe.values {
|
||||||
|
for (param_name, param_value) in params_with_value {
|
||||||
|
sub_recipe_params.insert(param_name.clone(), param_value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(params_map) = params_from_tool_call.as_object() {
|
||||||
|
for (key, value) in params_map {
|
||||||
|
sub_recipe_params.insert(
|
||||||
|
key.to_string(),
|
||||||
|
value.as_str().unwrap_or(&value.to_string()).to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(sub_recipe_params)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run_sub_recipe(sub_recipe: &SubRecipe, params: Value) -> Result<String> {
|
||||||
|
let command_params = prepare_command_params(sub_recipe, params)?;
|
||||||
|
|
||||||
|
let mut command = Command::new("goose");
|
||||||
|
command.arg("run").arg("--recipe").arg(&sub_recipe.path);
|
||||||
|
|
||||||
|
for (key, value) in command_params {
|
||||||
|
command.arg("--params").arg(format!("{}={}", key, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
command.stdout(std::process::Stdio::piped());
|
||||||
|
command.stderr(std::process::Stdio::piped());
|
||||||
|
|
||||||
|
let mut child = command
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to spawn: {}", e))?;
|
||||||
|
|
||||||
|
let stdout = child.stdout.take().expect("Failed to capture stdout");
|
||||||
|
let stderr = child.stderr.take().expect("Failed to capture stderr");
|
||||||
|
|
||||||
|
let mut stdout_reader = BufReader::new(stdout).lines();
|
||||||
|
let mut stderr_reader = BufReader::new(stderr).lines();
|
||||||
|
let stdout_sub_recipe_name = sub_recipe.name.clone();
|
||||||
|
let stderr_sub_recipe_name = sub_recipe.name.clone();
|
||||||
|
|
||||||
|
// Spawn background tasks to read from stdout and stderr
|
||||||
|
let stdout_task = tokio::spawn(async move {
|
||||||
|
let mut buffer = String::new();
|
||||||
|
while let Ok(Some(line)) = stdout_reader.next_line().await {
|
||||||
|
println!("[sub-recipe {}] {}", stdout_sub_recipe_name, line);
|
||||||
|
buffer.push_str(&line);
|
||||||
|
buffer.push('\n');
|
||||||
|
}
|
||||||
|
buffer
|
||||||
|
});
|
||||||
|
|
||||||
|
let stderr_task = tokio::spawn(async move {
|
||||||
|
let mut buffer = String::new();
|
||||||
|
while let Ok(Some(line)) = stderr_reader.next_line().await {
|
||||||
|
eprintln!(
|
||||||
|
"[stderr for sub-recipe {}] {}",
|
||||||
|
stderr_sub_recipe_name, line
|
||||||
|
);
|
||||||
|
buffer.push_str(&line);
|
||||||
|
buffer.push('\n');
|
||||||
|
}
|
||||||
|
buffer
|
||||||
|
});
|
||||||
|
|
||||||
|
let status = child
|
||||||
|
.wait()
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to wait for process: {}", e))?;
|
||||||
|
|
||||||
|
let stdout_output = stdout_task.await.unwrap();
|
||||||
|
let stderr_output = stderr_task.await.unwrap();
|
||||||
|
|
||||||
|
if status.success() {
|
||||||
|
Ok(stdout_output)
|
||||||
|
} else {
|
||||||
|
Err(anyhow::anyhow!("Command failed:\n{}", stderr_output))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
155
crates/goose/src/agents/recipe_tools/sub_recipe_tools/tests.rs
Normal file
155
crates/goose/src/agents/recipe_tools/sub_recipe_tools/tests.rs
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crate::recipe::SubRecipe;
|
||||||
|
|
||||||
|
fn setup_sub_recipe() -> SubRecipe {
|
||||||
|
let sub_recipe = SubRecipe {
|
||||||
|
name: "test_sub_recipe".to_string(),
|
||||||
|
path: "test_sub_recipe.yaml".to_string(),
|
||||||
|
values: Some(HashMap::from([("key1".to_string(), "value1".to_string())])),
|
||||||
|
};
|
||||||
|
sub_recipe
|
||||||
|
}
|
||||||
|
mod prepare_command_params_tests {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
agents::recipe_tools::sub_recipe_tools::{
|
||||||
|
prepare_command_params, tests::tests::setup_sub_recipe,
|
||||||
|
},
|
||||||
|
recipe::SubRecipe,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_prepare_command_params_basic() {
|
||||||
|
let mut params = HashMap::new();
|
||||||
|
params.insert("key2".to_string(), "value2".to_string());
|
||||||
|
|
||||||
|
let sub_recipe = setup_sub_recipe();
|
||||||
|
|
||||||
|
let params_value = serde_json::to_value(params).unwrap();
|
||||||
|
let result = prepare_command_params(&sub_recipe, params_value).unwrap();
|
||||||
|
assert_eq!(result.len(), 2);
|
||||||
|
assert_eq!(result.get("key1"), Some(&"value1".to_string()));
|
||||||
|
assert_eq!(result.get("key2"), Some(&"value2".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_prepare_command_params_empty() {
|
||||||
|
let sub_recipe = SubRecipe {
|
||||||
|
name: "test_sub_recipe".to_string(),
|
||||||
|
path: "test_sub_recipe.yaml".to_string(),
|
||||||
|
values: None,
|
||||||
|
};
|
||||||
|
let params: HashMap<String, String> = HashMap::new();
|
||||||
|
let params_value = serde_json::to_value(params).unwrap();
|
||||||
|
let result = prepare_command_params(&sub_recipe, params_value).unwrap();
|
||||||
|
assert_eq!(result.len(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod get_input_schema_tests {
|
||||||
|
use crate::{
|
||||||
|
agents::recipe_tools::sub_recipe_tools::{
|
||||||
|
get_input_schema, tests::tests::setup_sub_recipe,
|
||||||
|
},
|
||||||
|
recipe::SubRecipe,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_input_schema_with_parameters() {
|
||||||
|
let sub_recipe = setup_sub_recipe();
|
||||||
|
|
||||||
|
let sub_recipe_file_content = r#"{
|
||||||
|
"version": "1.0.0",
|
||||||
|
"title": "Test Recipe",
|
||||||
|
"description": "A test recipe",
|
||||||
|
"prompt": "Test prompt",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"key": "key1",
|
||||||
|
"input_type": "string",
|
||||||
|
"requirement": "required",
|
||||||
|
"description": "A test parameter"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "key2",
|
||||||
|
"input_type": "number",
|
||||||
|
"requirement": "optional",
|
||||||
|
"description": "An optional parameter"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
let temp_file = temp_dir.path().join("test_sub_recipe.yaml");
|
||||||
|
std::fs::write(&temp_file, sub_recipe_file_content).unwrap();
|
||||||
|
|
||||||
|
let mut sub_recipe = sub_recipe;
|
||||||
|
sub_recipe.path = temp_file.to_string_lossy().to_string();
|
||||||
|
|
||||||
|
let result = get_input_schema(&sub_recipe).unwrap();
|
||||||
|
|
||||||
|
// Verify the schema structure
|
||||||
|
assert_eq!(result["type"], "object");
|
||||||
|
assert!(result["properties"].is_object());
|
||||||
|
|
||||||
|
let properties = result["properties"].as_object().unwrap();
|
||||||
|
assert_eq!(properties.len(), 1);
|
||||||
|
|
||||||
|
let key2_prop = &properties["key2"];
|
||||||
|
assert_eq!(key2_prop["type"], "number");
|
||||||
|
assert_eq!(key2_prop["description"], "An optional parameter");
|
||||||
|
|
||||||
|
let required = result["required"].as_array().unwrap();
|
||||||
|
assert_eq!(required.len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_input_schema_no_parameters_values() {
|
||||||
|
let sub_recipe = SubRecipe {
|
||||||
|
name: "test_sub_recipe".to_string(),
|
||||||
|
path: "test_sub_recipe.yaml".to_string(),
|
||||||
|
values: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let sub_recipe_file_content = r#"{
|
||||||
|
"version": "1.0.0",
|
||||||
|
"title": "Test Recipe",
|
||||||
|
"description": "A test recipe",
|
||||||
|
"prompt": "Test prompt",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"key": "key1",
|
||||||
|
"input_type": "string",
|
||||||
|
"requirement": "required",
|
||||||
|
"description": "A test parameter"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
let temp_file = temp_dir.path().join("test_sub_recipe.yaml");
|
||||||
|
std::fs::write(&temp_file, sub_recipe_file_content).unwrap();
|
||||||
|
|
||||||
|
let mut sub_recipe = sub_recipe;
|
||||||
|
sub_recipe.path = temp_file.to_string_lossy().to_string();
|
||||||
|
|
||||||
|
let result = get_input_schema(&sub_recipe).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result["type"], "object");
|
||||||
|
assert!(result["properties"].is_object());
|
||||||
|
|
||||||
|
let properties = result["properties"].as_object().unwrap();
|
||||||
|
assert_eq!(properties.len(), 1);
|
||||||
|
|
||||||
|
let key1_prop = &properties["key1"];
|
||||||
|
assert_eq!(key1_prop["type"], "string");
|
||||||
|
assert_eq!(key1_prop["description"], "A test parameter");
|
||||||
|
assert_eq!(result["required"].as_array().unwrap().len(), 1);
|
||||||
|
assert_eq!(result["required"][0], "key1");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
89
crates/goose/src/agents/sub_recipe_manager.rs
Normal file
89
crates/goose/src/agents/sub_recipe_manager.rs
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
use mcp_core::{Content, Tool, ToolError};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
agents::{
|
||||||
|
recipe_tools::sub_recipe_tools::{
|
||||||
|
create_sub_recipe_tool, run_sub_recipe, SUB_RECIPE_TOOL_NAME_PREFIX,
|
||||||
|
},
|
||||||
|
tool_execution::ToolCallResult,
|
||||||
|
},
|
||||||
|
recipe::SubRecipe,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SubRecipeManager {
|
||||||
|
pub sub_recipe_tools: HashMap<String, Tool>,
|
||||||
|
pub sub_recipes: HashMap<String, SubRecipe>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SubRecipeManager {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SubRecipeManager {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
sub_recipe_tools: HashMap::new(),
|
||||||
|
sub_recipes: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_sub_recipe_tools(&mut self, sub_recipes_to_add: Vec<SubRecipe>) {
|
||||||
|
for sub_recipe in sub_recipes_to_add {
|
||||||
|
let sub_recipe_key = format!(
|
||||||
|
"{}_{}",
|
||||||
|
SUB_RECIPE_TOOL_NAME_PREFIX,
|
||||||
|
sub_recipe.name.clone()
|
||||||
|
);
|
||||||
|
let tool = create_sub_recipe_tool(&sub_recipe);
|
||||||
|
self.sub_recipe_tools.insert(sub_recipe_key.clone(), tool);
|
||||||
|
self.sub_recipes.insert(sub_recipe_key.clone(), sub_recipe);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_sub_recipe_tool(&self, tool_name: &str) -> bool {
|
||||||
|
self.sub_recipe_tools.contains_key(tool_name)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn dispatch_sub_recipe_tool_call(
|
||||||
|
&self,
|
||||||
|
tool_name: &str,
|
||||||
|
params: Value,
|
||||||
|
) -> ToolCallResult {
|
||||||
|
let result = self.call_sub_recipe_tool(tool_name, params).await;
|
||||||
|
match result {
|
||||||
|
Ok(call_result) => ToolCallResult::from(Ok(call_result)),
|
||||||
|
Err(e) => ToolCallResult::from(Err(ToolError::ExecutionError(e.to_string()))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn call_sub_recipe_tool(
|
||||||
|
&self,
|
||||||
|
tool_name: &str,
|
||||||
|
params: Value,
|
||||||
|
) -> Result<Vec<Content>, ToolError> {
|
||||||
|
let sub_recipe = self.sub_recipes.get(tool_name).ok_or_else(|| {
|
||||||
|
let sub_recipe_name = tool_name
|
||||||
|
.strip_prefix(SUB_RECIPE_TOOL_NAME_PREFIX)
|
||||||
|
.and_then(|s| s.strip_prefix("_"))
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ToolError::InvalidParameters(format!(
|
||||||
|
"Invalid sub-recipe tool name format: {}",
|
||||||
|
tool_name
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
ToolError::InvalidParameters(format!("Sub-recipe '{}' not found", sub_recipe_name))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let output = run_sub_recipe(sub_recipe, params).await.map_err(|e| {
|
||||||
|
ToolError::ExecutionError(format!("Sub-recipe execution failed: {}", e))
|
||||||
|
})?;
|
||||||
|
Ok(vec![Content::text(output)])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|
||||||
use crate::agents::extension::ExtensionConfig;
|
use crate::agents::extension::ExtensionConfig;
|
||||||
|
use serde::de::Deserializer;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
fn default_version() -> String {
|
fn default_version() -> String {
|
||||||
@@ -89,6 +93,9 @@ pub struct Recipe {
|
|||||||
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub parameters: Option<Vec<RecipeParameter>>, // any additional parameters for the recipe
|
pub parameters: Option<Vec<RecipeParameter>>, // any additional parameters for the recipe
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub sub_recipes: Option<Vec<SubRecipe>>, // sub-recipes for the recipe
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
@@ -112,6 +119,39 @@ pub struct Settings {
|
|||||||
pub temperature: Option<f32>,
|
pub temperature: Option<f32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
pub struct SubRecipe {
|
||||||
|
pub name: String,
|
||||||
|
pub path: String,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_value_map_as_string")]
|
||||||
|
pub values: Option<HashMap<String, String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deserialize_value_map_as_string<'de, D>(
|
||||||
|
deserializer: D,
|
||||||
|
) -> Result<Option<HashMap<String, String>>, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
// First, try to deserialize a map of values
|
||||||
|
let opt_raw: Option<HashMap<String, Value>> = 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)]
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum RecipeParameterRequirement {
|
pub enum RecipeParameterRequirement {
|
||||||
@@ -176,6 +216,7 @@ pub struct RecipeBuilder {
|
|||||||
activities: Option<Vec<String>>,
|
activities: Option<Vec<String>>,
|
||||||
author: Option<Author>,
|
author: Option<Author>,
|
||||||
parameters: Option<Vec<RecipeParameter>>,
|
parameters: Option<Vec<RecipeParameter>>,
|
||||||
|
sub_recipes: Option<Vec<SubRecipe>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Recipe {
|
impl Recipe {
|
||||||
@@ -206,6 +247,18 @@ impl Recipe {
|
|||||||
activities: None,
|
activities: None,
|
||||||
author: None,
|
author: None,
|
||||||
parameters: None,
|
parameters: None,
|
||||||
|
sub_recipes: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn from_content(content: &str) -> Result<Self> {
|
||||||
|
if serde_json::from_str::<serde_json::Value>(content).is_ok() {
|
||||||
|
Ok(serde_json::from_str(content)?)
|
||||||
|
} else if serde_yaml::from_str::<serde_yaml::Value>(content).is_ok() {
|
||||||
|
Ok(serde_yaml::from_str(content)?)
|
||||||
|
} else {
|
||||||
|
Err(anyhow::anyhow!(
|
||||||
|
"Unsupported format. Expected JSON or YAML."
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -274,6 +327,10 @@ impl RecipeBuilder {
|
|||||||
self.parameters = Some(parameters);
|
self.parameters = Some(parameters);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
pub fn sub_recipes(mut self, sub_recipes: Vec<SubRecipe>) -> Self {
|
||||||
|
self.sub_recipes = Some(sub_recipes);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Builds the Recipe instance
|
/// Builds the Recipe instance
|
||||||
///
|
///
|
||||||
@@ -298,6 +355,205 @@ impl RecipeBuilder {
|
|||||||
activities: self.activities,
|
activities: self.activities,
|
||||||
author: self.author,
|
author: self.author,
|
||||||
parameters: self.parameters,
|
parameters: self.parameters,
|
||||||
|
sub_recipes: self.sub_recipes,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_from_content_with_json() {
|
||||||
|
let content = r#"{
|
||||||
|
"version": "1.0.0",
|
||||||
|
"title": "Test Recipe",
|
||||||
|
"description": "A test recipe",
|
||||||
|
"prompt": "Test prompt",
|
||||||
|
"instructions": "Test instructions",
|
||||||
|
"extensions": [
|
||||||
|
{
|
||||||
|
"type": "stdio",
|
||||||
|
"name": "test_extension",
|
||||||
|
"cmd": "test_cmd",
|
||||||
|
"args": ["arg1", "arg2"],
|
||||||
|
"timeout": 300,
|
||||||
|
"description": "Test extension"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"key": "test_param",
|
||||||
|
"input_type": "string",
|
||||||
|
"requirement": "required",
|
||||||
|
"description": "A test parameter"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sub_recipes": [
|
||||||
|
{
|
||||||
|
"name": "test_sub_recipe",
|
||||||
|
"path": "test_sub_recipe.yaml",
|
||||||
|
"values": {
|
||||||
|
"sub_recipe_param": "sub_recipe_value"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
let recipe = Recipe::from_content(content).unwrap();
|
||||||
|
assert_eq!(recipe.version, "1.0.0");
|
||||||
|
assert_eq!(recipe.title, "Test Recipe");
|
||||||
|
assert_eq!(recipe.description, "A test recipe");
|
||||||
|
assert_eq!(recipe.instructions, Some("Test instructions".to_string()));
|
||||||
|
assert_eq!(recipe.prompt, Some("Test prompt".to_string()));
|
||||||
|
|
||||||
|
assert!(recipe.extensions.is_some());
|
||||||
|
let extensions = recipe.extensions.unwrap();
|
||||||
|
assert_eq!(extensions.len(), 1);
|
||||||
|
|
||||||
|
assert!(recipe.parameters.is_some());
|
||||||
|
let parameters = recipe.parameters.unwrap();
|
||||||
|
assert_eq!(parameters.len(), 1);
|
||||||
|
assert_eq!(parameters[0].key, "test_param");
|
||||||
|
assert!(matches!(
|
||||||
|
parameters[0].input_type,
|
||||||
|
RecipeParameterInputType::String
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
parameters[0].requirement,
|
||||||
|
RecipeParameterRequirement::Required
|
||||||
|
));
|
||||||
|
|
||||||
|
assert!(recipe.sub_recipes.is_some());
|
||||||
|
let sub_recipes = recipe.sub_recipes.unwrap();
|
||||||
|
assert_eq!(sub_recipes.len(), 1);
|
||||||
|
assert_eq!(sub_recipes[0].name, "test_sub_recipe");
|
||||||
|
assert_eq!(sub_recipes[0].path, "test_sub_recipe.yaml");
|
||||||
|
assert_eq!(
|
||||||
|
sub_recipes[0].values,
|
||||||
|
Some(HashMap::from([(
|
||||||
|
"sub_recipe_param".to_string(),
|
||||||
|
"sub_recipe_value".to_string()
|
||||||
|
)]))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_from_content_with_yaml() {
|
||||||
|
let content = r#"version: 1.0.0
|
||||||
|
title: Test Recipe
|
||||||
|
description: A test recipe
|
||||||
|
prompt: Test prompt
|
||||||
|
instructions: Test instructions
|
||||||
|
extensions:
|
||||||
|
- type: stdio
|
||||||
|
name: test_extension
|
||||||
|
cmd: test_cmd
|
||||||
|
args: [arg1, arg2]
|
||||||
|
timeout: 300
|
||||||
|
description: Test extension
|
||||||
|
parameters:
|
||||||
|
- key: test_param
|
||||||
|
input_type: string
|
||||||
|
requirement: required
|
||||||
|
description: A test parameter
|
||||||
|
sub_recipes:
|
||||||
|
- name: test_sub_recipe
|
||||||
|
path: test_sub_recipe.yaml
|
||||||
|
values:
|
||||||
|
sub_recipe_param: sub_recipe_value"#;
|
||||||
|
|
||||||
|
let recipe = Recipe::from_content(content).unwrap();
|
||||||
|
assert_eq!(recipe.version, "1.0.0");
|
||||||
|
assert_eq!(recipe.title, "Test Recipe");
|
||||||
|
assert_eq!(recipe.description, "A test recipe");
|
||||||
|
assert_eq!(recipe.instructions, Some("Test instructions".to_string()));
|
||||||
|
assert_eq!(recipe.prompt, Some("Test prompt".to_string()));
|
||||||
|
|
||||||
|
assert!(recipe.extensions.is_some());
|
||||||
|
let extensions = recipe.extensions.unwrap();
|
||||||
|
assert_eq!(extensions.len(), 1);
|
||||||
|
|
||||||
|
assert!(recipe.parameters.is_some());
|
||||||
|
let parameters = recipe.parameters.unwrap();
|
||||||
|
assert_eq!(parameters.len(), 1);
|
||||||
|
assert_eq!(parameters[0].key, "test_param");
|
||||||
|
assert!(matches!(
|
||||||
|
parameters[0].input_type,
|
||||||
|
RecipeParameterInputType::String
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
parameters[0].requirement,
|
||||||
|
RecipeParameterRequirement::Required
|
||||||
|
));
|
||||||
|
|
||||||
|
assert!(recipe.sub_recipes.is_some());
|
||||||
|
let sub_recipes = recipe.sub_recipes.unwrap();
|
||||||
|
assert_eq!(sub_recipes.len(), 1);
|
||||||
|
assert_eq!(sub_recipes[0].name, "test_sub_recipe");
|
||||||
|
assert_eq!(sub_recipes[0].path, "test_sub_recipe.yaml");
|
||||||
|
assert_eq!(
|
||||||
|
sub_recipes[0].values,
|
||||||
|
Some(HashMap::from([(
|
||||||
|
"sub_recipe_param".to_string(),
|
||||||
|
"sub_recipe_value".to_string()
|
||||||
|
)]))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_from_content_invalid_json() {
|
||||||
|
let content = "{ invalid json }";
|
||||||
|
|
||||||
|
let result = Recipe::from_content(content);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_from_content_missing_required_fields() {
|
||||||
|
let content = r#"{
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "A test recipe"
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
let result = Recipe::from_content(content);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_from_content_with_author() {
|
||||||
|
let content = r#"{
|
||||||
|
"version": "1.0.0",
|
||||||
|
"title": "Test Recipe",
|
||||||
|
"description": "A test recipe",
|
||||||
|
"instructions": "Test instructions",
|
||||||
|
"author": {
|
||||||
|
"contact": "test@example.com"
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
let recipe = Recipe::from_content(content).unwrap();
|
||||||
|
|
||||||
|
assert!(recipe.author.is_some());
|
||||||
|
let author = recipe.author.unwrap();
|
||||||
|
assert_eq!(author.contact, Some("test@example.com".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_from_content_with_activities() {
|
||||||
|
let content = r#"{
|
||||||
|
"version": "1.0.0",
|
||||||
|
"title": "Test Recipe",
|
||||||
|
"description": "A test recipe",
|
||||||
|
"instructions": "Test instructions",
|
||||||
|
"activities": ["activity1", "activity2"]
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
let recipe = Recipe::from_content(content).unwrap();
|
||||||
|
|
||||||
|
assert!(recipe.activities.is_some());
|
||||||
|
let activities = recipe.activities.unwrap();
|
||||||
|
assert_eq!(activities, vec!["activity1", "activity2"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1374,6 +1374,7 @@ mod tests {
|
|||||||
author: None,
|
author: None,
|
||||||
parameters: None,
|
parameters: None,
|
||||||
settings: None,
|
settings: None,
|
||||||
|
sub_recipes: None,
|
||||||
};
|
};
|
||||||
let mut recipe_file = File::create(&recipe_filename)?;
|
let mut recipe_file = File::create(&recipe_filename)?;
|
||||||
writeln!(
|
writeln!(
|
||||||
|
|||||||
Reference in New Issue
Block a user