1use 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
41pub const DEFAULT_MAX_SUBAGENT_DEPTH: u32 = 2;
49
50#[derive(Debug, Clone)]
55pub enum SubagentState {
56 Pending {
58 registered_at_ms: u64,
60 },
61 Active {
63 started_at_ms: u64,
65 },
66 Completed {
68 finished_at_ms: u64,
70 response: String,
73 },
74 Failed {
76 finished_at_ms: u64,
78 error: String,
80 },
81 Cancelled {
83 finished_at_ms: u64,
85 },
86}
87
88impl SubagentState {
89 pub fn is_terminal(&self) -> bool {
91 matches!(
92 self,
93 SubagentState::Completed { .. }
94 | SubagentState::Failed { .. }
95 | SubagentState::Cancelled { .. }
96 )
97 }
98
99 pub fn is_active(&self) -> bool {
101 matches!(self, SubagentState::Active { .. })
102 }
103}
104
105#[derive(Debug, Clone)]
107pub struct SubagentSpawnRequest {
108 pub agent_id: String,
111 pub config: AgentConfig,
113 pub task: String,
115 pub run_in_background: bool,
122 pub resume_from: Option<String>,
125 pub depth: u32,
128}
129
130#[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 pub fn state(&self) -> SubagentState {
144 self.state.read().clone()
145 }
146
147 pub fn run_in_background(&self) -> bool {
149 self.run_in_background
150 }
151
152 pub fn resume_from(&self) -> Option<&str> {
154 self.resume_from.as_deref()
155 }
156
157 pub fn spawned_at_ms(&self) -> u64 {
159 self.spawned_at_ms
160 }
161
162 pub fn cancel(&self) {
168 self.cancel_token.cancel();
169 }
170
171 pub async fn wait_for_completion(&self, timeout: Duration) -> Option<SubagentState> {
177 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#[derive(Debug, thiserror::Error)]
192pub enum SubagentCoordinatorError {
193 #[error("subagent depth {depth} exceeds maximum {max}")]
195 MaxDepthExceeded {
196 depth: u32,
198 max: u32,
200 },
201 #[error("subagent agent_id '{0}' already in use")]
203 DuplicateId(String),
204 #[error("supervisor spawn failed: {0}")]
207 SpawnFailed(String),
208 #[error("resume_from agent '{0}' not found")]
211 ResumeFromNotFound(String),
212}
213
214pub type Result<T, E = SubagentCoordinatorError> = std::result::Result<T, E>;
216
217#[derive(Clone)]
222pub struct SubagentCoordinator {
223 supervisor: AgentSupervisor,
224 trackers: Arc<RwLock<HashMap<String, Arc<SubagentTracker>>>>,
225 last_responses: Arc<RwLock<HashMap<String, String>>>,
228 max_depth: u32,
229}
230
231impl SubagentCoordinator {
232 pub fn new(supervisor: AgentSupervisor) -> Self {
235 Self::with_max_depth(supervisor, DEFAULT_MAX_SUBAGENT_DEPTH)
236 }
237
238 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 pub fn max_depth(&self) -> u32 {
250 self.max_depth
251 }
252
253 pub fn supervisor(&self) -> &AgentSupervisor {
256 &self.supervisor
257 }
258
259 pub fn tracked_count(&self) -> usize {
261 self.trackers.read().len()
262 }
263
264 pub fn tracker(&self, agent_id: &str) -> Option<Arc<SubagentTracker>> {
266 self.trackers.read().get(agent_id).cloned()
267 }
268
269 pub fn state(&self, agent_id: &str) -> Option<SubagentState> {
271 self.tracker(agent_id).map(|t| t.state())
272 }
273
274 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 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 if self.trackers.read().contains_key(&req.agent_id) {
305 return Err(SubagentCoordinatorError::DuplicateId(req.agent_id));
306 }
307
308 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 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 let handle = self.supervisor.spawn(req.config).map_err(|e| {
346 self.trackers.write().remove(&req.agent_id);
348 SubagentCoordinatorError::SpawnFailed(e.to_string())
349 })?;
350
351 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 {
360 let mut s = state_for_task.write();
361 *s = SubagentState::Active {
362 started_at_ms: now_ms(),
363 };
364 }
365
366 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 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 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 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 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 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 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 let _ = std::marker::PhantomData::<SdkError>;
618 }
619}