diff --git a/crates/goose-cli/src/recipes/secret_discovery.rs b/crates/goose-cli/src/recipes/secret_discovery.rs index 6a259d21e96f..8820ec409cf1 100644 --- a/crates/goose-cli/src/recipes/secret_discovery.rs +++ b/crates/goose-cli/src/recipes/secret_discovery.rs @@ -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, diff --git a/crates/goose-cli/src/session/builder.rs b/crates/goose-cli/src/session/builder.rs index 96321f4bce43..6845e3f2e222 100644 --- a/crates/goose-cli/src/session/builder.rs +++ b/crates/goose-cli/src/session/builder.rs @@ -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, diff --git a/crates/goose-server/src/routes/recipe_utils.rs b/crates/goose-server/src/routes/recipe_utils.rs index bb9c6421f247..05bb7dd94e58 100644 --- a/crates/goose-server/src/routes/recipe_utils.rs +++ b/crates/goose-server/src/routes/recipe_utils.rs @@ -156,15 +156,13 @@ pub async fn apply_recipe_to_agent( recipe: &Recipe, include_final_output_tool: bool, ) -> Option { - 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(); diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 61dfa73acd1e..c2700634bf16 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -385,6 +385,23 @@ impl Agent { sub_recipe_manager.add_sub_recipe_tools(sub_recipes); } + pub async fn apply_recipe_components( + &self, + sub_recipes: Option>, + response: Option, + 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( diff --git a/crates/goose/src/agents/recipe_tools/dynamic_task_tools.rs b/crates/goose/src/agents/recipe_tools/dynamic_task_tools.rs index 8f6b2881b249..bc61926ac38c 100644 --- a/crates/goose/src/agents/recipe_tools/dynamic_task_tools.rs +++ b/crates/goose/src/agents/recipe_tools/dynamic_task_tools.rs @@ -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, - #[serde(skip_serializing_if = "Option::is_none")] - pub context: Option>, - #[serde(skip_serializing_if = "Option::is_none")] pub activities: Option>, @@ -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); } diff --git a/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs b/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs index 8e93a658e933..b3f02ff9b400 100644 --- a/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs +++ b/crates/goose/src/agents/recipe_tools/sub_recipe_tools.rs @@ -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 { fn create_tasks_from_params( sub_recipe: &SubRecipe, command_params: &[std::collections::HashMap], -) -> Vec { - let tasks: Vec = 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> { + let recipe_file = load_local_recipe_file(&sub_recipe.path) + .map_err(|e| anyhow::anyhow!("Failed to load recipe {}: {}", sub_recipe.path, e))?; + + 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:: Result>, + ) + .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); + } - tasks + 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 { 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) diff --git a/crates/goose/src/agents/subagent_execution_tool/lib/mod.rs b/crates/goose/src/agents/subagent_execution_tool/lib/mod.rs index 48947c1438a8..6588dfab1073 100644 --- a/crates/goose/src/agents/subagent_execution_tool/lib/mod.rs +++ b/crates/goose/src/agents/subagent_execution_tool/lib/mod.rs @@ -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, diff --git a/crates/goose/src/agents/subagent_execution_tool/task_execution_tracker.rs b/crates/goose/src/agents/subagent_execution_tool/task_execution_tracker.rs index 8833c21e2f48..14ccf586a879 100644 --- a/crates/goose/src/agents/subagent_execution_tool/task_execution_tracker.rs +++ b/crates/goose/src/agents/subagent_execution_tool/task_execution_tracker.rs @@ -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 = 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::>() - .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(), diff --git a/crates/goose/src/agents/subagent_execution_tool/task_types.rs b/crates/goose/src/agents/subagent_execution_tool/task_types.rs index 3defebd02301..91d1ec6d3a06 100644 --- a/crates/goose/src/agents/subagent_execution_tool/task_types.rs +++ b/crates/goose/src/agents/subagent_execution_tool/task_types.rs @@ -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>, } #[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> { - matches!(self.task_type, TaskType::SubRecipe) - .then(|| self.payload.get("sub_recipe")?.as_object()) - .flatten() - } - - pub fn get_command_parameters(&self) -> Option<&Map> { - 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)] diff --git a/crates/goose/src/agents/subagent_execution_tool/tasks.rs b/crates/goose/src/agents/subagent_execution_tool/tasks.rs index f8a46ae7ace8..ceec8f474f77 100644 --- a/crates/goose/src/agents/subagent_execution_tool/tasks.rs +++ b/crates/goose/src/agents/subagent_execution_tool/tasks.rs @@ -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, + _task_execution_tracker: Arc, 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, - task_config: TaskConfig, - cancellation_token: CancellationToken, -) -> Result { - 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 { 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()); - } - }; + 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)) + } + (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, + }; - 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) + 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))?; } } -} - -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, - 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); - } - - 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))? + 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)) } - }; - - 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, -) -> tokio::task::JoinHandle { - 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); - } + _ = cancellation_token.cancelled() => { + Err("Task cancelled".to_string()) } - buffer - }) -} - -fn extract_json_from_line(line: &str) -> Option { - 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::(potential_json).is_ok() { - Some(potential_json.to_string()) - } else { - None - } -} - -fn process_output(stdout_output: String) -> Result { - 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)) } } diff --git a/crates/goose/src/agents/subagent_execution_tool/tasks_manager.rs b/crates/goose/src/agents/subagent_execution_tool/tasks_manager.rs index b416518a9cbc..bec13114449d 100644 --- a/crates/goose/src/agents/subagent_execution_tool/tasks_manager.rs +++ b/crates/goose/src/agents/subagent_execution_tool/tasks_manager.rs @@ -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, + }, } } diff --git a/crates/goose/src/agents/subagent_execution_tool/utils/mod.rs b/crates/goose/src/agents/subagent_execution_tool/utils/mod.rs index e1e48835c7c3..58de4c27d288 100644 --- a/crates/goose/src/agents/subagent_execution_tool/utils/mod.rs +++ b/crates/goose/src/agents/subagent_execution_tool/utils/mod.rs @@ -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) -> (usize, usize, usize, usize, usize) { diff --git a/crates/goose/src/agents/subagent_execution_tool/utils/tests.rs b/crates/goose/src/agents/subagent_execution_tool/utils/tests.rs index 3e5c5cd291a2..1ef64d8b2d6a 100644 --- a/crates/goose/src/agents/subagent_execution_tool/utils/tests.rs +++ b/crates/goose/src/agents/subagent_execution_tool/utils/tests.rs @@ -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) } diff --git a/crates/goose/src/agents/subagent_handler.rs b/crates/goose/src/agents/subagent_handler.rs index e02cf6427c41..561ba7962722 100644 --- a/crates/goose/src/agents/subagent_handler.rs +++ b/crates/goose/src/agents/subagent_handler.rs @@ -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)>> + 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 { - 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, - ) - })?; - - // Extract text content based on return_last_only flag + 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); + } + 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 = 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> + 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)) }) } diff --git a/crates/goose/src/recipe/mod.rs b/crates/goose/src/recipe/mod.rs index 9f6e27ce1127..6549d0f486f0 100644 --- a/crates/goose/src/recipe/mod.rs +++ b/crates/goose/src/recipe/mod.rs @@ -51,9 +51,6 @@ pub struct Recipe { )] pub extensions: Option>, // a list of extensions to enable - #[serde(skip_serializing_if = "Option::is_none")] - pub context: Option>, // any additional context - #[serde(skip_serializing_if = "Option::is_none")] pub settings: Option, // settings for the recipe @@ -204,7 +201,6 @@ pub struct RecipeBuilder { // Optional fields prompt: Option, extensions: Option>, - context: Option>, settings: Option, activities: Option>, author: Option, @@ -242,7 +238,6 @@ impl Recipe { instructions: None, prompt: None, extensions: None, - context: None, settings: None, activities: None, author: None, @@ -317,11 +312,6 @@ impl RecipeBuilder { self } - pub fn context(mut self, context: Vec) -> Self { - self.context = Some(context); - self - } - pub fn settings(mut self, settings: Settings) -> Self { self.settings = Some(settings); self @@ -372,7 +362,6 @@ impl RecipeBuilder { instructions: self.instructions, prompt: self.prompt, extensions: self.extensions, - context: self.context, settings: self.settings, activities: self.activities, author: self.author, @@ -711,7 +700,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, diff --git a/crates/goose/src/recipe/read_recipe_file_content.rs b/crates/goose/src/recipe/read_recipe_file_content.rs index 61b740304a04..740e61a2a905 100644 --- a/crates/goose/src/recipe/read_recipe_file_content.rs +++ b/crates/goose/src/recipe/read_recipe_file_content.rs @@ -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, diff --git a/crates/goose/src/scheduler.rs b/crates/goose/src/scheduler.rs index 84ce29560a23..509450502ee5 100644 --- a/crates/goose/src/scheduler.rs +++ b/crates/goose/src/scheduler.rs @@ -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, diff --git a/crates/goose/tests/dynamic_task_tools_tests.rs b/crates/goose/tests/dynamic_task_tools_tests.rs index 46d0b6cc0ff5..14e349b010ee 100644 --- a/crates/goose/tests/dynamic_task_tools_tests.rs +++ b/crates/goose/tests/dynamic_task_tools_tests.rs @@ -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()); } diff --git a/crates/goose/tests/task_types_tests.rs b/crates/goose/tests/task_types_tests.rs deleted file mode 100644 index 970d0286bd5d..000000000000 --- a/crates/goose/tests/task_types_tests.rs +++ /dev/null @@ -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::("\"inline_recipe\"").unwrap(), - TaskType::InlineRecipe - ); - assert_eq!( - serde_json::from_str::("\"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::("\"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()); -} diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index d7412d5bf626..3b37413f9c13 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -3758,13 +3758,6 @@ ], "nullable": true }, - "context": { - "type": "array", - "items": { - "type": "string" - }, - "nullable": true - }, "description": { "type": "string" }, diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 6468fd3ee3cb..d2154abff289 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -547,7 +547,6 @@ export type RawTextContent = { export type Recipe = { activities?: Array | null; author?: Author | null; - context?: Array | null; description: string; extensions?: Array | null; instructions?: string | null; diff --git a/ui/desktop/src/components/schedule/CreateScheduleModal.tsx b/ui/desktop/src/components/schedule/CreateScheduleModal.tsx index 11663e59dd49..a9d9f1c3fd80 100644 --- a/ui/desktop/src/components/schedule/CreateScheduleModal.tsx +++ b/ui/desktop/src/components/schedule/CreateScheduleModal.tsx @@ -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,