Unify subrecipe and subagent execution through shared recipe pipeline (#5082)
This commit is contained in:
@@ -166,7 +166,6 @@ mod tests {
|
|||||||
available_tools: Vec::new(),
|
available_tools: Vec::new(),
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
context: None,
|
|
||||||
settings: None,
|
settings: None,
|
||||||
activities: None,
|
activities: None,
|
||||||
author: None,
|
author: None,
|
||||||
@@ -210,7 +209,6 @@ mod tests {
|
|||||||
instructions: Some("Test instructions".to_string()),
|
instructions: Some("Test instructions".to_string()),
|
||||||
prompt: None,
|
prompt: None,
|
||||||
extensions: None,
|
extensions: None,
|
||||||
context: None,
|
|
||||||
settings: None,
|
settings: None,
|
||||||
activities: None,
|
activities: None,
|
||||||
author: None,
|
author: None,
|
||||||
@@ -255,7 +253,6 @@ mod tests {
|
|||||||
available_tools: Vec::new(),
|
available_tools: Vec::new(),
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
context: None,
|
|
||||||
settings: None,
|
settings: None,
|
||||||
activities: None,
|
activities: None,
|
||||||
author: None,
|
author: None,
|
||||||
@@ -309,7 +306,6 @@ mod tests {
|
|||||||
sequential_when_repeated: false,
|
sequential_when_repeated: false,
|
||||||
description: None,
|
description: None,
|
||||||
}]),
|
}]),
|
||||||
context: None,
|
|
||||||
settings: None,
|
settings: None,
|
||||||
activities: None,
|
activities: None,
|
||||||
author: None,
|
author: None,
|
||||||
|
|||||||
@@ -283,13 +283,13 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession {
|
|||||||
// Create the agent
|
// Create the agent
|
||||||
let agent: Agent = Agent::new();
|
let agent: Agent = Agent::new();
|
||||||
|
|
||||||
if let Some(sub_recipes) = session_config.sub_recipes {
|
agent
|
||||||
agent.add_sub_recipes(sub_recipes).await;
|
.apply_recipe_components(
|
||||||
}
|
session_config.sub_recipes,
|
||||||
|
session_config.final_output_response,
|
||||||
if let Some(final_output_response) = session_config.final_output_response {
|
true,
|
||||||
agent.add_final_output_tool(final_output_response).await;
|
)
|
||||||
}
|
.await;
|
||||||
|
|
||||||
let new_provider = match create(&provider_name, model_config).await {
|
let new_provider = match create(&provider_name, model_config).await {
|
||||||
Ok(provider) => provider,
|
Ok(provider) => provider,
|
||||||
|
|||||||
@@ -155,15 +155,13 @@ pub async fn apply_recipe_to_agent(
|
|||||||
recipe: &Recipe,
|
recipe: &Recipe,
|
||||||
include_final_output_tool: bool,
|
include_final_output_tool: bool,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
if let Some(sub_recipes) = &recipe.sub_recipes {
|
agent
|
||||||
agent.add_sub_recipes(sub_recipes.clone()).await;
|
.apply_recipe_components(
|
||||||
}
|
recipe.sub_recipes.clone(),
|
||||||
|
recipe.response.clone(),
|
||||||
if include_final_output_tool {
|
include_final_output_tool,
|
||||||
if let Some(response) = &recipe.response {
|
)
|
||||||
agent.add_final_output_tool(response.clone()).await;
|
.await;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
recipe.instructions.as_ref().map(|instructions| {
|
recipe.instructions.as_ref().map(|instructions| {
|
||||||
let mut context: HashMap<&str, Value> = HashMap::new();
|
let mut context: HashMap<&str, Value> = HashMap::new();
|
||||||
|
|||||||
@@ -387,6 +387,23 @@ impl Agent {
|
|||||||
sub_recipe_manager.add_sub_recipe_tools(sub_recipes);
|
sub_recipe_manager.add_sub_recipe_tools(sub_recipes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn apply_recipe_components(
|
||||||
|
&self,
|
||||||
|
sub_recipes: Option<Vec<SubRecipe>>,
|
||||||
|
response: Option<Response>,
|
||||||
|
include_final_output: bool,
|
||||||
|
) {
|
||||||
|
if let Some(sub_recipes) = sub_recipes {
|
||||||
|
self.add_sub_recipes(sub_recipes).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
if include_final_output {
|
||||||
|
if let Some(response) = response {
|
||||||
|
self.add_final_output_tool(response).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Dispatch a single tool call to the appropriate client
|
/// Dispatch a single tool call to the appropriate client
|
||||||
#[instrument(skip(self, tool_call, request_id), fields(input, output))]
|
#[instrument(skip(self, tool_call, request_id), fields(input, output))]
|
||||||
pub async fn dispatch_tool_call(
|
pub async fn dispatch_tool_call(
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use crate::agents::extension::ExtensionConfig;
|
|||||||
use crate::agents::subagent_execution_tool::tasks_manager::TasksManager;
|
use crate::agents::subagent_execution_tool::tasks_manager::TasksManager;
|
||||||
use crate::agents::subagent_execution_tool::{
|
use crate::agents::subagent_execution_tool::{
|
||||||
lib::ExecutionMode,
|
lib::ExecutionMode,
|
||||||
task_types::{Task, TaskType},
|
task_types::{Task, TaskPayload},
|
||||||
};
|
};
|
||||||
use crate::agents::tool_execution::ToolCallResult;
|
use crate::agents::tool_execution::ToolCallResult;
|
||||||
use crate::config::GooseMode;
|
use crate::config::GooseMode;
|
||||||
@@ -81,9 +81,6 @@ pub struct TaskParameter {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub retry: Option<JsonObject>,
|
pub retry: Option<JsonObject>,
|
||||||
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub context: Option<Vec<String>>,
|
|
||||||
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub activities: Option<Vec<String>>,
|
pub activities: Option<Vec<String>>,
|
||||||
|
|
||||||
@@ -116,7 +113,7 @@ pub fn create_dynamic_task_tool() -> Tool {
|
|||||||
|
|
||||||
Tool::new(
|
Tool::new(
|
||||||
DYNAMIC_TASK_TOOL_NAME_PREFIX.to_string(),
|
DYNAMIC_TASK_TOOL_NAME_PREFIX.to_string(),
|
||||||
"Create tasks with instructions or prompt. For simple tasks, only include the instructions field. Extensions control: omit field = use all current extensions; empty array [] = no extensions; array with names = only those extensions. Specify extensions as shortnames (the prefixes for your tools). Specify return_last_only as true and have your subagent summarize its work in its last message to conserve your own context. Optional: title, description, extensions, settings, retry, response schema, context, activities. Arrays for multiple tasks.".to_string(),
|
"Create tasks with instructions or prompt. For simple tasks, only include the instructions field. Extensions control: omit field = use all current extensions; empty array [] = no extensions; array with names = only those extensions. Specify extensions as shortnames (the prefixes for your tools). Specify return_last_only as true and have your subagent summarize its work in its last message to conserve your own context. Optional: title, description, extensions, settings, retry, response schema, activities. Arrays for multiple tasks.".to_string(),
|
||||||
input_schema,
|
input_schema,
|
||||||
).annotate(ToolAnnotations {
|
).annotate(ToolAnnotations {
|
||||||
title: Some("Create Dynamic Tasks".to_string()),
|
title: Some("Create Dynamic Tasks".to_string()),
|
||||||
@@ -228,7 +225,6 @@ pub fn task_params_to_inline_recipe(
|
|||||||
builder = apply_if_ok(builder, task_param.get("settings"), RecipeBuilder::settings);
|
builder = apply_if_ok(builder, task_param.get("settings"), RecipeBuilder::settings);
|
||||||
builder = apply_if_ok(builder, task_param.get("response"), RecipeBuilder::response);
|
builder = apply_if_ok(builder, task_param.get("response"), RecipeBuilder::response);
|
||||||
builder = apply_if_ok(builder, task_param.get("retry"), RecipeBuilder::retry);
|
builder = apply_if_ok(builder, task_param.get("retry"), RecipeBuilder::retry);
|
||||||
builder = apply_if_ok(builder, task_param.get("context"), RecipeBuilder::context);
|
|
||||||
builder = apply_if_ok(
|
builder = apply_if_ok(
|
||||||
builder,
|
builder,
|
||||||
task_param.get("activities"),
|
task_param.get("activities"),
|
||||||
@@ -297,17 +293,6 @@ pub async fn create_dynamic_task(
|
|||||||
// All tasks must use the new inline recipe path
|
// All tasks must use the new inline recipe path
|
||||||
match task_params_to_inline_recipe(task_param, &loaded_extensions) {
|
match task_params_to_inline_recipe(task_param, &loaded_extensions) {
|
||||||
Ok(recipe) => {
|
Ok(recipe) => {
|
||||||
let recipe_json = match serde_json::to_value(&recipe) {
|
|
||||||
Ok(json) => json,
|
|
||||||
Err(e) => {
|
|
||||||
return ToolCallResult::from(Err(ErrorData {
|
|
||||||
code: ErrorCode::INTERNAL_ERROR,
|
|
||||||
message: Cow::from(format!("Failed to serialize recipe: {}", e)),
|
|
||||||
data: None,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Extract return_last_only flag if present
|
// Extract return_last_only flag if present
|
||||||
let return_last_only = task_param
|
let return_last_only = task_param
|
||||||
.get("return_last_only")
|
.get("return_last_only")
|
||||||
@@ -316,11 +301,12 @@ pub async fn create_dynamic_task(
|
|||||||
|
|
||||||
let task = Task {
|
let task = Task {
|
||||||
id: uuid::Uuid::new_v4().to_string(),
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
task_type: TaskType::InlineRecipe,
|
payload: TaskPayload {
|
||||||
payload: json!({
|
recipe,
|
||||||
"recipe": recipe_json,
|
return_last_only,
|
||||||
"return_last_only": return_last_only
|
sequential_when_repeated: false,
|
||||||
}),
|
parameter_values: None,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
tasks.push(task);
|
tasks.push(task);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ use rmcp::model::{Tool, ToolAnnotations};
|
|||||||
use serde_json::{json, Map, Value};
|
use serde_json::{json, Map, Value};
|
||||||
|
|
||||||
use crate::agents::subagent_execution_tool::lib::ExecutionMode;
|
use crate::agents::subagent_execution_tool::lib::ExecutionMode;
|
||||||
use crate::agents::subagent_execution_tool::task_types::{Task, TaskType};
|
use crate::agents::subagent_execution_tool::task_types::{Task, TaskPayload};
|
||||||
use crate::agents::subagent_execution_tool::tasks_manager::TasksManager;
|
use crate::agents::subagent_execution_tool::tasks_manager::TasksManager;
|
||||||
|
use crate::recipe::build_recipe::build_recipe_from_template;
|
||||||
|
use crate::recipe::local_recipes::load_local_recipe_file;
|
||||||
use crate::recipe::{Recipe, RecipeParameter, RecipeParameterRequirement, SubRecipe};
|
use crate::recipe::{Recipe, RecipeParameter, RecipeParameterRequirement, SubRecipe};
|
||||||
|
|
||||||
use super::param_utils::prepare_command_params;
|
use super::param_utils::prepare_command_params;
|
||||||
@@ -54,27 +56,37 @@ fn extract_task_parameters(params: &Value) -> Vec<Value> {
|
|||||||
fn create_tasks_from_params(
|
fn create_tasks_from_params(
|
||||||
sub_recipe: &SubRecipe,
|
sub_recipe: &SubRecipe,
|
||||||
command_params: &[std::collections::HashMap<String, String>],
|
command_params: &[std::collections::HashMap<String, String>],
|
||||||
) -> Vec<Task> {
|
) -> Result<Vec<Task>> {
|
||||||
let tasks: Vec<Task> = command_params
|
let recipe_file = load_local_recipe_file(&sub_recipe.path)
|
||||||
.iter()
|
.map_err(|e| anyhow::anyhow!("Failed to load recipe {}: {}", sub_recipe.path, e))?;
|
||||||
.map(|task_command_param| {
|
|
||||||
let payload = json!({
|
|
||||||
"sub_recipe": {
|
|
||||||
"name": sub_recipe.name.clone(),
|
|
||||||
"command_parameters": task_command_param,
|
|
||||||
"recipe_path": sub_recipe.path.clone(),
|
|
||||||
"sequential_when_repeated": sub_recipe.sequential_when_repeated
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Task {
|
|
||||||
id: uuid::Uuid::new_v4().to_string(),
|
|
||||||
task_type: TaskType::SubRecipe,
|
|
||||||
payload,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
tasks
|
let mut tasks = Vec::new();
|
||||||
|
for task_command_param in command_params {
|
||||||
|
let recipe = build_recipe_from_template(
|
||||||
|
recipe_file.content.clone(),
|
||||||
|
&recipe_file.parent_dir,
|
||||||
|
task_command_param
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.clone(), v.clone()))
|
||||||
|
.collect(),
|
||||||
|
None::<fn(&str, &str) -> Result<String, anyhow::Error>>,
|
||||||
|
)
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to build recipe: {}", e))?;
|
||||||
|
|
||||||
|
let task = Task {
|
||||||
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
|
payload: TaskPayload {
|
||||||
|
recipe,
|
||||||
|
return_last_only: false,
|
||||||
|
sequential_when_repeated: sub_recipe.sequential_when_repeated,
|
||||||
|
parameter_values: Some(task_command_param.clone()),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
tasks.push(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(tasks)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_task_execution_payload(tasks: &[Task], sub_recipe: &SubRecipe) -> Value {
|
fn create_task_execution_payload(tasks: &[Task], sub_recipe: &SubRecipe) -> Value {
|
||||||
@@ -97,7 +109,7 @@ pub async fn create_sub_recipe_task(
|
|||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
let task_params_array = extract_task_parameters(¶ms);
|
let task_params_array = extract_task_parameters(¶ms);
|
||||||
let command_params = prepare_command_params(sub_recipe, task_params_array.clone())?;
|
let command_params = prepare_command_params(sub_recipe, task_params_array.clone())?;
|
||||||
let tasks = create_tasks_from_params(sub_recipe, &command_params);
|
let tasks = create_tasks_from_params(sub_recipe, &command_params)?;
|
||||||
let task_execution_payload = create_task_execution_payload(&tasks, sub_recipe);
|
let task_execution_payload = create_task_execution_payload(&tasks, sub_recipe);
|
||||||
|
|
||||||
let tasks_json = serde_json::to_string(&task_execution_payload)
|
let tasks_json = serde_json::to_string(&task_execution_payload)
|
||||||
|
|||||||
@@ -33,7 +33,11 @@ pub async fn execute_tasks(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ExecutionMode::Parallel => {
|
ExecutionMode::Parallel => {
|
||||||
if tasks.iter().any(|task| task.get_sequential_when_repeated()) {
|
let any_sequential = tasks
|
||||||
|
.iter()
|
||||||
|
.any(|task| task.payload.sequential_when_repeated);
|
||||||
|
|
||||||
|
if any_sequential {
|
||||||
Ok(json!(
|
Ok(json!(
|
||||||
{
|
{
|
||||||
"execution_mode": ExecutionMode::Sequential,
|
"execution_mode": ExecutionMode::Sequential,
|
||||||
|
|||||||
@@ -15,9 +15,10 @@ use crate::agents::subagent_execution_tool::notification_events::{
|
|||||||
use crate::agents::subagent_execution_tool::task_types::{Task, TaskInfo, TaskResult, TaskStatus};
|
use crate::agents::subagent_execution_tool::task_types::{Task, TaskInfo, TaskResult, TaskStatus};
|
||||||
use crate::agents::subagent_execution_tool::utils::{count_by_status, get_task_name};
|
use crate::agents::subagent_execution_tool::utils::{count_by_status, get_task_name};
|
||||||
use crate::utils::is_token_cancelled;
|
use crate::utils::is_token_cancelled;
|
||||||
use serde_json::Value;
|
|
||||||
use tokio::sync::mpsc::Sender;
|
use tokio::sync::mpsc::Sender;
|
||||||
|
|
||||||
|
const RECIPE_TASK_TYPE: &str = "recipe";
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum DisplayMode {
|
pub enum DisplayMode {
|
||||||
MultipleTasksOutput,
|
MultipleTasksOutput,
|
||||||
@@ -28,25 +29,22 @@ const THROTTLE_INTERVAL_MS: u64 = 250;
|
|||||||
const COMPLETION_NOTIFICATION_DELAY_MS: u64 = 500;
|
const COMPLETION_NOTIFICATION_DELAY_MS: u64 = 500;
|
||||||
|
|
||||||
fn format_task_metadata(task_info: &TaskInfo) -> String {
|
fn format_task_metadata(task_info: &TaskInfo) -> String {
|
||||||
if let Some(params) = task_info.task.get_command_parameters() {
|
// If we have parameter values, format them nicely
|
||||||
if params.is_empty() {
|
if let Some(ref params) = task_info.task.payload.parameter_values {
|
||||||
return String::new();
|
if !params.is_empty() {
|
||||||
|
let mut param_strs: Vec<String> = params
|
||||||
|
.iter()
|
||||||
|
.filter(|(k, _)| k.as_str() != "recipe_dir")
|
||||||
|
.map(|(k, v)| format!("{}={}", k, v))
|
||||||
|
.collect();
|
||||||
|
if !param_strs.is_empty() {
|
||||||
|
param_strs.sort();
|
||||||
|
return param_strs.join(", ");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
params
|
|
||||||
.iter()
|
|
||||||
.map(|(key, value)| {
|
|
||||||
let value_str = match value {
|
|
||||||
Value::String(s) => s.clone(),
|
|
||||||
_ => value.to_string(),
|
|
||||||
};
|
|
||||||
format!("{}={}", key, value_str)
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(",")
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
}
|
}
|
||||||
|
// Fallback to recipe title if no parameters
|
||||||
|
task_info.task.payload.recipe.title.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct TaskExecutionTracker {
|
pub struct TaskExecutionTracker {
|
||||||
@@ -151,13 +149,15 @@ impl TaskExecutionTracker {
|
|||||||
async fn format_line(&self, task_info: Option<&TaskInfo>, line: &str) -> String {
|
async fn format_line(&self, task_info: Option<&TaskInfo>, line: &str) -> String {
|
||||||
if let Some(task_info) = task_info {
|
if let Some(task_info) = task_info {
|
||||||
let task_name = get_task_name(task_info);
|
let task_name = get_task_name(task_info);
|
||||||
let task_type = task_info.task.task_type.clone();
|
|
||||||
let metadata = format_task_metadata(task_info);
|
let metadata = format_task_metadata(task_info);
|
||||||
|
|
||||||
if metadata.is_empty() {
|
if metadata.is_empty() {
|
||||||
format!("[{} ({})] {}", task_name, task_type, line)
|
format!("[{} ({})] {}", task_name, RECIPE_TASK_TYPE, line)
|
||||||
} else {
|
} else {
|
||||||
format!("[{} ({}) {}] {}", task_name, task_type, metadata, line)
|
format!(
|
||||||
|
"[{} ({}) {}] {}",
|
||||||
|
task_name, RECIPE_TASK_TYPE, metadata, line
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
line.to_string()
|
line.to_string()
|
||||||
@@ -232,7 +232,7 @@ impl TaskExecutionTracker {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
current_output: task_info.current_output.clone(),
|
current_output: task_info.current_output.clone(),
|
||||||
task_type: task_info.task.task_type.to_string(),
|
task_type: RECIPE_TASK_TYPE.to_string(),
|
||||||
task_name: get_task_name(task_info).to_string(),
|
task_name: get_task_name(task_info).to_string(),
|
||||||
task_metadata: format_task_metadata(task_info),
|
task_metadata: format_task_metadata(task_info),
|
||||||
error: task_info.error().cloned(),
|
error: task_info.error().cloned(),
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{Map, Value};
|
use serde_json::Value;
|
||||||
use std::fmt;
|
use std::collections::HashMap;
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
use crate::agents::subagent_execution_tool::task_execution_tracker::TaskExecutionTracker;
|
use crate::agents::subagent_execution_tool::task_execution_tracker::TaskExecutionTracker;
|
||||||
|
use crate::recipe::Recipe;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
@@ -16,59 +17,19 @@ pub enum ExecutionMode {
|
|||||||
Parallel,
|
Parallel,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
pub struct TaskPayload {
|
||||||
pub enum TaskType {
|
pub recipe: Recipe,
|
||||||
InlineRecipe,
|
pub return_last_only: bool,
|
||||||
SubRecipe,
|
pub sequential_when_repeated: bool,
|
||||||
}
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub parameter_values: Option<HashMap<String, String>>,
|
||||||
impl fmt::Display for TaskType {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
match self {
|
|
||||||
TaskType::InlineRecipe => write!(f, "inline_recipe"),
|
|
||||||
TaskType::SubRecipe => write!(f, "sub_recipe"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Task {
|
pub struct Task {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub task_type: TaskType,
|
pub payload: TaskPayload,
|
||||||
pub payload: Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Task {
|
|
||||||
pub fn get_sub_recipe(&self) -> Option<&Map<String, Value>> {
|
|
||||||
matches!(self.task_type, TaskType::SubRecipe)
|
|
||||||
.then(|| self.payload.get("sub_recipe")?.as_object())
|
|
||||||
.flatten()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_command_parameters(&self) -> Option<&Map<String, Value>> {
|
|
||||||
self.get_sub_recipe()
|
|
||||||
.and_then(|sr| sr.get("command_parameters"))
|
|
||||||
.and_then(|cp| cp.as_object())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_sequential_when_repeated(&self) -> bool {
|
|
||||||
self.get_sub_recipe()
|
|
||||||
.and_then(|sr| sr.get("sequential_when_repeated").and_then(|v| v.as_bool()))
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_sub_recipe_name(&self) -> Option<&str> {
|
|
||||||
self.get_sub_recipe()
|
|
||||||
.and_then(|sr| sr.get("name"))
|
|
||||||
.and_then(|name| name.as_str())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_sub_recipe_path(&self) -> Option<&str> {
|
|
||||||
self.get_sub_recipe()
|
|
||||||
.and_then(|sr| sr.get("recipe_path"))
|
|
||||||
.and_then(|path| path.as_str())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -1,29 +1,18 @@
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::process::Stdio;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
|
||||||
use tokio::process::Command;
|
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
use crate::agents::subagent_execution_tool::task_execution_tracker::TaskExecutionTracker;
|
use crate::agents::subagent_execution_tool::task_execution_tracker::TaskExecutionTracker;
|
||||||
use crate::agents::subagent_execution_tool::task_types::{Task, TaskResult, TaskStatus, TaskType};
|
use crate::agents::subagent_execution_tool::task_types::{Task, TaskResult, TaskStatus};
|
||||||
use crate::agents::subagent_execution_tool::utils::strip_ansi_codes;
|
|
||||||
use crate::agents::subagent_task_config::TaskConfig;
|
use crate::agents::subagent_task_config::TaskConfig;
|
||||||
|
|
||||||
pub async fn process_task(
|
pub async fn process_task(
|
||||||
task: &Task,
|
task: &Task,
|
||||||
task_execution_tracker: Arc<TaskExecutionTracker>,
|
_task_execution_tracker: Arc<TaskExecutionTracker>,
|
||||||
task_config: TaskConfig,
|
task_config: TaskConfig,
|
||||||
cancellation_token: CancellationToken,
|
cancellation_token: CancellationToken,
|
||||||
) -> TaskResult {
|
) -> TaskResult {
|
||||||
match get_task_result(
|
match handle_recipe_task(task.clone(), task_config, cancellation_token).await {
|
||||||
task.clone(),
|
|
||||||
task_execution_tracker,
|
|
||||||
task_config,
|
|
||||||
cancellation_token,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(data) => TaskResult {
|
Ok(data) => TaskResult {
|
||||||
task_id: task.id.clone(),
|
task_id: task.id.clone(),
|
||||||
status: TaskStatus::Completed,
|
status: TaskStatus::Completed,
|
||||||
@@ -39,234 +28,67 @@ pub async fn process_task(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_task_result(
|
async fn handle_recipe_task(
|
||||||
task: Task,
|
|
||||||
task_execution_tracker: Arc<TaskExecutionTracker>,
|
|
||||||
task_config: TaskConfig,
|
|
||||||
cancellation_token: CancellationToken,
|
|
||||||
) -> Result<Value, String> {
|
|
||||||
match task.task_type {
|
|
||||||
TaskType::InlineRecipe => {
|
|
||||||
handle_inline_recipe_task(task, task_config, cancellation_token).await
|
|
||||||
}
|
|
||||||
TaskType::SubRecipe => {
|
|
||||||
let (command, output_identifier) = build_command(&task)?;
|
|
||||||
let (stdout_output, stderr_output, success) = run_command(
|
|
||||||
command,
|
|
||||||
&output_identifier,
|
|
||||||
&task.id,
|
|
||||||
task_execution_tracker,
|
|
||||||
cancellation_token,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if success {
|
|
||||||
process_output(stdout_output)
|
|
||||||
} else {
|
|
||||||
Err(format!("Command failed:\n{}", &stderr_output))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_inline_recipe_task(
|
|
||||||
task: Task,
|
task: Task,
|
||||||
mut task_config: TaskConfig,
|
mut task_config: TaskConfig,
|
||||||
cancellation_token: CancellationToken,
|
cancellation_token: CancellationToken,
|
||||||
) -> Result<Value, String> {
|
) -> Result<Value, String> {
|
||||||
use crate::agents::subagent_handler::run_complete_subagent_task;
|
use crate::agents::subagent_handler::run_complete_subagent_task;
|
||||||
use crate::recipe::Recipe;
|
use crate::model::ModelConfig;
|
||||||
|
use crate::providers;
|
||||||
|
|
||||||
let recipe_value = task
|
let recipe = task.payload.recipe;
|
||||||
.payload
|
let return_last_only = task.payload.return_last_only;
|
||||||
.get("recipe")
|
|
||||||
.ok_or_else(|| "Missing recipe in inline_recipe task payload".to_string())?;
|
|
||||||
|
|
||||||
let recipe: Recipe = serde_json::from_value(recipe_value.clone())
|
if let Some(ref exts) = recipe.extensions {
|
||||||
.map_err(|e| format!("Invalid recipe in payload: {}", e))?;
|
|
||||||
|
|
||||||
let return_last_only = task
|
|
||||||
.payload
|
|
||||||
.get("return_last_only")
|
|
||||||
.and_then(|v| v.as_bool())
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
// If extensions are explicitly provided in the recipe (even if empty),
|
|
||||||
// override the task_config extensions. Empty array means no extensions.
|
|
||||||
if let Some(exts) = recipe.extensions {
|
|
||||||
task_config.extensions = exts.clone();
|
task_config.extensions = exts.clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
let instruction = recipe
|
if let Some(ref settings) = recipe.settings {
|
||||||
.instructions
|
let new_provider = match (
|
||||||
.or(recipe.prompt)
|
&settings.goose_provider,
|
||||||
.ok_or_else(|| "No instructions or prompt in recipe".to_string())?;
|
&settings.goose_model,
|
||||||
|
settings.temperature,
|
||||||
let result = tokio::select! {
|
) {
|
||||||
result = run_complete_subagent_task(
|
(Some(provider), Some(model), temp) => {
|
||||||
instruction,
|
let config = ModelConfig::new_or_fail(model).with_temperature(temp);
|
||||||
task_config,
|
Some((provider.clone(), config))
|
||||||
return_last_only,
|
|
||||||
) => result,
|
|
||||||
_ = cancellation_token.cancelled() => {
|
|
||||||
return Err("Task cancelled".to_string());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(result_text) => Ok(serde_json::json!({
|
|
||||||
"result": result_text
|
|
||||||
})),
|
|
||||||
Err(e) => {
|
|
||||||
let error_msg = format!("Inline recipe execution failed: {}", e);
|
|
||||||
Err(error_msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_command(task: &Task) -> Result<(Command, String), String> {
|
|
||||||
let task_error = |field: &str| format!("Task {}: Missing {}", task.id, field);
|
|
||||||
|
|
||||||
if !matches!(task.task_type, TaskType::SubRecipe) {
|
|
||||||
return Err("Only sub-recipe tasks can be executed as commands".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let sub_recipe_name = task
|
|
||||||
.get_sub_recipe_name()
|
|
||||||
.ok_or_else(|| task_error("sub_recipe name"))?;
|
|
||||||
let path = task
|
|
||||||
.get_sub_recipe_path()
|
|
||||||
.ok_or_else(|| task_error("sub_recipe path"))?;
|
|
||||||
let command_parameters = task
|
|
||||||
.get_command_parameters()
|
|
||||||
.ok_or_else(|| task_error("command_parameters"))?;
|
|
||||||
|
|
||||||
let mut command = Command::new("goose");
|
|
||||||
command
|
|
||||||
.arg("run")
|
|
||||||
.arg("--recipe")
|
|
||||||
.arg(path)
|
|
||||||
.arg("--no-session");
|
|
||||||
|
|
||||||
for (key, value) in command_parameters {
|
|
||||||
let key_str = key.to_string();
|
|
||||||
let value_str = value.as_str().unwrap_or(&value.to_string()).to_string();
|
|
||||||
command
|
|
||||||
.arg("--params")
|
|
||||||
.arg(format!("{}={}", key_str, value_str));
|
|
||||||
}
|
|
||||||
|
|
||||||
command.stdout(Stdio::piped());
|
|
||||||
command.stderr(Stdio::piped());
|
|
||||||
|
|
||||||
Ok((command, format!("sub-recipe {}", sub_recipe_name)))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn run_command(
|
|
||||||
mut command: Command,
|
|
||||||
output_identifier: &str,
|
|
||||||
task_id: &str,
|
|
||||||
task_execution_tracker: Arc<TaskExecutionTracker>,
|
|
||||||
cancellation_token: CancellationToken,
|
|
||||||
) -> Result<(String, String, bool), String> {
|
|
||||||
let mut child = command
|
|
||||||
.spawn()
|
|
||||||
.map_err(|e| format!("Failed to spawn goose: {}", e))?;
|
|
||||||
|
|
||||||
let stdout = child.stdout.take().expect("Failed to capture stdout");
|
|
||||||
let stderr = child.stderr.take().expect("Failed to capture stderr");
|
|
||||||
|
|
||||||
let stdout_task = spawn_output_reader(
|
|
||||||
stdout,
|
|
||||||
output_identifier,
|
|
||||||
false,
|
|
||||||
task_id,
|
|
||||||
task_execution_tracker.clone(),
|
|
||||||
);
|
|
||||||
let stderr_task = spawn_output_reader(
|
|
||||||
stderr,
|
|
||||||
output_identifier,
|
|
||||||
true,
|
|
||||||
task_id,
|
|
||||||
task_execution_tracker.clone(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let result = tokio::select! {
|
|
||||||
_ = cancellation_token.cancelled() => {
|
|
||||||
if let Err(e) = child.kill().await {
|
|
||||||
tracing::warn!("Failed to kill child process: {}", e);
|
|
||||||
}
|
}
|
||||||
|
(Some(_), None, _) => {
|
||||||
stdout_task.abort();
|
return Err("Recipe specifies provider but no model".to_string());
|
||||||
stderr_task.abort();
|
|
||||||
return Err("Command cancelled".to_string());
|
|
||||||
}
|
|
||||||
status_result = child.wait() => {
|
|
||||||
status_result.map_err(|e| format!("Failed to wait for process: {}", e))?
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let stdout_output = stdout_task.await.unwrap();
|
|
||||||
let stderr_output = stderr_task.await.unwrap();
|
|
||||||
|
|
||||||
Ok((stdout_output, stderr_output, result.success()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn spawn_output_reader(
|
|
||||||
reader: impl tokio::io::AsyncRead + Unpin + Send + 'static,
|
|
||||||
output_identifier: &str,
|
|
||||||
is_stderr: bool,
|
|
||||||
task_id: &str,
|
|
||||||
task_execution_tracker: Arc<TaskExecutionTracker>,
|
|
||||||
) -> tokio::task::JoinHandle<String> {
|
|
||||||
let output_identifier = output_identifier.to_string();
|
|
||||||
let task_id = task_id.to_string();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut buffer = String::new();
|
|
||||||
let mut lines = BufReader::new(reader).lines();
|
|
||||||
while let Ok(Some(line)) = lines.next_line().await {
|
|
||||||
let line = strip_ansi_codes(&line);
|
|
||||||
buffer.push_str(&line);
|
|
||||||
buffer.push('\n');
|
|
||||||
|
|
||||||
if !is_stderr {
|
|
||||||
task_execution_tracker
|
|
||||||
.send_live_output(&task_id, &line)
|
|
||||||
.await;
|
|
||||||
} else {
|
|
||||||
tracing::warn!("Task stderr [{}]: {}", output_identifier, line);
|
|
||||||
}
|
}
|
||||||
|
(None, model_or_temp, _)
|
||||||
|
if model_or_temp.is_some() || settings.temperature.is_some() =>
|
||||||
|
{
|
||||||
|
let provider_name = task_config.provider.get_name().to_string();
|
||||||
|
let mut config = task_config.provider.get_model_config();
|
||||||
|
|
||||||
|
if let Some(model) = &settings.goose_model {
|
||||||
|
config.model_name = model.clone();
|
||||||
|
}
|
||||||
|
if let Some(temp) = settings.temperature {
|
||||||
|
config = config.with_temperature(Some(temp));
|
||||||
|
}
|
||||||
|
|
||||||
|
Some((provider_name, config))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some((provider_name, model_config)) = new_provider {
|
||||||
|
task_config.provider = providers::create(&provider_name, model_config)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to create provider '{}': {}", provider_name, e))?;
|
||||||
}
|
}
|
||||||
buffer
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_json_from_line(line: &str) -> Option<String> {
|
|
||||||
let start = line.find('{')?;
|
|
||||||
let end = line.rfind('}')?;
|
|
||||||
|
|
||||||
if start >= end {
|
|
||||||
return None;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let potential_json = line.get(start..=end)?;
|
tokio::select! {
|
||||||
if serde_json::from_str::<Value>(potential_json).is_ok() {
|
result = run_complete_subagent_task(recipe, task_config, return_last_only) => {
|
||||||
Some(potential_json.to_string())
|
result.map(|text| serde_json::json!({"result": text}))
|
||||||
} else {
|
.map_err(|e| format!("Recipe execution failed: {}", e))
|
||||||
None
|
}
|
||||||
}
|
_ = cancellation_token.cancelled() => {
|
||||||
}
|
Err("Task cancelled".to_string())
|
||||||
|
}
|
||||||
fn process_output(stdout_output: String) -> Result<Value, String> {
|
|
||||||
let last_line = stdout_output
|
|
||||||
.lines()
|
|
||||||
.filter(|line| !line.trim().is_empty())
|
|
||||||
.next_back()
|
|
||||||
.unwrap_or("");
|
|
||||||
|
|
||||||
if let Some(json_string) = extract_json_from_line(last_line) {
|
|
||||||
Ok(Value::String(json_string))
|
|
||||||
} else {
|
|
||||||
Ok(Value::String(stdout_output))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ use std::sync::Arc;
|
|||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
use crate::agents::subagent_execution_tool::task_types::Task;
|
use crate::agents::subagent_execution_tool::task_types::Task;
|
||||||
#[cfg(test)]
|
|
||||||
use crate::agents::subagent_execution_tool::task_types::TaskType;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct TasksManager {
|
pub struct TasksManager {
|
||||||
@@ -57,19 +55,26 @@ impl TasksManager {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use serde_json::json;
|
use crate::agents::subagent_execution_tool::task_types::TaskPayload;
|
||||||
|
use crate::recipe::Recipe;
|
||||||
|
|
||||||
fn create_test_task(id: &str, sub_recipe_name: &str) -> Task {
|
fn create_test_task(id: &str, sub_recipe_name: &str) -> Task {
|
||||||
|
let recipe = Recipe::builder()
|
||||||
|
.version("1.0.0")
|
||||||
|
.title(sub_recipe_name)
|
||||||
|
.description("Test recipe")
|
||||||
|
.instructions("Test instructions")
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
task_type: TaskType::SubRecipe,
|
payload: TaskPayload {
|
||||||
payload: json!({
|
recipe,
|
||||||
"sub_recipe": {
|
return_last_only: false,
|
||||||
"name": sub_recipe_name,
|
sequential_when_repeated: false,
|
||||||
"command_parameters": {},
|
parameter_values: None,
|
||||||
"recipe_path": "/test/path"
|
},
|
||||||
}
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,7 @@ use std::collections::HashMap;
|
|||||||
use crate::agents::subagent_execution_tool::task_types::{TaskInfo, TaskStatus};
|
use crate::agents::subagent_execution_tool::task_types::{TaskInfo, TaskStatus};
|
||||||
|
|
||||||
pub fn get_task_name(task_info: &TaskInfo) -> &str {
|
pub fn get_task_name(task_info: &TaskInfo) -> &str {
|
||||||
task_info
|
&task_info.task.payload.recipe.title
|
||||||
.task
|
|
||||||
.get_sub_recipe_name()
|
|
||||||
.unwrap_or(&task_info.task.id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn count_by_status(tasks: &HashMap<String, TaskInfo>) -> (usize, usize, usize, usize, usize) {
|
pub fn count_by_status(tasks: &HashMap<String, TaskInfo>) -> (usize, usize, usize, usize, usize) {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use crate::agents::subagent_execution_tool::task_types::{Task, TaskInfo, TaskStatus, TaskType};
|
use crate::agents::subagent_execution_tool::task_types::{Task, TaskInfo, TaskPayload, TaskStatus};
|
||||||
use crate::agents::subagent_execution_tool::utils::{
|
use crate::agents::subagent_execution_tool::utils::{
|
||||||
count_by_status, get_task_name, strip_ansi_codes,
|
count_by_status, get_task_name, strip_ansi_codes,
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
use crate::recipe::Recipe;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
fn create_task_info_with_defaults(task: Task, status: TaskStatus) -> TaskInfo {
|
fn create_task_info_with_defaults(task: Task, status: TaskStatus) -> TaskInfo {
|
||||||
@@ -20,76 +20,51 @@ mod test_get_task_name {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_extracts_sub_recipe_name() {
|
fn test_extracts_recipe_title() {
|
||||||
let sub_recipe_task = Task {
|
let recipe = Recipe::builder()
|
||||||
|
.version("1.0.0")
|
||||||
|
.title("my_recipe")
|
||||||
|
.description("Test")
|
||||||
|
.instructions("do something")
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let task = Task {
|
||||||
id: "task_1".to_string(),
|
id: "task_1".to_string(),
|
||||||
task_type: TaskType::SubRecipe,
|
payload: TaskPayload {
|
||||||
payload: json!({
|
recipe,
|
||||||
"sub_recipe": {
|
return_last_only: false,
|
||||||
"name": "my_recipe",
|
sequential_when_repeated: false,
|
||||||
"recipe_path": "/path/to/recipe"
|
parameter_values: None,
|
||||||
}
|
},
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let task_info = create_task_info_with_defaults(sub_recipe_task, TaskStatus::Pending);
|
let task_info = create_task_info_with_defaults(task, TaskStatus::Pending);
|
||||||
|
|
||||||
assert_eq!(get_task_name(&task_info), "my_recipe");
|
assert_eq!(get_task_name(&task_info), "my_recipe");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn falls_back_to_task_id_for_inline_recipe() {
|
|
||||||
let inline_task = Task {
|
|
||||||
id: "task_2".to_string(),
|
|
||||||
task_type: TaskType::InlineRecipe,
|
|
||||||
payload: json!({"recipe": {"instructions": "do something"}}),
|
|
||||||
};
|
|
||||||
|
|
||||||
let task_info = create_task_info_with_defaults(inline_task, TaskStatus::Pending);
|
|
||||||
|
|
||||||
assert_eq!(get_task_name(&task_info), "task_2");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn falls_back_to_task_id_when_sub_recipe_name_missing() {
|
|
||||||
let malformed_task = Task {
|
|
||||||
id: "task_3".to_string(),
|
|
||||||
task_type: TaskType::SubRecipe,
|
|
||||||
payload: json!({
|
|
||||||
"sub_recipe": {
|
|
||||||
"recipe_path": "/path/to/recipe"
|
|
||||||
// missing "name" field
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
let task_info = create_task_info_with_defaults(malformed_task, TaskStatus::Pending);
|
|
||||||
|
|
||||||
assert_eq!(get_task_name(&task_info), "task_3");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn falls_back_to_task_id_when_sub_recipe_missing() {
|
|
||||||
let malformed_task = Task {
|
|
||||||
id: "task_4".to_string(),
|
|
||||||
task_type: TaskType::SubRecipe,
|
|
||||||
payload: json!({}), // missing "sub_recipe" field
|
|
||||||
};
|
|
||||||
|
|
||||||
let task_info = create_task_info_with_defaults(malformed_task, TaskStatus::Pending);
|
|
||||||
|
|
||||||
assert_eq!(get_task_name(&task_info), "task_4");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
mod count_by_status {
|
mod count_by_status {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn create_test_task(id: &str, status: TaskStatus) -> TaskInfo {
|
fn create_test_task(id: &str, status: TaskStatus) -> TaskInfo {
|
||||||
|
let recipe = Recipe::builder()
|
||||||
|
.version("1.0.0")
|
||||||
|
.title("Test Recipe")
|
||||||
|
.description("Test")
|
||||||
|
.instructions("Test")
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let task = Task {
|
let task = Task {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
task_type: TaskType::InlineRecipe,
|
payload: TaskPayload {
|
||||||
payload: json!({}),
|
recipe,
|
||||||
|
return_last_only: false,
|
||||||
|
sequential_when_repeated: false,
|
||||||
|
parameter_values: None,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
create_task_info_with_defaults(task, status)
|
create_task_info_with_defaults(task, status)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use crate::{
|
|||||||
agents::{subagent_task_config::TaskConfig, AgentEvent, SessionConfig},
|
agents::{subagent_task_config::TaskConfig, AgentEvent, SessionConfig},
|
||||||
conversation::{message::Message, Conversation},
|
conversation::{message::Message, Conversation},
|
||||||
execution::manager::AgentManager,
|
execution::manager::AgentManager,
|
||||||
|
recipe::Recipe,
|
||||||
session::SessionManager,
|
session::SessionManager,
|
||||||
};
|
};
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
@@ -10,27 +11,30 @@ use futures::StreamExt;
|
|||||||
use rmcp::model::{ErrorCode, ErrorData};
|
use rmcp::model::{ErrorCode, ErrorData};
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
use tracing::debug;
|
use tracing::{debug, info};
|
||||||
|
|
||||||
|
type AgentMessagesFuture =
|
||||||
|
Pin<Box<dyn Future<Output = Result<(Conversation, Option<String>)>> + Send>>;
|
||||||
|
|
||||||
/// Standalone function to run a complete subagent task with output options
|
/// Standalone function to run a complete subagent task with output options
|
||||||
pub async fn run_complete_subagent_task(
|
pub async fn run_complete_subagent_task(
|
||||||
text_instruction: String,
|
recipe: Recipe,
|
||||||
task_config: TaskConfig,
|
task_config: TaskConfig,
|
||||||
return_last_only: bool,
|
return_last_only: bool,
|
||||||
) -> Result<String, anyhow::Error> {
|
) -> Result<String, anyhow::Error> {
|
||||||
let messages = get_agent_messages(text_instruction, task_config)
|
let (messages, final_output) = get_agent_messages(recipe, task_config).await.map_err(|e| {
|
||||||
.await
|
ErrorData::new(
|
||||||
.map_err(|e| {
|
ErrorCode::INTERNAL_ERROR,
|
||||||
ErrorData::new(
|
format!("Failed to execute task: {}", e),
|
||||||
ErrorCode::INTERNAL_ERROR,
|
None,
|
||||||
format!("Failed to execute task: {}", e),
|
)
|
||||||
None,
|
})?;
|
||||||
)
|
|
||||||
})?;
|
if let Some(output) = final_output {
|
||||||
|
return Ok(output);
|
||||||
|
}
|
||||||
|
|
||||||
// Extract text content based on return_last_only flag
|
|
||||||
let response_text = if return_last_only {
|
let response_text = if return_last_only {
|
||||||
// Get only the last message's text content
|
|
||||||
messages
|
messages
|
||||||
.messages()
|
.messages()
|
||||||
.last()
|
.last()
|
||||||
@@ -44,7 +48,6 @@ pub async fn run_complete_subagent_task(
|
|||||||
})
|
})
|
||||||
.unwrap_or_else(|| String::from("No text content in last message"))
|
.unwrap_or_else(|| String::from("No text content in last message"))
|
||||||
} else {
|
} else {
|
||||||
// Extract all text content from all messages (original behavior)
|
|
||||||
let all_text_content: Vec<String> = messages
|
let all_text_content: Vec<String> = messages
|
||||||
.iter()
|
.iter()
|
||||||
.flat_map(|message| {
|
.flat_map(|message| {
|
||||||
@@ -88,15 +91,17 @@ pub async fn run_complete_subagent_task(
|
|||||||
all_text_content.join("\n")
|
all_text_content.join("\n")
|
||||||
};
|
};
|
||||||
|
|
||||||
// Return the result
|
|
||||||
Ok(response_text)
|
Ok(response_text)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_agent_messages(
|
fn get_agent_messages(recipe: Recipe, task_config: TaskConfig) -> AgentMessagesFuture {
|
||||||
text_instruction: String,
|
|
||||||
task_config: TaskConfig,
|
|
||||||
) -> Pin<Box<dyn Future<Output = Result<Conversation>> + Send>> {
|
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
|
let text_instruction = recipe
|
||||||
|
.instructions
|
||||||
|
.clone()
|
||||||
|
.or(recipe.prompt.clone())
|
||||||
|
.ok_or_else(|| anyhow!("Recipe has no instructions or prompt"))?;
|
||||||
|
|
||||||
let agent_manager = AgentManager::instance()
|
let agent_manager = AgentManager::instance()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow!("Failed to create AgentManager: {}", e))?;
|
.map_err(|e| anyhow!("Failed to create AgentManager: {}", e))?;
|
||||||
@@ -130,14 +135,24 @@ fn get_agent_messages(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let has_response_schema = recipe.response.is_some();
|
||||||
|
agent
|
||||||
|
.apply_recipe_components(recipe.sub_recipes.clone(), recipe.response.clone(), true)
|
||||||
|
.await;
|
||||||
|
|
||||||
let user_message = Message::user().with_text(text_instruction);
|
let user_message = Message::user().with_text(text_instruction);
|
||||||
let mut conversation = Conversation::new_unvalidated(vec![user_message.clone()]);
|
let mut conversation = Conversation::new_unvalidated(vec![user_message.clone()]);
|
||||||
|
|
||||||
|
if let Some(activities) = recipe.activities {
|
||||||
|
for activity in activities {
|
||||||
|
info!("Recipe activity: {}", activity);
|
||||||
|
}
|
||||||
|
}
|
||||||
let session_config = SessionConfig {
|
let session_config = SessionConfig {
|
||||||
id: session.id.clone(),
|
id: session.id.clone(),
|
||||||
schedule_id: None,
|
schedule_id: None,
|
||||||
max_turns: task_config.max_turns.map(|v| v as u32),
|
max_turns: task_config.max_turns.map(|v| v as u32),
|
||||||
retry_config: None,
|
retry_config: recipe.retry,
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut stream = crate::session_context::with_session_id(Some(session.id.clone()), async {
|
let mut stream = crate::session_context::with_session_id(Some(session.id.clone()), async {
|
||||||
@@ -159,6 +174,17 @@ fn get_agent_messages(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(conversation)
|
let final_output = if has_response_schema {
|
||||||
|
agent
|
||||||
|
.final_output_tool
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|tool| tool.final_output.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok((conversation, final_output))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,9 +53,6 @@ pub struct Recipe {
|
|||||||
)]
|
)]
|
||||||
pub extensions: Option<Vec<ExtensionConfig>>, // a list of extensions to enable
|
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")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub settings: Option<Settings>, // settings for the recipe
|
pub settings: Option<Settings>, // settings for the recipe
|
||||||
|
|
||||||
@@ -206,7 +203,6 @@ pub struct RecipeBuilder {
|
|||||||
// Optional fields
|
// Optional fields
|
||||||
prompt: Option<String>,
|
prompt: Option<String>,
|
||||||
extensions: Option<Vec<ExtensionConfig>>,
|
extensions: Option<Vec<ExtensionConfig>>,
|
||||||
context: Option<Vec<String>>,
|
|
||||||
settings: Option<Settings>,
|
settings: Option<Settings>,
|
||||||
activities: Option<Vec<String>>,
|
activities: Option<Vec<String>>,
|
||||||
author: Option<Author>,
|
author: Option<Author>,
|
||||||
@@ -252,7 +248,6 @@ impl Recipe {
|
|||||||
instructions: None,
|
instructions: None,
|
||||||
prompt: None,
|
prompt: None,
|
||||||
extensions: None,
|
extensions: None,
|
||||||
context: None,
|
|
||||||
settings: None,
|
settings: None,
|
||||||
activities: None,
|
activities: None,
|
||||||
author: None,
|
author: None,
|
||||||
@@ -327,11 +322,6 @@ impl RecipeBuilder {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn context(mut self, context: Vec<String>) -> Self {
|
|
||||||
self.context = Some(context);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn settings(mut self, settings: Settings) -> Self {
|
pub fn settings(mut self, settings: Settings) -> Self {
|
||||||
self.settings = Some(settings);
|
self.settings = Some(settings);
|
||||||
self
|
self
|
||||||
@@ -382,7 +372,6 @@ impl RecipeBuilder {
|
|||||||
instructions: self.instructions,
|
instructions: self.instructions,
|
||||||
prompt: self.prompt,
|
prompt: self.prompt,
|
||||||
extensions: self.extensions,
|
extensions: self.extensions,
|
||||||
context: self.context,
|
|
||||||
settings: self.settings,
|
settings: self.settings,
|
||||||
activities: self.activities,
|
activities: self.activities,
|
||||||
author: self.author,
|
author: self.author,
|
||||||
@@ -721,7 +710,6 @@ isGlobal: true"#;
|
|||||||
instructions: Some("clean instructions".to_string()),
|
instructions: Some("clean instructions".to_string()),
|
||||||
prompt: Some("clean prompt".to_string()),
|
prompt: Some("clean prompt".to_string()),
|
||||||
extensions: None,
|
extensions: None,
|
||||||
context: None,
|
|
||||||
settings: None,
|
settings: None,
|
||||||
activities: Some(vec!["clean activity 1".to_string()]),
|
activities: Some(vec!["clean activity 1".to_string()]),
|
||||||
author: None,
|
author: None,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct RecipeFile {
|
pub struct RecipeFile {
|
||||||
pub content: String,
|
pub content: String,
|
||||||
pub parent_dir: PathBuf,
|
pub parent_dir: PathBuf,
|
||||||
|
|||||||
@@ -1421,7 +1421,7 @@ mod tests {
|
|||||||
instructions: None,
|
instructions: None,
|
||||||
prompt: Some("This is a test prompt for a scheduled job.".to_string()),
|
prompt: Some("This is a test prompt for a scheduled job.".to_string()),
|
||||||
extensions: None,
|
extensions: None,
|
||||||
context: None,
|
|
||||||
activities: None,
|
activities: None,
|
||||||
author: None,
|
author: None,
|
||||||
parameters: None,
|
parameters: None,
|
||||||
|
|||||||
@@ -197,9 +197,7 @@ mod tests {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let recipe = task_params_to_inline_recipe(¶ms, &test_loaded_extensions()).unwrap();
|
let recipe = task_params_to_inline_recipe(¶ms, &test_loaded_extensions()).unwrap();
|
||||||
assert!(recipe.context.is_some());
|
|
||||||
assert!(recipe.activities.is_some());
|
assert!(recipe.activities.is_some());
|
||||||
assert_eq!(recipe.context.unwrap(), vec!["context1", "context2"]);
|
|
||||||
assert_eq!(recipe.activities.unwrap(), vec!["activity1", "activity2"]);
|
assert_eq!(recipe.activities.unwrap(), vec!["activity1", "activity2"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,7 +276,6 @@ mod tests {
|
|||||||
// Invalid fields should be ignored (None)
|
// Invalid fields should be ignored (None)
|
||||||
assert!(recipe.settings.is_none());
|
assert!(recipe.settings.is_none());
|
||||||
assert!(recipe.extensions.is_none());
|
assert!(recipe.extensions.is_none());
|
||||||
assert!(recipe.context.is_none());
|
|
||||||
assert!(recipe.activities.is_none());
|
assert!(recipe.activities.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
use goose::agents::subagent_execution_tool::task_types::{Task, TaskType};
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_task_type_serialization() {
|
|
||||||
// Test that TaskType serializes to the expected string format
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::to_string(&TaskType::InlineRecipe).unwrap(),
|
|
||||||
"\"inline_recipe\""
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::to_string(&TaskType::SubRecipe).unwrap(),
|
|
||||||
"\"sub_recipe\""
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_task_type_deserialization() {
|
|
||||||
// Test that strings deserialize to the correct TaskType variants
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::from_str::<TaskType>("\"inline_recipe\"").unwrap(),
|
|
||||||
TaskType::InlineRecipe
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::from_str::<TaskType>("\"sub_recipe\"").unwrap(),
|
|
||||||
TaskType::SubRecipe
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_task_serialization_with_enum() {
|
|
||||||
let task = Task {
|
|
||||||
id: "test-id".to_string(),
|
|
||||||
task_type: TaskType::InlineRecipe,
|
|
||||||
payload: json!({"recipe": "test"}),
|
|
||||||
};
|
|
||||||
|
|
||||||
let serialized = serde_json::to_value(&task).unwrap();
|
|
||||||
assert_eq!(serialized["id"], "test-id");
|
|
||||||
assert_eq!(serialized["task_type"], "inline_recipe");
|
|
||||||
assert_eq!(serialized["payload"]["recipe"], "test");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_task_deserialization_with_string() {
|
|
||||||
// Test backward compatibility - JSON with string task_type should deserialize
|
|
||||||
let json_str = r#"{
|
|
||||||
"id": "test-id",
|
|
||||||
"task_type": "sub_recipe",
|
|
||||||
"payload": {"sub_recipe": {"name": "test"}}
|
|
||||||
}"#;
|
|
||||||
|
|
||||||
let task: Task = serde_json::from_str(json_str).unwrap();
|
|
||||||
assert_eq!(task.id, "test-id");
|
|
||||||
assert_eq!(task.task_type, TaskType::SubRecipe);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_task_type_display() {
|
|
||||||
assert_eq!(TaskType::InlineRecipe.to_string(), "inline_recipe");
|
|
||||||
assert_eq!(TaskType::SubRecipe.to_string(), "sub_recipe");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_task_methods_with_sub_recipe() {
|
|
||||||
let task = Task {
|
|
||||||
id: "test-1".to_string(),
|
|
||||||
task_type: TaskType::SubRecipe,
|
|
||||||
payload: json!({
|
|
||||||
"sub_recipe": {
|
|
||||||
"name": "test_recipe",
|
|
||||||
"recipe_path": "/path/to/recipe",
|
|
||||||
"command_parameters": {"key": "value"},
|
|
||||||
"sequential_when_repeated": true
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(task.get_sub_recipe().is_some());
|
|
||||||
assert_eq!(task.get_sub_recipe_name(), Some("test_recipe"));
|
|
||||||
assert_eq!(task.get_sub_recipe_path(), Some("/path/to/recipe"));
|
|
||||||
assert!(task.get_command_parameters().is_some());
|
|
||||||
assert!(task.get_sequential_when_repeated());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_task_methods_with_inline_recipe() {
|
|
||||||
let task = Task {
|
|
||||||
id: "test-3".to_string(),
|
|
||||||
task_type: TaskType::InlineRecipe,
|
|
||||||
payload: json!({
|
|
||||||
"recipe": {
|
|
||||||
"instructions": "Test instructions"
|
|
||||||
},
|
|
||||||
"return_last_only": true
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(task.get_sub_recipe().is_none());
|
|
||||||
assert!(task.get_sub_recipe_name().is_none());
|
|
||||||
assert!(task.get_sub_recipe_path().is_none());
|
|
||||||
assert!(task.get_command_parameters().is_none());
|
|
||||||
assert!(!task.get_sequential_when_repeated());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_invalid_task_type_deserialization() {
|
|
||||||
// Test that invalid task_type strings fail to deserialize
|
|
||||||
let result = serde_json::from_str::<TaskType>("\"invalid_type\"");
|
|
||||||
assert!(result.is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_task_with_missing_fields() {
|
|
||||||
let task = Task {
|
|
||||||
id: "test-4".to_string(),
|
|
||||||
task_type: TaskType::SubRecipe,
|
|
||||||
payload: json!({}), // Missing sub_recipe field
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(task.get_sub_recipe().is_none());
|
|
||||||
assert!(task.get_sub_recipe_name().is_none());
|
|
||||||
assert!(task.get_sub_recipe_path().is_none());
|
|
||||||
assert!(task.get_command_parameters().is_none());
|
|
||||||
assert!(!task.get_sequential_when_repeated());
|
|
||||||
}
|
|
||||||
@@ -3762,13 +3762,6 @@
|
|||||||
],
|
],
|
||||||
"nullable": true
|
"nullable": true
|
||||||
},
|
},
|
||||||
"context": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"nullable": true
|
|
||||||
},
|
|
||||||
"description": {
|
"description": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -548,7 +548,6 @@ export type RawTextContent = {
|
|||||||
export type Recipe = {
|
export type Recipe = {
|
||||||
activities?: Array<string> | null;
|
activities?: Array<string> | null;
|
||||||
author?: Author | null;
|
author?: Author | null;
|
||||||
context?: Array<string> | null;
|
|
||||||
description: string;
|
description: string;
|
||||||
extensions?: Array<ExtensionConfig> | null;
|
extensions?: Array<ExtensionConfig> | null;
|
||||||
instructions?: string | null;
|
instructions?: string | null;
|
||||||
|
|||||||
@@ -60,9 +60,6 @@ interface CleanRecipe {
|
|||||||
prompt?: string;
|
prompt?: string;
|
||||||
activities?: string[];
|
activities?: string[];
|
||||||
extensions?: CleanExtension[];
|
extensions?: CleanExtension[];
|
||||||
goosehints?: string;
|
|
||||||
context?: string[];
|
|
||||||
profile?: string;
|
|
||||||
author?: {
|
author?: {
|
||||||
contact?: string;
|
contact?: string;
|
||||||
metadata?: string;
|
metadata?: string;
|
||||||
@@ -238,10 +235,6 @@ function recipeToYaml(recipe: Recipe, executionMode: ExecutionMode): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (recipe.context && recipe.context.length > 0) {
|
|
||||||
cleanRecipe.context = recipe.context;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (recipe.author) {
|
if (recipe.author) {
|
||||||
cleanRecipe.author = {
|
cleanRecipe.author = {
|
||||||
contact: recipe.author.contact || undefined,
|
contact: recipe.author.contact || undefined,
|
||||||
|
|||||||
Reference in New Issue
Block a user