Skip to main content

rho_tools/
task.rs

1use std::path::{Path, PathBuf};
2
3use async_trait::async_trait;
4use tokio_util::sync::CancellationToken;
5
6use rho_core::tool::{AgentTool, ToolError};
7use rho_core::types::{Content, ToolResult};
8
9pub struct TaskTool {
10    rho_binary: PathBuf,
11    cwd: PathBuf,
12}
13
14impl TaskTool {
15    pub fn new(cwd: PathBuf) -> Self {
16        // Find the rho binary — prefer the one next to current exe
17        let rho_binary = std::env::current_exe()
18            .ok()
19            .and_then(|p| {
20                let dir = p.parent()?;
21                let candidate = dir.join("rho");
22                if candidate.exists() {
23                    Some(candidate)
24                } else {
25                    None
26                }
27            })
28            .unwrap_or_else(|| PathBuf::from("rho"));
29
30        Self { rho_binary, cwd }
31    }
32}
33
34#[async_trait]
35impl AgentTool for TaskTool {
36    fn name(&self) -> &str {
37        "task"
38    }
39
40    fn label(&self) -> String {
41        "Task (subagent)".into()
42    }
43
44    fn description(&self) -> String {
45        "Launch a subagent to handle a task. The subagent runs as a separate process with \
46         its own context. Use this for research, analysis, or delegating work that should \
47         not pollute the current conversation context."
48            .into()
49    }
50
51    fn parameters_schema(&self) -> serde_json::Value {
52        serde_json::json!({
53            "type": "object",
54            "required": ["prompt"],
55            "properties": {
56                "prompt": {
57                    "type": "string",
58                    "description": "The task prompt for the subagent"
59                },
60                "agent": {
61                    "type": "string",
62                    "description": "Name of agent config from .rho/agents/ (optional)"
63                },
64                "tools": {
65                    "type": "string",
66                    "description": "Comma-separated list of allowed tools (e.g. 'read,grep,find')"
67                }
68            }
69        })
70    }
71
72    async fn execute(
73        &self,
74        _tool_call_id: &str,
75        params: serde_json::Value,
76        cancel: CancellationToken,
77    ) -> Result<ToolResult, ToolError> {
78        let prompt = params["prompt"]
79            .as_str()
80            .ok_or_else(|| ToolError::InvalidParameters("prompt is required".into()))?;
81
82        let agent_name = params["agent"].as_str();
83        let tools_override = params["tools"].as_str();
84
85        // Load agent config if specified
86        let agent_config = if let Some(name) = agent_name {
87            load_agent_config(&self.cwd, name)
88        } else {
89            None
90        };
91
92        let mut cmd = tokio::process::Command::new(&self.rho_binary);
93        cmd.current_dir(&self.cwd);
94
95        // Apply tools restriction
96        if let Some(tools) = tools_override {
97            cmd.arg("--tools").arg(tools);
98        } else if let Some(ref ac) = agent_config {
99            if let Some(ref tools) = ac.tools {
100                cmd.arg("--tools").arg(tools);
101            }
102        }
103
104        // Apply model override
105        if let Some(ref ac) = agent_config {
106            if let Some(ref model) = ac.model {
107                cmd.arg("--model").arg(model);
108            }
109        }
110
111        // Apply system prompt append
112        if let Some(ref ac) = agent_config {
113            if let Some(ref append) = ac.system_prompt_append {
114                cmd.arg("--system-append").arg(append);
115            }
116        }
117
118        // The prompt is the positional argument
119        cmd.arg(prompt);
120
121        // Capture output
122        cmd.stdout(std::process::Stdio::piped());
123        cmd.stderr(std::process::Stdio::piped());
124
125        let child = cmd.spawn().map_err(|e| {
126            ToolError::ExecutionFailed(format!("Failed to spawn subagent: {}", e))
127        })?;
128
129        let output: std::process::Output = tokio::select! {
130            result = child.wait_with_output() => {
131                result.map_err(|e| ToolError::ExecutionFailed(format!("Subagent error: {}", e)))?
132            }
133            _ = cancel.cancelled() => {
134                return Ok(ToolResult {
135                    content: vec![Content::Text {
136                        text: "Subagent cancelled".into(),
137                    }],
138                    details: serde_json::json!({}),
139                });
140            }
141        };
142
143        let stdout = String::from_utf8_lossy(&output.stdout);
144        let stderr = String::from_utf8_lossy(&output.stderr);
145
146        let mut result_text = stdout.to_string();
147        if !stderr.is_empty() && !output.status.success() {
148            result_text.push_str("\n\n[stderr]\n");
149            result_text.push_str(&stderr);
150        }
151
152        // Truncate to 20KB
153        if result_text.len() > 20_000 {
154            result_text.truncate(20_000);
155            result_text.push_str("\n... [truncated]");
156        }
157
158        Ok(ToolResult {
159            content: vec![Content::Text { text: result_text }],
160            details: serde_json::json!({
161                "exit_code": output.status.code(),
162            }),
163        })
164    }
165}
166
167#[derive(Debug, Clone)]
168struct AgentConfig {
169    tools: Option<String>,
170    model: Option<String>,
171    system_prompt_append: Option<String>,
172}
173
174/// Load agent config from .rho/agents/{name}.md or .claude/agents/{name}.md
175fn load_agent_config(cwd: &Path, name: &str) -> Option<AgentConfig> {
176    let candidates = [
177        cwd.join(format!(".rho/agents/{}.md", name)),
178        cwd.join(format!(".claude/agents/{}.md", name)),
179    ];
180
181    for path in &candidates {
182        if let Ok(content) = std::fs::read_to_string(path) {
183            return Some(parse_agent_config(&content));
184        }
185    }
186
187    // Check home directory
188    if let Some(home) = dirs::home_dir() {
189        let path = home.join(format!(".rho/agents/{}.md", name));
190        if let Ok(content) = std::fs::read_to_string(&path) {
191            return Some(parse_agent_config(&content));
192        }
193    }
194
195    None
196}
197
198fn parse_agent_config(content: &str) -> AgentConfig {
199    let trimmed = content.trim_start();
200    if !trimmed.starts_with("---") {
201        return AgentConfig {
202            tools: None,
203            model: None,
204            system_prompt_append: Some(content.to_string()),
205        };
206    }
207
208    let after_first = &trimmed[3..];
209    let Some(end) = after_first.find("\n---") else {
210        return AgentConfig {
211            tools: None,
212            model: None,
213            system_prompt_append: Some(content.to_string()),
214        };
215    };
216
217    let frontmatter = &after_first[..end];
218    let body_start = 3 + end + 4;
219    let body = trimmed[body_start..].trim().to_string();
220
221    let mut config = AgentConfig {
222        tools: None,
223        model: None,
224        system_prompt_append: if body.is_empty() { None } else { Some(body) },
225    };
226
227    for line in frontmatter.lines() {
228        let line = line.trim();
229        if let Some(val) = line.strip_prefix("tools:") {
230            config.tools = Some(val.trim().to_string());
231        } else if let Some(val) = line.strip_prefix("model:") {
232            config.model = Some(val.trim().to_string());
233        }
234    }
235
236    config
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn parse_agent_config_with_frontmatter() {
245        let content = "\
246---
247name: researcher
248tools: read,grep,find
249model: claude-sonnet-4-5-20250929
250---
251You are a research agent. Analyze code and return findings.
252Do not modify any files.";
253
254        let config = parse_agent_config(content);
255        assert_eq!(config.tools.as_deref(), Some("read,grep,find"));
256        assert_eq!(config.model.as_deref(), Some("claude-sonnet-4-5-20250929"));
257        assert!(config
258            .system_prompt_append
259            .unwrap()
260            .contains("research agent"));
261    }
262
263    #[test]
264    fn parse_agent_config_no_frontmatter() {
265        let content = "Just do research.";
266        let config = parse_agent_config(content);
267        assert!(config.tools.is_none());
268        assert!(config.model.is_none());
269        assert_eq!(config.system_prompt_append.as_deref(), Some(content));
270    }
271
272    #[test]
273    fn task_tool_schema() {
274        let tool = TaskTool::new(PathBuf::from("."));
275        let schema = tool.parameters_schema();
276        assert_eq!(schema["required"][0], "prompt");
277        assert!(schema["properties"]["agent"].is_object());
278        assert!(schema["properties"]["tools"].is_object());
279    }
280}