//! 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`. /// /// Invariant enforced here: `md_path` values are unique across entries. #[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). #[serde(rename = "agents")] pub entries: Vec, } impl AgentManifest { /// Builds a validated manifest. /// /// # Errors /// Returns [`DomainError::InconsistentManifest`] if two entries share the /// same `md_path`. pub fn new(version: u32, entries: Vec) -> 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), }); } } Ok(Self { version, entries }) } }