Skip to main content

exo_catapult/
agent.rs

1// Copyright 2026 Exochain Foundation
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at:
6//
7//     https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14//
15// SPDX-License-Identifier: Apache-2.0
16
17//! Catapult agent definitions and roster management.
18
19use exo_core::{DeterministicMap, Did, Timestamp};
20use serde::{Deserialize, Serialize};
21use uuid::Uuid;
22
23use crate::{
24    error::{CatapultError, Result},
25    oda::OdaSlot,
26};
27
28/// Operational status of an agent within the ODA.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
30pub enum AgentStatus {
31    /// Slot open, candidate being evaluated.
32    Recruiting,
33    /// Selected, undergoing preparation.
34    Onboarding,
35    /// Fully operational.
36    Active,
37    /// Temporarily stood down (budget, heartbeat, or governance action).
38    Suspended,
39    /// Honorably released from the ODA.
40    Released,
41}
42
43/// An agent assigned to an ODA slot within a newco.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct CatapultAgent {
46    /// Agent's decentralized identifier.
47    pub did: Did,
48    /// ODA slot this agent fills.
49    pub slot: OdaSlot,
50    /// Human-readable display name.
51    pub display_name: String,
52    /// Agent capabilities / specializations.
53    pub capabilities: Vec<String>,
54    /// Current operational status.
55    pub status: AgentStatus,
56    /// Last heartbeat timestamp.
57    pub last_heartbeat: Timestamp,
58    /// Budget spent in integer cents.
59    pub budget_spent_cents: u64,
60    /// Budget limit in integer cents.
61    pub budget_limit_cents: u64,
62    /// When this agent was hired.
63    pub hired_at: Timestamp,
64    /// DID of the agent that recruited this one.
65    pub hired_by: Did,
66    /// Optional link to CommandBase.ai profile name.
67    pub commandbase_profile: Option<String>,
68}
69
70/// Caller-supplied deterministic metadata for hiring an agent.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct CatapultAgentInput {
73    pub did: Did,
74    pub slot: OdaSlot,
75    pub display_name: String,
76    pub capabilities: Vec<String>,
77    pub status: AgentStatus,
78    pub last_heartbeat: Timestamp,
79    pub budget_spent_cents: u64,
80    pub budget_limit_cents: u64,
81    pub hired_at: Timestamp,
82    pub hired_by: Did,
83    pub commandbase_profile: Option<String>,
84}
85
86impl CatapultAgent {
87    /// Create an agent from caller-supplied lifecycle metadata.
88    ///
89    /// # Errors
90    /// Returns [`CatapultError`] when the input contains placeholder
91    /// timestamps, an empty display name, or an unusable budget limit.
92    pub fn new(input: CatapultAgentInput) -> Result<Self> {
93        validate_agent_input(&input)?;
94        Ok(Self {
95            did: input.did,
96            slot: input.slot,
97            display_name: input.display_name,
98            capabilities: input.capabilities,
99            status: input.status,
100            last_heartbeat: input.last_heartbeat,
101            budget_spent_cents: input.budget_spent_cents,
102            budget_limit_cents: input.budget_limit_cents,
103            hired_at: input.hired_at,
104            hired_by: input.hired_by,
105            commandbase_profile: input.commandbase_profile,
106        })
107    }
108
109    /// Validate externally supplied or deserialized agent metadata.
110    ///
111    /// # Errors
112    /// Returns [`CatapultError`] when the agent contains placeholder
113    /// lifecycle metadata.
114    pub fn validate(&self) -> Result<()> {
115        validate_agent_input(&CatapultAgentInput {
116            did: self.did.clone(),
117            slot: self.slot,
118            display_name: self.display_name.clone(),
119            capabilities: self.capabilities.clone(),
120            status: self.status,
121            last_heartbeat: self.last_heartbeat,
122            budget_spent_cents: self.budget_spent_cents,
123            budget_limit_cents: self.budget_limit_cents,
124            hired_at: self.hired_at,
125            hired_by: self.hired_by.clone(),
126            commandbase_profile: self.commandbase_profile.clone(),
127        })
128    }
129}
130
131/// The ODA roster — a governed map of slots to agents.
132#[derive(Debug, Clone, Default, Serialize, Deserialize)]
133pub struct AgentRoster {
134    agents: DeterministicMap<OdaSlot, CatapultAgent>,
135}
136
137impl AgentRoster {
138    /// Create an empty roster.
139    #[must_use]
140    pub fn new() -> Self {
141        Self {
142            agents: DeterministicMap::new(),
143        }
144    }
145
146    /// Fill an ODA slot with an agent. Returns an error if the slot is already occupied.
147    pub fn fill_slot(&mut self, agent: CatapultAgent) -> Result<()> {
148        agent.validate()?;
149        let slot = agent.slot;
150        if self.agents.contains_key(&slot) {
151            return Err(CatapultError::SlotAlreadyFilled(slot));
152        }
153        self.agents.insert(slot, agent);
154        Ok(())
155    }
156
157    /// Release an agent from a slot, returning the agent.
158    pub fn release_slot(&mut self, slot: &OdaSlot) -> Result<CatapultAgent> {
159        self.agents
160            .remove(slot)
161            .ok_or(CatapultError::SlotEmpty(*slot))
162    }
163
164    /// Look up an agent by slot.
165    #[must_use]
166    pub fn get(&self, slot: &OdaSlot) -> Option<&CatapultAgent> {
167        self.agents.get(slot)
168    }
169
170    /// Look up an agent by DID.
171    #[must_use]
172    pub fn get_by_did(&self, did: &Did) -> Option<&CatapultAgent> {
173        self.agents.values().find(|a| a.did == *did)
174    }
175
176    /// Return the founding agents (HR + Deep Researcher).
177    #[must_use]
178    pub fn founding_agents(&self) -> Vec<&CatapultAgent> {
179        OdaSlot::FOUNDERS
180            .iter()
181            .filter_map(|slot| self.agents.get(slot))
182            .collect()
183    }
184
185    /// Whether all 12 ODA slots are filled.
186    #[must_use]
187    pub fn is_complete(&self) -> bool {
188        OdaSlot::ALL
189            .iter()
190            .all(|slot| self.agents.contains_key(slot))
191    }
192
193    /// Number of filled slots.
194    #[must_use]
195    pub fn filled_count(&self) -> usize {
196        self.agents.len()
197    }
198
199    /// Number of vacant slots.
200    #[must_use]
201    pub fn vacancy_count(&self) -> usize {
202        12_usize.saturating_sub(self.agents.len())
203    }
204
205    /// Number of agents currently in Active status.
206    #[must_use]
207    pub fn active_count(&self) -> usize {
208        self.agents
209            .values()
210            .filter(|a| a.status == AgentStatus::Active)
211            .count()
212    }
213
214    /// Check whether a specific set of slots are filled.
215    #[must_use]
216    pub fn has_slots(&self, required: &[OdaSlot]) -> bool {
217        required.iter().all(|slot| self.agents.contains_key(slot))
218    }
219
220    /// Iterate over all filled slots.
221    pub fn iter(&self) -> impl Iterator<Item = (&OdaSlot, &CatapultAgent)> {
222        self.agents.iter()
223    }
224
225    /// Validate every roster entry and the map key-to-slot invariant.
226    ///
227    /// # Errors
228    /// Returns [`CatapultError`] when an agent contains placeholder metadata
229    /// or is stored under the wrong ODA slot key.
230    pub fn validate(&self) -> Result<()> {
231        for (slot, agent) in &self.agents {
232            if *slot != agent.slot {
233                return Err(CatapultError::InvalidAgent {
234                    reason: format!(
235                        "agent {} stored under slot {} but declares slot {}",
236                        agent.did,
237                        slot.slug(),
238                        agent.slot.slug()
239                    ),
240                });
241            }
242            agent.validate()?;
243        }
244        Ok(())
245    }
246
247    /// Generate a unique DID for an agent in this newco.
248    ///
249    /// Format: `did:exo:catapult:<newco_id>:<slot_name>`
250    pub fn generate_did(newco_id: &Uuid, slot: &OdaSlot) -> exo_core::Result<Did> {
251        Did::new(&format!("did:exo:catapult:{newco_id}:{}", slot.slug()))
252    }
253}
254
255fn validate_agent_input(input: &CatapultAgentInput) -> Result<()> {
256    if input.display_name.trim().is_empty() {
257        return Err(CatapultError::InvalidAgent {
258            reason: "agent display name must not be empty".into(),
259        });
260    }
261    if input.last_heartbeat == Timestamp::ZERO {
262        return Err(CatapultError::InvalidAgent {
263            reason: "agent last heartbeat must be caller-supplied HLC".into(),
264        });
265    }
266    if input.hired_at == Timestamp::ZERO {
267        return Err(CatapultError::InvalidAgent {
268            reason: "agent hired_at must be caller-supplied HLC".into(),
269        });
270    }
271    if input.last_heartbeat < input.hired_at {
272        return Err(CatapultError::InvalidAgent {
273            reason: "agent last heartbeat must not precede hired_at".into(),
274        });
275    }
276    if input.budget_limit_cents == 0 {
277        return Err(CatapultError::InvalidAgent {
278            reason: "agent budget limit must be nonzero".into(),
279        });
280    }
281    Ok(())
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    fn test_did(name: &str) -> Did {
289        Did::new(&format!("did:exo:test-{name}")).unwrap()
290    }
291
292    fn make_agent(slot: OdaSlot, name: &str) -> CatapultAgent {
293        CatapultAgent {
294            did: test_did(name),
295            slot,
296            display_name: name.into(),
297            capabilities: vec!["test".into()],
298            status: AgentStatus::Active,
299            last_heartbeat: Timestamp::new(1_765_000_000_100, 0),
300            budget_spent_cents: 0,
301            budget_limit_cents: 100_000,
302            hired_at: Timestamp::new(1_765_000_000_000, 0),
303            hired_by: test_did("hr"),
304            commandbase_profile: None,
305        }
306    }
307
308    #[test]
309    fn agent_new_requires_caller_supplied_lifecycle_metadata() {
310        let agent = CatapultAgent::new(CatapultAgentInput {
311            did: test_did("valid"),
312            slot: OdaSlot::DeepResearcher,
313            display_name: "valid".into(),
314            capabilities: vec!["research".into()],
315            status: AgentStatus::Active,
316            last_heartbeat: Timestamp::new(1_765_000_000_100, 0),
317            budget_spent_cents: 0,
318            budget_limit_cents: 100_000,
319            hired_at: Timestamp::new(1_765_000_000_000, 0),
320            hired_by: test_did("hr"),
321            commandbase_profile: None,
322        })
323        .unwrap();
324
325        assert_eq!(agent.slot, OdaSlot::DeepResearcher);
326        assert_ne!(agent.last_heartbeat, Timestamp::ZERO);
327        assert_ne!(agent.hired_at, Timestamp::ZERO);
328    }
329
330    #[test]
331    fn roster_rejects_placeholder_agent_metadata() {
332        let mut roster = AgentRoster::new();
333        let mut agent = make_agent(OdaSlot::DeepResearcher, "dr1");
334        agent.last_heartbeat = Timestamp::ZERO;
335        assert!(roster.fill_slot(agent).is_err());
336
337        let mut agent = make_agent(OdaSlot::DeepResearcher, "dr1");
338        agent.hired_at = Timestamp::ZERO;
339        assert!(roster.fill_slot(agent).is_err());
340
341        let mut agent = make_agent(OdaSlot::DeepResearcher, "dr1");
342        agent.budget_limit_cents = 0;
343        assert!(roster.fill_slot(agent).is_err());
344    }
345
346    #[test]
347    fn agent_validation_rejects_empty_name_and_regressive_heartbeat() {
348        let mut agent = make_agent(OdaSlot::DeepResearcher, "dr1");
349        agent.display_name = "   ".into();
350        assert!(agent.validate().is_err());
351
352        let mut agent = make_agent(OdaSlot::DeepResearcher, "dr1");
353        agent.last_heartbeat = Timestamp::new(1, 0);
354        assert!(agent.validate().is_err());
355    }
356
357    #[test]
358    fn roster_validate_detects_deserialized_slot_key_mismatch() {
359        let mut roster = AgentRoster::new();
360        let agent = make_agent(OdaSlot::DeepResearcher, "dr1");
361        roster.agents.insert(OdaSlot::HrPeopleOps1, agent);
362
363        let err = roster.validate().unwrap_err().to_string();
364        assert!(err.contains("hrpeopleops1"));
365        assert!(err.contains("deepresearcher"));
366        assert!(!err.contains("HrPeopleOps1"));
367        assert!(!err.contains("DeepResearcher"));
368    }
369
370    #[test]
371    fn active_count_ignores_non_active_agents() {
372        let mut roster = AgentRoster::new();
373        roster
374            .fill_slot(make_agent(OdaSlot::DeepResearcher, "dr1"))
375            .unwrap();
376        let mut suspended = make_agent(OdaSlot::HrPeopleOps1, "hr1");
377        suspended.status = AgentStatus::Suspended;
378        roster.fill_slot(suspended).unwrap();
379
380        assert_eq!(roster.active_count(), 1);
381        assert_eq!(roster.iter().count(), 2);
382    }
383
384    #[test]
385    fn fill_and_get() {
386        let mut roster = AgentRoster::new();
387        let agent = make_agent(OdaSlot::HrPeopleOps1, "hr1");
388        roster.fill_slot(agent).unwrap();
389        assert_eq!(roster.filled_count(), 1);
390        assert_eq!(roster.vacancy_count(), 11);
391        assert!(roster.get(&OdaSlot::HrPeopleOps1).is_some());
392    }
393
394    #[test]
395    fn duplicate_slot_rejected() {
396        let mut roster = AgentRoster::new();
397        roster
398            .fill_slot(make_agent(OdaSlot::DeepResearcher, "dr1"))
399            .unwrap();
400        let result = roster.fill_slot(make_agent(OdaSlot::DeepResearcher, "dr2"));
401        assert!(result.is_err());
402    }
403
404    #[test]
405    fn release_slot() {
406        let mut roster = AgentRoster::new();
407        roster
408            .fill_slot(make_agent(OdaSlot::VentureCommander, "vc"))
409            .unwrap();
410        let released = roster.release_slot(&OdaSlot::VentureCommander).unwrap();
411        assert_eq!(released.display_name, "vc");
412        assert_eq!(roster.filled_count(), 0);
413    }
414
415    #[test]
416    fn release_empty_slot() {
417        let mut roster = AgentRoster::new();
418        assert!(roster.release_slot(&OdaSlot::VentureCommander).is_err());
419    }
420
421    #[test]
422    fn founding_agents() {
423        let mut roster = AgentRoster::new();
424        roster
425            .fill_slot(make_agent(OdaSlot::HrPeopleOps1, "hr"))
426            .unwrap();
427        roster
428            .fill_slot(make_agent(OdaSlot::DeepResearcher, "dr"))
429            .unwrap();
430        assert_eq!(roster.founding_agents().len(), 2);
431    }
432
433    #[test]
434    fn complete_roster() {
435        let mut roster = AgentRoster::new();
436        for (i, slot) in OdaSlot::ALL.iter().enumerate() {
437            roster
438                .fill_slot(make_agent(*slot, &format!("agent-{i}")))
439                .unwrap();
440        }
441        assert!(roster.is_complete());
442        assert_eq!(roster.filled_count(), 12);
443        assert_eq!(roster.vacancy_count(), 0);
444        assert_eq!(roster.active_count(), 12);
445    }
446
447    #[test]
448    fn has_slots() {
449        let mut roster = AgentRoster::new();
450        roster
451            .fill_slot(make_agent(OdaSlot::HrPeopleOps1, "hr"))
452            .unwrap();
453        roster
454            .fill_slot(make_agent(OdaSlot::DeepResearcher, "dr"))
455            .unwrap();
456        assert!(roster.has_slots(&OdaSlot::FOUNDERS));
457        assert!(!roster.has_slots(&[OdaSlot::VentureCommander]));
458    }
459
460    #[test]
461    fn get_by_did() {
462        let mut roster = AgentRoster::new();
463        let agent = make_agent(OdaSlot::VentureCommander, "vc");
464        let did = agent.did.clone();
465        roster.fill_slot(agent).unwrap();
466        assert!(roster.get_by_did(&did).is_some());
467        assert!(roster.get_by_did(&test_did("nonexistent")).is_none());
468    }
469
470    #[test]
471    fn generate_did_format() {
472        let id = Uuid::nil();
473        let did = AgentRoster::generate_did(&id, &OdaSlot::VentureCommander).unwrap();
474        assert_eq!(
475            did.as_str(),
476            "did:exo:catapult:00000000-0000-0000-0000-000000000000:venturecommander"
477        );
478    }
479
480    #[test]
481    fn agent_slot_boundary_labels_do_not_depend_on_debug_formatting() {
482        let source = include_str!("agent.rs");
483        let production = source
484            .split("#[cfg(test)]")
485            .next()
486            .expect("production section");
487        assert!(
488            !production.contains("format!(\"{slot:?}\")"),
489            "agent DID generation must use explicit slot labels"
490        );
491        assert!(
492            !production.contains("{slot:?}"),
493            "agent validation errors must use explicit slot labels"
494        );
495    }
496
497    #[test]
498    fn agent_status_serde() {
499        let statuses = [
500            AgentStatus::Recruiting,
501            AgentStatus::Onboarding,
502            AgentStatus::Active,
503            AgentStatus::Suspended,
504            AgentStatus::Released,
505        ];
506        for s in &statuses {
507            let j = serde_json::to_string(s).unwrap();
508            let rt: AgentStatus = serde_json::from_str(&j).unwrap();
509            assert_eq!(&rt, s);
510        }
511    }
512}