diff --git a/crates/goose/src/recipe/mod.rs b/crates/goose/src/recipe/mod.rs index 6995de843d22..922a05846086 100644 --- a/crates/goose/src/recipe/mod.rs +++ b/crates/goose/src/recipe/mod.rs @@ -21,57 +21,6 @@ fn default_version() -> String { "1.0.0".to_string() } -/// A Recipe represents a personalized, user-generated agent configuration that defines -/// specific behaviors and capabilities within the goose system. -/// -/// # Fields -/// -/// ## Required Fields -/// * `version` - Semantic version of the Recipe file format (defaults to "1.0.0") -/// * `title` - Short, descriptive name of the Recipe -/// * `description` - Detailed description explaining the Recipe's purpose and functionality -/// * `Instructions` - Instructions that defines the Recipe's behavior -/// -/// ## Optional Fields -/// * `prompt` - the initial prompt to the session to start with -/// * `extensions` - List of extension configurations required by the Recipe -/// * `context` - Supplementary context information for the Recipe -/// * `activities` - Activity labels that appear when loading the Recipe -/// * `author` - Information about the Recipe's creator and metadata -/// * `parameters` - Additional parameters for the Recipe -/// * `response` - Response configuration including JSON schema validation -/// * `retry` - Retry configuration for automated validation and recovery -/// # Example -/// -/// -/// use goose::recipe::Recipe; -/// -/// // Using the builder pattern -/// let recipe = Recipe::builder() -/// .title("Example Agent") -/// .description("An example Recipe configuration") -/// .instructions("Act as a helpful assistant") -/// .build() -/// .expect("Missing required fields"); -/// -/// // Or using struct initialization -/// let recipe = Recipe { -/// version: "1.0.0".to_string(), -/// title: "Example Agent".to_string(), -/// description: "An example Recipe configuration".to_string(), -/// instructions: Some("Act as a helpful assistant".to_string()), -/// prompt: None, -/// extensions: None, -/// context: None, -/// activities: None, -/// author: None, -/// settings: None, -/// parameters: None, -/// response: None, -/// sub_recipes: None, -/// retry: None, -/// }; -/// #[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] pub struct Recipe { // Required fields @@ -276,20 +225,6 @@ impl Recipe { false } - /// Creates a new RecipeBuilder to construct a Recipe instance - /// - /// # Example - /// - /// - /// use goose::recipe::Recipe; - /// - /// let recipe = Recipe::builder() - /// .title("My Recipe") - /// .description("A helpful assistant") - /// .instructions("Act as a helpful assistant") - /// .build() - /// .expect("Failed to build Recipe: missing required fields"); - /// pub fn builder() -> RecipeBuilder { RecipeBuilder { version: default_version(), @@ -309,24 +244,34 @@ impl Recipe { } } pub fn from_content(content: &str) -> Result { - let recipe: Recipe = - if let Ok(json_value) = serde_json::from_str::(content) { - if let Some(nested_recipe) = json_value.get("recipe") { - serde_json::from_value(nested_recipe.clone())? - } else { - serde_json::from_str(content)? - } - } else if let Ok(yaml_value) = serde_yaml::from_str::(content) { - if let Some(nested_recipe) = yaml_value.get("recipe") { - serde_yaml::from_value(nested_recipe.clone())? - } else { - serde_yaml::from_str(content)? + // Parse using YAML parser (since JSON is a subset of YAML, this handles both) + let mut value: serde_yaml::Value = serde_yaml::from_str(content) + .map_err(|e| anyhow::anyhow!("Failed to parse recipe content as YAML/JSON: {}", e))?; + + // Handle nested legacy recipe format + if let Some(nested_recipe) = value.get("recipe") { + value = nested_recipe.clone(); + } + + if let Some(extensions) = value + .get_mut("extensions") + .and_then(|v| v.as_sequence_mut()) + { + for ext in extensions.iter_mut() { + if let Some(obj) = ext.as_mapping_mut() { + if let Some(desc) = obj.get("description") { + if desc.is_null() || desc.as_str().is_some_and(|s| s.is_empty()) { + if let Some(name) = obj.get("name").and_then(|n| n.as_str()) { + obj.insert("description".into(), name.into()); + } + } + } } - } else { - return Err(anyhow::anyhow!( - "Unsupported format. Expected JSON or YAML." - )); - }; + } + } + + let recipe: Recipe = serde_yaml::from_value(value) + .map_err(|e| anyhow::anyhow!("Failed to deserialize recipe: {}", e))?; if let Some(ref retry_config) = recipe.retry { if let Err(validation_error) = retry_config.validate() { @@ -342,25 +287,21 @@ impl Recipe { } impl RecipeBuilder { - /// Sets the version of the Recipe pub fn version(mut self, version: impl Into) -> Self { self.version = version.into(); self } - /// Sets the title of the Recipe (required) pub fn title(mut self, title: impl Into) -> Self { self.title = Some(title.into()); self } - /// Sets the description of the Recipe (required) pub fn description(mut self, description: impl Into) -> Self { self.description = Some(description.into()); self } - /// Sets the instructions for the Recipe (required) pub fn instructions(mut self, instructions: impl Into) -> Self { self.instructions = Some(instructions.into()); self @@ -371,13 +312,11 @@ impl RecipeBuilder { self } - /// Sets the extensions for the Recipe pub fn extensions(mut self, extensions: Vec) -> Self { self.extensions = Some(extensions); self } - /// Sets the context for the Recipe pub fn context(mut self, context: Vec) -> Self { self.context = Some(context); self @@ -388,19 +327,16 @@ impl RecipeBuilder { self } - /// Sets the activities for the Recipe pub fn activities(mut self, activities: Vec) -> Self { self.activities = Some(activities); self } - /// Sets the author information for the Recipe pub fn author(mut self, author: Author) -> Self { self.author = Some(author); self } - /// Sets the parameters for the Recipe pub fn parameters(mut self, parameters: Vec) -> Self { self.parameters = Some(parameters); self @@ -416,15 +352,11 @@ impl RecipeBuilder { self } - /// Sets the retry configuration for the Recipe pub fn retry(mut self, retry: RetryConfig) -> Self { self.retry = Some(retry); self } - /// Builds the Recipe instance - /// - /// Returns an error if any required fields are missing pub fn build(self) -> Result { let title = self.title.ok_or("Title is required")?; let description = self.description.ok_or("Description is required")?; @@ -806,4 +738,40 @@ isGlobal: true"#; recipe.prompt = Some(format!("prompt{}", '\u{E0042}')); assert!(recipe.check_for_security_warnings()); } + + #[test] + fn test_from_content_with_null_description() { + let content = r#"{ + "version": "1.0.0", + "title": "Test Recipe", + "description": "A test recipe", + "instructions": "Test instructions", + "extensions": [ + { + "type": "stdio", + "name": "test_extension", + "cmd": "test_cmd", + "args": [], + "timeout": 300, + "description": null + } + ] + }"#; + + let recipe = Recipe::from_content(content).unwrap(); + + assert!(recipe.extensions.is_some()); + let extensions = recipe.extensions.unwrap(); + assert_eq!(extensions.len(), 1); + + if let ExtensionConfig::Stdio { + name, description, .. + } = &extensions[0] + { + assert_eq!(name, "test_extension"); + assert_eq!(description, "test_extension"); + } else { + panic!("Expected Stdio extension"); + } + } } diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 2265633433e1..a8398f713d3d 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -3425,7 +3425,6 @@ }, "Recipe": { "type": "object", - "description": "A Recipe represents a personalized, user-generated agent configuration that defines\nspecific behaviors and capabilities within the goose system.\n\n# Fields\n\n## Required Fields\n* `version` - Semantic version of the Recipe file format (defaults to \"1.0.0\")\n* `title` - Short, descriptive name of the Recipe\n* `description` - Detailed description explaining the Recipe's purpose and functionality\n* `Instructions` - Instructions that defines the Recipe's behavior\n\n## Optional Fields\n* `prompt` - the initial prompt to the session to start with\n* `extensions` - List of extension configurations required by the Recipe\n* `context` - Supplementary context information for the Recipe\n* `activities` - Activity labels that appear when loading the Recipe\n* `author` - Information about the Recipe's creator and metadata\n* `parameters` - Additional parameters for the Recipe\n* `response` - Response configuration including JSON schema validation\n* `retry` - Retry configuration for automated validation and recovery\n# Example\n\n\nuse goose::recipe::Recipe;\n\n// Using the builder pattern\nlet recipe = Recipe::builder()\n.title(\"Example Agent\")\n.description(\"An example Recipe configuration\")\n.instructions(\"Act as a helpful assistant\")\n.build()\n.expect(\"Missing required fields\");\n\n// Or using struct initialization\nlet recipe = Recipe {\nversion: \"1.0.0\".to_string(),\ntitle: \"Example Agent\".to_string(),\ndescription: \"An example Recipe configuration\".to_string(),\ninstructions: Some(\"Act as a helpful assistant\".to_string()),\nprompt: None,\nextensions: None,\ncontext: None,\nactivities: None,\nauthor: None,\nsettings: None,\nparameters: None,\nresponse: None,\nsub_recipes: None,\nretry: None,\n};\n", "required": [ "title", "description" diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 3ec84d9ed92c..bf69f3e86789 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -547,59 +547,6 @@ export type RawTextContent = { text: string; }; -/** - * A Recipe represents a personalized, user-generated agent configuration that defines - * specific behaviors and capabilities within the goose system. - * - * # Fields - * - * ## Required Fields - * * `version` - Semantic version of the Recipe file format (defaults to "1.0.0") - * * `title` - Short, descriptive name of the Recipe - * * `description` - Detailed description explaining the Recipe's purpose and functionality - * * `Instructions` - Instructions that defines the Recipe's behavior - * - * ## Optional Fields - * * `prompt` - the initial prompt to the session to start with - * * `extensions` - List of extension configurations required by the Recipe - * * `context` - Supplementary context information for the Recipe - * * `activities` - Activity labels that appear when loading the Recipe - * * `author` - Information about the Recipe's creator and metadata - * * `parameters` - Additional parameters for the Recipe - * * `response` - Response configuration including JSON schema validation - * * `retry` - Retry configuration for automated validation and recovery - * # Example - * - * - * use goose::recipe::Recipe; - * - * // Using the builder pattern - * let recipe = Recipe::builder() - * .title("Example Agent") - * .description("An example Recipe configuration") - * .instructions("Act as a helpful assistant") - * .build() - * .expect("Missing required fields"); - * - * // Or using struct initialization - * let recipe = Recipe { - * version: "1.0.0".to_string(), - * title: "Example Agent".to_string(), - * description: "An example Recipe configuration".to_string(), - * instructions: Some("Act as a helpful assistant".to_string()), - * prompt: None, - * extensions: None, - * context: None, - * activities: None, - * author: None, - * settings: None, - * parameters: None, - * response: None, - * sub_recipes: None, - * retry: None, - * }; - * - */ export type Recipe = { activities?: Array | null; author?: Author | null;