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 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 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 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 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 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 cmd.arg(prompt);
120
121 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 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
174fn 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 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}