Skip to main content

aster/agents/
subagent_tool.rs

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
194/// Note: SubRecipe.sequential_when_repeated is surfaced as a hint in the tool description
195/// (e.g., "[run sequentially, not in parallel]") but not enforced. The LLM controls
196/// sequencing by making sequential vs parallel tool calls.
197pub 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, &params)
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) = &params.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) = &params.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) = &params.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) = &params.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) = &params.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}