Skip to main content

aster_server/routes/
recipe_utils.rs

1use std::collections::HashMap;
2use std::fs;
3use std::hash::DefaultHasher;
4use std::hash::{Hash, Hasher};
5use std::path::PathBuf;
6use std::sync::Arc;
7
8use crate::routes::errors::ErrorResponse;
9use crate::state::AppState;
10use anyhow::Result;
11use aster::agents::Agent;
12use aster::prompt_template::render_global_file;
13use aster::recipe::build_recipe::{build_recipe_from_template, RecipeError};
14use aster::recipe::local_recipes::{get_recipe_library_dir, list_local_recipes};
15use aster::recipe::validate_recipe::validate_recipe_template_from_content;
16use aster::recipe::Recipe;
17use axum::http::StatusCode;
18use serde::Serialize;
19use serde_json::Value;
20use tracing::error;
21use utoipa::ToSchema;
22
23pub struct RecipeValidationError {
24    pub status: StatusCode,
25    pub message: String,
26}
27
28#[derive(Debug, Serialize, ToSchema)]
29pub struct RecipeManifest {
30    pub id: String,
31    pub recipe: Recipe,
32    #[schema(value_type = String)]
33    pub file_path: PathBuf,
34    pub last_modified: String,
35    pub schedule_cron: Option<String>,
36    pub slash_command: Option<String>,
37}
38
39pub fn short_id_from_path(path: &str) -> String {
40    let mut hasher = DefaultHasher::new();
41    path.hash(&mut hasher);
42    let h = hasher.finish();
43    format!("{:016x}", h)
44}
45
46pub fn get_all_recipes_manifests() -> Result<Vec<RecipeManifest>> {
47    let recipes_with_path = list_local_recipes()?;
48    let mut recipe_manifests_with_path = Vec::new();
49    for (file_path, recipe) in recipes_with_path {
50        let Ok(last_modified) = fs::metadata(file_path.clone())
51            .map(|m| chrono::DateTime::<chrono::Utc>::from(m.modified().unwrap()).to_rfc3339())
52        else {
53            continue;
54        };
55
56        let manifest_with_path = RecipeManifest {
57            id: short_id_from_path(file_path.to_string_lossy().as_ref()),
58            recipe,
59            file_path,
60            last_modified,
61            schedule_cron: None,
62            slash_command: None,
63        };
64        recipe_manifests_with_path.push(manifest_with_path);
65    }
66    recipe_manifests_with_path.sort_by(|a, b| b.last_modified.cmp(&a.last_modified));
67
68    Ok(recipe_manifests_with_path)
69}
70
71pub fn validate_recipe(recipe: &Recipe) -> Result<(), RecipeValidationError> {
72    let recipe_yaml = recipe.to_yaml().map_err(|err| {
73        let message = err.to_string();
74        error!("Failed to serialize recipe for validation: {}", message);
75        RecipeValidationError {
76            status: StatusCode::BAD_REQUEST,
77            message,
78        }
79    })?;
80
81    validate_recipe_template_from_content(&recipe_yaml, None).map_err(|err| {
82        let message = err.to_string();
83        error!("Recipe validation failed: {}", message);
84        RecipeValidationError {
85            status: StatusCode::BAD_REQUEST,
86            message,
87        }
88    })?;
89
90    Ok(())
91}
92
93pub async fn get_recipe_file_path_by_id(
94    state: &AppState,
95    id: &str,
96) -> Result<PathBuf, ErrorResponse> {
97    let cached_path = {
98        let map = state.recipe_file_hash_map.lock().await;
99        map.get(id).cloned()
100    };
101
102    if let Some(path) = cached_path {
103        return Ok(path);
104    }
105
106    let recipe_manifest_with_paths = get_all_recipes_manifests().unwrap_or_default();
107    let mut recipe_file_hash_map = HashMap::new();
108    let mut resolved_path: Option<PathBuf> = None;
109
110    for recipe_manifest_with_path in &recipe_manifest_with_paths {
111        if recipe_manifest_with_path.id == id {
112            resolved_path = Some(recipe_manifest_with_path.file_path.clone());
113        }
114        recipe_file_hash_map.insert(
115            recipe_manifest_with_path.id.clone(),
116            recipe_manifest_with_path.file_path.clone(),
117        );
118    }
119
120    state.set_recipe_file_hash_map(recipe_file_hash_map).await;
121
122    resolved_path.ok_or_else(|| ErrorResponse {
123        message: format!("Recipe not found: {}", id),
124        status: StatusCode::NOT_FOUND,
125    })
126}
127
128pub async fn load_recipe_by_id(state: &AppState, id: &str) -> Result<Recipe, ErrorResponse> {
129    let path = get_recipe_file_path_by_id(state, id).await?;
130
131    Recipe::from_file_path(&path).map_err(|err| ErrorResponse {
132        message: format!("Failed to load recipe: {}", err),
133        status: StatusCode::INTERNAL_SERVER_ERROR,
134    })
135}
136
137pub async fn build_recipe_with_parameter_values(
138    original_recipe: &Recipe,
139    user_recipe_values: HashMap<String, String>,
140) -> Result<Option<Recipe>> {
141    let recipe_content = original_recipe.to_yaml()?;
142
143    let recipe_dir = get_recipe_library_dir(true);
144    let params = user_recipe_values.into_iter().collect();
145
146    let recipe = match build_recipe_from_template(
147        recipe_content,
148        &recipe_dir,
149        params,
150        None::<fn(&str, &str) -> Result<String, anyhow::Error>>,
151    ) {
152        Ok(recipe) => Some(recipe),
153        Err(RecipeError::MissingParams { .. }) => None,
154        Err(e) => return Err(anyhow::anyhow!(e)),
155    };
156
157    Ok(recipe)
158}
159
160pub async fn apply_recipe_to_agent(
161    agent: &Arc<Agent>,
162    recipe: &Recipe,
163    include_final_output_tool: bool,
164) -> Option<String> {
165    agent
166        .apply_recipe_components(
167            recipe.sub_recipes.clone(),
168            recipe.response.clone(),
169            include_final_output_tool,
170        )
171        .await;
172
173    recipe.instructions.as_ref().map(|instructions| {
174        let mut context: HashMap<&str, Value> = HashMap::new();
175        context.insert("recipe_instructions", Value::String(instructions.clone()));
176        render_global_file("desktop_recipe_instruction.md", &context).expect("Prompt should render")
177    })
178}