Applied server side call to parse and save recipe (#5022)

This commit is contained in:
Lifei Zhou
2025-10-09 15:45:46 +11:00
committed by GitHub
parent fc7836d649
commit 1396d315e5
42 changed files with 868 additions and 2126 deletions
+1 -1
View File
@@ -1019,7 +1019,7 @@ pub async fn cli() -> Result<()> {
.and_then(|rf| {
goose::recipe::template_recipe::parse_recipe_content(
&rf.content,
rf.parent_dir.to_string_lossy().to_string(),
Some(rf.parent_dir.to_string_lossy().to_string()),
)
.ok()
.map(|(r, _)| r.version)
+14 -90
View File
@@ -1,43 +1,25 @@
use anyhow::Result;
use console::style;
use goose::recipe::validate_recipe::validate_recipe_template_from_file;
use crate::recipes::github_recipe::RecipeSource;
use crate::recipes::recipe::load_recipe_for_validation;
use crate::recipes::search_recipe::list_available_recipes;
use crate::recipes::search_recipe::{list_available_recipes, load_recipe_file};
use goose::recipe_deeplink;
/// Validates a recipe file
///
/// # Arguments
///
/// * `file_path` - Path to the recipe file to validate
///
/// # Returns
///
/// Result indicating success or failure
pub fn handle_validate(recipe_name: &str) -> Result<()> {
// Load and validate the recipe file
match load_recipe_for_validation(recipe_name) {
Ok(_) => {
println!("{} recipe file is valid", style("").green().bold());
Ok(())
}
Err(err) => {
println!("{} {}", style("").red().bold(), err);
Err(err)
}
}
let recipe_file = load_recipe_file(recipe_name)?;
validate_recipe_template_from_file(&recipe_file).map_err(|err| {
anyhow::anyhow!(
"{} recipe file is invalid: {}",
style("").red().bold(),
err
)
})?;
println!("{} recipe file is valid", style("").green().bold());
Ok(())
}
/// Generates a deeplink for a recipe file
///
/// # Arguments
///
/// * `recipe_name` - Path to the recipe file
///
/// # Returns
///
/// Result indicating success or failure
pub fn handle_deeplink(recipe_name: &str) -> Result<String> {
match generate_deeplink(recipe_name) {
Ok((deeplink_url, recipe)) => {
@@ -60,15 +42,6 @@ pub fn handle_deeplink(recipe_name: &str) -> Result<String> {
}
}
/// Opens a recipe in Goose Desktop
///
/// # Arguments
///
/// * `recipe_name` - Path to the recipe file
///
/// # Returns
///
/// Result indicating success or failure
pub fn handle_open(recipe_name: &str) -> Result<()> {
// Generate the deeplink using the helper function (no printing)
// This reuses all the validation and encoding logic
@@ -107,16 +80,6 @@ pub fn handle_open(recipe_name: &str) -> Result<()> {
}
}
/// Lists all available recipes from local paths and GitHub repositories
///
/// # Arguments
///
/// * `format` - Output format ("text" or "json")
/// * `verbose` - Whether to show detailed information
///
/// # Returns
///
/// Result indicating success or failure
pub fn handle_list(format: &str, verbose: bool) -> Result<()> {
let recipes = match list_available_recipes() {
Ok(recipes) => recipes,
@@ -168,18 +131,10 @@ pub fn handle_list(format: &str, verbose: bool) -> Result<()> {
Ok(())
}
/// Helper function to generate a deeplink
///
/// # Arguments
///
/// * `recipe_name` - Path to the recipe file
///
/// # Returns
///
/// Result containing the deeplink URL and recipe
fn generate_deeplink(recipe_name: &str) -> Result<(String, goose::recipe::Recipe)> {
let recipe_file = load_recipe_file(recipe_name)?;
// Load the recipe file first to validate it
let recipe = load_recipe_for_validation(recipe_name)?;
let recipe = validate_recipe_template_from_file(&recipe_file)?;
match recipe_deeplink::encode(&recipe) {
Ok(encoded) => {
let full_url = format!("goose://recipe?config={}", encoded);
@@ -227,20 +182,6 @@ prompt: "Test prompt content {{ name }}"
instructions: "Test instructions"
"#;
const RECIPE_WITH_INVALID_JSON_SCHEMA: &str = r#"
title: "Test Recipe with Invalid JSON Schema"
description: "A test recipe with invalid JSON schema"
prompt: "Test prompt content"
instructions: "Test instructions"
response:
json_schema:
type: invalid_type
properties:
result:
type: unknown_type
required: "should_be_array_not_string"
"#;
#[test]
fn test_handle_deeplink_valid_recipe() {
let temp_dir = TempDir::new().expect("Failed to create temp directory");
@@ -305,23 +246,6 @@ response:
assert!(result.is_err());
}
#[test]
fn test_handle_validation_recipe_with_invalid_json_schema() {
let temp_dir = TempDir::new().expect("Failed to create temp directory");
let recipe_path = create_test_recipe_file(
&temp_dir,
"test_recipe.yaml",
RECIPE_WITH_INVALID_JSON_SCHEMA,
);
let result = handle_validate(&recipe_path);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("JSON schema validation failed"));
}
#[test]
fn test_generate_deeplink_valid_recipe() {
let temp_dir = TempDir::new().expect("Failed to create temp directory");
@@ -331,7 +331,7 @@ fn get_github_recipe_info(repo: &str, dir_name: &str, recipe_filename: &str) ->
.map_err(|e| anyhow!("Failed to convert content to string: {}", e))?;
// Parse the recipe content
let (recipe, _) = parse_recipe_content(&content, format!("{}/{}", repo, dir_name))?;
let (recipe, _) = parse_recipe_content(&content, Some(format!("{}/{}", repo, dir_name)))?;
return Ok(RecipeInfo {
name: dir_name.to_string(),
+5 -30
View File
@@ -7,13 +7,13 @@ use crate::recipes::secret_discovery::{discover_recipe_secrets, SecretRequiremen
use anyhow::Result;
use goose::config::Config;
use goose::recipe::build_recipe::{
apply_values_to_parameters, build_recipe_from_template, validate_recipe_parameters, RecipeError,
apply_values_to_parameters, build_recipe_from_template, RecipeError,
};
use goose::recipe::read_recipe_file_content::RecipeFile;
use goose::recipe::template_recipe::render_recipe_for_preview;
use goose::recipe::validate_recipe::validate_recipe_parameters;
use goose::recipe::Recipe;
use serde_json::Value;
use std::collections::HashMap;
fn create_user_prompt_callback() -> impl Fn(&str, &str) -> Result<String> {
|key: &str, description: &str| -> Result<String> {
@@ -131,29 +131,11 @@ pub fn render_recipe_as_yaml(recipe_name: &str, params: Vec<(String, String)>) -
}
}
pub fn load_recipe_for_validation(recipe_name: &str) -> Result<Recipe> {
let (recipe_file, recipe_dir_str) = load_recipe_file_with_dir(recipe_name)?;
let recipe_file_content = &recipe_file.content;
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(),
)?;
if let Some(response) = &recipe.response {
if let Some(json_schema) = &response.json_schema {
validate_json_schema(json_schema)?;
}
}
Ok(recipe)
}
pub fn explain_recipe(recipe_name: &str, params: Vec<(String, String)>) -> Result<()> {
let (recipe_file, recipe_dir_str) = load_recipe_file_with_dir(recipe_name)?;
let recipe_file_content = &recipe_file.content;
let recipe_parameters = validate_recipe_parameters(recipe_file_content, &recipe_dir_str)?;
let recipe_parameters =
validate_recipe_parameters(recipe_file_content, Some(recipe_dir_str.clone()))?;
let (params_for_template, missing_params) = apply_values_to_parameters(
&params,
@@ -163,7 +145,7 @@ pub fn explain_recipe(recipe_name: &str, params: Vec<(String, String)>) -> Resul
)?;
let recipe = render_recipe_for_preview(
recipe_file_content,
recipe_dir_str.to_string(),
Some(recipe_dir_str.clone()),
&params_for_template,
)?;
print_recipe_explanation(&recipe);
@@ -172,13 +154,6 @@ pub fn explain_recipe(recipe_name: &str, params: Vec<(String, String)>) -> Resul
Ok(())
}
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)),
}
}
#[cfg(test)]
mod tests {
use goose::recipe::{RecipeParameterInputType, RecipeParameterRequirement};
+1
View File
@@ -39,6 +39,7 @@ utoipa = { version = "4.1", features = ["axum_extras", "chrono"] }
reqwest = { version = "0.12.9", features = ["json", "rustls-tls", "blocking", "multipart"], default-features = false }
tokio-util = "0.7.15"
uuid = { version = "1.11", features = ["v4"] }
serde_path_to_error = "0.1.20"
[[bin]]
name = "goosed"
+120 -18
View File
@@ -1,17 +1,42 @@
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use axum::extract::rejection::JsonRejection;
use axum::routing::get;
use axum::{extract::State, http::StatusCode, routing::post, Json, Router};
use goose::recipe::local_recipes;
use goose::recipe::validate_recipe::validate_recipe_template_from_content;
use goose::recipe::Recipe;
use goose::recipe_deeplink;
use goose::session::SessionManager;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use serde_path_to_error::deserialize as deserialize_with_path;
use utoipa::ToSchema;
fn format_json_rejection_message(rejection: &JsonRejection) -> String {
match rejection {
JsonRejection::JsonDataError(err) => {
format!("Request body validation failed: {}", clean_data_error(err))
}
JsonRejection::JsonSyntaxError(err) => format!("Invalid JSON payload: {}", err.body_text()),
JsonRejection::MissingJsonContentType(err) => err.body_text(),
JsonRejection::BytesRejection(err) => err.body_text(),
_ => rejection.body_text(),
}
}
fn clean_data_error(err: &axum::extract::rejection::JsonDataError) -> String {
let message = err.body_text();
message
.strip_prefix("Failed to deserialize the JSON body into the target type: ")
.map(|s| s.to_string())
.unwrap_or_else(|| message.to_string())
}
use crate::routes::errors::ErrorResponse;
use crate::routes::recipe_utils::get_all_recipes_manifests;
use crate::state::AppState;
@@ -19,7 +44,6 @@ use crate::state::AppState;
#[derive(Debug, Deserialize, ToSchema)]
pub struct CreateRecipeRequest {
session_id: String,
// Optional fields
#[serde(default)]
author: Option<AuthorRequest>,
}
@@ -72,7 +96,6 @@ pub struct ScanRecipeResponse {
pub struct SaveRecipeRequest {
recipe: Recipe,
id: Option<String>,
is_global: Option<bool>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct ParseRecipeRequest {
@@ -86,7 +109,6 @@ pub struct ParseRecipeResponse {
#[derive(Debug, Serialize, ToSchema)]
pub struct RecipeManifestResponse {
name: String,
recipe: Recipe,
#[serde(rename = "lastModified")]
last_modified: String,
@@ -115,7 +137,6 @@ pub struct ListRecipeResponse {
),
tag = "Recipe Management"
)]
/// Create a Recipe configuration from the current session
async fn create_recipe(
State(state): State<Arc<AppState>>,
Json(request): Json<CreateRecipeRequest>,
@@ -125,7 +146,6 @@ async fn create_recipe(
request.session_id
);
// Load messages from session
let session = match SessionManager::get_session(&request.session_id, true).await {
Ok(session) => session,
Err(e) => {
@@ -148,7 +168,6 @@ async fn create_recipe(
let agent = state.get_agent_for_route(request.session_id).await?;
// Create base recipe from agent state and messages
let recipe_result = agent.create_recipe(conversation).await;
match recipe_result {
@@ -261,7 +280,6 @@ async fn list_recipes(
let file_path = recipe_manifest_with_path.file_path.clone();
recipe_file_hash_map.insert(id.clone(), file_path);
RecipeManifestResponse {
name: recipe_manifest_with_path.name.clone(),
recipe: recipe_manifest_with_path.recipe.clone(),
id: id.clone(),
last_modified: recipe_manifest_with_path.last_modified.clone(),
@@ -291,10 +309,9 @@ async fn delete_recipe(
State(state): State<Arc<AppState>>,
Json(request): Json<DeleteRecipeRequest>,
) -> StatusCode {
let recipe_file_hash_map = state.recipe_file_hash_map.lock().await;
let file_path = match recipe_file_hash_map.get(&request.id) {
Some(path) => path,
None => return StatusCode::NOT_FOUND,
let file_path = match get_recipe_file_path_by_id(state.clone(), &request.id).await {
Ok(path) => path,
Err(err) => return err.status,
};
if fs::remove_file(file_path).is_err() {
@@ -317,14 +334,18 @@ async fn delete_recipe(
)]
async fn save_recipe(
State(state): State<Arc<AppState>>,
Json(request): Json<SaveRecipeRequest>,
payload: Result<Json<Value>, JsonRejection>,
) -> Result<StatusCode, ErrorResponse> {
let file_path = match request.id {
Some(id) => state.recipe_file_hash_map.lock().await.get(&id).cloned(),
let Json(raw_json) = payload.map_err(json_rejection_to_error_response)?;
let request = deserialize_save_recipe_request(raw_json)?;
validate_recipe(&request.recipe)?;
let file_path = match request.id.as_ref() {
Some(id) => Some(get_recipe_file_path_by_id(state.clone(), id).await?),
None => None,
};
match local_recipes::save_recipe_to_file(request.recipe, request.is_global, file_path) {
match local_recipes::save_recipe_to_file(request.recipe, file_path) {
Ok(_) => Ok(StatusCode::NO_CONTENT),
Err(e) => Err(ErrorResponse {
message: e.to_string(),
@@ -333,6 +354,85 @@ async fn save_recipe(
}
}
fn json_rejection_to_error_response(rejection: JsonRejection) -> ErrorResponse {
ErrorResponse {
message: format_json_rejection_message(&rejection),
status: StatusCode::BAD_REQUEST,
}
}
fn validate_recipe(recipe: &Recipe) -> Result<(), ErrorResponse> {
let recipe_json = serde_json::to_string(recipe).map_err(|err| ErrorResponse {
message: err.to_string(),
status: StatusCode::BAD_REQUEST,
})?;
validate_recipe_template_from_content(&recipe_json, None).map_err(|err| ErrorResponse {
message: err.to_string(),
status: StatusCode::BAD_REQUEST,
})?;
Ok(())
}
fn deserialize_save_recipe_request(value: Value) -> Result<SaveRecipeRequest, ErrorResponse> {
let payload = value.to_string();
let mut deserializer = serde_json::Deserializer::from_str(&payload);
let result: Result<SaveRecipeRequest, _> = deserialize_with_path(&mut deserializer);
result.map_err(|err| {
let path = err.path().to_string();
let inner = err.into_inner();
let message = if path.is_empty() {
format!("Save recipe validation failed: {}", inner)
} else {
format!(
"save recipe validation failed at {}: {}",
path.trim_start_matches('.'),
inner
)
};
ErrorResponse {
message,
status: StatusCode::BAD_REQUEST,
}
})
}
async fn get_recipe_file_path_by_id(
state: Arc<AppState>,
id: &str,
) -> Result<PathBuf, ErrorResponse> {
let cached_path = {
let map = state.recipe_file_hash_map.lock().await;
map.get(id).cloned()
};
if let Some(path) = cached_path {
return Ok(path);
}
let recipe_manifest_with_paths = get_all_recipes_manifests().unwrap_or_default();
let mut recipe_file_hash_map = HashMap::new();
let mut resolved_path: Option<PathBuf> = None;
for recipe_manifest_with_path in &recipe_manifest_with_paths {
if recipe_manifest_with_path.id == id {
resolved_path = Some(recipe_manifest_with_path.file_path.clone());
}
recipe_file_hash_map.insert(
recipe_manifest_with_path.id.clone(),
recipe_manifest_with_path.file_path.clone(),
);
}
state.set_recipe_file_hash_map(recipe_file_hash_map).await;
resolved_path.ok_or_else(|| ErrorResponse {
message: format!("Recipe not found: {}", id),
status: StatusCode::NOT_FOUND,
})
}
#[utoipa::path(
post,
path = "/recipes/parse",
@@ -347,9 +447,11 @@ async fn save_recipe(
async fn parse_recipe(
Json(request): Json<ParseRecipeRequest>,
) -> Result<Json<ParseRecipeResponse>, ErrorResponse> {
let recipe = Recipe::from_content(&request.content).map_err(|e| ErrorResponse {
message: format!("Invalid recipe format: {}", e),
status: StatusCode::BAD_REQUEST,
let recipe = validate_recipe_template_from_content(&request.content, None).map_err(|e| {
ErrorResponse {
message: format!("Invalid recipe format: {}", e),
status: StatusCode::BAD_REQUEST,
}
})?;
Ok(Json(ParseRecipeResponse { recipe }))
@@ -8,14 +8,8 @@ use anyhow::Result;
use goose::recipe::local_recipes::list_local_recipes;
use goose::recipe::Recipe;
use std::path::Path;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
pub struct RecipeManifestWithPath {
pub id: String,
pub name: String,
pub recipe: Recipe,
pub file_path: PathBuf,
pub last_modified: String,
@@ -37,16 +31,9 @@ pub fn get_all_recipes_manifests() -> Result<Vec<RecipeManifestWithPath>> {
else {
continue;
};
let recipe_metadata =
RecipeManifestMetadata::from_yaml_file(&file_path).unwrap_or_else(|_| {
RecipeManifestMetadata {
name: recipe.title.clone(),
}
});
let manifest_with_path = RecipeManifestWithPath {
id: short_id_from_path(file_path.to_string_lossy().as_ref()),
name: recipe_metadata.name,
recipe,
file_path,
last_modified,
@@ -57,44 +44,3 @@ pub fn get_all_recipes_manifests() -> Result<Vec<RecipeManifestWithPath>> {
Ok(recipe_manifests_with_path)
}
// this is a temporary struct to deserilize the UI recipe files. should not be used for other purposes.
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
struct RecipeManifestMetadata {
pub name: String,
}
impl RecipeManifestMetadata {
pub fn from_yaml_file(path: &Path) -> Result<Self> {
let content = fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("Failed to read file {}: {}", path.display(), e))?;
let metadata = serde_yaml::from_str::<Self>(&content)
.map_err(|e| anyhow::anyhow!("Failed to parse YAML: {}", e))?;
Ok(metadata)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn test_from_yaml_file_success() {
let temp_dir = tempdir().unwrap();
let file_path = temp_dir.path().join("test_recipe.yaml");
let yaml_content = r#"
name: "Test Recipe"
isGlobal: true
recipe: recipe_content
"#;
fs::write(&file_path, yaml_content).unwrap();
let result = RecipeManifestMetadata::from_yaml_file(&file_path).unwrap();
assert_eq!(result.name, "Test Recipe");
}
}
+7 -5
View File
@@ -237,11 +237,13 @@ async fn run_now_handler(
.and_then(|content| {
goose::recipe::template_recipe::parse_recipe_content(
&content,
std::path::Path::new(&job.source)
.parent()
.unwrap_or_else(|| std::path::Path::new(""))
.to_string_lossy()
.to_string(),
Some(
std::path::Path::new(&job.source)
.parent()
.unwrap_or_else(|| std::path::Path::new(""))
.to_string_lossy()
.to_string(),
),
)
.ok()
.map(|(r, _)| r.version)
+8 -96
View File
@@ -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(&params, 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(&param_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#"
+28 -32
View File
@@ -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)?;
+19 -28
View File
@@ -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),
}
}
}
+20 -17
View File
@@ -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
+156
View File
@@ -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(&param_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(", ")))
}
}