Applied server side call to parse and save recipe (#5022)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(
|
||||
¶ms,
|
||||
@@ -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()),
|
||||
¶ms_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};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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