feat(memory): config embedders (LOT C2) + suggestion contextuelle (LOT C3) + contexte projet partagé
- LOT C2 (§14.5.3) : use cases de configuration des embedders déclaratifs (List/Save/Delete + DescribeEmbedderEngines : modèles ONNX recommandés, environnement local détecté, stratégies compilées). UI EmbedderSettings. - LOT C3 (§14.5.5) : suggestion contextuelle best-effort à l'activation quand la mémoire dépasse le budget de recall sans embedder configuré (event EmbedderSuggested, anti-spam 1×/session, « ne plus demander »). - Contexte projet partagé .ideai/CONTEXT.md (model-agnostic) injecté à tous les agents/profils au lancement, avant la persona. UI ProjectContextPanel. Tests : backend workspace vert (0 échec) ; frontend 306/306. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -14,17 +14,18 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{
|
||||
AgentContextStore, AgentRuntime, ContextInjectionPlan, EventBus, FileSystem, IdGenerator,
|
||||
MemoryQuery, MemoryRecall, PreparedContext, ProfileStore, PtyPort, RemotePath, SessionPlan,
|
||||
SkillStore, SpawnSpec, StoreError,
|
||||
AgentContextStore, AgentRuntime, ContextInjectionPlan, EventBus, FileSystem, FsError,
|
||||
IdGenerator, MemoryQuery, MemoryRecall, PreparedContext, ProfileStore, PtyPort, RemotePath,
|
||||
SessionPlan, SkillStore, SpawnSpec, StoreError,
|
||||
};
|
||||
use domain::{
|
||||
Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile, ContextInjection, DomainEvent,
|
||||
ManifestEntry, MarkdownDoc, MemoryIndexEntry, MemoryType, NodeId, Project, ProfileId,
|
||||
ManifestEntry, MarkdownDoc, MemoryIndexEntry, MemoryType, NodeId, ProfileId, Project,
|
||||
ProjectPath, PtySize, SessionKind, SessionStatus, Skill, TerminalSession,
|
||||
};
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::project::project_context_path;
|
||||
use crate::terminal::TerminalSessions;
|
||||
|
||||
/// Directory (relative to `.ideai/`) under which agent contexts are written.
|
||||
@ -35,7 +36,7 @@ const AGENTS_SUBDIR: &str = "agents";
|
||||
/// (é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.
|
||||
const AGENT_MEMORY_RECALL_BUDGET: usize = 2_048;
|
||||
pub const AGENT_MEMORY_RECALL_BUDGET: usize = 2_048;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CreateAgentFromScratch
|
||||
@ -115,7 +116,9 @@ impl CreateAgentFromScratch {
|
||||
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?;
|
||||
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());
|
||||
@ -171,7 +174,10 @@ impl ListAgents {
|
||||
let agents = manifest
|
||||
.entries
|
||||
.iter()
|
||||
.map(|e| e.to_agent().map_err(|err| AppError::Invalid(err.to_string())))
|
||||
.map(|e| {
|
||||
e.to_agent()
|
||||
.map_err(|err| AppError::Invalid(err.to_string()))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(ListAgentsOutput { agents })
|
||||
}
|
||||
@ -311,7 +317,9 @@ impl DeleteAgent {
|
||||
}
|
||||
let manifest = AgentManifest::new(manifest.version, entries)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
self.contexts.save_manifest(&input.project, &manifest).await?;
|
||||
self.contexts
|
||||
.save_manifest(&input.project, &manifest)
|
||||
.await?;
|
||||
self.events.publish(DomainEvent::LayoutChanged {
|
||||
project_id: input.project.id,
|
||||
});
|
||||
@ -380,6 +388,11 @@ pub struct LaunchAgent {
|
||||
/// 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 {
|
||||
@ -397,6 +410,7 @@ impl LaunchAgent {
|
||||
events: Arc<dyn EventBus>,
|
||||
ids: Arc<dyn IdGenerator>,
|
||||
recall: Arc<dyn MemoryRecall>,
|
||||
embedder_suggestion: Option<Arc<crate::embedder::CheckEmbedderSuggestion>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
contexts,
|
||||
@ -409,6 +423,7 @@ impl LaunchAgent {
|
||||
events,
|
||||
ids,
|
||||
recall,
|
||||
embedder_suggestion,
|
||||
}
|
||||
}
|
||||
|
||||
@ -426,7 +441,11 @@ impl LaunchAgent {
|
||||
) -> 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 {
|
||||
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()),
|
||||
@ -453,6 +472,19 @@ impl LaunchAgent {
|
||||
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
|
||||
@ -486,26 +518,22 @@ impl LaunchAgent {
|
||||
// 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:
|
||||
// - on a *different* (or unspecified) node → refuse the launch with
|
||||
// `AgentAlreadyRunning`, pointing at the existing host cell;
|
||||
// - on the *same* node → idempotent (a concurrent double-launch of the
|
||||
// very same cell): return the existing session without respawning,
|
||||
// assigning no new conversation id.
|
||||
// - 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) {
|
||||
let host_node = self
|
||||
.sessions
|
||||
.node_for_agent(&input.agent_id)
|
||||
.expect("a live session always has a host node");
|
||||
let same_node = input.node_id.as_ref() == Some(&host_node);
|
||||
if !same_node {
|
||||
return Err(AppError::AgentAlreadyRunning {
|
||||
agent_id: input.agent_id,
|
||||
node_id: host_node,
|
||||
});
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Same node: idempotent — hand back the already-registered session,
|
||||
// no respawn, nothing new to persist.
|
||||
// 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,
|
||||
@ -525,9 +553,7 @@ impl LaunchAgent {
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|p| p.id == agent.profile_id)
|
||||
.ok_or_else(|| {
|
||||
AppError::NotFound(format!("profile {} for agent", 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
|
||||
@ -571,13 +597,27 @@ impl LaunchAgent {
|
||||
// 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,
|
||||
@ -598,9 +638,7 @@ impl LaunchAgent {
|
||||
session_id,
|
||||
node_id,
|
||||
spec.cwd.clone(),
|
||||
SessionKind::Agent {
|
||||
agent_id: agent.id,
|
||||
},
|
||||
SessionKind::Agent { agent_id: agent.id },
|
||||
size,
|
||||
);
|
||||
session.status = SessionStatus::Running;
|
||||
@ -718,6 +756,7 @@ impl LaunchAgent {
|
||||
project: &Project,
|
||||
context_rel_path: &str,
|
||||
content: &MarkdownDoc,
|
||||
project_context: &str,
|
||||
skills: &[Skill],
|
||||
memory: &[MemoryIndexEntry],
|
||||
spec: &mut SpawnSpec,
|
||||
@ -733,6 +772,7 @@ impl LaunchAgent {
|
||||
// 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,
|
||||
@ -769,7 +809,10 @@ fn join(base: &ProjectPath, rel: &str) -> String {
|
||||
/// # 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> {
|
||||
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}")))
|
||||
}
|
||||
|
||||
@ -841,8 +884,9 @@ fn json_escape(s: &str) -> String {
|
||||
|
||||
/// 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 agent's persona `.md`, then the bodies
|
||||
/// of its assigned `skills` under a `# Skills` section (ARCHITECTURE §14.2).
|
||||
/// 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
|
||||
@ -858,6 +902,7 @@ fn json_escape(s: &str) -> String {
|
||||
#[must_use]
|
||||
pub(crate) fn compose_convention_file(
|
||||
project_root: &str,
|
||||
project_context: &str,
|
||||
agent_md: &str,
|
||||
skills: &[Skill],
|
||||
memory: &[MemoryIndexEntry],
|
||||
@ -871,6 +916,21 @@ pub(crate) fn compose_convention_file(
|
||||
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() {
|
||||
@ -918,7 +978,11 @@ fn memory_type_label(kind: MemoryType) -> &'static str {
|
||||
/// 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 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) {
|
||||
@ -967,7 +1031,7 @@ mod tests {
|
||||
#[test]
|
||||
fn compose_convention_file_carries_root_then_persona() {
|
||||
let doc =
|
||||
compose_convention_file("/abs/project/root", "# Persona\n\nDo things.", &[], &[]);
|
||||
compose_convention_file("/abs/project/root", "", "# Persona\n\nDo things.", &[], &[]);
|
||||
|
||||
// Absolute project root present.
|
||||
assert!(doc.contains("/abs/project/root"));
|
||||
@ -982,6 +1046,26 @@ mod tests {
|
||||
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| {
|
||||
@ -995,8 +1079,12 @@ mod tests {
|
||||
};
|
||||
let doc = compose_convention_file(
|
||||
"/root",
|
||||
"",
|
||||
"# Persona",
|
||||
&[s(1, "refactor", "REFAC_BODY"), s(2, "review", "REVIEW_BODY")],
|
||||
&[
|
||||
s(1, "refactor", "REFAC_BODY"),
|
||||
s(2, "review", "REVIEW_BODY"),
|
||||
],
|
||||
&[],
|
||||
);
|
||||
|
||||
@ -1028,7 +1116,7 @@ mod tests {
|
||||
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.", &[], &[]);
|
||||
let with_empty = compose_convention_file("/root", "", "# Persona\n\nDo X.", &[], &[]);
|
||||
assert!(
|
||||
!with_empty.contains("# Mémoire projet"),
|
||||
"no memory ⇒ no memory section"
|
||||
@ -1037,7 +1125,7 @@ mod tests {
|
||||
// 4-arg call; this pins the omission contract explicitly).
|
||||
assert_eq!(
|
||||
with_empty,
|
||||
compose_convention_file("/root", "# Persona\n\nDo X.", &[], &[])
|
||||
compose_convention_file("/root", "", "# Persona\n\nDo X.", &[], &[])
|
||||
);
|
||||
}
|
||||
|
||||
@ -1045,11 +1133,17 @@ mod tests {
|
||||
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),
|
||||
mem(
|
||||
"beta-note",
|
||||
"Beta",
|
||||
"the second hook",
|
||||
MemoryType::Reference,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@ -1066,7 +1160,10 @@ mod tests {
|
||||
// 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");
|
||||
assert!(
|
||||
alpha_at < beta_at,
|
||||
"memory entries emitted in the given order"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -1080,6 +1177,7 @@ mod tests {
|
||||
.unwrap();
|
||||
let doc = compose_convention_file(
|
||||
"/root",
|
||||
"",
|
||||
"# Persona",
|
||||
std::slice::from_ref(&skill),
|
||||
&[mem("note", "Note", "a hook", MemoryType::Project)],
|
||||
|
||||
Reference in New Issue
Block a user