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:
@ -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();
|
||||
|
||||
@ -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(
|
||||
|
||||
Reference in New Issue
Block a user