diff --git a/crates/goose-cli/src/recipes/recipe.rs b/crates/goose-cli/src/recipes/recipe.rs index 8b1fe3a6ab18..987b7d295b6e 100644 --- a/crates/goose-cli/src/recipes/recipe.rs +++ b/crates/goose-cli/src/recipes/recipe.rs @@ -9,9 +9,7 @@ use goose::config::Config; use goose::recipe::build_recipe::{ 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::validate_recipe::parse_and_validate_parameters; use goose::recipe::Recipe; use serde_json::Value; @@ -23,19 +21,16 @@ fn create_user_prompt_callback() -> impl Fn(&str, &str) -> Result { } } -fn load_recipe_file_with_dir(recipe_name: &str) -> Result<(RecipeFile, String)> { - let recipe_file = load_recipe_file(recipe_name)?; - let recipe_dir_str = recipe_file - .parent_dir - .to_str() - .ok_or_else(|| anyhow::anyhow!("Error getting recipe directory"))? - .to_string(); - Ok((recipe_file, recipe_dir_str)) -} - pub fn load_recipe(recipe_name: &str, params: Vec<(String, String)>) -> Result { let recipe_file = load_recipe_file(recipe_name)?; - match build_recipe_from_template(recipe_file, params, Some(create_user_prompt_callback())) { + let recipe_content = recipe_file.content; + let recipe_dir = recipe_file.parent_dir; + match build_recipe_from_template( + recipe_content, + &recipe_dir, + params, + Some(create_user_prompt_callback()), + ) { Ok(recipe) => { let secret_requirements = discover_recipe_secrets(&recipe); if let Err(e) = collect_missing_secrets(&secret_requirements) { @@ -132,10 +127,12 @@ pub fn render_recipe_as_yaml(recipe_name: &str, params: Vec<(String, String)>) - } 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 = load_recipe_file(recipe_name)?; + let recipe_dir_str = recipe_file.parent_dir.display().to_string(); let recipe_file_content = &recipe_file.content; - let recipe_parameters = - validate_recipe_parameters(recipe_file_content, Some(recipe_dir_str.clone()))?; + let recipe_template = + parse_and_validate_parameters(recipe_file_content, Some(recipe_dir_str.clone()))?; + let recipe_parameters = recipe_template.parameters.clone(); let (params_for_template, missing_params) = apply_values_to_parameters( ¶ms, @@ -143,12 +140,7 @@ pub fn explain_recipe(recipe_name: &str, params: Vec<(String, String)>) -> Resul &recipe_dir_str, None:: Result>, )?; - let recipe = render_recipe_for_preview( - recipe_file_content, - Some(recipe_dir_str.clone()), - ¶ms_for_template, - )?; - print_recipe_explanation(&recipe); + print_recipe_explanation(&recipe_template); print_required_parameters_for_template(params_for_template, missing_params); Ok(()) diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 73551c81271b..dade7c5a976f 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -344,11 +344,9 @@ derive_utoipa!(Icon as IconSchema); super::routes::agent::start_agent, super::routes::agent::resume_agent, super::routes::agent::get_tools, - super::routes::agent::add_sub_recipes, - super::routes::agent::extend_prompt, + super::routes::agent::update_from_session, super::routes::agent::update_agent_provider, super::routes::agent::update_router_tool_selector, - super::routes::agent::update_session_config, super::routes::reply::confirm_permission, super::routes::reply::reply, super::routes::context::manage_context, @@ -400,6 +398,7 @@ derive_utoipa!(Icon as IconSchema); super::routes::session::SessionListResponse, super::routes::session::UpdateSessionDescriptionRequest, super::routes::session::UpdateSessionUserRecipeValuesRequest, + super::routes::session::UpdateSessionUserRecipeValuesResponse, Message, MessageContent, MessageMetadata, @@ -479,16 +478,12 @@ derive_utoipa!(Icon as IconSchema); goose::recipe::SubRecipe, goose::agents::types::RetryConfig, goose::agents::types::SuccessCheck, - super::routes::agent::AddSubRecipesRequest, - super::routes::agent::AddSubRecipesResponse, - super::routes::agent::ExtendPromptRequest, - super::routes::agent::ExtendPromptResponse, super::routes::agent::UpdateProviderRequest, - super::routes::agent::SessionConfigRequest, super::routes::agent::GetToolsQuery, super::routes::agent::UpdateRouterToolSelectorRequest, super::routes::agent::StartAgentRequest, super::routes::agent::ResumeAgentRequest, + super::routes::agent::UpdateFromSessionRequest, super::routes::setup::SetupResponse, )) )] diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index d488167ab50b..497030432033 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -1,5 +1,7 @@ use crate::routes::errors::ErrorResponse; -use crate::routes::recipe_utils::{load_recipe_by_id, validate_recipe}; +use crate::routes::recipe_utils::{ + apply_recipe_to_agent, build_recipe_with_parameter_values, load_recipe_by_id, validate_recipe, +}; use crate::state::AppState; use axum::{ extract::{Query, State}, @@ -9,44 +11,30 @@ use axum::{ }; use goose::config::PermissionManager; +use goose::config::Config; use goose::model::ModelConfig; +use goose::prompt_template::render_global_file; use goose::providers::create; -use goose::recipe::{Recipe, Response}; +use goose::recipe::Recipe; use goose::recipe_deeplink; use goose::session::{Session, SessionManager}; use goose::{ agents::{extension::ToolInfo, extension_manager::get_parameter_names}, config::permission::PermissionLevel, }; -use goose::{config::Config, recipe::SubRecipe}; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; +use serde_json::Value; +use std::collections::HashMap; use std::path::PathBuf; use std::sync::atomic::Ordering; use std::sync::Arc; use tracing::error; #[derive(Deserialize, utoipa::ToSchema)] -pub struct ExtendPromptRequest { - extension: String, +pub struct UpdateFromSessionRequest { session_id: String, } -#[derive(Serialize, utoipa::ToSchema)] -pub struct ExtendPromptResponse { - success: bool, -} - -#[derive(Deserialize, utoipa::ToSchema)] -pub struct AddSubRecipesRequest { - sub_recipes: Vec, - session_id: String, -} - -#[derive(Serialize, utoipa::ToSchema)] -pub struct AddSubRecipesResponse { - success: bool, -} - #[derive(Deserialize, utoipa::ToSchema)] pub struct UpdateProviderRequest { provider: String, @@ -54,12 +42,6 @@ pub struct UpdateProviderRequest { session_id: String, } -#[derive(Deserialize, utoipa::ToSchema)] -pub struct SessionConfigRequest { - response: Option, - session_id: String, -} - #[derive(Deserialize, utoipa::ToSchema)] pub struct GetToolsQuery { extension_name: Option, @@ -109,7 +91,7 @@ async fn start_agent( recipe_deeplink, } = payload; - let resolved_recipe = if let Some(deeplink) = recipe_deeplink { + let original_recipe = if let Some(deeplink) = recipe_deeplink { match recipe_deeplink::decode(&deeplink) { Ok(recipe) => Some(recipe), Err(err) => { @@ -129,7 +111,7 @@ async fn start_agent( recipe }; - if let Some(ref recipe) = resolved_recipe { + if let Some(ref recipe) = original_recipe { if let Err(err) = validate_recipe(recipe) { return Err(ErrorResponse { message: err.message, @@ -151,7 +133,7 @@ async fn start_agent( } })?; - if let Some(recipe) = resolved_recipe { + if let Some(recipe) = original_recipe { SessionManager::update_session(&session.id) .recipe(Some(recipe)) .apply() @@ -207,40 +189,61 @@ async fn resume_agent( #[utoipa::path( post, - path = "/agent/add_sub_recipes", - request_body = AddSubRecipesRequest, + path = "/agent/update_from_session", + request_body = UpdateFromSessionRequest, responses( - (status = 200, description = "Added sub recipes to agent successfully", body = AddSubRecipesResponse), + (status = 200, description = "Update agent from session data successfully"), (status = 401, description = "Unauthorized - invalid secret key"), (status = 424, description = "Agent not initialized"), ), )] -async fn add_sub_recipes( +async fn update_from_session( State(state): State>, - Json(payload): Json, -) -> Result, StatusCode> { - let agent = state.get_agent_for_route(payload.session_id).await?; - agent.add_sub_recipes(payload.sub_recipes.clone()).await; - Ok(Json(AddSubRecipesResponse { success: true })) -} + Json(payload): Json, +) -> Result { + let agent = state + .get_agent_for_route(payload.session_id.clone()) + .await + .map_err(|status| ErrorResponse { + message: format!("Failed to get agent: {}", status), + status, + })?; + let session = SessionManager::get_session(&payload.session_id, false) + .await + .map_err(|err| ErrorResponse { + message: format!("Failed to get session: {}", err), + status: StatusCode::INTERNAL_SERVER_ERROR, + })?; + let context: HashMap<&str, Value> = HashMap::new(); + let desktop_prompt = + render_global_file("desktop_prompt.md", &context).expect("Prompt should render"); + let mut update_prompt = desktop_prompt; + if let Some(recipe) = session.recipe { + match build_recipe_with_parameter_values( + &recipe, + session.user_recipe_values.unwrap_or_default(), + ) + .await + { + Ok(Some(recipe)) => { + if let Some(prompt) = apply_recipe_to_agent(&agent, &recipe, true).await { + update_prompt = prompt; + } + } + Ok(None) => { + // Recipe has missing parameters - use default prompt + } + Err(e) => { + return Err(ErrorResponse { + message: e.to_string(), + status: StatusCode::INTERNAL_SERVER_ERROR, + }); + } + } + } + agent.extend_system_prompt(update_prompt).await; -#[utoipa::path( - post, - path = "/agent/prompt", - request_body = ExtendPromptRequest, - responses( - (status = 200, description = "Extended system prompt successfully", body = ExtendPromptResponse), - (status = 401, description = "Unauthorized - invalid secret key"), - (status = 424, description = "Agent not initialized"), - ), -)] -async fn extend_prompt( - State(state): State>, - Json(payload): Json, -) -> Result, StatusCode> { - let agent = state.get_agent_for_route(payload.session_id).await?; - agent.extend_system_prompt(payload.extension.clone()).await; - Ok(Json(ExtendPromptResponse { success: true })) + Ok(StatusCode::OK) } #[utoipa::path( @@ -378,46 +381,16 @@ async fn update_router_tool_selector( )) } -#[utoipa::path( - post, - path = "/agent/session_config", - request_body = SessionConfigRequest, - responses( - (status = 200, description = "Session config updated successfully", body = String), - (status = 401, description = "Unauthorized - invalid secret key"), - (status = 424, description = "Agent not initialized"), - (status = 500, description = "Internal server error") - ) -)] -async fn update_session_config( - State(state): State>, - Json(payload): Json, -) -> Result, StatusCode> { - let agent = state.get_agent_for_route(payload.session_id).await?; - if let Some(response) = payload.response { - agent.add_final_output_tool(response).await; - - tracing::info!("Added final output tool with response config"); - Ok(Json( - "Session config updated with final output tool".to_string(), - )) - } else { - Ok(Json("Nothing provided to update.".to_string())) - } -} - pub fn routes(state: Arc) -> Router { Router::new() .route("/agent/start", post(start_agent)) .route("/agent/resume", post(resume_agent)) - .route("/agent/prompt", post(extend_prompt)) .route("/agent/tools", get(get_tools)) .route("/agent/update_provider", post(update_agent_provider)) .route( "/agent/update_router_tool_selector", post(update_router_tool_selector), ) - .route("/agent/session_config", post(update_session_config)) - .route("/agent/add_sub_recipes", post(add_sub_recipes)) + .route("/agent/update_from_session", post(update_from_session)) .with_state(state) } diff --git a/crates/goose-server/src/routes/recipe.rs b/crates/goose-server/src/routes/recipe.rs index 5b39ccba869f..45e74c5db509 100644 --- a/crates/goose-server/src/routes/recipe.rs +++ b/crates/goose-server/src/routes/recipe.rs @@ -382,7 +382,6 @@ fn ensure_recipe_valid(recipe: &Recipe) -> Result<(), ErrorResponse> { status: err.status, }); } - Ok(()) } diff --git a/crates/goose-server/src/routes/recipe_utils.rs b/crates/goose-server/src/routes/recipe_utils.rs index 2c4b02a3ed2d..bb9c6421f247 100644 --- a/crates/goose-server/src/routes/recipe_utils.rs +++ b/crates/goose-server/src/routes/recipe_utils.rs @@ -3,16 +3,21 @@ use std::fs; use std::hash::DefaultHasher; use std::hash::{Hash, Hasher}; use std::path::PathBuf; +use std::sync::Arc; use anyhow::Result; use axum::http::StatusCode; use crate::routes::errors::ErrorResponse; use crate::state::AppState; -use goose::recipe::local_recipes::list_local_recipes; +use goose::agents::Agent; +use goose::prompt_template::render_global_file; +use goose::recipe::build_recipe::{build_recipe_from_template, RecipeError}; +use goose::recipe::local_recipes::{get_recipe_library_dir, list_local_recipes}; use goose::recipe::validate_recipe::validate_recipe_template_from_content; use goose::recipe::Recipe; -use serde_json; +use serde_json::Value; +use serde_yaml; use tracing::error; pub struct RecipeValidationError { @@ -58,7 +63,7 @@ pub fn get_all_recipes_manifests() -> Result> { } pub fn validate_recipe(recipe: &Recipe) -> Result<(), RecipeValidationError> { - let recipe_json = serde_json::to_string(recipe).map_err(|err| { + let recipe_yaml = serde_yaml::to_string(recipe).map_err(|err| { let message = err.to_string(); error!("Failed to serialize recipe for validation: {}", message); RecipeValidationError { @@ -67,7 +72,7 @@ pub fn validate_recipe(recipe: &Recipe) -> Result<(), RecipeValidationError> { } })?; - validate_recipe_template_from_content(&recipe_json, None).map_err(|err| { + validate_recipe_template_from_content(&recipe_yaml, None).map_err(|err| { let message = err.to_string(); error!("Recipe validation failed: {}", message); RecipeValidationError { @@ -122,3 +127,48 @@ pub async fn load_recipe_by_id(state: &AppState, id: &str) -> Result, +) -> Result> { + let recipe_content = serde_yaml::to_string(&original_recipe)?; + + let recipe_dir = get_recipe_library_dir(true); + let params = user_recipe_values.into_iter().collect(); + + let recipe = match build_recipe_from_template( + recipe_content, + &recipe_dir, + params, + None:: Result>, + ) { + Ok(recipe) => Some(recipe), + Err(RecipeError::MissingParams { .. }) => None, + Err(e) => return Err(anyhow::anyhow!(e)), + }; + + Ok(recipe) +} + +pub async fn apply_recipe_to_agent( + agent: &Arc, + recipe: &Recipe, + include_final_output_tool: bool, +) -> Option { + if let Some(sub_recipes) = &recipe.sub_recipes { + agent.add_sub_recipes(sub_recipes.clone()).await; + } + + if include_final_output_tool { + if let Some(response) = &recipe.response { + agent.add_final_output_tool(response.clone()).await; + } + } + + recipe.instructions.as_ref().map(|instructions| { + let mut context: HashMap<&str, Value> = HashMap::new(); + context.insert("recipe_instructions", Value::String(instructions.clone())); + render_global_file("desktop_recipe_instruction.md", &context).expect("Prompt should render") + }) +} diff --git a/crates/goose-server/src/routes/session.rs b/crates/goose-server/src/routes/session.rs index 3bea6a1251f0..6095f0cb2a3e 100644 --- a/crates/goose-server/src/routes/session.rs +++ b/crates/goose-server/src/routes/session.rs @@ -1,4 +1,7 @@ +use crate::routes::errors::ErrorResponse; +use crate::routes::recipe_utils::{apply_recipe_to_agent, build_recipe_with_parameter_values}; use crate::state::AppState; +use axum::extract::State; use axum::routing::post; use axum::{ extract::Path, @@ -6,6 +9,7 @@ use axum::{ routing::{delete, get, put}, Json, Router, }; +use goose::recipe::Recipe; use goose::session::session_manager::SessionInsights; use goose::session::{Session, SessionManager}; use serde::{Deserialize, Serialize}; @@ -34,6 +38,11 @@ pub struct UpdateSessionUserRecipeValuesRequest { user_recipe_values: HashMap, } +#[derive(Debug, Serialize, ToSchema)] +pub struct UpdateSessionUserRecipeValuesResponse { + recipe: Recipe, +} + #[derive(Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct ImportSessionRequest { @@ -151,10 +160,10 @@ async fn update_session_description( ("session_id" = String, Path, description = "Unique identifier for the session") ), responses( - (status = 200, description = "Session user recipe values updated successfully"), + (status = 200, description = "Session user recipe values updated successfully", body = UpdateSessionUserRecipeValuesResponse), (status = 401, description = "Unauthorized - Invalid or missing API key"), - (status = 404, description = "Session not found"), - (status = 500, description = "Internal server error") + (status = 404, description = "Session not found", body = ErrorResponse), + (status = 500, description = "Internal server error", body = ErrorResponse) ), security( ("api_key" = []) @@ -163,16 +172,54 @@ async fn update_session_description( )] // Update session user recipe parameter values async fn update_session_user_recipe_values( + State(state): State>, Path(session_id): Path, Json(request): Json, -) -> Result { +) -> Result, ErrorResponse> { SessionManager::update_session(&session_id) .user_recipe_values(Some(request.user_recipe_values)) .apply() .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + .map_err(|err| ErrorResponse { + message: err.to_string(), + status: StatusCode::INTERNAL_SERVER_ERROR, + })?; - Ok(StatusCode::OK) + let session = SessionManager::get_session(&session_id, false) + .await + .map_err(|err| ErrorResponse { + message: err.to_string(), + status: StatusCode::INTERNAL_SERVER_ERROR, + })?; + let recipe = session.recipe.ok_or_else(|| ErrorResponse { + message: "Recipe not found".to_string(), + status: StatusCode::NOT_FOUND, + })?; + + let user_recipe_values = session.user_recipe_values.unwrap_or_default(); + match build_recipe_with_parameter_values(&recipe, user_recipe_values).await { + Ok(Some(recipe)) => { + let agent = state + .get_agent_for_route(session_id.clone()) + .await + .map_err(|status| ErrorResponse { + message: format!("Failed to get agent: {}", status), + status, + })?; + if let Some(prompt) = apply_recipe_to_agent(&agent, &recipe, false).await { + agent.extend_system_prompt(prompt).await; + } + Ok(Json(UpdateSessionUserRecipeValuesResponse { recipe })) + } + Ok(None) => Err(ErrorResponse { + message: "Missing required parameters".to_string(), + status: StatusCode::BAD_REQUEST, + }), + Err(e) => Err(ErrorResponse { + message: e.to_string(), + status: StatusCode::INTERNAL_SERVER_ERROR, + }), + } } #[utoipa::path( diff --git a/crates/goose/src/prompts/desktop_prompt.md b/crates/goose/src/prompts/desktop_prompt.md new file mode 100644 index 000000000000..dafb81ba5aed --- /dev/null +++ b/crates/goose/src/prompts/desktop_prompt.md @@ -0,0 +1,14 @@ +You are being accessed through the Goose Desktop application. + +The user is interacting with you through a graphical user interface with the following features: +- A chat interface where messages are displayed in a conversation format +- Support for markdown formatting in your responses +- Support for code blocks with syntax highlighting +- Tool use messages are included in the chat but outputs may need to be expanded + +The user can add extensions for you through the "Settings" page, which is available in the menu +on the top right of the window. There is a section on that page for extensions, and it links to +the registry. + +Some extensions are builtin, such as Developer and Memory, while +3rd party extensions can be browsed at https://block.github.io/goose/v1/extensions/. \ No newline at end of file diff --git a/crates/goose/src/prompts/desktop_recipe_instruction.md b/crates/goose/src/prompts/desktop_recipe_instruction.md new file mode 100644 index 000000000000..445ffa4678fc --- /dev/null +++ b/crates/goose/src/prompts/desktop_recipe_instruction.md @@ -0,0 +1,15 @@ +You are a helpful agent. +You are being accessed through the Goose Desktop application, pre configured with instructions as requested by a human. + +The user is interacting with you through a graphical user interface with the following features: +- A chat interface where messages are displayed in a conversation format +- Support for markdown formatting in your responses +- Support for code blocks with syntax highlighting +- Tool use messages are included in the chat but outputs may need to be expanded + +It is VERY IMPORTANT that you take note of the provided instructions, also check if a style of output is requested and always do your best to adhere to it. +You can also validate your output after you have generated it to ensure it meets the requirements of the user. +There may be (but not always) some tools mentioned in the instructions which you can check are available to this instance of goose (and try to help the user if they are not or find alternatives). + +IMPORTANT instructions for you to operate as agent: +{{recipe_instructions}} \ No newline at end of file diff --git a/crates/goose/src/recipe/build_recipe/mod.rs b/crates/goose/src/recipe/build_recipe/mod.rs index 05b23ff750f2..33648a839c5f 100644 --- a/crates/goose/src/recipe/build_recipe/mod.rs +++ b/crates/goose/src/recipe/build_recipe/mod.rs @@ -1,4 +1,4 @@ -use crate::recipe::read_recipe_file_content::{read_parameter_file_content, RecipeFile}; +use crate::recipe::read_recipe_file_content::read_parameter_file_content; use crate::recipe::template_recipe::render_recipe_content_with_params; use crate::recipe::validate_recipe::validate_recipe_template_from_content; use crate::recipe::{ @@ -19,33 +19,26 @@ pub enum RecipeError { RecipeParsing { source: anyhow::Error }, } -pub fn render_recipe_template( - recipe_file: RecipeFile, +fn render_recipe_template( + recipe_content: String, + recipe_dir: &Path, params: Vec<(String, String)>, user_prompt_fn: Option, ) -> Result<(String, Vec)> where F: Fn(&str, &str) -> Result, { - let RecipeFile { - content: recipe_file_content, - parent_dir: recipe_parent_dir, - .. - } = recipe_file; - let recipe_dir_str = recipe_parent_dir - .to_str() - .ok_or_else(|| anyhow::anyhow!("Error getting recipe directory"))?; - let recipe_parameters = validate_recipe_template_from_content( - &recipe_file_content, - Some(recipe_dir_str.to_string()), - )? - .parameters; + let recipe_dir_str = recipe_dir.display().to_string(); + + let recipe_parameters = + validate_recipe_template_from_content(&recipe_content, Some(recipe_dir_str.clone()))? + .parameters; let (params_for_template, missing_params) = - apply_values_to_parameters(¶ms, recipe_parameters, recipe_dir_str, user_prompt_fn)?; + apply_values_to_parameters(¶ms, recipe_parameters, &recipe_dir_str, user_prompt_fn)?; let rendered_content = if missing_params.is_empty() { - render_recipe_content_with_params(&recipe_file_content, ¶ms_for_template)? + render_recipe_content_with_params(&recipe_content, ¶ms_for_template)? } else { String::new() }; @@ -54,16 +47,16 @@ where } pub fn build_recipe_from_template( - recipe_file: RecipeFile, + recipe_content: String, + recipe_dir: &Path, params: Vec<(String, String)>, user_prompt_fn: Option, ) -> Result where F: Fn(&str, &str) -> Result, { - let recipe_parent_dir = recipe_file.parent_dir.clone(); let (rendered_content, missing_params) = - render_recipe_template(recipe_file, params.clone(), user_prompt_fn) + render_recipe_template(recipe_content, recipe_dir, params.clone(), user_prompt_fn) .map_err(|source| RecipeError::TemplateRendering { source })?; if !missing_params.is_empty() { @@ -77,8 +70,7 @@ where if let Some(ref mut sub_recipes) = recipe.sub_recipes { for sub_recipe in sub_recipes { - if let Ok(resolved_path) = resolve_sub_recipe_path(&sub_recipe.path, &recipe_parent_dir) - { + if let Ok(resolved_path) = resolve_sub_recipe_path(&sub_recipe.path, recipe_dir) { sub_recipe.path = resolved_path; } } @@ -90,7 +82,7 @@ where pub fn apply_values_to_parameters( user_params: &[(String, String)], recipe_parameters: Option>, - recipe_parent_dir: &str, + recipe_dir: &str, user_prompt_fn: Option, ) -> Result<(HashMap, Vec)> where @@ -99,7 +91,7 @@ where let mut param_map: HashMap = user_params.iter().cloned().collect(); param_map.insert( BUILT_IN_RECIPE_DIR_PARAM.to_string(), - recipe_parent_dir.to_string(), + recipe_dir.to_string(), ); let mut missing_params: Vec = Vec::new(); for param in recipe_parameters.unwrap_or_default() { diff --git a/crates/goose/src/recipe/build_recipe/tests.rs b/crates/goose/src/recipe/build_recipe/tests.rs index f38240c8ca4b..480fc954f2f3 100644 --- a/crates/goose/src/recipe/build_recipe/tests.rs +++ b/crates/goose/src/recipe/build_recipe/tests.rs @@ -3,12 +3,13 @@ use crate::recipe::build_recipe::{ }; use crate::recipe::read_recipe_file_content::RecipeFile; use crate::recipe::{RecipeParameterInputType, RecipeParameterRequirement}; +use std::path::PathBuf; use tempfile::TempDir; #[allow(clippy::type_complexity)] const NO_USER_PROMPT: Option Result> = None; -fn setup_recipe_file(instructions_and_parameters: &str) -> (TempDir, RecipeFile) { +fn setup_recipe_file(instructions_and_parameters: &str) -> (TempDir, String, PathBuf) { let recipe_content = format!( r#"{{ "version": "1.0.0", @@ -22,14 +23,10 @@ fn setup_recipe_file(instructions_and_parameters: &str) -> (TempDir, RecipeFile) let recipe_path = temp_dir.path().join("test_recipe.json"); std::fs::write(&recipe_path, recipe_content).unwrap(); + let recipe_dir = temp_dir.path().to_path_buf(); + let recipe_content = std::fs::read_to_string(&recipe_path).unwrap(); - let recipe_file = RecipeFile { - content: std::fs::read_to_string(&recipe_path).unwrap(), - parent_dir: temp_dir.path().to_path_buf(), - file_path: recipe_path, - }; - - (temp_dir, recipe_file) + (temp_dir, recipe_content, recipe_dir) } fn setup_test_file(temp_dir: &TempDir, filename: &str, content: &str) -> std::path::PathBuf { @@ -101,10 +98,11 @@ fn test_build_recipe_from_template_success() { } ]"#; - let (_temp_dir, recipe_file) = setup_recipe_file(instructions_and_parameters); + let (_temp_dir, recipe_content, recipe_dir) = setup_recipe_file(instructions_and_parameters); let params = vec![("my_name".to_string(), "value".to_string())]; - let recipe = build_recipe_from_template(recipe_file, params, NO_USER_PROMPT).unwrap(); + let recipe = + build_recipe_from_template(recipe_content, &recipe_dir, params, NO_USER_PROMPT).unwrap(); assert_eq!(recipe.title, "Test Recipe"); assert_eq!(recipe.description, "A test recipe"); @@ -134,10 +132,11 @@ fn test_build_recipe_from_template_success_variable_in_prompt() { } ]"#; - let (_temp_dir, recipe_file) = setup_recipe_file(instructions_and_parameters); + let (_temp_dir, recipe_content, recipe_dir) = setup_recipe_file(instructions_and_parameters); let params = vec![("my_name".to_string(), "value".to_string())]; - let recipe = build_recipe_from_template(recipe_file, params, NO_USER_PROMPT).unwrap(); + let recipe = + build_recipe_from_template(recipe_content, &recipe_dir, params, NO_USER_PROMPT).unwrap(); assert_eq!(recipe.title, "Test Recipe"); assert_eq!(recipe.description, "A test recipe"); @@ -165,9 +164,10 @@ fn test_build_recipe_from_template_wrong_parameters_in_recipe_file() { "description": "A test parameter" } ]"#; - let (_temp_dir, recipe_file) = setup_recipe_file(instructions_and_parameters); + let (_temp_dir, recipe_content, recipe_dir) = setup_recipe_file(instructions_and_parameters); - let build_recipe_result = build_recipe_from_template(recipe_file, Vec::new(), NO_USER_PROMPT); + let build_recipe_result = + build_recipe_from_template(recipe_content, &recipe_dir, Vec::new(), NO_USER_PROMPT); assert!(build_recipe_result.is_err()); let err = build_recipe_result.unwrap_err(); println!("{}", err); @@ -203,10 +203,11 @@ fn test_build_recipe_from_template_with_default_values_in_recipe_file() { "description": "A test parameter" } ]"#; - let (_temp_dir, recipe_file) = setup_recipe_file(instructions_and_parameters); + let (_temp_dir, recipe_content, recipe_dir) = setup_recipe_file(instructions_and_parameters); let params = vec![("param_without_default".to_string(), "value1".to_string())]; - let recipe = build_recipe_from_template(recipe_file, params, NO_USER_PROMPT).unwrap(); + let recipe = + build_recipe_from_template(recipe_content, &recipe_dir, params, NO_USER_PROMPT).unwrap(); assert_eq!(recipe.title, "Test Recipe"); assert_eq!(recipe.description, "A test recipe"); @@ -229,9 +230,11 @@ fn test_build_recipe_from_template_optional_parameters_with_empty_default_values "default": "" } ]"#; - let (_temp_dir, recipe_file) = setup_recipe_file(instructions_and_parameters); + let (_temp_dir, recipe_content, recipe_dir) = setup_recipe_file(instructions_and_parameters); - let recipe = build_recipe_from_template(recipe_file, Vec::new(), NO_USER_PROMPT).unwrap(); + let recipe = + build_recipe_from_template(recipe_content, &recipe_dir, Vec::new(), NO_USER_PROMPT) + .unwrap(); assert_eq!(recipe.title, "Test Recipe"); assert_eq!(recipe.description, "A test recipe"); assert_eq!(recipe.instructions.unwrap(), "Test instructions with "); @@ -249,9 +252,10 @@ fn test_build_recipe_from_template_optional_parameters_without_default_values_in "description": "A test parameter" } ]"#; - let (_temp_dir, recipe_file) = setup_recipe_file(instructions_and_parameters); + let (_temp_dir, recipe_content, recipe_dir) = setup_recipe_file(instructions_and_parameters); - let build_recipe_result = build_recipe_from_template(recipe_file, Vec::new(), NO_USER_PROMPT); + let build_recipe_result = + build_recipe_from_template(recipe_content, &recipe_dir, Vec::new(), NO_USER_PROMPT); assert!(build_recipe_result.is_err()); let err = build_recipe_result.unwrap_err(); println!("{}", err); @@ -276,9 +280,10 @@ fn test_build_recipe_from_template_wrong_input_type_in_recipe_file() { } ]"#; let params = vec![("param".to_string(), "value".to_string())]; - let (_temp_dir, recipe_file) = setup_recipe_file(instructions_and_parameters); + let (_temp_dir, recipe_content, recipe_dir) = setup_recipe_file(instructions_and_parameters); - let build_recipe_result = build_recipe_from_template(recipe_file, params, NO_USER_PROMPT); + let build_recipe_result = + build_recipe_from_template(recipe_content, &recipe_dir, params, NO_USER_PROMPT); assert!(build_recipe_result.is_err()); let err = build_recipe_result.unwrap_err(); match err { @@ -296,9 +301,11 @@ fn test_build_recipe_from_template_success_without_parameters() { let instructions_and_parameters = r#" "instructions": "Test instructions" "#; - let (_temp_dir, recipe_file) = setup_recipe_file(instructions_and_parameters); + let (_temp_dir, recipe_content, recipe_dir) = setup_recipe_file(instructions_and_parameters); - let recipe = build_recipe_from_template(recipe_file, Vec::new(), NO_USER_PROMPT).unwrap(); + let recipe = + build_recipe_from_template(recipe_content, &recipe_dir, Vec::new(), NO_USER_PROMPT) + .unwrap(); assert_eq!(recipe.instructions.unwrap(), "Test instructions"); assert!(recipe.parameters.is_none()); } @@ -306,9 +313,10 @@ fn test_build_recipe_from_template_success_without_parameters() { #[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 (_temp_dir, recipe_content, recipe_dir) = setup_recipe_file(instructions_and_parameters); - let build_recipe_result = build_recipe_from_template(recipe_file, Vec::new(), NO_USER_PROMPT); + let build_recipe_result = + build_recipe_from_template(recipe_content, &recipe_dir, Vec::new(), NO_USER_PROMPT); assert!(build_recipe_result.is_err()); let err = build_recipe_result.unwrap_err(); println!("{}", err); @@ -366,8 +374,13 @@ fn test_template_inheritance() { ("is_enabled".to_string(), "true".to_string()), ]; - let parent_recipe = - build_recipe_from_template(parent_recipe_file, params.clone(), NO_USER_PROMPT).unwrap(); + let parent_recipe = build_recipe_from_template( + parent_recipe_file.content, + &parent_recipe_file.parent_dir, + params.clone(), + NO_USER_PROMPT, + ) + .unwrap(); assert_eq!(parent_recipe.description, "Parent recipe"); assert_eq!( parent_recipe.prompt.unwrap(), @@ -380,8 +393,13 @@ fn test_template_inheritance() { "is_enabled" ); - let child_recipe = - build_recipe_from_template(child_recipe_file, params, NO_USER_PROMPT).unwrap(); + let child_recipe = build_recipe_from_template( + child_recipe_file.content, + &child_recipe_file.parent_dir, + params, + NO_USER_PROMPT, + ) + .unwrap(); assert_eq!(child_recipe.title, "Parent"); assert_eq!(child_recipe.description, "Parent recipe"); assert_eq!( @@ -467,7 +485,13 @@ instructions: Child instructions file_path: main_recipe_path, }; - let recipe = build_recipe_from_template(recipe_file, Vec::new(), NO_USER_PROMPT).unwrap(); + let recipe = build_recipe_from_template( + recipe_file.content, + &recipe_file.parent_dir, + Vec::new(), + NO_USER_PROMPT, + ) + .unwrap(); assert_eq!(recipe.title, "Main Recipe"); assert!(recipe.sub_recipes.is_some()); @@ -505,7 +529,12 @@ parameters: "FILE_PARAM".to_string(), test_file_path.to_string_lossy().to_string(), )]; - let result = build_recipe_from_template(recipe_file, params, NO_USER_PROMPT); + let result = build_recipe_from_template( + recipe_file.content, + &recipe_file.parent_dir, + params, + NO_USER_PROMPT, + ); assert!(result.is_ok()); let recipe = result.unwrap(); @@ -530,7 +559,12 @@ parameters: "FILE_PARAM".to_string(), "/nonexistent/path/file.txt".to_string(), )]; - let result = build_recipe_from_template(recipe_file, params, NO_USER_PROMPT); + let result = build_recipe_from_template( + recipe_file.content, + &recipe_file.parent_dir, + params, + NO_USER_PROMPT, + ); assert!(result.is_err()); if let Err(RecipeError::TemplateRendering { source }) = result { @@ -553,7 +587,12 @@ parameters: let (_temp_dir, recipe_file) = setup_yaml_recipe_file(instructions_and_parameters); let params = vec![]; - let result = build_recipe_from_template(recipe_file, params, NO_USER_PROMPT); + let result = build_recipe_from_template( + recipe_file.content, + &recipe_file.parent_dir, + params, + NO_USER_PROMPT, + ); assert!(result.is_err()); if let Err(RecipeError::TemplateRendering { source }) = result { diff --git a/crates/goose/src/recipe/template_recipe.rs b/crates/goose/src/recipe/template_recipe.rs index f9ffefa4ed21..4490d2ffcd9f 100644 --- a/crates/goose/src/recipe/template_recipe.rs +++ b/crates/goose/src/recipe/template_recipe.rs @@ -150,6 +150,11 @@ fn get_env_with_template_variables( Ok((env, template_variables)) } +fn uses_template_inheritance(content: &str) -> bool { + let re = Regex::new(r"\{%-?\s*(extends|include)").unwrap(); + re.is_match(content) +} + pub fn parse_recipe_content( content: &str, recipe_dir: Option, @@ -163,46 +168,23 @@ pub fn parse_recipe_content( UndefinedBehavior::Lenient, )?; let template = env.get_template(CURRENT_TEMPLATE_NAME).unwrap(); - let rendered_content = template - .render(()) - .map_err(|e| anyhow::anyhow!("Failed to parse the recipe {}", e))?; - let recipe = Recipe::from_content(&rendered_content)?; + + // Detect if template uses inheritance or includes + let recipe_content = if uses_template_inheritance(&preprocessed_content) { + // Must render to resolve inheritance + template + .render(()) + .map_err(|e| anyhow::anyhow!("Failed to parse the recipe {}", e))? + } else { + // Preserve conditionals and variables as-is + preprocessed_content + }; + + let recipe = Recipe::from_content(&recipe_content)?; // return recipe (without loading any variables) and the variable names that are in the recipe Ok((recipe, template_variables)) } -// render the recipe for validation, deeplink and explain, etc. -pub fn render_recipe_for_preview( - content: &str, - recipe_dir: Option, - params: &HashMap, -) -> Result { - // Pre-process template variables to handle invalid variable names - let preprocessed_content = preprocess_template_variables(content)?; - - let (env, template_variables) = get_env_with_template_variables( - &preprocessed_content, - recipe_dir, - UndefinedBehavior::Lenient, - )?; - let template = env.get_template(CURRENT_TEMPLATE_NAME).unwrap(); - // if the variables are not provided, the template will be rendered with the variables, otherwise it will keep the variables as is - let mut ctx = preserve_vars(&template_variables).clone(); - ctx.extend(params.clone()); - let rendered_content = template - .render(ctx) - .map_err(|e| anyhow::anyhow!("Failed to parse the recipe {}", e))?; - Recipe::from_content(&rendered_content) -} - -fn preserve_vars(variables: &HashSet) -> HashMap { - let mut context = HashMap::::new(); - for template_var in variables { - context.insert(template_var.clone(), format!("{{{{ {} }}}}", template_var)); - } - context -} - #[cfg(test)] mod tests { mod render_content_with_params_tests { diff --git a/crates/goose/src/recipe/validate_recipe.rs b/crates/goose/src/recipe/validate_recipe.rs index 32c62057b04b..21c169b8babb 100644 --- a/crates/goose/src/recipe/validate_recipe.rs +++ b/crates/goose/src/recipe/validate_recipe.rs @@ -1,22 +1,22 @@ use crate::recipe::read_recipe_file_content::RecipeFile; -use crate::recipe::template_recipe::{parse_recipe_content, render_recipe_for_preview}; +use crate::recipe::template_recipe::parse_recipe_content; use crate::recipe::{ Recipe, RecipeParameter, RecipeParameterInputType, RecipeParameterRequirement, BUILT_IN_RECIPE_DIR_PARAM, }; use anyhow::Result; -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; -pub fn validate_recipe_parameters( +pub fn parse_and_validate_parameters( recipe_file_content: &str, recipe_dir_str: Option, -) -> Result>> { +) -> Result { 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) + let recipe_parameters = &recipe_template.parameters; + validate_optional_parameters(recipe_parameters)?; + validate_parameters_in_template(recipe_parameters, &template_variables)?; + Ok(recipe_template) } fn validate_json_schema(schema: &serde_json::Value) -> Result<()> { @@ -40,8 +40,8 @@ pub fn validate_recipe_template_from_content( recipe_content: &str, recipe_dir: Option, ) -> Result { - validate_recipe_parameters(recipe_content, recipe_dir.clone())?; - let recipe = render_recipe_for_preview(recipe_content, recipe_dir, &HashMap::new())?; + parse_and_validate_parameters(recipe_content, recipe_dir.clone())?; + let (recipe, _) = parse_recipe_content(recipe_content, recipe_dir)?; validate_prompt_or_instructions(&recipe)?; if let Some(response) = &recipe.response { @@ -154,3 +154,47 @@ fn validate_optional_parameters(parameters: &Option>) -> Re Err(anyhow::anyhow!("Optional parameters missing default values in the recipe: {}. Please provide defaults.", optional_params_without_default_values.join(", "))) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_validate_recipe_template_from_content_success() { + let recipe_content = r#" +version: 1.0.0 +title: Test Recipe +description: A test recipe for validation +instructions: Test instructions with {{ user_role }} +prompt: | + {% if user_role in ["Director, Account Management", "Senior Director, Account Management"] %} + - Focus on strategic planning and organizational performance + {% else %} + - Provide foundational account management guidance + {% endif %} +parameters: + - key: user_role + input_type: string + requirement: required + description: A test parameter +"#; + + let result = validate_recipe_template_from_content(recipe_content, None); + if let Err(e) = &result { + eprintln!("Validation error: {}", e); + eprintln!("Error chain:"); + let mut source = e.source(); + while let Some(err) = source { + eprintln!(" Caused by: {}", err); + source = err.source(); + } + } + assert!(result.is_ok(), "Validation failed: {:?}", result.err()); + + let recipe = result.unwrap(); + assert_eq!(recipe.title, "Test Recipe"); + assert_eq!(recipe.description, "A test recipe for validation"); + assert!(recipe.instructions.is_some()); + println!("Recipe: {:?}", recipe.prompt); + } +} diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index e922c722ddd4..50c80c25f270 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -13,78 +13,6 @@ "version": "1.11.0" }, "paths": { - "/agent/add_sub_recipes": { - "post": { - "tags": [ - "super::routes::agent" - ], - "operationId": "add_sub_recipes", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AddSubRecipesRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Added sub recipes to agent successfully", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AddSubRecipesResponse" - } - } - } - }, - "401": { - "description": "Unauthorized - invalid secret key" - }, - "424": { - "description": "Agent not initialized" - } - } - } - }, - "/agent/prompt": { - "post": { - "tags": [ - "super::routes::agent" - ], - "operationId": "extend_prompt", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ExtendPromptRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Extended system prompt successfully", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ExtendPromptResponse" - } - } - } - }, - "401": { - "description": "Unauthorized - invalid secret key" - }, - "424": { - "description": "Agent not initialized" - } - } - } - }, "/agent/resume": { "post": { "tags": [ @@ -124,45 +52,6 @@ } } }, - "/agent/session_config": { - "post": { - "tags": [ - "super::routes::agent" - ], - "operationId": "update_session_config", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SessionConfigRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Session config updated successfully", - "content": { - "text/plain": { - "schema": { - "type": "string" - } - } - } - }, - "401": { - "description": "Unauthorized - invalid secret key" - }, - "424": { - "description": "Agent not initialized" - }, - "500": { - "description": "Internal server error" - } - } - } - }, "/agent/start": { "post": { "tags": [ @@ -269,6 +158,35 @@ } } }, + "/agent/update_from_session": { + "post": { + "tags": [ + "super::routes::agent" + ], + "operationId": "update_from_session", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateFromSessionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Update agent from session data successfully" + }, + "401": { + "description": "Unauthorized - invalid secret key" + }, + "424": { + "description": "Agent not initialized" + } + } + } + }, "/agent/update_provider": { "post": { "tags": [ @@ -2011,16 +1929,37 @@ }, "responses": { "200": { - "description": "Session user recipe values updated successfully" + "description": "Session user recipe values updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateSessionUserRecipeValuesResponse" + } + } + } }, "401": { "description": "Unauthorized - Invalid or missing API key" }, "404": { - "description": "Session not found" + "description": "Session not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } }, "500": { - "description": "Internal server error" + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } }, "security": [ @@ -2053,35 +1992,6 @@ }, "components": { "schemas": { - "AddSubRecipesRequest": { - "type": "object", - "required": [ - "sub_recipes", - "session_id" - ], - "properties": { - "session_id": { - "type": "string" - }, - "sub_recipes": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SubRecipe" - } - } - } - }, - "AddSubRecipesResponse": { - "type": "object", - "required": [ - "success" - ], - "properties": { - "success": { - "type": "boolean" - } - } - }, "Annotations": { "type": "object", "properties": { @@ -2526,32 +2436,6 @@ } } }, - "ExtendPromptRequest": { - "type": "object", - "required": [ - "extension", - "session_id" - ], - "properties": { - "extension": { - "type": "string" - }, - "session_id": { - "type": "string" - } - } - }, - "ExtendPromptResponse": { - "type": "object", - "required": [ - "success" - ], - "properties": { - "success": { - "type": "boolean" - } - } - }, "ExtensionConfig": { "oneOf": [ { @@ -4167,25 +4051,6 @@ } } }, - "SessionConfigRequest": { - "type": "object", - "required": [ - "session_id" - ], - "properties": { - "response": { - "allOf": [ - { - "$ref": "#/components/schemas/Response" - } - ], - "nullable": true - }, - "session_id": { - "type": "string" - } - } - }, "SessionDisplayInfo": { "type": "object", "required": [ @@ -4644,6 +4509,17 @@ } } }, + "UpdateFromSessionRequest": { + "type": "object", + "required": [ + "session_id" + ], + "properties": { + "session_id": { + "type": "string" + } + } + }, "UpdateProviderRequest": { "type": "object", "required": [ @@ -4712,6 +4588,17 @@ } } }, + "UpdateSessionUserRecipeValuesResponse": { + "type": "object", + "required": [ + "recipe" + ], + "properties": { + "recipe": { + "$ref": "#/components/schemas/Recipe" + } + } + }, "UpsertConfigQuery": { "type": "object", "required": [ diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts index a4a30661dcf8..c965aabaf443 100644 --- a/ui/desktop/src/api/sdk.gen.ts +++ b/ui/desktop/src/api/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AddSubRecipesData, AddSubRecipesErrors, AddSubRecipesResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, ConfirmPermissionData, ConfirmPermissionErrors, ConfirmPermissionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, ExtendPromptData, ExtendPromptErrors, ExtendPromptResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetToolsData, GetToolsErrors, GetToolsResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, ManageContextData, ManageContextErrors, ManageContextResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StatusData, StatusResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateRouterToolSelectorData, UpdateRouterToolSelectorErrors, UpdateRouterToolSelectorResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionConfigData, UpdateSessionConfigErrors, UpdateSessionConfigResponses, UpdateSessionDescriptionData, UpdateSessionDescriptionErrors, UpdateSessionDescriptionResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen'; +import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, ConfirmPermissionData, ConfirmPermissionErrors, ConfirmPermissionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetToolsData, GetToolsErrors, GetToolsResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, ManageContextData, ManageContextErrors, ManageContextResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StatusData, StatusResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateRouterToolSelectorData, UpdateRouterToolSelectorErrors, UpdateRouterToolSelectorResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionDescriptionData, UpdateSessionDescriptionErrors, UpdateSessionDescriptionResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen'; export type Options = Options2 & { /** @@ -18,28 +18,6 @@ export type Options; }; -export const addSubRecipes = (options: Options) => { - return (options.client ?? client).post({ - url: '/agent/add_sub_recipes', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } - }); -}; - -export const extendPrompt = (options: Options) => { - return (options.client ?? client).post({ - url: '/agent/prompt', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } - }); -}; - export const resumeAgent = (options: Options) => { return (options.client ?? client).post({ url: '/agent/resume', @@ -51,17 +29,6 @@ export const resumeAgent = (options: Optio }); }; -export const updateSessionConfig = (options: Options) => { - return (options.client ?? client).post({ - url: '/agent/session_config', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } - }); -}; - export const startAgent = (options: Options) => { return (options.client ?? client).post({ url: '/agent/start', @@ -80,6 +47,17 @@ export const getTools = (options: Options< }); }; +export const updateFromSession = (options: Options) => { + return (options.client ?? client).post({ + url: '/agent/update_from_session', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); +}; + export const updateAgentProvider = (options: Options) => { return (options.client ?? client).post({ url: '/agent/update_provider', diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index defdc3bc6437..77b2f0f1330c 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -4,15 +4,6 @@ export type ClientOptions = { baseUrl: `${string}://${string}` | (string & {}); }; -export type AddSubRecipesRequest = { - session_id: string; - sub_recipes: Array; -}; - -export type AddSubRecipesResponse = { - success: boolean; -}; - export type Annotations = { audience?: Array; lastModified?: string; @@ -180,15 +171,6 @@ export type ErrorResponse = { message: string; }; -export type ExtendPromptRequest = { - extension: string; - session_id: string; -}; - -export type ExtendPromptResponse = { - success: boolean; -}; - /** * Represents the different types of MCP extensions that can be added to the manager */ @@ -709,11 +691,6 @@ export type Session = { working_dir: string; }; -export type SessionConfigRequest = { - response?: Response | null; - session_id: string; -}; - export type SessionDisplayInfo = { accumulatedInputTokens?: number | null; accumulatedOutputTokens?: number | null; @@ -874,6 +851,10 @@ export type UpdateCustomProviderRequest = { supports_streaming?: boolean | null; }; +export type UpdateFromSessionRequest = { + session_id: string; +}; + export type UpdateProviderRequest = { model?: string | null; provider: string; @@ -904,6 +885,10 @@ export type UpdateSessionUserRecipeValuesRequest = { }; }; +export type UpdateSessionUserRecipeValuesResponse = { + recipe: Recipe; +}; + export type UpsertConfigQuery = { is_secret: boolean; key: string; @@ -914,60 +899,6 @@ export type UpsertPermissionsQuery = { tool_permissions: Array; }; -export type AddSubRecipesData = { - body: AddSubRecipesRequest; - path?: never; - query?: never; - url: '/agent/add_sub_recipes'; -}; - -export type AddSubRecipesErrors = { - /** - * Unauthorized - invalid secret key - */ - 401: unknown; - /** - * Agent not initialized - */ - 424: unknown; -}; - -export type AddSubRecipesResponses = { - /** - * Added sub recipes to agent successfully - */ - 200: AddSubRecipesResponse; -}; - -export type AddSubRecipesResponse2 = AddSubRecipesResponses[keyof AddSubRecipesResponses]; - -export type ExtendPromptData = { - body: ExtendPromptRequest; - path?: never; - query?: never; - url: '/agent/prompt'; -}; - -export type ExtendPromptErrors = { - /** - * Unauthorized - invalid secret key - */ - 401: unknown; - /** - * Agent not initialized - */ - 424: unknown; -}; - -export type ExtendPromptResponses = { - /** - * Extended system prompt successfully - */ - 200: ExtendPromptResponse; -}; - -export type ExtendPromptResponse2 = ExtendPromptResponses[keyof ExtendPromptResponses]; - export type ResumeAgentData = { body: ResumeAgentRequest; path?: never; @@ -999,37 +930,6 @@ export type ResumeAgentResponses = { export type ResumeAgentResponse = ResumeAgentResponses[keyof ResumeAgentResponses]; -export type UpdateSessionConfigData = { - body: SessionConfigRequest; - path?: never; - query?: never; - url: '/agent/session_config'; -}; - -export type UpdateSessionConfigErrors = { - /** - * Unauthorized - invalid secret key - */ - 401: unknown; - /** - * Agent not initialized - */ - 424: unknown; - /** - * Internal server error - */ - 500: unknown; -}; - -export type UpdateSessionConfigResponses = { - /** - * Session config updated successfully - */ - 200: string; -}; - -export type UpdateSessionConfigResponse = UpdateSessionConfigResponses[keyof UpdateSessionConfigResponses]; - export type StartAgentData = { body: StartAgentRequest; path?: never; @@ -1103,6 +1003,31 @@ export type GetToolsResponses = { export type GetToolsResponse = GetToolsResponses[keyof GetToolsResponses]; +export type UpdateFromSessionData = { + body: UpdateFromSessionRequest; + path?: never; + query?: never; + url: '/agent/update_from_session'; +}; + +export type UpdateFromSessionErrors = { + /** + * Unauthorized - invalid secret key + */ + 401: unknown; + /** + * Agent not initialized + */ + 424: unknown; +}; + +export type UpdateFromSessionResponses = { + /** + * Update agent from session data successfully + */ + 200: unknown; +}; + export type UpdateAgentProviderData = { body: UpdateProviderRequest; path?: never; @@ -2487,20 +2412,24 @@ export type UpdateSessionUserRecipeValuesErrors = { /** * Session not found */ - 404: unknown; + 404: ErrorResponse; /** * Internal server error */ - 500: unknown; + 500: ErrorResponse; }; +export type UpdateSessionUserRecipeValuesError = UpdateSessionUserRecipeValuesErrors[keyof UpdateSessionUserRecipeValuesErrors]; + export type UpdateSessionUserRecipeValuesResponses = { /** * Session user recipe values updated successfully */ - 200: unknown; + 200: UpdateSessionUserRecipeValuesResponse; }; +export type UpdateSessionUserRecipeValuesResponse2 = UpdateSessionUserRecipeValuesResponses[keyof UpdateSessionUserRecipeValuesResponses]; + export type StatusData = { body?: never; path?: never; diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index b04bc7327fc5..c14ce2d0822e 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -158,7 +158,7 @@ function BaseChatContent({ const { recipe, recipeId, - recipeParameters, + recipeParameterValues, filteredParameters, initialPrompt, isParameterModalOpen, @@ -338,7 +338,7 @@ function BaseChatContent({ append={(text: string) => appendWithTracking(text)} activities={Array.isArray(recipe.activities) ? recipe.activities : null} title={recipe.title} - parameterValues={recipeParameters || {}} + parameterValues={recipeParameterValues || {}} /> )} diff --git a/ui/desktop/src/components/BaseChat2.tsx b/ui/desktop/src/components/BaseChat2.tsx index cdf5292fd089..714b0100df7e 100644 --- a/ui/desktop/src/components/BaseChat2.tsx +++ b/ui/desktop/src/components/BaseChat2.tsx @@ -98,7 +98,7 @@ function BaseChatContent({ messageHistoryIndex: 0, messages: [], recipe: null, - recipeParameters: null, + recipeParameterValues: null, }; setChat(emptyChat); @@ -111,7 +111,7 @@ function BaseChatContent({ messageHistoryIndex: 0, messages: conversation, recipe: null, - recipeParameters: null, + recipeParameterValues: null, }; setChat(loadedChat); diff --git a/ui/desktop/src/components/recipes/shared/RecipeFormFields.tsx b/ui/desktop/src/components/recipes/shared/RecipeFormFields.tsx index dc168748e676..7754c3186d5e 100644 --- a/ui/desktop/src/components/recipes/shared/RecipeFormFields.tsx +++ b/ui/desktop/src/components/recipes/shared/RecipeFormFields.tsx @@ -7,7 +7,6 @@ import JsonSchemaEditor from './JsonSchemaEditor'; import InstructionsEditor from './InstructionsEditor'; import { Button } from '../../ui/button'; import { RecipeFormApi } from './recipeFormSchema'; -import { extractTemplateVariables } from '../../../utils/providerUtils'; // Type for field API to avoid linting issues - use any to bypass complex type constraints // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -25,6 +24,27 @@ interface RecipeFormFieldsProps { onJsonSchemaChange?: (value: string) => void; } +export const extractTemplateVariables = (content: string): string[] => { + const templateVarRegex = /\{\{(.*?)\}\}/g; + const variables: string[] = []; + let match; + + while ((match = templateVarRegex.exec(content)) !== null) { + const variable = match[1].trim(); + + if (variable && !variables.includes(variable)) { + // Filter out complex variables that aren't valid parameter names + // This matches the backend logic in filter_complex_variables() + const validVarRegex = /^\s*[a-zA-Z_][a-zA-Z0-9_]*\s*$/; + if (validVarRegex.test(variable)) { + variables.push(variable); + } + } + } + + return variables; +}; + export function RecipeFormFields({ form, onTitleChange, diff --git a/ui/desktop/src/components/recipes/shared/__tests__/RecipeFormFields.test.tsx b/ui/desktop/src/components/recipes/shared/__tests__/RecipeFormFields.test.tsx index 20dedcd8d8e1..b075dd2dcdcf 100644 --- a/ui/desktop/src/components/recipes/shared/__tests__/RecipeFormFields.test.tsx +++ b/ui/desktop/src/components/recipes/shared/__tests__/RecipeFormFields.test.tsx @@ -3,7 +3,7 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { useForm } from '@tanstack/react-form'; -import { RecipeFormFields } from '../RecipeFormFields'; +import { RecipeFormFields, extractTemplateVariables } from '../RecipeFormFields'; import { type RecipeFormData } from '../recipeFormSchema'; describe('RecipeFormFields', () => { @@ -683,4 +683,132 @@ describe('RecipeFormFields', () => { } }); }); + + describe('extractTemplateVariables', () => { + it('should extract simple template variables', () => { + const content = 'Hello {{name}}, welcome to {{app}}!'; + const result = extractTemplateVariables(content); + expect(result).toEqual(['name', 'app']); + }); + + it('should extract variables with underscores', () => { + const content = 'User: {{user_name}}, ID: {{user_id}}'; + const result = extractTemplateVariables(content); + expect(result).toEqual(['user_name', 'user_id']); + }); + + it('should extract variables that start with underscore', () => { + const content = 'Private: {{_private}}, Internal: {{__internal}}'; + const result = extractTemplateVariables(content); + expect(result).toEqual(['_private', '__internal']); + }); + + it('should handle variables with numbers', () => { + const content = 'Item {{item1}}, Version {{version2_0}}'; + const result = extractTemplateVariables(content); + expect(result).toEqual(['item1', 'version2_0']); + }); + + it('should trim whitespace from variables', () => { + const content = 'Hello {{ name }}, welcome to {{ app }}!'; + const result = extractTemplateVariables(content); + expect(result).toEqual(['name', 'app']); + }); + + it('should ignore invalid variable names with spaces', () => { + const content = 'Invalid: {{user name}}, Valid: {{username}}'; + const result = extractTemplateVariables(content); + expect(result).toEqual(['username']); + }); + + it('should ignore invalid variable names with dots', () => { + const content = 'Invalid: {{user.name}}, Valid: {{user_name}}'; + const result = extractTemplateVariables(content); + expect(result).toEqual(['user_name']); + }); + + it('should ignore invalid variable names with pipes', () => { + const content = 'Invalid: {{name|upper}}, Valid: {{name}}'; + const result = extractTemplateVariables(content); + expect(result).toEqual(['name']); + }); + + it('should ignore invalid variable names with special characters', () => { + const content = 'Invalid: {{user@name}}, {{user-name}}, {{user$name}}, Valid: {{username}}'; + const result = extractTemplateVariables(content); + expect(result).toEqual(['username']); + }); + + it('should ignore variables starting with numbers', () => { + const content = 'Invalid: {{1name}}, {{2user}}, Valid: {{name1}}'; + const result = extractTemplateVariables(content); + expect(result).toEqual(['name1']); + }); + + it('should remove duplicates', () => { + const content = 'Hello {{name}}, goodbye {{name}}, welcome {{app}}, use {{app}}'; + const result = extractTemplateVariables(content); + expect(result).toEqual(['name', 'app']); + }); + + it('should handle empty content', () => { + const content = ''; + const result = extractTemplateVariables(content); + expect(result).toEqual([]); + }); + + it('should handle content with no variables', () => { + const content = 'This is just plain text with no variables.'; + const result = extractTemplateVariables(content); + expect(result).toEqual([]); + }); + + it('should handle single braces (not template variables)', () => { + const content = 'This {is} not a {template} variable but {{this}} is.'; + const result = extractTemplateVariables(content); + expect(result).toEqual(['this']); + }); + + it('should handle malformed template syntax', () => { + const content = 'Malformed: {{{name}}}, {{name}}, {name}}'; + const result = extractTemplateVariables(content); + expect(result).toEqual(['name']); + }); + + it('should handle empty variable names', () => { + const content = 'Empty: {{}}, Valid: {{name}}'; + const result = extractTemplateVariables(content); + expect(result).toEqual(['name']); + }); + + it('should handle variables with only whitespace', () => { + const content = 'Whitespace: {{ }}, Valid: {{name}}'; + const result = extractTemplateVariables(content); + expect(result).toEqual(['name']); + }); + + it('should ignore complex template expressions with dots and pipes', () => { + const content = + 'Complex: {{steps.fetch_payment_data.data.payments.totalEdgeCount | number_format}}, Valid: {{simple_param}}'; + const result = extractTemplateVariables(content); + expect(result).toEqual(['simple_param']); + }); + + it('should handle complex mixed content', () => { + const content = ` + Welcome {{user_name}}! + + Your account details: + - ID: {{user_id}} + - Email: {{email_address}} + - Invalid: {{user.email}} + - Invalid: {{user name}} + - Invalid: {{1invalid}} + + Thank you for using {{app_name}}! + `; + const result = extractTemplateVariables(content); + expect(result).toEqual(['user_name', 'user_id', 'email_address', 'app_name']); + }); + }); }); diff --git a/ui/desktop/src/contexts/ChatContext.tsx b/ui/desktop/src/contexts/ChatContext.tsx index 7e4cf90faffe..fe0ffbe11342 100644 --- a/ui/desktop/src/contexts/ChatContext.tsx +++ b/ui/desktop/src/contexts/ChatContext.tsx @@ -59,7 +59,7 @@ export const ChatProvider: React.FC = ({ messages: [], messageHistoryIndex: 0, recipe: null, - recipeParameters: null, + recipeParameterValues: null, }); clearDraft(); }; @@ -68,7 +68,7 @@ export const ChatProvider: React.FC = ({ setChat({ ...chat, recipe: recipe, - recipeParameters: null, + recipeParameterValues: null, }); }; diff --git a/ui/desktop/src/hooks/useAgent.ts b/ui/desktop/src/hooks/useAgent.ts index b5907ec9cebd..2749ce5bfe48 100644 --- a/ui/desktop/src/hooks/useAgent.ts +++ b/ui/desktop/src/hooks/useAgent.ts @@ -85,7 +85,7 @@ export function useAgent(): UseAgentReturn { messageHistoryIndex: 0, messages, recipe: agentSession.recipe, - recipeParameters: agentSession.user_recipe_values || null, + recipeParameterValues: agentSession.user_recipe_values || null, }; } @@ -186,7 +186,7 @@ export function useAgent(): UseAgentReturn { messageHistoryIndex: 0, messages: messages, recipe: recipe, - recipeParameters: agentSession.user_recipe_values || null, + recipeParameterValues: agentSession.user_recipe_values || null, }; setAgentState(AgentState.INITIALIZED); diff --git a/ui/desktop/src/hooks/useRecipeManager.ts b/ui/desktop/src/hooks/useRecipeManager.ts index c9801af026c3..cdf24c783271 100644 --- a/ui/desktop/src/hooks/useRecipeManager.ts +++ b/ui/desktop/src/hooks/useRecipeManager.ts @@ -3,11 +3,7 @@ import { Recipe, scanRecipe } from '../recipe'; import { createUserMessage } from '../types/message'; import { Message } from '../api'; -import { - updateSystemPromptWithParameters, - substituteParameters, - filterValidUsedParameters, -} from '../utils/providerUtils'; +import { substituteParameters } from '../utils/providerUtils'; import { updateSessionUserRecipeValues } from '../api'; import { useChatContext } from '../contexts/ChatContext'; import { ChatType } from '../types/chat'; @@ -21,7 +17,7 @@ export const useRecipeManager = (chat: ChatType, recipe?: Recipe | null) => { const [hasSecurityWarnings, setHasSecurityWarnings] = useState(false); const [readyForAutoUserPrompt, setReadyForAutoUserPrompt] = useState(false); const [recipeError, setRecipeError] = useState(null); - const recipeParameters = chat.recipeParameters; + const recipeParameterValues = chat.recipeParameterValues; const chatContext = useChatContext(); const messages = chat.messages; @@ -35,7 +31,7 @@ export const useRecipeManager = (chat: ChatType, recipe?: Recipe | null) => { }, [messages]); const finalRecipe = chat.recipe; - + const resolvedRecipe = chat.resolvedRecipe; useEffect(() => { if (!chatContext) return; @@ -62,7 +58,7 @@ export const useRecipeManager = (chat: ChatType, recipe?: Recipe | null) => { chatContext.setChat({ ...chatContext.chat, recipe: recipe, - recipeParameters: null, + recipeParameterValues: null, messages: [], }); } @@ -104,16 +100,8 @@ export const useRecipeManager = (chat: ChatType, recipe?: Recipe | null) => { checkRecipeAcceptance(); }, [finalRecipe, recipe, chat.messages.length]); - // Filter parameters to only show valid ones that are actually used in the recipe const filteredParameters = useMemo(() => { - if (!finalRecipe?.parameters) { - return []; - } - return filterValidUsedParameters(finalRecipe.parameters, { - prompt: finalRecipe.prompt || undefined, - instructions: finalRecipe.instructions || undefined, - activities: finalRecipe.activities || undefined, - }); + return finalRecipe?.parameters ?? []; }, [finalRecipe]); // Check if template variables are actually used in the recipe content @@ -123,20 +111,8 @@ export const useRecipeManager = (chat: ChatType, recipe?: Recipe | null) => { // Check if all required parameters have been filled in const hasAllRequiredParameters = useMemo(() => { - if (!requiresParameters) { - return true; // No parameters required, so all are "filled" - } - - if (!recipeParameters) { - return false; // Parameters required but none provided - } - - // Check if all filtered parameters have values - return filteredParameters.every((param) => { - const value = recipeParameters[param.key]; - return value !== undefined && value !== null && value.trim() !== ''; - }); - }, [filteredParameters, recipeParameters, requiresParameters]); + return !requiresParameters || resolvedRecipe != null; + }, [requiresParameters, resolvedRecipe]); const hasMessages = messages.length > 0; useEffect(() => { @@ -146,17 +122,10 @@ export const useRecipeManager = (chat: ChatType, recipe?: Recipe | null) => { // 3. Not all required parameters have been filled in yet // 4. Parameter modal is not already open (prevent multiple opens) // 5. No messages in chat yet (don't show after conversation has started) - if ( - requiresParameters && - recipeAccepted && - !hasAllRequiredParameters && - !isParameterModalOpen && - !hasMessages - ) { + if (recipeAccepted && !hasAllRequiredParameters && !isParameterModalOpen && !hasMessages) { setIsParameterModalOpen(true); } }, [ - requiresParameters, hasAllRequiredParameters, recipeAccepted, filteredParameters, @@ -166,6 +135,20 @@ export const useRecipeManager = (chat: ChatType, recipe?: Recipe | null) => { finalRecipe?.title, ]); + useEffect(() => { + if ( + !requiresParameters && + chatContext && + finalRecipe && + chatContext.chat.resolvedRecipe !== finalRecipe + ) { + chatContext?.setChat({ + ...chatContext.chat, + resolvedRecipe: finalRecipe, + }); + } + }, [requiresParameters, finalRecipe, chatContext]); + useEffect(() => { setReadyForAutoUserPrompt(true); }, []); @@ -174,29 +157,12 @@ export const useRecipeManager = (chat: ChatType, recipe?: Recipe | null) => { if (!finalRecipe?.prompt || !recipeAccepted || finalRecipe?.isScheduledExecution) { return ''; } - - if (requiresParameters && recipeParameters) { - return substituteParameters(finalRecipe.prompt, recipeParameters); - } - - return finalRecipe.prompt; - }, [finalRecipe, recipeParameters, recipeAccepted, requiresParameters]); + return resolvedRecipe?.prompt ?? finalRecipe.prompt; + }, [finalRecipe, recipeAccepted, resolvedRecipe]); const handleParameterSubmit = async (inputValues: Record) => { - // Update chat state with parameters - if (chatContext) { - chatContext.setChat({ - ...chatContext.chat, - recipeParameters: inputValues, - }); - } - setIsParameterModalOpen(false); - try { - await updateSystemPromptWithParameters(chat.sessionId, inputValues, finalRecipe || undefined); - - // Save recipe parameters to session metadata - await updateSessionUserRecipeValues({ + let response = await updateSessionUserRecipeValues({ path: { session_id: chat.sessionId, }, @@ -205,6 +171,15 @@ export const useRecipeManager = (chat: ChatType, recipe?: Recipe | null) => { }, throwOnError: true, }); + let resolvedRecipe = response.data?.recipe; + if (chatContext) { + chatContext.setChat({ + ...chatContext.chat, + recipeParameterValues: inputValues, + resolvedRecipe, + }); + } + setIsParameterModalOpen(false); } catch (error) { console.error('Failed to update system prompt with parameters:', error); } @@ -237,14 +212,14 @@ export const useRecipeManager = (chat: ChatType, recipe?: Recipe | null) => { if ( finalRecipe?.isScheduledExecution && finalRecipe?.prompt && - (!requiresParameters || recipeParameters) && + (!requiresParameters || recipeParameterValues) && messages.length === 0 && !isLoading && readyForAutoUserPrompt && recipeAccepted ) { - const finalPrompt = recipeParameters - ? substituteParameters(finalRecipe.prompt, recipeParameters) + const finalPrompt = recipeParameterValues + ? substituteParameters(finalRecipe.prompt, recipeParameterValues) : finalRecipe.prompt; const userMessage = createUserMessage(finalPrompt); @@ -286,7 +261,7 @@ export const useRecipeManager = (chat: ChatType, recipe?: Recipe | null) => { return { recipe: finalRecipe, recipeId, - recipeParameters, + recipeParameterValues, filteredParameters, initialPrompt, isParameterModalOpen, diff --git a/ui/desktop/src/recipe/add_sub_recipe_on_agent.ts b/ui/desktop/src/recipe/add_sub_recipe_on_agent.ts deleted file mode 100644 index f58d81d3e0b6..000000000000 --- a/ui/desktop/src/recipe/add_sub_recipe_on_agent.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { addSubRecipes, SubRecipe } from '../api'; - -export async function addSubRecipesToAgent(sessionId: string, subRecipes: SubRecipe[]) { - const add_sub_recipe_response = await addSubRecipes({ - body: { session_id: sessionId, sub_recipes: subRecipes }, - }); - if (add_sub_recipe_response.error) { - console.warn(`Failed to add sub recipes: ${add_sub_recipe_response.error}`); - } else { - console.log('Added sub recipes'); - } -} diff --git a/ui/desktop/src/recipe/recipe_management.ts b/ui/desktop/src/recipe/recipe_management.ts index 6117827b53d1..fd0f844c7b1f 100644 --- a/ui/desktop/src/recipe/recipe_management.ts +++ b/ui/desktop/src/recipe/recipe_management.ts @@ -1,6 +1,6 @@ import { Recipe, saveRecipe as saveRecipeApi, listRecipes, RecipeManifestResponse } from '../api'; -export async function saveRecipe(recipe: Recipe, recipeId?: string | null): Promise { +export const saveRecipe = async (recipe: Recipe, recipeId?: string | null): Promise => { try { let response = await saveRecipeApi({ body: { @@ -17,9 +17,9 @@ export async function saveRecipe(recipe: Recipe, recipeId?: string | null): Prom } throw new Error(error_message); } -} +}; -export async function listSavedRecipes(): Promise { +export const listSavedRecipes = async (): Promise => { try { const listRecipeResponse = await listRecipes(); return listRecipeResponse?.data?.recipe_manifest_responses ?? []; @@ -27,20 +27,20 @@ export async function listSavedRecipes(): Promise { console.warn('Failed to list saved recipes:', error); return []; } -} +}; -function parseLastModified(val: string | Date): Date { +const parseLastModified = (val: string | Date): Date => { return val instanceof Date ? val : new Date(val); -} +}; -export function convertToLocaleDateString(lastModified: string): string { +export const convertToLocaleDateString = (lastModified: string): string => { if (lastModified) { return parseLastModified(lastModified).toLocaleDateString(); } return ''; -} +}; -export function getStorageDirectory(isGlobal: boolean): string { +export const getStorageDirectory = (isGlobal: boolean): string => { if (isGlobal) { return '~/.config/goose/recipes'; } else { @@ -48,4 +48,4 @@ export function getStorageDirectory(isGlobal: boolean): string { const workingDir = window.appConfig.get('GOOSE_WORKING_DIR') as string; return `${workingDir}/.goose/recipes`; } -} +}; diff --git a/ui/desktop/src/types/chat.ts b/ui/desktop/src/types/chat.ts index 70130ebcf935..2e77ad03b995 100644 --- a/ui/desktop/src/types/chat.ts +++ b/ui/desktop/src/types/chat.ts @@ -7,5 +7,6 @@ export interface ChatType { messageHistoryIndex: number; messages: Message[]; recipe?: Recipe | null; // Add recipe configuration to chat state - recipeParameters?: Record | null; // Add recipe parameters to chat state + resolvedRecipe?: Recipe | null; // Add resolved recipe with parameter values rendered to chat state + recipeParameterValues?: Record | null; // Add recipe parameters to chat state } diff --git a/ui/desktop/src/utils/__tests__/providerUtils.test.ts b/ui/desktop/src/utils/__tests__/providerUtils.test.ts index 2c0dda92463a..a64848cc0aaa 100644 --- a/ui/desktop/src/utils/__tests__/providerUtils.test.ts +++ b/ui/desktop/src/utils/__tests__/providerUtils.test.ts @@ -1,361 +1,7 @@ import { describe, it, expect } from 'vitest'; -import { - extractTemplateVariables, - filterValidUsedParameters, - substituteParameters, -} from '../providerUtils'; -import type { RecipeParameter } from '../../api'; +import { substituteParameters } from '../providerUtils'; describe('providerUtils', () => { - describe('extractTemplateVariables', () => { - it('should extract simple template variables', () => { - const content = 'Hello {{name}}, welcome to {{app}}!'; - const result = extractTemplateVariables(content); - expect(result).toEqual(['name', 'app']); - }); - - it('should extract variables with underscores', () => { - const content = 'User: {{user_name}}, ID: {{user_id}}'; - const result = extractTemplateVariables(content); - expect(result).toEqual(['user_name', 'user_id']); - }); - - it('should extract variables that start with underscore', () => { - const content = 'Private: {{_private}}, Internal: {{__internal}}'; - const result = extractTemplateVariables(content); - expect(result).toEqual(['_private', '__internal']); - }); - - it('should handle variables with numbers', () => { - const content = 'Item {{item1}}, Version {{version2_0}}'; - const result = extractTemplateVariables(content); - expect(result).toEqual(['item1', 'version2_0']); - }); - - it('should trim whitespace from variables', () => { - const content = 'Hello {{ name }}, welcome to {{ app }}!'; - const result = extractTemplateVariables(content); - expect(result).toEqual(['name', 'app']); - }); - - it('should ignore invalid variable names with spaces', () => { - const content = 'Invalid: {{user name}}, Valid: {{username}}'; - const result = extractTemplateVariables(content); - expect(result).toEqual(['username']); - }); - - it('should ignore invalid variable names with dots', () => { - const content = 'Invalid: {{user.name}}, Valid: {{user_name}}'; - const result = extractTemplateVariables(content); - expect(result).toEqual(['user_name']); - }); - - it('should ignore invalid variable names with pipes', () => { - const content = 'Invalid: {{name|upper}}, Valid: {{name}}'; - const result = extractTemplateVariables(content); - expect(result).toEqual(['name']); - }); - - it('should ignore invalid variable names with special characters', () => { - const content = 'Invalid: {{user@name}}, {{user-name}}, {{user$name}}, Valid: {{username}}'; - const result = extractTemplateVariables(content); - expect(result).toEqual(['username']); - }); - - it('should ignore variables starting with numbers', () => { - const content = 'Invalid: {{1name}}, {{2user}}, Valid: {{name1}}'; - const result = extractTemplateVariables(content); - expect(result).toEqual(['name1']); - }); - - it('should remove duplicates', () => { - const content = 'Hello {{name}}, goodbye {{name}}, welcome {{app}}, use {{app}}'; - const result = extractTemplateVariables(content); - expect(result).toEqual(['name', 'app']); - }); - - it('should handle empty content', () => { - const content = ''; - const result = extractTemplateVariables(content); - expect(result).toEqual([]); - }); - - it('should handle content with no variables', () => { - const content = 'This is just plain text with no variables.'; - const result = extractTemplateVariables(content); - expect(result).toEqual([]); - }); - - it('should handle single braces (not template variables)', () => { - const content = 'This {is} not a {template} variable but {{this}} is.'; - const result = extractTemplateVariables(content); - expect(result).toEqual(['this']); - }); - - it('should handle malformed template syntax', () => { - const content = 'Malformed: {{{name}}}, {{name}}, {name}}'; - const result = extractTemplateVariables(content); - expect(result).toEqual(['name']); - }); - - it('should handle empty variable names', () => { - const content = 'Empty: {{}}, Valid: {{name}}'; - const result = extractTemplateVariables(content); - expect(result).toEqual(['name']); - }); - - it('should handle variables with only whitespace', () => { - const content = 'Whitespace: {{ }}, Valid: {{name}}'; - const result = extractTemplateVariables(content); - expect(result).toEqual(['name']); - }); - - it('should ignore complex template expressions with dots and pipes', () => { - const content = - 'Complex: {{steps.fetch_payment_data.data.payments.totalEdgeCount | number_format}}, Valid: {{simple_param}}'; - const result = extractTemplateVariables(content); - expect(result).toEqual(['simple_param']); - }); - - it('should handle complex mixed content', () => { - const content = ` - Welcome {{user_name}}! - - Your account details: - - ID: {{user_id}} - - Email: {{email_address}} - - Invalid: {{user.email}} - - Invalid: {{user name}} - - Invalid: {{1invalid}} - - Thank you for using {{app_name}}! - `; - const result = extractTemplateVariables(content); - expect(result).toEqual(['user_name', 'user_id', 'email_address', 'app_name']); - }); - }); - - describe('filterValidUsedParameters', () => { - const createParameter = ( - key: string, - description = '', - requirement: 'required' | 'optional' | 'user_prompt' = 'optional' - ): RecipeParameter => ({ - key, - description, - input_type: 'string', - requirement, - }); - - it('should filter parameters to only include valid ones used in content', () => { - const parameters = [ - createParameter('valid_param'), - createParameter('invalid param'), // has space - createParameter('unused_param'), - createParameter('used_param'), - ]; - - const recipeContent = { - prompt: 'Use {{valid_param}} and {{used_param}}', - instructions: 'Additional {{valid_param}} usage', - }; - - const result = filterValidUsedParameters(parameters, recipeContent); - expect(result).toEqual([createParameter('valid_param'), createParameter('used_param')]); - }); - - it('should handle parameters used only in prompt', () => { - const parameters = [createParameter('prompt_param'), createParameter('unused_param')]; - - const recipeContent = { - prompt: 'Use {{prompt_param}}', - instructions: 'No parameters here', - }; - - const result = filterValidUsedParameters(parameters, recipeContent); - expect(result).toEqual([createParameter('prompt_param')]); - }); - - it('should handle parameters used only in instructions', () => { - const parameters = [createParameter('instruction_param'), createParameter('unused_param')]; - - const recipeContent = { - prompt: 'No parameters here', - instructions: 'Use {{instruction_param}}', - }; - - const result = filterValidUsedParameters(parameters, recipeContent); - expect(result).toEqual([createParameter('instruction_param')]); - }); - - it('should remove duplicate parameters (keep first occurrence)', () => { - const parameters = [ - createParameter('duplicate_param', 'First occurrence'), - createParameter('duplicate_param', 'Second occurrence'), - createParameter('unique_param'), - ]; - - const recipeContent = { - prompt: 'Use {{duplicate_param}} and {{unique_param}}', - }; - - const result = filterValidUsedParameters(parameters, recipeContent); - expect(result).toEqual([ - createParameter('duplicate_param', 'First occurrence'), - createParameter('unique_param'), - ]); - }); - - it('should filter out parameters with invalid names', () => { - const parameters = [ - createParameter('valid_param'), - createParameter('invalid param'), // space - createParameter('invalid.param'), // dot - createParameter('invalid|param'), // pipe - createParameter('invalid-param'), // dash - createParameter('invalid@param'), // at symbol - createParameter('1invalid'), // starts with number - createParameter('_valid_param'), // starts with underscore (valid) - ]; - - const recipeContent = { - prompt: - 'Use all: {{valid_param}} {{invalid param}} {{invalid.param}} {{invalid|param}} {{invalid-param}} {{invalid@param}} {{1invalid}} {{_valid_param}}', - }; - - const result = filterValidUsedParameters(parameters, recipeContent); - expect(result).toEqual([createParameter('valid_param'), createParameter('_valid_param')]); - }); - - it('should handle empty parameters array', () => { - const parameters: RecipeParameter[] = []; - const recipeContent = { - prompt: 'Use {{some_param}}', - }; - - const result = filterValidUsedParameters(parameters, recipeContent); - expect(result).toEqual([]); - }); - - it('should handle undefined parameters', () => { - const parameters = undefined; - const recipeContent = { - prompt: 'Use {{some_param}}', - }; - - const result = filterValidUsedParameters(parameters, recipeContent); - expect(result).toEqual([]); - }); - - it('should handle empty recipe content', () => { - const parameters = [createParameter('param1'), createParameter('param2')]; - const recipeContent = {}; - - const result = filterValidUsedParameters(parameters, recipeContent); - expect(result).toEqual([]); - }); - - it('should handle recipe content with empty strings', () => { - const parameters = [createParameter('param1'), createParameter('param2')]; - const recipeContent = { - prompt: '', - instructions: '', - }; - - const result = filterValidUsedParameters(parameters, recipeContent); - expect(result).toEqual([]); - }); - - it('should handle recipe content with undefined values', () => { - const parameters = [createParameter('param1'), createParameter('param2')]; - const recipeContent = { - prompt: undefined, - instructions: undefined, - }; - - const result = filterValidUsedParameters(parameters, recipeContent); - expect(result).toEqual([]); - }); - - it('should preserve parameter properties', () => { - const parameters = [ - { - key: 'test_param', - description: 'A test parameter', - input_type: 'string' as const, - requirement: 'required' as const, - }, - ]; - - const recipeContent = { - prompt: 'Use {{test_param}}', - }; - - const result = filterValidUsedParameters(parameters, recipeContent); - expect(result).toEqual([ - { - key: 'test_param', - description: 'A test parameter', - input_type: 'string', - requirement: 'required', - }, - ]); - }); - - it('should filter out complex template expressions with dots and pipes', () => { - const parameters = [ - createParameter('steps.fetch_payment_data.data.payments.totalEdgeCount | number_format'), // complex invalid - createParameter('simple_param'), // valid - createParameter('another.invalid.param'), // invalid with dots - createParameter('valid_param'), // valid - ]; - - const recipeContent = { - prompt: - 'Use {{steps.fetch_payment_data.data.payments.totalEdgeCount | number_format}} and {{simple_param}}', - instructions: 'Also use {{another.invalid.param}} and {{valid_param}}', - }; - - const result = filterValidUsedParameters(parameters, recipeContent); - expect(result).toEqual([createParameter('simple_param'), createParameter('valid_param')]); - }); - - it('should handle complex recipe content with multiple parameter usages', () => { - const parameters = [ - createParameter('user_name'), - createParameter('user_email'), - createParameter('app_name'), - createParameter('invalid param'), - createParameter('unused_param'), - createParameter('version_number'), - ]; - - const recipeContent = { - prompt: ` - Welcome {{user_name}}! - - Your details: - - Name: {{user_name}} - - Email: {{user_email}} - `, - instructions: ` - Please use {{app_name}} version {{version_number}}. - - Contact {{user_email}} for support. - Invalid usage: {{invalid param}} - `, - }; - - const result = filterValidUsedParameters(parameters, recipeContent); - expect(result).toEqual([ - createParameter('user_name'), - createParameter('user_email'), - createParameter('app_name'), - createParameter('version_number'), - ]); - }); - }); - describe('substituteParameters', () => { it('should substitute simple parameters', () => { const text = 'Hello {{name}}, welcome to {{app}}!'; diff --git a/ui/desktop/src/utils/providerUtils.ts b/ui/desktop/src/utils/providerUtils.ts index e707e08fd0a5..3dbd13434423 100644 --- a/ui/desktop/src/utils/providerUtils.ts +++ b/ui/desktop/src/utils/providerUtils.ts @@ -4,130 +4,7 @@ import { addToAgentOnStartup, } from '../components/settings/extensions'; import type { ExtensionConfig, FixedExtensionEntry } from '../components/ConfigContext'; -import { addSubRecipesToAgent } from '../recipe/add_sub_recipe_on_agent'; -import { - extendPrompt, - Recipe, - RecipeParameter, - SubRecipe, - updateAgentProvider, - updateSessionConfig, -} from '../api'; - -// Desktop-specific system prompt extension -const desktopPrompt = `You are being accessed through the Goose Desktop application. - -The user is interacting with you through a graphical user interface with the following features: -- A chat interface where messages are displayed in a conversation format -- Support for markdown formatting in your responses -- Support for code blocks with syntax highlighting -- Tool use messages are included in the chat but outputs may need to be expanded - -The user can add extensions for you through the "Settings" page, which is available in the menu -on the top right of the window. There is a section on that page for extensions, and it links to -the registry. - -Some extensions are builtin, such as Developer and Memory, while -3rd party extensions can be browsed at https://block.github.io/goose/v1/extensions/. -`; - -// Desktop-specific system prompt extension when a bot is in play -const desktopPromptBot = `You are a helpful agent. -You are being accessed through the Goose Desktop application, pre configured with instructions as requested by a human. - -The user is interacting with you through a graphical user interface with the following features: -- A chat interface where messages are displayed in a conversation format -- Support for markdown formatting in your responses -- Support for code blocks with syntax highlighting -- Tool use messages are included in the chat but outputs may need to be expanded - -It is VERY IMPORTANT that you take note of the provided instructions, also check if a style of output is requested and always do your best to adhere to it. -You can also validate your output after you have generated it to ensure it meets the requirements of the user. -There may be (but not always) some tools mentioned in the instructions which you can check are available to this instance of goose (and try to help the user if they are not or find alternatives). -`; - -// Helper function to extract template variables from text (matches backend logic) -export const extractTemplateVariables = (content: string): string[] => { - const templateVarRegex = /\{\{(.*?)\}\}/g; - const variables: string[] = []; - let match; - - while ((match = templateVarRegex.exec(content)) !== null) { - const variable = match[1].trim(); - - if (variable && !variables.includes(variable)) { - // Filter out complex variables that aren't valid parameter names - // This matches the backend logic in filter_complex_variables() - const isValid = isValidParameterName(variable); - - if (isValid) { - variables.push(variable); - } - } - } - - return variables; -}; - -// Helper function to check if a variable name is valid for parameters -// Matches backend regex: r"^\s*[a-zA-Z_][a-zA-Z0-9_]*\s*$" -const isValidParameterName = (variable: string): boolean => { - const validVarRegex = /^\s*[a-zA-Z_][a-zA-Z0-9_]*\s*$/; - return validVarRegex.test(variable); -}; - -// Helper function to filter recipe parameters to only show valid ones that are actually used -export const filterValidUsedParameters = ( - parameters: RecipeParameter[] | undefined, - recipeContent: { prompt?: string; instructions?: string; activities?: string[] } -): RecipeParameter[] => { - if (!parameters) { - return []; - } - - // Extract all template variables used in the recipe content - const promptVariables = recipeContent.prompt - ? extractTemplateVariables(recipeContent.prompt) - : []; - const instructionVariables = recipeContent.instructions - ? extractTemplateVariables(recipeContent.instructions) - : []; - - // Extract variables from activities using flatMap - const activityVariables = recipeContent.activities?.flatMap(extractTemplateVariables) ?? []; - - const allUsedVariables = [ - ...new Set([...promptVariables, ...instructionVariables, ...activityVariables]), - ]; - - // Filter parameters to only include: - // 1. Parameters with valid names (no spaces, dots, pipes, etc.) - // 2. Parameters that are actually used in the recipe content - // 3. Remove duplicates (keep first occurrence) - const seenKeys = new Set(); - - return parameters.filter((param) => { - // Check if parameter key is valid (no spaces, special characters) - const isValid = isValidParameterName(param.key); - if (!isValid) { - return false; - } - - // Check if parameter is actually used in the recipe content - const isUsed = allUsedVariables.includes(param.key); - if (!isUsed) { - return false; - } - - // Remove duplicates (keep first occurrence) - if (seenKeys.has(param.key)) { - return false; - } - - seenKeys.add(param.key); - return true; - }); -}; +import { Recipe, updateAgentProvider, updateFromSession } from '../api'; // Helper function to substitute parameters in text export const substituteParameters = (text: string, params: Record): string => { @@ -142,54 +19,6 @@ export const substituteParameters = (text: string, params: Record, - recipe?: { - instructions?: string | null; - sub_recipes?: SubRecipe[] | null; - parameters?: RecipeParameter[] | null; - } -): Promise => { - const subRecipes = recipe?.sub_recipes; - try { - const originalInstructions = recipe?.instructions; - - if (!originalInstructions) { - return; - } - // Substitute parameters in the instructions - const substitutedInstructions = substituteParameters(originalInstructions, recipeParameters); - - // Update the system prompt with substituted instructions - const response = await extendPrompt({ - body: { - session_id: sessionId, - extension: `${desktopPromptBot}\nIMPORTANT instructions for you to operate as agent:\n${substitutedInstructions}`, - }, - }); - if (response.error) { - console.warn(`Failed to update system prompt with parameters: ${response.error}`); - } - } catch (error) { - console.error('Error updating system prompt with parameters:', error); - } - if (subRecipes && subRecipes?.length > 0) { - for (const subRecipe of subRecipes) { - if (subRecipe.values) { - for (const key in subRecipe.values) { - subRecipe.values[key] = substituteParameters(subRecipe.values[key], recipeParameters); - } - } - } - await addSubRecipesToAgent(sessionId, subRecipes); - } -}; - export const initializeSystem = async ( sessionId: string, provider: string, @@ -223,69 +52,13 @@ export const initializeSystem = async ( if (!sessionId) { console.log('This will not end well'); } - - // Get recipe - prefer from options (session metadata) over app config - const recipe = options?.recipe; - const recipe_instructions = (recipe as { instructions?: string })?.instructions; - const responseConfig = (recipe as { response?: { json_schema?: unknown } })?.response; - const subRecipes = (recipe as { sub_recipes?: SubRecipe[] })?.sub_recipes; - const hasSubRecipes = subRecipes && subRecipes?.length > 0; - const recipeParameters = options?.recipeParameters; - - // Determine the system prompt - let prompt = desktopPrompt; - - // If we have recipe instructions, add them to the system prompt with parameter substitution - if (recipe_instructions) { - const substitutedInstructions = recipeParameters - ? substituteParameters(recipe_instructions, recipeParameters) - : recipe_instructions; - - prompt = `${desktopPromptBot}\nIMPORTANT instructions for you to operate as agent:\n${substitutedInstructions}`; - } - - // Extend the system prompt with desktop-specific information - await extendPrompt({ + await updateFromSession({ body: { session_id: sessionId, - extension: prompt, }, + throwOnError: true, }); - if (hasSubRecipes) { - let finalSubRecipes = subRecipes; - - // If we have parameters, substitute them in sub-recipe values - if (recipeParameters) { - finalSubRecipes = subRecipes.map((subRecipe) => ({ - ...subRecipe, - values: subRecipe.values - ? Object.fromEntries( - Object.entries(subRecipe.values).map(([key, value]) => [ - key, - substituteParameters(value, recipeParameters), - ]) - ) - : subRecipe.values, - })); - } - - await addSubRecipesToAgent(sessionId, finalSubRecipes); - } - - // Configure session with response config if present - if (responseConfig?.json_schema) { - const sessionConfigResponse = await updateSessionConfig({ - body: { - session_id: sessionId, - response: responseConfig, - }, - }); - if (sessionConfigResponse.error) { - console.warn(`Failed to configure session: ${sessionConfigResponse.error}`); - } - } - if (!options?.getExtensions || !options?.addExtension) { console.warn('Extension helpers not provided in alpha mode'); return;