Unify subrecipe and subagent execution through shared recipe pipeline (#5082)

This commit is contained in:
tlongwell-block
2025-11-06 19:02:03 -05:00
committed by GitHub
parent 3bf769edfe
commit 20ba2fdb7e
22 changed files with 261 additions and 616 deletions
@@ -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,
+7 -7
View File
@@ -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();
+17
View File
@@ -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(&params);
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)
}
+47 -21
View File
@@ -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))
})
}
-12
View File
@@ -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,
+1 -1
View File
@@ -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(&params, &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());
}
-126
View File
@@ -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());
}
-7
View File
@@ -3762,13 +3762,6 @@
],
"nullable": true
},
"context": {
"type": "array",
"items": {
"type": "string"
},
"nullable": true
},
"description": {
"type": "string"
},
-1
View File
@@ -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,