1use std::borrow::Cow;
2use std::collections::HashMap;
3use std::path::PathBuf;
4
5use anyhow::{anyhow, Result};
6use futures::FutureExt;
7use rmcp::model::{Content, ErrorCode, ErrorData, Tool};
8use serde::Deserialize;
9use serde_json::{json, Value};
10use tokio_util::sync::CancellationToken;
11
12use crate::agents::subagent_handler::run_complete_subagent_task;
13use crate::agents::subagent_task_config::TaskConfig;
14use crate::agents::tool_execution::ToolCallResult;
15use crate::providers;
16use crate::recipe::build_recipe::build_recipe_from_template;
17use crate::recipe::local_recipes::load_local_recipe_file;
18use crate::recipe::{Recipe, SubRecipe};
19use crate::session::SessionManager;
20
21pub const SUBAGENT_TOOL_NAME: &str = "subagent";
22
23const SUMMARY_INSTRUCTIONS: &str = r#"
24Important: Your parent agent will only receive your final message as a summary of your work.
25Make sure your last message provides a comprehensive summary of:
26- What you were asked to do
27- What actions you took
28- The results or outcomes
29- Any important findings or recommendations
30
31Be concise but complete.
32"#;
33
34#[derive(Debug, Deserialize)]
35pub struct SubagentParams {
36 pub instructions: Option<String>,
37 pub subrecipe: Option<String>,
38 pub parameters: Option<HashMap<String, Value>>,
39 pub extensions: Option<Vec<String>>,
40 pub settings: Option<SubagentSettings>,
41 #[serde(default = "default_summary")]
42 pub summary: bool,
43 pub images: Option<Vec<ImageData>>,
44}
45
46#[derive(Debug, Deserialize)]
47pub struct ImageData {
48 pub data: String,
49 pub mime_type: String,
50}
51
52fn default_summary() -> bool {
53 true
54}
55
56#[derive(Debug, Deserialize)]
57pub struct SubagentSettings {
58 pub provider: Option<String>,
59 pub model: Option<String>,
60 pub temperature: Option<f32>,
61}
62
63pub fn create_subagent_tool(sub_recipes: &[SubRecipe]) -> Tool {
64 let description = build_tool_description(sub_recipes);
65
66 let schema = json!({
67 "type": "object",
68 "properties": {
69 "instructions": {
70 "type": "string",
71 "description": "Instructions for the subagent. Required for ad-hoc tasks. For predefined tasks, adds additional context."
72 },
73 "subrecipe": {
74 "type": "string",
75 "description": "Name of a predefined subrecipe to run."
76 },
77 "parameters": {
78 "type": "object",
79 "additionalProperties": true,
80 "description": "Parameters for the subrecipe. Only valid when 'subrecipe' is specified."
81 },
82 "extensions": {
83 "type": "array",
84 "items": {"type": "string"},
85 "description": "Extensions to enable. Omit to inherit all, empty array for none."
86 },
87 "settings": {
88 "type": "object",
89 "properties": {
90 "provider": {"type": "string", "description": "Override LLM provider"},
91 "model": {"type": "string", "description": "Override model"},
92 "temperature": {"type": "number", "description": "Override temperature"}
93 },
94 "description": "Override model/provider settings."
95 },
96 "summary": {
97 "type": "boolean",
98 "default": true,
99 "description": "If true (default), return only the subagent's final summary."
100 },
101 "images": {
102 "type": "array",
103 "items": {
104 "type": "object",
105 "properties": {
106 "data": {"type": "string", "description": "Base64 encoded image data"},
107 "mime_type": {"type": "string", "description": "MIME type of the image"}
108 },
109 "required": ["data", "mime_type"]
110 },
111 "description": "Images to include in the subagent task for multimodal analysis."
112 }
113 }
114 });
115
116 Tool::new(
117 SUBAGENT_TOOL_NAME,
118 description,
119 schema.as_object().unwrap().clone(),
120 )
121}
122
123fn build_tool_description(sub_recipes: &[SubRecipe]) -> String {
124 let mut desc = String::from(
125 "Delegate a task to a subagent that runs independently with its own context.\n\n\
126 Modes:\n\
127 1. Ad-hoc: Provide `instructions` for a custom task\n\
128 2. Predefined: Provide `subrecipe` name to run a predefined task\n\
129 3. Augmented: Provide both `subrecipe` and `instructions` to add context\n\n\
130 The subagent has access to the same tools as you by default. \
131 Use `extensions` to limit which extensions the subagent can use.\n\n\
132 For parallel execution, make multiple `subagent` tool calls in the same message.",
133 );
134
135 if !sub_recipes.is_empty() {
136 desc.push_str("\n\nAvailable subrecipes:");
137 for sr in sub_recipes {
138 let params_info = get_subrecipe_params_description(sr);
139 let sequential_hint = if sr.sequential_when_repeated {
140 " [run sequentially, not in parallel]"
141 } else {
142 ""
143 };
144 desc.push_str(&format!(
145 "\n• {}{} - {}{}",
146 sr.name,
147 sequential_hint,
148 sr.description.as_deref().unwrap_or("No description"),
149 if params_info.is_empty() {
150 String::new()
151 } else {
152 format!(" (params: {})", params_info)
153 }
154 ));
155 }
156 }
157
158 desc
159}
160
161fn get_subrecipe_params_description(sub_recipe: &SubRecipe) -> String {
162 match load_local_recipe_file(&sub_recipe.path) {
163 Ok(recipe_file) => match Recipe::from_content(&recipe_file.content) {
164 Ok(recipe) => {
165 if let Some(params) = recipe.parameters {
166 params
167 .iter()
168 .filter(|p| {
169 sub_recipe
170 .values
171 .as_ref()
172 .map(|v| !v.contains_key(&p.key))
173 .unwrap_or(true)
174 })
175 .map(|p| {
176 let req = match p.requirement {
177 crate::recipe::RecipeParameterRequirement::Required => "[required]",
178 _ => "[optional]",
179 };
180 format!("{} {}", p.key, req)
181 })
182 .collect::<Vec<_>>()
183 .join(", ")
184 } else {
185 String::new()
186 }
187 }
188 Err(_) => String::new(),
189 },
190 Err(_) => String::new(),
191 }
192}
193
194pub fn handle_subagent_tool(
198 params: Value,
199 task_config: TaskConfig,
200 sub_recipes: HashMap<String, SubRecipe>,
201 working_dir: PathBuf,
202 cancellation_token: Option<CancellationToken>,
203) -> ToolCallResult {
204 let parsed_params: SubagentParams = match serde_json::from_value(params) {
205 Ok(p) => p,
206 Err(e) => {
207 return ToolCallResult::from(Err(ErrorData {
208 code: ErrorCode::INVALID_PARAMS,
209 message: Cow::from(format!("Invalid parameters: {}", e)),
210 data: None,
211 }));
212 }
213 };
214
215 if parsed_params.instructions.is_none() && parsed_params.subrecipe.is_none() {
216 return ToolCallResult::from(Err(ErrorData {
217 code: ErrorCode::INVALID_PARAMS,
218 message: Cow::from("Must provide 'instructions' or 'subrecipe' (or both)"),
219 data: None,
220 }));
221 }
222
223 if parsed_params.parameters.is_some() && parsed_params.subrecipe.is_none() {
224 return ToolCallResult::from(Err(ErrorData {
225 code: ErrorCode::INVALID_PARAMS,
226 message: Cow::from("'parameters' can only be used with 'subrecipe'"),
227 data: None,
228 }));
229 }
230
231 let recipe = match build_recipe(&parsed_params, &sub_recipes) {
232 Ok(r) => r,
233 Err(e) => {
234 return ToolCallResult::from(Err(ErrorData {
235 code: ErrorCode::INVALID_PARAMS,
236 message: Cow::from(e.to_string()),
237 data: None,
238 }));
239 }
240 };
241
242 ToolCallResult {
243 notification_stream: None,
244 result: Box::new(
245 execute_subagent(
246 recipe,
247 task_config,
248 parsed_params,
249 working_dir,
250 cancellation_token,
251 )
252 .boxed(),
253 ),
254 }
255}
256
257async fn execute_subagent(
258 recipe: Recipe,
259 task_config: TaskConfig,
260 params: SubagentParams,
261 working_dir: PathBuf,
262 cancellation_token: Option<CancellationToken>,
263) -> Result<rmcp::model::CallToolResult, ErrorData> {
264 let session = SessionManager::create_session(
265 working_dir,
266 "Subagent task".to_string(),
267 crate::session::session_manager::SessionType::SubAgent,
268 )
269 .await
270 .map_err(|e| ErrorData {
271 code: ErrorCode::INTERNAL_ERROR,
272 message: Cow::from(format!("Failed to create session: {}", e)),
273 data: None,
274 })?;
275
276 let task_config = apply_settings_overrides(task_config, ¶ms)
277 .await
278 .map_err(|e| ErrorData {
279 code: ErrorCode::INVALID_PARAMS,
280 message: Cow::from(e.to_string()),
281 data: None,
282 })?;
283
284 let result = run_complete_subagent_task(
285 recipe,
286 task_config,
287 params.summary,
288 session.id,
289 params.images,
290 cancellation_token,
291 )
292 .await;
293
294 match result {
295 Ok(text) => Ok(rmcp::model::CallToolResult {
296 content: vec![Content::text(text)],
297 structured_content: None,
298 is_error: Some(false),
299 meta: None,
300 }),
301 Err(e) => Err(ErrorData {
302 code: ErrorCode::INTERNAL_ERROR,
303 message: Cow::from(e.to_string()),
304 data: None,
305 }),
306 }
307}
308
309fn build_recipe(
310 params: &SubagentParams,
311 sub_recipes: &HashMap<String, SubRecipe>,
312) -> Result<Recipe> {
313 let mut recipe = if let Some(subrecipe_name) = ¶ms.subrecipe {
314 build_subrecipe(subrecipe_name, params, sub_recipes)?
315 } else {
316 build_adhoc_recipe(params)?
317 };
318
319 if params.summary {
320 let current = recipe.instructions.unwrap_or_default();
321 recipe.instructions = Some(format!("{}\n{}", current, SUMMARY_INSTRUCTIONS));
322 }
323
324 Ok(recipe)
325}
326
327fn build_subrecipe(
328 subrecipe_name: &str,
329 params: &SubagentParams,
330 sub_recipes: &HashMap<String, SubRecipe>,
331) -> Result<Recipe> {
332 let sub_recipe = sub_recipes.get(subrecipe_name).ok_or_else(|| {
333 let available: Vec<_> = sub_recipes.keys().cloned().collect();
334 anyhow!(
335 "Unknown subrecipe '{}'. Available: {}",
336 subrecipe_name,
337 available.join(", ")
338 )
339 })?;
340
341 let recipe_file = load_local_recipe_file(&sub_recipe.path)
342 .map_err(|e| anyhow!("Failed to load subrecipe '{}': {}", subrecipe_name, e))?;
343
344 let mut param_values: Vec<(String, String)> = Vec::new();
345
346 if let Some(values) = &sub_recipe.values {
347 for (k, v) in values {
348 param_values.push((k.clone(), v.clone()));
349 }
350 }
351
352 if let Some(provided_params) = ¶ms.parameters {
353 for (k, v) in provided_params {
354 let value_str = match v {
355 Value::String(s) => s.clone(),
356 other => other.to_string(),
357 };
358 param_values.push((k.clone(), value_str));
359 }
360 }
361
362 let mut recipe = build_recipe_from_template(
363 recipe_file.content,
364 &recipe_file.parent_dir,
365 param_values,
366 None::<fn(&str, &str) -> Result<String, anyhow::Error>>,
367 )
368 .map_err(|e| anyhow!("Failed to build subrecipe: {}", e))?;
369
370 if let Some(extra) = ¶ms.instructions {
371 let mut current = recipe.instructions.take().unwrap_or_default();
372 if !current.is_empty() {
373 current.push_str("\n\n");
374 }
375 current.push_str(extra);
376 recipe.instructions = Some(current);
377 }
378
379 Ok(recipe)
380}
381
382fn build_adhoc_recipe(params: &SubagentParams) -> Result<Recipe> {
383 let instructions = params
384 .instructions
385 .as_ref()
386 .ok_or_else(|| anyhow!("Instructions required for ad-hoc task"))?;
387
388 let recipe = Recipe::builder()
389 .version("1.0.0")
390 .title("Subagent Task")
391 .description("Ad-hoc subagent task")
392 .instructions(instructions)
393 .build()
394 .map_err(|e| anyhow!("Failed to build recipe: {}", e))?;
395
396 if recipe.check_for_security_warnings() {
397 return Err(anyhow!("Recipe contains potentially harmful content"));
398 }
399
400 Ok(recipe)
401}
402
403async fn apply_settings_overrides(
404 mut task_config: TaskConfig,
405 params: &SubagentParams,
406) -> Result<TaskConfig> {
407 if let Some(settings) = ¶ms.settings {
408 if settings.provider.is_some() || settings.model.is_some() || settings.temperature.is_some()
409 {
410 let provider_name = settings
411 .provider
412 .clone()
413 .unwrap_or_else(|| task_config.provider.get_name().to_string());
414
415 let mut model_config = task_config.provider.get_model_config();
416
417 if let Some(model) = &settings.model {
418 model_config.model_name = model.clone();
419 }
420
421 if let Some(temp) = settings.temperature {
422 model_config = model_config.with_temperature(Some(temp));
423 }
424
425 task_config.provider = providers::create(&provider_name, model_config)
426 .await
427 .map_err(|e| anyhow!("Failed to create provider '{}': {}", provider_name, e))?;
428 }
429 }
430
431 if let Some(extension_names) = ¶ms.extensions {
432 if extension_names.is_empty() {
433 task_config.extensions = Vec::new();
434 } else {
435 task_config
436 .extensions
437 .retain(|ext| extension_names.contains(&ext.name()));
438 }
439 }
440
441 Ok(task_config)
442}
443
444#[cfg(test)]
445mod tests {
446 use super::*;
447
448 #[test]
449 fn test_tool_name() {
450 assert_eq!(SUBAGENT_TOOL_NAME, "subagent");
451 }
452
453 #[test]
454 fn test_create_tool_without_subrecipes() {
455 let tool = create_subagent_tool(&[]);
456 assert_eq!(tool.name, "subagent");
457 assert!(tool.description.as_ref().unwrap().contains("Ad-hoc"));
458 assert!(!tool
459 .description
460 .as_ref()
461 .unwrap()
462 .contains("Available subrecipes"));
463 }
464
465 #[test]
466 fn test_create_tool_with_subrecipes() {
467 let sub_recipes = vec![SubRecipe {
468 name: "test_recipe".to_string(),
469 path: "test.yaml".to_string(),
470 values: None,
471 sequential_when_repeated: false,
472 description: Some("A test recipe".to_string()),
473 }];
474
475 let tool = create_subagent_tool(&sub_recipes);
476 assert!(tool
477 .description
478 .as_ref()
479 .unwrap()
480 .contains("Available subrecipes"));
481 assert!(tool.description.as_ref().unwrap().contains("test_recipe"));
482 }
483
484 #[test]
485 fn test_sequential_hint_in_description() {
486 let sub_recipes = vec![
487 SubRecipe {
488 name: "parallel_ok".to_string(),
489 path: "test.yaml".to_string(),
490 values: None,
491 sequential_when_repeated: false,
492 description: Some("Can run in parallel".to_string()),
493 },
494 SubRecipe {
495 name: "sequential_only".to_string(),
496 path: "test.yaml".to_string(),
497 values: None,
498 sequential_when_repeated: true,
499 description: Some("Must run sequentially".to_string()),
500 },
501 ];
502
503 let tool = create_subagent_tool(&sub_recipes);
504 let desc = tool.description.as_ref().unwrap();
505
506 assert!(desc.contains("parallel_ok"));
507 assert!(!desc.contains("parallel_ok [run sequentially"));
508
509 assert!(desc.contains("sequential_only [run sequentially, not in parallel]"));
510 }
511
512 #[test]
513 fn test_params_deserialization_full() {
514 let params: SubagentParams = serde_json::from_value(json!({
515 "instructions": "Extra context",
516 "subrecipe": "my_recipe",
517 "parameters": {"key": "value"},
518 "extensions": ["developer"],
519 "settings": {"model": "gpt-4"},
520 "summary": false
521 }))
522 .unwrap();
523
524 assert_eq!(params.instructions, Some("Extra context".to_string()));
525 assert_eq!(params.subrecipe, Some("my_recipe".to_string()));
526 assert!(params.parameters.is_some());
527 assert_eq!(params.extensions, Some(vec!["developer".to_string()]));
528 assert!(!params.summary);
529 }
530}