fix: backwards compatible parsing recipe file (#5020)

This commit is contained in:
Lifei Zhou
2025-10-06 21:34:16 +11:00
committed by GitHub
parent 0df1e0704b
commit 5526b59f53
3 changed files with 63 additions and 149 deletions
+63 -95
View File
@@ -24,57 +24,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
@@ -279,20 +228,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(),
@@ -318,24 +253,34 @@ impl Recipe {
}
pub fn from_content(content: &str) -> Result<Self> {
let recipe: Recipe =
if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(content) {
if let Some(nested_recipe) = json_value.get("recipe") {
serde_json::from_value(nested_recipe.clone())?
} else {
serde_json::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 if let Ok(yaml_value) = serde_yaml::from_str::<serde_yaml::Value>(content) {
if let Some(nested_recipe) = yaml_value.get("recipe") {
serde_yaml::from_value(nested_recipe.clone())?
} else {
serde_yaml::from_str(content)?
}
} 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() {
@@ -351,25 +296,21 @@ impl Recipe {
}
impl RecipeBuilder {
/// Sets the version of the Recipe
pub fn version(mut self, version: impl Into<String>) -> Self {
self.version = version.into();
self
}
/// Sets the title of the Recipe (required)
pub fn title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
/// Sets the description of the Recipe (required)
pub fn description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
/// Sets the instructions for the Recipe (required)
pub fn instructions(mut self, instructions: impl Into<String>) -> Self {
self.instructions = Some(instructions.into());
self
@@ -380,13 +321,11 @@ impl RecipeBuilder {
self
}
/// Sets the extensions for the Recipe
pub fn extensions(mut self, extensions: Vec<ExtensionConfig>) -> Self {
self.extensions = Some(extensions);
self
}
/// Sets the context for the Recipe
pub fn context(mut self, context: Vec<String>) -> Self {
self.context = Some(context);
self
@@ -397,19 +336,16 @@ impl RecipeBuilder {
self
}
/// Sets the activities for the Recipe
pub fn activities(mut self, activities: Vec<String>) -> 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<RecipeParameter>) -> Self {
self.parameters = Some(parameters);
self
@@ -425,15 +361,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<Recipe, &'static str> {
let title = self.title.ok_or("Title is required")?;
let description = self.description.ok_or("Description is required")?;
@@ -815,4 +747,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");
}
}
}
-1
View File
@@ -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"
-53
View File
@@ -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<string> | null;
author?: Author | null;