-
Notifications
You must be signed in to change notification settings - Fork 14
Add the agent memory lifecycle: brain, holistic recall, pre/post-turn, belief builds #194
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
2ccc89c
9b505a4
1c94c56
42a4079
757b6e9
e67577b
1728e29
c7d29f1
cee37e9
408950b
b298ff5
8230354
47981cc
84b6099
833468b
3e86a44
c7db7b8
cc75b77
6384a32
1a6cfb8
631def4
a0a90fa
e6649ec
45ee82c
0c6d347
2ee7e93
500f147
c1a82be
6102fc7
4a78959
108a63e
fae7c06
33818a8
c9d02f6
e6cf5d3
96887a0
0b33be4
ccd550c
7cce299
8751682
a3b8246
2c02383
3d480f7
3efbf48
786dc45
499ce70
d5ca3e6
505acf7
e7baa92
8b83fe5
bdde707
06cce6d
6bae4c5
6d7d44b
d6fc144
363fbc4
1749b07
7f88126
cea1470
59240d8
b572308
7ac2f43
5f01546
4399055
4f9ba3d
b250e1e
a9a6abf
732feb3
1bd8815
82e1dec
55ef43e
5147876
7b3e90b
cfcabb3
659c2f5
da491d2
c408bf3
21303c3
7a452a8
77e0120
3daccfd
110df45
d013bb3
5dbafcb
aa92161
676eea8
db9df44
382f816
059fa07
e1358e2
c14985f
5db58ce
5522004
0fd4ee9
e421743
a48001f
aedbfd5
9bf47ad
ad42b85
0919865
0722161
35bcade
a96cb03
c281f22
112a5d3
1dce71b
137ae87
a3a059e
196228b
2a82861
be8ee0f
2bfddaa
52ad8a5
37a9427
47cc1b1
c2b510c
98d9b23
a9c4ccd
4a04199
12d2756
ddbb641
03efaba
d7ef4c1
2012b65
fc59be9
de482f7
824915a
ab2dead
583c36b
08677db
5b56a96
5cd0235
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| //! The reference engine's consolidation: a toy belief per source item. | ||
| //! | ||
| //! Real engines distil beliefs with a model. The reference engine needs only | ||
| //! something deterministic and obvious: each document or conversation an | ||
| //! [`ConsolidateRequest`] admits yields one [`LearningKind::Fact`] — the | ||
| //! first sentence of the document's prose, or of the conversation's first | ||
| //! user turn — stored at the source item's own node with the source's metadata, a | ||
| //! `consolidated` tag, and the source's id as evidence. Consolidating twice is | ||
| //! a replay, so it never duplicates a belief. | ||
|
|
||
| use crate::{ConsolidateRequest, DocumentBody, ItemKind, LearningKind, Role, StoreItem}; | ||
|
|
||
| /// Tag on every belief the reference engine distils. | ||
| pub const CONSOLIDATED_TAG: &str = "consolidated"; | ||
|
|
||
| /// Confidence of a distilled belief: a first sentence is a weak signal. | ||
| const BELIEF_CONFIDENCE: f32 = 0.5; | ||
|
|
||
| /// Longest statement a belief keeps, in characters. | ||
| const MAX_STATEMENT_CHARS: usize = 240; | ||
|
|
||
| /// The beliefs `request` distils from `items`, in item order. | ||
| pub(super) fn distil(items: &[StoreItem], request: &ConsolidateRequest) -> Vec<StoreItem> { | ||
| let kinds = request.admitted_kinds(); | ||
| items | ||
| .iter() | ||
| .filter(|item| item.kind() != ItemKind::Learning && kinds.contains(&item.kind())) | ||
| .filter(|item| request.reach.admits(&item.meta().namespace)) | ||
| .filter_map(|item| { | ||
| let statement = first_sentence(&source_text(item)?)?; | ||
| let mut meta = item.meta().clone(); | ||
| if !meta.tags.iter().any(|tag| tag == CONSOLIDATED_TAG) { | ||
| meta.tags.push(CONSOLIDATED_TAG.to_string()); | ||
| } | ||
| Some(StoreItem::Learning { | ||
| text: statement, | ||
| kind: LearningKind::Fact, | ||
| confidence: BELIEF_CONFIDENCE, | ||
| evidence: Some(item.fingerprint()), | ||
| meta, | ||
| }) | ||
| }) | ||
| .collect() | ||
| } | ||
|
|
||
| /// The text a belief is drawn from: a document's body, or a conversation's | ||
| /// first user turn. | ||
| fn source_text(item: &StoreItem) -> Option<String> { | ||
| match item { | ||
| StoreItem::Document { | ||
| body: DocumentBody::Text(text), | ||
| .. | ||
| } => Some(text.clone()), | ||
| StoreItem::Conversation { turns, .. } => turns | ||
| .iter() | ||
| .find(|turn| turn.role == Role::User) | ||
| .map(|turn| turn.text.clone()), | ||
| _ => None, | ||
| } | ||
| } | ||
|
|
||
| /// The first sentence of `text`'s prose, whitespace collapsed; markdown | ||
| /// headings are skipped unless they are all there is. `None` when nothing is | ||
| /// left. | ||
| fn first_sentence(text: &str) -> Option<String> { | ||
| let (headings, prose): (Vec<&str>, Vec<&str>) = text | ||
| .lines() | ||
| .map(str::trim) | ||
| .filter(|line| !line.is_empty()) | ||
| .partition(|line| line.starts_with('#')); | ||
| let lines = if prose.is_empty() { headings } else { prose }; | ||
| let collapsed = lines | ||
| .iter() | ||
| .map(|line| line.trim_start_matches('#')) | ||
| .flat_map(|line| line.split_whitespace()) | ||
| .collect::<Vec<_>>() | ||
| .join(" "); | ||
| let end = collapsed | ||
| .char_indices() | ||
| .find(|(_, c)| matches!(c, '.' | '!' | '?')) | ||
| .map_or(collapsed.len(), |(index, c)| index + c.len_utf8()); | ||
| let sentence: String = collapsed[..end].chars().take(MAX_STATEMENT_CHARS).collect(); | ||
| (!sentence.is_empty()).then_some(sentence) | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| #[path = "distil_tests.rs"] | ||
| mod tests; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| //! The reference engine's toy belief distillation. | ||
|
|
||
| use super::*; | ||
| use crate::{MemoryMeta, Namespace, Reach, Turn}; | ||
|
|
||
| fn at(namespace: Namespace) -> MemoryMeta { | ||
| MemoryMeta { | ||
| namespace, | ||
| ..MemoryMeta::default() | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn distils_the_first_sentence_of_documents_and_user_turns() { | ||
| let items = vec![ | ||
| StoreItem::document( | ||
| "# Refunds\n\nRefunds take five days. Ask support.", | ||
| at(Namespace::source("markdown")), | ||
| ), | ||
| StoreItem::Conversation { | ||
| turns: vec![ | ||
| Turn::new(Role::Assistant, "Hello!"), | ||
| Turn::new(Role::User, "I live in Lagos. What is the weather?"), | ||
| ], | ||
| meta: at(Namespace::agent("support")), | ||
| }, | ||
| StoreItem::learning( | ||
| "already a belief", | ||
| LearningKind::Fact, | ||
| 0.9, | ||
| at(Namespace::ROOT), | ||
| ), | ||
| ]; | ||
| let beliefs = distil( | ||
| &items, | ||
| &ConsolidateRequest::new(Reach::subtree(Namespace::ROOT)), | ||
| ); | ||
| let texts: Vec<String> = beliefs.iter().map(StoreItem::render_text).collect(); | ||
| assert_eq!(texts, ["Refunds take five days.", "I live in Lagos."]); | ||
| let StoreItem::Learning { evidence, meta, .. } = &beliefs[1] else { | ||
| panic!("a belief is a learning"); | ||
| }; | ||
| assert_eq!(evidence.as_deref(), Some(items[1].fingerprint().as_str())); | ||
| assert_eq!(meta.namespace, Namespace::agent("support")); | ||
| assert_eq!(meta.tags, [CONSOLIDATED_TAG]); | ||
| } | ||
|
|
||
| #[test] | ||
| fn honours_the_reach_and_the_kinds() { | ||
| let items = vec![ | ||
| StoreItem::document("Pdf fact.", at(Namespace::source("pdf"))), | ||
| StoreItem::document("Notion fact.", at(Namespace::source("notion"))), | ||
| ]; | ||
| let pdf_only = ConsolidateRequest::new(Reach::exact(Namespace::source("pdf"))); | ||
| assert_eq!(distil(&items, &pdf_only).len(), 1); | ||
| let conversations_only = | ||
| ConsolidateRequest::new(Reach::subtree(Namespace::ROOT)).kinds([ItemKind::Conversation]); | ||
| assert!(distil(&items, &conversations_only).is_empty()); | ||
| } | ||
|
|
||
| #[test] | ||
| fn skips_blank_text_and_caps_long_sentences() { | ||
| assert_eq!(first_sentence(" \n# \n"), None); | ||
| assert_eq!( | ||
| first_sentence("# Only a title").as_deref(), | ||
| Some("Only a title") | ||
| ); | ||
| let long = "word ".repeat(200); | ||
| assert_eq!( | ||
| first_sentence(&long).map(|s| s.chars().count()), | ||
| Some(MAX_STATEMENT_CHARS) | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,16 +4,22 @@ | |
| //! It is the suite's calibration subject: a failure against it means the | ||
| //! assertion is wrong, not the engine. It serves every fetch mode, using a | ||
| //! trivial keyword scorer and a deterministic toy vector (see `score`), and | ||
| //! answers recall by quoting its best hybrid hits. | ||
| //! answers recall by quoting its best hybrid hits. It consolidates on demand, | ||
| //! distilling one toy belief per source item (see `distil`), so a host's | ||
| //! whole memory lifecycle runs offline against it. | ||
|
|
||
| mod distil; | ||
| mod score; | ||
|
|
||
| use std::sync::Mutex; | ||
|
|
||
| pub use distil::CONSOLIDATED_TAG; | ||
|
|
||
| use crate::{ | ||
| Citation, EngineDescriptor, EngineHealth, Error, FetchMode, FetchPage, FetchRequest, | ||
| ForgetReport, ForgetTarget, Hit, ItemId, ListPage, ListRequest, MemoryEngine, MetaFilter, | ||
| RecallAnswer, RecallRequest, Result, StoreItem, StoreReceipt, | ||
| Citation, ConsolidateReceipt, ConsolidateRequest, ConsolidateStatus, Consolidation, | ||
| EngineDescriptor, EngineHealth, Error, FetchMode, FetchPage, FetchRequest, ForgetReport, | ||
| ForgetTarget, Hit, ItemId, ListPage, ListRequest, MemoryEngine, MetaFilter, RecallAnswer, | ||
| RecallRequest, Result, StoreItem, StoreReceipt, | ||
| }; | ||
| use async_trait::async_trait; | ||
|
|
||
|
|
@@ -47,6 +53,7 @@ impl ReferenceEngine { | |
| needs_key: false, | ||
| default_endpoint: None, | ||
| fetch_modes: FetchMode::ALL.to_vec(), | ||
| consolidation: Consolidation::OnDemand, | ||
| }, | ||
| items: Mutex::new(Vec::new()), | ||
| } | ||
|
|
@@ -163,7 +170,11 @@ impl MemoryEngine for ReferenceEngine { | |
| req.validate()?; | ||
| let hits = self.ranked(&req.query, req.mode, &req.filter)?; | ||
| let (hits, next_cursor) = page(hits, req.cursor.as_deref(), req.limit)?; | ||
| Ok(FetchPage { hits, next_cursor }) | ||
| Ok(FetchPage { | ||
| hits, | ||
| next_cursor, | ||
| beliefs: Vec::new(), | ||
| }) | ||
| } | ||
|
|
||
| async fn store(&self, item: StoreItem) -> Result<StoreReceipt> { | ||
|
|
@@ -194,6 +205,34 @@ impl MemoryEngine for ReferenceEngine { | |
| }) | ||
| } | ||
|
|
||
| /// Distils one belief per admitted document or conversation, at once: | ||
| /// the build is [`ConsolidateStatus::Completed`] on return. | ||
| async fn consolidate(&self, req: ConsolidateRequest) -> Result<ConsolidateReceipt> { | ||
| req.validate()?; | ||
| let mut items = self.items()?; | ||
| let beliefs = distil::distil(&items, &req); | ||
| let mut nodes: Vec<&crate::Namespace> = Vec::new(); | ||
| for belief in &beliefs { | ||
| if !nodes.contains(&&belief.meta().namespace) { | ||
| nodes.push(&belief.meta().namespace); | ||
| } | ||
| } | ||
| let scopes = nodes.len() * req.admitted_kinds().len(); | ||
| let built = beliefs.len(); | ||
| for belief in beliefs { | ||
| let id = belief.fingerprint(); | ||
| if !items.iter().any(|held| held.fingerprint() == id) { | ||
| items.push(belief); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove derived beliefs when forgetting their source Generated beliefs are stored as ordinary [RULE] orphaned-derived-data · |
||
| } | ||
| } | ||
| Ok(ConsolidateReceipt { | ||
| status: ConsolidateStatus::Completed, | ||
| jobs: Vec::new(), | ||
| scopes, | ||
| built: Some(built), | ||
| }) | ||
| } | ||
|
|
||
| async fn list(&self, req: ListRequest) -> Result<ListPage> { | ||
| req.validate()?; | ||
| let matching: Vec<Hit> = self | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Execute background jobs so beliefs reach the store
The example queues
report.jobsbut never runs the queue or theAgentMemory::background()runner. A host following this integration guide can complete every turn while belief-building jobs remain unprocessed, so distilled beliefs are not persisted into the engine. This leaves the earlier high-severity persistence issue unresolved; show the queue worker consuming these jobs (or invoke the background runner) before presenting the lifecycle as complete.[RULE] unprocessed-background-jobs ·