Files
IdeA/crates/domain/src/agent.rs
Blomios 2433e173a1 feat(agent): backend hot-swap profil (A0+A1) + inventaire reprise session (B1) — L15
Cadrage Architecture §15 (figé) : « agent = entité à session persistante ».

- A0 (domaine) : Agent::with_profile, LayoutTree::leaf, event AgentProfileChanged
  (+ DTO miroir DomainEventDto camelCase).
- A1 (application) : use case ChangeAgentProfile — no-op si profil identique,
  mutation manifeste, nettoyage conversation_id/agent_was_running sur layouts
  persistés, swap à chaud (kill PTY + relance même cellule via composition de
  LaunchAgent), event AgentProfileChanged. Décision : repartir à neuf (on garde
  .md + mémoire, on jette l'historique de conversation).
- B1 (application) : use case ListResumableAgents (lecture seule) — inventaire des
  cellules was_running||conversation_id, resume_supported selon profil, best-effort.

Aucun nouveau port/adapter (composition de l'existant). Hexagonal strict.
Tests : domaine 11 + app-tauri dto + ChangeAgentProfile 9 + ListResumableAgents 8,
suite application complète verte (0 régression).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 09:55:44 +02:00

309 lines
11 KiB
Rust

//! 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<TemplateId> {
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<SkillRef>,
}
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<String>,
context_path: impl Into<String>,
profile_id: ProfileId,
origin: AgentOrigin,
synchronized: bool,
) -> Result<Self, DomainError> {
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<SkillRef>) -> 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<TemplateId>,
/// 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<TemplateVersion>,
/// 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<SkillRef>,
}
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<String>,
md_path: impl Into<String>,
profile_id: ProfileId,
template_id: Option<TemplateId>,
synchronized: bool,
synced_template_version: Option<TemplateVersion>,
) -> Result<Self, DomainError> {
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<Agent, DomainError> {
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<ManifestEntry>,
}
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<ManifestEntry>) -> Result<Self, DomainError> {
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 })
}
}