Files
tkmind_go/crates/goose/src/recipe/mod.rs
T
2025-09-26 23:44:38 -05:00

809 lines
26 KiB
Rust

use anyhow::Result;
use serde_json::Value;
use std::collections::HashMap;
use std::fmt;
use crate::agents::extension::ExtensionConfig;
use crate::agents::types::RetryConfig;
use crate::utils::contains_unicode_tags;
use serde::de::Deserializer;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
pub mod build_recipe;
pub mod read_recipe_file_content;
pub mod template_recipe;
pub const BUILT_IN_RECIPE_DIR_PARAM: &str = "recipe_dir";
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
#[serde(default = "default_version")]
pub version: String, // version of the file format, sem ver
pub title: String, // short title of the recipe
pub description: String, // a longer description of the recipe
// Optional fields
// Note: at least one of instructions or prompt need to be set
#[serde(skip_serializing_if = "Option::is_none")]
pub instructions: Option<String>, // the instructions for the model
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt: Option<String>, // the prompt to start the session with
#[serde(skip_serializing_if = "Option::is_none")]
pub extensions: Option<Vec<ExtensionConfig>>, // a list of extensions to enable
#[serde(skip_serializing_if = "Option::is_none")]
pub context: Option<Vec<String>>, // any additional context
#[serde(skip_serializing_if = "Option::is_none")]
pub settings: Option<Settings>, // settings for the recipe
#[serde(skip_serializing_if = "Option::is_none")]
pub activities: Option<Vec<String>>, // the activity pills that show up when loading the
#[serde(skip_serializing_if = "Option::is_none")]
pub author: Option<Author>, // any additional author information
#[serde(skip_serializing_if = "Option::is_none")]
pub parameters: Option<Vec<RecipeParameter>>, // any additional parameters for the recipe
#[serde(skip_serializing_if = "Option::is_none")]
pub response: Option<Response>, // response configuration including JSON schema
#[serde(skip_serializing_if = "Option::is_none")]
pub sub_recipes: Option<Vec<SubRecipe>>, // sub-recipes for the recipe
#[serde(skip_serializing_if = "Option::is_none")]
pub retry: Option<RetryConfig>,
}
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
pub struct Author {
#[serde(skip_serializing_if = "Option::is_none")]
pub contact: Option<String>, // creator/contact information of the recipe
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<String>, // any additional metadata for the author
}
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
pub struct Settings {
#[serde(skip_serializing_if = "Option::is_none")]
pub goose_provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub goose_model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
}
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
pub struct Response {
#[serde(skip_serializing_if = "Option::is_none")]
pub json_schema: Option<serde_json::Value>,
}
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
pub struct SubRecipe {
pub name: String,
pub path: String,
#[serde(default, deserialize_with = "deserialize_value_map_as_string")]
pub values: Option<HashMap<String, String>>,
#[serde(default)]
pub sequential_when_repeated: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
fn deserialize_value_map_as_string<'de, D>(
deserializer: D,
) -> Result<Option<HashMap<String, String>>, D::Error>
where
D: Deserializer<'de>,
{
// First, try to deserialize a map of values
let opt_raw: Option<HashMap<String, Value>> = Option::deserialize(deserializer)?;
match opt_raw {
Some(raw_map) => {
let mut result = HashMap::new();
for (k, v) in raw_map {
let s = match v {
Value::String(s) => s,
_ => serde_json::to_string(&v).map_err(serde::de::Error::custom)?,
};
result.insert(k, s);
}
Ok(Some(result))
}
None => Ok(None),
}
}
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum RecipeParameterRequirement {
Required,
Optional,
UserPrompt,
}
impl fmt::Display for RecipeParameterRequirement {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{}",
serde_json::to_string(self).unwrap().trim_matches('"')
)
}
}
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum RecipeParameterInputType {
String,
Number,
Boolean,
Date,
/// File parameter that imports content from a file path.
/// Cannot have default values to prevent importing sensitive user files.
File,
Select,
}
impl fmt::Display for RecipeParameterInputType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{}",
serde_json::to_string(self).unwrap().trim_matches('"')
)
}
}
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
pub struct RecipeParameter {
pub key: String,
pub input_type: RecipeParameterInputType,
pub requirement: RecipeParameterRequirement,
pub description: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub default: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub options: Option<Vec<String>>,
}
/// Builder for creating Recipe instances
pub struct RecipeBuilder {
// Required fields with default values
version: String,
title: Option<String>,
description: Option<String>,
instructions: Option<String>,
// Optional fields
prompt: Option<String>,
extensions: Option<Vec<ExtensionConfig>>,
context: Option<Vec<String>>,
settings: Option<Settings>,
activities: Option<Vec<String>>,
author: Option<Author>,
parameters: Option<Vec<RecipeParameter>>,
response: Option<Response>,
sub_recipes: Option<Vec<SubRecipe>>,
retry: Option<RetryConfig>,
}
impl Recipe {
/// Returns true if harmful content is detected in instructions, prompt, or activities fields
pub fn check_for_security_warnings(&self) -> bool {
if [self.instructions.as_deref(), self.prompt.as_deref()]
.iter()
.flatten()
.any(|&field| contains_unicode_tags(field))
{
return true;
}
if let Some(activities) = &self.activities {
return activities
.iter()
.any(|activity| contains_unicode_tags(activity));
}
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(),
title: None,
description: None,
instructions: None,
prompt: None,
extensions: None,
context: None,
settings: None,
activities: None,
author: None,
parameters: None,
response: None,
sub_recipes: None,
retry: None,
}
}
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)?
}
} else {
return Err(anyhow::anyhow!(
"Unsupported format. Expected JSON or YAML."
));
};
if let Some(ref retry_config) = recipe.retry {
if let Err(validation_error) = retry_config.validate() {
return Err(anyhow::anyhow!(
"Invalid retry configuration: {}",
validation_error
));
}
}
Ok(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
}
pub fn prompt(mut self, prompt: impl Into<String>) -> Self {
self.prompt = Some(prompt.into());
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
}
pub fn settings(mut self, settings: Settings) -> Self {
self.settings = Some(settings);
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
}
pub fn response(mut self, response: Response) -> Self {
self.response = Some(response);
self
}
pub fn sub_recipes(mut self, sub_recipes: Vec<SubRecipe>) -> Self {
self.sub_recipes = Some(sub_recipes);
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")?;
if self.instructions.is_none() && self.prompt.is_none() {
return Err("At least one of 'prompt' or 'instructions' is required");
}
Ok(Recipe {
version: self.version,
title,
description,
instructions: self.instructions,
prompt: self.prompt,
extensions: self.extensions,
context: self.context,
settings: self.settings,
activities: self.activities,
author: self.author,
parameters: self.parameters,
response: self.response,
sub_recipes: self.sub_recipes,
retry: self.retry,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_from_content_with_json() {
let content = r#"{
"version": "1.0.0",
"title": "Test Recipe",
"description": "A test recipe",
"prompt": "Test prompt",
"instructions": "Test instructions",
"extensions": [
{
"type": "stdio",
"name": "test_extension",
"cmd": "test_cmd",
"args": ["arg1", "arg2"],
"timeout": 300,
"description": "Test extension"
}
],
"parameters": [
{
"key": "test_param",
"input_type": "string",
"requirement": "required",
"description": "A test parameter"
}
],
"response": {
"json_schema": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"age": {
"type": "number"
}
},
"required": ["name"]
}
},
"sub_recipes": [
{
"name": "test_sub_recipe",
"path": "test_sub_recipe.yaml",
"values": {
"sub_recipe_param": "sub_recipe_value"
}
}
]
}"#;
let recipe = Recipe::from_content(content).unwrap();
assert_eq!(recipe.version, "1.0.0");
assert_eq!(recipe.title, "Test Recipe");
assert_eq!(recipe.description, "A test recipe");
assert_eq!(recipe.instructions, Some("Test instructions".to_string()));
assert_eq!(recipe.prompt, Some("Test prompt".to_string()));
assert!(recipe.extensions.is_some());
let extensions = recipe.extensions.unwrap();
assert_eq!(extensions.len(), 1);
assert!(recipe.parameters.is_some());
let parameters = recipe.parameters.unwrap();
assert_eq!(parameters.len(), 1);
assert_eq!(parameters[0].key, "test_param");
assert!(matches!(
parameters[0].input_type,
RecipeParameterInputType::String
));
assert!(matches!(
parameters[0].requirement,
RecipeParameterRequirement::Required
));
assert!(recipe.response.is_some());
let response = recipe.response.unwrap();
assert!(response.json_schema.is_some());
let json_schema = response.json_schema.unwrap();
assert_eq!(json_schema["type"], "object");
assert!(json_schema["properties"].is_object());
assert_eq!(json_schema["properties"]["name"]["type"], "string");
assert_eq!(json_schema["properties"]["age"]["type"], "number");
assert_eq!(json_schema["required"], serde_json::json!(["name"]));
assert!(recipe.sub_recipes.is_some());
let sub_recipes = recipe.sub_recipes.unwrap();
assert_eq!(sub_recipes.len(), 1);
assert_eq!(sub_recipes[0].name, "test_sub_recipe");
assert_eq!(sub_recipes[0].path, "test_sub_recipe.yaml");
assert_eq!(
sub_recipes[0].values,
Some(HashMap::from([(
"sub_recipe_param".to_string(),
"sub_recipe_value".to_string()
)]))
);
}
#[test]
fn test_from_content_with_yaml() {
let content = r#"version: 1.0.0
title: Test Recipe
description: A test recipe
prompt: Test prompt
instructions: Test instructions
extensions:
- type: stdio
name: test_extension
cmd: test_cmd
args: [arg1, arg2]
timeout: 300
description: Test extension
parameters:
- key: test_param
input_type: string
requirement: required
description: A test parameter
response:
json_schema:
type: object
properties:
name:
type: string
age:
type: number
required:
- name
sub_recipes:
- name: test_sub_recipe
path: test_sub_recipe.yaml
values:
sub_recipe_param: sub_recipe_value"#;
let recipe = Recipe::from_content(content).unwrap();
assert_eq!(recipe.version, "1.0.0");
assert_eq!(recipe.title, "Test Recipe");
assert_eq!(recipe.description, "A test recipe");
assert_eq!(recipe.instructions, Some("Test instructions".to_string()));
assert_eq!(recipe.prompt, Some("Test prompt".to_string()));
assert!(recipe.extensions.is_some());
let extensions = recipe.extensions.unwrap();
assert_eq!(extensions.len(), 1);
assert!(recipe.parameters.is_some());
let parameters = recipe.parameters.unwrap();
assert_eq!(parameters.len(), 1);
assert_eq!(parameters[0].key, "test_param");
assert!(matches!(
parameters[0].input_type,
RecipeParameterInputType::String
));
assert!(matches!(
parameters[0].requirement,
RecipeParameterRequirement::Required
));
assert!(recipe.response.is_some());
let response = recipe.response.unwrap();
assert!(response.json_schema.is_some());
let json_schema = response.json_schema.unwrap();
assert_eq!(json_schema["type"], "object");
assert!(json_schema["properties"].is_object());
assert_eq!(json_schema["properties"]["name"]["type"], "string");
assert_eq!(json_schema["properties"]["age"]["type"], "number");
assert_eq!(json_schema["required"], serde_json::json!(["name"]));
assert!(recipe.sub_recipes.is_some());
let sub_recipes = recipe.sub_recipes.unwrap();
assert_eq!(sub_recipes.len(), 1);
assert_eq!(sub_recipes[0].name, "test_sub_recipe");
assert_eq!(sub_recipes[0].path, "test_sub_recipe.yaml");
assert_eq!(
sub_recipes[0].values,
Some(HashMap::from([(
"sub_recipe_param".to_string(),
"sub_recipe_value".to_string()
)]))
);
}
#[test]
fn test_from_content_invalid_json() {
let content = "{ invalid json }";
let result = Recipe::from_content(content);
assert!(result.is_err());
}
#[test]
fn test_from_content_missing_required_fields() {
let content = r#"{
"version": "1.0.0",
"description": "A test recipe"
}"#;
let result = Recipe::from_content(content);
assert!(result.is_err());
}
#[test]
fn test_from_content_with_author() {
let content = r#"{
"version": "1.0.0",
"title": "Test Recipe",
"description": "A test recipe",
"instructions": "Test instructions",
"author": {
"contact": "test@example.com"
}
}"#;
let recipe = Recipe::from_content(content).unwrap();
assert!(recipe.author.is_some());
let author = recipe.author.unwrap();
assert_eq!(author.contact, Some("test@example.com".to_string()));
}
#[test]
fn test_inline_python_extension() {
let content = r#"{
"version": "1.0.0",
"title": "Test Recipe",
"description": "A test recipe",
"instructions": "Test instructions",
"extensions": [
{
"type": "inline_python",
"name": "test_python",
"code": "print('hello world')",
"timeout": 300,
"description": "Test python extension",
"dependencies": ["numpy", "matplotlib"]
}
]
}"#;
let recipe = Recipe::from_content(content).unwrap();
assert!(recipe.extensions.is_some());
let extensions = recipe.extensions.unwrap();
assert_eq!(extensions.len(), 1);
match &extensions[0] {
ExtensionConfig::InlinePython {
name,
code,
description,
timeout,
dependencies,
..
} => {
assert_eq!(name, "test_python");
assert_eq!(code, "print('hello world')");
assert_eq!(description.as_deref(), Some("Test python extension"));
assert_eq!(timeout, &Some(300));
assert!(dependencies.is_some());
let deps = dependencies.as_ref().unwrap();
assert!(deps.contains(&"numpy".to_string()));
assert!(deps.contains(&"matplotlib".to_string()));
}
_ => panic!("Expected InlinePython extension"),
}
}
#[test]
fn test_from_content_with_activities() {
let content = r#"{
"version": "1.0.0",
"title": "Test Recipe",
"description": "A test recipe",
"instructions": "Test instructions",
"activities": ["activity1", "activity2"]
}"#;
let recipe = Recipe::from_content(content).unwrap();
assert!(recipe.activities.is_some());
let activities = recipe.activities.unwrap();
assert_eq!(activities, vec!["activity1", "activity2"]);
}
#[test]
fn test_from_content_with_nested_recipe_yaml() {
let content = r#"name: test_recipe
recipe:
title: Nested Recipe Test
description: A test recipe with nested structure
instructions: Test instructions for nested recipe
activities:
- Test activity 1
- Test activity 2
prompt: Test prompt
extensions: []
isGlobal: true"#;
let recipe = Recipe::from_content(content).unwrap();
assert_eq!(recipe.title, "Nested Recipe Test");
assert_eq!(recipe.description, "A test recipe with nested structure");
assert_eq!(
recipe.instructions,
Some("Test instructions for nested recipe".to_string())
);
assert_eq!(recipe.prompt, Some("Test prompt".to_string()));
assert!(recipe.activities.is_some());
let activities = recipe.activities.unwrap();
assert_eq!(activities, vec!["Test activity 1", "Test activity 2"]);
assert!(recipe.extensions.is_some());
let extensions = recipe.extensions.unwrap();
assert_eq!(extensions.len(), 0);
}
#[test]
fn test_check_for_security_warnings() {
let mut recipe = Recipe {
version: "1.0.0".to_string(),
title: "Test".to_string(),
description: "Test".to_string(),
instructions: Some("clean instructions".to_string()),
prompt: Some("clean prompt".to_string()),
extensions: None,
context: None,
settings: None,
activities: Some(vec!["clean activity 1".to_string()]),
author: None,
parameters: None,
response: None,
sub_recipes: None,
retry: None,
};
assert!(!recipe.check_for_security_warnings());
// Malicious activities
recipe.activities = Some(vec![
"clean activity".to_string(),
format!("malicious{}activity", '\u{E0041}'),
]);
assert!(recipe.check_for_security_warnings());
// Malicious instructions
recipe.instructions = Some(format!("instructions{}", '\u{E0041}'));
assert!(recipe.check_for_security_warnings());
// Malicious prompt
recipe.prompt = Some(format!("prompt{}", '\u{E0042}'));
assert!(recipe.check_for_security_warnings());
}
}