//! Agent entity, its origin and the project agent manifest. use serde::{Deserialize, Serialize}; use crate::error::DomainError; use crate::ids::{AgentId, ProfileId, TemplateId}; use crate::skill::SkillRef; use crate::template::TemplateVersion; /// Origin of an agent: created from scratch, or derived from a template. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", tag = "type")] pub enum AgentOrigin { /// Created from scratch; no template link. Scratch, /// Derived from a template, tracking the last synced version. #[serde(rename_all = "camelCase")] FromTemplate { /// Source template. template_id: TemplateId, /// Template version recorded at the last successful sync. synced_template_version: TemplateVersion, }, } impl AgentOrigin { /// Returns the source template id, if any. #[must_use] pub fn template_id(&self) -> Option { match self { Self::Scratch => None, Self::FromTemplate { template_id, .. } => Some(*template_id), } } /// Returns `true` if this origin is a template. #[must_use] pub fn is_from_template(&self) -> bool { matches!(self, Self::FromTemplate { .. }) } } /// A project-scoped agent. /// /// Invariants enforced here: /// - `name` non-empty, /// - `context_path` is a relative, safe path (the `.md` lives under `.ideai/`), /// - `synchronized == true` ⇒ `origin == FromTemplate { .. }`. /// /// Note: "`context` must exist at activation" and "`profile_id` must reference a /// known profile" are *runtime/cross-aggregate* invariants checked by the /// application layer (they require I/O or the profile registry), not by this /// pure constructor. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Agent { /// Stable identifier. pub id: AgentId, /// Display name. pub name: String, /// Relative path of the agent's `.md` within `.ideai/` (e.g. `agents/foo.md`). pub context_path: String, /// Runtime profile reference. pub profile_id: ProfileId, /// Origin of the agent. pub origin: AgentOrigin, /// Whether the agent tracks its template (only valid for template origins). pub synchronized: bool, /// Skills assigned to this agent, injected into its convention file at /// activation (ARCHITECTURE §14.2). Empty by default. #[serde(default)] pub skills: Vec, } impl Agent { /// Builds a validated agent. /// /// # Errors /// - [`DomainError::EmptyField`] if `name` is empty, /// - [`DomainError::PathNotRelativeSafe`] if `context_path` is absolute or /// contains `..`, /// - [`DomainError::SyncRequiresTemplate`] if `synchronized` is `true` while /// `origin` is [`AgentOrigin::Scratch`]. pub fn new( id: AgentId, name: impl Into, context_path: impl Into, profile_id: ProfileId, origin: AgentOrigin, synchronized: bool, ) -> Result { let name = name.into(); let context_path = context_path.into(); crate::validation::non_empty(&name, "agent.name")?; crate::validation::relative_safe(&context_path)?; if synchronized && !origin.is_from_template() { return Err(DomainError::SyncRequiresTemplate); } Ok(Self { id, name, context_path, profile_id, origin, synchronized, skills: Vec::new(), }) } /// Change le profil runtime de l'agent. Le contexte `.md`, l'origine template /// et la synchronisation sont **inchangés** (décision verrouillée : on garde le /// `.md`/mémoire, on ne touche qu'au moteur). Pur, infaillible (un `ProfileId` /// est déjà un VO validé). #[must_use] pub fn with_profile(mut self, profile_id: ProfileId) -> Self { self.profile_id = profile_id; self } /// Returns a copy of this agent carrying the given assigned skills, /// deduplicated by `skill_id` (keeping first occurrence). #[must_use] pub fn with_skills(mut self, skills: Vec) -> Self { self.skills = Vec::new(); for skill in skills { self.assign_skill(skill); } self } /// Assigns a skill to this agent. Idempotent: re-assigning the same /// `skill_id` is a no-op (returns `false`); a new assignment returns `true`. pub fn assign_skill(&mut self, skill: SkillRef) -> bool { if self.skills.iter().any(|s| s.skill_id == skill.skill_id) { return false; } self.skills.push(skill); true } /// Removes a skill assignment by id. Returns `true` if a skill was removed. pub fn unassign_skill(&mut self, skill_id: crate::ids::SkillId) -> bool { let before = self.skills.len(); self.skills.retain(|s| s.skill_id != skill_id); self.skills.len() != before } } /// One entry in the project agent manifest (`.ideai/agents.json`). /// /// This is the **persisted form of an [`Agent`]** (ARCHITECTURE §9.1): the /// manifest is the source of truth for a project's agents, so each entry carries /// everything needed to reconstruct the agent — its `name` and `profile_id` /// included (without them the IDE could not list agents or resolve the profile to /// launch). The template link is kept flat (`template_id` + /// `synced_template_version`) for a compact on-disk shape; [`to_agent`] folds it /// back into an [`AgentOrigin`]. /// /// Invariants: /// - `name` non-empty, /// - `md_path` relative and safe, /// - `synchronized == true` ⇒ `template_id.is_some() && synced_template_version.is_some()`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ManifestEntry { /// The agent this entry describes. pub agent_id: AgentId, /// Display name of the agent. pub name: String, /// Relative path of the agent's `.md`. pub md_path: String, /// Runtime profile reference. pub profile_id: ProfileId, /// Source template, if any. #[serde(default, skip_serializing_if = "Option::is_none")] pub template_id: Option, /// Whether the agent tracks its template. pub synchronized: bool, /// Template version recorded at the last sync. #[serde(default, skip_serializing_if = "Option::is_none")] pub synced_template_version: Option, /// Skills assigned to this agent (ARCHITECTURE §14.2). Defaults to empty for /// backward-compatible deserialisation of pre-L12 manifests. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub skills: Vec, } impl ManifestEntry { /// Builds a validated manifest entry. /// /// # Errors /// - [`DomainError::EmptyField`] if `name` is empty, /// - [`DomainError::PathNotRelativeSafe`] if `md_path` is absolute or has `..`, /// - [`DomainError::InconsistentManifest`] if `synchronized` is `true` while /// `template_id` or `synced_template_version` is missing. pub fn new( agent_id: AgentId, name: impl Into, md_path: impl Into, profile_id: ProfileId, template_id: Option, synchronized: bool, synced_template_version: Option, ) -> Result { let name = name.into(); let md_path = md_path.into(); crate::validation::non_empty(&name, "manifestEntry.name")?; crate::validation::relative_safe(&md_path)?; if synchronized && (template_id.is_none() || synced_template_version.is_none()) { return Err(DomainError::InconsistentManifest { reason: "synchronized entry requires templateId and syncedTemplateVersion" .to_string(), }); } Ok(Self { agent_id, name, md_path, profile_id, template_id, synchronized, synced_template_version, skills: Vec::new(), }) } /// Projects an [`Agent`] into its manifest entry (flattening the origin). /// /// Infallible: an [`Agent`] is already validated, and every entry invariant /// is implied by the agent's invariants. #[must_use] pub fn from_agent(agent: &Agent) -> Self { let (template_id, synced_template_version) = match &agent.origin { AgentOrigin::Scratch => (None, None), AgentOrigin::FromTemplate { template_id, synced_template_version, } => (Some(*template_id), Some(*synced_template_version)), }; Self { agent_id: agent.id, name: agent.name.clone(), md_path: agent.context_path.clone(), profile_id: agent.profile_id, template_id, synchronized: agent.synchronized, synced_template_version, skills: agent.skills.clone(), } } /// Reconstructs the validated [`Agent`] this entry persists, folding the flat /// template link back into an [`AgentOrigin`]. /// /// # Errors /// Returns a [`DomainError`] if the persisted fields violate an [`Agent`] /// invariant (e.g. a synchronized entry whose origin is not a template). pub fn to_agent(&self) -> Result { let origin = match (self.template_id, self.synced_template_version) { (Some(template_id), Some(synced_template_version)) => AgentOrigin::FromTemplate { template_id, synced_template_version, }, _ => AgentOrigin::Scratch, }; Ok(Agent::new( self.agent_id, self.name.clone(), self.md_path.clone(), self.profile_id, origin, self.synchronized, )? .with_skills(self.skills.clone())) } } /// In-memory image of `.ideai/agents.json`. /// /// Invariants enforced here: /// - `md_path` values are unique across entries; /// - `orchestrator == Some(id)` ⇒ `id` is present in `entries` (referential). /// /// **Ordering invariant**: `entries` are kept in **creation order**, so /// `entries.first()` is the **oldest** agent. There is no per-entry timestamp; the /// vector position *is* the chronology, which is why the default orchestrator (when /// none is designated) is `entries.first()`. #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentManifest { /// Schema version of the manifest file. pub version: u32, /// Entries (one per project agent), in **creation order** (oldest first). #[serde(rename = "agents")] pub entries: Vec, /// Explicitly designated orchestrator agent, if any. /// /// We persist only the **deviation** from the default: `None` (the common case) /// means « the oldest agent orchestrates » (see [`effective_orchestrator`]); /// `Some(id)` is an explicit designation. Skipped on serialisation when `None`, /// so pre-feature manifests round-trip unchanged (free backward compatibility). /// /// [`effective_orchestrator`]: AgentManifest::effective_orchestrator #[serde(default, skip_serializing_if = "Option::is_none")] pub orchestrator: Option, } impl AgentManifest { /// Builds a validated manifest with **no** explicit orchestrator (the default: /// the oldest agent orchestrates — see [`effective_orchestrator`]). /// /// [`effective_orchestrator`]: AgentManifest::effective_orchestrator /// /// # Errors /// Returns [`DomainError::InconsistentManifest`] if two entries share the /// same `md_path`. pub fn new(version: u32, entries: Vec) -> Result { Self::with_orchestrator(version, entries, None) } /// Builds a validated manifest carrying an explicit `orchestrator` designation. /// /// # Errors /// - [`DomainError::InconsistentManifest`] if two entries share the same /// `md_path`; /// - [`DomainError::InconsistentManifest`] if `orchestrator == Some(id)` while /// `id` is **not** present in `entries` (referential integrity). pub fn with_orchestrator( version: u32, entries: Vec, orchestrator: Option, ) -> Result { let mut seen = std::collections::HashSet::new(); for entry in &entries { if !seen.insert(entry.md_path.as_str()) { return Err(DomainError::InconsistentManifest { reason: format!("duplicate md_path `{}`", entry.md_path), }); } } if let Some(id) = orchestrator { if !entries.iter().any(|e| e.agent_id == id) { return Err(DomainError::InconsistentManifest { reason: format!("designated orchestrator `{id}` is not a known agent"), }); } } Ok(Self { version, entries, orchestrator, }) } /// The **effective** orchestrator agent: the explicit designation if any, /// otherwise the **oldest** agent (`entries.first()`, by the creation-order /// invariant). `None` only when the manifest has no entries at all. #[must_use] pub fn effective_orchestrator(&self) -> Option { self.orchestrator .or_else(|| self.entries.first().map(|e| e.agent_id)) } /// Folds the [`effective_orchestrator`](Self::effective_orchestrator) into the /// [`OrchestratorDesignation`] value object consumed by the FileGuard policy. #[must_use] pub fn orchestrator_designation(&self) -> crate::fileguard::OrchestratorDesignation { match self.effective_orchestrator() { Some(id) => crate::fileguard::OrchestratorDesignation::of(id), None => crate::fileguard::OrchestratorDesignation::none(), } } /// Designates `id` as the project's orchestrator (radio semantics: overwrites any /// previous designation). /// /// # Errors /// [`DomainError::InconsistentManifest`] if `id` is not a known agent of this /// manifest. pub fn designate(&mut self, id: AgentId) -> Result<(), DomainError> { if !self.entries.iter().any(|e| e.agent_id == id) { return Err(DomainError::InconsistentManifest { reason: format!("cannot designate unknown agent `{id}` as orchestrator"), }); } self.orchestrator = Some(id); Ok(()) } /// Reacts to the removal of an agent: if it was the **explicitly designated** /// orchestrator, clears the designation so the manifest falls back to the default /// (lazy succession — the next-oldest agent becomes effective). A no-op when the /// removed agent was not the designated one. /// /// Pure bookkeeping: it does **not** remove the entry itself (the caller owns /// `entries`); it only keeps the `orchestrator` deviation consistent. pub fn on_agent_deleted(&mut self, removed: AgentId) { if self.orchestrator == Some(removed) { self.orchestrator = None; } } } #[cfg(test)] mod orchestrator_tests { use super::*; use crate::conversation::ConversationParty; use crate::fileguard::{may_write_directly, GuardedResource, OrchestratorDesignation}; fn agent_id(n: u128) -> AgentId { AgentId::from_uuid(uuid::Uuid::from_u128(n)) } /// Builds a manifest entry for `id`, with a `md_path` derived from `n` so entries /// stay unique. fn entry(n: u128) -> ManifestEntry { ManifestEntry::new( agent_id(n), format!("agent-{n}"), format!("agents/agent-{n}.md"), ProfileId::from_uuid(uuid::Uuid::from_u128(1000 + n)), None, false, None, ) .unwrap() } #[test] fn default_orchestrator_is_oldest_agent_when_none_designated() { // entries are in creation order: first is the oldest → the effective default. let m = AgentManifest::new(1, vec![entry(1), entry(2), entry(3)]).unwrap(); assert_eq!(m.orchestrator, None); assert_eq!(m.effective_orchestrator(), Some(agent_id(1))); assert_eq!( m.orchestrator_designation(), OrchestratorDesignation::of(agent_id(1)) ); } #[test] fn empty_manifest_has_no_effective_orchestrator() { let m = AgentManifest::new(1, vec![]).unwrap(); assert_eq!(m.effective_orchestrator(), None); assert_eq!( m.orchestrator_designation(), OrchestratorDesignation::none() ); } #[test] fn explicit_designation_overrides_oldest() { let mut m = AgentManifest::new(1, vec![entry(1), entry(2)]).unwrap(); m.designate(agent_id(2)).unwrap(); assert_eq!(m.orchestrator, Some(agent_id(2))); assert_eq!(m.effective_orchestrator(), Some(agent_id(2))); } #[test] fn designate_is_radio_and_rejects_unknown_agent() { let mut m = AgentManifest::new(1, vec![entry(1), entry(2)]).unwrap(); m.designate(agent_id(1)).unwrap(); // Radio: a second designation overwrites the first (never accumulates). m.designate(agent_id(2)).unwrap(); assert_eq!(m.orchestrator, Some(agent_id(2))); // Referential: designating an agent absent from `entries` is rejected. let err = m.designate(agent_id(99)).unwrap_err(); assert!(matches!(err, DomainError::InconsistentManifest { .. })); // …and the previous valid designation is untouched. assert_eq!(m.orchestrator, Some(agent_id(2))); } #[test] fn lazy_succession_falls_back_to_oldest_when_designated_is_deleted() { let mut m = AgentManifest::new(1, vec![entry(1), entry(2), entry(3)]).unwrap(); m.designate(agent_id(3)).unwrap(); // The designated agent is removed → designation cleared, default takes over. m.entries.retain(|e| e.agent_id != agent_id(3)); m.on_agent_deleted(agent_id(3)); assert_eq!(m.orchestrator, None); // Falls back to the (now) oldest remaining agent. assert_eq!(m.effective_orchestrator(), Some(agent_id(1))); } #[test] fn on_agent_deleted_is_noop_for_non_designated_agent() { let mut m = AgentManifest::new(1, vec![entry(1), entry(2)]).unwrap(); m.designate(agent_id(2)).unwrap(); m.on_agent_deleted(agent_id(1)); // Deleting a non-designated agent leaves the designation intact. assert_eq!(m.orchestrator, Some(agent_id(2))); } #[test] fn constructor_enforces_referential_integrity_of_orchestrator() { // Some(id) with id present in entries → ok. assert!( AgentManifest::with_orchestrator(1, vec![entry(1), entry(2)], Some(agent_id(2)),) .is_ok() ); // Some(id) with id absent → rejected. let err = AgentManifest::with_orchestrator(1, vec![entry(1)], Some(agent_id(42))).unwrap_err(); assert!(matches!(err, DomainError::InconsistentManifest { .. })); // None is always valid (even with an empty manifest). assert!(AgentManifest::with_orchestrator(1, vec![], None).is_ok()); } #[test] fn designated_agent_may_write_project_context_via_manifest_designation() { let mut m = AgentManifest::new(1, vec![entry(1), entry(2)]).unwrap(); m.designate(agent_id(1)).unwrap(); let d = m.orchestrator_designation(); // The designated agent writes the project context directly… assert!(may_write_directly( ConversationParty::agent(agent_id(1)), &GuardedResource::ProjectContext, &d, )); // …another agent is refused (single-writer preserved)… assert!(!may_write_directly( ConversationParty::agent(agent_id(2)), &GuardedResource::ProjectContext, &d, )); // …and the human is always allowed. assert!(may_write_directly( ConversationParty::User, &GuardedResource::ProjectContext, &d, )); } }