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