feat(memory): injecter le rappel mémoire dans le convention file à l'activation (§14.5.4)

À l'activation d'un agent, LaunchAgent compose désormais une section
« # Mémoire projet » dans le convention file généré (CLAUDE.md/AGENTS.md…),
au même titre que les skills assignés (§14.2). Les agents lisent ainsi la
mémoire projet sans aucun flag ni mécanisme propre à une CLI.

- LaunchAgent reçoit le port MemoryRecall (Arc<dyn MemoryRecall>), résout le
  rappel (budget AGENT_MEMORY_RECALL_BUDGET=2048, requête = persona de l'agent)
  en best-effort : mémoire vide/absente ou erreur ⇒ section omise, le launch
  n'est jamais bloqué (comme un SkillRef dangling).
- compose_convention_file gagne un argument `memory: &[MemoryIndexEntry]`
  (reste pure) ; section injectée seulement pour la stratégie conventionFile.
- state.rs : une seule instance NaiveMemoryRecall partagée (recall + LaunchAgent).

Tests: 57 binaires verts. compose_convention_file (vide ⇒ inchangé, ordre,
cohabitation skills) + intégration LaunchAgent (section présente / absente /
best-effort sur erreur / pas d'injection en stratégie env).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 09:01:51 +02:00
parent 98a8b7292a
commit 2435857cbf
6 changed files with 484 additions and 26 deletions

View File

@ -15,13 +15,13 @@ use std::sync::Arc;
use domain::ports::{
AgentContextStore, AgentRuntime, ContextInjectionPlan, EventBus, FileSystem, IdGenerator,
PreparedContext, ProfileStore, PtyPort, RemotePath, SessionPlan, SkillStore, SpawnSpec,
StoreError,
MemoryQuery, MemoryRecall, PreparedContext, ProfileStore, PtyPort, RemotePath, SessionPlan,
SkillStore, SpawnSpec, StoreError,
};
use domain::{
Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile, ContextInjection, DomainEvent,
ManifestEntry, MarkdownDoc, NodeId, Project, ProfileId, ProjectPath, PtySize, SessionKind,
SessionStatus, Skill, TerminalSession,
ManifestEntry, MarkdownDoc, MemoryIndexEntry, MemoryType, NodeId, Project, ProfileId,
ProjectPath, PtySize, SessionKind, SessionStatus, Skill, TerminalSession,
};
use crate::error::AppError;
@ -30,6 +30,13 @@ 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.
const AGENT_MEMORY_RECALL_BUDGET: usize = 2_048;
// ---------------------------------------------------------------------------
// CreateAgentFromScratch
// ---------------------------------------------------------------------------
@ -369,6 +376,10 @@ pub struct LaunchAgent {
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>,
}
impl LaunchAgent {
@ -385,6 +396,7 @@ impl LaunchAgent {
sessions: Arc<TerminalSessions>,
events: Arc<dyn EventBus>,
ids: Arc<dyn IdGenerator>,
recall: Arc<dyn MemoryRecall>,
) -> Self {
Self {
contexts,
@ -396,6 +408,7 @@ impl LaunchAgent {
sessions,
events,
ids,
recall,
}
}
@ -422,6 +435,24 @@ impl LaunchAgent {
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()
}
/// Executes the launch.
///
/// Step order is contractually significant (and unit-tested): resolve the
@ -507,8 +538,18 @@ 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?;
self.apply_injection(&input.project, &agent.context_path, &content, &skills, &mut spec)
.await?;
let memory = self
.resolve_memory(&input.project.root, content.as_str())
.await;
self.apply_injection(
&input.project,
&agent.context_path,
&content,
&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?;
@ -645,6 +686,7 @@ impl LaunchAgent {
context_rel_path: &str,
content: &MarkdownDoc,
skills: &[Skill],
memory: &[MemoryIndexEntry],
spec: &mut SpawnSpec,
) -> Result<(), AppError> {
match spec.context_plan.clone() {
@ -656,8 +698,12 @@ impl LaunchAgent {
// 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(), content.as_str(), skills);
let document = compose_convention_file(
project.root.as_str(),
content.as_str(),
skills,
memory,
);
let path = RemotePath::new(join(&spec.cwd, &target));
self.fs.write(&path, document.as_bytes()).await?;
}
@ -770,9 +816,19 @@ fn json_escape(s: &str) -> String {
/// 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, agent_md: &str, skills: &[Skill]) -> String {
pub(crate) fn compose_convention_file(
project_root: &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);
@ -794,9 +850,36 @@ pub(crate) fn compose_convention_file(project_root: &str, agent_md: &str, skills
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).
@ -850,7 +933,8 @@ mod tests {
#[test]
fn compose_convention_file_carries_root_then_persona() {
let doc = compose_convention_file("/abs/project/root", "# Persona\n\nDo things.", &[]);
let doc =
compose_convention_file("/abs/project/root", "# Persona\n\nDo things.", &[], &[]);
// Absolute project root present.
assert!(doc.contains("/abs/project/root"));
@ -880,6 +964,7 @@ mod tests {
"/root",
"# Persona",
&[s(1, "refactor", "REFAC_BODY"), s(2, "review", "REVIEW_BODY")],
&[],
);
// Both skill bodies present, after the persona.
@ -896,6 +981,89 @@ mod tests {
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");

View File

@ -25,10 +25,11 @@ use domain::ids::{AgentId, ProfileId, ProjectId};
use domain::markdown::MarkdownDoc;
use domain::ports::{
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
ExitStatus, FileSystem, FsError, IdGenerator, OutputStream, PreparedContext, ProfileStore,
PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec,
StoreError,
ExitStatus, FileSystem, FsError, IdGenerator, MemoryError, MemoryQuery, MemoryRecall,
OutputStream, PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, RemotePath,
RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError,
};
use domain::{MemoryIndexEntry, MemorySlug, MemoryType};
use domain::profile::{AgentProfile, ContextInjection, SessionStrategy};
use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef;
@ -222,6 +223,58 @@ impl SkillStore for FakeSkills {
}
}
// ---------------------------------------------------------------------------
// FakeRecall (MemoryRecall) — returns a canned index, or fails (best-effort test)
// ---------------------------------------------------------------------------
/// In-memory [`MemoryRecall`] fake for the launch tests: empty by default
/// (⇒ no memory section, behaviour unchanged), configurable with canned entries,
/// or set to fail so the best-effort degradation at activation can be asserted.
#[derive(Clone, Default)]
struct FakeRecall {
entries: Arc<Vec<MemoryIndexEntry>>,
fail: bool,
}
impl FakeRecall {
fn returning(entries: Vec<MemoryIndexEntry>) -> Self {
Self {
entries: Arc::new(entries),
fail: false,
}
}
fn failing() -> Self {
Self {
entries: Arc::default(),
fail: true,
}
}
}
#[async_trait]
impl MemoryRecall for FakeRecall {
async fn recall(
&self,
_root: &ProjectPath,
_query: &MemoryQuery,
) -> Result<Vec<MemoryIndexEntry>, MemoryError> {
if self.fail {
return Err(MemoryError::Io("recall boom".to_owned()));
}
Ok((*self.entries).clone())
}
}
/// Builds a memory index entry for the launch tests.
fn mem_entry(slug: &str, title: &str, hook: &str, kind: MemoryType) -> MemoryIndexEntry {
MemoryIndexEntry {
slug: MemorySlug::new(slug).unwrap(),
title: title.to_owned(),
hook: hook.to_owned(),
r#type: kind,
}
}
// ---------------------------------------------------------------------------
// FakeRuntime (AgentRuntime) — records prepare + returns a configured plan
// ---------------------------------------------------------------------------
@ -652,6 +705,17 @@ fn launch_fixture(injection: ContextInjection, plan: Option<ContextInjectionPlan
fn launch_fixture_with_profile(
profile: AgentProfile,
plan: Option<ContextInjectionPlan>,
) -> LaunchFixture {
launch_fixture_with_profile_and_recall(profile, plan, FakeRecall::default())
}
/// Like [`launch_fixture_with_profile`] but also takes the [`MemoryRecall`] fake,
/// so the memory-injection feature can be exercised. The default callers pass an
/// empty [`FakeRecall`] ⇒ no memory section ⇒ behaviour unchanged.
fn launch_fixture_with_profile_and_recall(
profile: AgentProfile,
plan: Option<ContextInjectionPlan>,
recall: FakeRecall,
) -> LaunchFixture {
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id);
let contexts = FakeContexts::with_agent(&agent, "# ctx body");
@ -673,6 +737,7 @@ fn launch_fixture_with_profile(
Arc::clone(&sessions),
Arc::new(bus.clone()),
Arc::new(SeqIds::new()),
Arc::new(recall),
);
(launch, agent, fs, pty, bus, sessions, tr, session_probe)
}
@ -812,6 +877,7 @@ async fn two_agents_same_root_get_distinct_run_dirs_no_collision() {
Arc::clone(&sessions),
Arc::new(SpyBus::default()),
Arc::new(SeqIds::new()),
Arc::new(FakeRecall::default()),
);
launch.execute(launch_input(agent_a.id)).await.unwrap();
@ -885,6 +951,7 @@ async fn launch_conventionfile_injects_assigned_skills_in_order() {
Arc::new(TerminalSessions::new()),
Arc::new(SpyBus::default()),
Arc::new(SeqIds::new()),
Arc::new(FakeRecall::default()),
);
launch.execute(launch_input(agent.id)).await.unwrap();
@ -902,6 +969,115 @@ async fn launch_conventionfile_injects_assigned_skills_in_order() {
assert!(persona_at < refac_at && refac_at < review_at, "ordering: {doc}");
}
#[tokio::test]
async fn launch_conventionfile_injects_project_memory_in_order() {
// A non-empty recall ⇒ the generated convention file must carry a
// `# Mémoire projet` section with one line per entry, in the recalled order
// and the exact `- [Title](slug.md) — hook (type)` format (§14.5.4).
let recall = FakeRecall::returning(vec![
mem_entry("git-optional", "Git optionnel", "git reste un simple tool", MemoryType::Project),
mem_entry("perm-archi", "Permissions", "sandbox OS + résumé injecté", MemoryType::Reference),
]);
let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) =
launch_fixture_with_profile_and_recall(
profile(pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap()),
Some(ContextInjectionPlan::File {
target: "CLAUDE.md".to_owned(),
}),
recall,
);
launch.execute(launch_input(agent.id)).await.unwrap();
let writes = fs.context_writes();
assert_eq!(writes.len(), 1);
let doc = String::from_utf8(writes[0].1.clone()).unwrap();
assert!(doc.contains("# Mémoire projet"), "memory section present: {doc}");
assert!(
doc.contains("- [Git optionnel](git-optional.md) — git reste un simple tool (project)"),
"first memory line exact format: {doc}"
);
assert!(
doc.contains("- [Permissions](perm-archi.md) — sandbox OS + résumé injecté (reference)"),
"second memory line exact format: {doc}"
);
// Recalled order preserved; section after the persona.
let persona_at = doc.find("# ctx body").unwrap();
let section_at = doc.find("# Mémoire projet").unwrap();
let first_at = doc.find("[Git optionnel]").unwrap();
let second_at = doc.find("[Permissions]").unwrap();
assert!(persona_at < section_at, "memory after persona: {doc}");
assert!(first_at < second_at, "entries kept in recalled order: {doc}");
}
#[tokio::test]
async fn launch_conventionfile_without_memory_omits_section() {
// The default empty recall ⇒ no `# Mémoire projet` section (behaviour unchanged).
let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture(
ContextInjection::convention_file("CLAUDE.md").unwrap(),
Some(ContextInjectionPlan::File {
target: "CLAUDE.md".to_owned(),
}),
);
launch.execute(launch_input(agent.id)).await.unwrap();
let doc = String::from_utf8(fs.context_writes()[0].1.clone()).unwrap();
assert!(!doc.contains("# Mémoire projet"), "no memory ⇒ no section: {doc}");
}
#[tokio::test]
async fn launch_recall_error_degrades_to_no_section_without_failing() {
// A failing recall must NOT fail the launch (best-effort, §14.5.4): the launch
// succeeds and the convention file simply carries no memory section.
let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) =
launch_fixture_with_profile_and_recall(
profile(pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap()),
Some(ContextInjectionPlan::File {
target: "CLAUDE.md".to_owned(),
}),
FakeRecall::failing(),
);
// Launch still succeeds despite the recall error.
launch.execute(launch_input(agent.id)).await.expect("launch must succeed");
let doc = String::from_utf8(fs.context_writes()[0].1.clone()).unwrap();
assert!(
!doc.contains("# Mémoire projet"),
"recall error ⇒ no section, launch unaffected: {doc}"
);
}
#[tokio::test]
async fn launch_env_strategy_injects_no_memory_section() {
// For the `env` strategy IdeA writes no convention file, so no memory section is
// injected — aligned with how skills are only injected for `conventionFile`.
let recall = FakeRecall::returning(vec![mem_entry(
"note",
"Note",
"a hook",
MemoryType::User,
)]);
let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) =
launch_fixture_with_profile_and_recall(
profile(pid(9), ContextInjection::env("IDEA_CONTEXT").unwrap()),
Some(ContextInjectionPlan::Env {
var: "IDEA_CONTEXT".to_owned(),
}),
recall,
);
launch.execute(launch_input(agent.id)).await.unwrap();
// The env strategy writes no convention file at all (only the run-dir seed).
assert!(
fs.context_writes().is_empty(),
"env strategy must not write a convention file"
);
}
#[tokio::test]
async fn launch_skips_dangling_skill_ref_without_failing() {
// The agent references a skill that no longer exists in the store: launch must
@ -932,6 +1108,7 @@ async fn launch_skips_dangling_skill_ref_without_failing() {
Arc::new(TerminalSessions::new()),
Arc::new(SpyBus::default()),
Arc::new(SeqIds::new()),
Arc::new(FakeRecall::default()),
);
launch.execute(launch_input(agent.id)).await.expect("launch must succeed");
@ -1009,6 +1186,7 @@ async fn launch_unknown_profile_is_not_found() {
Arc::new(TerminalSessions::new()),
Arc::new(SpyBus::default()),
Arc::new(SeqIds::new()),
Arc::new(FakeRecall::default()),
);
let err = launch.execute(launch_input(agent.id)).await.unwrap_err();

View File

@ -184,6 +184,21 @@ impl SkillStore for FakeSkills {
}
}
/// An empty [`MemoryRecall`]: no project memory ⇒ no memory section injected,
/// leaving the launch behaviour unchanged for the service-level tests.
#[derive(Default)]
struct FakeRecall;
#[async_trait]
impl domain::ports::MemoryRecall for FakeRecall {
async fn recall(
&self,
_root: &ProjectPath,
_query: &domain::ports::MemoryQuery,
) -> Result<Vec<domain::MemoryIndexEntry>, domain::ports::MemoryError> {
Ok(Vec::new())
}
}
/// A [`SkillStore`] that records every `save` so a `create_skill` dispatch can be
/// asserted against the persisted skill (name + scope).
#[derive(Clone, Default)]
@ -414,6 +429,7 @@ fn fixture(contexts: FakeContexts) -> Fixture {
Arc::clone(&sessions),
Arc::new(bus.clone()),
Arc::new(SeqIds::new()),
Arc::new(FakeRecall),
));
let list = Arc::new(ListAgents::new(Arc::new(contexts.clone())));
let close = Arc::new(CloseTerminal::new(