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:
@ -69,7 +69,10 @@ struct FakeContexts(Arc<Mutex<(AgentManifest, HashMap<String, String>)>>);
|
||||
impl FakeContexts {
|
||||
fn new(entries: Vec<ManifestEntry>) -> Self {
|
||||
Self(Arc::new(Mutex::new((
|
||||
AgentManifest { version: 1, entries },
|
||||
AgentManifest {
|
||||
version: 1,
|
||||
entries,
|
||||
},
|
||||
HashMap::new(),
|
||||
))))
|
||||
}
|
||||
@ -92,13 +95,11 @@ impl FakeContexts {
|
||||
}
|
||||
#[async_trait]
|
||||
impl AgentContextStore for FakeContexts {
|
||||
async fn read_context(
|
||||
&self,
|
||||
_p: &Project,
|
||||
agent: &AgentId,
|
||||
) -> Result<MarkdownDoc, StoreError> {
|
||||
async fn read_context(&self, _p: &Project, agent: &AgentId) -> Result<MarkdownDoc, StoreError> {
|
||||
let md = self.md_path_of(agent).ok_or(StoreError::NotFound)?;
|
||||
self.content(&md).map(MarkdownDoc::new).ok_or(StoreError::NotFound)
|
||||
self.content(&md)
|
||||
.map(MarkdownDoc::new)
|
||||
.ok_or(StoreError::NotFound)
|
||||
}
|
||||
async fn write_context(
|
||||
&self,
|
||||
@ -107,7 +108,11 @@ impl AgentContextStore for FakeContexts {
|
||||
md: &MarkdownDoc,
|
||||
) -> Result<(), StoreError> {
|
||||
let path = self.md_path_of(agent).ok_or(StoreError::NotFound)?;
|
||||
self.0.lock().unwrap().1.insert(path, md.as_str().to_owned());
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.1
|
||||
.insert(path, md.as_str().to_owned());
|
||||
Ok(())
|
||||
}
|
||||
async fn load_manifest(&self, _p: &Project) -> Result<AgentManifest, StoreError> {
|
||||
@ -186,7 +191,16 @@ fn template(id: TemplateId, name: &str, content: &str, version: u64) -> AgentTem
|
||||
}
|
||||
/// A synchronized, template-backed manifest entry synced at `synced`.
|
||||
fn synced_entry(agent: AgentId, md: &str, template: TemplateId, synced: u64) -> ManifestEntry {
|
||||
ManifestEntry::new(agent, "A", md, pid(1), Some(template), true, Some(v(synced))).unwrap()
|
||||
ManifestEntry::new(
|
||||
agent,
|
||||
"A",
|
||||
md,
|
||||
pid(1),
|
||||
Some(template),
|
||||
true,
|
||||
Some(v(synced)),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -234,13 +248,16 @@ async fn update_template_bumps_version_and_publishes_event() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_unknown_template_is_not_found() {
|
||||
let err = UpdateTemplate::new(Arc::new(FakeTemplates::default()), Arc::new(SpyBus::default()))
|
||||
.execute(UpdateTemplateInput {
|
||||
template_id: tid(404),
|
||||
content: "x".to_owned(),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
let err = UpdateTemplate::new(
|
||||
Arc::new(FakeTemplates::default()),
|
||||
Arc::new(SpyBus::default()),
|
||||
)
|
||||
.execute(UpdateTemplateInput {
|
||||
template_id: tid(404),
|
||||
content: "x".to_owned(),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
|
||||
}
|
||||
|
||||
@ -279,7 +296,10 @@ async fn create_agent_from_template_links_origin_and_seeds_context() {
|
||||
}
|
||||
);
|
||||
// Context seeded with the template content under the agent's md path.
|
||||
assert_eq!(contexts.content(&out.agent.context_path).as_deref(), Some("# body"));
|
||||
assert_eq!(
|
||||
contexts.content(&out.agent.context_path).as_deref(),
|
||||
Some("# body")
|
||||
);
|
||||
assert_eq!(contexts.manifest().entries.len(), 1);
|
||||
}
|
||||
|
||||
@ -295,8 +315,16 @@ async fn detect_drift_flags_only_synchronized_agents_behind() {
|
||||
// a2: synchronized, synced at v3 → up to date, no drift.
|
||||
// a3: from template but NOT synchronized → ignored.
|
||||
// a4: scratch (no template) → ignored.
|
||||
let a3 = ManifestEntry::new(aid(3), "A3", "agents/a3.md", pid(1), Some(tid(1)), false, Some(v(1)))
|
||||
.unwrap();
|
||||
let a3 = ManifestEntry::new(
|
||||
aid(3),
|
||||
"A3",
|
||||
"agents/a3.md",
|
||||
pid(1),
|
||||
Some(tid(1)),
|
||||
false,
|
||||
Some(v(1)),
|
||||
)
|
||||
.unwrap();
|
||||
let a4 = ManifestEntry::new(aid(4), "A4", "agents/a4.md", pid(1), None, false, None).unwrap();
|
||||
let contexts = FakeContexts::new(vec![
|
||||
synced_entry(aid(1), "agents/a1.md", tid(1), 1),
|
||||
@ -306,14 +334,10 @@ async fn detect_drift_flags_only_synchronized_agents_behind() {
|
||||
]);
|
||||
let bus = SpyBus::default();
|
||||
|
||||
let out = DetectAgentDrift::new(
|
||||
Arc::new(store),
|
||||
Arc::new(contexts),
|
||||
Arc::new(bus.clone()),
|
||||
)
|
||||
.execute(DetectAgentDriftInput { project: project() })
|
||||
.await
|
||||
.unwrap();
|
||||
let out = DetectAgentDrift::new(Arc::new(store), Arc::new(contexts), Arc::new(bus.clone()))
|
||||
.execute(DetectAgentDriftInput { project: project() })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.drifts.len(), 1, "only a1 drifts");
|
||||
assert_eq!(out.drifts[0].agent_id, aid(1));
|
||||
@ -375,7 +399,10 @@ async fn sync_applies_to_synchronized_and_updates_version_and_context() {
|
||||
assert!(out.synced);
|
||||
assert_eq!(out.version, Some(v(3)));
|
||||
// Context replaced by the template content.
|
||||
assert_eq!(contexts.content("agents/a1.md").as_deref(), Some("newest body"));
|
||||
assert_eq!(
|
||||
contexts.content("agents/a1.md").as_deref(),
|
||||
Some("newest body")
|
||||
);
|
||||
// Manifest synced version advanced to 3.
|
||||
let entry = &contexts.manifest().entries[0];
|
||||
assert_eq!(entry.synced_template_version, Some(v(3)));
|
||||
@ -392,9 +419,16 @@ async fn sync_applies_to_synchronized_and_updates_version_and_context() {
|
||||
async fn sync_ignores_non_synchronized_agent() {
|
||||
let store = FakeTemplates::with(vec![template(tid(1), "T", "body", 3)]);
|
||||
// Non-synchronized agent from a template.
|
||||
let entry =
|
||||
ManifestEntry::new(aid(1), "A", "agents/a1.md", pid(1), Some(tid(1)), false, Some(v(1)))
|
||||
.unwrap();
|
||||
let entry = ManifestEntry::new(
|
||||
aid(1),
|
||||
"A",
|
||||
"agents/a1.md",
|
||||
pid(1),
|
||||
Some(tid(1)),
|
||||
false,
|
||||
Some(v(1)),
|
||||
)
|
||||
.unwrap();
|
||||
let contexts = FakeContexts::new(vec![entry]);
|
||||
contexts
|
||||
.write_context(&project(), &aid(1), &MarkdownDoc::new("keep me"))
|
||||
@ -417,5 +451,8 @@ async fn sync_ignores_non_synchronized_agent() {
|
||||
assert!(!out.synced, "non-synchronized agent is left untouched");
|
||||
assert_eq!(out.version, None);
|
||||
assert_eq!(contexts.content("agents/a1.md").as_deref(), Some("keep me"));
|
||||
assert!(bus.events().is_empty(), "no sync event for an ignored agent");
|
||||
assert!(
|
||||
bus.events().is_empty(),
|
||||
"no sync event for an ignored agent"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user