feat: created sub recipe tools (#2982)

This commit is contained in:
Lifei Zhou
2025-06-25 09:29:26 +10:00
committed by GitHub
parent 566796f767
commit 9e6247d9ed
18 changed files with 1329 additions and 426 deletions
+256
View File
@@ -1,6 +1,10 @@
use anyhow::Result;
use serde_json::Value;
use std::collections::HashMap;
use std::fmt;
use crate::agents::extension::ExtensionConfig;
use serde::de::Deserializer;
use serde::{Deserialize, Serialize};
fn default_version() -> String {
@@ -89,6 +93,9 @@ pub struct Recipe {
#[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 sub_recipes: Option<Vec<SubRecipe>>, // sub-recipes for the recipe
}
#[derive(Serialize, Deserialize, Debug)]
@@ -112,6 +119,39 @@ pub struct Settings {
pub temperature: Option<f32>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct SubRecipe {
pub name: String,
pub path: String,
#[serde(default, deserialize_with = "deserialize_value_map_as_string")]
pub values: Option<HashMap<String, 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)]
#[serde(rename_all = "snake_case")]
pub enum RecipeParameterRequirement {
@@ -176,6 +216,7 @@ pub struct RecipeBuilder {
activities: Option<Vec<String>>,
author: Option<Author>,
parameters: Option<Vec<RecipeParameter>>,
sub_recipes: Option<Vec<SubRecipe>>,
}
impl Recipe {
@@ -206,6 +247,18 @@ impl Recipe {
activities: None,
author: None,
parameters: None,
sub_recipes: None,
}
}
pub fn from_content(content: &str) -> Result<Self> {
if serde_json::from_str::<serde_json::Value>(content).is_ok() {
Ok(serde_json::from_str(content)?)
} else if serde_yaml::from_str::<serde_yaml::Value>(content).is_ok() {
Ok(serde_yaml::from_str(content)?)
} else {
Err(anyhow::anyhow!(
"Unsupported format. Expected JSON or YAML."
))
}
}
}
@@ -274,6 +327,10 @@ impl RecipeBuilder {
self.parameters = Some(parameters);
self
}
pub fn sub_recipes(mut self, sub_recipes: Vec<SubRecipe>) -> Self {
self.sub_recipes = Some(sub_recipes);
self
}
/// Builds the Recipe instance
///
@@ -298,6 +355,205 @@ impl RecipeBuilder {
activities: self.activities,
author: self.author,
parameters: self.parameters,
sub_recipes: self.sub_recipes,
})
}
}
#[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"
}
],
"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.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
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.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_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"]);
}
}