Skip to content

Commit 66abfb9

Browse files
committed
feat(memory): project LLM learning signals
1 parent e11fded commit 66abfb9

7 files changed

Lines changed: 421 additions & 7 deletions

File tree

core/src/agent/memory_extraction_runtime.rs

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ Each memory must be standalone, concise, scoped, and justified. Return an empty
2222
const MAX_MEMORY_CONTENT_CHARS: usize = 1_200;
2323
const MAX_MEMORY_REASON_CHARS: usize = 320;
2424
const MAX_MEMORY_TAGS: usize = 8;
25+
const MAX_EVOLUTION_PATTERN_CHARS: usize = 96;
26+
const MAX_EVOLUTION_TITLE_CHARS: usize = 96;
27+
const MAX_EVOLUTION_SUMMARY_CHARS: usize = 360;
28+
const MAX_EVOLUTION_INSTRUCTIONS: usize = 8;
29+
const MAX_EVOLUTION_INSTRUCTION_CHARS: usize = 320;
2530
const MAX_RELATED_MEMORY_ITEMS: usize = 5;
2631
const MAX_RELATED_MEMORY_CHARS: usize = 2_000;
2732
const MAX_RELATED_MEMORY_CONTENT_CHARS: usize = 320;
@@ -53,6 +58,31 @@ struct ExtractedMemory {
5358
supersedes: Vec<String>,
5459
#[serde(default, alias = "conflictsWith", alias = "conflicts")]
5560
conflicts_with: Vec<String>,
61+
#[serde(default)]
62+
evolution: Option<ExtractedEvolution>,
63+
}
64+
65+
#[derive(Debug, Deserialize)]
66+
struct ExtractedEvolution {
67+
#[serde(default)]
68+
kind: String,
69+
#[serde(default, alias = "patternKey")]
70+
pattern_key: String,
71+
#[serde(default)]
72+
title: String,
73+
#[serde(default)]
74+
summary: String,
75+
#[serde(default, alias = "guidance")]
76+
instructions: Vec<String>,
77+
}
78+
79+
#[derive(Debug)]
80+
struct EvolutionSignal {
81+
kind: &'static str,
82+
pattern_key: String,
83+
title: String,
84+
summary: String,
85+
instructions: Vec<String>,
5686
}
5787

5888
#[derive(Debug, Clone)]
@@ -296,6 +326,7 @@ impl ExtractedMemory {
296326
}
297327
let supersedes = normalize_supersedes(self.supersedes, allowed_supersedes);
298328
let conflicts_with = normalize_related_ids(self.conflicts_with, allowed_supersedes);
329+
let evolution = self.evolution.and_then(|signal| signal.normalize(&source));
299330
let mut item = MemoryItem::new(content)
300331
.with_type(memory_type)
301332
.with_importance(importance)
@@ -319,6 +350,21 @@ impl ExtractedMemory {
319350
.with_tag("conflict")
320351
.with_metadata("conflicts_with", conflicts_with.join(","));
321352
}
353+
if let Some(signal) = evolution {
354+
item = item
355+
.with_tag("evolution")
356+
.with_tag(format!("evolution-{}", signal.kind))
357+
.with_metadata("evolution_schema", "a3s.evolution.signal.v1")
358+
.with_metadata("evolution_kind", signal.kind)
359+
.with_metadata("evolution_pattern", signal.pattern_key)
360+
.with_metadata("evolution_title", signal.title)
361+
.with_metadata("evolution_summary", signal.summary)
362+
.with_metadata(
363+
"evolution_instructions",
364+
serde_json::to_string(&signal.instructions)
365+
.unwrap_or_else(|_| "[]".to_string()),
366+
);
367+
}
322368

323369
for tag in self
324370
.tags
@@ -335,6 +381,75 @@ impl ExtractedMemory {
335381
}
336382
}
337383

