Skip to main content

agent_works/multi_agent/
registry.rs

1//! Agent lifecycle registry.
2//!
3//! The [`AgentRegistry`] tracks all active sub-agents, enforces spawn limits
4//! (total count and depth), and manages the lifecycle state machine
5//! (`idle → running → done`).
6
7use std::collections::HashMap;
8
9use super::config::MultiAgentConfig;
10use super::path::AgentPath;
11
12// ---------------------------------------------------------------------------
13// Types
14// ---------------------------------------------------------------------------
15
16/// Lifecycle status of a sub-agent.
17#[derive(Clone, Debug, PartialEq, Eq)]
18pub enum AgentStatus {
19    /// Agent is spawned but has not yet received a task.
20    Idle,
21    /// Agent is currently executing a task.
22    Running,
23    /// Agent has completed its task and is idle, waiting for a new task or close.
24    Done,
25}
26
27/// A registered agent entry in the registry.
28#[derive(Clone, Debug)]
29pub struct AgentEntry {
30    /// The agent's tree path.
31    pub path: AgentPath,
32    /// Current lifecycle status.
33    pub status: AgentStatus,
34    /// Depth from root (root=0, direct child=1, etc.).
35    pub depth: i32,
36    /// Number of tools registered on this agent (for `list_agents` output).
37    pub tool_count: usize,
38}
39
40/// Errors that can occur during spawn attempts.
41#[derive(Clone, Debug, PartialEq, Eq)]
42pub enum SpawnError {
43    /// Spawning would exceed the maximum number of sub-agents.
44    MaxAgentsReached { max: usize },
45    /// Spawning would exceed the maximum agent nesting depth.
46    DepthLimitReached { max: i32, attempted: i32 },
47    /// An agent with this path already exists.
48    AlreadyExists,
49}
50
51impl std::fmt::Display for SpawnError {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        match self {
54            Self::MaxAgentsReached { max } => {
55                write!(f, "max agents reached (limit: {})", max)
56            }
57            Self::DepthLimitReached { max, attempted } => {
58                write!(
59                    f,
60                    "agent depth limit reached (max: {}, attempted: {})",
61                    max, attempted
62                )
63            }
64            Self::AlreadyExists => {
65                write!(f, "agent with this path already exists")
66            }
67        }
68    }
69}
70
71// ---------------------------------------------------------------------------
72// AgentRegistry
73// ---------------------------------------------------------------------------
74
75/// Tracks active sub-agents and enforces spawn limits.
76///
77/// # Lifecycle
78///
79/// ```text
80/// register() → status=Idle
81///   → set_status(Running) on followup_task
82///     → set_status(Done) on completion
83///       → close() removes from registry
84/// ```
85///
86/// Done agents still count toward the quota until explicitly `close()`d.
87/// There is no automatic garbage collection in v1.
88pub struct AgentRegistry {
89    config: MultiAgentConfig,
90    agents: HashMap<AgentPath, AgentEntry>,
91}
92
93impl AgentRegistry {
94    /// Create a new registry with the given configuration.
95    pub fn new(config: MultiAgentConfig) -> Self {
96        Self {
97            config,
98            agents: HashMap::new(),
99        }
100    }
101
102    /// Check whether a new agent can be spawned at the given depth.
103    ///
104    /// Returns `Ok(())` if spawning is allowed, or a [`SpawnError`] describing
105    /// which limit was exceeded.
106    pub fn can_spawn(&self, depth: i32) -> Result<(), SpawnError> {
107        // Check total count limit
108        if self.config.enabled && self.agents.len() >= self.config.max_sub_agents {
109            return Err(SpawnError::MaxAgentsReached {
110                max: self.config.max_sub_agents,
111            });
112        }
113
114        // Check depth limit
115        if self.config.enabled && depth > self.config.max_agent_depth {
116            return Err(SpawnError::DepthLimitReached {
117                max: self.config.max_agent_depth,
118                attempted: depth,
119            });
120        }
121
122        Ok(())
123    }
124
125    /// Register a new agent.
126    ///
127    /// Returns `Ok(())` on success, or a [`SpawnError`] if limits are exceeded
128    /// or the path already exists.
129    pub fn register(
130        &mut self,
131        path: &AgentPath,
132        depth: i32,
133        tool_count: usize,
134    ) -> Result<(), SpawnError> {
135        self.can_spawn(depth)?;
136
137        if self.agents.contains_key(path) {
138            return Err(SpawnError::AlreadyExists);
139        }
140
141        self.agents.insert(
142            path.clone(),
143            AgentEntry {
144                path: path.clone(),
145                status: AgentStatus::Idle,
146                depth,
147                tool_count,
148            },
149        );
150
151        Ok(())
152    }
153
154    /// Close (remove) an agent from the registry.
155    ///
156    /// Returns the removed entry, or `None` if the agent was not registered.
157    /// This releases the agent's quota slot.
158    pub fn close(&mut self, path: &AgentPath) -> Option<AgentEntry> {
159        self.agents.remove(path)
160    }
161
162    /// Update the lifecycle status of an agent.
163    ///
164    /// Returns `true` if the agent was found and updated.
165    pub fn set_status(&mut self, path: &AgentPath, status: AgentStatus) -> bool {
166        match self.agents.get_mut(path) {
167            Some(entry) => {
168                entry.status = status;
169                true
170            }
171            None => false,
172        }
173    }
174
175    /// Get an agent entry by path.
176    pub fn get(&self, path: &AgentPath) -> Option<&AgentEntry> {
177        self.agents.get(path)
178    }
179
180    /// List all registered agents (all statuses).
181    pub fn list(&self) -> Vec<&AgentEntry> {
182        let mut entries: Vec<&AgentEntry> = self.agents.values().collect();
183        entries.sort_by(|a, b| a.path.cmp(&b.path));
184        entries
185    }
186
187    /// Return the total number of registered agents.
188    pub fn count(&self) -> usize {
189        self.agents.len()
190    }
191
192    /// Return the number of agents with a specific status.
193    pub fn count_by_status(&self, status: &AgentStatus) -> usize {
194        self.agents.values().filter(|e| e.status == *status).count()
195    }
196
197    /// Return whether the registry is empty.
198    pub fn is_empty(&self) -> bool {
199        self.agents.is_empty()
200    }
201
202    /// Check if an agent path is registered.
203    pub fn contains(&self, path: &AgentPath) -> bool {
204        self.agents.contains_key(path)
205    }
206
207    /// Get a reference to the configuration.
208    pub fn config(&self) -> &MultiAgentConfig {
209        &self.config
210    }
211}
212
213// ---------------------------------------------------------------------------
214// Tests
215// ---------------------------------------------------------------------------
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    fn test_config() -> MultiAgentConfig {
222        MultiAgentConfig::enabled()
223    }
224
225    fn test_path(name: &str) -> AgentPath {
226        AgentPath::root().join(name)
227    }
228
229    #[test]
230    fn register_and_close() {
231        let mut reg = AgentRegistry::new(test_config());
232        let path = test_path("worker");
233
234        assert!(reg.register(&path, 1, 5).is_ok());
235        assert_eq!(reg.count(), 1);
236        assert!(reg.contains(&path));
237
238        let entry = reg.get(&path).unwrap();
239        assert_eq!(entry.status, AgentStatus::Idle);
240        assert_eq!(entry.depth, 1);
241        assert_eq!(entry.tool_count, 5);
242
243        let closed = reg.close(&path).unwrap();
244        assert_eq!(closed.path, path);
245        assert_eq!(reg.count(), 0);
246        assert!(!reg.contains(&path));
247    }
248
249    #[test]
250    fn duplicate_register_fails() {
251        let mut reg = AgentRegistry::new(test_config());
252        let path = test_path("worker");
253
254        assert!(reg.register(&path, 1, 3).is_ok());
255        assert_eq!(
256            reg.register(&path, 1, 3).unwrap_err(),
257            SpawnError::AlreadyExists
258        );
259    }
260
261    #[test]
262    fn max_agents_limit() {
263        let config = MultiAgentConfig::with_limits(2, 1);
264        let mut reg = AgentRegistry::new(config);
265
266        assert!(reg.register(&test_path("a"), 1, 1).is_ok());
267        assert!(reg.register(&test_path("b"), 1, 1).is_ok());
268        assert_eq!(
269            reg.register(&test_path("c"), 1, 1).unwrap_err(),
270            SpawnError::MaxAgentsReached { max: 2 }
271        );
272    }
273
274    #[test]
275    fn depth_limit() {
276        let config = MultiAgentConfig::with_limits(8, 1);
277        let reg = AgentRegistry::new(config);
278
279        // Depth 1 is allowed
280        assert!(reg.can_spawn(1).is_ok());
281
282        // Depth 2 exceeds limit
283        assert_eq!(
284            reg.can_spawn(2).unwrap_err(),
285            SpawnError::DepthLimitReached {
286                max: 1,
287                attempted: 2
288            }
289        );
290    }
291
292    #[test]
293    fn lifecycle_states() {
294        let mut reg = AgentRegistry::new(test_config());
295        let path = test_path("worker");
296
297        reg.register(&path, 1, 3).unwrap();
298        assert_eq!(reg.count_by_status(&AgentStatus::Idle), 1);
299
300        reg.set_status(&path, AgentStatus::Running);
301        assert_eq!(reg.count_by_status(&AgentStatus::Idle), 0);
302        assert_eq!(reg.count_by_status(&AgentStatus::Running), 1);
303        assert_eq!(reg.get(&path).unwrap().status, AgentStatus::Running);
304
305        reg.set_status(&path, AgentStatus::Done);
306        assert_eq!(reg.count_by_status(&AgentStatus::Running), 0);
307        assert_eq!(reg.count_by_status(&AgentStatus::Done), 1);
308
309        // Done agent still counts toward quota
310        assert_eq!(reg.count(), 1);
311    }
312
313    #[test]
314    fn close_frees_quota() {
315        let config = MultiAgentConfig::with_limits(1, 1);
316        let mut reg = AgentRegistry::new(config);
317
318        let path = test_path("worker");
319        reg.register(&path, 1, 3).unwrap();
320        assert_eq!(reg.count(), 1);
321
322        // Can't spawn another — quota full
323        assert!(reg.can_spawn(1).is_err());
324
325        // Close frees the slot
326        reg.close(&path);
327        assert_eq!(reg.count(), 0);
328        assert!(reg.can_spawn(1).is_ok());
329    }
330
331    #[test]
332    fn list_sorted() {
333        let mut reg = AgentRegistry::new(test_config());
334        reg.register(&test_path("b"), 1, 1).unwrap();
335        reg.register(&test_path("a"), 1, 1).unwrap();
336
337        let list = reg.list();
338        assert_eq!(list.len(), 2);
339        assert_eq!(list[0].path.name(), "a");
340        assert_eq!(list[1].path.name(), "b");
341    }
342
343    #[test]
344    fn set_status_nonexistent() {
345        let mut reg = AgentRegistry::new(test_config());
346        assert!(!reg.set_status(&test_path("ghost"), AgentStatus::Running));
347    }
348
349    #[test]
350    fn disabled_config_allows_spawn() {
351        // When config.enabled is false, limits are NOT checked
352        // (spawning still works, it's just that the 6 tools aren't registered)
353        let config = MultiAgentConfig {
354            enabled: false,
355            ..MultiAgentConfig::default()
356        };
357        let reg = AgentRegistry::new(config);
358
359        // can_spawn still returns Ok even with 0 sub_agents allowed
360        // because limits are only enforced when enabled=true
361        assert!(reg.can_spawn(999).is_ok());
362    }
363
364    #[test]
365    fn spawn_error_display() {
366        assert_eq!(
367            SpawnError::MaxAgentsReached { max: 8 }.to_string(),
368            "max agents reached (limit: 8)"
369        );
370        assert_eq!(
371            SpawnError::DepthLimitReached {
372                max: 1,
373                attempted: 2
374            }
375            .to_string(),
376            "agent depth limit reached (max: 1, attempted: 2)"
377        );
378        assert_eq!(
379            SpawnError::AlreadyExists.to_string(),
380            "agent with this path already exists"
381        );
382    }
383}