Skip to main content

rx4/
subagent.rs

1//! Subagent: isolated agent execution contexts with optional git worktree
2//! isolation.
3//!
4//! A subagent is a lightweight, independently-scoped agent run spawned by a
5//! parent agent or host. Each subagent has its own config (model, tools,
6//! permissions) and produces a [`SubagentResult`] when it completes.
7//!
8//! When a [`Provider`] is attached to the manager, spawn drives a real
9//! [`crate::agent::Agent`] loop. Without a provider, the subagent still
10//! records a completed run (prompt accepted, no LLM turns).
11
12use 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/// Errors raised by subagent lifecycle operations.
25#[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/// Lifecycle state of a subagent.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum SubagentStatus {
41    /// Created but not yet started.
42    Pending,
43    /// Currently executing.
44    Running,
45    /// Finished successfully.
46    Completed,
47    /// Terminated with an error.
48    Failed,
49    /// Cancelled by the parent.
50    Cancelled,
51}
52
53/// Declarative configuration for a subagent.
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct SubagentConfig {
56    /// Human-readable name for the subagent.
57    pub name: String,
58    /// Optional system prompt override.
59    pub system_prompt: Option<String>,
60    /// Optional model id override.
61    pub model: Option<String>,
62    /// Maximum tool iterations / turns.
63    #[serde(default = "default_max_steps")]
64    pub max_steps: usize,
65    /// Optional allowlist of tool names.
66    pub allowed_tools: Option<Vec<String>>,
67    /// Optional denylist of tool names.
68    pub denied_tools: Option<Vec<String>>,
69    /// Optional permission mode for the child agent.
70    pub permission_mode: Option<PermissionMode>,
71    /// When true, create an isolated work directory under `.rx4/worktrees/`.
72    #[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/// The outcome of a completed subagent run.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct SubagentResult {
98    /// Final text output produced by the subagent.
99    pub output: String,
100    /// Paths modified by the subagent (relative to its workspace).
101    #[serde(default, skip_serializing_if = "Vec::is_empty")]
102    pub files_modified: Vec<String>,
103    /// Number of tool calls performed.
104    pub tool_calls: usize,
105    /// Error message if the subagent failed.
106    #[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/// Internal shared state for a single subagent.
122#[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/// Handle to a spawned subagent. Cheap to clone — all state is shared.
136#[derive(Debug, Clone)]
137pub struct SubagentHandle {
138    id: String,
139    name: String,
140    state: Arc<Mutex<SubagentState>>,
141}
142
143impl SubagentHandle {
144    /// The unique identifier of this subagent.
145    pub fn id(&self) -> &str {
146        &self.id
147    }
148
149    /// The human-readable name of this subagent.
150    pub fn name(&self) -> &str {
151        &self.name
152    }
153
154    /// Current lifecycle status.
155    pub fn status(&self) -> SubagentStatus {
156        self.state.lock().status
157    }
158
159    /// The result if the subagent has completed, otherwise `None`.
160    pub fn result(&self) -> Option<SubagentResult> {
161        self.state.lock().result.clone()
162    }
163
164    /// The filesystem path of the git worktree if isolation was requested.
165    pub fn worktree_path(&self) -> Option<PathBuf> {
166        self.state.lock().worktree_path.clone()
167    }
168
169    /// Block asynchronously until the subagent completes and return its result.
170    #[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    /// Synchronous variant of [`wait`](Self::wait) for non-async callers.
193    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/// Manages the lifecycle of a collection of subagents.
213#[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    /// Create an empty manager.
231    pub fn new() -> Self {
232        Self::default()
233    }
234
235    /// Attach a provider so spawned subagents run a real agent loop.
236    pub fn with_provider(mut self, provider: Arc<dyn Provider>) -> Self {
237        self.provider = Some(provider);
238        self
239    }
240
241    /// Attach a tool registry for child agents.
242    pub fn with_tools(mut self, tools: Arc<ToolRegistry>) -> Self {
243        self.tools = Some(tools);
244        self
245    }
246
247    /// Spawn a new subagent with the given config and prompt.
248    ///
249    /// If `config.workspace_isolation` is true, a work directory is created at
250    /// `.rx4/worktrees/{id}` inside `parent_workspace`.
251    ///
252    /// When a provider is configured, runs [`Agent::prompt`] (blocking the
253    /// current thread via a temporary runtime if needed). Otherwise completes
254    /// offline with the prompt recorded in the result.
255    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    /// Async spawn that runs the agent loop on the current runtime.
322    #[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    /// List all known subagent handles.
395    pub fn list(&self) -> Vec<&SubagentHandle> {
396        self.subagents.values().collect()
397    }
398
399    /// Look up a subagent by id.
400    pub fn get(&self, id: &str) -> Option<&SubagentHandle> {
401        self.subagents.get(id)
402    }
403
404    /// Cancel a running or pending subagent.
405    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    /// Wait for all subagents to complete and return their results.
424    #[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    /// Synchronous variant of [`wait_all`](Self::wait_all).
434    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        // ToolRegistry is not Clone; rebuild empty and let host pass Arc only.
492        // When Arc tools provided, set via clone of Arc if Agent accepts Arc.
493        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}