Lifei/UI parameter input (#5222)

This commit is contained in:
Lifei Zhou
2025-10-20 10:05:54 +11:00
committed by GitHub
parent 6c3e07e9c7
commit 8b53a5696e
27 changed files with 717 additions and 1250 deletions
@@ -0,0 +1,14 @@
You are being accessed through the Goose Desktop application.
The user is interacting with you through a graphical user interface with the following features:
- A chat interface where messages are displayed in a conversation format
- Support for markdown formatting in your responses
- Support for code blocks with syntax highlighting
- Tool use messages are included in the chat but outputs may need to be expanded
The user can add extensions for you through the "Settings" page, which is available in the menu
on the top right of the window. There is a section on that page for extensions, and it links to
the registry.
Some extensions are builtin, such as Developer and Memory, while
3rd party extensions can be browsed at https://block.github.io/goose/v1/extensions/.
@@ -0,0 +1,15 @@
You are a helpful agent.
You are being accessed through the Goose Desktop application, pre configured with instructions as requested by a human.
The user is interacting with you through a graphical user interface with the following features:
- A chat interface where messages are displayed in a conversation format
- Support for markdown formatting in your responses
- Support for code blocks with syntax highlighting
- Tool use messages are included in the chat but outputs may need to be expanded
It is VERY IMPORTANT that you take note of the provided instructions, also check if a style of output is requested and always do your best to adhere to it.
You can also validate your output after you have generated it to ensure it meets the requirements of the user.
There may be (but not always) some tools mentioned in the instructions which you can check are available to this instance of goose (and try to help the user if they are not or find alternatives).
IMPORTANT instructions for you to operate as agent:
{{recipe_instructions}}
+17 -25
View File
@@ -1,4 +1,4 @@
use crate::recipe::read_recipe_file_content::{read_parameter_file_content, RecipeFile};
use crate::recipe::read_recipe_file_content::read_parameter_file_content;
use crate::recipe::template_recipe::render_recipe_content_with_params;
use crate::recipe::validate_recipe::validate_recipe_template_from_content;
use crate::recipe::{
@@ -19,33 +19,26 @@ pub enum RecipeError {
RecipeParsing { source: anyhow::Error },
}
pub fn render_recipe_template<F>(
recipe_file: RecipeFile,
fn render_recipe_template<F>(
recipe_content: String,
recipe_dir: &Path,
params: Vec<(String, String)>,
user_prompt_fn: Option<F>,
) -> Result<(String, Vec<String>)>
where
F: Fn(&str, &str) -> Result<String, anyhow::Error>,
{
let RecipeFile {
content: recipe_file_content,
parent_dir: recipe_parent_dir,
..
} = recipe_file;
let recipe_dir_str = recipe_parent_dir
.to_str()
.ok_or_else(|| anyhow::anyhow!("Error getting recipe directory"))?;
let recipe_parameters = validate_recipe_template_from_content(
&recipe_file_content,
Some(recipe_dir_str.to_string()),
)?
.parameters;
let recipe_dir_str = recipe_dir.display().to_string();
let recipe_parameters =
validate_recipe_template_from_content(&recipe_content, Some(recipe_dir_str.clone()))?
.parameters;
let (params_for_template, missing_params) =
apply_values_to_parameters(&params, recipe_parameters, recipe_dir_str, user_prompt_fn)?;
apply_values_to_parameters(&params, recipe_parameters, &recipe_dir_str, user_prompt_fn)?;
let rendered_content = if missing_params.is_empty() {
render_recipe_content_with_params(&recipe_file_content, &params_for_template)?
render_recipe_content_with_params(&recipe_content, &params_for_template)?
} else {
String::new()
};
@@ -54,16 +47,16 @@ where
}
pub fn build_recipe_from_template<F>(
recipe_file: RecipeFile,
recipe_content: String,
recipe_dir: &Path,
params: Vec<(String, String)>,
user_prompt_fn: Option<F>,
) -> Result<Recipe, RecipeError>
where
F: Fn(&str, &str) -> Result<String, anyhow::Error>,
{
let recipe_parent_dir = recipe_file.parent_dir.clone();
let (rendered_content, missing_params) =
render_recipe_template(recipe_file, params.clone(), user_prompt_fn)
render_recipe_template(recipe_content, recipe_dir, params.clone(), user_prompt_fn)
.map_err(|source| RecipeError::TemplateRendering { source })?;
if !missing_params.is_empty() {
@@ -77,8 +70,7 @@ where
if let Some(ref mut sub_recipes) = recipe.sub_recipes {
for sub_recipe in sub_recipes {
if let Ok(resolved_path) = resolve_sub_recipe_path(&sub_recipe.path, &recipe_parent_dir)
{
if let Ok(resolved_path) = resolve_sub_recipe_path(&sub_recipe.path, recipe_dir) {
sub_recipe.path = resolved_path;
}
}
@@ -90,7 +82,7 @@ where
pub fn apply_values_to_parameters<F>(
user_params: &[(String, String)],
recipe_parameters: Option<Vec<RecipeParameter>>,
recipe_parent_dir: &str,
recipe_dir: &str,
user_prompt_fn: Option<F>,
) -> Result<(HashMap<String, String>, Vec<String>)>
where
@@ -99,7 +91,7 @@ where
let mut param_map: HashMap<String, String> = user_params.iter().cloned().collect();
param_map.insert(
BUILT_IN_RECIPE_DIR_PARAM.to_string(),
recipe_parent_dir.to_string(),
recipe_dir.to_string(),
);
let mut missing_params: Vec<String> = Vec::new();
for param in recipe_parameters.unwrap_or_default() {
+73 -34
View File
@@ -3,12 +3,13 @@ use crate::recipe::build_recipe::{
};
use crate::recipe::read_recipe_file_content::RecipeFile;
use crate::recipe::{RecipeParameterInputType, RecipeParameterRequirement};
use std::path::PathBuf;
use tempfile::TempDir;
#[allow(clippy::type_complexity)]
const NO_USER_PROMPT: Option<fn(&str, &str) -> Result<String, anyhow::Error>> = None;
fn setup_recipe_file(instructions_and_parameters: &str) -> (TempDir, RecipeFile) {
fn setup_recipe_file(instructions_and_parameters: &str) -> (TempDir, String, PathBuf) {
let recipe_content = format!(
r#"{{
"version": "1.0.0",
@@ -22,14 +23,10 @@ fn setup_recipe_file(instructions_and_parameters: &str) -> (TempDir, RecipeFile)
let recipe_path = temp_dir.path().join("test_recipe.json");
std::fs::write(&recipe_path, recipe_content).unwrap();
let recipe_dir = temp_dir.path().to_path_buf();
let recipe_content = std::fs::read_to_string(&recipe_path).unwrap();
let recipe_file = RecipeFile {
content: std::fs::read_to_string(&recipe_path).unwrap(),
parent_dir: temp_dir.path().to_path_buf(),
file_path: recipe_path,
};
(temp_dir, recipe_file)
(temp_dir, recipe_content, recipe_dir)
}
fn setup_test_file(temp_dir: &TempDir, filename: &str, content: &str) -> std::path::PathBuf {
@@ -101,10 +98,11 @@ fn test_build_recipe_from_template_success() {
}
]"#;
let (_temp_dir, recipe_file) = setup_recipe_file(instructions_and_parameters);
let (_temp_dir, recipe_content, recipe_dir) = setup_recipe_file(instructions_and_parameters);
let params = vec![("my_name".to_string(), "value".to_string())];
let recipe = build_recipe_from_template(recipe_file, params, NO_USER_PROMPT).unwrap();
let recipe =
build_recipe_from_template(recipe_content, &recipe_dir, params, NO_USER_PROMPT).unwrap();
assert_eq!(recipe.title, "Test Recipe");
assert_eq!(recipe.description, "A test recipe");
@@ -134,10 +132,11 @@ fn test_build_recipe_from_template_success_variable_in_prompt() {
}
]"#;
let (_temp_dir, recipe_file) = setup_recipe_file(instructions_and_parameters);
let (_temp_dir, recipe_content, recipe_dir) = setup_recipe_file(instructions_and_parameters);
let params = vec![("my_name".to_string(), "value".to_string())];
let recipe = build_recipe_from_template(recipe_file, params, NO_USER_PROMPT).unwrap();
let recipe =
build_recipe_from_template(recipe_content, &recipe_dir, params, NO_USER_PROMPT).unwrap();
assert_eq!(recipe.title, "Test Recipe");
assert_eq!(recipe.description, "A test recipe");
@@ -165,9 +164,10 @@ fn test_build_recipe_from_template_wrong_parameters_in_recipe_file() {
"description": "A test parameter"
}
]"#;
let (_temp_dir, recipe_file) = setup_recipe_file(instructions_and_parameters);
let (_temp_dir, recipe_content, recipe_dir) = setup_recipe_file(instructions_and_parameters);
let build_recipe_result = build_recipe_from_template(recipe_file, Vec::new(), NO_USER_PROMPT);
let build_recipe_result =
build_recipe_from_template(recipe_content, &recipe_dir, Vec::new(), NO_USER_PROMPT);
assert!(build_recipe_result.is_err());
let err = build_recipe_result.unwrap_err();
println!("{}", err);
@@ -203,10 +203,11 @@ fn test_build_recipe_from_template_with_default_values_in_recipe_file() {
"description": "A test parameter"
}
]"#;
let (_temp_dir, recipe_file) = setup_recipe_file(instructions_and_parameters);
let (_temp_dir, recipe_content, recipe_dir) = setup_recipe_file(instructions_and_parameters);
let params = vec![("param_without_default".to_string(), "value1".to_string())];
let recipe = build_recipe_from_template(recipe_file, params, NO_USER_PROMPT).unwrap();
let recipe =
build_recipe_from_template(recipe_content, &recipe_dir, params, NO_USER_PROMPT).unwrap();
assert_eq!(recipe.title, "Test Recipe");
assert_eq!(recipe.description, "A test recipe");
@@ -229,9 +230,11 @@ fn test_build_recipe_from_template_optional_parameters_with_empty_default_values
"default": ""
}
]"#;
let (_temp_dir, recipe_file) = setup_recipe_file(instructions_and_parameters);
let (_temp_dir, recipe_content, recipe_dir) = setup_recipe_file(instructions_and_parameters);
let recipe = build_recipe_from_template(recipe_file, Vec::new(), NO_USER_PROMPT).unwrap();
let recipe =
build_recipe_from_template(recipe_content, &recipe_dir, Vec::new(), NO_USER_PROMPT)
.unwrap();
assert_eq!(recipe.title, "Test Recipe");
assert_eq!(recipe.description, "A test recipe");
assert_eq!(recipe.instructions.unwrap(), "Test instructions with ");
@@ -249,9 +252,10 @@ fn test_build_recipe_from_template_optional_parameters_without_default_values_in
"description": "A test parameter"
}
]"#;
let (_temp_dir, recipe_file) = setup_recipe_file(instructions_and_parameters);
let (_temp_dir, recipe_content, recipe_dir) = setup_recipe_file(instructions_and_parameters);
let build_recipe_result = build_recipe_from_template(recipe_file, Vec::new(), NO_USER_PROMPT);
let build_recipe_result =
build_recipe_from_template(recipe_content, &recipe_dir, Vec::new(), NO_USER_PROMPT);
assert!(build_recipe_result.is_err());
let err = build_recipe_result.unwrap_err();
println!("{}", err);
@@ -276,9 +280,10 @@ fn test_build_recipe_from_template_wrong_input_type_in_recipe_file() {
}
]"#;
let params = vec![("param".to_string(), "value".to_string())];
let (_temp_dir, recipe_file) = setup_recipe_file(instructions_and_parameters);
let (_temp_dir, recipe_content, recipe_dir) = setup_recipe_file(instructions_and_parameters);
let build_recipe_result = build_recipe_from_template(recipe_file, params, NO_USER_PROMPT);
let build_recipe_result =
build_recipe_from_template(recipe_content, &recipe_dir, params, NO_USER_PROMPT);
assert!(build_recipe_result.is_err());
let err = build_recipe_result.unwrap_err();
match err {
@@ -296,9 +301,11 @@ fn test_build_recipe_from_template_success_without_parameters() {
let instructions_and_parameters = r#"
"instructions": "Test instructions"
"#;
let (_temp_dir, recipe_file) = setup_recipe_file(instructions_and_parameters);
let (_temp_dir, recipe_content, recipe_dir) = setup_recipe_file(instructions_and_parameters);
let recipe = build_recipe_from_template(recipe_file, Vec::new(), NO_USER_PROMPT).unwrap();
let recipe =
build_recipe_from_template(recipe_content, &recipe_dir, Vec::new(), NO_USER_PROMPT)
.unwrap();
assert_eq!(recipe.instructions.unwrap(), "Test instructions");
assert!(recipe.parameters.is_none());
}
@@ -306,9 +313,10 @@ fn test_build_recipe_from_template_success_without_parameters() {
#[test]
fn test_build_recipe_from_template_missing_prompt_and_instructions() {
let instructions_and_parameters = "";
let (_temp_dir, recipe_file) = setup_recipe_file(instructions_and_parameters);
let (_temp_dir, recipe_content, recipe_dir) = setup_recipe_file(instructions_and_parameters);
let build_recipe_result = build_recipe_from_template(recipe_file, Vec::new(), NO_USER_PROMPT);
let build_recipe_result =
build_recipe_from_template(recipe_content, &recipe_dir, Vec::new(), NO_USER_PROMPT);
assert!(build_recipe_result.is_err());
let err = build_recipe_result.unwrap_err();
println!("{}", err);
@@ -366,8 +374,13 @@ fn test_template_inheritance() {
("is_enabled".to_string(), "true".to_string()),
];
let parent_recipe =
build_recipe_from_template(parent_recipe_file, params.clone(), NO_USER_PROMPT).unwrap();
let parent_recipe = build_recipe_from_template(
parent_recipe_file.content,
&parent_recipe_file.parent_dir,
params.clone(),
NO_USER_PROMPT,
)
.unwrap();
assert_eq!(parent_recipe.description, "Parent recipe");
assert_eq!(
parent_recipe.prompt.unwrap(),
@@ -380,8 +393,13 @@ fn test_template_inheritance() {
"is_enabled"
);
let child_recipe =
build_recipe_from_template(child_recipe_file, params, NO_USER_PROMPT).unwrap();
let child_recipe = build_recipe_from_template(
child_recipe_file.content,
&child_recipe_file.parent_dir,
params,
NO_USER_PROMPT,
)
.unwrap();
assert_eq!(child_recipe.title, "Parent");
assert_eq!(child_recipe.description, "Parent recipe");
assert_eq!(
@@ -467,7 +485,13 @@ instructions: Child instructions
file_path: main_recipe_path,
};
let recipe = build_recipe_from_template(recipe_file, Vec::new(), NO_USER_PROMPT).unwrap();
let recipe = build_recipe_from_template(
recipe_file.content,
&recipe_file.parent_dir,
Vec::new(),
NO_USER_PROMPT,
)
.unwrap();
assert_eq!(recipe.title, "Main Recipe");
assert!(recipe.sub_recipes.is_some());
@@ -505,7 +529,12 @@ parameters:
"FILE_PARAM".to_string(),
test_file_path.to_string_lossy().to_string(),
)];
let result = build_recipe_from_template(recipe_file, params, NO_USER_PROMPT);
let result = build_recipe_from_template(
recipe_file.content,
&recipe_file.parent_dir,
params,
NO_USER_PROMPT,
);
assert!(result.is_ok());
let recipe = result.unwrap();
@@ -530,7 +559,12 @@ parameters:
"FILE_PARAM".to_string(),
"/nonexistent/path/file.txt".to_string(),
)];
let result = build_recipe_from_template(recipe_file, params, NO_USER_PROMPT);
let result = build_recipe_from_template(
recipe_file.content,
&recipe_file.parent_dir,
params,
NO_USER_PROMPT,
);
assert!(result.is_err());
if let Err(RecipeError::TemplateRendering { source }) = result {
@@ -553,7 +587,12 @@ parameters:
let (_temp_dir, recipe_file) = setup_yaml_recipe_file(instructions_and_parameters);
let params = vec![];
let result = build_recipe_from_template(recipe_file, params, NO_USER_PROMPT);
let result = build_recipe_from_template(
recipe_file.content,
&recipe_file.parent_dir,
params,
NO_USER_PROMPT,
);
assert!(result.is_err());
if let Err(RecipeError::TemplateRendering { source }) = result {
+18 -36
View File
@@ -150,6 +150,11 @@ fn get_env_with_template_variables(
Ok((env, template_variables))
}
fn uses_template_inheritance(content: &str) -> bool {
let re = Regex::new(r"\{%-?\s*(extends|include)").unwrap();
re.is_match(content)
}
pub fn parse_recipe_content(
content: &str,
recipe_dir: Option<String>,
@@ -163,46 +168,23 @@ pub fn parse_recipe_content(
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)?;
// Detect if template uses inheritance or includes
let recipe_content = if uses_template_inheritance(&preprocessed_content) {
// Must render to resolve inheritance
template
.render(())
.map_err(|e| anyhow::anyhow!("Failed to parse the recipe {}", e))?
} else {
// Preserve conditionals and variables as-is
preprocessed_content
};
let recipe = Recipe::from_content(&recipe_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: Option<String>,
params: &HashMap<String, String>,
) -> Result<Recipe> {
// Pre-process template variables to handle invalid variable names
let preprocessed_content = preprocess_template_variables(content)?;
let (env, template_variables) = get_env_with_template_variables(
&preprocessed_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 {
+54 -10
View File
@@ -1,22 +1,22 @@
use crate::recipe::read_recipe_file_content::RecipeFile;
use crate::recipe::template_recipe::{parse_recipe_content, render_recipe_for_preview};
use crate::recipe::template_recipe::parse_recipe_content;
use crate::recipe::{
Recipe, RecipeParameter, RecipeParameterInputType, RecipeParameterRequirement,
BUILT_IN_RECIPE_DIR_PARAM,
};
use anyhow::Result;
use std::collections::{HashMap, HashSet};
use std::collections::HashSet;
pub fn validate_recipe_parameters(
pub fn parse_and_validate_parameters(
recipe_file_content: &str,
recipe_dir_str: Option<String>,
) -> Result<Option<Vec<RecipeParameter>>> {
) -> Result<Recipe> {
let (recipe_template, template_variables) =
parse_recipe_content(recipe_file_content, recipe_dir_str)?;
let recipe_parameters = recipe_template.parameters;
validate_optional_parameters(&recipe_parameters)?;
validate_parameters_in_template(&recipe_parameters, &template_variables)?;
Ok(recipe_parameters)
let recipe_parameters = &recipe_template.parameters;
validate_optional_parameters(recipe_parameters)?;
validate_parameters_in_template(recipe_parameters, &template_variables)?;
Ok(recipe_template)
}
fn validate_json_schema(schema: &serde_json::Value) -> Result<()> {
@@ -40,8 +40,8 @@ pub fn validate_recipe_template_from_content(
recipe_content: &str,
recipe_dir: Option<String>,
) -> Result<Recipe> {
validate_recipe_parameters(recipe_content, recipe_dir.clone())?;
let recipe = render_recipe_for_preview(recipe_content, recipe_dir, &HashMap::new())?;
parse_and_validate_parameters(recipe_content, recipe_dir.clone())?;
let (recipe, _) = parse_recipe_content(recipe_content, recipe_dir)?;
validate_prompt_or_instructions(&recipe)?;
if let Some(response) = &recipe.response {
@@ -154,3 +154,47 @@ fn validate_optional_parameters(parameters: &Option<Vec<RecipeParameter>>) -> Re
Err(anyhow::anyhow!("Optional parameters missing default values in the recipe: {}. Please provide defaults.", optional_params_without_default_values.join(", ")))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_recipe_template_from_content_success() {
let recipe_content = r#"
version: 1.0.0
title: Test Recipe
description: A test recipe for validation
instructions: Test instructions with {{ user_role }}
prompt: |
{% if user_role in ["Director, Account Management", "Senior Director, Account Management"] %}
- Focus on strategic planning and organizational performance
{% else %}
- Provide foundational account management guidance
{% endif %}
parameters:
- key: user_role
input_type: string
requirement: required
description: A test parameter
"#;
let result = validate_recipe_template_from_content(recipe_content, None);
if let Err(e) = &result {
eprintln!("Validation error: {}", e);
eprintln!("Error chain:");
let mut source = e.source();
while let Some(err) = source {
eprintln!(" Caused by: {}", err);
source = err.source();
}
}
assert!(result.is_ok(), "Validation failed: {:?}", result.err());
let recipe = result.unwrap();
assert_eq!(recipe.title, "Test Recipe");
assert_eq!(recipe.description, "A test recipe for validation");
assert!(recipe.instructions.is_some());
println!("Recipe: {:?}", recipe.prompt);
}
}