aster_server/routes/
recipe_utils.rs1use 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}