Skip to main content

telos_agent/orchestration/subagent/
tool.rs

1//! In-process subagent tool — runs a nested agent session as a tool call.
2
3use async_trait::async_trait;
4use serde_json::{Value, json};
5use std::sync::Arc;
6
7mod agent_mode;
8mod fork_mode;
9
10use crate::config::AgentConfig;
11use crate::error::AgentError;
12use crate::model::provider::ModelProvider;
13use crate::orchestration::subagent::SubagentRegistry;
14use crate::tools::api::{
15    PermissionDecision, Tool, ToolContext, ToolDefinition, ToolOutput, ToolRegistry,
16};
17
18/// Tool that delegates to a nested agent session or runs fork lenses.
19pub struct SubagentTool {
20    provider: Arc<dyn ModelProvider + Send + Sync>,
21    tools: ToolRegistry,
22    config: AgentConfig,
23    registry: SubagentRegistry,
24}
25
26impl SubagentTool {
27    pub fn new(
28        provider: Arc<dyn ModelProvider + Send + Sync>,
29        tools: ToolRegistry,
30        config: AgentConfig,
31    ) -> Self {
32        Self { provider, tools, config, registry: SubagentRegistry::with_builtin_agents() }
33    }
34
35    pub fn with_registry(
36        provider: Arc<dyn ModelProvider + Send + Sync>,
37        tools: ToolRegistry,
38        config: AgentConfig,
39        registry: SubagentRegistry,
40    ) -> Self {
41        Self { provider, tools, config, registry }
42    }
43}
44
45#[async_trait]
46impl Tool for SubagentTool {
47    fn definition(&self) -> ToolDefinition {
48        ToolDefinition {
49            name: "subagent".into(),
50            description:
51                "Delegate to a specialized subagent with its own turn loop. Run in parallel or background."
52                    .into(),
53            input_schema: json!({
54                "type": "object",
55                "properties": {
56                    "description": {
57                        "type": "string",
58                        "description": "Short 3-5 word description of what the agent will do"
59                    },
60                    "prompt": { "type": "string" },
61                    "subagent_type": {
62                        "type": "string",
63                        "description": "Specialized agent type to use; defaults to general-purpose"
64                    },
65                    "system_prompt": { "type": "string" },
66                    "max_iterations": { "type": "integer" },
67                    "model": {
68                        "type": "string",
69                        "enum": ["thinking", "execution", "recovery", "summarization", "inherit"]
70                    },
71                    "run_in_background": { "type": "boolean" },
72                    "isolation": {
73                        "type": "string",
74                        "enum": ["none", "worktree"]
75                    },
76                    "mode": {
77                        "type": "string",
78                        "enum": ["agent", "fork"],
79                        "description": "When 'fork', runs multiple concurrent lenses instead of a full agent session"
80                    },
81                    "forks": {
82                        "type": "array",
83                        "items": {
84                            "type": "object",
85                            "properties": {
86                                "lens": { "type": "string" },
87                                "system_prompt": { "type": "string" },
88                                "task": { "type": "string" },
89                                "output_schema": { "type": "object" },
90                                "allowed_tools": {
91                                    "type": "array",
92                                    "items": { "type": "string" }
93                                }
94                            },
95                            "required": ["lens", "system_prompt", "task"]
96                        }
97                    }
98                },
99                "required": ["prompt"]
100            }),
101        }
102    }
103
104    fn prompt_text(&self) -> Option<&'static str> {
105        Some(
106            "Use Subagent to delegate self-contained tasks or protect the main context window. \
107Provide a short description and a self-contained prompt with scope, files, constraints, and expected output. \
108Use subagent_type (general-purpose, Explore, Plan, Verification) matching the task's needs. \
109Launch multiple independent subagent calls in one message for parallel execution. \
110Use run_in_background for long work and isolation: worktree for write-heavy tasks. \
111Don't duplicate work already being done in the parent session.",
112        )
113    }
114
115    async fn validate(&self, arguments: &Value, _context: &ToolContext) -> Result<(), AgentError> {
116        arguments
117            .get("prompt")
118            .and_then(|value| value.as_str())
119            .ok_or_else(|| AgentError::Validation("missing string `prompt`".into()))?;
120
121        if arguments.get("run_in_background").and_then(|value| value.as_bool()).unwrap_or(false)
122            && self.config.task_manager.is_none()
123        {
124            return Err(AgentError::Validation(
125                "run_in_background requires AgentConfig.task_manager".into(),
126            ));
127        }
128
129        if let Some(agent_type) = arguments.get("subagent_type").and_then(|v| v.as_str())
130            && self.registry.get(agent_type).is_none()
131        {
132            let available = self
133                .registry
134                .definitions()
135                .into_iter()
136                .map(|agent| agent.name.as_str())
137                .collect::<Vec<_>>()
138                .join(", ");
139            return Err(AgentError::Validation(format!(
140                "unknown subagent_type `{agent_type}`. Available agents: {available}"
141            )));
142        }
143
144        let mode = arguments.get("mode").and_then(|v| v.as_str()).unwrap_or("agent");
145
146        if mode == "fork"
147            && matches!(
148                arguments.get("isolation").and_then(|value| value.as_str()),
149                Some("worktree")
150            )
151        {
152            return Err(AgentError::Validation(
153                "worktree isolation is only supported in agent mode".into(),
154            ));
155        }
156
157        if mode == "fork" {
158            let forks = arguments
159                .get("forks")
160                .and_then(|f| f.as_array())
161                .ok_or_else(|| AgentError::Validation("fork mode requires `forks` array".into()))?;
162            if forks.is_empty() {
163                return Err(AgentError::Validation(
164                    "fork mode requires at least one fork entry".into(),
165                ));
166            }
167            for (i, item) in forks.iter().enumerate() {
168                if item.get("lens").and_then(|v| v.as_str()).is_none() {
169                    return Err(AgentError::Validation(format!("fork[{}] missing `lens`", i)));
170                }
171                if item.get("system_prompt").and_then(|v| v.as_str()).is_none() {
172                    return Err(AgentError::Validation(format!(
173                        "fork[{}] missing `system_prompt`",
174                        i
175                    )));
176                }
177                if item.get("task").and_then(|v| v.as_str()).is_none() {
178                    return Err(AgentError::Validation(format!("fork[{}] missing `task`", i)));
179                }
180            }
181        }
182
183        Ok(())
184    }
185
186    async fn check_permission(
187        &self,
188        _arguments: &Value,
189        _context: &ToolContext,
190    ) -> Result<PermissionDecision, AgentError> {
191        // Subagent is a pure delegation mechanism — its child tools run inside
192        // a child AgentSession that inherits the parent's approval_handler via
193        // config.clone(). Every child tool call already goes through the normal
194        // approval flow (auto-allow, safety checks, human prompts). Requiring
195        // separate approval for the subagent call itself is redundant and causes
196        // the parent tool phase to block while waiting for a decision that
197        // conveys no additional security benefit.
198        Ok(PermissionDecision::Allow)
199    }
200
201    async fn invoke(
202        &self,
203        arguments: Value,
204        context: ToolContext,
205    ) -> Result<ToolOutput, AgentError> {
206        let mode = arguments.get("mode").and_then(|v| v.as_str()).unwrap_or("agent");
207
208        match mode {
209            "fork" => {
210                let result = self.run_fork(&arguments, &context).await?;
211                Ok(result)
212            }
213            _ => self.run_agent(arguments, context).await,
214        }
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221    use crate::model::mock::MockProvider;
222    use crate::orchestration::subagent::{AgentDefinition, AgentSource};
223    use async_trait::async_trait;
224    use serde_json::json;
225    use std::sync::Arc;
226
227    struct NamedTool(&'static str);
228
229    #[async_trait]
230    impl Tool for NamedTool {
231        fn definition(&self) -> ToolDefinition {
232            ToolDefinition {
233                name: self.0.into(),
234                description: "test".into(),
235                input_schema: json!({"type": "object"}),
236            }
237        }
238
239        async fn invoke(
240            &self,
241            _arguments: Value,
242            _context: ToolContext,
243        ) -> Result<ToolOutput, AgentError> {
244            Ok(ToolOutput::text("ok"))
245        }
246    }
247
248    #[test]
249    fn subagent_tool_schema_exposes_agent_tool_fields() {
250        let tool = SubagentTool::new(
251            Arc::new(MockProvider::new(vec![])),
252            ToolRegistry::new(),
253            AgentConfig::default(),
254        );
255        let schema = tool.definition().input_schema;
256        let properties = schema["properties"].as_object().unwrap();
257        assert!(properties.contains_key("description"));
258        assert!(properties.contains_key("subagent_type"));
259        assert!(properties.contains_key("model"));
260        assert!(properties.contains_key("run_in_background"));
261        assert!(properties.contains_key("isolation"));
262    }
263
264    #[test]
265    fn subagent_prompt_text_names_supported_agent_types() {
266        let tool = SubagentTool::new(
267            Arc::new(MockProvider::new(vec![])),
268            ToolRegistry::new(),
269            AgentConfig::default(),
270        );
271        let prompt = tool.prompt_text().unwrap();
272        assert!(prompt.contains("subagent_type"));
273        assert!(prompt.contains("general-purpose"));
274        assert!(prompt.contains("Explore"));
275        assert!(prompt.contains("Plan"));
276        assert!(prompt.contains("Verification"));
277    }
278
279    #[tokio::test]
280    async fn validate_rejects_unknown_subagent_type_with_available_agents() {
281        let tool = SubagentTool::new(
282            Arc::new(MockProvider::new(vec![])),
283            ToolRegistry::new(),
284            AgentConfig::default(),
285        );
286
287        let err = tool
288            .validate(
289                &json!({
290                    "prompt": "inspect",
291                    "subagent_type": "Nope"
292                }),
293                &ToolContext::dummy(),
294            )
295            .await
296            .unwrap_err();
297
298        let message = err.to_string();
299        assert!(message.contains("unknown subagent_type `Nope`"));
300        assert!(message.contains("Explore"));
301    }
302
303    #[tokio::test]
304    async fn validate_rejects_background_without_task_manager_and_fork_worktree() {
305        let tool = SubagentTool::new(
306            Arc::new(MockProvider::new(vec![])),
307            ToolRegistry::new(),
308            AgentConfig::default(),
309        );
310
311        let background = tool
312            .validate(
313                &json!({
314                    "prompt": "inspect",
315                    "run_in_background": true
316                }),
317                &ToolContext::dummy(),
318            )
319            .await
320            .unwrap_err();
321        assert!(background.to_string().contains("requires AgentConfig.task_manager"));
322
323        let worktree = tool
324            .validate(
325                &json!({
326                    "prompt": "inspect",
327                    "mode": "fork",
328                    "isolation": "worktree",
329                    "forks": [{"lens": "a", "system_prompt": "a", "task": "a"}]
330                }),
331                &ToolContext::dummy(),
332            )
333            .await
334            .unwrap_err();
335        assert!(worktree.to_string().contains("only supported in agent mode"));
336    }
337
338    #[test]
339    fn subagent_prompt_text_describes_distribution_controls() {
340        let tool = SubagentTool::new(
341            Arc::new(MockProvider::new(vec![])),
342            ToolRegistry::new(),
343            AgentConfig::default(),
344        );
345        let prompt = tool.prompt_text().unwrap();
346        assert!(prompt.contains("self-contained prompt"));
347        assert!(prompt.contains("independent subagent calls"));
348        assert!(prompt.contains("run_in_background"));
349        assert!(prompt.contains("isolation: worktree"));
350    }
351
352    #[test]
353    fn subagent_prompt_text_names_supported_agent_types_and_mode() {
354        let tool = SubagentTool::new(
355            Arc::new(MockProvider::new(vec![])),
356            ToolRegistry::new(),
357            AgentConfig::default(),
358        );
359        let prompt = tool.prompt_text().unwrap();
360        assert!(prompt.contains("Subagent"));
361        assert!(prompt.contains("subagent_type"));
362    }
363
364    #[tokio::test]
365    async fn subagent_type_selects_agent_prompt_and_result_metadata() {
366        let provider =
367            Arc::new(MockProvider::new(vec![crate::model::provider::CompletionResponse {
368                message: crate::Message::assistant("explore result"),
369                stop_reason: crate::model::provider::StopReason::EndTurn,
370                usage: None,
371                model: None,
372            }]));
373        let tool = SubagentTool::new(provider.clone(), ToolRegistry::new(), AgentConfig::default());
374
375        let output = tool
376            .invoke(
377                json!({
378                    "description": "Explore code",
379                    "prompt": "Find the runtime loop",
380                    "subagent_type": "Explore"
381                }),
382                ToolContext::dummy(),
383            )
384            .await
385            .unwrap();
386
387        let requests = provider.requests.lock().await;
388        let system_prompt = requests[0].system_prompt_text().unwrap_or_default();
389        assert!(system_prompt.contains("You are an explore agent"), "{system_prompt}");
390        drop(requests);
391
392        assert_eq!(output.content["agent_type"], "Explore");
393        assert_eq!(output.content["description"], "Explore code");
394        assert_eq!(output.content["status"], "completed");
395        assert_eq!(output.content["final_text"], "explore result");
396        assert!(output.content["agent_id"].as_str().unwrap().starts_with("agent_"));
397    }
398
399    #[tokio::test]
400    async fn subagent_initial_prompt_is_prepended_to_delegated_prompt() {
401        let provider =
402            Arc::new(MockProvider::new(vec![crate::model::provider::CompletionResponse {
403                message: crate::Message::assistant("done"),
404                stop_reason: crate::model::provider::StopReason::EndTurn,
405                usage: None,
406                model: None,
407            }]));
408        let mut registry = SubagentRegistry::new();
409        let mut agent = AgentDefinition::new(
410            "preflight",
411            "Preflight agent",
412            "You prepare before work.",
413            AgentSource::BuiltIn,
414        );
415        agent.initial_prompt = Some("Read CONTRIBUTING.md first.".into());
416        registry.register(agent);
417        let tool = SubagentTool::with_registry(
418            provider.clone(),
419            ToolRegistry::new(),
420            AgentConfig::default(),
421            registry,
422        );
423
424        tool.invoke(
425            json!({
426                "description": "Run preflight",
427                "prompt": "Inspect parser",
428                "subagent_type": "preflight"
429            }),
430            ToolContext::dummy(),
431        )
432        .await
433        .unwrap();
434
435        let requests = provider.requests.lock().await;
436        let user_text = requests[0]
437            .messages
438            .iter()
439            .find(|message| message.role == crate::Role::User)
440            .expect("request should include delegated user prompt")
441            .text_content();
442        assert!(user_text.starts_with("Read CONTRIBUTING.md first."));
443        assert!(user_text.contains("Inspect parser"));
444    }
445
446    #[tokio::test]
447    async fn subagent_declared_skills_are_injected_into_system_prompt() {
448        let provider =
449            Arc::new(MockProvider::new(vec![crate::model::provider::CompletionResponse {
450                message: crate::Message::assistant("done"),
451                stop_reason: crate::model::provider::StopReason::EndTurn,
452                usage: None,
453                model: None,
454            }]));
455        let mut registry = SubagentRegistry::new();
456        let mut agent = AgentDefinition::new(
457            "debugger",
458            "Debugging agent",
459            "You debug the delegated issue.",
460            AgentSource::BuiltIn,
461        );
462        agent.skills = vec!["debug".into(), "verify".into()];
463        registry.register(agent);
464        let tool = SubagentTool::with_registry(
465            provider.clone(),
466            ToolRegistry::new(),
467            AgentConfig::default(),
468            registry,
469        );
470
471        tool.invoke(
472            json!({
473                "description": "Debug issue",
474                "prompt": "Find the bug",
475                "subagent_type": "debugger"
476            }),
477            ToolContext::dummy(),
478        )
479        .await
480        .unwrap();
481
482        let requests = provider.requests.lock().await;
483        let system_prompt = requests[0].system_prompt_text().unwrap_or_default();
484        assert!(system_prompt.contains("# Subagent Skills"), "{system_prompt}");
485        assert!(system_prompt.contains("Skill tool"), "{system_prompt}");
486        assert!(system_prompt.contains("debug"), "{system_prompt}");
487        assert!(system_prompt.contains("verify"), "{system_prompt}");
488    }
489
490    #[tokio::test]
491    async fn subagent_system_prompt_contains_learning_contract() {
492        let provider =
493            Arc::new(MockProvider::new(vec![crate::model::provider::CompletionResponse {
494                message: crate::Message::assistant("done"),
495                stop_reason: crate::model::provider::StopReason::EndTurn,
496                usage: None,
497                model: None,
498            }]));
499        let tool = SubagentTool::new(provider.clone(), ToolRegistry::new(), AgentConfig::default());
500
501        tool.invoke(
502            json!({
503                "description": "Explore code",
504                "prompt": "Find the runtime loop",
505                "subagent_type": "Explore"
506            }),
507            ToolContext::dummy(),
508        )
509        .await
510        .unwrap();
511
512        let requests = provider.requests.lock().await;
513        let system_prompt = requests[0].system_prompt_text().unwrap_or_default();
514        assert!(system_prompt.contains("# Subagent Learning"), "{system_prompt}");
515        assert!(system_prompt.contains("memory"), "{system_prompt}");
516        assert!(system_prompt.contains("Reusable learning"), "{system_prompt}");
517    }
518
519    #[tokio::test]
520    async fn subagent_progress_is_attached_to_parent_tool_call_with_data() {
521        let provider = Arc::new(MockProvider::new(vec![
522            crate::model::provider::CompletionResponse {
523                message: crate::Message {
524                    role: crate::Role::Assistant,
525                    blocks: vec![crate::ContentBlock::ToolCall(crate::ToolCall {
526                        id: "child-call".into(),
527                        name: "Read".into(),
528                        arguments: json!({ "file_path": "src/lib.rs" }),
529                    })],
530                },
531                stop_reason: crate::model::provider::StopReason::ToolUse,
532                usage: None,
533                model: None,
534            },
535            crate::model::provider::CompletionResponse {
536                message: crate::Message::assistant("done"),
537                stop_reason: crate::model::provider::StopReason::EndTurn,
538                usage: None,
539                model: None,
540            },
541        ]));
542        let tool = SubagentTool::new(provider, ToolRegistry::new(), AgentConfig::default());
543        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
544        let mut context = ToolContext::dummy();
545        context.tool_call_id = Some("parent-call".into());
546        context.progress = Some(tx);
547
548        tool.invoke(
549            json!({
550                "description": "Explore code",
551                "prompt": "Find the runtime loop",
552                "subagent_type": "Explore"
553            }),
554            context,
555        )
556        .await
557        .unwrap();
558
559        let mut progress = Vec::new();
560        while let Ok(item) = rx.try_recv() {
561            progress.push(item);
562        }
563
564        assert!(progress.iter().any(|item| {
565            item.tool_call_id.as_deref() == Some("parent-call")
566                && item.data.as_ref().and_then(|data| data["kind"].as_str()) == Some("subagent")
567                && item.data.as_ref().and_then(|data| data["event"].as_str()) == Some("tool_call")
568                && item.data.as_ref().and_then(|data| data["name"].as_str()) == Some("Read")
569        }));
570    }
571
572    #[test]
573    fn filters_child_tools_with_agent_allowlist_and_denylist() {
574        let mut tools = ToolRegistry::new();
575        tools.register(NamedTool("Read"));
576        tools.register(NamedTool("Write"));
577        tools.register(NamedTool("Grep"));
578
579        let mut agent = AgentDefinition::new("limited", "limited", "prompt", AgentSource::BuiltIn);
580        agent.allowed_tools = vec!["Read".into(), "Write".into()];
581        agent.disallowed_tools = vec!["Write".into()];
582
583        let filtered = agent_mode::filter_tools_for_agent(&tools, &agent);
584        let names = filtered.definitions().into_iter().map(|def| def.name).collect::<Vec<_>>();
585
586        assert_eq!(names, vec!["Read"]);
587        assert!(filtered.get("Read").is_ok());
588        assert!(filtered.get("Write").is_err());
589        assert!(filtered.get("Grep").is_err());
590    }
591
592    #[test]
593    fn wildcard_allowlist_keeps_all_tools_before_denylist() {
594        let mut tools = ToolRegistry::new();
595        tools.register(NamedTool("Read"));
596        tools.register(NamedTool("Write"));
597
598        let mut agent = AgentDefinition::new("wild", "wild", "prompt", AgentSource::BuiltIn);
599        agent.allowed_tools = vec!["*".into()];
600        agent.disallowed_tools = vec!["Write".into()];
601
602        let filtered = agent_mode::filter_tools_for_agent(&tools, &agent);
603
604        assert!(filtered.get("Read").is_ok());
605        assert!(filtered.get("Write").is_err());
606    }
607
608    #[test]
609    fn declared_skills_keep_skill_tool_available_with_allowlist() {
610        let mut tools = ToolRegistry::new();
611        tools.register(NamedTool("Read"));
612        tools.register(NamedTool("Skill"));
613        tools.register(NamedTool("MemoryRead"));
614        tools.register(NamedTool("MemoryWrite"));
615        tools.register(NamedTool("MemoryGrep"));
616
617        let mut agent = AgentDefinition::new("skilled", "skilled", "prompt", AgentSource::BuiltIn);
618        agent.allowed_tools = vec!["Read".into()];
619        agent.skills = vec!["debug".into()];
620
621        let filtered = agent_mode::filter_tools_for_agent(&tools, &agent);
622
623        assert!(filtered.get("Read").is_ok());
624        assert!(filtered.get("Skill").is_ok());
625        assert!(filtered.get("MemoryRead").is_ok());
626        assert!(filtered.get("MemoryWrite").is_ok());
627        assert!(filtered.get("MemoryGrep").is_ok());
628    }
629}