Skip to main content

oxicode_sdk/lifecycle/
subagent_coordinator.rs

1//! Subagent coordinator — tracks spawned subagents through the
2//! `pending → active → completed` lifecycle with cancellation,
3//! background mode, and depth guarding.
4//!
5//! Ported from grok's `SubagentCoordinator` (see
6//! `docs/designs/2026-07-18-stub-completion.md` §4.6) with these
7//! deviations:
8//!
9//! - **No ~80-field spawn context.** Spawning takes a tightly-typed
10//!   [`SubagentSpawnRequest`] instead of a god-struct.
11//! - **`MAX_SUBAGENT_DEPTH` defaults to 2** (OMP default) rather than
12//!   grok's fixed `1`. Configurable per-coordinator.
13//! - **`resume_from`** is implemented as a prompt-preamble inheritance
14//!   (last response text) for MVP — full transcript cloning is a
15//!   follow-up.
16//!
17//! # Lifecycle
18//!
19//! 1. [`SubagentCoordinator::spawn`] registers a [`SubagentTracker`]
20//!    in [`SubagentState::Pending`] and kicks off a background task
21//!    that transitions through `Active` → `Completed`/`Failed`/`Cancelled`.
22//! 2. [`SubagentCoordinator::tracker`] returns the tracker for polling.
23//! 3. [`SubagentTracker::wait_for_completion`] blocks (with timeout)
24//!    until the run finishes. On timeout it returns `None`; the
25//!    underlying task keeps running in the background — the caller can
26//!    poll again or treat the agent as backgrounded.
27//! 4. [`SubagentCoordinator::cancel`] triggers cancellation; the
28//!    background task observes it and transitions to `Cancelled`.
29
30use std::collections::HashMap;
31use std::sync::Arc;
32use std::time::Duration;
33
34use parking_lot::RwLock;
35use tokio::sync::Notify;
36use tokio_util::sync::CancellationToken;
37
38use crate::lifecycle::AgentSupervisor;
39use oxicode_agent::AgentConfig;
40
41/// Default ceiling on subagent nesting depth.
42///
43/// grok hard-codes this to `1` (no recursive subagents). OMP defaults
44/// to `2`. We adopt the OMP default to allow one level of recursive
45/// delegation — deeper trees blow up token budgets without
46/// commensurate capability gains, and the cap keeps runaway
47/// delegation observable.
48pub const DEFAULT_MAX_SUBAGENT_DEPTH: u32 = 2;
49
50/// One subagent's lifecycle state.
51///
52/// Order matters — `is_terminal()` returns true for the last three
53/// variants.
54#[derive(Debug, Clone)]
55pub enum SubagentState {
56    /// Spawn registered, background task not yet running the agent.
57    Pending {
58        /// When the spawn was registered (ms since epoch).
59        registered_at_ms: u64,
60    },
61    /// Agent is currently executing.
62    Active {
63        /// When the run started (ms since epoch).
64        started_at_ms: u64,
65    },
66    /// Agent finished successfully.
67    Completed {
68        /// When the run finished (ms since epoch).
69        finished_at_ms: u64,
70        /// The agent's final response text (may be empty if the run
71        /// produced no text — e.g. tool-only output).
72        response: String,
73    },
74    /// Agent run failed.
75    Failed {
76        /// When the run finished (ms since epoch).
77        finished_at_ms: u64,
78        /// Error message from the failed run.
79        error: String,
80    },
81    /// Agent run was cancelled.
82    Cancelled {
83        /// When the cancellation took effect (ms since epoch).
84        finished_at_ms: u64,
85    },
86}
87
88impl SubagentState {
89    /// Whether this state will never transition again.
90    pub fn is_terminal(&self) -> bool {
91        matches!(
92            self,
93            SubagentState::Completed { .. }
94                | SubagentState::Failed { .. }
95                | SubagentState::Cancelled { .. }
96        )
97    }
98
99    /// Whether the underlying run is currently active.
100    pub fn is_active(&self) -> bool {
101        matches!(self, SubagentState::Active { .. })
102    }
103}
104
105/// Inputs to [`SubagentCoordinator::spawn`].
106#[derive(Debug, Clone)]
107pub struct SubagentSpawnRequest {
108    /// Caller-chosen unique ID for this subagent. Two spawns with the
109    /// same ID collide and the second is rejected.
110    pub agent_id: String,
111    /// Agent configuration (model, tools, etc.).
112    pub config: AgentConfig,
113    /// Initial task prompt.
114    pub task: String,
115    /// If `true`, [`SubagentCoordinator::spawn`] returns immediately
116    /// and the caller polls via [`SubagentCoordinator::tracker`].
117    /// If `false`, the spawn still returns immediately (background
118    /// task drives the run) — the flag is informational and surfaces
119    /// in [`SubagentTracker::run_in_background`] for callers that
120    /// want to distinguish "fire-and-forget" from "foreground await".
121    pub run_in_background: bool,
122    /// If `Some(parent_id)`, the parent's last response text is
123    /// prepended to `task` as preamble context.
124    pub resume_from: Option<String>,
125    /// Current nesting depth (caller-supplied). The coordinator
126    /// rejects spawns whose depth exceeds its configured maximum.
127    pub depth: u32,
128}
129
130/// Per-subagent tracker — exposes cancellation and completion polling.
131#[derive(Debug)]
132pub struct SubagentTracker {
133    cancel_token: CancellationToken,
134    completion: Arc<Notify>,
135    state: Arc<RwLock<SubagentState>>,
136    run_in_background: bool,
137    resume_from: Option<String>,
138    spawned_at_ms: u64,
139}
140
141impl SubagentTracker {
142    /// Current lifecycle state (zero-copy snapshot).
143    pub fn state(&self) -> SubagentState {
144        self.state.read().clone()
145    }
146
147    /// Whether this subagent was spawned in background mode.
148    pub fn run_in_background(&self) -> bool {
149        self.run_in_background
150    }
151
152    /// Parent agent ID whose transcript was inherited, if any.
153    pub fn resume_from(&self) -> Option<&str> {
154        self.resume_from.as_deref()
155    }
156
157    /// Registration timestamp (ms since epoch).
158    pub fn spawned_at_ms(&self) -> u64 {
159        self.spawned_at_ms
160    }
161
162    /// Trigger cancellation. The background task observes this and
163    /// transitions to [`SubagentState::Cancelled`] on its next
164    /// `tokio::select!` tick. Returns immediately — callers should
165    /// poll [`Self::state`] or [`Self::wait_for_completion`] to
166    /// observe the actual transition.
167    pub fn cancel(&self) {
168        self.cancel_token.cancel();
169    }
170
171    /// Block until the underlying run reaches a terminal state, or
172    /// `timeout` elapses.
173    ///
174    /// On timeout returns `None`; the run continues in the
175    /// background and the caller may poll again.
176    pub async fn wait_for_completion(&self, timeout: Duration) -> Option<SubagentState> {
177        // Fast-path: already terminal.
178        let current = self.state();
179        if current.is_terminal() {
180            return Some(current);
181        }
182
183        match tokio::time::timeout(timeout, self.completion.notified()).await {
184            Ok(()) => Some(self.state()),
185            Err(_) => None,
186        }
187    }
188}
189
190/// Coordinator error.
191#[derive(Debug, thiserror::Error)]
192pub enum SubagentCoordinatorError {
193    /// Spawn requested with `depth` greater than the coordinator's max.
194    #[error("subagent depth {depth} exceeds maximum {max}")]
195    MaxDepthExceeded {
196        /// The depth the caller requested.
197        depth: u32,
198        /// The coordinator's configured maximum.
199        max: u32,
200    },
201    /// Spawn requested with an `agent_id` already in use.
202    #[error("subagent agent_id '{0}' already in use")]
203    DuplicateId(String),
204    /// The underlying supervisor rejected the spawn (model/provider
205    /// not found, etc.).
206    #[error("supervisor spawn failed: {0}")]
207    SpawnFailed(String),
208    /// `resume_from` referenced an agent the coordinator has no
209    /// record of.
210    #[error("resume_from agent '{0}' not found")]
211    ResumeFromNotFound(String),
212}
213
214/// Result type for coordinator operations.
215pub type Result<T, E = SubagentCoordinatorError> = std::result::Result<T, E>;
216
217/// Subagent coordinator.
218///
219/// Wraps an [`AgentSupervisor`] to add lifecycle tracking, cancellation,
220/// and depth guarding. Owns the [`SubagentTracker`] registry.
221#[derive(Clone)]
222pub struct SubagentCoordinator {
223    supervisor: AgentSupervisor,
224    trackers: Arc<RwLock<HashMap<String, Arc<SubagentTracker>>>>,
225    /// Last response text per agent, used to populate `resume_from`
226    /// preambles for downstream spawns.
227    last_responses: Arc<RwLock<HashMap<String, String>>>,
228    max_depth: u32,
229}
230
231impl SubagentCoordinator {
232    /// Create a new coordinator with the default max depth
233    /// ([`DEFAULT_MAX_SUBAGENT_DEPTH`]).
234    pub fn new(supervisor: AgentSupervisor) -> Self {
235        Self::with_max_depth(supervisor, DEFAULT_MAX_SUBAGENT_DEPTH)
236    }
237
238    /// Create with an explicit max subagent nesting depth.
239    pub fn with_max_depth(supervisor: AgentSupervisor, max_depth: u32) -> Self {
240        Self {
241            supervisor,
242            trackers: Arc::new(RwLock::new(HashMap::new())),
243            last_responses: Arc::new(RwLock::new(HashMap::new())),
244            max_depth,
245        }
246    }
247
248    /// Configured maximum nesting depth.
249    pub fn max_depth(&self) -> u32 {
250        self.max_depth
251    }
252
253    /// Borrow the wrapped supervisor (for direct spawns that bypass
254    /// coordinator tracking).
255    pub fn supervisor(&self) -> &AgentSupervisor {
256        &self.supervisor
257    }
258
259    /// Number of currently-tracked subagents (any state).
260    pub fn tracked_count(&self) -> usize {
261        self.trackers.read().len()
262    }
263
264    /// Look up a tracker by agent ID.
265    pub fn tracker(&self, agent_id: &str) -> Option<Arc<SubagentTracker>> {
266        self.trackers.read().get(agent_id).cloned()
267    }
268
269    /// Current state of an agent, or `None` if unknown.
270    pub fn state(&self, agent_id: &str) -> Option<SubagentState> {
271        self.tracker(agent_id).map(|t| t.state())
272    }
273
274    /// Snapshot of every tracked agent ID → state.
275    pub fn snapshot(&self) -> HashMap<String, SubagentState> {
276        self.trackers
277            .read()
278            .iter()
279            .map(|(id, t)| (id.clone(), t.state()))
280            .collect()
281    }
282
283    /// Spawn a subagent. Returns immediately with the agent ID on
284    /// success — the run proceeds in a background task.
285    ///
286    /// Errors:
287    /// - [`SubagentCoordinatorError::MaxDepthExceeded`] — `req.depth`
288    ///   exceeds [`Self::max_depth`].
289    /// - [`SubagentCoordinatorError::DuplicateId`] — `req.agent_id`
290    ///   is already tracked.
291    /// - [`SubagentCoordinatorError::SpawnFailed`] — supervisor
292    ///   rejected the spawn (bad model/provider).
293    /// - [`SubagentCoordinatorError::ResumeFromNotFound`] —
294    ///   `req.resume_from` names an unknown agent.
295    pub fn spawn(&self, req: SubagentSpawnRequest) -> Result<String> {
296        if req.depth > self.max_depth {
297            return Err(SubagentCoordinatorError::MaxDepthExceeded {
298                depth: req.depth,
299                max: self.max_depth,
300            });
301        }
302
303        // Reserve the ID first so concurrent spawns collide cleanly.
304        if self.trackers.read().contains_key(&req.agent_id) {
305            return Err(SubagentCoordinatorError::DuplicateId(req.agent_id));
306        }
307
308        // Resolve resume_from → preamble.
309        let task = if let Some(parent_id) = &req.resume_from {
310            let parent_response = self
311                .last_responses
312                .read()
313                .get(parent_id)
314                .cloned()
315                .ok_or_else(|| SubagentCoordinatorError::ResumeFromNotFound(parent_id.clone()))?;
316            format!(
317                "Previous context from agent '{parent_id}':\n---\n{parent_response}\n---\n\n{task}",
318                task = req.task
319            )
320        } else {
321            req.task.clone()
322        };
323
324        // Register Pending tracker BEFORE spawning so a quick cancel
325        // races fairly with the spawn.
326        let now = now_ms();
327        let state = Arc::new(RwLock::new(SubagentState::Pending {
328            registered_at_ms: now,
329        }));
330        let completion = Arc::new(Notify::new());
331        let cancel_token = CancellationToken::new();
332        let tracker = Arc::new(SubagentTracker {
333            cancel_token: cancel_token.clone(),
334            completion: completion.clone(),
335            state: state.clone(),
336            run_in_background: req.run_in_background,
337            resume_from: req.resume_from.clone(),
338            spawned_at_ms: now,
339        });
340        self.trackers
341            .write()
342            .insert(req.agent_id.clone(), tracker.clone());
343
344        // Supervisor spawn (synchronous: creates Agent + AgentHandle).
345        let handle = self.supervisor.spawn(req.config).map_err(|e| {
346            // Roll back the tracker insertion so the ID can be reused.
347            self.trackers.write().remove(&req.agent_id);
348            SubagentCoordinatorError::SpawnFailed(e.to_string())
349        })?;
350
351        // Drive the run in the background.
352        let agent_id = req.agent_id.clone();
353        let last_responses = self.last_responses.clone();
354        let state_for_task = state.clone();
355        let completion_for_task = completion.clone();
356
357        tokio::spawn(async move {
358            // Pending → Active.
359            {
360                let mut s = state_for_task.write();
361                *s = SubagentState::Active {
362                    started_at_ms: now_ms(),
363                };
364            }
365
366            // Race the run vs cancellation.
367            let outcome = tokio::select! {
368                _ = cancel_token.cancelled() => {
369                    let mut s = state_for_task.write();
370                    *s = SubagentState::Cancelled { finished_at_ms: now_ms() };
371                    None
372                }
373                r = handle.run(task) => Some(r),
374            };
375
376            if let Some(res) = outcome {
377                let mut s = state_for_task.write();
378                match res {
379                    Ok((response, _)) => {
380                        last_responses
381                            .write()
382                            .insert(agent_id.clone(), response.content.clone());
383                        *s = SubagentState::Completed {
384                            finished_at_ms: now_ms(),
385                            response: response.content,
386                        };
387                    }
388                    Err(e) => {
389                        *s = SubagentState::Failed {
390                            finished_at_ms: now_ms(),
391                            error: e.to_string(),
392                        };
393                    }
394                }
395            }
396
397            completion_for_task.notify_waiters();
398        });
399
400        Ok(req.agent_id)
401    }
402
403    /// Trigger cancellation for an agent. Returns `true` if the agent
404    /// was tracked (and is now scheduled to transition to
405    /// [`SubagentState::Cancelled`]).
406    pub fn cancel(&self, agent_id: &str) -> bool {
407        if let Some(t) = self.tracker(agent_id) {
408            t.cancel();
409            true
410        } else {
411            false
412        }
413    }
414
415    /// Block until the named agent reaches a terminal state, or
416    /// `timeout` elapses. Returns the terminal state on completion,
417    /// `None` on timeout (the underlying run continues), or `None`
418    /// if the agent is unknown.
419    ///
420    /// This is the "block_wait_slot" operation from the design doc:
421    /// on timeout the caller can either treat the agent as
422    /// backgrounded (move on) or re-poll.
423    pub async fn block_wait_slot(
424        &self,
425        agent_id: &str,
426        timeout: Duration,
427    ) -> Option<SubagentState> {
428        let tracker = self.tracker(agent_id)?;
429        tracker.wait_for_completion(timeout).await
430    }
431}
432
433fn now_ms() -> u64 {
434    use std::time::{SystemTime, UNIX_EPOCH};
435    SystemTime::now()
436        .duration_since(UNIX_EPOCH)
437        .map(|d| d.as_millis() as u64)
438        .unwrap_or(0)
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444    use crate::error::SdkError;
445    use crate::lifecycle::SnapshotStore;
446    use std::future::Future;
447    use std::pin::Pin;
448
449    /// A snapshot store that does nothing — used so the supervisor
450    /// can be constructed without filesystem access.
451    struct NoopSnapshotStore;
452
453    impl SnapshotStore for NoopSnapshotStore {
454        fn save<'a>(
455            &'a self,
456            _snapshot: &'a crate::lifecycle::AgentSnapshot,
457        ) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send + 'a>> {
458            Box::pin(async { Ok(()) })
459        }
460        fn load<'a>(
461            &'a self,
462            _agent_id: &'a str,
463        ) -> Pin<
464            Box<
465                dyn Future<Output = anyhow::Result<Option<crate::lifecycle::AgentSnapshot>>>
466                    + Send
467                    + 'a,
468            >,
469        > {
470            Box::pin(async { Ok(None) })
471        }
472        fn list(&self) -> Pin<Box<dyn Future<Output = anyhow::Result<Vec<String>>> + Send + '_>> {
473            Box::pin(async { Ok(vec![]) })
474        }
475        fn delete<'a>(
476            &'a self,
477            _agent_id: &'a str,
478        ) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send + 'a>> {
479            Box::pin(async { Ok(()) })
480        }
481    }
482
483    /// A provider resolver that always fails — sufficient for the
484    /// depth/duplicate/rejection tests since we never actually run.
485    struct FailingResolver;
486
487    impl oxicode_agent::ProviderResolver for FailingResolver {
488        fn resolve_model(&self, _id: &str) -> Option<oxicode_ai::Model> {
489            None
490        }
491        fn resolve_provider(&self, _provider: &str) -> Option<Arc<dyn oxicode_ai::Provider>> {
492            None
493        }
494    }
495
496    fn make_coordinator(max_depth: u32) -> SubagentCoordinator {
497        let resolver: Arc<dyn oxicode_agent::ProviderResolver> = Arc::new(FailingResolver);
498        let store: Arc<dyn SnapshotStore> = Arc::new(NoopSnapshotStore);
499        let supervisor = AgentSupervisor::new(resolver, store);
500        SubagentCoordinator::with_max_depth(supervisor, max_depth)
501    }
502
503    fn basic_request(id: &str, depth: u32) -> SubagentSpawnRequest {
504        SubagentSpawnRequest {
505            agent_id: id.to_string(),
506            config: AgentConfig {
507                model_id: "anthropic/claude-3-5-sonnet".into(),
508                ..Default::default()
509            },
510            task: "do nothing".into(),
511            run_in_background: true,
512            resume_from: None,
513            depth,
514        }
515    }
516
517    #[test]
518    fn rejects_depth_above_max() {
519        let coord = make_coordinator(2);
520        let req = basic_request("a", 3);
521        let err = coord.spawn(req).unwrap_err();
522        assert!(
523            matches!(
524                err,
525                SubagentCoordinatorError::MaxDepthExceeded { depth: 3, max: 2 }
526            ),
527            "got: {err:?}"
528        );
529    }
530
531    #[test]
532    fn spawn_fails_when_resolver_fails() {
533        // With FailingResolver, the supervisor's spawn() must reject.
534        // We assert the error path rolls back the tracker insertion
535        // (so a retry with the same ID is not blocked by DuplicateId).
536        let coord = make_coordinator(2);
537        let err = coord.spawn(basic_request("a", 0)).unwrap_err();
538        assert!(
539            matches!(err, SubagentCoordinatorError::SpawnFailed(_)),
540            "got: {err:?}"
541        );
542        assert_eq!(
543            coord.tracked_count(),
544            0,
545            "tracker must roll back on spawn failure"
546        );
547    }
548
549    #[test]
550    fn rejects_unknown_resume_from() {
551        let coord = make_coordinator(2);
552        let mut req = basic_request("a", 0);
553        req.resume_from = Some("nonexistent".into());
554        let err = coord.spawn(req).unwrap_err();
555        assert!(
556            matches!(err, SubagentCoordinatorError::ResumeFromNotFound(_)),
557            "got: {err:?}"
558        );
559    }
560
561    #[test]
562    fn tracked_count_starts_zero() {
563        let coord = make_coordinator(2);
564        assert_eq!(coord.tracked_count(), 0);
565        assert_eq!(coord.max_depth(), 2);
566    }
567
568    #[test]
569    fn cancel_for_unknown_returns_false() {
570        let coord = make_coordinator(2);
571        assert!(!coord.cancel("ghost"));
572    }
573
574    #[test]
575    fn block_wait_slot_unknown_returns_none() {
576        let coord = make_coordinator(2);
577        let rt = tokio::runtime::Builder::new_current_thread()
578            .enable_time()
579            .build()
580            .unwrap();
581        let r = rt.block_on(coord.block_wait_slot("ghost", Duration::from_millis(10)));
582        assert!(r.is_none());
583    }
584
585    #[test]
586    fn snapshot_of_empty_coordinator() {
587        let coord = make_coordinator(2);
588        assert!(coord.snapshot().is_empty());
589    }
590
591    #[test]
592    fn default_max_depth_is_two() {
593        let resolver: Arc<dyn oxicode_agent::ProviderResolver> = Arc::new(FailingResolver);
594        let store: Arc<dyn SnapshotStore> = Arc::new(NoopSnapshotStore);
595        let supervisor = AgentSupervisor::new(resolver, store);
596        let coord = SubagentCoordinator::new(supervisor);
597        assert_eq!(coord.max_depth(), DEFAULT_MAX_SUBAGENT_DEPTH);
598        assert_eq!(coord.max_depth(), 2);
599    }
600
601    #[test]
602    fn error_type_is_displayable() {
603        // Sanity: every variant formats without panicking.
604        let e1 = SubagentCoordinatorError::MaxDepthExceeded { depth: 3, max: 2 };
605        let e2 = SubagentCoordinatorError::DuplicateId("x".into());
606        let e3 = SubagentCoordinatorError::SpawnFailed("nope".into());
607        let e4 = SubagentCoordinatorError::ResumeFromNotFound("p".into());
608        assert!(!e1.to_string().is_empty());
609        assert!(!e2.to_string().is_empty());
610        assert!(!e3.to_string().is_empty());
611        assert!(!e4.to_string().is_empty());
612    }
613
614    #[test]
615    fn sdkerror_unused() {
616        // Compile-only check: SdkError stays in scope via the import.
617        let _ = std::marker::PhantomData::<SdkError>;
618    }
619}