Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 63 additions & 95 deletions crates/goose/src/recipe/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
/// };
///
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks for deleting this!

#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
pub struct Recipe {
// Required fields
Expand Down Expand Up @@ -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(),
Expand All @@ -309,24 +244,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)?
}
} 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)?
// 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") {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ugh

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is legacy format that we have to maintain :(

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() {
Expand All @@ -342,25 +287,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
Expand All @@ -371,13 +312,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
Expand All @@ -388,19 +327,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
Expand All @@ -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<Recipe, &'static str> {
let title = self.title.ok_or("Title is required")?;
let description = self.description.ok_or("Description is required")?;
Expand Down Expand Up @@ -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");
}
}
}
1 change: 0 additions & 1 deletion ui/desktop/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
53 changes: 0 additions & 53 deletions ui/desktop/src/api/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading