feat: created sub recipe tools (#2982)
This commit is contained in:
@@ -10,13 +10,14 @@ use futures_util::stream;
|
||||
use futures_util::stream::StreamExt;
|
||||
use mcp_core::protocol::JsonRpcMessage;
|
||||
|
||||
use crate::agents::sub_recipe_manager::SubRecipeManager;
|
||||
use crate::config::{Config, ExtensionConfigManager, PermissionManager};
|
||||
use crate::message::Message;
|
||||
use crate::permission::permission_judge::check_tool_permissions;
|
||||
use crate::permission::PermissionConfirmation;
|
||||
use crate::providers::base::Provider;
|
||||
use crate::providers::errors::ProviderError;
|
||||
use crate::recipe::{Author, Recipe, Settings};
|
||||
use crate::recipe::{Author, Recipe, Settings, SubRecipe};
|
||||
use crate::scheduler_trait::SchedulerTrait;
|
||||
use crate::tool_monitor::{ToolCall, ToolMonitor};
|
||||
use regex::Regex;
|
||||
@@ -52,6 +53,7 @@ use super::tool_execution::{ToolCallResult, CHAT_MODE_TOOL_SKIPPED_RESPONSE, DEC
|
||||
pub struct Agent {
|
||||
pub(super) provider: Mutex<Option<Arc<dyn Provider>>>,
|
||||
pub(super) extension_manager: Mutex<ExtensionManager>,
|
||||
pub(super) sub_recipe_manager: Mutex<SubRecipeManager>,
|
||||
pub(super) frontend_tools: Mutex<HashMap<String, FrontendTool>>,
|
||||
pub(super) frontend_instructions: Mutex<Option<String>>,
|
||||
pub(super) prompt_manager: Mutex<PromptManager>,
|
||||
@@ -80,6 +82,7 @@ impl Agent {
|
||||
Self {
|
||||
provider: Mutex::new(None),
|
||||
extension_manager: Mutex::new(ExtensionManager::new()),
|
||||
sub_recipe_manager: Mutex::new(SubRecipeManager::new()),
|
||||
frontend_tools: Mutex::new(HashMap::new()),
|
||||
frontend_instructions: Mutex::new(None),
|
||||
prompt_manager: Mutex::new(PromptManager::new()),
|
||||
@@ -193,6 +196,11 @@ impl Agent {
|
||||
Ok(tools)
|
||||
}
|
||||
|
||||
pub async fn add_sub_recipes(&self, sub_recipes: Vec<SubRecipe>) {
|
||||
let mut sub_recipe_manager = self.sub_recipe_manager.lock().await;
|
||||
sub_recipe_manager.add_sub_recipe_tools(sub_recipes);
|
||||
}
|
||||
|
||||
/// Dispatch a single tool call to the appropriate client
|
||||
#[instrument(skip(self, tool_call, request_id), fields(input, output))]
|
||||
pub async fn dispatch_tool_call(
|
||||
@@ -242,7 +250,13 @@ impl Agent {
|
||||
}
|
||||
|
||||
let extension_manager = self.extension_manager.lock().await;
|
||||
let result: ToolCallResult = if tool_call.name == PLATFORM_READ_RESOURCE_TOOL_NAME {
|
||||
let sub_recipe_manager = self.sub_recipe_manager.lock().await;
|
||||
|
||||
let result: ToolCallResult = if sub_recipe_manager.is_sub_recipe_tool(&tool_call.name) {
|
||||
sub_recipe_manager
|
||||
.dispatch_sub_recipe_tool_call(&tool_call.name, tool_call.arguments.clone())
|
||||
.await
|
||||
} else if tool_call.name == PLATFORM_READ_RESOURCE_TOOL_NAME {
|
||||
// Check if the tool is read_resource and handle it separately
|
||||
ToolCallResult::from(
|
||||
extension_manager
|
||||
@@ -465,17 +479,26 @@ impl Agent {
|
||||
|
||||
if extension_name.is_none() || extension_name.as_deref() == Some("platform") {
|
||||
// Add platform tools
|
||||
prefixed_tools.push(platform_tools::search_available_extensions_tool());
|
||||
prefixed_tools.push(platform_tools::manage_extensions_tool());
|
||||
prefixed_tools.push(platform_tools::manage_schedule_tool());
|
||||
prefixed_tools.extend([
|
||||
platform_tools::search_available_extensions_tool(),
|
||||
platform_tools::manage_extensions_tool(),
|
||||
platform_tools::manage_schedule_tool(),
|
||||
]);
|
||||
|
||||
// Add resource tools if supported
|
||||
if extension_manager.supports_resources() {
|
||||
prefixed_tools.push(platform_tools::read_resource_tool());
|
||||
prefixed_tools.push(platform_tools::list_resources_tool());
|
||||
prefixed_tools.extend([
|
||||
platform_tools::read_resource_tool(),
|
||||
platform_tools::list_resources_tool(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if extension_name.is_none() {
|
||||
let sub_recipe_manager = self.sub_recipe_manager.lock().await;
|
||||
prefixed_tools.extend(sub_recipe_manager.sub_recipe_tools.values().cloned());
|
||||
}
|
||||
|
||||
prefixed_tools
|
||||
}
|
||||
|
||||
|
||||
@@ -5,11 +5,12 @@ pub mod extension_manager;
|
||||
mod large_response_handler;
|
||||
pub mod platform_tools;
|
||||
pub mod prompt_manager;
|
||||
mod recipe_tools;
|
||||
mod reply_parts;
|
||||
mod router_tool_selector;
|
||||
mod router_tools;
|
||||
mod schedule_tool;
|
||||
|
||||
pub mod sub_recipe_manager;
|
||||
mod tool_execution;
|
||||
mod tool_router_index_manager;
|
||||
pub(crate) mod tool_vectordb;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
pub mod sub_recipe_tools;
|
||||
@@ -0,0 +1,167 @@
|
||||
use std::{collections::HashMap, fs};
|
||||
|
||||
use anyhow::Result;
|
||||
use mcp_core::tool::{Tool, ToolAnnotations};
|
||||
use serde_json::{json, Map, Value};
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::recipe::{Recipe, RecipeParameter, RecipeParameterRequirement, SubRecipe};
|
||||
|
||||
pub const SUB_RECIPE_TOOL_NAME_PREFIX: &str = "subrecipe__run_";
|
||||
|
||||
pub fn create_sub_recipe_tool(sub_recipe: &SubRecipe) -> Tool {
|
||||
let input_schema = get_input_schema(sub_recipe).unwrap();
|
||||
Tool::new(
|
||||
format!("{}_{}", SUB_RECIPE_TOOL_NAME_PREFIX, sub_recipe.name),
|
||||
"Run a sub recipe.
|
||||
Use this tool when you need to run a sub-recipe.
|
||||
The sub recipe will be run with the provided parameters
|
||||
and return the output of the sub recipe."
|
||||
.to_string(),
|
||||
input_schema,
|
||||
Some(ToolAnnotations {
|
||||
title: Some(format!("run sub recipe {}", sub_recipe.name)),
|
||||
read_only_hint: false,
|
||||
destructive_hint: true,
|
||||
idempotent_hint: false,
|
||||
open_world_hint: true,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn get_sub_recipe_parameter_definition(
|
||||
sub_recipe: &SubRecipe,
|
||||
) -> Result<Option<Vec<RecipeParameter>>> {
|
||||
let content = fs::read_to_string(sub_recipe.path.clone())
|
||||
.map_err(|e| anyhow::anyhow!("Failed to read recipe file {}: {}", sub_recipe.path, e))?;
|
||||
let recipe = Recipe::from_content(&content)?;
|
||||
Ok(recipe.parameters)
|
||||
}
|
||||
|
||||
fn get_input_schema(sub_recipe: &SubRecipe) -> Result<Value> {
|
||||
let mut sub_recipe_params_map = HashMap::<String, String>::new();
|
||||
if let Some(params_with_value) = &sub_recipe.values {
|
||||
for (param_name, param_value) in params_with_value {
|
||||
sub_recipe_params_map.insert(param_name.clone(), param_value.clone());
|
||||
}
|
||||
}
|
||||
let parameter_definition = get_sub_recipe_parameter_definition(sub_recipe)?;
|
||||
if let Some(parameters) = parameter_definition {
|
||||
let mut properties = Map::new();
|
||||
let mut required = Vec::new();
|
||||
for param in parameters {
|
||||
if sub_recipe_params_map.contains_key(¶m.key) {
|
||||
continue;
|
||||
}
|
||||
properties.insert(
|
||||
param.key.clone(),
|
||||
json!({
|
||||
"type": param.input_type.to_string(),
|
||||
"description": param.description.clone(),
|
||||
}),
|
||||
);
|
||||
if !matches!(param.requirement, RecipeParameterRequirement::Optional) {
|
||||
required.push(param.key);
|
||||
}
|
||||
}
|
||||
Ok(json!({
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
"required": required
|
||||
}))
|
||||
} else {
|
||||
Ok(json!({
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare_command_params(
|
||||
sub_recipe: &SubRecipe,
|
||||
params_from_tool_call: Value,
|
||||
) -> Result<HashMap<String, String>> {
|
||||
let mut sub_recipe_params = HashMap::<String, String>::new();
|
||||
if let Some(params_with_value) = &sub_recipe.values {
|
||||
for (param_name, param_value) in params_with_value {
|
||||
sub_recipe_params.insert(param_name.clone(), param_value.clone());
|
||||
}
|
||||
}
|
||||
if let Some(params_map) = params_from_tool_call.as_object() {
|
||||
for (key, value) in params_map {
|
||||
sub_recipe_params.insert(
|
||||
key.to_string(),
|
||||
value.as_str().unwrap_or(&value.to_string()).to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(sub_recipe_params)
|
||||
}
|
||||
|
||||
pub async fn run_sub_recipe(sub_recipe: &SubRecipe, params: Value) -> Result<String> {
|
||||
let command_params = prepare_command_params(sub_recipe, params)?;
|
||||
|
||||
let mut command = Command::new("goose");
|
||||
command.arg("run").arg("--recipe").arg(&sub_recipe.path);
|
||||
|
||||
for (key, value) in command_params {
|
||||
command.arg("--params").arg(format!("{}={}", key, value));
|
||||
}
|
||||
|
||||
command.stdout(std::process::Stdio::piped());
|
||||
command.stderr(std::process::Stdio::piped());
|
||||
|
||||
let mut child = command
|
||||
.spawn()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to spawn: {}", e))?;
|
||||
|
||||
let stdout = child.stdout.take().expect("Failed to capture stdout");
|
||||
let stderr = child.stderr.take().expect("Failed to capture stderr");
|
||||
|
||||
let mut stdout_reader = BufReader::new(stdout).lines();
|
||||
let mut stderr_reader = BufReader::new(stderr).lines();
|
||||
let stdout_sub_recipe_name = sub_recipe.name.clone();
|
||||
let stderr_sub_recipe_name = sub_recipe.name.clone();
|
||||
|
||||
// Spawn background tasks to read from stdout and stderr
|
||||
let stdout_task = tokio::spawn(async move {
|
||||
let mut buffer = String::new();
|
||||
while let Ok(Some(line)) = stdout_reader.next_line().await {
|
||||
println!("[sub-recipe {}] {}", stdout_sub_recipe_name, line);
|
||||
buffer.push_str(&line);
|
||||
buffer.push('\n');
|
||||
}
|
||||
buffer
|
||||
});
|
||||
|
||||
let stderr_task = tokio::spawn(async move {
|
||||
let mut buffer = String::new();
|
||||
while let Ok(Some(line)) = stderr_reader.next_line().await {
|
||||
eprintln!(
|
||||
"[stderr for sub-recipe {}] {}",
|
||||
stderr_sub_recipe_name, line
|
||||
);
|
||||
buffer.push_str(&line);
|
||||
buffer.push('\n');
|
||||
}
|
||||
buffer
|
||||
});
|
||||
|
||||
let status = child
|
||||
.wait()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to wait for process: {}", e))?;
|
||||
|
||||
let stdout_output = stdout_task.await.unwrap();
|
||||
let stderr_output = stderr_task.await.unwrap();
|
||||
|
||||
if status.success() {
|
||||
Ok(stdout_output)
|
||||
} else {
|
||||
Err(anyhow::anyhow!("Command failed:\n{}", stderr_output))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,155 @@
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::recipe::SubRecipe;
|
||||
|
||||
fn setup_sub_recipe() -> SubRecipe {
|
||||
let sub_recipe = SubRecipe {
|
||||
name: "test_sub_recipe".to_string(),
|
||||
path: "test_sub_recipe.yaml".to_string(),
|
||||
values: Some(HashMap::from([("key1".to_string(), "value1".to_string())])),
|
||||
};
|
||||
sub_recipe
|
||||
}
|
||||
mod prepare_command_params_tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::{
|
||||
agents::recipe_tools::sub_recipe_tools::{
|
||||
prepare_command_params, tests::tests::setup_sub_recipe,
|
||||
},
|
||||
recipe::SubRecipe,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn test_prepare_command_params_basic() {
|
||||
let mut params = HashMap::new();
|
||||
params.insert("key2".to_string(), "value2".to_string());
|
||||
|
||||
let sub_recipe = setup_sub_recipe();
|
||||
|
||||
let params_value = serde_json::to_value(params).unwrap();
|
||||
let result = prepare_command_params(&sub_recipe, params_value).unwrap();
|
||||
assert_eq!(result.len(), 2);
|
||||
assert_eq!(result.get("key1"), Some(&"value1".to_string()));
|
||||
assert_eq!(result.get("key2"), Some(&"value2".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prepare_command_params_empty() {
|
||||
let sub_recipe = SubRecipe {
|
||||
name: "test_sub_recipe".to_string(),
|
||||
path: "test_sub_recipe.yaml".to_string(),
|
||||
values: None,
|
||||
};
|
||||
let params: HashMap<String, String> = HashMap::new();
|
||||
let params_value = serde_json::to_value(params).unwrap();
|
||||
let result = prepare_command_params(&sub_recipe, params_value).unwrap();
|
||||
assert_eq!(result.len(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
mod get_input_schema_tests {
|
||||
use crate::{
|
||||
agents::recipe_tools::sub_recipe_tools::{
|
||||
get_input_schema, tests::tests::setup_sub_recipe,
|
||||
},
|
||||
recipe::SubRecipe,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn test_get_input_schema_with_parameters() {
|
||||
let sub_recipe = setup_sub_recipe();
|
||||
|
||||
let sub_recipe_file_content = r#"{
|
||||
"version": "1.0.0",
|
||||
"title": "Test Recipe",
|
||||
"description": "A test recipe",
|
||||
"prompt": "Test prompt",
|
||||
"parameters": [
|
||||
{
|
||||
"key": "key1",
|
||||
"input_type": "string",
|
||||
"requirement": "required",
|
||||
"description": "A test parameter"
|
||||
},
|
||||
{
|
||||
"key": "key2",
|
||||
"input_type": "number",
|
||||
"requirement": "optional",
|
||||
"description": "An optional parameter"
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let temp_file = temp_dir.path().join("test_sub_recipe.yaml");
|
||||
std::fs::write(&temp_file, sub_recipe_file_content).unwrap();
|
||||
|
||||
let mut sub_recipe = sub_recipe;
|
||||
sub_recipe.path = temp_file.to_string_lossy().to_string();
|
||||
|
||||
let result = get_input_schema(&sub_recipe).unwrap();
|
||||
|
||||
// Verify the schema structure
|
||||
assert_eq!(result["type"], "object");
|
||||
assert!(result["properties"].is_object());
|
||||
|
||||
let properties = result["properties"].as_object().unwrap();
|
||||
assert_eq!(properties.len(), 1);
|
||||
|
||||
let key2_prop = &properties["key2"];
|
||||
assert_eq!(key2_prop["type"], "number");
|
||||
assert_eq!(key2_prop["description"], "An optional parameter");
|
||||
|
||||
let required = result["required"].as_array().unwrap();
|
||||
assert_eq!(required.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_input_schema_no_parameters_values() {
|
||||
let sub_recipe = SubRecipe {
|
||||
name: "test_sub_recipe".to_string(),
|
||||
path: "test_sub_recipe.yaml".to_string(),
|
||||
values: None,
|
||||
};
|
||||
|
||||
let sub_recipe_file_content = r#"{
|
||||
"version": "1.0.0",
|
||||
"title": "Test Recipe",
|
||||
"description": "A test recipe",
|
||||
"prompt": "Test prompt",
|
||||
"parameters": [
|
||||
{
|
||||
"key": "key1",
|
||||
"input_type": "string",
|
||||
"requirement": "required",
|
||||
"description": "A test parameter"
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let temp_file = temp_dir.path().join("test_sub_recipe.yaml");
|
||||
std::fs::write(&temp_file, sub_recipe_file_content).unwrap();
|
||||
|
||||
let mut sub_recipe = sub_recipe;
|
||||
sub_recipe.path = temp_file.to_string_lossy().to_string();
|
||||
|
||||
let result = get_input_schema(&sub_recipe).unwrap();
|
||||
|
||||
assert_eq!(result["type"], "object");
|
||||
assert!(result["properties"].is_object());
|
||||
|
||||
let properties = result["properties"].as_object().unwrap();
|
||||
assert_eq!(properties.len(), 1);
|
||||
|
||||
let key1_prop = &properties["key1"];
|
||||
assert_eq!(key1_prop["type"], "string");
|
||||
assert_eq!(key1_prop["description"], "A test parameter");
|
||||
assert_eq!(result["required"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(result["required"][0], "key1");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
use mcp_core::{Content, Tool, ToolError};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::{
|
||||
agents::{
|
||||
recipe_tools::sub_recipe_tools::{
|
||||
create_sub_recipe_tool, run_sub_recipe, SUB_RECIPE_TOOL_NAME_PREFIX,
|
||||
},
|
||||
tool_execution::ToolCallResult,
|
||||
},
|
||||
recipe::SubRecipe,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SubRecipeManager {
|
||||
pub sub_recipe_tools: HashMap<String, Tool>,
|
||||
pub sub_recipes: HashMap<String, SubRecipe>,
|
||||
}
|
||||
|
||||
impl Default for SubRecipeManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl SubRecipeManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sub_recipe_tools: HashMap::new(),
|
||||
sub_recipes: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_sub_recipe_tools(&mut self, sub_recipes_to_add: Vec<SubRecipe>) {
|
||||
for sub_recipe in sub_recipes_to_add {
|
||||
let sub_recipe_key = format!(
|
||||
"{}_{}",
|
||||
SUB_RECIPE_TOOL_NAME_PREFIX,
|
||||
sub_recipe.name.clone()
|
||||
);
|
||||
let tool = create_sub_recipe_tool(&sub_recipe);
|
||||
self.sub_recipe_tools.insert(sub_recipe_key.clone(), tool);
|
||||
self.sub_recipes.insert(sub_recipe_key.clone(), sub_recipe);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_sub_recipe_tool(&self, tool_name: &str) -> bool {
|
||||
self.sub_recipe_tools.contains_key(tool_name)
|
||||
}
|
||||
|
||||
pub async fn dispatch_sub_recipe_tool_call(
|
||||
&self,
|
||||
tool_name: &str,
|
||||
params: Value,
|
||||
) -> ToolCallResult {
|
||||
let result = self.call_sub_recipe_tool(tool_name, params).await;
|
||||
match result {
|
||||
Ok(call_result) => ToolCallResult::from(Ok(call_result)),
|
||||
Err(e) => ToolCallResult::from(Err(ToolError::ExecutionError(e.to_string()))),
|
||||
}
|
||||
}
|
||||
|
||||
async fn call_sub_recipe_tool(
|
||||
&self,
|
||||
tool_name: &str,
|
||||
params: Value,
|
||||
) -> Result<Vec<Content>, ToolError> {
|
||||
let sub_recipe = self.sub_recipes.get(tool_name).ok_or_else(|| {
|
||||
let sub_recipe_name = tool_name
|
||||
.strip_prefix(SUB_RECIPE_TOOL_NAME_PREFIX)
|
||||
.and_then(|s| s.strip_prefix("_"))
|
||||
.ok_or_else(|| {
|
||||
ToolError::InvalidParameters(format!(
|
||||
"Invalid sub-recipe tool name format: {}",
|
||||
tool_name
|
||||
))
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
ToolError::InvalidParameters(format!("Sub-recipe '{}' not found", sub_recipe_name))
|
||||
})?;
|
||||
|
||||
let output = run_sub_recipe(sub_recipe, params).await.map_err(|e| {
|
||||
ToolError::ExecutionError(format!("Sub-recipe execution failed: {}", e))
|
||||
})?;
|
||||
Ok(vec![Content::text(output)])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user