feat: show recipe explanation (#2530)

Co-authored-by: Michael Neale <michael.neale@gmail.com>
This commit is contained in:
Lifei Zhou
2025-05-15 10:57:05 +10:00
committed by GitHub
parent c1a6d811dc
commit e968e0022c
6 changed files with 227 additions and 100 deletions

View File

@@ -11,7 +11,7 @@ use crate::commands::project::{handle_project_default, handle_projects_interacti
use crate::commands::recipe::{handle_deeplink, handle_validate};
use crate::commands::session::{handle_session_list, handle_session_remove};
use crate::logging::setup_logging;
use crate::recipes::recipe::load_recipe;
use crate::recipes::recipe::{explain_recipe_with_parameters, load_recipe_as_template};
use crate::session;
use crate::session::{build_session, SessionBuilderConfig};
use goose_bench::bench_config::BenchRunConfig;
@@ -333,6 +333,13 @@ enum Command {
)]
no_session: bool,
/// Show the recipe title, description, and parameters
#[arg(
long = "explain",
help = "Show the recipe title, description, and parameters"
)]
explain: bool,
/// Maximum number of consecutive identical tool calls allowed
#[arg(
long = "max-tool-repetitions",
@@ -536,9 +543,10 @@ pub async fn cli() -> Result<()> {
remote_extensions,
builtins,
params,
explain,
}) => {
let input_config = match (instructions, input_text, recipe) {
(Some(file), _, _) if file == "-" => {
let input_config = match (instructions, input_text, recipe, explain) {
(Some(file), _, _, _) if file == "-" => {
let mut input = String::new();
std::io::stdin()
.read_to_string(&mut input)
@@ -550,7 +558,7 @@ pub async fn cli() -> Result<()> {
additional_system_prompt: None,
}
}
(Some(file), _, _) => {
(Some(file), _, _, _) => {
let contents = std::fs::read_to_string(&file).unwrap_or_else(|err| {
eprintln!(
"Instruction file not found — did you mean to use goose run --text?\n{}",
@@ -564,14 +572,18 @@ pub async fn cli() -> Result<()> {
additional_system_prompt: None,
}
}
(_, Some(text), _) => InputConfig {
(_, Some(text), _, _) => InputConfig {
contents: Some(text),
extensions_override: None,
additional_system_prompt: None,
},
(_, _, Some(recipe_name)) => {
(_, _, Some(recipe_name), explain) => {
if explain {
explain_recipe_with_parameters(&recipe_name, params)?;
return Ok(());
}
let recipe =
load_recipe(&recipe_name, true, Some(params)).unwrap_or_else(|err| {
load_recipe_as_template(&recipe_name, params).unwrap_or_else(|err| {
eprintln!("{}: {}", console::style("Error").red().bold(), err);
std::process::exit(1);
});
@@ -581,7 +593,7 @@ pub async fn cli() -> Result<()> {
additional_system_prompt: recipe.instructions,
}
}
(None, None, None) => {
(None, None, None, _) => {
eprintln!("Error: Must provide either --instructions (-i), --text (-t), or --recipe. Use -i - for stdin.");
std::process::exit(1);
}

View File

@@ -15,7 +15,7 @@ use crate::recipes::recipe::load_recipe;
/// Result indicating success or failure
pub fn handle_validate(recipe_name: &str) -> Result<()> {
// Load and validate the recipe file
match load_recipe(recipe_name, false, None) {
match load_recipe(recipe_name) {
Ok(_) => {
println!("{} recipe file is valid", style("").green().bold());
Ok(())
@@ -38,7 +38,7 @@ pub fn handle_validate(recipe_name: &str) -> Result<()> {
/// Result indicating success or failure
pub fn handle_deeplink(recipe_name: &str) -> Result<()> {
// Load the recipe file first to validate it
match load_recipe(recipe_name, false, None) {
match load_recipe(recipe_name) {
Ok(recipe) => {
if let Ok(recipe_json) = serde_json::to_string(&recipe) {
let deeplink = base64::engine::general_purpose::STANDARD.encode(recipe_json);

View File

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

View File

@@ -0,0 +1,72 @@
use std::collections::HashMap;
use console::style;
use goose::recipe::Recipe;
pub fn print_recipe_explanation(recipe: &Recipe) {
println!(
"{} {}",
style("🔍 Loading recipe:").bold().green(),
style(&recipe.title).green()
);
println!("{}", style("📄 Description:").bold());
println!(" {}", recipe.description);
if let Some(params) = &recipe.parameters {
if !params.is_empty() {
println!("{}", style("⚙️ Recipe Parameters:").bold());
for param in params {
let default_display = match &param.default {
Some(val) => format!(" (default: {})", val),
None => String::new(),
};
println!(
" - {} ({}, {}){}: {}",
style(&param.key).cyan(),
param.input_type,
param.requirement,
default_display,
param.description
);
}
}
}
}
pub fn print_required_parameters_for_template(
params_for_template: HashMap<String, String>,
missing_params: Vec<String>,
) {
if !params_for_template.is_empty() {
println!(
"{}",
style("📥 Parameters used to load this recipe:").bold()
);
for (key, value) in params_for_template {
println!(" {}: {}", key, value);
}
}
if !missing_params.is_empty() {
println!(
"{}",
style("🔴 Missing parameters in the command line if you want to run the recipe:")
.bold()
);
for param in missing_params.iter() {
println!(" - {}", param);
}
println!(
"📩 {}:",
style("Please provide the following parameters in the command line if you want to run the recipe:").bold()
);
println!(" {}", missing_parameters_command_line(missing_params));
}
}
pub fn missing_parameters_command_line(missing_params: Vec<String>) -> String {
missing_params
.iter()
.map(|key| format!("--params {}=your_value", key))
.collect::<Vec<_>>()
.join(" ")
}

View File

@@ -1,6 +1,10 @@
use anyhow::Result;
use console::style;
use crate::recipes::print_recipe::{
missing_parameters_command_line, print_recipe_explanation,
print_required_parameters_for_template,
};
use crate::recipes::search_recipe::retrieve_recipe_file;
use goose::recipe::{Recipe, RecipeParameter, RecipeParameterRequirement};
use minijinja::{Environment, Error, Template, UndefinedBehavior};
@@ -8,12 +12,63 @@ use serde_json::Value as JsonValue;
use serde_yaml::Value as YamlValue;
use std::collections::{HashMap, HashSet};
/// Loads, validates a recipe from a YAML or JSON file, and renders it with the given parameters
///
/// # Arguments
///
/// * `path` - Path to the recipe file (YAML or JSON)
/// * `params` - parameters to render the recipe with
///
/// # Returns
///
/// The rendered recipe if successful
///
/// # Errors
///
/// Returns an error if:
/// - Recipe is not valid
/// - The required fields are missing
pub fn load_recipe_as_template(recipe_name: &str, params: Vec<(String, String)>) -> Result<Recipe> {
let recipe_file_content = retrieve_recipe_file(recipe_name)?;
let recipe = validate_recipe_file_parameters(&recipe_file_content)?;
let (params_for_template, missing_params) =
apply_values_to_parameters(&params, recipe.parameters, true)?;
if !missing_params.is_empty() {
return Err(anyhow::anyhow!(
"Please provide the following parameters in the command line: {}",
missing_parameters_command_line(missing_params)
));
}
let rendered_content = render_content_with_params(&recipe_file_content, &params_for_template)?;
let recipe = parse_recipe_content(&rendered_content)?;
// Display information about the loaded recipe
println!(
"{} {}",
style("Loading recipe:").green().bold(),
style(&recipe.title).green()
);
println!("{} {}", style("Description:").bold(), &recipe.description);
if !params_for_template.is_empty() {
println!("{}", style("Parameters used to load this recipe:").bold());
for (key, value) in params_for_template {
println!("{}: {}", key, value);
}
}
println!();
Ok(recipe)
}
/// Loads and validates a recipe from a YAML or JSON file
///
/// # Arguments
///
/// * `path` - Path to the recipe file (YAML or JSON)
/// * `log` - whether to log information about the recipe or not
/// * `params` - optional parameters to render the recipe with
///
/// # Returns
@@ -26,65 +81,43 @@ use std::collections::{HashMap, HashSet};
/// - The file doesn't exist
/// - The file can't be read
/// - The YAML/JSON is invalid
/// - The required fields are missing
pub fn load_recipe(
recipe_name: &str,
log: bool,
params: Option<Vec<(String, String)>>,
) -> Result<Recipe> {
/// - The parameter definition does not match the template variables in the recipe file
pub fn load_recipe(recipe_name: &str) -> Result<Recipe> {
let recipe_file_content = retrieve_recipe_file(recipe_name)?;
let recipe_parameters = validate_recipe_file_parameters(&recipe_file_content)?;
let (rendered_content, params_for_template) = if let Some(user_params) = params {
let params_for_template = apply_values_to_parameters(&user_params, recipe_parameters)?;
(
render_content_with_params(&recipe_file_content, &params_for_template)?,
Some(params_for_template),
)
} else {
(recipe_file_content, None)
};
let recipe = parse_recipe_content(&rendered_content)?;
if log {
// Display information about the loaded recipe
println!(
"{} {}",
style("Loading recipe:").green().bold(),
style(&recipe.title).green()
);
println!("{} {}", style("Description:").dim(), &recipe.description);
if let Some(params) = params_for_template {
if !params.is_empty() {
println!("{}", style("Parameters:").dim());
for (key, value) in params {
println!("{}: {}", key, value);
}
}
}
println!(); // Add a blank line for spacing
}
Ok(recipe)
validate_recipe_file_parameters(&recipe_file_content)
}
fn validate_recipe_file_parameters(recipe_file_content: &str) -> Result<Vec<RecipeParameter>> {
pub fn explain_recipe_with_parameters(
recipe_name: &str,
params: Vec<(String, String)>,
) -> Result<()> {
let recipe_file_content = retrieve_recipe_file(recipe_name)?;
let raw_recipe = validate_recipe_file_parameters(&recipe_file_content)?;
print_recipe_explanation(&raw_recipe);
let recipe_parameters = raw_recipe.parameters;
let (params_for_template, missing_params) =
apply_values_to_parameters(&params, recipe_parameters, false)?;
print_required_parameters_for_template(params_for_template, missing_params);
Ok(())
}
fn validate_recipe_file_parameters(recipe_file_content: &str) -> Result<Recipe> {
let recipe_from_recipe_file: Recipe = parse_recipe_content(recipe_file_content)?;
validate_optional_parameters(&recipe_from_recipe_file)?;
validate_parameters_in_template(recipe_from_recipe_file, recipe_file_content)
validate_parameters_in_template(&recipe_from_recipe_file.parameters, recipe_file_content)?;
Ok(recipe_from_recipe_file)
}
fn validate_parameters_in_template(
recipe: Recipe,
recipe_parameters: &Option<Vec<RecipeParameter>>,
recipe_file_content: &str,
) -> Result<Vec<RecipeParameter>> {
) -> Result<()> {
let template_variables = extract_template_variables(recipe_file_content)?;
let param_keys: HashSet<String> = recipe
.parameters
let param_keys: HashSet<String> = recipe_parameters
.as_ref()
.unwrap_or(&vec![])
.iter()
@@ -100,7 +133,7 @@ fn validate_parameters_in_template(
.collect::<Vec<_>>();
if missing_keys.is_empty() && extra_keys.is_empty() {
return Ok(recipe.parameters.unwrap_or_default());
return Ok(());
}
let mut message = String::new();
@@ -173,15 +206,16 @@ fn extract_template_variables(template_str: &str) -> Result<HashSet<String>> {
fn apply_values_to_parameters(
user_params: &[(String, String)],
recipe_parameters: Vec<RecipeParameter>,
) -> Result<HashMap<String, String>> {
recipe_parameters: Option<Vec<RecipeParameter>>,
enable_user_prompt: bool,
) -> Result<(HashMap<String, String>, Vec<String>)> {
let mut param_map: HashMap<String, String> = user_params.iter().cloned().collect();
let mut missing_params: Vec<String> = Vec::new();
for param in recipe_parameters {
for param in recipe_parameters.unwrap_or_default() {
if !param_map.contains_key(&param.key) {
match (&param.default, &param.requirement) {
(Some(default), _) => param_map.insert(param.key.clone(), default.clone()),
(None, RecipeParameterRequirement::UserPrompt) => {
(None, RecipeParameterRequirement::UserPrompt) if enable_user_prompt => {
let input_value = cliclack::input(format!(
"Please enter {} ({})",
param.key, param.description
@@ -196,29 +230,16 @@ fn apply_values_to_parameters(
};
}
}
match missing_params.is_empty() {
true => Ok(param_map),
false => {
let formatted = missing_params
.iter()
.map(|key| format!("--params {}=your_value", key))
.collect::<Vec<_>>()
.join(" ");
Err(anyhow::anyhow!(
"Please provide the following parameters in the command line: {}",
formatted
))
}
}
Ok((param_map, missing_params))
}
fn render_content_with_params(content: &str, params: &HashMap<String, String>) -> Result<String> {
// Create a minijinja environment and context
let mut env = minijinja::Environment::new();
env.set_undefined_behavior(UndefinedBehavior::Strict);
let template: Template<'_, '_> = env.template_from_str(content)
.map_err(|e: Error| anyhow::anyhow!("Failed to render recipe {}, please check if the recipe has proper syntax for variables: eg: {{ variable_name }}", e.to_string()))?;
let template: Template<'_, '_> = env
.template_from_str(content)
.map_err(|e: Error| anyhow::anyhow!("Invalid template syntax: {}", e.to_string()))?;
// Render the template with the parameters
template.render(params).map_err(|e: Error| {
@@ -284,13 +305,11 @@ mod tests {
let content = "Hello {{ unclosed";
let params = HashMap::new();
let err = render_content_with_params(content, &params).unwrap_err();
assert!(err
.to_string()
.contains("please check if the recipe has proper syntax"));
assert!(err.to_string().contains("Invalid template syntax"));
}
#[test]
fn test_load_recipe_success() {
fn test_load_recipe_as_template_success() {
let instructions_and_parameters = r#"
"instructions": "Test instructions with {{ my_name }}",
"parameters": [
@@ -305,7 +324,7 @@ mod tests {
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(recipe_path.to_str().unwrap(), false, Some(params)).unwrap();
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");
@@ -323,7 +342,7 @@ mod tests {
}
#[test]
fn test_load_recipe_success_variable_in_prompt() {
fn test_load_recipe_as_template_success_variable_in_prompt() {
let instructions_and_parameters = r#"
"instructions": "Test instructions",
"prompt": "My prompt {{ my_name }}",
@@ -339,7 +358,7 @@ mod tests {
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(recipe_path.to_str().unwrap(), false, Some(params)).unwrap();
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");
@@ -356,7 +375,7 @@ mod tests {
}
#[test]
fn test_load_recipe_wrong_parameters_in_recipe_file() {
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": [
@@ -369,7 +388,7 @@ mod tests {
]"#;
let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters);
let load_recipe_result = load_recipe(recipe_path.to_str().unwrap(), false, None);
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());
@@ -384,7 +403,7 @@ mod tests {
}
#[test]
fn test_load_recipe_with_default_values_in_recipe_file() {
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": [
@@ -405,7 +424,7 @@ mod tests {
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(recipe_path.to_str().unwrap(), false, Some(params)).unwrap();
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");
@@ -416,7 +435,7 @@ mod tests {
}
#[test]
fn test_load_recipe_optional_parameters_without_default_values_in_recipe_file() {
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": [
@@ -429,7 +448,7 @@ mod tests {
]"#;
let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters);
let load_recipe_result = load_recipe(recipe_path.to_str().unwrap(), false, None);
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());
@@ -439,7 +458,7 @@ mod tests {
}
#[test]
fn test_load_recipe_wrong_input_type_in_recipe_file() {
fn test_load_recipe_as_template_wrong_input_type_in_recipe_file() {
let instructions_and_parameters = r#"
"instructions": "Test instructions with {{ param }}",
"parameters": [
@@ -453,22 +472,22 @@ mod tests {
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(recipe_path.to_str().unwrap(), false, Some(params));
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();
assert!(err.to_string().contains("unknown variant `some_invalid_type`, expected one of `string`, `number`, `date`, `file`"));
assert!(err
.to_string()
.contains("unknown variant `some_invalid_type`"));
}
#[test]
fn test_load_recipe_success_without_parameters() {
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 load_recipe_result = load_recipe(recipe_path.to_str().unwrap(), false, None);
assert!(load_recipe_result.is_ok());
let recipe = load_recipe_result.unwrap();
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());
}

View File

@@ -1,3 +1,5 @@
use std::fmt;
use crate::agents::extension::ExtensionConfig;
use serde::{Deserialize, Serialize};
@@ -102,15 +104,36 @@ pub enum RecipeParameterRequirement {
UserPrompt,
}
impl fmt::Display for RecipeParameterRequirement {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{}",
serde_json::to_string(self).unwrap().trim_matches('"')
)
}
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "snake_case")]
pub enum RecipeParameterInputType {
String,
Number,
Boolean,
Date,
File,
}
impl fmt::Display for RecipeParameterInputType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{}",
serde_json::to_string(self).unwrap().trim_matches('"')
)
}
}
#[derive(Serialize, Deserialize, Debug)]
pub struct RecipeParameter {
pub key: String,