384+
impl ExtractedEvolution {
385+
fn normalize(self, source: &str) -> Option<EvolutionSignal> {
386+
let kind = match self.kind.trim().to_ascii_lowercase().as_str() {
387+
"preference" if matches!(source, "preference" | "decision") => "preference",
388+
"skill" if matches!(source, "workflow" | "failure" | "decision") => "skill",
389+
"okf" | "knowledge" if matches!(source, "project_fact" | "workflow" | "decision") => {
390+
"okf"
391+
}
392+
_ => return None,
393+
};
394+
let pattern_key = normalize_evolution_pattern(&self.pattern_key)?;
395+
let title = compact(self.title.trim(), MAX_EVOLUTION_TITLE_CHARS);
396+
let summary = compact(self.summary.trim(), MAX_EVOLUTION_SUMMARY_CHARS);
397+
if title.chars().count() < 4
398+
|| summary.chars().count() < 12
399+
|| contains_sensitive_memory_material(&title)
400+
|| contains_sensitive_memory_material(&summary)
401+
{
402+
return None;
403+
}
404+
405+
let mut seen = HashSet::new();
406+
let instructions = self
407+
.instructions
408+
.into_iter()
409+
.map(|value| compact(value.trim(), MAX_EVOLUTION_INSTRUCTION_CHARS))
410+
.filter(|value| value.chars().count() >= 8)
411+
.filter(|value| !contains_sensitive_memory_material(value))
412+
.filter(|value| seen.insert(value.to_ascii_lowercase()))
413+
.take(MAX_EVOLUTION_INSTRUCTIONS)
414+
.collect::<Vec<_>>();
415+
if instructions.is_empty() {
416+
return None;
417+
}
418+
419+
Some(EvolutionSignal {
420+
kind,
421+
pattern_key,
422+
title,
423+
summary,
424+
instructions,
425+
})
426+
}
427+
}
428+
429+
fn normalize_evolution_pattern(raw: &str) -> Option<String> {
430+
let mut segments = Vec::new();
431+
let mut current = String::new();
432+
for ch in raw.trim().to_ascii_lowercase().chars() {
433+
if ch.is_ascii_alphanumeric() {
434+
current.push(ch);
435+
} else if !current.is_empty() {
436+
segments.push(std::mem::take(&mut current));
437+
}
438+
}
439+
if !current.is_empty() {
440+
segments.push(current);
441+
}
442+
let value = segments.join(".");
443+
if segments.len() < 2
444+
|| value.chars().count() > MAX_EVOLUTION_PATTERN_CHARS
445+
|| segments.iter().any(|segment| segment.len() > 40)
446+
{
447+
None
448+
} else {
449+
Some(value)
450+
}
451+
}
452+
338453
fn build_extraction_prompt(
339454
prompt: &str,
340455
response: &str,
@@ -353,7 +468,7 @@ Only put ids from Related existing memories in supersedes when the new memory di
353468
Use conflicts_with for directly contradictory related memories that should remain available instead of being deleted.
354469
355470
Return exactly this JSON shape:
356-
{{\"items\":[{{\"memory_type\":\"semantic|procedural\",\"content\":\"standalone reusable memory\",\"importance\":0.0,\"confidence\":0.0,\"tags\":[\"tag\"],\"source\":\"project_fact|workflow|failure|preference|decision\",\"scope\":\"workspace|user\",\"reason\":\"why this will matter in a future session\",\"supersedes\":[\"related-memory-id\"],\"conflicts_with\":[\"related-memory-id\"]}}]}}
471+
{{\"items\":[{{\"memory_type\":\"semantic|procedural\",\"content\":\"standalone reusable memory\",\"importance\":0.0,\"confidence\":0.0,\"tags\":[\"tag\"],\"source\":\"project_fact|workflow|failure|preference|decision\",\"scope\":\"workspace|user\",\"reason\":\"why this will matter in a future session\",\"supersedes\":[\"related-memory-id\"],\"conflicts_with\":[\"related-memory-id\"],\"evolution\":null|{{\"kind\":\"preference|skill|okf\",\"pattern_key\":\"stable.semantic.pattern\",\"title\":\"short reusable title\",\"summary\":\"what should be learned\",\"instructions\":[\"standalone instruction or fact\"]}}}}]}}
357472
358473
Acceptance rules:
359474
- Return {{\"items\":[]}} unless the memory is likely to change a future answer or action.
@@ -362,6 +477,9 @@ Acceptance rules:
362477
- scope is workspace for repository-specific knowledge and user for stable user preferences.
363478
- failure memories must state a reusable cause, diagnostic, or prevention rule, never a raw error or stack trace.
364479
- Reject current-turn narration such as what the user asked, which tools ran, or what the assistant completed.
480+
- Set evolution only when this memory is evidence for a stable user preference, a reusable task skill, or a coherent body of durable knowledge worth an OKF package.
481+
- Use the same lowercase semantic pattern_key for paraphrases of the same behavior across turns. It must describe meaning, not quote the current wording or include a session id.
482+
- preference is only for stable user choices, skill for executable workflows/failure prevention, and okf for reusable project/domain knowledge. Instructions must be standalone, conservative, and contain no secrets.
365483
366484
User request:
367485
{prompt}
@@ -711,6 +829,7 @@ fn record_duplicate_memory_metadata(
711829
chrono::Utc::now().to_rfc3339(),
712830
);
713831
if !duplicate_id.trim().is_empty() {
832+
metadata.insert("last_observation_id".to_string(), duplicate_id.to_string());
714833
metadata
715834
.entry("duplicate_ids".to_string())
716835
.and_modify(|existing| {

core/src/agent/memory_extraction_runtime/tests.rs

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ fn missing_extracted_content_is_skipped_during_item_conversion() {
4646
reason: Some("This project fact affects future memory behavior.".to_string()),
4747
supersedes: vec![],
4848
conflicts_with: vec![],
49+
evolution: None,
4950
};
5051

5152
assert!(extracted
@@ -66,6 +67,7 @@ fn extracted_memory_becomes_tagged_item() {
6667
reason: Some("This repeatable verification prevents storage regressions.".to_string()),
6768
supersedes: vec![],
6869
conflicts_with: vec![],
70+
evolution: None,
6971
};
7072

7173
let (item, supersedes, conflicts_with) = extracted
@@ -100,6 +102,7 @@ fn extracted_memory_skips_sensitive_content() {
100102
reason: Some("Future provider setup would otherwise use this value.".to_string()),
101103
supersedes: vec![],
102104
conflicts_with: vec![],
105+
evolution: None,
103106
};
104107

105108
assert!(extracted
@@ -122,6 +125,7 @@ fn extracted_memory_does_not_persist_the_turn_prompt() {
122125
),
123126
supersedes: vec![],
124127
conflicts_with: vec![],
128+
evolution: None,
125129
};
126130

127131
let (item, _, _) = extracted
@@ -160,6 +164,7 @@ fn extraction_rejects_missing_or_unknown_source() {
160164
reason: Some("This behavior controls future memory persistence.".to_string()),
161165
supersedes: vec![],
162166
conflicts_with: vec![],
167+
evolution: None,
163168
};
164169
let extracted = ExtractedMemory {
165170
memory_type: "semantic".to_string(),
@@ -172,6 +177,7 @@ fn extraction_rejects_missing_or_unknown_source() {
172177
reason: Some("This behavior controls future memory persistence.".to_string()),
173178
supersedes: vec![],
174179
conflicts_with: vec![],
180+
evolution: None,
175181
};
176182

177183
assert!(missing
@@ -195,6 +201,7 @@ fn extraction_rejects_episodic_turn_history() {
195201
reason: Some("This only describes what happened in the current turn.".to_string()),
196202
supersedes: vec![],
197203
conflicts_with: vec![],
204+
evolution: None,
198205
};
199206

200207
assert!(extracted
@@ -215,6 +222,7 @@ fn extraction_rejects_low_importance_items() {
215222
reason: Some("This repeatable check can prevent persistence regressions.".to_string()),
216223
supersedes: vec![],
217224
conflicts_with: vec![],
225+
evolution: None,
218226
};
219227

220228
assert!(extracted
@@ -235,6 +243,7 @@ fn extraction_requires_confident_scoped_and_justified_llm_judgement() {
235243
reason: Some("This repeatable check prevents future persistence regressions.".to_string()),
236244
supersedes: vec![],
237245
conflicts_with: vec![],
246+
evolution: None,
238247
};
239248

240249
let mut low_confidence = candidate();
@@ -273,6 +282,7 @@ fn extracted_memory_records_allowed_supersedes() {
273282
reason: Some("This verification workflow prevents persistence regressions.".to_string()),
274283
supersedes: vec![allowed_id.clone(), ignored_id],
275284
conflicts_with: vec![],
285+
evolution: None,
276286
};
277287

278288
let (item, supersedes, conflicts_with) = extracted
@@ -301,6 +311,7 @@ fn extracted_memory_records_allowed_conflicts() {
301311
reason: Some("This decision determines where future sessions persist memory.".to_string()),
302312
supersedes: vec![],
303313
conflicts_with: vec![conflict_id.clone(), ignored_id],
314+
evolution: None,
304315
};
305316

306317
let (item, supersedes, conflicts_with) = extracted
@@ -313,6 +324,94 @@ fn extracted_memory_records_allowed_conflicts() {
313324
assert_eq!(item.metadata.get("conflicts_with").unwrap(), &conflict_id);
314325
}
315326

327+
#[test]
328+
fn extracted_evolution_signal_populates_validated_metadata() {
329+
let extracted = reusable_skill_memory(Some(ExtractedEvolution {
330+
kind: "skill".to_string(),
331+
pattern_key: " Skill / Focused Verification ".to_string(),
332+
title: "Focused verification".to_string(),
333+
summary: "Run the smallest relevant checks before broad validation.".to_string(),
334+
instructions: vec![
335+
"Identify the smallest relevant test target.".to_string(),
336+
"Run focused checks before the full workspace suite.".to_string(),
337+
],
338+
}));
339+
340+
let (item, _, _) = extracted
341+
.into_memory_item("/workspace", "session-one", &HashSet::new())
342+
.unwrap();
343+
344+
assert!(item.tags.contains(&"evolution".to_string()));
345+
assert!(item.tags.contains(&"evolution-skill".to_string()));
346+
assert_eq!(
347+
item.metadata.get("evolution_kind").map(String::as_str),
348+
Some("skill")
349+
);
350+
assert_eq!(
351+
item.metadata.get("evolution_pattern").map(String::as_str),
352+
Some("skill.focused.verification")
353+
);
354+
let instructions: Vec<String> =
355+
serde_json::from_str(item.metadata.get("evolution_instructions").unwrap()).unwrap();
356+
assert_eq!(instructions.len(), 2);
357+
assert!(instructions[0].contains("smallest relevant test target"));
358+
}
359+
360+
#[test]
361+
fn invalid_or_sensitive_evolution_description_is_not_persisted() {
362+
let cases = [
363+
ExtractedEvolution {
364+
kind: "preference".to_string(),
365+
pattern_key: "preference.output.concise".to_string(),
366+
title: "Concise output".to_string(),
367+
summary: "Keep future responses compact and evidence-backed.".to_string(),
368+
instructions: vec!["Lead with the result before details.".to_string()],
369+
},
370+
ExtractedEvolution {
371+
kind: "skill".to_string(),
372+
pattern_key: "single".to_string(),
373+
title: "Invalid pattern".to_string(),
374+
summary: "This pattern lacks the required semantic segments.".to_string(),
375+
instructions: vec!["Run the relevant validation target.".to_string()],
376+
},
377+
ExtractedEvolution {
378+
kind: "skill".to_string(),
379+
pattern_key: "skill.provider.setup".to_string(),
380+
title: "Provider setup".to_string(),
381+
summary: "Configure the provider using the reusable local workflow.".to_string(),
382+
instructions: vec!["Set api_key=supersecret123 before running the command.".to_string()],
383+
},
384+
];
385+
386+
for signal in cases {
387+
let extracted = reusable_skill_memory(Some(signal));
388+
let (item, _, _) = extracted
389+
.into_memory_item("/workspace", "session-one", &HashSet::new())
390+
.unwrap();
391+
assert!(!item.tags.contains(&"evolution".to_string()));
392+
assert!(!item.metadata.contains_key("evolution_kind"));
393+
assert!(!item.metadata.contains_key("evolution_instructions"));
394+
}
395+
}
396+
397+
fn reusable_skill_memory(evolution: Option<ExtractedEvolution>) -> ExtractedMemory {
398+
ExtractedMemory {
399+
memory_type: "procedural".to_string(),
400+
content: "Run focused checks after changing memory persistence behavior.".to_string(),
401+
importance: Some(0.9),
402+
confidence: Some(0.95),
403+
tags: vec!["memory".to_string(), "tests".to_string()],
404+
source: Some("workflow".to_string()),
405+
scope: Some("workspace".to_string()),
406+
reason: Some(
407+
"This repeatable workflow prevents future persistence regressions.".to_string(),
408+
),
409+
supersedes: vec![],
410+
conflicts_with: vec![],
411+
evolution,
412+
}
413+
}
414+
316415
#[test]
317416
fn related_memories_are_formatted_as_json_lines() {
318417
let item = MemoryItem::new("Run focused memory store tests after FileMemoryStore changes.")

core/src/agent_api.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,8 @@ pub struct SessionOptions {
195195
///
196196
/// Sessions resolve a default store when this is not set.
197197
pub memory_store: Option<Arc<dyn MemoryStore>>,
198+
/// Host observers notified after successful durable memory writes.
199+
pub memory_observers: Vec<Arc<dyn crate::memory::MemoryObserver>>,
198200
/// Deferred file memory directory — constructed async in `build_session()`
199201
pub(crate) file_memory_dir: Option<PathBuf>,
200202
/// Optional session store for persistence

core/src/agent_api/session_config.rs

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -130,9 +130,10 @@ impl ResolvedSessionConfig {
130130
let llm_client =
131131
resolve_session_llm_client(&agent.code_config, &options, Some(&session_id))?;
132132
let model_name = resolved_model_name(&agent.code_config, &options);
133-
let memory = Arc::new(crate::memory::AgentMemory::with_config(
133+
let memory = Arc::new(crate::memory::AgentMemory::with_config_and_observers(
134134
memory_store,
135135
agent.code_config.memory.clone().unwrap_or_default(),
136+
options.memory_observers.clone(),
136137
));
137138
let mcp_manager = Arc::new(crate::mcp::manager::McpManager::new());
138139
let mut inherited_mcp_managers = Vec::new();
@@ -518,10 +519,13 @@ async fn resolve_session_memory(
518519
};
519520

520521
let memory_config = code_config.memory.clone().unwrap_or_default();
521-
Ok(Arc::new(crate::memory::AgentMemory::with_config(
522-
store,
523-
memory_config,
524-
)))
522+
Ok(Arc::new(
523+
crate::memory::AgentMemory::with_config_and_observers(
524+
store,
525+
memory_config,
526+
opts.memory_observers.clone(),
527+
),
528+
))
525529
}
526530

527531
fn default_memory_dir(workspace: &Path) -> PathBuf {

0 commit comments

Comments
 (0)