Applied server side call to parse and save recipe (#5022)
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
use crate::recipe::read_recipe_file_content::{read_parameter_file_content, RecipeFile};
|
||||
use crate::recipe::template_recipe::{parse_recipe_content, render_recipe_content_with_params};
|
||||
use crate::recipe::template_recipe::render_recipe_content_with_params;
|
||||
use crate::recipe::validate_recipe::validate_recipe_template_from_content;
|
||||
use crate::recipe::{
|
||||
Recipe, RecipeParameter, RecipeParameterInputType, RecipeParameterRequirement,
|
||||
BUILT_IN_RECIPE_DIR_PARAM,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -34,7 +35,11 @@ where
|
||||
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 recipe_parameters = validate_recipe_template_from_content(
|
||||
&recipe_file_content,
|
||||
Some(recipe_dir_str.to_string()),
|
||||
)?
|
||||
.parameters;
|
||||
|
||||
let (params_for_template, missing_params) =
|
||||
apply_values_to_parameters(¶ms, recipe_parameters, recipe_dir_str, user_prompt_fn)?;
|
||||
@@ -48,18 +53,6 @@ where
|
||||
Ok((rendered_content, missing_params))
|
||||
}
|
||||
|
||||
pub 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 build_recipe_from_template<F>(
|
||||
recipe_file: RecipeFile,
|
||||
params: Vec<(String, String)>,
|
||||
@@ -94,87 +87,6 @@ where
|
||||
Ok(recipe)
|
||||
}
|
||||
|
||||
fn validate_parameters_in_template(
|
||||
recipe_parameters: &Option<Vec<RecipeParameter>>,
|
||||
template_variables: &HashSet<String>,
|
||||
) -> Result<()> {
|
||||
let mut template_variables = template_variables.clone();
|
||||
template_variables.remove(BUILT_IN_RECIPE_DIR_PARAM);
|
||||
|
||||
let param_keys: HashSet<String> = recipe_parameters
|
||||
.as_ref()
|
||||
.unwrap_or(&vec![])
|
||||
.iter()
|
||||
.map(|p| p.key.clone())
|
||||
.collect();
|
||||
|
||||
let missing_keys = template_variables
|
||||
.difference(¶m_keys)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let extra_keys = param_keys
|
||||
.difference(&template_variables)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if missing_keys.is_empty() && extra_keys.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut message = String::new();
|
||||
|
||||
if !missing_keys.is_empty() {
|
||||
message.push_str(&format!(
|
||||
"Missing definitions for parameters in the recipe file: {}.",
|
||||
missing_keys
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
if !extra_keys.is_empty() {
|
||||
message.push_str(&format!(
|
||||
"\nUnnecessary parameter definitions: {}.",
|
||||
extra_keys
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
));
|
||||
}
|
||||
Err(anyhow::anyhow!("{}", message.trim_end()))
|
||||
}
|
||||
|
||||
fn validate_optional_parameters(parameters: &Option<Vec<RecipeParameter>>) -> Result<()> {
|
||||
let empty_params = vec![];
|
||||
let params = parameters.as_ref().unwrap_or(&empty_params);
|
||||
|
||||
let file_params_with_defaults: Vec<String> = params
|
||||
.iter()
|
||||
.filter(|p| matches!(p.input_type, RecipeParameterInputType::File) && p.default.is_some())
|
||||
.map(|p| p.key.clone())
|
||||
.collect();
|
||||
|
||||
if !file_params_with_defaults.is_empty() {
|
||||
return Err(anyhow::anyhow!("File parameters cannot have default values to avoid importing sensitive user files: {}", file_params_with_defaults.join(", ")));
|
||||
}
|
||||
|
||||
let optional_params_without_default_values: Vec<String> = params
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
matches!(p.requirement, RecipeParameterRequirement::Optional) && p.default.is_none()
|
||||
})
|
||||
.map(|p| p.key.clone())
|
||||
.collect();
|
||||
|
||||
if optional_params_without_default_values.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow::anyhow!("Optional parameters missing default values in the recipe: {}. Please provide defaults.", optional_params_without_default_values.join(", ")))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_values_to_parameters<F>(
|
||||
user_params: &[(String, String)],
|
||||
recipe_parameters: Option<Vec<RecipeParameter>>,
|
||||
|
||||
@@ -303,6 +303,27 @@ fn test_build_recipe_from_template_success_without_parameters() {
|
||||
assert!(recipe.parameters.is_none());
|
||||
}
|
||||
|
||||
#[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 build_recipe_result = build_recipe_from_template(recipe_file, Vec::new(), NO_USER_PROMPT);
|
||||
assert!(build_recipe_result.is_err());
|
||||
let err = build_recipe_result.unwrap_err();
|
||||
println!("{}", err);
|
||||
|
||||
match err {
|
||||
RecipeError::TemplateRendering { source } => {
|
||||
let err_str = source.to_string();
|
||||
assert!(
|
||||
err_str.contains("Recipe must specify at least one of `instructions` or `prompt`.")
|
||||
);
|
||||
}
|
||||
_ => panic!("Expected TemplateRendering error"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_template_inheritance() {
|
||||
let parent_content = r#"
|
||||
|
||||
@@ -119,8 +119,16 @@ fn scan_directory_for_recipes(dir: &Path) -> Result<Vec<(PathBuf, Recipe)>> {
|
||||
if path.is_file() {
|
||||
if let Some(extension) = path.extension() {
|
||||
if RECIPE_FILE_EXTENSIONS.contains(&extension.to_string_lossy().as_ref()) {
|
||||
if let Ok(recipe) = Recipe::from_file_path(&path) {
|
||||
recipes.push((path.clone(), recipe));
|
||||
match Recipe::from_file_path(&path) {
|
||||
Ok(recipe) => recipes.push((path.clone(), recipe)),
|
||||
Err(e) => {
|
||||
let error_message = format!(
|
||||
"Failed to load recipe from file {}: {}",
|
||||
path.display(),
|
||||
e
|
||||
);
|
||||
tracing::error!("{}", error_message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,7 +138,7 @@ fn scan_directory_for_recipes(dir: &Path) -> Result<Vec<(PathBuf, Recipe)>> {
|
||||
Ok(recipes)
|
||||
}
|
||||
|
||||
fn generate_recipe_filename(title: &str) -> String {
|
||||
fn generate_recipe_filename(title: &str, recipe_library_dir: &Path) -> PathBuf {
|
||||
let base_name = title
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
@@ -145,41 +153,29 @@ fn generate_recipe_filename(title: &str) -> String {
|
||||
} else {
|
||||
base_name
|
||||
};
|
||||
format!("{}.yaml", filename)
|
||||
|
||||
let mut candidate = recipe_library_dir.join(format!("{}.yaml", filename));
|
||||
if !candidate.exists() {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
let mut counter = 1;
|
||||
loop {
|
||||
candidate = recipe_library_dir.join(format!("{}-{}.yaml", filename, counter));
|
||||
if !candidate.exists() {
|
||||
return candidate;
|
||||
}
|
||||
counter += 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_recipe_to_file(
|
||||
recipe: Recipe,
|
||||
is_global: Option<bool>,
|
||||
file_path: Option<PathBuf>,
|
||||
) -> anyhow::Result<PathBuf> {
|
||||
let is_global_value = is_global.unwrap_or(true);
|
||||
|
||||
let default_file_path =
|
||||
get_recipe_library_dir(is_global_value).join(generate_recipe_filename(&recipe.title));
|
||||
pub fn save_recipe_to_file(recipe: Recipe, file_path: Option<PathBuf>) -> anyhow::Result<PathBuf> {
|
||||
let recipe_library_dir = get_recipe_library_dir(true);
|
||||
|
||||
let file_path_value = match file_path {
|
||||
Some(path) => path,
|
||||
None => {
|
||||
if default_file_path.exists() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Recipe file already exists at: {:?}",
|
||||
default_file_path
|
||||
));
|
||||
}
|
||||
default_file_path
|
||||
}
|
||||
None => generate_recipe_filename(&recipe.title, &recipe_library_dir),
|
||||
};
|
||||
let all_recipes = list_local_recipes()?;
|
||||
|
||||
for (existing_path, existing_recipe) in &all_recipes {
|
||||
if existing_recipe.title == recipe.title && existing_path != &file_path_value {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Recipe with title '{}' already exists",
|
||||
recipe.title
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let yaml_content = serde_yaml::to_string(&recipe)?;
|
||||
fs::write(&file_path_value, yaml_content)?;
|
||||
|
||||
@@ -15,7 +15,9 @@ use utoipa::ToSchema;
|
||||
pub mod build_recipe;
|
||||
pub mod local_recipes;
|
||||
pub mod read_recipe_file_content;
|
||||
mod recipe_extension_adapter;
|
||||
pub mod template_recipe;
|
||||
pub mod validate_recipe;
|
||||
|
||||
pub const BUILT_IN_RECIPE_DIR_PARAM: &str = "recipe_dir";
|
||||
pub const RECIPE_FILE_EXTENSIONS: &[&str] = &["yaml", "json"];
|
||||
@@ -42,7 +44,11 @@ pub struct Recipe {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prompt: Option<String>, // the prompt to start the session with
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(
|
||||
skip_serializing_if = "Option::is_none",
|
||||
default,
|
||||
deserialize_with = "recipe_extension_adapter::deserialize_recipe_extensions"
|
||||
)]
|
||||
pub extensions: Option<Vec<ExtensionConfig>>, // a list of extensions to enable
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -253,34 +259,19 @@ impl Recipe {
|
||||
}
|
||||
|
||||
pub fn from_content(content: &str) -> Result<Self> {
|
||||
// Parse using YAML parser (since JSON is a subset of YAML, this handles both)
|
||||
let mut value: serde_yaml::Value = serde_yaml::from_str(content)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to parse recipe content as YAML/JSON: {}", e))?;
|
||||
|
||||
// Handle nested legacy recipe format
|
||||
if let Some(nested_recipe) = value.get("recipe") {
|
||||
value = nested_recipe.clone();
|
||||
}
|
||||
|
||||
if let Some(extensions) = value
|
||||
.get_mut("extensions")
|
||||
.and_then(|v| v.as_sequence_mut())
|
||||
{
|
||||
for ext in extensions.iter_mut() {
|
||||
if let Some(obj) = ext.as_mapping_mut() {
|
||||
if let Some(desc) = obj.get("description") {
|
||||
if desc.is_null() || desc.as_str().is_some_and(|s| s.is_empty()) {
|
||||
if let Some(name) = obj.get("name").and_then(|n| n.as_str()) {
|
||||
obj.insert("description".into(), name.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
let recipe: Recipe = match serde_yaml::from_str::<serde_yaml::Value>(content) {
|
||||
Ok(yaml_value) => {
|
||||
if let Some(nested_recipe) = yaml_value.get("recipe") {
|
||||
serde_yaml::from_value(nested_recipe.clone())
|
||||
.map_err(|e| anyhow::anyhow!("Failed to parse nested recipe: {}", e))?
|
||||
} else {
|
||||
serde_yaml::from_str(content)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to parse recipe: {}", e))?
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let recipe: Recipe = serde_yaml::from_value(value)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to deserialize recipe: {}", e))?;
|
||||
Err(_) => serde_yaml::from_str(content)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to parse recipe: {}", e))?,
|
||||
};
|
||||
|
||||
if let Some(ref retry_config) = recipe.retry {
|
||||
if let Err(validation_error) = retry_config.validate() {
|
||||
@@ -778,7 +769,7 @@ isGlobal: true"#;
|
||||
} = &extensions[0]
|
||||
{
|
||||
assert_eq!(name, "test_extension");
|
||||
assert_eq!(description, "test_extension");
|
||||
assert_eq!(description, "");
|
||||
} else {
|
||||
panic!("Expected Stdio extension");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
use crate::agents::extension::{Envs, ExtensionConfig};
|
||||
use rmcp::model::Tool;
|
||||
use serde::de::Deserializer;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
enum RecipeExtensionConfigInternal {
|
||||
#[serde(rename = "sse")]
|
||||
Sse {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
uri: String,
|
||||
#[serde(default)]
|
||||
envs: Envs,
|
||||
#[serde(default)]
|
||||
env_keys: Vec<String>,
|
||||
timeout: Option<u64>,
|
||||
#[serde(default)]
|
||||
bundled: Option<bool>,
|
||||
#[serde(default)]
|
||||
available_tools: Vec<String>,
|
||||
},
|
||||
#[serde(rename = "stdio")]
|
||||
Stdio {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
cmd: String,
|
||||
args: Vec<String>,
|
||||
#[serde(default)]
|
||||
envs: Envs,
|
||||
#[serde(default)]
|
||||
env_keys: Vec<String>,
|
||||
timeout: Option<u64>,
|
||||
#[serde(default)]
|
||||
bundled: Option<bool>,
|
||||
#[serde(default)]
|
||||
available_tools: Vec<String>,
|
||||
},
|
||||
#[serde(rename = "builtin")]
|
||||
Builtin {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
display_name: Option<String>,
|
||||
timeout: Option<u64>,
|
||||
#[serde(default)]
|
||||
bundled: Option<bool>,
|
||||
#[serde(default)]
|
||||
available_tools: Vec<String>,
|
||||
},
|
||||
#[serde(rename = "platform")]
|
||||
Platform {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
#[serde(default)]
|
||||
bundled: Option<bool>,
|
||||
#[serde(default)]
|
||||
available_tools: Vec<String>,
|
||||
},
|
||||
#[serde(rename = "streamable_http")]
|
||||
StreamableHttp {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
uri: String,
|
||||
#[serde(default)]
|
||||
envs: Envs,
|
||||
#[serde(default)]
|
||||
env_keys: Vec<String>,
|
||||
#[serde(default)]
|
||||
headers: HashMap<String, String>,
|
||||
timeout: Option<u64>,
|
||||
#[serde(default)]
|
||||
bundled: Option<bool>,
|
||||
#[serde(default)]
|
||||
available_tools: Vec<String>,
|
||||
},
|
||||
#[serde(rename = "frontend")]
|
||||
Frontend {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
tools: Vec<Tool>,
|
||||
instructions: Option<String>,
|
||||
#[serde(default)]
|
||||
bundled: Option<bool>,
|
||||
#[serde(default)]
|
||||
available_tools: Vec<String>,
|
||||
},
|
||||
#[serde(rename = "inline_python")]
|
||||
InlinePython {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
code: String,
|
||||
timeout: Option<u64>,
|
||||
#[serde(default)]
|
||||
dependencies: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
available_tools: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
macro_rules! map_recipe_extensions {
|
||||
($value:expr; $( $variant:ident { $( $field:ident ),* $(,)? } ),+ $(,)?) => {{
|
||||
match $value {
|
||||
$(
|
||||
RecipeExtensionConfigInternal::$variant {
|
||||
name,
|
||||
description,
|
||||
$( $field ),*
|
||||
} => ExtensionConfig::$variant {
|
||||
name,
|
||||
description: description.unwrap_or_default(),
|
||||
$( $field ),*
|
||||
},
|
||||
)+
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
impl From<RecipeExtensionConfigInternal> for ExtensionConfig {
|
||||
fn from(internal_variant: RecipeExtensionConfigInternal) -> Self {
|
||||
map_recipe_extensions!(
|
||||
internal_variant;
|
||||
Sse {
|
||||
uri,
|
||||
envs,
|
||||
env_keys,
|
||||
timeout,
|
||||
bundled,
|
||||
available_tools
|
||||
},
|
||||
Stdio {
|
||||
cmd,
|
||||
args,
|
||||
envs,
|
||||
env_keys,
|
||||
timeout,
|
||||
bundled,
|
||||
available_tools
|
||||
},
|
||||
Builtin {
|
||||
display_name,
|
||||
timeout,
|
||||
bundled,
|
||||
available_tools
|
||||
},
|
||||
Platform {
|
||||
bundled,
|
||||
available_tools
|
||||
},
|
||||
StreamableHttp {
|
||||
uri,
|
||||
envs,
|
||||
env_keys,
|
||||
headers,
|
||||
timeout,
|
||||
bundled,
|
||||
available_tools
|
||||
},
|
||||
Frontend {
|
||||
tools,
|
||||
instructions,
|
||||
bundled,
|
||||
available_tools
|
||||
},
|
||||
InlinePython {
|
||||
code,
|
||||
timeout,
|
||||
dependencies,
|
||||
available_tools
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deserialize_recipe_extensions<'de, D>(
|
||||
deserializer: D,
|
||||
) -> Result<Option<Vec<ExtensionConfig>>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let remotes = Option::<Vec<RecipeExtensionConfigInternal>>::deserialize(deserializer)?;
|
||||
Ok(remotes.map(|items| {
|
||||
items
|
||||
.into_iter()
|
||||
.map(ExtensionConfig::from)
|
||||
.collect::<Vec<_>>()
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Wrapper {
|
||||
#[serde(deserialize_with = "deserialize_recipe_extensions")]
|
||||
extensions: Option<Vec<ExtensionConfig>>,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_extension_defaults_description() {
|
||||
let wrapper: Wrapper = serde_json::from_value(json!({
|
||||
"extensions": [{
|
||||
"type": "builtin",
|
||||
"name": "test-builtin",
|
||||
"display_name": "Test Builtin",
|
||||
"timeout": 120,
|
||||
"bundled": true,
|
||||
"available_tools": ["tool_a", "tool_b"],
|
||||
}]
|
||||
}))
|
||||
.expect("failed to deserialize extensions");
|
||||
|
||||
let extensions = wrapper.extensions.expect("expected extensions");
|
||||
assert_eq!(extensions.len(), 1);
|
||||
|
||||
match &extensions[0] {
|
||||
ExtensionConfig::Builtin {
|
||||
name,
|
||||
description,
|
||||
display_name,
|
||||
timeout,
|
||||
bundled,
|
||||
available_tools,
|
||||
} => {
|
||||
assert_eq!(name, "test-builtin");
|
||||
assert_eq!(description, "");
|
||||
assert_eq!(display_name.as_deref(), Some("Test Builtin"));
|
||||
assert_eq!(*timeout, Some(120));
|
||||
assert_eq!(*bundled, Some(true));
|
||||
assert_eq!(
|
||||
available_tools,
|
||||
&vec!["tool_a".to_string(), "tool_b".to_string()]
|
||||
);
|
||||
}
|
||||
other => panic!("unexpected extension variant: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_extension_null_description_defaults_to_empty() {
|
||||
let wrapper: Wrapper = serde_json::from_value(json!({
|
||||
"extensions": [{
|
||||
"type": "builtin",
|
||||
"name": "null-description-builtin",
|
||||
"description": null,
|
||||
}]
|
||||
}))
|
||||
.expect("failed to deserialize extensions with null description");
|
||||
|
||||
let extensions = wrapper.extensions.expect("expected extensions");
|
||||
assert_eq!(extensions.len(), 1);
|
||||
|
||||
match &extensions[0] {
|
||||
ExtensionConfig::Builtin {
|
||||
name,
|
||||
description,
|
||||
display_name,
|
||||
timeout,
|
||||
bundled,
|
||||
available_tools,
|
||||
} => {
|
||||
assert_eq!(name, "null-description-builtin");
|
||||
assert_eq!(description, "");
|
||||
assert!(display_name.is_none());
|
||||
assert!(timeout.is_none());
|
||||
assert!(bundled.is_none());
|
||||
assert!(available_tools.is_empty());
|
||||
}
|
||||
other => panic!("unexpected extension variant: {:?}", other),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,7 +98,7 @@ pub fn render_recipe_content_with_params(
|
||||
|
||||
let env = add_template_in_env(
|
||||
&content_with_safe_variables,
|
||||
params.get(BUILT_IN_RECIPE_DIR_PARAM).unwrap().clone(),
|
||||
params.get(BUILT_IN_RECIPE_DIR_PARAM).cloned(),
|
||||
UndefinedBehavior::Strict,
|
||||
)?;
|
||||
let template = env.get_template(CURRENT_TEMPLATE_NAME).unwrap();
|
||||
@@ -110,23 +110,26 @@ pub fn render_recipe_content_with_params(
|
||||
|
||||
fn add_template_in_env(
|
||||
content: &str,
|
||||
recipe_dir: String,
|
||||
recipe_dir: Option<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)),
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(recipe_dir) = recipe_dir {
|
||||
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)
|
||||
@@ -134,7 +137,7 @@ fn add_template_in_env(
|
||||
|
||||
fn get_env_with_template_variables(
|
||||
content: &str,
|
||||
recipe_dir: String,
|
||||
recipe_dir: Option<String>,
|
||||
undefined_behavior: UndefinedBehavior,
|
||||
) -> Result<(Environment<'_>, HashSet<String>)> {
|
||||
let env = add_template_in_env(content, recipe_dir, undefined_behavior)?;
|
||||
@@ -149,7 +152,7 @@ fn get_env_with_template_variables(
|
||||
|
||||
pub fn parse_recipe_content(
|
||||
content: &str,
|
||||
recipe_dir: String,
|
||||
recipe_dir: Option<String>,
|
||||
) -> Result<(Recipe, HashSet<String>)> {
|
||||
// Pre-process template variables to handle invalid variable names
|
||||
let preprocessed_content = preprocess_template_variables(content)?;
|
||||
@@ -171,7 +174,7 @@ pub fn parse_recipe_content(
|
||||
// render the recipe for validation, deeplink and explain, etc.
|
||||
pub fn render_recipe_for_preview(
|
||||
content: &str,
|
||||
recipe_dir: String,
|
||||
recipe_dir: Option<String>,
|
||||
params: &HashMap<String, String>,
|
||||
) -> Result<Recipe> {
|
||||
// Pre-process template variables to handle invalid variable names
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
use crate::recipe::read_recipe_file_content::RecipeFile;
|
||||
use crate::recipe::template_recipe::{parse_recipe_content, render_recipe_for_preview};
|
||||
use crate::recipe::{
|
||||
Recipe, RecipeParameter, RecipeParameterInputType, RecipeParameterRequirement,
|
||||
BUILT_IN_RECIPE_DIR_PARAM,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
pub fn validate_recipe_parameters(
|
||||
recipe_file_content: &str,
|
||||
recipe_dir_str: Option<String>,
|
||||
) -> Result<Option<Vec<RecipeParameter>>> {
|
||||
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)
|
||||
}
|
||||
|
||||
fn validate_json_schema(schema: &serde_json::Value) -> Result<()> {
|
||||
match jsonschema::validator_for(schema) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(err) => Err(anyhow::anyhow!("JSON schema validation failed: {}", err)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_recipe_template_from_file(recipe_file: &RecipeFile) -> Result<Recipe> {
|
||||
let recipe_dir = recipe_file
|
||||
.parent_dir
|
||||
.to_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Error getting recipe directory"))?
|
||||
.to_string();
|
||||
|
||||
validate_recipe_template_from_content(&recipe_file.content, Some(recipe_dir))
|
||||
}
|
||||
|
||||
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())?;
|
||||
|
||||
validate_prompt_or_instructions(&recipe)?;
|
||||
if let Some(response) = &recipe.response {
|
||||
if let Some(json_schema) = &response.json_schema {
|
||||
validate_json_schema(json_schema)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(recipe)
|
||||
}
|
||||
|
||||
fn validate_prompt_or_instructions(recipe: &Recipe) -> Result<()> {
|
||||
let has_instructions = recipe
|
||||
.instructions
|
||||
.as_ref()
|
||||
.map(|value| !value.trim().is_empty())
|
||||
.unwrap_or(false);
|
||||
let has_prompt = recipe
|
||||
.prompt
|
||||
.as_ref()
|
||||
.map(|value| !value.trim().is_empty())
|
||||
.unwrap_or(false);
|
||||
|
||||
if has_instructions || has_prompt {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(anyhow::anyhow!(
|
||||
"Recipe must specify at least one of `instructions` or `prompt`."
|
||||
))
|
||||
}
|
||||
|
||||
fn validate_parameters_in_template(
|
||||
recipe_parameters: &Option<Vec<RecipeParameter>>,
|
||||
template_variables: &HashSet<String>,
|
||||
) -> Result<()> {
|
||||
let mut template_variables = template_variables.clone();
|
||||
template_variables.remove(BUILT_IN_RECIPE_DIR_PARAM);
|
||||
|
||||
let param_keys: HashSet<String> = recipe_parameters
|
||||
.as_ref()
|
||||
.unwrap_or(&vec![])
|
||||
.iter()
|
||||
.map(|p| p.key.clone())
|
||||
.collect();
|
||||
|
||||
let missing_keys = template_variables
|
||||
.difference(¶m_keys)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let extra_keys = param_keys
|
||||
.difference(&template_variables)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if missing_keys.is_empty() && extra_keys.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut message = String::new();
|
||||
|
||||
if !missing_keys.is_empty() {
|
||||
message.push_str(&format!(
|
||||
"Missing definitions for parameters in the recipe file: {}.",
|
||||
missing_keys
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
if !extra_keys.is_empty() {
|
||||
message.push_str(&format!(
|
||||
"\nUnnecessary parameter definitions: {}.",
|
||||
extra_keys
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
));
|
||||
}
|
||||
Err(anyhow::anyhow!("{}", message.trim_end()))
|
||||
}
|
||||
|
||||
fn validate_optional_parameters(parameters: &Option<Vec<RecipeParameter>>) -> Result<()> {
|
||||
let empty_params = vec![];
|
||||
let params = parameters.as_ref().unwrap_or(&empty_params);
|
||||
|
||||
let file_params_with_defaults: Vec<String> = params
|
||||
.iter()
|
||||
.filter(|p| matches!(p.input_type, RecipeParameterInputType::File) && p.default.is_some())
|
||||
.map(|p| p.key.clone())
|
||||
.collect();
|
||||
|
||||
if !file_params_with_defaults.is_empty() {
|
||||
return Err(anyhow::anyhow!("File parameters cannot have default values to avoid importing sensitive user files: {}", file_params_with_defaults.join(", ")));
|
||||
}
|
||||
|
||||
let optional_params_without_default_values: Vec<String> = params
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
matches!(p.requirement, RecipeParameterRequirement::Optional) && p.default.is_none()
|
||||
})
|
||||
.map(|p| p.key.clone())
|
||||
.collect();
|
||||
|
||||
if optional_params_without_default_values.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow::anyhow!("Optional parameters missing default values in the recipe: {}. Please provide defaults.", optional_params_without_default_values.join(", ")))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user