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>
1469 lines
56 KiB
Rust
1469 lines
56 KiB
Rust
//! Agent lifecycle use cases (ARCHITECTURE §6, L6).
|
|
//!
|
|
//! These own the *project-agent* side (distinct from the profile side in
|
|
//! [`super::usecases`]): creating agents and their `.md` contexts under
|
|
//! `.ideai/`, listing/reading/updating them, and — the centrepiece —
|
|
//! [`LaunchAgent`], which resolves the agent's profile + context, applies the
|
|
//! profile's context-injection strategy, opens a PTY cell at the right `cwd` and
|
|
//! spawns the CLI.
|
|
//!
|
|
//! Every use case talks **only to ports** ([`AgentContextStore`], [`ProfileStore`],
|
|
//! [`AgentRuntime`], [`PtyPort`], [`FileSystem`], [`EventBus`]); none knows about
|
|
//! a concrete adapter or Tauri.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use domain::ports::{
|
|
AgentContextStore, AgentRuntime, ContextInjectionPlan, EventBus, FileSystem, FsError,
|
|
IdGenerator, MemoryQuery, MemoryRecall, PreparedContext, ProfileStore, ProjectStore, PtyPort,
|
|
RemotePath, SessionPlan, SkillStore, SpawnSpec, StoreError,
|
|
};
|
|
use domain::{
|
|
Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile, ContextInjection, DomainEvent,
|
|
ManifestEntry, MarkdownDoc, MemoryIndexEntry, MemoryType, NodeId, ProfileId, Project,
|
|
ProjectPath, PtySize, SessionKind, SessionStatus, Skill, TerminalSession,
|
|
};
|
|
|
|
use crate::error::AppError;
|
|
use crate::layout::{persist_doc, resolve_doc};
|
|
use crate::project::project_context_path;
|
|
use crate::terminal::TerminalSessions;
|
|
|
|
/// Directory (relative to `.ideai/`) under which agent contexts are written.
|
|
const AGENTS_SUBDIR: &str = "agents";
|
|
|
|
/// Token budget of the project-memory recall injected into the convention file at
|
|
/// agent activation (ARCHITECTURE §14.5.4). Bounds the number of index entries
|
|
/// (étage 1) handed to the agent. Internal and intentionally **not yet exposed in
|
|
/// config**: it may later become a per-project setting without changing the
|
|
/// contract.
|
|
pub const AGENT_MEMORY_RECALL_BUDGET: usize = 2_048;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CreateAgentFromScratch
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Input for [`CreateAgentFromScratch::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct CreateAgentInput {
|
|
/// The project that owns the agent.
|
|
pub project: Project,
|
|
/// Display name of the agent.
|
|
pub name: String,
|
|
/// Runtime profile the agent launches with.
|
|
pub profile_id: ProfileId,
|
|
/// Initial `.md` content (empty when `None`).
|
|
pub initial_content: Option<String>,
|
|
}
|
|
|
|
/// Output of [`CreateAgentFromScratch::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct CreateAgentOutput {
|
|
/// The freshly-created agent.
|
|
pub agent: Agent,
|
|
}
|
|
|
|
/// Creates a project agent from scratch: mints an id, derives a unique `.md`
|
|
/// path, records the manifest entry, then writes the (possibly empty) context.
|
|
pub struct CreateAgentFromScratch {
|
|
contexts: Arc<dyn AgentContextStore>,
|
|
ids: Arc<dyn domain::ports::IdGenerator>,
|
|
events: Arc<dyn EventBus>,
|
|
}
|
|
|
|
impl CreateAgentFromScratch {
|
|
/// Builds the use case from its injected ports.
|
|
#[must_use]
|
|
pub fn new(
|
|
contexts: Arc<dyn AgentContextStore>,
|
|
ids: Arc<dyn domain::ports::IdGenerator>,
|
|
events: Arc<dyn EventBus>,
|
|
) -> Self {
|
|
Self {
|
|
contexts,
|
|
ids,
|
|
events,
|
|
}
|
|
}
|
|
|
|
/// Executes creation.
|
|
///
|
|
/// Ordering matters: the manifest entry is persisted **before** the context
|
|
/// is written, because [`AgentContextStore::write_context`] resolves the
|
|
/// on-disk path from the manifest.
|
|
///
|
|
/// # Errors
|
|
/// - [`AppError::Invalid`] if the name is empty or the manifest would become
|
|
/// inconsistent,
|
|
/// - [`AppError::Store`] on persistence failure.
|
|
pub async fn execute(&self, input: CreateAgentInput) -> Result<CreateAgentOutput, AppError> {
|
|
let manifest = self.contexts.load_manifest(&input.project).await?;
|
|
|
|
let id = AgentId::from_uuid(self.ids.new_uuid());
|
|
let md_path = unique_md_path(&input.name, &manifest);
|
|
|
|
let agent = Agent::new(
|
|
id,
|
|
input.name,
|
|
md_path,
|
|
input.profile_id,
|
|
AgentOrigin::Scratch,
|
|
false,
|
|
)
|
|
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
|
|
|
// Append the entry and re-validate the whole manifest (unique md_paths).
|
|
let mut entries = manifest.entries;
|
|
entries.push(ManifestEntry::from_agent(&agent));
|
|
let manifest = AgentManifest::new(manifest.version, entries)
|
|
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
|
self.contexts
|
|
.save_manifest(&input.project, &manifest)
|
|
.await?;
|
|
|
|
// Now the path resolves: write the initial context.
|
|
let md = MarkdownDoc::new(input.initial_content.unwrap_or_default());
|
|
self.contexts
|
|
.write_context(&input.project, &agent.id, &md)
|
|
.await?;
|
|
|
|
self.events.publish(DomainEvent::LayoutChanged {
|
|
project_id: input.project.id,
|
|
});
|
|
|
|
Ok(CreateAgentOutput { agent })
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ListAgents
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Input for [`ListAgents::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ListAgentsInput {
|
|
/// The project whose agents to list.
|
|
pub project: Project,
|
|
}
|
|
|
|
/// Output of [`ListAgents::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ListAgentsOutput {
|
|
/// The project's agents (reconstructed from the manifest).
|
|
pub agents: Vec<Agent>,
|
|
}
|
|
|
|
/// Lists a project's agents by reconstructing them from the manifest entries.
|
|
pub struct ListAgents {
|
|
contexts: Arc<dyn AgentContextStore>,
|
|
}
|
|
|
|
impl ListAgents {
|
|
/// Builds the use case from the [`AgentContextStore`] port.
|
|
#[must_use]
|
|
pub fn new(contexts: Arc<dyn AgentContextStore>) -> Self {
|
|
Self { contexts }
|
|
}
|
|
|
|
/// Loads the manifest and folds each entry back into an [`Agent`].
|
|
///
|
|
/// # Errors
|
|
/// - [`AppError::Store`] on persistence failure,
|
|
/// - [`AppError::Invalid`] if a persisted entry violates an agent invariant.
|
|
pub async fn execute(&self, input: ListAgentsInput) -> Result<ListAgentsOutput, AppError> {
|
|
let manifest = self.contexts.load_manifest(&input.project).await?;
|
|
let agents = manifest
|
|
.entries
|
|
.iter()
|
|
.map(|e| {
|
|
e.to_agent()
|
|
.map_err(|err| AppError::Invalid(err.to_string()))
|
|
})
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
Ok(ListAgentsOutput { agents })
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ReadAgentContext / UpdateAgentContext
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Input for [`ReadAgentContext::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ReadAgentContextInput {
|
|
/// The owning project.
|
|
pub project: Project,
|
|
/// The agent whose `.md` to read.
|
|
pub agent_id: AgentId,
|
|
}
|
|
|
|
/// Output of [`ReadAgentContext::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ReadAgentContextOutput {
|
|
/// The agent's Markdown context.
|
|
pub content: MarkdownDoc,
|
|
}
|
|
|
|
/// Reads an agent's `.md` context.
|
|
pub struct ReadAgentContext {
|
|
contexts: Arc<dyn AgentContextStore>,
|
|
}
|
|
|
|
impl ReadAgentContext {
|
|
/// Builds the use case.
|
|
#[must_use]
|
|
pub fn new(contexts: Arc<dyn AgentContextStore>) -> Self {
|
|
Self { contexts }
|
|
}
|
|
|
|
/// Reads the context.
|
|
///
|
|
/// # Errors
|
|
/// - [`AppError::NotFound`] if the agent (or its `.md`) is unknown,
|
|
/// - [`AppError::Store`] on persistence failure.
|
|
pub async fn execute(
|
|
&self,
|
|
input: ReadAgentContextInput,
|
|
) -> Result<ReadAgentContextOutput, AppError> {
|
|
let content = self
|
|
.contexts
|
|
.read_context(&input.project, &input.agent_id)
|
|
.await?;
|
|
Ok(ReadAgentContextOutput { content })
|
|
}
|
|
}
|
|
|
|
/// Input for [`UpdateAgentContext::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct UpdateAgentContextInput {
|
|
/// The owning project.
|
|
pub project: Project,
|
|
/// The agent whose `.md` to overwrite.
|
|
pub agent_id: AgentId,
|
|
/// New Markdown content.
|
|
pub content: String,
|
|
}
|
|
|
|
/// Overwrites an agent's `.md` context.
|
|
pub struct UpdateAgentContext {
|
|
contexts: Arc<dyn AgentContextStore>,
|
|
}
|
|
|
|
impl UpdateAgentContext {
|
|
/// Builds the use case.
|
|
#[must_use]
|
|
pub fn new(contexts: Arc<dyn AgentContextStore>) -> Self {
|
|
Self { contexts }
|
|
}
|
|
|
|
/// Writes the new context.
|
|
///
|
|
/// # Errors
|
|
/// - [`AppError::NotFound`] if the agent is unknown,
|
|
/// - [`AppError::Store`] on persistence failure.
|
|
pub async fn execute(&self, input: UpdateAgentContextInput) -> Result<(), AppError> {
|
|
let md = MarkdownDoc::new(input.content);
|
|
self.contexts
|
|
.write_context(&input.project, &input.agent_id, &md)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ChangeAgentProfile
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Input for [`ChangeAgentProfile::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ChangeAgentProfileInput {
|
|
/// The owning project.
|
|
pub project: Project,
|
|
/// The agent whose runtime profile to hot-swap.
|
|
pub agent_id: AgentId,
|
|
/// The new runtime profile.
|
|
pub profile_id: ProfileId,
|
|
/// Terminal height in rows for a possible hot relaunch.
|
|
pub rows: u16,
|
|
/// Terminal width in columns for a possible hot relaunch.
|
|
pub cols: u16,
|
|
}
|
|
|
|
/// Output of [`ChangeAgentProfile::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ChangeAgentProfileOutput {
|
|
/// The mutated agent (now carrying the new profile).
|
|
pub agent: Agent,
|
|
/// The freshly relaunched session, when a live session was hot-swapped.
|
|
pub relaunched: Option<TerminalSession>,
|
|
}
|
|
|
|
/// Hot-swaps an existing agent's runtime profile (ARCHITECTURE §15.1).
|
|
///
|
|
/// **Single Responsibility**: mutate the profile in the manifest, clear the now
|
|
/// foreign conversation id on every persisted layout cell hosting the agent, and
|
|
/// — if the agent is live — kill its PTY and re-sequence the session in the same
|
|
/// cell with the new engine. The relaunch is **composed**, not duplicated: this
|
|
/// use case *calls* [`LaunchAgent::execute`] rather than re-implementing the spawn.
|
|
///
|
|
/// Ports consumed (ISP — only what is needed): [`AgentContextStore`] (manifest),
|
|
/// [`ProfileStore`] (validate the target profile), [`ProjectStore`] +
|
|
/// [`FileSystem`] (clean the conversation id on persisted layouts, exactly like
|
|
/// [`crate::layout::SnapshotRunningAgents`]), [`TerminalSessions`] + [`PtyPort`]
|
|
/// (detect/kill a live session), an [`Arc<LaunchAgent>`] for the hot relaunch, and
|
|
/// [`EventBus`] to publish.
|
|
pub struct ChangeAgentProfile {
|
|
contexts: Arc<dyn AgentContextStore>,
|
|
profiles: Arc<dyn ProfileStore>,
|
|
projects: Arc<dyn ProjectStore>,
|
|
fs: Arc<dyn FileSystem>,
|
|
sessions: Arc<TerminalSessions>,
|
|
pty: Arc<dyn PtyPort>,
|
|
launch: Arc<LaunchAgent>,
|
|
events: Arc<dyn EventBus>,
|
|
}
|
|
|
|
impl ChangeAgentProfile {
|
|
/// Builds the use case from its injected ports (and the composed launcher).
|
|
#[must_use]
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn new(
|
|
contexts: Arc<dyn AgentContextStore>,
|
|
profiles: Arc<dyn ProfileStore>,
|
|
projects: Arc<dyn ProjectStore>,
|
|
fs: Arc<dyn FileSystem>,
|
|
sessions: Arc<TerminalSessions>,
|
|
pty: Arc<dyn PtyPort>,
|
|
launch: Arc<LaunchAgent>,
|
|
events: Arc<dyn EventBus>,
|
|
) -> Self {
|
|
Self {
|
|
contexts,
|
|
profiles,
|
|
projects,
|
|
fs,
|
|
sessions,
|
|
pty,
|
|
launch,
|
|
events,
|
|
}
|
|
}
|
|
|
|
/// Executes the hot-swap, following the 7-step algorithm of §15.1.
|
|
///
|
|
/// # Errors
|
|
/// - [`AppError::NotFound`] if the agent or the target profile is unknown,
|
|
/// - [`AppError::Invalid`] on a manifest/layout invariant violation,
|
|
/// - [`AppError::Store`] / [`AppError::FileSystem`] / [`AppError::Process`] on
|
|
/// the respective port failures (manifest, layouts, PTY kill, relaunch).
|
|
pub async fn execute(
|
|
&self,
|
|
input: ChangeAgentProfileInput,
|
|
) -> Result<ChangeAgentProfileOutput, AppError> {
|
|
// 1. Load the manifest and resolve the agent's entry (NotFound otherwise).
|
|
let manifest = self.contexts.load_manifest(&input.project).await?;
|
|
let entry = manifest
|
|
.entries
|
|
.iter()
|
|
.find(|e| e.agent_id == input.agent_id)
|
|
.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
|
|
|
|
// 2. Same profile ⇒ no-op: return the agent unchanged, no kill/relaunch,
|
|
// no event.
|
|
if entry.profile_id == input.profile_id {
|
|
let agent = entry
|
|
.to_agent()
|
|
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
|
return Ok(ChangeAgentProfileOutput {
|
|
agent,
|
|
relaunched: None,
|
|
});
|
|
}
|
|
|
|
// 3. Validate that the target profile is a known one (ProfileStore.list).
|
|
let known = self
|
|
.profiles
|
|
.list()
|
|
.await?
|
|
.into_iter()
|
|
.any(|p| p.id == input.profile_id);
|
|
if !known {
|
|
return Err(AppError::NotFound(format!("profile {}", input.profile_id)));
|
|
}
|
|
|
|
// 4. Mutate the entry (new profile), re-validate (to_agent + manifest)
|
|
// and persist.
|
|
let mut entries = manifest.entries;
|
|
let mut mutated_agent = None;
|
|
for e in &mut entries {
|
|
if e.agent_id == input.agent_id {
|
|
e.profile_id = input.profile_id;
|
|
let agent = e
|
|
.to_agent()
|
|
.map_err(|err| AppError::Invalid(err.to_string()))?;
|
|
mutated_agent = Some(agent);
|
|
}
|
|
}
|
|
let agent = mutated_agent
|
|
.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
|
|
let manifest = AgentManifest::new(manifest.version, entries)
|
|
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
|
self.contexts
|
|
.save_manifest(&input.project, &manifest)
|
|
.await?;
|
|
|
|
// 5. Clean the conversation on every persisted layout: for each leaf
|
|
// hosting this agent, drop the (now foreign) conversation id and reset
|
|
// the running flag. Mirrors `SnapshotRunningAgents`:
|
|
// resolve_doc → walk agent_leaves → mutate → persist_doc (if changed).
|
|
self.clean_conversation(&input.project, &input.agent_id)
|
|
.await?;
|
|
|
|
// 6. A live session? Kill its PTY then relaunch in the same cell with the
|
|
// new profile and a discarded conversation id.
|
|
let relaunched = self.relaunch_if_live(&input).await?;
|
|
|
|
// 7. Publish the profile change and return.
|
|
self.events.publish(DomainEvent::AgentProfileChanged {
|
|
agent_id: input.agent_id,
|
|
profile_id: input.profile_id,
|
|
});
|
|
|
|
Ok(ChangeAgentProfileOutput { agent, relaunched })
|
|
}
|
|
|
|
/// Clears the conversation id and resets the running flag on every persisted
|
|
/// layout leaf hosting `agent_id` (step 5). Persists only when something
|
|
/// actually changed (a project with no such leaf is a no-op write).
|
|
async fn clean_conversation(
|
|
&self,
|
|
project: &Project,
|
|
agent_id: &AgentId,
|
|
) -> Result<(), AppError> {
|
|
let project = self.projects.load_project(project.id).await?;
|
|
let mut doc = resolve_doc(self.fs.as_ref(), &project).await?;
|
|
let mut changed = false;
|
|
|
|
for named in &mut doc.layouts {
|
|
for (leaf_id, leaf_agent) in named.tree.agent_leaves() {
|
|
if &leaf_agent != agent_id {
|
|
continue;
|
|
}
|
|
// Pure ops — only NodeNotFound is possible, which cannot happen
|
|
// since `leaf_id` came from this very tree.
|
|
named.tree = named
|
|
.tree
|
|
.set_cell_conversation(leaf_id, None)
|
|
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
|
named.tree = named
|
|
.tree
|
|
.set_agent_running(leaf_id, false)
|
|
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
if changed {
|
|
persist_doc(self.fs.as_ref(), &project, &doc).await?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Kills the agent's live PTY (if any) and relaunches it in the same cell with
|
|
/// the new profile (step 6), composing [`LaunchAgent::execute`]. Returns the
|
|
/// relaunched session, or `None` when the agent had no live session.
|
|
async fn relaunch_if_live(
|
|
&self,
|
|
input: &ChangeAgentProfileInput,
|
|
) -> Result<Option<TerminalSession>, AppError> {
|
|
let Some(session_id) = self.sessions.session_for_agent(&input.agent_id) else {
|
|
return Ok(None);
|
|
};
|
|
// The hosting cell of the live session — the relaunch reopens here.
|
|
let node_id = self.sessions.node_for_agent(&input.agent_id);
|
|
|
|
// Kill the PTY: remove from the registry first (so the relaunch's
|
|
// one-live-session-per-agent guard sees no live session), then kill the
|
|
// process.
|
|
if let Some(handle) = self.sessions.remove(&session_id) {
|
|
self.pty.kill(&handle).await?;
|
|
}
|
|
|
|
let output = self
|
|
.launch
|
|
.execute(LaunchAgentInput {
|
|
project: input.project.clone(),
|
|
agent_id: input.agent_id,
|
|
rows: input.rows,
|
|
cols: input.cols,
|
|
node_id,
|
|
// Conversation id discarded: the previous one belonged to the old
|
|
// engine; the new profile starts (or assigns) a fresh one.
|
|
conversation_id: None,
|
|
})
|
|
.await?;
|
|
Ok(Some(output.session))
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// DeleteAgent
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Input for [`DeleteAgent::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct DeleteAgentInput {
|
|
/// The owning project.
|
|
pub project: Project,
|
|
/// The agent to remove.
|
|
pub agent_id: AgentId,
|
|
}
|
|
|
|
/// Removes an agent from the project manifest.
|
|
///
|
|
/// The orphaned `.md` file is left on disk: the [`FileSystem`] port exposes no
|
|
/// delete, and keeping the file is the safe default (the user may want to recover
|
|
/// the context). Re-creating an agent with the same name reuses a fresh path.
|
|
pub struct DeleteAgent {
|
|
contexts: Arc<dyn AgentContextStore>,
|
|
events: Arc<dyn EventBus>,
|
|
}
|
|
|
|
impl DeleteAgent {
|
|
/// Builds the use case.
|
|
#[must_use]
|
|
pub fn new(contexts: Arc<dyn AgentContextStore>, events: Arc<dyn EventBus>) -> Self {
|
|
Self { contexts, events }
|
|
}
|
|
|
|
/// Drops the manifest entry for the agent.
|
|
///
|
|
/// # Errors
|
|
/// - [`AppError::NotFound`] if the agent is not in the manifest,
|
|
/// - [`AppError::Store`] on persistence failure.
|
|
pub async fn execute(&self, input: DeleteAgentInput) -> Result<(), AppError> {
|
|
let manifest = self.contexts.load_manifest(&input.project).await?;
|
|
let before = manifest.entries.len();
|
|
let entries: Vec<ManifestEntry> = manifest
|
|
.entries
|
|
.into_iter()
|
|
.filter(|e| e.agent_id != input.agent_id)
|
|
.collect();
|
|
if entries.len() == before {
|
|
return Err(AppError::NotFound(format!("agent {}", input.agent_id)));
|
|
}
|
|
let manifest = AgentManifest::new(manifest.version, entries)
|
|
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
|
self.contexts
|
|
.save_manifest(&input.project, &manifest)
|
|
.await?;
|
|
self.events.publish(DomainEvent::LayoutChanged {
|
|
project_id: input.project.id,
|
|
});
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// LaunchAgent
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Input for [`LaunchAgent::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct LaunchAgentInput {
|
|
/// The owning project.
|
|
pub project: Project,
|
|
/// The agent to launch.
|
|
pub agent_id: AgentId,
|
|
/// Initial terminal height in rows.
|
|
pub rows: u16,
|
|
/// Initial terminal width in columns.
|
|
pub cols: u16,
|
|
/// The layout leaf hosting the session (a fresh node when `None`).
|
|
pub node_id: Option<NodeId>,
|
|
/// The persistent CLI conversation id currently recorded on the hosting cell,
|
|
/// if any. `Some` means a previous conversation exists and the launch should
|
|
/// **resume** it; `None` means a fresh cell (the launch may *assign* a new id
|
|
/// when the profile supports it). The caller (which owns the layout) reads this
|
|
/// from the leaf's [`domain::layout::LeafCell::conversation_id`].
|
|
pub conversation_id: Option<String>,
|
|
}
|
|
|
|
/// Output of [`LaunchAgent::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct LaunchAgentOutput {
|
|
/// The created agent terminal session.
|
|
pub session: TerminalSession,
|
|
/// The conversation id **assigned** by this launch, when the profile supports
|
|
/// session assignment and the cell had none yet. The caller persists it on the
|
|
/// hosting leaf (via the layout flow, e.g. `set_cell_conversation`) so the next
|
|
/// open resumes instead of re-assigning. `None` when nothing new was assigned
|
|
/// (resume of an existing id, degraded mode, or a profile without a session
|
|
/// block) — the caller has nothing to persist.
|
|
pub assigned_conversation_id: Option<String>,
|
|
}
|
|
|
|
/// Launches an agent: resolve profile + context, prepare the invocation, apply
|
|
/// the context-injection plan, open a PTY at the resolved `cwd`, spawn the CLI.
|
|
///
|
|
/// This is the orchestrating use case of L6 and therefore consumes several ports
|
|
/// — each only for the slice it needs (Interface Segregation): the context store
|
|
/// (agent `.md` + manifest), the profile store (resolve the runtime), the runtime
|
|
/// (build the [`SpawnSpec`]), the filesystem (materialise a `conventionFile`
|
|
/// context), and the PTY (spawn + optional stdin injection).
|
|
pub struct LaunchAgent {
|
|
contexts: Arc<dyn AgentContextStore>,
|
|
profiles: Arc<dyn ProfileStore>,
|
|
runtime: Arc<dyn AgentRuntime>,
|
|
fs: Arc<dyn FileSystem>,
|
|
pty: Arc<dyn PtyPort>,
|
|
skills: Arc<dyn SkillStore>,
|
|
sessions: Arc<TerminalSessions>,
|
|
events: Arc<dyn EventBus>,
|
|
ids: Arc<dyn IdGenerator>,
|
|
/// Bounded recall of the project's memory index, injected into the convention
|
|
/// file at activation (ARCHITECTURE §14.5.4). Best-effort by contract: an absent
|
|
/// or empty memory yields an empty list, never blocking a launch.
|
|
recall: Arc<dyn MemoryRecall>,
|
|
/// Optional contextual embedder-suggestion check (LOT C3, §14.5.5), run
|
|
/// best-effort right after the memory recall at activation — the moment an agent
|
|
/// reads the project memory. `None` keeps the launcher independent of it (legacy
|
|
/// wiring / tests). A failure here never affects the launch.
|
|
embedder_suggestion: Option<Arc<crate::embedder::CheckEmbedderSuggestion>>,
|
|
}
|
|
|
|
impl LaunchAgent {
|
|
/// Builds the use case from its injected ports.
|
|
#[must_use]
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn new(
|
|
contexts: Arc<dyn AgentContextStore>,
|
|
profiles: Arc<dyn ProfileStore>,
|
|
runtime: Arc<dyn AgentRuntime>,
|
|
fs: Arc<dyn FileSystem>,
|
|
pty: Arc<dyn PtyPort>,
|
|
skills: Arc<dyn SkillStore>,
|
|
sessions: Arc<TerminalSessions>,
|
|
events: Arc<dyn EventBus>,
|
|
ids: Arc<dyn IdGenerator>,
|
|
recall: Arc<dyn MemoryRecall>,
|
|
embedder_suggestion: Option<Arc<crate::embedder::CheckEmbedderSuggestion>>,
|
|
) -> Self {
|
|
Self {
|
|
contexts,
|
|
profiles,
|
|
runtime,
|
|
fs,
|
|
pty,
|
|
skills,
|
|
sessions,
|
|
events,
|
|
ids,
|
|
recall,
|
|
embedder_suggestion,
|
|
}
|
|
}
|
|
|
|
/// Resolves the Markdown bodies of an agent's assigned skills, in the
|
|
/// **manifest order** (deterministic). A skill that no longer exists in its
|
|
/// store (deleted out from under the assignment) is silently skipped — a
|
|
/// dangling [`domain::SkillRef`] must not block a launch.
|
|
///
|
|
/// # Errors
|
|
/// [`AppError::Store`] on any store failure other than a missing skill.
|
|
async fn resolve_skills(
|
|
&self,
|
|
agent: &Agent,
|
|
root: &ProjectPath,
|
|
) -> Result<Vec<Skill>, AppError> {
|
|
let mut out = Vec::with_capacity(agent.skills.len());
|
|
for skill_ref in &agent.skills {
|
|
match self
|
|
.skills
|
|
.get(skill_ref.scope, root, skill_ref.skill_id)
|
|
.await
|
|
{
|
|
Ok(skill) => out.push(skill),
|
|
Err(StoreError::NotFound) => {}
|
|
Err(e) => return Err(e.into()),
|
|
}
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// Resolves the project's memory recall (index/hooks) to inject into the
|
|
/// convention file at activation (ARCHITECTURE §14.5.4), mirroring
|
|
/// [`Self::resolve_skills`]. The query text is the agent's persona `.md`
|
|
/// (irrelevant to the naïve adapter, but already the right query for the future
|
|
/// semantic recall — zero refactor at étage 2), bounded by
|
|
/// [`AGENT_MEMORY_RECALL_BUDGET`].
|
|
///
|
|
/// **Best-effort, never blocking**: an absent or empty memory yields an empty
|
|
/// list by the [`MemoryRecall`] contract, and any unexpected error degrades to
|
|
/// an empty list rather than failing the launch (exactly like a dangling skill).
|
|
async fn resolve_memory(&self, root: &ProjectPath, persona: &str) -> Vec<MemoryIndexEntry> {
|
|
let query = MemoryQuery {
|
|
text: persona.to_owned(),
|
|
token_budget: AGENT_MEMORY_RECALL_BUDGET,
|
|
};
|
|
self.recall.recall(root, &query).await.unwrap_or_default()
|
|
}
|
|
|
|
/// Reads the shared project context from `.ideai/CONTEXT.md`.
|
|
///
|
|
/// A missing file is normal for existing projects and simply omits the
|
|
/// project-context section from the generated model context.
|
|
async fn resolve_project_context(&self, project: &Project) -> Result<String, AppError> {
|
|
match self.fs.read(&project_context_path(project)).await {
|
|
Ok(bytes) => String::from_utf8(bytes)
|
|
.map_err(|e| AppError::Store(format!("project context is not UTF-8: {e}"))),
|
|
Err(FsError::NotFound(_)) => Ok(String::new()),
|
|
Err(e) => Err(AppError::FileSystem(e.to_string())),
|
|
}
|
|
}
|
|
|
|
/// Executes the launch.
|
|
///
|
|
/// Step order is contractually significant (and unit-tested): resolve the
|
|
/// agent + context, **`prepare_invocation`**, **apply the injection plan**
|
|
/// (write a `conventionFile` / set an env var), then **`pty.spawn`** at the
|
|
/// resolved `cwd`, and finally pipe the context on stdin for the `Stdin`
|
|
/// strategy.
|
|
///
|
|
/// # Errors
|
|
/// - [`AppError::NotFound`] if the agent or its profile is unknown,
|
|
/// - [`AppError::Invalid`] for a zero-sized terminal,
|
|
/// - [`AppError::Store`] / [`AppError::FileSystem`] / [`AppError::Process`] on
|
|
/// the respective port failures.
|
|
pub async fn execute(&self, input: LaunchAgentInput) -> Result<LaunchAgentOutput, AppError> {
|
|
let size =
|
|
PtySize::new(input.rows, input.cols).map_err(|e| AppError::Invalid(e.to_string()))?;
|
|
|
|
// 1. Resolve the agent from the manifest (name + profile + md_path).
|
|
let manifest = self.contexts.load_manifest(&input.project).await?;
|
|
let entry = manifest
|
|
.entries
|
|
.iter()
|
|
.find(|e| e.agent_id == input.agent_id)
|
|
.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
|
|
let agent = entry
|
|
.to_agent()
|
|
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
|
|
|
// 1b. Enforce the "one live session per agent" invariant (decision: an
|
|
// agent is a singleton that runs in a single cell at a time). This
|
|
// runs AFTER the NotFound resolution above (so an unknown agent still
|
|
// errors NotFound) but BEFORE any I/O (run dir, seed, spawn). If the
|
|
// agent already owns a live session:
|
|
// - with a requested node → rebind the live session to that cell and
|
|
// return it without respawning;
|
|
// - without a requested node → idempotent background/no-op launch:
|
|
// return the existing session without respawning.
|
|
// The resume path (agent dead ⇒ no live session) is unaffected.
|
|
if let Some(existing_id) = self.sessions.session_for_agent(&input.agent_id) {
|
|
if let Some(node_id) = input.node_id {
|
|
if let Some(session) = self.sessions.rebind_agent_node(&input.agent_id, node_id) {
|
|
return Ok(LaunchAgentOutput {
|
|
session,
|
|
assigned_conversation_id: None,
|
|
});
|
|
}
|
|
}
|
|
// Idempotent — hand back the already-registered session, no respawn,
|
|
// nothing new to persist.
|
|
if let Some(session) = self.sessions.session(&existing_id) {
|
|
return Ok(LaunchAgentOutput {
|
|
session,
|
|
assigned_conversation_id: None,
|
|
});
|
|
}
|
|
}
|
|
|
|
// 2. Read its context and resolve its profile.
|
|
let content = self
|
|
.contexts
|
|
.read_context(&input.project, &agent.id)
|
|
.await?;
|
|
let profile = self
|
|
.profiles
|
|
.list()
|
|
.await?
|
|
.into_iter()
|
|
.find(|p| p.id == agent.profile_id)
|
|
.ok_or_else(|| AppError::NotFound(format!("profile {} for agent", agent.profile_id)))?;
|
|
|
|
// 3. Compute and create the agent's isolated run directory
|
|
// `<root>/.ideai/run/<agent-id>/` (ARCHITECTURE §14.1). The PTY cwd is
|
|
// *never* the project root: each agent gets its own directory so that N
|
|
// instances of the same profile never collide on a single conventional
|
|
// file (CLAUDE.md, …). This is the only I/O in the cwd resolution; the
|
|
// runtime's `prepare_invocation` stays pure.
|
|
let run_dir = agent_run_dir(&input.project.root, &agent.id)
|
|
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
|
self.fs
|
|
.create_dir_all(&RemotePath::new(run_dir.as_str().to_owned()))
|
|
.await?;
|
|
|
|
// 3b. Seed the CLI's permission config in the run dir so the agent runs
|
|
// with the project's full autonomy and never blocks on per-command
|
|
// permission prompts. The agent's cwd is the run dir, so the CLI
|
|
// writes/reads its permission file there; without a seed, the CLI
|
|
// accumulates narrow per-command approvals and keeps prompting.
|
|
// Pragmatic per-CLI seed pending the universal `.ideai/permissions.json`
|
|
// + OS-sandbox model. Non-clobbering and best-effort.
|
|
self.seed_cli_permissions(&profile, &run_dir, &input.project.root)
|
|
.await?;
|
|
|
|
// 4. Prepare the invocation (pure): command + args + injection plan + cwd.
|
|
// The run dir is passed as the cwd base; the profile's `{agentRunDir}`
|
|
// placeholder resolves against it.
|
|
let prepared = PreparedContext {
|
|
content: content.clone(),
|
|
relative_path: agent.context_path.clone(),
|
|
};
|
|
// 4a. Resolve the session intention (T4). The conversation id is a property
|
|
// of the *cell*, not the PTY: the caller (which owns the layout) passes
|
|
// the cell's current `conversation_id`. Any id this launch *assigns* is
|
|
// returned in the output so the caller persists it on the leaf.
|
|
let (session_plan, assigned_conversation_id) =
|
|
self.resolve_session_plan(&profile, input.conversation_id.clone());
|
|
let mut spec =
|
|
self.runtime
|
|
.prepare_invocation(&profile, &prepared, &run_dir, &session_plan)?;
|
|
|
|
// 5. Resolve the agent's assigned skills (their `.md` bodies), then apply
|
|
// the injection plan side effects *before* spawning.
|
|
let skills = self.resolve_skills(&agent, &input.project.root).await?;
|
|
let project_context = self.resolve_project_context(&input.project).await?;
|
|
let memory = self
|
|
.resolve_memory(&input.project.root, content.as_str())
|
|
.await;
|
|
// Best-effort contextual embedder suggestion (LOT C3, §14.5.5): the agent
|
|
// has just read the project memory, so this is the moment to check whether a
|
|
// semantic embedder would now help. Fully isolated from the launch outcome —
|
|
// an error or absence of the check never affects activation.
|
|
if let Some(check) = &self.embedder_suggestion {
|
|
let _ = check
|
|
.execute(crate::embedder::CheckEmbedderSuggestionInput {
|
|
project_id: input.project.id,
|
|
project_root: input.project.root.clone(),
|
|
})
|
|
.await;
|
|
}
|
|
self.apply_injection(
|
|
&input.project,
|
|
&agent.context_path,
|
|
&content,
|
|
&project_context,
|
|
&skills,
|
|
&memory,
|
|
&mut spec,
|
|
)
|
|
.await?;
|
|
|
|
// 6. Spawn the PTY at the resolved cwd; adopt its session id everywhere.
|
|
let handle = self.pty.spawn(spec.clone(), size).await?;
|
|
let session_id = handle.session_id;
|
|
|
|
// 7. For the Stdin strategy, pipe the context once the PTY is live.
|
|
if matches!(spec.context_plan, Some(ContextInjectionPlan::Stdin)) {
|
|
self.pty.write(&handle, content.as_str().as_bytes())?;
|
|
}
|
|
|
|
let node_id = input.node_id.unwrap_or_else(NodeId::new_random);
|
|
let mut session = TerminalSession::starting(
|
|
session_id,
|
|
node_id,
|
|
spec.cwd.clone(),
|
|
SessionKind::Agent { agent_id: agent.id },
|
|
size,
|
|
);
|
|
session.status = SessionStatus::Running;
|
|
self.sessions.insert(handle, session.clone());
|
|
|
|
self.events.publish(DomainEvent::AgentLaunched {
|
|
agent_id: agent.id,
|
|
session_id,
|
|
});
|
|
|
|
Ok(LaunchAgentOutput {
|
|
session,
|
|
assigned_conversation_id,
|
|
})
|
|
}
|
|
|
|
/// Resolves the [`SessionPlan`] for a launch from the profile's session
|
|
/// strategy and the cell's current `conversation_id` (T4).
|
|
///
|
|
/// Returns the plan *and* — when this launch mints a fresh id — that id, so the
|
|
/// caller can persist it on the hosting leaf. The id is only generated for an
|
|
/// `Assign` (profile has a `session` block with an `assign_flag`, and the cell
|
|
/// had no id yet); every other branch returns `None` (nothing to persist).
|
|
///
|
|
/// Branches:
|
|
/// - cell already has an id ⇒ [`SessionPlan::Resume`] (reopen) — no new id;
|
|
/// - no id, profile has `session.assign_flag` ⇒ mint a UUID, [`SessionPlan::Assign`];
|
|
/// - no id, profile has `session` but no `assign_flag` (degraded) ⇒
|
|
/// [`SessionPlan::None`] (nothing to resume on a first launch; the adapter
|
|
/// uses the bare resume flag only on later reopens);
|
|
/// - profile without a `session` block ⇒ [`SessionPlan::None`] (legacy).
|
|
fn resolve_session_plan(
|
|
&self,
|
|
profile: &AgentProfile,
|
|
cell_conversation_id: Option<String>,
|
|
) -> (SessionPlan, Option<String>) {
|
|
// No session strategy at all: behave exactly as before.
|
|
let Some(session) = &profile.session else {
|
|
return (SessionPlan::None, None);
|
|
};
|
|
|
|
// The cell already carries a conversation: resume it (no new id minted).
|
|
if let Some(conversation_id) = cell_conversation_id {
|
|
return (SessionPlan::Resume { conversation_id }, None);
|
|
}
|
|
|
|
// Fresh cell. Only mint+assign an id when the profile can assign one;
|
|
// otherwise (degraded mode) the first launch has nothing to resume.
|
|
if session.assign_flag.is_some() {
|
|
let conversation_id = self.ids.new_uuid().to_string();
|
|
(
|
|
SessionPlan::Assign {
|
|
conversation_id: conversation_id.clone(),
|
|
},
|
|
Some(conversation_id),
|
|
)
|
|
} else {
|
|
(SessionPlan::None, None)
|
|
}
|
|
}
|
|
|
|
/// Seeds the agent's run dir with the CLI permission config matching its
|
|
/// context-injection convention, so the agent inherits the project's autonomy
|
|
/// instead of prompting per command.
|
|
///
|
|
/// Conditioned on the CLI convention (only Claude Code — convention file
|
|
/// `CLAUDE.md` — has a known seed today); a no-op for any other CLI.
|
|
/// Best-effort and **non-clobbering**: an existing file (possibly user-edited)
|
|
/// is left untouched.
|
|
///
|
|
/// # Errors
|
|
/// [`AppError::FileSystem`] if the directory/file cannot be written.
|
|
async fn seed_cli_permissions(
|
|
&self,
|
|
profile: &AgentProfile,
|
|
run_dir: &ProjectPath,
|
|
project_root: &ProjectPath,
|
|
) -> Result<(), AppError> {
|
|
let is_claude = matches!(
|
|
&profile.context_injection,
|
|
ContextInjection::ConventionFile { target }
|
|
if target
|
|
.rsplit(['/', '\\'])
|
|
.next()
|
|
.unwrap_or(target)
|
|
.eq_ignore_ascii_case("CLAUDE.md")
|
|
);
|
|
if !is_claude {
|
|
return Ok(());
|
|
}
|
|
|
|
let settings_path =
|
|
RemotePath::new(format!("{}/.claude/settings.local.json", run_dir.as_str()));
|
|
if self.fs.exists(&settings_path).await? {
|
|
return Ok(());
|
|
}
|
|
self.fs
|
|
.create_dir_all(&RemotePath::new(format!("{}/.claude", run_dir.as_str())))
|
|
.await?;
|
|
self.fs
|
|
.write(
|
|
&settings_path,
|
|
claude_settings_seed(project_root.as_str()).as_bytes(),
|
|
)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Applies the context-injection plan that must happen *before* spawn:
|
|
/// materialising a `conventionFile` context (write the `.md` to `<cwd>/target`)
|
|
/// or attaching the on-disk context path to an environment variable. `Args` is
|
|
/// already folded into the spec by the runtime; `Stdin` is handled post-spawn.
|
|
async fn apply_injection(
|
|
&self,
|
|
project: &Project,
|
|
context_rel_path: &str,
|
|
content: &MarkdownDoc,
|
|
project_context: &str,
|
|
skills: &[Skill],
|
|
memory: &[MemoryIndexEntry],
|
|
spec: &mut SpawnSpec,
|
|
) -> Result<(), AppError> {
|
|
match spec.context_plan.clone() {
|
|
Some(ContextInjectionPlan::File { target }) => {
|
|
// conventionFile (ARCHITECTURE §14.1): IdeA *generates* the
|
|
// conventional file (e.g. CLAUDE.md) inside the agent's isolated
|
|
// run directory — `spec.cwd` is that run dir, never the project
|
|
// root, so there is zero collision between agents. The document is
|
|
// composed: an absolute project-root header (so the agent knows
|
|
// where to operate, since its cwd is *not* the root), the agent's
|
|
// persona `.md`, then the bodies of its assigned skills (§14.2).
|
|
let document = compose_convention_file(
|
|
project.root.as_str(),
|
|
project_context,
|
|
content.as_str(),
|
|
skills,
|
|
memory,
|
|
);
|
|
let path = RemotePath::new(join(&spec.cwd, &target));
|
|
self.fs.write(&path, document.as_bytes()).await?;
|
|
}
|
|
Some(ContextInjectionPlan::Env { var }) => {
|
|
// Hand the CLI the absolute path of the agent's `.md` (which lives at
|
|
// `<root>/.ideai/<context_rel_path>`) via the environment variable.
|
|
let abspath = join(&project.root, &format!(".ideai/{context_rel_path}"));
|
|
spec.env.push((var, abspath));
|
|
}
|
|
// Args were folded into spec.args by prepare_invocation; Stdin is
|
|
// applied after the PTY is live.
|
|
Some(ContextInjectionPlan::Args { .. }) | Some(ContextInjectionPlan::Stdin) | None => {}
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Builds an absolute path string by joining a [`ProjectPath`] with a relative
|
|
/// segment using a POSIX separator.
|
|
fn join(base: &ProjectPath, rel: &str) -> String {
|
|
let b = base.as_str().trim_end_matches(['/', '\\']);
|
|
format!("{b}/{rel}")
|
|
}
|
|
|
|
/// Computes an agent's isolated run directory `<root>/.ideai/run/<agent-id>/`
|
|
/// (ARCHITECTURE §14.1). This is the PTY cwd for the agent — never the project
|
|
/// root — guaranteeing that two distinct agents on the same project root get two
|
|
/// distinct cwd (the anti-collision contract).
|
|
///
|
|
/// # Errors
|
|
/// Propagates [`DomainError`](domain::error::DomainError) if the joined path is
|
|
/// not a valid [`ProjectPath`] (should not happen for an absolute project root).
|
|
pub(crate) fn agent_run_dir(
|
|
root: &ProjectPath,
|
|
agent_id: &AgentId,
|
|
) -> Result<ProjectPath, domain::error::DomainError> {
|
|
ProjectPath::new(join(root, &format!(".ideai/run/{agent_id}")))
|
|
}
|
|
|
|
/// Builds the Claude Code permission seed (`.claude/settings.local.json`) written
|
|
/// into an agent's run dir: full project autonomy (`bypassPermissions` + broad
|
|
/// Read/Edit/Write/Bash) with the project root granted as an additional working
|
|
/// directory (the cwd is the run dir, the agent works on the root above it), while
|
|
/// keeping destructive/out-of-project commands denied. `project_root` is embedded
|
|
/// verbatim; it is JSON-escaped to stay valid for unusual paths.
|
|
///
|
|
/// Pure (no I/O), so it is unit-testable in isolation.
|
|
#[must_use]
|
|
fn claude_settings_seed(project_root: &str) -> String {
|
|
let root = json_escape(project_root);
|
|
format!(
|
|
r#"{{
|
|
"permissions": {{
|
|
"defaultMode": "bypassPermissions",
|
|
"additionalDirectories": [
|
|
"{root}"
|
|
],
|
|
"allow": [
|
|
"Read",
|
|
"Edit",
|
|
"Write",
|
|
"Bash"
|
|
],
|
|
"deny": [
|
|
"Bash(sudo *)",
|
|
"Bash(rm -rf /)",
|
|
"Bash(rm -rf /*)",
|
|
"Bash(rm -rf ~)",
|
|
"Bash(rm -rf ~/)",
|
|
"Bash(rm -rf ~/*)",
|
|
"Bash(rm -rf $HOME*)",
|
|
"Bash(mkfs*)",
|
|
"Bash(dd if=*)",
|
|
"Bash(shutdown*)",
|
|
"Bash(reboot*)"
|
|
]
|
|
}},
|
|
"skipDangerousModePermissionPrompt": true,
|
|
"sandbox": {{
|
|
"enabled": false
|
|
}}
|
|
}}
|
|
"#
|
|
)
|
|
}
|
|
|
|
/// Minimal JSON string escaper for embedding a filesystem path in the settings
|
|
/// seed (handles the characters that actually occur in paths: backslash, quote,
|
|
/// and control chars).
|
|
fn json_escape(s: &str) -> String {
|
|
let mut out = String::with_capacity(s.len());
|
|
for c in s.chars() {
|
|
match c {
|
|
'"' => out.push_str("\\\""),
|
|
'\\' => out.push_str("\\\\"),
|
|
'\n' => out.push_str("\\n"),
|
|
'\r' => out.push_str("\\r"),
|
|
'\t' => out.push_str("\\t"),
|
|
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
|
|
c => out.push(c),
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Composes the convention file IdeA writes into an agent's run directory: an
|
|
/// absolute project-root header (the agent's cwd is the run dir, *not* the root,
|
|
/// so it must be told where to work), the IdeA orchestration contract, the
|
|
/// agent's persona `.md`, then the bodies of its assigned `skills` under a
|
|
/// `# Skills` section (ARCHITECTURE §14.2).
|
|
///
|
|
/// Skills are emitted in the order given (the caller passes them in manifest
|
|
/// order, making the output deterministic); each is introduced by a `##` header
|
|
/// carrying its name. When `skills` is empty the section is omitted entirely, so
|
|
/// an agent with no skills gets exactly the previous document.
|
|
///
|
|
/// The project's `memory` recall (index/hooks, ARCHITECTURE §14.5.4) is appended as
|
|
/// a `# Mémoire projet` section — one `- [Title](slug.md) — hook (type)` line per
|
|
/// entry, in the order given. When `memory` is empty the section is omitted
|
|
/// entirely, so an agent with no memory gets exactly the previous document.
|
|
///
|
|
/// Kept as a **pure** function (no I/O) so it is unit-testable in isolation.
|
|
#[must_use]
|
|
pub(crate) fn compose_convention_file(
|
|
project_root: &str,
|
|
project_context: &str,
|
|
agent_md: &str,
|
|
skills: &[Skill],
|
|
memory: &[MemoryIndexEntry],
|
|
) -> String {
|
|
let mut out = String::new();
|
|
out.push_str("# Project root\n\n");
|
|
out.push_str(project_root);
|
|
out.push_str("\n\nTous tes travaux portent sur ce project root (chemin absolu ci-dessus). ");
|
|
out.push_str(
|
|
"Ton répertoire courant est un dossier d'exécution isolé (`.ideai/run/<agent>/`) ; \
|
|
opère sur le project root, pas sur ce dossier.\n\n",
|
|
);
|
|
out.push_str("---\n\n");
|
|
out.push_str("# Orchestration IdeA\n\n");
|
|
out.push_str(
|
|
"Pour déléguer une tâche à un autre agent, n'utilise jamais les subagents \
|
|
natifs du fournisseur IA. Écris une requête d'orchestration IdeA dans \
|
|
`.ideai/requests/<ton-agent>/` ; IdeA lancera ou réattachera l'agent cible \
|
|
avec son propre AI Profile, son contexte et sa mémoire.\n\n",
|
|
);
|
|
out.push_str("---\n\n");
|
|
|
|
if !project_context.trim().is_empty() {
|
|
out.push_str("# Contexte projet\n\n");
|
|
out.push_str(project_context.trim());
|
|
out.push_str("\n\n---\n\n");
|
|
}
|
|
|
|
out.push_str(agent_md);
|
|
|
|
if !skills.is_empty() {
|
|
out.push_str("\n\n---\n\n# Skills\n");
|
|
for skill in skills {
|
|
out.push_str("\n## ");
|
|
out.push_str(&skill.name);
|
|
out.push_str("\n\n");
|
|
out.push_str(skill.content_md.as_str());
|
|
out.push('\n');
|
|
}
|
|
}
|
|
|
|
if !memory.is_empty() {
|
|
out.push_str("\n\n---\n\n# Mémoire projet\n\n");
|
|
for entry in memory {
|
|
out.push_str("- [");
|
|
out.push_str(&entry.title);
|
|
out.push_str("](");
|
|
out.push_str(entry.slug.as_str());
|
|
out.push_str(".md) — ");
|
|
out.push_str(&entry.hook);
|
|
out.push_str(" (");
|
|
out.push_str(memory_type_label(entry.r#type));
|
|
out.push_str(")\n");
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Renders a [`MemoryType`] as its stable lowercase label for the convention-file
|
|
/// memory section (`user`/`feedback`/`project`/`reference`).
|
|
#[must_use]
|
|
fn memory_type_label(kind: MemoryType) -> &'static str {
|
|
match kind {
|
|
MemoryType::User => "user",
|
|
MemoryType::Feedback => "feedback",
|
|
MemoryType::Project => "project",
|
|
MemoryType::Reference => "reference",
|
|
}
|
|
}
|
|
|
|
/// Derives a unique, filesystem-safe `md_path` (`agents/<slug>.md`) for a new
|
|
/// agent, disambiguating against the manifest's existing paths with a numeric
|
|
/// suffix when needed. Shared with the template-driven agent creation (L7).
|
|
pub(crate) fn unique_md_path(name: &str, manifest: &AgentManifest) -> String {
|
|
let slug = slugify(name);
|
|
let base = if slug.is_empty() {
|
|
"agent".to_owned()
|
|
} else {
|
|
slug
|
|
};
|
|
let mut candidate = format!("{AGENTS_SUBDIR}/{base}.md");
|
|
let mut n = 2;
|
|
while manifest.entries.iter().any(|e| e.md_path == candidate) {
|
|
candidate = format!("{AGENTS_SUBDIR}/{base}-{n}.md");
|
|
n += 1;
|
|
}
|
|
candidate
|
|
}
|
|
|
|
/// Lowercases and slugifies a display name into a safe file stem
|
|
/// (`[a-z0-9-]`), collapsing runs of separators.
|
|
fn slugify(name: &str) -> String {
|
|
let mut out = String::with_capacity(name.len());
|
|
let mut prev_dash = false;
|
|
for ch in name.trim().chars() {
|
|
if ch.is_ascii_alphanumeric() {
|
|
out.push(ch.to_ascii_lowercase());
|
|
prev_dash = false;
|
|
} else if !prev_dash {
|
|
out.push('-');
|
|
prev_dash = true;
|
|
}
|
|
}
|
|
out.trim_matches('-').to_owned()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn agent_run_dir_is_under_ideai_run_and_unique_per_agent() {
|
|
let root = ProjectPath::new("/home/me/proj").unwrap();
|
|
let a = AgentId::from_uuid(uuid::Uuid::from_u128(1));
|
|
let b = AgentId::from_uuid(uuid::Uuid::from_u128(2));
|
|
|
|
let dir_a = agent_run_dir(&root, &a).unwrap();
|
|
let dir_b = agent_run_dir(&root, &b).unwrap();
|
|
|
|
assert_eq!(dir_a.as_str(), format!("/home/me/proj/.ideai/run/{a}"));
|
|
assert_ne!(dir_a, dir_b, "distinct agents → distinct run dirs");
|
|
// Never the project root.
|
|
assert_ne!(dir_a.as_str(), "/home/me/proj");
|
|
}
|
|
|
|
#[test]
|
|
fn compose_convention_file_carries_root_then_persona() {
|
|
let doc =
|
|
compose_convention_file("/abs/project/root", "", "# Persona\n\nDo things.", &[], &[]);
|
|
|
|
// Absolute project root present.
|
|
assert!(doc.contains("/abs/project/root"));
|
|
// Persona present.
|
|
assert!(doc.contains("# Persona"));
|
|
assert!(doc.contains("Do things."));
|
|
// Root header precedes the persona body (ordering of the composition).
|
|
let root_at = doc.find("/abs/project/root").unwrap();
|
|
let persona_at = doc.find("# Persona").unwrap();
|
|
assert!(root_at < persona_at, "root header must precede the persona");
|
|
// No skills ⇒ no Skills section.
|
|
assert!(!doc.contains("# Skills"));
|
|
}
|
|
|
|
#[test]
|
|
fn compose_convention_file_includes_project_context_before_persona() {
|
|
let doc = compose_convention_file(
|
|
"/root",
|
|
"# Shared project context\n\nUse pnpm.",
|
|
"# Persona\n\nDo X.",
|
|
&[],
|
|
&[],
|
|
);
|
|
|
|
assert!(doc.contains("# Contexte projet"));
|
|
assert!(doc.contains("Use pnpm."));
|
|
let project_context_at = doc.find("# Contexte projet").unwrap();
|
|
let persona_at = doc.find("# Persona").unwrap();
|
|
assert!(
|
|
project_context_at < persona_at,
|
|
"shared project context must precede agent persona"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn compose_convention_file_appends_assigned_skills_in_order() {
|
|
let s = |n: u128, name: &str, body: &str| {
|
|
Skill::new(
|
|
domain::SkillId::from_uuid(uuid::Uuid::from_u128(n)),
|
|
name,
|
|
MarkdownDoc::new(body),
|
|
domain::SkillScope::Global,
|
|
)
|
|
.unwrap()
|
|
};
|
|
let doc = compose_convention_file(
|
|
"/root",
|
|
"",
|
|
"# Persona",
|
|
&[
|
|
s(1, "refactor", "REFAC_BODY"),
|
|
s(2, "review", "REVIEW_BODY"),
|
|
],
|
|
&[],
|
|
);
|
|
|
|
// Both skill bodies present, after the persona.
|
|
assert!(doc.contains("REFAC_BODY"));
|
|
assert!(doc.contains("REVIEW_BODY"));
|
|
let persona_at = doc.find("# Persona").unwrap();
|
|
let refac_at = doc.find("REFAC_BODY").unwrap();
|
|
let review_at = doc.find("REVIEW_BODY").unwrap();
|
|
assert!(persona_at < refac_at, "skills come after the persona");
|
|
// Deterministic order: first assigned skill precedes the second.
|
|
assert!(refac_at < review_at, "skills emitted in the given order");
|
|
// Skill names surface as sub-headers.
|
|
assert!(doc.contains("## refactor"));
|
|
assert!(doc.contains("## review"));
|
|
}
|
|
|
|
/// Builds a memory index entry for the convention-file composition tests.
|
|
fn mem(slug_str: &str, title: &str, hook: &str, kind: MemoryType) -> MemoryIndexEntry {
|
|
MemoryIndexEntry {
|
|
slug: domain::MemorySlug::new(slug_str).unwrap(),
|
|
title: title.to_owned(),
|
|
hook: hook.to_owned(),
|
|
r#type: kind,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn compose_convention_file_empty_memory_is_identical_to_no_memory() {
|
|
// An empty `memory` must yield exactly the previous document: no section,
|
|
// byte-for-byte identical to the no-skills/no-memory composition.
|
|
let with_empty = compose_convention_file("/root", "", "# Persona\n\nDo X.", &[], &[]);
|
|
assert!(
|
|
!with_empty.contains("# Mémoire projet"),
|
|
"no memory ⇒ no memory section"
|
|
);
|
|
// Same document whether or not we thread an empty slice (it already is the
|
|
// 4-arg call; this pins the omission contract explicitly).
|
|
assert_eq!(
|
|
with_empty,
|
|
compose_convention_file("/root", "", "# Persona\n\nDo X.", &[], &[])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn compose_convention_file_appends_memory_entries_in_order() {
|
|
let doc = compose_convention_file(
|
|
"/root",
|
|
"",
|
|
"# Persona",
|
|
&[],
|
|
&[
|
|
mem("alpha-note", "Alpha", "the first hook", MemoryType::User),
|
|
mem(
|
|
"beta-note",
|
|
"Beta",
|
|
"the second hook",
|
|
MemoryType::Reference,
|
|
),
|
|
],
|
|
);
|
|
|
|
// Section present, after the persona.
|
|
assert!(doc.contains("# Mémoire projet"));
|
|
let persona_at = doc.find("# Persona").unwrap();
|
|
let section_at = doc.find("# Mémoire projet").unwrap();
|
|
assert!(persona_at < section_at, "memory comes after the persona");
|
|
|
|
// Exact line format: `- [Title](slug.md) — hook (type)`.
|
|
assert!(doc.contains("- [Alpha](alpha-note.md) — the first hook (user)"));
|
|
assert!(doc.contains("- [Beta](beta-note.md) — the second hook (reference)"));
|
|
|
|
// Deterministic order: first entry precedes the second.
|
|
let alpha_at = doc.find("[Alpha]").unwrap();
|
|
let beta_at = doc.find("[Beta]").unwrap();
|
|
assert!(
|
|
alpha_at < beta_at,
|
|
"memory entries emitted in the given order"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn compose_convention_file_memory_and_skills_coexist() {
|
|
let skill = Skill::new(
|
|
domain::SkillId::from_uuid(uuid::Uuid::from_u128(1)),
|
|
"refactor",
|
|
MarkdownDoc::new("REFAC_BODY"),
|
|
domain::SkillScope::Global,
|
|
)
|
|
.unwrap();
|
|
let doc = compose_convention_file(
|
|
"/root",
|
|
"",
|
|
"# Persona",
|
|
std::slice::from_ref(&skill),
|
|
&[mem("note", "Note", "a hook", MemoryType::Project)],
|
|
);
|
|
|
|
// Both sections present.
|
|
assert!(doc.contains("# Skills"));
|
|
assert!(doc.contains("REFAC_BODY"));
|
|
assert!(doc.contains("# Mémoire projet"));
|
|
assert!(doc.contains("- [Note](note.md) — a hook (project)"));
|
|
|
|
// Skills section precedes the memory section (persona → skills → memory).
|
|
let skills_at = doc.find("# Skills").unwrap();
|
|
let memory_at = doc.find("# Mémoire projet").unwrap();
|
|
assert!(skills_at < memory_at, "skills come before memory");
|
|
}
|
|
|
|
#[test]
|
|
fn claude_settings_seed_grants_autonomy_and_keeps_guardrails() {
|
|
let json = claude_settings_seed("/home/me/proj");
|
|
|
|
// Full autonomy.
|
|
assert!(json.contains("\"defaultMode\": \"bypassPermissions\""));
|
|
assert!(json.contains("\"Bash\""));
|
|
// Project root granted as an additional working directory.
|
|
assert!(json.contains("\"/home/me/proj\""));
|
|
// Destructive guardrails preserved.
|
|
assert!(json.contains("Bash(sudo *)"));
|
|
assert!(json.contains("Bash(rm -rf /)"));
|
|
assert!(json.contains("Bash(mkfs*)"));
|
|
// Valid JSON.
|
|
let parsed: serde_json::Value = serde_json::from_str(&json).expect("seed is valid JSON");
|
|
assert_eq!(parsed["permissions"]["defaultMode"], "bypassPermissions");
|
|
assert_eq!(
|
|
parsed["permissions"]["additionalDirectories"][0],
|
|
"/home/me/proj"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn claude_settings_seed_escapes_paths_for_valid_json() {
|
|
// A path with a backslash and a quote must not break the JSON.
|
|
let json = claude_settings_seed(r#"/weird\path"x"#);
|
|
let parsed: serde_json::Value =
|
|
serde_json::from_str(&json).expect("seed with odd path is valid JSON");
|
|
assert_eq!(
|
|
parsed["permissions"]["additionalDirectories"][0],
|
|
r#"/weird\path"x"#
|
|
);
|
|
}
|
|
}
|