Align Dynamic Task Interface with Recipe Interface (#4311)

This commit is contained in:
tlongwell-block
2025-09-03 19:04:19 -04:00
committed by GitHub
parent bfa223fc50
commit 3897345ca6
17 changed files with 1163 additions and 219 deletions
+31 -10
View File
@@ -416,13 +416,8 @@ fn render_text_editor_request(call: &ToolCall, debug: bool) {
fn render_shell_request(call: &ToolCall, debug: bool) {
print_tool_header(call);
match call.arguments.get("command") {
Some(Value::String(s)) => {
println!("{}: {}", style("command").dim(), style(s).green());
}
_ => print_params(&call.arguments, 0, debug),
}
print_params(&call.arguments, 0, debug);
println!();
}
fn render_dynamic_task_request(call: &ToolCall, debug: bool) {
@@ -442,10 +437,36 @@ fn render_dynamic_task_request(call: &ToolCall, debug: bool) {
// For strings, print the full content without truncation
println!(" {}: {}", style(key).dim(), style(s).green());
}
Value::Array(arr) => {
// For arrays, print each item on its own line
println!(" {}:", style(key).dim());
for item in arr {
if let Value::String(s) = item {
println!(" - {}", style(s).green());
} else if let Value::Object(_) = item {
// For objects in arrays, print them with indentation
print!(" - ");
print_params(item, 3, debug);
} else {
println!(
" - {}",
style(format!("{}", item)).green()
);
}
}
}
Value::Object(_) => {
// For objects, print them with proper indentation
println!(" {}:", style(key).dim());
print_params(value, 2, debug);
}
_ => {
// For everything else, use print_params
print!(" ");
print_params(value, 0, debug);
// For other types (numbers, booleans, null)
println!(
" {}: {}",
style(key).dim(),
style(format!("{}", value)).green()
);
}
}
}
+12 -1
View File
@@ -455,7 +455,18 @@ impl Agent {
)
.await
} else if tool_call.name == DYNAMIC_TASK_TOOL_NAME_PREFIX {
create_dynamic_task(tool_call.arguments.clone(), &self.tasks_manager).await
// Get loaded extensions for shortname resolution
let loaded_extensions = self
.extension_manager
.list_extensions()
.await
.unwrap_or_default();
create_dynamic_task(
tool_call.arguments.clone(),
&self.tasks_manager,
loaded_extensions,
)
.await
} else if tool_call.name == PLATFORM_READ_RESOURCE_TOOL_NAME {
// Check if the tool is read_resource and handle it separately
ToolCallResult::from(
+1 -1
View File
@@ -7,7 +7,7 @@ pub mod final_output_tool;
mod large_response_handler;
pub mod platform_tools;
pub mod prompt_manager;
mod recipe_tools;
pub mod recipe_tools;
mod reply_parts;
pub mod retry;
mod router_tool_selector;
@@ -2,9 +2,15 @@
// Module: Dynamic Task Tools
// Handles creation of tasks dynamically without sub-recipes
// =======================================
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};
use crate::agents::subagent_execution_tool::{
lib::ExecutionMode,
task_types::{Task, TaskType},
};
use crate::agents::tool_execution::ToolCallResult;
use crate::recipe::{Recipe, RecipeBuilder};
use anyhow::{anyhow, Result};
use rmcp::model::{Content, ErrorCode, ErrorData, Tool, ToolAnnotations};
use rmcp::object;
use serde_json::{json, Value};
@@ -15,58 +21,207 @@ pub const DYNAMIC_TASK_TOOL_NAME_PREFIX: &str = "dynamic_task__create_task";
pub fn create_dynamic_task_tool() -> Tool {
Tool::new(
DYNAMIC_TASK_TOOL_NAME_PREFIX.to_string(),
"Use this tool to create one or more dynamic tasks from a shared text instruction and varying parameters.\
How it works:
- Provide a single text instruction
- Use the 'task_parameters' field to pass an array of parameter sets
- Each resulting task will use the same instruction with different parameter values
This is useful when performing the same operation across many inputs (e.g., getting weather for multiple cities, searching multiple slack channels, iterating through various linear tickets, etc).
Once created, these tasks should be passed to the 'subagent__execute_task' tool for execution. Tasks can run sequentially or in parallel.
---
What is a 'subagent'?
A 'subagent' is a stateless sub-process that executes a single task independently. Use subagents when:
- You want to parallelize similar work across different inputs
- You are not sure your search or operation will succeed on the first try
Each subagent receives a task with a defined payload and returns a result, which is not visible to the user unless explicitly summarized by the system.
---
Examples of 'task_parameters' for a single task:
text_instruction: Search for the config file in the root directory.
Examples of 'task_parameters' for multiple tasks:
text_instruction: Get weather for Melbourne.
text_instruction: Get weather for Los Angeles.
text_instruction: Get weather for San Francisco.
".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(),
object!({
"type": "object",
"properties": {
"task_parameters": {
"type": "array",
"description": "Array of parameter sets for creating tasks. \
For a single task, provide an array with one element. \
For multiple tasks, provide an array with multiple elements, each with different parameter values. \
If there is no parameter set, provide an empty array.",
"description": "Array of tasks. Each needs 'instructions' OR 'prompt'.",
"items": {
"type": "object",
"properties": {
"text_instruction": {
// Required (one of these)
"instructions": {
"type": "string",
"description": "The text instruction to execute"
"description": "Task instructions"
},
"prompt": {
"type": "string",
"description": "Initial prompt"
},
// Optional - auto-generated if not provided
"title": {"type": "string"},
"description": {"type": "string"},
"extensions": {"type": "array"},
"settings": {"type": "object"},
"parameters": {"type": "array"},
"response": {"type": "object"},
"retry": {"type": "object"},
"context": {"type": "array"},
"activities": {"type": "array"},
"return_last_only": {
"type": "boolean",
"description": "If true, return only the last message from the subagent (default: false, returns full conversation)"
}
},
"required": ["text_instruction"]
}
"anyOf": [
{"required": ["instructions"]},
{"required": ["prompt"]}
]
},
"minItems": 1
},
"execution_mode": {
"type": "string",
"enum": ["sequential", "parallel"],
"description": "How to execute multiple tasks (default: parallel for multiple tasks, sequential for single task)"
}
}
},
"required": ["task_parameters"]
})
).annotate(ToolAnnotations {
title: Some("Dynamic Task Creation".to_string()),
title: Some("Create Dynamic Tasks".to_string()),
read_only_hint: Some(false),
destructive_hint: Some(true),
destructive_hint: Some(false),
idempotent_hint: Some(false),
open_world_hint: Some(true),
})
}
fn process_extensions(
extensions: &Value,
_loaded_extensions: &[String],
) -> Option<Vec<ExtensionConfig>> {
// First try to deserialize as ExtensionConfig array
if let Ok(ext_configs) = serde_json::from_value::<Vec<ExtensionConfig>>(extensions.clone()) {
return Some(ext_configs);
}
// Try to handle mixed array of strings and objects
if let Some(arr) = extensions.as_array() {
// If the array is empty, return an empty Vec (not None)
// This is important: empty array means "no extensions"
if arr.is_empty() {
return Some(Vec::new());
}
let mut converted_extensions = Vec::new();
for ext in arr {
if let Some(name_str) = ext.as_str() {
// Look up the full extension config by name
match crate::config::ExtensionConfigManager::get_config_by_name(name_str) {
Ok(Some(config)) => {
// Check if the extension is enabled
if crate::config::ExtensionConfigManager::is_enabled(&config.key())
.unwrap_or(false)
{
converted_extensions.push(config);
} else {
tracing::warn!("Extension '{}' is disabled, skipping", name_str);
}
}
Ok(None) => {
tracing::warn!("Extension '{}' not found in configuration", name_str);
}
Err(e) => {
tracing::warn!("Error looking up extension '{}': {}", name_str, e);
}
}
} else if let Ok(ext_config) = serde_json::from_value::<ExtensionConfig>(ext.clone()) {
converted_extensions.push(ext_config);
}
}
// Return the converted extensions even if empty
// (empty means user explicitly wants no extensions)
return Some(converted_extensions);
}
None
}
// Helper function to apply recipe builder methods if value can be deserialized
fn apply_if_ok<T: serde::de::DeserializeOwned>(
builder: RecipeBuilder,
value: Option<&Value>,
f: impl FnOnce(RecipeBuilder, T) -> RecipeBuilder,
) -> RecipeBuilder {
match value.and_then(|v| serde_json::from_value(v.clone()).ok()) {
Some(parsed) => f(builder, parsed),
None => builder,
}
}
pub fn task_params_to_inline_recipe(
task_param: &Value,
loaded_extensions: &[String],
) -> Result<Recipe> {
// Extract and validate core fields
let instructions = task_param.get("instructions").and_then(|v| v.as_str());
let prompt = task_param.get("prompt").and_then(|v| v.as_str());
if instructions.is_none() && prompt.is_none() {
return Err(anyhow!("Either 'instructions' or 'prompt' is required"));
}
// Build recipe with auto-generated defaults
let mut builder = Recipe::builder()
.version("1.0.0")
.title(
task_param
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("Dynamic Task"),
)
.description(
task_param
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("Inline recipe task"),
);
// Set instructions/prompt
if let Some(inst) = instructions {
builder = builder.instructions(inst);
}
if let Some(p) = prompt {
builder = builder.prompt(p);
}
// Handle extensions
if let Some(extensions) = task_param.get("extensions") {
if let Some(ext_configs) = process_extensions(extensions, loaded_extensions) {
builder = builder.extensions(ext_configs);
}
}
// Handle other optional fields
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"),
RecipeBuilder::activities,
);
builder = apply_if_ok(
builder,
task_param.get("parameters"),
RecipeBuilder::parameters,
);
// Build and validate
let recipe = builder
.build()
.map_err(|e| anyhow!("Failed to build recipe: {}", e))?;
// Security validation
if recipe.check_for_security_warnings() {
return Err(anyhow!("Recipe contains potentially harmful content"));
}
// Validate retry config if present
if let Some(ref retry) = recipe.retry {
retry
.validate()
.map_err(|e| anyhow!("Invalid retry config: {}", e))?;
}
Ok(recipe)
}
fn extract_task_parameters(params: &Value) -> Vec<Value> {
params
.get("task_parameters")
@@ -75,29 +230,6 @@ fn extract_task_parameters(params: &Value) -> Vec<Value> {
.unwrap_or_default()
}
fn create_text_instruction_tasks_from_params(task_params: &[Value]) -> Vec<Task> {
task_params
.iter()
.map(|task_param| {
let text_instruction = task_param
.get("text_instruction")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let payload = json!({
"text_instruction": text_instruction
});
Task {
id: uuid::Uuid::new_v4().to_string(),
task_type: "text_instruction".to_string(),
payload,
}
})
.collect()
}
fn create_task_execution_payload(tasks: Vec<Task>, execution_mode: ExecutionMode) -> Value {
let task_ids: Vec<String> = tasks.iter().map(|task| task.id.clone()).collect();
json!({
@@ -106,25 +238,79 @@ fn create_task_execution_payload(tasks: Vec<Task>, execution_mode: ExecutionMode
})
}
pub async fn create_dynamic_task(params: Value, tasks_manager: &TasksManager) -> ToolCallResult {
pub async fn create_dynamic_task(
params: Value,
tasks_manager: &TasksManager,
loaded_extensions: Vec<String>,
) -> ToolCallResult {
let task_params_array = extract_task_parameters(&params);
if task_params_array.is_empty() {
return ToolCallResult::from(Err(ErrorData {
code: ErrorCode::INTERNAL_ERROR,
message: Cow::from("No task parameters provided"),
code: ErrorCode::INVALID_PARAMS,
message: Cow::from("task_parameters array cannot be empty"),
data: None,
}));
}
let tasks = create_text_instruction_tasks_from_params(&task_params_array);
// Convert each parameter set to inline recipe and create tasks
let mut tasks = Vec::new();
for task_param in &task_params_array {
// 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,
}));
}
};
// Use parallel execution if there are multiple tasks, sequential for single task
let execution_mode = if tasks.len() > 1 {
ExecutionMode::Parallel
} else {
ExecutionMode::Sequential
};
// Extract return_last_only flag if present
let return_last_only = task_param
.get("return_last_only")
.and_then(|v| v.as_bool())
.unwrap_or(false);
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
}),
};
tasks.push(task);
}
Err(e) => {
return ToolCallResult::from(Err(ErrorData {
code: ErrorCode::INVALID_PARAMS,
message: Cow::from(format!("Invalid task parameters: {}", e)),
data: None,
}));
}
}
}
let execution_mode = params
.get("execution_mode")
.and_then(|v| v.as_str())
.map(|s| match s {
"sequential" => ExecutionMode::Sequential,
"parallel" => ExecutionMode::Parallel,
_ => ExecutionMode::Parallel,
})
.unwrap_or_else(|| {
if tasks.len() > 1 {
ExecutionMode::Parallel
} else {
ExecutionMode::Sequential
}
});
let task_execution_payload = create_task_execution_payload(tasks.clone(), execution_mode);
@@ -138,6 +324,7 @@ pub async fn create_dynamic_task(params: Value, tasks_manager: &TasksManager) ->
}))
}
};
tasks_manager.save_tasks(tasks.clone()).await;
tasks_manager.save_tasks(tasks).await;
ToolCallResult::from(Ok(vec![Content::text(tasks_json)]))
}
@@ -6,7 +6,8 @@ use anyhow::Result;
use rmcp::model::{Tool, ToolAnnotations};
use serde_json::{json, Map, Value};
use crate::agents::subagent_execution_tool::lib::{ExecutionMode, Task};
use crate::agents::subagent_execution_tool::lib::ExecutionMode;
use crate::agents::subagent_execution_tool::task_types::{Task, TaskType};
use crate::agents::subagent_execution_tool::tasks_manager::TasksManager;
use crate::recipe::{Recipe, RecipeParameter, RecipeParameterRequirement, SubRecipe};
@@ -67,7 +68,7 @@ fn create_tasks_from_params(
});
Task {
id: uuid::Uuid::new_v4().to_string(),
task_type: "sub_recipe".to_string(),
task_type: TaskType::SubRecipe,
payload,
}
})
+17 -12
View File
@@ -59,20 +59,25 @@ impl SubAgent {
// Create a new extension manager for this subagent
let extension_manager = ExtensionManager::new();
// Add extensions based on task_type:
// 1. If executing dynamic task (task_type = 'text_instruction'), default to using all enabled extensions
// 2. (TODO) If executing a sub-recipe task, only use recipe extensions
// Determine which extensions to add:
// 1. If task_config.extensions is Some(vec), use those specific extensions
// 2. If task_config.extensions is None, use all enabled extensions (backward compatibility)
// Get all enabled extensions from config
let enabled_extensions = ExtensionConfigManager::get_all()
.unwrap_or_default()
.into_iter()
.filter(|ext| ext.enabled)
.map(|ext| ext.config)
.collect::<Vec<ExtensionConfig>>();
let extensions_to_add = if let Some(ref extensions) = task_config.extensions {
// Use the explicitly specified extensions
extensions.clone()
} else {
// Default behavior: use all enabled extensions
ExtensionConfigManager::get_all()
.unwrap_or_default()
.into_iter()
.filter(|ext| ext.enabled)
.map(|ext| ext.config)
.collect::<Vec<ExtensionConfig>>()
};
// Add enabled extensions to the subagent's extension manager
for extension in enabled_extensions {
// Add the determined extensions to the subagent's extension manager
for extension in extensions_to_add {
if let Err(e) = extension_manager.add_extension(extension).await {
debug!("Failed to add extension to subagent: {}", e);
// Continue with other extensions even if one fails
@@ -44,8 +44,6 @@ fn format_task_metadata(task_info: &TaskInfo) -> String {
})
.collect::<Vec<_>>()
.join(",")
} else if let Some(text_instruction) = task_info.task.get_text_instruction() {
format!("instruction={}", text_instruction)
} else {
String::new()
}
@@ -234,7 +232,7 @@ impl TaskExecutionTracker {
}
}),
current_output: task_info.current_output.clone(),
task_type: task_info.task.task_type.clone(),
task_type: task_info.task.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,5 +1,6 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::fmt;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use tokio::sync::mpsc;
@@ -15,16 +16,32 @@ 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 Task {
pub id: String,
pub task_type: String,
pub task_type: TaskType,
pub payload: Value,
}
impl Task {
pub fn get_sub_recipe(&self) -> Option<&Map<String, Value>> {
(self.task_type == "sub_recipe")
matches!(self.task_type, TaskType::SubRecipe)
.then(|| self.payload.get("sub_recipe")?.as_object())
.flatten()
}
@@ -52,16 +69,6 @@ impl Task {
.and_then(|sr| sr.get("recipe_path"))
.and_then(|path| path.as_str())
}
pub fn get_text_instruction(&self) -> Option<&str> {
if self.task_type != "sub_recipe" {
self.payload
.get("text_instruction")
.and_then(|text| text.as_str())
} else {
None
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -6,9 +6,8 @@ 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};
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_handler::run_complete_subagent_task;
use crate::agents::subagent_task_config::TaskConfig;
pub async fn process_task(
@@ -46,60 +45,71 @@ async fn get_task_result(
task_config: TaskConfig,
cancellation_token: CancellationToken,
) -> Result<Value, String> {
if task.task_type == "text_instruction" {
// Handle text_instruction tasks using subagent system
handle_text_instruction_task(
task,
task_execution_tracker,
task_config,
cancellation_token,
)
.await
} else {
// Handle sub_recipe tasks using command execution
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?;
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))
if success {
process_output(stdout_output)
} else {
Err(format!("Command failed:\n{}", &stderr_output))
}
}
}
}
async fn handle_text_instruction_task(
async fn handle_inline_recipe_task(
task: Task,
task_execution_tracker: Arc<TaskExecutionTracker>,
task_config: TaskConfig,
mut task_config: TaskConfig,
cancellation_token: CancellationToken,
) -> Result<Value, String> {
let text_instruction = task
.get_text_instruction()
.ok_or_else(|| format!("Task {}: Missing text_instruction", task.id))?;
use crate::agents::subagent_handler::run_complete_subagent_task_with_options;
use crate::recipe::Recipe;
// Start tracking the task
task_execution_tracker.start_task(&task.id).await;
let recipe_value = task
.payload
.get("recipe")
.ok_or_else(|| "Missing recipe in inline_recipe task payload".to_string())?;
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);
task_config.extensions = recipe.extensions.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(text_instruction.to_string(), task_config) => result,
result = run_complete_subagent_task_with_options(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!("Subagent execution failed: {}", e);
let error_msg = format!("Inline recipe execution failed: {}", e);
Err(error_msg)
}
}
@@ -108,36 +118,39 @@ async fn handle_text_instruction_task(
fn build_command(task: &Task) -> Result<(Command, String), String> {
let task_error = |field: &str| format!("Task {}: Missing {}", task.id, field);
let (mut command, output_identifier) = if task.task_type == "sub_recipe" {
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"))?;
if !matches!(task.task_type, TaskType::SubRecipe) {
return Err("Only sub-recipe tasks can be executed as commands".to_string());
}
let mut cmd = Command::new("goose");
cmd.arg("run").arg("--recipe").arg(path).arg("--no-session");
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"))?;
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();
cmd.arg("--params")
.arg(format!("{}={}", key_str, value_str));
}
(cmd, format!("sub-recipe {}", sub_recipe_name))
} else {
// This branch should not be reached for text_instruction tasks anymore
// as they are handled in handle_text_instruction_task
return Err("Text instruction tasks are handled separately".to_string());
};
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, output_identifier))
Ok((command, format!("sub-recipe {}", sub_recipe_name)))
}
async fn run_command(
@@ -174,7 +187,7 @@ async fn run_command(
if let Err(e) = child.kill().await {
tracing::warn!("Failed to kill child process: {}", e);
}
// Abort the output reading tasks
stdout_task.abort();
stderr_task.abort();
return Err("Command cancelled".to_string());
@@ -4,6 +4,8 @@ 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 {
@@ -60,7 +62,7 @@ mod tests {
fn create_test_task(id: &str, sub_recipe_name: &str) -> Task {
Task {
id: id.to_string(),
task_type: "sub_recipe".to_string(),
task_type: TaskType::SubRecipe,
payload: json!({
"sub_recipe": {
"name": sub_recipe_name,
@@ -1,4 +1,4 @@
use crate::agents::subagent_execution_tool::task_types::{Task, TaskInfo, TaskStatus};
use crate::agents::subagent_execution_tool::task_types::{Task, TaskInfo, TaskStatus, TaskType};
use crate::agents::subagent_execution_tool::utils::{
count_by_status, get_task_name, strip_ansi_codes,
};
@@ -23,7 +23,7 @@ mod test_get_task_name {
fn test_extracts_sub_recipe_name() {
let sub_recipe_task = Task {
id: "task_1".to_string(),
task_type: "sub_recipe".to_string(),
task_type: TaskType::SubRecipe,
payload: json!({
"sub_recipe": {
"name": "my_recipe",
@@ -38,14 +38,14 @@ mod test_get_task_name {
}
#[test]
fn falls_back_to_task_id_for_text_instruction() {
let text_task = Task {
fn falls_back_to_task_id_for_inline_recipe() {
let inline_task = Task {
id: "task_2".to_string(),
task_type: "text_instruction".to_string(),
payload: json!({"text_instruction": "do something"}),
task_type: TaskType::InlineRecipe,
payload: json!({"recipe": {"instructions": "do something"}}),
};
let task_info = create_task_info_with_defaults(text_task, TaskStatus::Pending);
let task_info = create_task_info_with_defaults(inline_task, TaskStatus::Pending);
assert_eq!(get_task_name(&task_info), "task_2");
}
@@ -54,7 +54,7 @@ mod test_get_task_name {
fn falls_back_to_task_id_when_sub_recipe_name_missing() {
let malformed_task = Task {
id: "task_3".to_string(),
task_type: "sub_recipe".to_string(),
task_type: TaskType::SubRecipe,
payload: json!({
"sub_recipe": {
"recipe_path": "/path/to/recipe"
@@ -72,7 +72,7 @@ mod test_get_task_name {
fn falls_back_to_task_id_when_sub_recipe_missing() {
let malformed_task = Task {
id: "task_4".to_string(),
task_type: "sub_recipe".to_string(),
task_type: TaskType::SubRecipe,
payload: json!({}), // missing "sub_recipe" field
};
@@ -88,7 +88,7 @@ mod count_by_status {
fn create_test_task(id: &str, status: TaskStatus) -> TaskInfo {
let task = Task {
id: id.to_string(),
task_type: "test".to_string(),
task_type: TaskType::InlineRecipe,
payload: json!({}),
};
create_task_info_with_defaults(task, status)
+58 -30
View File
@@ -7,6 +7,15 @@ use rmcp::model::{ErrorCode, ErrorData};
pub async fn run_complete_subagent_task(
text_instruction: String,
task_config: TaskConfig,
) -> Result<String, anyhow::Error> {
run_complete_subagent_task_with_options(text_instruction, task_config, false).await
}
/// Standalone function to run a complete subagent task with output options
pub async fn run_complete_subagent_task_with_options(
text_instruction: String,
task_config: TaskConfig,
return_last_only: bool,
) -> Result<String, anyhow::Error> {
// Create the subagent with the parent agent's provider
let subagent = SubAgent::new(task_config.clone()).await.map_err(|e| {
@@ -22,46 +31,65 @@ pub async fn run_complete_subagent_task(
.reply_subagent(text_instruction, task_config)
.await?;
// Extract all text content from all messages
let all_text_content: Vec<String> = messages
.iter()
.flat_map(|message| {
message.content.iter().filter_map(|content| {
match content {
// 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()
.and_then(|message| {
message.content.iter().find_map(|content| match content {
crate::conversation::message::MessageContent::Text(text_content) => {
Some(text_content.text.clone())
}
crate::conversation::message::MessageContent::ToolResponse(tool_response) => {
// Extract text from tool response
if let Ok(contents) = &tool_response.tool_result {
let texts: Vec<String> = contents
.iter()
.filter_map(|content| {
if let rmcp::model::RawContent::Text(raw_text_content) =
&content.raw
{
Some(raw_text_content.text.clone())
} else {
None
}
})
.collect();
if !texts.is_empty() {
Some(format!("Tool result: {}", texts.join("\n")))
_ => None,
})
})
.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| {
message.content.iter().filter_map(|content| {
match content {
crate::conversation::message::MessageContent::Text(text_content) => {
Some(text_content.text.clone())
}
crate::conversation::message::MessageContent::ToolResponse(
tool_response,
) => {
// Extract text from tool response
if let Ok(contents) = &tool_response.tool_result {
let texts: Vec<String> = contents
.iter()
.filter_map(|content| {
if let rmcp::model::RawContent::Text(raw_text_content) =
&content.raw
{
Some(raw_text_content.text.clone())
} else {
None
}
})
.collect();
if !texts.is_empty() {
Some(format!("Tool result: {}", texts.join("\n")))
} else {
None
}
} else {
None
}
} else {
None
}
_ => None,
}
_ => None,
}
})
})
})
.collect();
.collect();
let response_text = all_text_content.join("\n");
all_text_content.join("\n")
};
// Return the result
Ok(response_text)
@@ -5,7 +5,7 @@ use std::sync::Arc;
use uuid::Uuid;
/// Default maximum number of turns for task execution
pub const DEFAULT_SUBAGENT_MAX_TURNS: usize = 5;
pub const DEFAULT_SUBAGENT_MAX_TURNS: usize = 25;
/// Environment variable name for configuring max turns
pub const GOOSE_SUBAGENT_MAX_TURNS_ENV_VAR: &str = "GOOSE_SUBAGENT_MAX_TURNS";
@@ -16,6 +16,7 @@ pub struct TaskConfig {
pub id: String,
pub provider: Option<Arc<dyn Provider>>,
pub max_turns: Option<usize>,
pub extensions: Option<Vec<crate::agents::extension::ExtensionConfig>>,
}
impl fmt::Debug for TaskConfig {
@@ -24,6 +25,7 @@ impl fmt::Debug for TaskConfig {
.field("id", &self.id)
.field("provider", &"<dyn Provider>")
.field("max_turns", &self.max_turns)
.field("extensions", &self.extensions)
.finish()
}
}
@@ -40,6 +42,7 @@ impl TaskConfig {
.and_then(|val| val.parse::<usize>().ok())
.unwrap_or(DEFAULT_SUBAGENT_MAX_TURNS),
),
extensions: None,
}
}
+2 -1
View File
@@ -31,5 +31,6 @@ You have access to {{tool_count}} tools: {{available_tools}}
- **Completion**: Clearly indicate when your task is complete
- **Scope**: Stay focused on your assigned task
- **Format**: Use Markdown formatting for responses
- **Summarization**: If asked for a summary or report of your work, that should be the last message you generate
Remember: You are part of a larger system. Your specialized focus helps the main agent handle multiple concerns efficiently. Complete your task efficiently with less tool usage.
Remember: You are part of a larger system. Your specialized focus helps the main agent handle multiple concerns efficiently. Complete your task efficiently with less tool usage.
+15 -6
View File
@@ -40,21 +40,30 @@ No extensions are defined. You should let the user know that they should add ext
# Task Management
- Required — use `todo__read` and `todo__write` for any task with 2+ steps, multiple files/components, or uncertain scope. Skipping them is an error.
- Start — `todo__read`, then `todo__write` a brief checklist (Markdown checkboxes).
- During — after each major action, update via `todo__write`: mark done, add/edit items, note blockers/dependencies.
- Finish — ensure every item is checked, or clearly list what remains.
- Overwrite warning — `todo__write` replaces the entire list; always read before writing. It is an error to not read before writing.
- Quality — keep items short, specific, and actionoriented.
- Use `todo__read` and `todo__write` for tasks with 2+ steps, multiple files/components, or uncertain scope
- Workflow — Start: read → write checklist | During: read → update progress | End: verify all complete
- Warning — `todo__write` overwrites entirely; always `todo__read` first (skipping is an error)
- Keep items short, specific, action-oriented
- Not using the todo tools for complex tasks is an error
Template:
```markdown
- [ ] Implement feature X
- [ ] Update API
- [ ] Write tests
- [ ] Run tests (subagent in parallel)
- [ ] Run lint (subagent in parallel)
- [ ] Blocked: waiting on credentials
```
Execute via subagent by default — only handle directly when step-by-step visibility is essential.
- Delegate via `dynamic_task__create_task` for: result-only operations, parallelizable work, multi-part requests, verification, exploration
- Parallel subagents for multiple operations, single subagents for independent work
- Explore solutions in parallel — launch parallel subagents with different approaches (if non-interfering)
- Provide all needed context — subagents cannot see your context
- Use extension filters to limit resource access
- Use return_last_only when only a summary or simple answer is required — inform subagent of this choice.
# Response Guidelines
- Use Markdown formatting for all responses.
@@ -0,0 +1,532 @@
use goose::agents::recipe_tools::dynamic_task_tools::{
create_dynamic_task, task_params_to_inline_recipe,
};
use serde_json::json;
#[cfg(test)]
mod tests {
use super::*;
// Helper function to create a list of loaded extensions for testing
fn test_loaded_extensions() -> Vec<String> {
vec!["developer".to_string(), "memory".to_string()]
}
#[test]
fn test_minimal_task_with_instructions() {
let params = json!({
"instructions": "Test task"
});
let recipe = task_params_to_inline_recipe(&params, &test_loaded_extensions()).unwrap();
assert_eq!(recipe.instructions, Some("Test task".to_string()));
assert_eq!(recipe.title, "Dynamic Task");
assert_eq!(recipe.description, "Inline recipe task");
}
#[test]
fn test_minimal_task_with_prompt() {
let params = json!({
"prompt": "Test prompt"
});
let recipe = task_params_to_inline_recipe(&params, &test_loaded_extensions()).unwrap();
assert_eq!(recipe.prompt, Some("Test prompt".to_string()));
}
#[test]
fn test_missing_required_fields() {
let params = json!({
"title": "Test"
});
let result = task_params_to_inline_recipe(&params, &test_loaded_extensions());
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("instructions' or 'prompt"));
}
#[test]
fn test_with_recipe_fields() {
let params = json!({
"instructions": "Test",
"title": "Custom Title",
"description": "Custom Description",
"retry": {
"max_retries": 3,
"checks": [
{
"type": "shell",
"command": "echo test"
}
]
},
"response": {
"json_schema": {
"type": "object"
}
}
});
let recipe = task_params_to_inline_recipe(&params, &test_loaded_extensions()).unwrap();
assert_eq!(recipe.title, "Custom Title");
assert_eq!(recipe.description, "Custom Description");
assert!(recipe.retry.is_some());
assert!(recipe.response.is_some());
// Verify retry config details
let retry = recipe.retry.unwrap();
assert_eq!(retry.max_retries, 3);
assert_eq!(retry.checks.len(), 1);
}
#[test]
fn test_security_validation() {
let params = json!({
"instructions": format!("Test{}", '\u{E0041}') // Harmful Unicode tag
});
let result = task_params_to_inline_recipe(&params, &test_loaded_extensions());
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("harmful"));
}
#[tokio::test]
async fn test_create_multiple_tasks() {
use goose::agents::subagent_execution_tool::tasks_manager::TasksManager;
let tasks_manager = TasksManager::new();
let params = json!({
"task_parameters": [
{"instructions": "Task 1"},
{"prompt": "Task 2"}
]
});
let result = create_dynamic_task(params, &tasks_manager, test_loaded_extensions()).await;
// Check that the result is successful by awaiting the future
let tool_result = result.result.await;
assert!(tool_result.is_ok());
let contents = tool_result.unwrap();
assert!(!contents.is_empty());
// Parse the returned JSON to verify task creation
if let Some(text_content) = contents.first().and_then(|c| c.as_text()) {
let task_payload: serde_json::Value = serde_json::from_str(&text_content.text).unwrap();
assert!(task_payload.get("task_ids").is_some());
let task_ids = task_payload.get("task_ids").unwrap().as_array().unwrap();
assert_eq!(task_ids.len(), 2);
}
}
#[test]
fn test_return_last_only_flag() {
let params_with_flag = json!({
"instructions": "Test task",
"return_last_only": true
});
let recipe =
task_params_to_inline_recipe(&params_with_flag, &test_loaded_extensions()).unwrap();
assert_eq!(recipe.instructions, Some("Test task".to_string()));
// The flag should not affect the recipe itself, only the task payload
// We can't test the task creation here without async context
let params_without_flag = json!({
"instructions": "Test task"
});
let recipe2 =
task_params_to_inline_recipe(&params_without_flag, &test_loaded_extensions()).unwrap();
assert_eq!(recipe2.instructions, Some("Test task".to_string()));
}
#[tokio::test]
async fn test_text_instruction_not_supported() {
use goose::agents::subagent_execution_tool::tasks_manager::TasksManager;
let tasks_manager = TasksManager::new();
let params = json!({
"task_parameters": [
{"text_instruction": "Legacy task"}
]
});
let result = create_dynamic_task(params, &tasks_manager, test_loaded_extensions()).await;
// Check that the result fails since text_instruction is no longer supported
let tool_result = result.result.await;
assert!(tool_result.is_err());
// Verify the error message indicates missing required fields
if let Err(err) = tool_result {
let error_msg = err.message.to_string();
assert!(error_msg.contains("instructions") || error_msg.contains("prompt"));
}
}
#[test]
fn test_with_extensions() {
let params = json!({
"instructions": "Test",
"extensions": [
{
"type": "builtin",
"name": "developer"
}
]
});
let recipe = task_params_to_inline_recipe(&params, &test_loaded_extensions()).unwrap();
assert!(recipe.extensions.is_some());
let extensions = recipe.extensions.unwrap();
assert_eq!(extensions.len(), 1);
}
#[test]
fn test_with_context_and_activities() {
let params = json!({
"instructions": "Test",
"context": ["context1", "context2"],
"activities": ["activity1", "activity2"]
});
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"]);
}
#[test]
fn test_invalid_retry_config() {
// Test with max_retries = 0 (invalid)
let params = json!({
"instructions": "Test",
"retry": {
"max_retries": 0, // Invalid: must be > 0
"checks": []
}
});
let result = task_params_to_inline_recipe(&params, &test_loaded_extensions());
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Invalid retry config"));
}
#[test]
fn test_invalid_retry_config_missing_checks() {
// Test with missing required field 'checks'
let params = json!({
"instructions": "Test",
"retry": {
"max_retries": 3
// Missing 'checks' field
}
});
let result = task_params_to_inline_recipe(&params, &test_loaded_extensions());
// This should fail during deserialization since 'checks' is required
assert!(result.is_ok()); // But retry field will be None due to failed deserialization
let recipe = result.unwrap();
assert!(recipe.retry.is_none());
}
// Additional edge case tests
#[test]
fn test_both_instructions_and_prompt() {
// Test that both instructions and prompt can be provided
let params = json!({
"instructions": "Test instructions",
"prompt": "Test prompt"
});
let recipe = task_params_to_inline_recipe(&params, &test_loaded_extensions()).unwrap();
assert_eq!(recipe.instructions, Some("Test instructions".to_string()));
assert_eq!(recipe.prompt, Some("Test prompt".to_string()));
}
#[test]
fn test_empty_task_parameters_array() {
// This test is for the create_dynamic_task function
// We can't test it here without async, but we document the expected behavior
// Empty task_parameters array should return an error
}
#[test]
fn test_invalid_json_in_optional_fields() {
// Test that invalid JSON in optional fields is gracefully ignored
let params = json!({
"instructions": "Test",
"settings": "not an object", // Invalid: should be object
"extensions": "not an array", // Invalid: should be array
"context": {"not": "an array"}, // Invalid: should be array
"activities": 123 // Invalid: should be array
});
let recipe = task_params_to_inline_recipe(&params, &test_loaded_extensions()).unwrap();
assert_eq!(recipe.instructions, Some("Test".to_string()));
// 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());
}
#[test]
fn test_with_settings() {
let params = json!({
"instructions": "Test",
"settings": {
"goose_provider": "openai",
"goose_model": "gpt-4",
"temperature": 0.7
}
});
let recipe = task_params_to_inline_recipe(&params, &test_loaded_extensions()).unwrap();
assert!(recipe.settings.is_some());
let settings = recipe.settings.unwrap();
assert_eq!(settings.goose_provider, Some("openai".to_string()));
assert_eq!(settings.goose_model, Some("gpt-4".to_string()));
assert_eq!(settings.temperature, Some(0.7));
}
#[test]
fn test_with_parameters() {
let params = json!({
"instructions": "Test",
"parameters": [
{
"key": "test_param",
"input_type": "string",
"requirement": "required",
"description": "A test parameter"
}
]
});
let recipe = task_params_to_inline_recipe(&params, &test_loaded_extensions()).unwrap();
assert!(recipe.parameters.is_some());
let parameters = recipe.parameters.unwrap();
assert_eq!(parameters.len(), 1);
assert_eq!(parameters[0].key, "test_param");
}
#[test]
fn test_empty_strings_for_required_fields() {
// Empty strings should be valid for instructions/prompt
let params = json!({
"instructions": ""
});
let recipe = task_params_to_inline_recipe(&params, &test_loaded_extensions()).unwrap();
assert_eq!(recipe.instructions, Some("".to_string()));
}
#[test]
fn test_very_long_instruction() {
// Test with a very long instruction string
let long_instruction = "a".repeat(10000);
let params = json!({
"instructions": long_instruction.clone()
});
let recipe = task_params_to_inline_recipe(&params, &test_loaded_extensions()).unwrap();
assert_eq!(recipe.instructions, Some(long_instruction));
}
#[tokio::test]
async fn test_mixed_valid_and_invalid_tasks() {
use goose::agents::subagent_execution_tool::tasks_manager::TasksManager;
let tasks_manager = TasksManager::new();
let params = json!({
"task_parameters": [
{"instructions": "Valid task"},
{"title": "Invalid - missing instruction"}, // This should cause error
]
});
let result = create_dynamic_task(params, &tasks_manager, test_loaded_extensions()).await;
// Should fail on the invalid task
let tool_result = result.result.await;
assert!(tool_result.is_err());
}
#[test]
fn test_unicode_in_non_instruction_fields() {
// Unicode tags should be allowed in non-instruction fields
let params = json!({
"instructions": "Test",
"title": format!("Title with unicode {}", '\u{E0041}'),
"description": format!("Description with unicode {}", '\u{E0041}')
});
// This should succeed - only instructions/prompt/activities are checked for security
let recipe = task_params_to_inline_recipe(&params, &test_loaded_extensions()).unwrap();
assert!(recipe.title.contains('\u{E0041}'));
assert!(recipe.description.contains('\u{E0041}'));
}
#[test]
fn test_extension_shortnames() {
// Test that extension shortnames are properly resolved
// Note: This test now depends on actual config, so it may not find all extensions
// if they're not configured in the test environment
let loaded_exts = vec!["developer".to_string(), "memory".to_string()];
let params = json!({
"instructions": "Test",
"extensions": ["developer", "memory"]
});
let recipe = task_params_to_inline_recipe(&params, &loaded_exts).unwrap();
assert!(recipe.extensions.is_some());
let extensions = recipe.extensions.unwrap();
// We can't guarantee both extensions exist in config during tests
// Just check that we got some extensions and they have the right structure
assert!(extensions.len() <= 2);
if !extensions.is_empty() {
// Check that the first one is a valid ExtensionConfig
assert!(matches!(
&extensions[0],
goose::agents::extension::ExtensionConfig::Builtin { .. }
| goose::agents::extension::ExtensionConfig::Stdio { .. }
| goose::agents::extension::ExtensionConfig::Sse { .. }
| goose::agents::extension::ExtensionConfig::StreamableHttp { .. }
| goose::agents::extension::ExtensionConfig::Frontend { .. }
| goose::agents::extension::ExtensionConfig::InlinePython { .. }
));
}
}
#[test]
fn test_mixed_extension_formats() {
// Test mixing shortnames and full configs
// Note: Shortnames depend on config being present, which may not exist in CI
let loaded_exts = vec!["developer".to_string(), "memory".to_string()];
let params = json!({
"instructions": "Test",
"extensions": [
"developer", // Shortname - may not resolve in CI
{
"type": "stdio",
"name": "custom",
"cmd": "echo",
"args": ["test"]
}
]
});
let recipe = task_params_to_inline_recipe(&params, &loaded_exts).unwrap();
assert!(recipe.extensions.is_some());
let extensions = recipe.extensions.unwrap();
// At minimum we should get the full config (stdio), shortname may not resolve
assert!(extensions.len() >= 1 && extensions.len() <= 2);
// The last one should always be the stdio config we provided
if let Some(last) = extensions.last() {
match last {
goose::agents::extension::ExtensionConfig::Stdio { name, .. } => {
assert_eq!(name, "custom");
}
_ => {
// If we got 2 extensions, the second should be stdio
if extensions.len() == 2 {
panic!("Expected stdio extension config for 'custom'");
}
}
}
}
}
#[test]
fn test_unknown_extension_shortname() {
// Test that unknown extension shortnames are skipped while valid configs are kept
let loaded_exts = vec!["developer".to_string()];
let params = json!({
"instructions": "Test",
"extensions": [
"unknown_extension_1", // Full config should always work
{
"type": "builtin",
"name": "test_builtin",
"display_name": "Test Builtin",
"description": "Test extension"
},
"unknown_extension_2" // Should be skipped
]
});
let recipe = task_params_to_inline_recipe(&params, &loaded_exts).unwrap();
assert!(recipe.extensions.is_some());
let extensions = recipe.extensions.unwrap();
// Should only get the full config, unknown shortnames should be skipped
assert_eq!(extensions.len(), 1);
// Verify it's the builtin we provided
match &extensions[0] {
goose::agents::extension::ExtensionConfig::Builtin { name, .. } => {
assert_eq!(name, "test_builtin");
}
_ => panic!("Expected builtin extension config"),
}
}
#[test]
fn test_empty_extensions_array() {
// Test that an empty extensions array results in no extensions
let loaded_exts = vec!["developer".to_string(), "memory".to_string()];
let params = json!({
"instructions": "Test",
"extensions": []
});
let recipe = task_params_to_inline_recipe(&params, &loaded_exts).unwrap();
assert!(recipe.extensions.is_some());
let extensions = recipe.extensions.unwrap();
// Empty array should mean no extensions
assert_eq!(extensions.len(), 0);
}
#[test]
fn test_omitted_extensions_field() {
// Test that omitting the extensions field results in None (use all)
let loaded_exts = vec!["developer".to_string(), "memory".to_string()];
let params = json!({
"instructions": "Test"
// No extensions field
});
let recipe = task_params_to_inline_recipe(&params, &loaded_exts).unwrap();
// When extensions field is omitted, recipe.extensions should be None
assert!(recipe.extensions.is_none());
}
#[test]
fn test_null_values_in_optional_fields() {
// Test that null values in optional fields are handled gracefully
let params = json!({
"instructions": "Test",
"title": null,
"description": null,
"extensions": null,
"settings": null
});
let recipe = task_params_to_inline_recipe(&params, &test_loaded_extensions()).unwrap();
assert_eq!(recipe.instructions, Some("Test".to_string()));
// Null values should use defaults or be None
assert_eq!(recipe.title, "Dynamic Task"); // Should use default
assert_eq!(recipe.description, "Inline recipe task"); // Should use default
assert!(recipe.extensions.is_none());
assert!(recipe.settings.is_none());
}
}
+126
View File
@@ -0,0 +1,126 @@
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());
}