1use crate::agent::{Agent, ToolRegistry};
13use crate::permissions::{PermissionMode, Policy};
14use crate::provider::Provider;
15use chrono::{DateTime, Utc};
16use parking_lot::Mutex;
17use serde::{Deserialize, Serialize};
18use std::collections::HashMap;
19use std::path::{Path, PathBuf};
20use std::sync::Arc;
21use thiserror::Error;
22use uuid::Uuid;
23
24#[derive(Debug, Clone, PartialEq, Eq, Error)]
26pub enum SubagentError {
27 #[error("subagent spawn failed: {0}")]
28 SpawnFailed(String),
29 #[error("subagent not found: {0}")]
30 NotFound(String),
31 #[error("subagent already running: {0}")]
32 AlreadyRunning(String),
33 #[error("git error: {0}")]
34 GitError(String),
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum SubagentStatus {
41 Pending,
43 Running,
45 Completed,
47 Failed,
49 Cancelled,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct SubagentConfig {
56 pub name: String,
58 pub system_prompt: Option<String>,
60 pub model: Option<String>,
62 #[serde(default = "default_max_steps")]
64 pub max_steps: usize,
65 pub allowed_tools: Option<Vec<String>>,
67 pub denied_tools: Option<Vec<String>>,
69 pub permission_mode: Option<PermissionMode>,
71 #[serde(default)]
73 pub workspace_isolation: bool,
74}
75
76fn default_max_steps() -> usize {
77 25
78}
79
80impl Default for SubagentConfig {
81 fn default() -> Self {
82 Self {
83 name: "subagent".to_string(),
84 system_prompt: None,
85 model: None,
86 max_steps: default_max_steps(),
87 allowed_tools: None,
88 denied_tools: None,
89 permission_mode: None,
90 workspace_isolation: false,
91 }
92 }
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct SubagentResult {
98 pub output: String,
100 #[serde(default, skip_serializing_if = "Vec::is_empty")]
102 pub files_modified: Vec<String>,
103 pub tool_calls: usize,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub error: Option<String>,
108}
109
110impl SubagentResult {
111 fn offline(name: &str, prompt: &str) -> Self {
112 Self {
113 output: format!("subagent {name} completed offline for prompt: {prompt}"),
114 files_modified: vec![],
115 tool_calls: 0,
116 error: None,
117 }
118 }
119}
120
121#[derive(Debug)]
123struct SubagentState {
124 #[allow(dead_code)]
125 id: String,
126 #[allow(dead_code)]
127 name: String,
128 status: SubagentStatus,
129 result: Option<SubagentResult>,
130 worktree_path: Option<PathBuf>,
131 #[allow(dead_code)]
132 spawned_at: DateTime<Utc>,
133}
134
135#[derive(Debug, Clone)]
137pub struct SubagentHandle {
138 id: String,
139 name: String,
140 state: Arc<Mutex<SubagentState>>,
141}
142
143impl SubagentHandle {
144 pub fn id(&self) -> &str {
146 &self.id
147 }
148
149 pub fn name(&self) -> &str {
151 &self.name
152 }
153
154 pub fn status(&self) -> SubagentStatus {
156 self.state.lock().status
157 }
158
159 pub fn result(&self) -> Option<SubagentResult> {
161 self.state.lock().result.clone()
162 }
163
164 pub fn worktree_path(&self) -> Option<PathBuf> {
166 self.state.lock().worktree_path.clone()
167 }
168
169 #[cfg(feature = "ipc")]
171 pub async fn wait(&self) -> SubagentResult {
172 loop {
173 {
174 let guard = self.state.lock();
175 if matches!(
176 guard.status,
177 SubagentStatus::Completed | SubagentStatus::Failed | SubagentStatus::Cancelled
178 ) {
179 return guard.result.clone().unwrap_or_else(|| SubagentResult {
180 output: String::new(),
181 files_modified: vec![],
182 tool_calls: 0,
183 error: Some("no result recorded".to_string()),
184 });
185 }
186 }
187 tokio::task::yield_now().await;
188 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
189 }
190 }
191
192 pub fn wait_sync(&self) -> SubagentResult {
194 let guard = self.state.lock();
195 guard.result.clone().unwrap_or_else(|| SubagentResult {
196 output: String::new(),
197 files_modified: vec![],
198 tool_calls: 0,
199 error: Some("no result recorded".to_string()),
200 })
201 }
202
203 fn transition(&self, status: SubagentStatus, result: Option<SubagentResult>) {
204 let mut guard = self.state.lock();
205 guard.status = status;
206 if result.is_some() {
207 guard.result = result;
208 }
209 }
210}
211
212#[derive(Default)]
214pub struct SubagentManager {
215 subagents: HashMap<String, SubagentHandle>,
216 provider: Option<Arc<dyn Provider>>,
217 tools: Option<Arc<ToolRegistry>>,
218}
219
220impl std::fmt::Debug for SubagentManager {
221 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
222 f.debug_struct("SubagentManager")
223 .field("subagents", &self.subagents.len())
224 .field("has_provider", &self.provider.is_some())
225 .finish()
226 }
227}
228
229impl SubagentManager {
230 pub fn new() -> Self {
232 Self::default()
233 }
234
235 pub fn with_provider(mut self, provider: Arc<dyn Provider>) -> Self {
237 self.provider = Some(provider);
238 self
239 }
240
241 pub fn with_tools(mut self, tools: Arc<ToolRegistry>) -> Self {
243 self.tools = Some(tools);
244 self
245 }
246
247 pub fn spawn(
256 &mut self,
257 config: SubagentConfig,
258 prompt: &str,
259 parent_workspace: &Path,
260 ) -> Result<SubagentHandle, SubagentError> {
261 let id = Uuid::new_v4().to_string();
262 let worktree_path = if config.workspace_isolation {
263 Some(self.create_worktree(&id, parent_workspace)?)
264 } else {
265 None
266 };
267
268 let workspace = worktree_path
269 .clone()
270 .unwrap_or_else(|| parent_workspace.to_path_buf());
271
272 let state = Arc::new(Mutex::new(SubagentState {
273 id: id.clone(),
274 name: config.name.clone(),
275 status: SubagentStatus::Pending,
276 result: None,
277 worktree_path: worktree_path.clone(),
278 spawned_at: Utc::now(),
279 }));
280 let handle = SubagentHandle {
281 id: id.clone(),
282 name: config.name.clone(),
283 state,
284 };
285
286 handle.transition(SubagentStatus::Running, None);
287
288 let result = if let Some(provider) = self.provider.clone() {
289 match run_agent_subagent(provider, self.tools.clone(), &config, prompt, &workspace) {
290 Ok(r) => {
291 handle.transition(SubagentStatus::Completed, Some(r.clone()));
292 r
293 }
294 Err(e) => {
295 let r = SubagentResult {
296 output: String::new(),
297 files_modified: vec![],
298 tool_calls: 0,
299 error: Some(e),
300 };
301 handle.transition(SubagentStatus::Failed, Some(r.clone()));
302 r
303 }
304 }
305 } else {
306 let r = SubagentResult::offline(&config.name, prompt);
307 handle.transition(SubagentStatus::Completed, Some(r.clone()));
308 r
309 };
310
311 let _ = result;
312
313 if let Some(path) = worktree_path {
314 let _ = self.cleanup_worktree(&path);
315 }
316
317 self.subagents.insert(id, handle.clone());
318 Ok(handle)
319 }
320
321 #[cfg(feature = "ipc")]
323 pub async fn spawn_async(
324 &mut self,
325 config: SubagentConfig,
326 prompt: &str,
327 parent_workspace: &Path,
328 ) -> Result<SubagentHandle, SubagentError> {
329 let id = Uuid::new_v4().to_string();
330 let worktree_path = if config.workspace_isolation {
331 Some(self.create_worktree(&id, parent_workspace)?)
332 } else {
333 None
334 };
335 let workspace = worktree_path
336 .clone()
337 .unwrap_or_else(|| parent_workspace.to_path_buf());
338
339 let state = Arc::new(Mutex::new(SubagentState {
340 id: id.clone(),
341 name: config.name.clone(),
342 status: SubagentStatus::Pending,
343 result: None,
344 worktree_path: worktree_path.clone(),
345 spawned_at: Utc::now(),
346 }));
347 let handle = SubagentHandle {
348 id: id.clone(),
349 name: config.name.clone(),
350 state,
351 };
352 handle.transition(SubagentStatus::Running, None);
353
354 let result = if let Some(provider) = self.provider.clone() {
355 match run_agent_subagent_async(
356 provider,
357 self.tools.clone(),
358 &config,
359 prompt,
360 &workspace,
361 )
362 .await
363 {
364 Ok(r) => {
365 handle.transition(SubagentStatus::Completed, Some(r.clone()));
366 r
367 }
368 Err(e) => {
369 let r = SubagentResult {
370 output: String::new(),
371 files_modified: vec![],
372 tool_calls: 0,
373 error: Some(e),
374 };
375 handle.transition(SubagentStatus::Failed, Some(r.clone()));
376 r
377 }
378 }
379 } else {
380 let r = SubagentResult::offline(&config.name, prompt);
381 handle.transition(SubagentStatus::Completed, Some(r.clone()));
382 r
383 };
384 let _ = result;
385
386 if let Some(path) = worktree_path {
387 let _ = self.cleanup_worktree(&path);
388 }
389
390 self.subagents.insert(id, handle.clone());
391 Ok(handle)
392 }
393
394 pub fn list(&self) -> Vec<&SubagentHandle> {
396 self.subagents.values().collect()
397 }
398
399 pub fn get(&self, id: &str) -> Option<&SubagentHandle> {
401 self.subagents.get(id)
402 }
403
404 pub fn cancel(&mut self, id: &str) -> Result<(), SubagentError> {
406 let handle = self
407 .subagents
408 .get(id)
409 .ok_or_else(|| SubagentError::NotFound(id.to_string()))?;
410 {
411 let guard = handle.state.lock();
412 if matches!(
413 guard.status,
414 SubagentStatus::Completed | SubagentStatus::Failed | SubagentStatus::Cancelled
415 ) {
416 return Ok(());
417 }
418 }
419 handle.transition(SubagentStatus::Cancelled, None);
420 Ok(())
421 }
422
423 #[cfg(feature = "ipc")]
425 pub async fn wait_all(&self) -> Vec<SubagentResult> {
426 let mut results = Vec::with_capacity(self.subagents.len());
427 for handle in self.subagents.values() {
428 results.push(handle.wait().await);
429 }
430 results
431 }
432
433 pub fn wait_all_sync(&self) -> Vec<SubagentResult> {
435 self.subagents.values().map(|h| h.wait_sync()).collect()
436 }
437
438 fn create_worktree(&self, id: &str, parent_workspace: &Path) -> Result<PathBuf, SubagentError> {
439 let worktrees_dir = parent_workspace.join(".rx4").join("worktrees");
440 std::fs::create_dir_all(&worktrees_dir)
441 .map_err(|e| SubagentError::GitError(format!("create worktrees dir: {e}")))?;
442 let path = worktrees_dir.join(id);
443 std::fs::create_dir_all(&path)
444 .map_err(|e| SubagentError::GitError(format!("create worktree dir: {e}")))?;
445 Ok(path)
446 }
447
448 fn cleanup_worktree(&self, path: &Path) -> Result<(), SubagentError> {
449 std::fs::remove_dir_all(path)
450 .map_err(|e| SubagentError::GitError(format!("remove worktree dir: {e}")))
451 }
452}
453
454fn policy_from_config(config: &SubagentConfig) -> Policy {
455 let mut policy = match config
456 .permission_mode
457 .unwrap_or(PermissionMode::WorkspaceWrite)
458 {
459 PermissionMode::FullAccess => Policy::full_access(),
460 PermissionMode::ReadOnly => Policy::read_only(),
461 PermissionMode::WorkspaceWrite => Policy::workspace_write(),
462 PermissionMode::DenyAll => Policy::deny_all(),
463 };
464 if let Some(allow) = &config.allowed_tools {
465 policy.allowlist = allow.clone();
466 }
467 if let Some(deny) = &config.denied_tools {
468 policy.denylist = deny.clone();
469 }
470 policy
471}
472
473fn build_child_agent(
474 provider: Arc<dyn Provider>,
475 tools: Option<Arc<ToolRegistry>>,
476 config: &SubagentConfig,
477 workspace: &Path,
478) -> Agent {
479 let mut agent = Agent::new();
480 agent.set_provider(provider);
481 if let Some(model) = &config.model {
482 agent.set_model(model.clone());
483 }
484 if let Some(sys) = &config.system_prompt {
485 agent.set_system_prompt(sys.clone());
486 }
487 agent.max_tool_iterations = config.max_steps.max(1);
488 agent.set_policy(policy_from_config(config));
489 agent.set_workspace_root(workspace);
490 if let Some(tools) = tools {
491 agent.tools = tools;
494 }
495 agent
496}
497
498fn run_agent_subagent(
499 provider: Arc<dyn Provider>,
500 tools: Option<Arc<ToolRegistry>>,
501 config: &SubagentConfig,
502 prompt: &str,
503 workspace: &Path,
504) -> Result<SubagentResult, String> {
505 let config = config.clone();
506 let prompt = prompt.to_string();
507 let workspace = workspace.to_path_buf();
508 std::thread::spawn(move || {
509 let rt = tokio::runtime::Builder::new_current_thread()
510 .enable_all()
511 .build()
512 .map_err(|e| e.to_string())?;
513 rt.block_on(async move {
514 let mut agent = build_child_agent(provider, tools, &config, &workspace);
515 agent.prompt(&prompt).await.map_err(|e| e.to_string())?;
516 let messages = agent.messages.read().clone();
517 let tool_calls = messages
518 .iter()
519 .filter(|m| m.role == crate::provider::Role::Tool)
520 .count();
521 let output = messages
522 .iter()
523 .rev()
524 .find(|m| m.role == crate::provider::Role::Assistant)
525 .map(|m| m.content.clone())
526 .unwrap_or_default();
527 Ok(SubagentResult {
528 output,
529 files_modified: vec![],
530 tool_calls,
531 error: None,
532 })
533 })
534 })
535 .join()
536 .map_err(|_| "subagent thread panicked".to_string())?
537}
538
539#[cfg(feature = "ipc")]
540async fn run_agent_subagent_async(
541 provider: Arc<dyn Provider>,
542 tools: Option<Arc<ToolRegistry>>,
543 config: &SubagentConfig,
544 prompt: &str,
545 workspace: &Path,
546) -> Result<SubagentResult, String> {
547 let mut agent = build_child_agent(provider, tools, config, workspace);
548 agent.prompt(prompt).await.map_err(|e| e.to_string())?;
549 let messages = agent.messages.read().clone();
550 let tool_calls = messages
551 .iter()
552 .filter(|m| m.role == crate::provider::Role::Tool)
553 .count();
554 let output = messages
555 .iter()
556 .rev()
557 .find(|m| m.role == crate::provider::Role::Assistant)
558 .map(|m| m.content.clone())
559 .unwrap_or_default();
560 Ok(SubagentResult {
561 output,
562 files_modified: vec![],
563 tool_calls,
564 error: None,
565 })
566}
567
568#[cfg(test)]
569mod tests {
570 use super::*;
571
572 fn config(name: &str) -> SubagentConfig {
573 SubagentConfig {
574 name: name.to_string(),
575 ..SubagentConfig::default()
576 }
577 }
578
579 #[test]
580 fn config_default_uses_max_steps_25() {
581 let c = SubagentConfig::default();
582 assert_eq!(c.max_steps, 25);
583 assert_eq!(c.name, "subagent");
584 assert!(!c.workspace_isolation);
585 }
586
587 #[test]
588 fn config_construction_with_overrides() {
589 let c = SubagentConfig {
590 name: "reviewer".to_string(),
591 system_prompt: Some("you review code".to_string()),
592 model: Some("gpt-4o".to_string()),
593 max_steps: 10,
594 allowed_tools: Some(vec!["read".to_string()]),
595 denied_tools: Some(vec!["bash".to_string()]),
596 permission_mode: Some(PermissionMode::ReadOnly),
597 workspace_isolation: true,
598 };
599 assert_eq!(c.name, "reviewer");
600 assert_eq!(c.max_steps, 10);
601 assert!(c.workspace_isolation);
602 assert_eq!(c.allowed_tools.as_ref().unwrap().len(), 1);
603 }
604
605 #[test]
606 fn manager_spawn_returns_handle_with_result() {
607 let mut mgr = SubagentManager::new();
608 let handle = mgr
609 .spawn(config("worker"), "do the thing", Path::new("."))
610 .expect("spawn");
611 assert_eq!(handle.name(), "worker");
612 assert_eq!(handle.status(), SubagentStatus::Completed);
613 let result = handle.result().expect("result");
614 assert!(result.output.contains("do the thing"));
615 assert_eq!(result.tool_calls, 0);
616 }
617
618 #[test]
619 fn manager_list_and_get() {
620 let mut mgr = SubagentManager::new();
621 let h1 = mgr.spawn(config("a"), "p1", Path::new(".")).expect("spawn");
622 let h2 = mgr.spawn(config("b"), "p2", Path::new(".")).expect("spawn");
623 assert_eq!(mgr.list().len(), 2);
624 assert!(mgr.get(h1.id()).is_some());
625 assert!(mgr.get(h2.id()).is_some());
626 assert!(mgr.get("nope").is_none());
627 }
628
629 #[test]
630 fn manager_cancel_pending_or_running() {
631 let mut mgr = SubagentManager::new();
632 let handle = mgr.spawn(config("c"), "p", Path::new(".")).expect("spawn");
633 mgr.cancel(handle.id()).expect("cancel");
634 let status = handle.status();
635 assert!(
636 matches!(
637 status,
638 SubagentStatus::Cancelled | SubagentStatus::Completed
639 ),
640 "got {status:?}"
641 );
642 }
643
644 #[test]
645 fn manager_cancel_missing_returns_not_found() {
646 let mut mgr = SubagentManager::new();
647 let err = mgr.cancel("missing").unwrap_err();
648 assert!(matches!(err, SubagentError::NotFound(_)));
649 }
650
651 #[test]
652 fn status_transitions_cover_all_variants() {
653 let state = Arc::new(Mutex::new(SubagentState {
654 id: "x".to_string(),
655 name: "x".to_string(),
656 status: SubagentStatus::Pending,
657 result: None,
658 worktree_path: None,
659 spawned_at: Utc::now(),
660 }));
661 let handle = SubagentHandle {
662 id: "x".to_string(),
663 name: "x".to_string(),
664 state,
665 };
666 assert_eq!(handle.status(), SubagentStatus::Pending);
667 handle.transition(SubagentStatus::Running, None);
668 assert_eq!(handle.status(), SubagentStatus::Running);
669 handle.transition(
670 SubagentStatus::Completed,
671 Some(SubagentResult::offline("x", "done")),
672 );
673 assert_eq!(handle.status(), SubagentStatus::Completed);
674 assert!(handle.result().is_some());
675 }
676
677 #[test]
678 fn wait_all_sync_returns_results() {
679 let mut mgr = SubagentManager::new();
680 mgr.spawn(config("a"), "p1", Path::new(".")).expect("spawn");
681 mgr.spawn(config("b"), "p2", Path::new(".")).expect("spawn");
682 let results = mgr.wait_all_sync();
683 assert_eq!(results.len(), 2);
684 for r in &results {
685 assert!(!r.output.is_empty());
686 }
687 }
688
689 #[test]
690 fn workspace_isolation_creates_and_cleans_worktree() {
691 let tmp = tempfile::tempdir().expect("tempdir");
692 let mut mgr = SubagentManager::new();
693 let cfg = SubagentConfig {
694 name: "iso".to_string(),
695 workspace_isolation: true,
696 ..SubagentConfig::default()
697 };
698 let handle = mgr.spawn(cfg, "p", tmp.path()).expect("spawn");
699 let worktree = handle.worktree_path();
700 assert!(worktree.is_some());
701 assert!(!worktree.unwrap().exists(), "worktree should be cleaned up");
702 }
703
704 #[test]
705 fn subagent_error_display() {
706 assert_eq!(
707 SubagentError::NotFound("x".to_string()).to_string(),
708 "subagent not found: x"
709 );
710 assert_eq!(
711 SubagentError::SpawnFailed("boom".to_string()).to_string(),
712 "subagent spawn failed: boom"
713 );
714 assert_eq!(
715 SubagentError::AlreadyRunning("y".to_string()).to_string(),
716 "subagent already running: y"
717 );
718 assert_eq!(
719 SubagentError::GitError("bad".to_string()).to_string(),
720 "git error: bad"
721 );
722 }
723}