mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
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(),
|
||||
},
|
||||
]),
|
||||
context: None,
|
||||
settings: None,
|
||||
activities: None,
|
||||
author: None,
|
||||
@@ -210,7 +209,6 @@ mod tests {
|
||||
instructions: Some("Test instructions".to_string()),
|
||||
prompt: None,
|
||||
extensions: None,
|
||||
context: None,
|
||||
settings: None,
|
||||
activities: None,
|
||||
author: None,
|
||||
@@ -255,7 +253,6 @@ mod tests {
|
||||
available_tools: Vec::new(),
|
||||
},
|
||||
]),
|
||||
context: None,
|
||||
settings: None,
|
||||
activities: None,
|
||||
author: None,
|
||||
@@ -309,7 +306,6 @@ mod tests {
|
||||
sequential_when_repeated: false,
|
||||
description: None,
|
||||
}]),
|
||||
context: None,
|
||||
settings: None,
|
||||
activities: None,
|
||||
author: None,
|
||||
|
||||
@@ -283,13 +283,13 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession {
|
||||
// Create the agent
|
||||
let agent: Agent = Agent::new();
|
||||
|
||||
if let Some(sub_recipes) = session_config.sub_recipes {
|
||||
agent.add_sub_recipes(sub_recipes).await;
|
||||
}
|
||||
|
||||
if let Some(final_output_response) = session_config.final_output_response {
|
||||
agent.add_final_output_tool(final_output_response).await;
|
||||
}
|
||||
agent
|
||||
.apply_recipe_components(
|
||||
session_config.sub_recipes,
|
||||
session_config.final_output_response,
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
|
||||
let new_provider = match create(&provider_name, model_config).await {
|
||||
Ok(provider) => provider,
|
||||
|
||||
@@ -155,15 +155,13 @@ pub async fn apply_recipe_to_agent(
|
||||
recipe: &Recipe,
|
||||
include_final_output_tool: bool,
|
||||
) -> Option<String> {
|
||||
if let Some(sub_recipes) = &recipe.sub_recipes {
|
||||
agent.add_sub_recipes(sub_recipes.clone()).await;
|
||||
}
|
||||
|
||||
if include_final_output_tool {
|
||||
if let Some(response) = &recipe.response {
|
||||
agent.add_final_output_tool(response.clone()).await;
|
||||
}
|
||||
}
|
||||
agent
|
||||
.apply_recipe_components(
|
||||
recipe.sub_recipes.clone(),
|
||||
recipe.response.clone(),
|
||||
include_final_output_tool,
|
||||
)
|
||||
.await;
|
||||
|
||||
recipe.instructions.as_ref().map(|instructions| {
|
||||
let mut context: HashMap<&str, Value> = HashMap::new();
|
||||
|
||||
@@ -387,6 +387,23 @@ impl Agent {
|
||||
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
|
||||
#[instrument(skip(self, tool_call, request_id), fields(input, output))]
|
||||
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::{
|
||||
lib::ExecutionMode,
|
||||
task_types::{Task, TaskType},
|
||||
task_types::{Task, TaskPayload},
|
||||
};
|
||||
use crate::agents::tool_execution::ToolCallResult;
|
||||
use crate::config::GooseMode;
|
||||
@@ -81,9 +81,6 @@ pub struct TaskParameter {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub retry: Option<JsonObject>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub context: Option<Vec<String>>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub activities: Option<Vec<String>>,
|
||||
|
||||
@@ -116,7 +113,7 @@ pub fn create_dynamic_task_tool() -> Tool {
|
||||
|
||||
Tool::new(
|
||||
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,
|
||||
).annotate(ToolAnnotations {
|
||||
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("response"), RecipeBuilder::response);
|
||||
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,
|
||||
task_param.get("activities"),
|
||||
@@ -297,17 +293,6 @@ pub async fn create_dynamic_task(
|
||||
// All tasks must use the new inline recipe path
|
||||
match task_params_to_inline_recipe(task_param, &loaded_extensions) {
|
||||
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
|
||||
let return_last_only = task_param
|
||||
.get("return_last_only")
|
||||
@@ -316,11 +301,12 @@ pub async fn create_dynamic_task(
|
||||
|
||||
let task = Task {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
task_type: TaskType::InlineRecipe,
|
||||
payload: json!({
|
||||
"recipe": recipe_json,
|
||||
"return_last_only": return_last_only
|
||||
}),
|
||||
payload: TaskPayload {
|
||||
recipe,
|
||||
return_last_only,
|
||||
sequential_when_repeated: false,
|
||||
parameter_values: None,
|
||||
},
|
||||
};
|
||||
tasks.push(task);
|
||||
}
|
||||
|
||||
@@ -7,8 +7,10 @@ use rmcp::model::{Tool, ToolAnnotations};
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
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::recipe::build_recipe::build_recipe_from_template;
|
||||
use crate::recipe::local_recipes::load_local_recipe_file;
|
||||
use crate::recipe::{Recipe, RecipeParameter, RecipeParameterRequirement, SubRecipe};
|
||||
|
||||
use super::param_utils::prepare_command_params;
|
||||
@@ -54,27 +56,37 @@ fn extract_task_parameters(params: &Value) -> Vec<Value> {
|
||||
fn create_tasks_from_params(
|
||||
sub_recipe: &SubRecipe,
|
||||
command_params: &[std::collections::HashMap<String, String>],
|
||||
) -> Vec<Task> {
|
||||
let tasks: Vec<Task> = command_params
|
||||
.iter()
|
||||
.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();
|
||||
) -> Result<Vec<Task>> {
|
||||
let recipe_file = load_local_recipe_file(&sub_recipe.path)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to load recipe {}: {}", sub_recipe.path, e))?;
|
||||
|
||||
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 {
|
||||
@@ -97,7 +109,7 @@ pub async fn create_sub_recipe_task(
|
||||
) -> Result<String> {
|
||||
let task_params_array = extract_task_parameters(¶ms);
|
||||
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 tasks_json = serde_json::to_string(&task_execution_payload)
|
||||
|
||||
@@ -33,7 +33,11 @@ pub async fn execute_tasks(
|
||||
}
|
||||
}
|
||||
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!(
|
||||
{
|
||||
"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::utils::{count_by_status, get_task_name};
|
||||
use crate::utils::is_token_cancelled;
|
||||
use serde_json::Value;
|
||||
use tokio::sync::mpsc::Sender;
|
||||
|
||||
const RECIPE_TASK_TYPE: &str = "recipe";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum DisplayMode {
|
||||
MultipleTasksOutput,
|
||||
@@ -28,25 +29,22 @@ const THROTTLE_INTERVAL_MS: u64 = 250;
|
||||
const COMPLETION_NOTIFICATION_DELAY_MS: u64 = 500;
|
||||
|
||||
fn format_task_metadata(task_info: &TaskInfo) -> String {
|
||||
if let Some(params) = task_info.task.get_command_parameters() {
|
||||
if params.is_empty() {
|
||||
return String::new();
|
||||
// If we have parameter values, format them nicely
|
||||
if let Some(ref params) = task_info.task.payload.parameter_values {
|
||||
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 {
|
||||
@@ -151,13 +149,15 @@ impl TaskExecutionTracker {
|
||||
async fn format_line(&self, task_info: Option<&TaskInfo>, line: &str) -> String {
|
||||
if let Some(task_info) = 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);
|
||||
|
||||
if metadata.is_empty() {
|
||||
format!("[{} ({})] {}", task_name, task_type, line)
|
||||
format!("[{} ({})] {}", task_name, RECIPE_TASK_TYPE, line)
|
||||
} else {
|
||||
format!("[{} ({}) {}] {}", task_name, task_type, metadata, line)
|
||||
format!(
|
||||
"[{} ({}) {}] {}",
|
||||
task_name, RECIPE_TASK_TYPE, metadata, line
|
||||
)
|
||||
}
|
||||
} else {
|
||||
line.to_string()
|
||||
@@ -232,7 +232,7 @@ impl TaskExecutionTracker {
|
||||
}
|
||||
}),
|
||||
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_metadata: format_task_metadata(task_info),
|
||||
error: task_info.error().cloned(),
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use std::fmt;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::agents::subagent_execution_tool::task_execution_tracker::TaskExecutionTracker;
|
||||
use crate::recipe::Recipe;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
@@ -16,59 +17,19 @@ pub enum ExecutionMode {
|
||||
Parallel,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TaskType {
|
||||
InlineRecipe,
|
||||
SubRecipe,
|
||||
}
|
||||
|
||||
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)]
|
||||
pub struct TaskPayload {
|
||||
pub recipe: Recipe,
|
||||
pub return_last_only: bool,
|
||||
pub sequential_when_repeated: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parameter_values: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Task {
|
||||
pub id: String,
|
||||
pub task_type: TaskType,
|
||||
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())
|
||||
}
|
||||
pub payload: TaskPayload,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
@@ -1,29 +1,18 @@
|
||||
use serde_json::Value;
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::process::Command;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
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::utils::strip_ansi_codes;
|
||||
use crate::agents::subagent_execution_tool::task_types::{Task, TaskResult, TaskStatus};
|
||||
use crate::agents::subagent_task_config::TaskConfig;
|
||||
|
||||
pub async fn process_task(
|
||||
task: &Task,
|
||||
task_execution_tracker: Arc<TaskExecutionTracker>,
|
||||
_task_execution_tracker: Arc<TaskExecutionTracker>,
|
||||
task_config: TaskConfig,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> TaskResult {
|
||||
match get_task_result(
|
||||
task.clone(),
|
||||
task_execution_tracker,
|
||||
task_config,
|
||||
cancellation_token,
|
||||
)
|
||||
.await
|
||||
{
|
||||
match handle_recipe_task(task.clone(), task_config, cancellation_token).await {
|
||||
Ok(data) => TaskResult {
|
||||
task_id: task.id.clone(),
|
||||
status: TaskStatus::Completed,
|
||||
@@ -39,234 +28,67 @@ pub async fn process_task(
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_task_result(
|
||||
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(
|
||||
async fn handle_recipe_task(
|
||||
task: Task,
|
||||
mut task_config: TaskConfig,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> Result<Value, String> {
|
||||
use crate::agents::subagent_handler::run_complete_subagent_task;
|
||||
use crate::recipe::Recipe;
|
||||
use crate::model::ModelConfig;
|
||||
use crate::providers;
|
||||
|
||||
let recipe_value = task
|
||||
.payload
|
||||
.get("recipe")
|
||||
.ok_or_else(|| "Missing recipe in inline_recipe task payload".to_string())?;
|
||||
let recipe = task.payload.recipe;
|
||||
let return_last_only = task.payload.return_last_only;
|
||||
|
||||
let recipe: Recipe = serde_json::from_value(recipe_value.clone())
|
||||
.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 {
|
||||
if let Some(ref exts) = recipe.extensions {
|
||||
task_config.extensions = exts.clone();
|
||||
}
|
||||
|
||||
let instruction = recipe
|
||||
.instructions
|
||||
.or(recipe.prompt)
|
||||
.ok_or_else(|| "No instructions or prompt in recipe".to_string())?;
|
||||
|
||||
let result = tokio::select! {
|
||||
result = run_complete_subagent_task(
|
||||
instruction,
|
||||
task_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);
|
||||
if let Some(ref settings) = recipe.settings {
|
||||
let new_provider = match (
|
||||
&settings.goose_provider,
|
||||
&settings.goose_model,
|
||||
settings.temperature,
|
||||
) {
|
||||
(Some(provider), Some(model), temp) => {
|
||||
let config = ModelConfig::new_or_fail(model).with_temperature(temp);
|
||||
Some((provider.clone(), config))
|
||||
}
|
||||
|
||||
stdout_task.abort();
|
||||
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);
|
||||
(Some(_), None, _) => {
|
||||
return Err("Recipe specifies provider but no model".to_string());
|
||||
}
|
||||
(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)?;
|
||||
if serde_json::from_str::<Value>(potential_json).is_ok() {
|
||||
Some(potential_json.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
tokio::select! {
|
||||
result = run_complete_subagent_task(recipe, task_config, return_last_only) => {
|
||||
result.map(|text| serde_json::json!({"result": text}))
|
||||
.map_err(|e| format!("Recipe execution failed: {}", e))
|
||||
}
|
||||
_ = cancellation_token.cancelled() => {
|
||||
Err("Task cancelled".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::agents::subagent_execution_tool::task_types::Task;
|
||||
#[cfg(test)]
|
||||
use crate::agents::subagent_execution_tool::task_types::TaskType;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TasksManager {
|
||||
@@ -57,19 +55,26 @@ impl TasksManager {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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 {
|
||||
let recipe = Recipe::builder()
|
||||
.version("1.0.0")
|
||||
.title(sub_recipe_name)
|
||||
.description("Test recipe")
|
||||
.instructions("Test instructions")
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
Task {
|
||||
id: id.to_string(),
|
||||
task_type: TaskType::SubRecipe,
|
||||
payload: json!({
|
||||
"sub_recipe": {
|
||||
"name": sub_recipe_name,
|
||||
"command_parameters": {},
|
||||
"recipe_path": "/test/path"
|
||||
}
|
||||
}),
|
||||
payload: TaskPayload {
|
||||
recipe,
|
||||
return_last_only: false,
|
||||
sequential_when_repeated: false,
|
||||
parameter_values: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,7 @@ use std::collections::HashMap;
|
||||
use crate::agents::subagent_execution_tool::task_types::{TaskInfo, TaskStatus};
|
||||
|
||||
pub fn get_task_name(task_info: &TaskInfo) -> &str {
|
||||
task_info
|
||||
.task
|
||||
.get_sub_recipe_name()
|
||||
.unwrap_or(&task_info.task.id)
|
||||
&task_info.task.payload.recipe.title
|
||||
}
|
||||
|
||||
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::{
|
||||
count_by_status, get_task_name, strip_ansi_codes,
|
||||
};
|
||||
use serde_json::json;
|
||||
use crate::recipe::Recipe;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn create_task_info_with_defaults(task: Task, status: TaskStatus) -> TaskInfo {
|
||||
@@ -20,76 +20,51 @@ mod test_get_task_name {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_extracts_sub_recipe_name() {
|
||||
let sub_recipe_task = Task {
|
||||
fn test_extracts_recipe_title() {
|
||||
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(),
|
||||
task_type: TaskType::SubRecipe,
|
||||
payload: json!({
|
||||
"sub_recipe": {
|
||||
"name": "my_recipe",
|
||||
"recipe_path": "/path/to/recipe"
|
||||
}
|
||||
}),
|
||||
payload: TaskPayload {
|
||||
recipe,
|
||||
return_last_only: false,
|
||||
sequential_when_repeated: false,
|
||||
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");
|
||||
}
|
||||
|
||||
#[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 {
|
||||
use super::*;
|
||||
|
||||
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 {
|
||||
id: id.to_string(),
|
||||
task_type: TaskType::InlineRecipe,
|
||||
payload: json!({}),
|
||||
payload: TaskPayload {
|
||||
recipe,
|
||||
return_last_only: false,
|
||||
sequential_when_repeated: false,
|
||||
parameter_values: None,
|
||||
},
|
||||
};
|
||||
create_task_info_with_defaults(task, status)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::{
|
||||
agents::{subagent_task_config::TaskConfig, AgentEvent, SessionConfig},
|
||||
conversation::{message::Message, Conversation},
|
||||
execution::manager::AgentManager,
|
||||
recipe::Recipe,
|
||||
session::SessionManager,
|
||||
};
|
||||
use anyhow::{anyhow, Result};
|
||||
@@ -10,27 +11,30 @@ use futures::StreamExt;
|
||||
use rmcp::model::{ErrorCode, ErrorData};
|
||||
use std::future::Future;
|
||||
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
|
||||
pub async fn run_complete_subagent_task(
|
||||
text_instruction: String,
|
||||
recipe: Recipe,
|
||||
task_config: TaskConfig,
|
||||
return_last_only: bool,
|
||||
) -> Result<String, anyhow::Error> {
|
||||
let messages = get_agent_messages(text_instruction, task_config)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ErrorData::new(
|
||||
ErrorCode::INTERNAL_ERROR,
|
||||
format!("Failed to execute task: {}", e),
|
||||
None,
|
||||
)
|
||||
})?;
|
||||
let (messages, final_output) = get_agent_messages(recipe, task_config).await.map_err(|e| {
|
||||
ErrorData::new(
|
||||
ErrorCode::INTERNAL_ERROR,
|
||||
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 {
|
||||
// Get only the last message's text content
|
||||
messages
|
||||
.messages()
|
||||
.last()
|
||||
@@ -44,7 +48,6 @@ pub async fn run_complete_subagent_task(
|
||||
})
|
||||
.unwrap_or_else(|| String::from("No text content in last message"))
|
||||
} else {
|
||||
// Extract all text content from all messages (original behavior)
|
||||
let all_text_content: Vec<String> = messages
|
||||
.iter()
|
||||
.flat_map(|message| {
|
||||
@@ -88,15 +91,17 @@ pub async fn run_complete_subagent_task(
|
||||
all_text_content.join("\n")
|
||||
};
|
||||
|
||||
// Return the result
|
||||
Ok(response_text)
|
||||
}
|
||||
|
||||
fn get_agent_messages(
|
||||
text_instruction: String,
|
||||
task_config: TaskConfig,
|
||||
) -> Pin<Box<dyn Future<Output = Result<Conversation>> + Send>> {
|
||||
fn get_agent_messages(recipe: Recipe, task_config: TaskConfig) -> AgentMessagesFuture {
|
||||
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()
|
||||
.await
|
||||
.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 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 {
|
||||
id: session.id.clone(),
|
||||
schedule_id: None,
|
||||
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 {
|
||||
@@ -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
|
||||
|
||||
#[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
|
||||
|
||||
@@ -206,7 +203,6 @@ pub struct RecipeBuilder {
|
||||
// Optional fields
|
||||
prompt: Option<String>,
|
||||
extensions: Option<Vec<ExtensionConfig>>,
|
||||
context: Option<Vec<String>>,
|
||||
settings: Option<Settings>,
|
||||
activities: Option<Vec<String>>,
|
||||
author: Option<Author>,
|
||||
@@ -252,7 +248,6 @@ impl Recipe {
|
||||
instructions: None,
|
||||
prompt: None,
|
||||
extensions: None,
|
||||
context: None,
|
||||
settings: None,
|
||||
activities: None,
|
||||
author: None,
|
||||
@@ -327,11 +322,6 @@ impl RecipeBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
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
|
||||
@@ -382,7 +372,6 @@ impl RecipeBuilder {
|
||||
instructions: self.instructions,
|
||||
prompt: self.prompt,
|
||||
extensions: self.extensions,
|
||||
context: self.context,
|
||||
settings: self.settings,
|
||||
activities: self.activities,
|
||||
author: self.author,
|
||||
@@ -721,7 +710,6 @@ isGlobal: true"#;
|
||||
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,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RecipeFile {
|
||||
pub content: String,
|
||||
pub parent_dir: PathBuf,
|
||||
|
||||
@@ -1421,7 +1421,7 @@ mod tests {
|
||||
instructions: None,
|
||||
prompt: Some("This is a test prompt for a scheduled job.".to_string()),
|
||||
extensions: None,
|
||||
context: None,
|
||||
|
||||
activities: None,
|
||||
author: None,
|
||||
parameters: None,
|
||||
|
||||
@@ -197,9 +197,7 @@ mod tests {
|
||||
});
|
||||
|
||||
let recipe = task_params_to_inline_recipe(¶ms, &test_loaded_extensions()).unwrap();
|
||||
assert!(recipe.context.is_some());
|
||||
assert!(recipe.activities.is_some());
|
||||
assert_eq!(recipe.context.unwrap(), vec!["context1", "context2"]);
|
||||
assert_eq!(recipe.activities.unwrap(), vec!["activity1", "activity2"]);
|
||||
}
|
||||
|
||||
@@ -278,7 +276,6 @@ mod tests {
|
||||
// Invalid fields should be ignored (None)
|
||||
assert!(recipe.settings.is_none());
|
||||
assert!(recipe.extensions.is_none());
|
||||
assert!(recipe.context.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
|
||||
},
|
||||
"context": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"nullable": true
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -548,7 +548,6 @@ export type RawTextContent = {
|
||||
export type Recipe = {
|
||||
activities?: Array<string> | null;
|
||||
author?: Author | null;
|
||||
context?: Array<string> | null;
|
||||
description: string;
|
||||
extensions?: Array<ExtensionConfig> | null;
|
||||
instructions?: string | null;
|
||||
|
||||
@@ -60,9 +60,6 @@ interface CleanRecipe {
|
||||
prompt?: string;
|
||||
activities?: string[];
|
||||
extensions?: CleanExtension[];
|
||||
goosehints?: string;
|
||||
context?: string[];
|
||||
profile?: string;
|
||||
author?: {
|
||||
contact?: 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) {
|
||||
cleanRecipe.author = {
|
||||
contact: recipe.author.contact || undefined,
|
||||
|
||||
Reference in New Issue
Block a user