diff --git a/crates/tinymemory-api/src/write/mod.rs b/crates/tinymemory-api/src/write/mod.rs index b73e0c00..a8aea9af 100644 --- a/crates/tinymemory-api/src/write/mod.rs +++ b/crates/tinymemory-api/src/write/mod.rs @@ -19,19 +19,21 @@ //! # Example //! //! ``` -//! use tinymemory_api::{MemoryEngine, MemoryMeta, StoreItem, WaitFor, WriteOptions}; -//! use tinymemory_api::conformance::ReferenceEngine; +//! # #[cfg(feature = "conformance")] +//! { +//! use tinymemory_api::{MemoryEngine, MemoryMeta, StoreItem, WaitFor, WriteOptions}; +//! use tinymemory_api::conformance::ReferenceEngine; //! -//! # let runtime = tokio::runtime::Builder::new_current_thread().build()?; -//! # runtime.block_on(async { -//! let engine = ReferenceEngine::new(); -//! let item = StoreItem::document("logged without waiting", MemoryMeta::default()); -//! let receipt = engine.store_with(item, WriteOptions::accepted()).await?; -//! assert!(!receipt.replayed); -//! assert_eq!(WriteOptions::default().wait, WaitFor::Visible); -//! # Ok::<(), tinymemory_api::Error>(()) -//! # })?; -//! # Ok::<(), Box>(()) +//! let runtime = tokio::runtime::Builder::new_current_thread().build().unwrap(); +//! runtime.block_on(async { +//! let engine = ReferenceEngine::new(); +//! let item = StoreItem::document("logged without waiting", MemoryMeta::default()); +//! let receipt = engine.store_with(item, WriteOptions::accepted()).await?; +//! assert!(!receipt.replayed); +//! assert_eq!(WriteOptions::default().wait, WaitFor::Visible); +//! Ok::<(), tinymemory_api::Error>(()) +//! }).unwrap(); +//! } //! ``` use serde::{Deserialize, Serialize}; diff --git a/crates/tinymemory-integrations/src/cortex/lifecycle_tests.rs b/crates/tinymemory-integrations/src/cortex/lifecycle_tests.rs index 68974466..f70d5664 100644 --- a/crates/tinymemory-integrations/src/cortex/lifecycle_tests.rs +++ b/crates/tinymemory-integrations/src/cortex/lifecycle_tests.rs @@ -3,10 +3,10 @@ use std::sync::Arc; -use tinymemory_api::MemoryEngine; +use tinymemory_api::{LearningKind, MemoryEngine, MemoryMeta, Namespace, StoreItem}; use tinymemory_tools::{ - AgentMemory, Brain, BrainDocument, BrainSource, JobOutcome, MemoryLayout, PostTurn, PreTurn, - RecallPolicy, SessionStart, + AgentMemory, Brain, BrainDocument, BrainSource, CoreScope, JobOutcome, MemoryLayout, PostTurn, + PreTurn, RecallPolicy, SessionStart, }; use crate::cortex::CortexWire; @@ -141,3 +141,67 @@ async fn an_agent_loop_runs_the_same_on_either_wire() { } } } + +#[tokio::test] +async fn a_core_scope_recalls_the_company_node_on_either_wire() { + for (engine, state) in both().await { + let wire = engine.wire(); + let engine: Arc = Arc::new(engine); + let acme: Namespace = "ws:acme".parse().unwrap(); + let hive = MemoryLayout::new("ws:acme/team:hive".parse().unwrap()).unwrap(); + let agent = AgentMemory::new(engine.clone(), hive, "a") + .unwrap() + .with_core(vec![CoreScope::new(acme.clone(), "Company")]) + .unwrap(); + agent + .promote( + &acme, + StoreItem::learning( + "Acme closes for the holidays on Friday", + LearningKind::Fact, + 0.9, + MemoryMeta::default(), + ), + ) + .await + .unwrap(); + engine + .store(StoreItem::learning( + "Kestrel's private schedule", + LearningKind::Fact, + 0.9, + MemoryMeta { + namespace: "ws:acme/team:other".parse().unwrap(), + ..MemoryMeta::default() + }, + )) + .await + .unwrap(); + state.seen.lock().unwrap().recalls.clear(); + + let pack = agent.recall("holidays").await.unwrap().markdown; + assert!( + pack.contains("## Company\n\n- Acme closes for the holidays on Friday"), + "{wire:?}: {pack}" + ); + assert!(!pack.contains("Kestrel"), "{wire:?}: {pack}"); + let scopes: Vec = state + .seen + .lock() + .unwrap() + .recalls + .iter() + .filter_map(|body| body["scope"].as_str().map(str::to_string)) + .collect(); + assert!( + scopes + .iter() + .any(|scope| scope.ends_with("app:tinymemory/ws:acme/app:learnings")), + "{wire:?}: {scopes:?}" + ); + assert!( + scopes.iter().all(|scope| !scope.contains("team:other")), + "{wire:?}: {scopes:?}" + ); + } +} diff --git a/crates/tinymemory-integrations/tests/live_cortex_lifecycle.rs b/crates/tinymemory-integrations/tests/live_cortex_lifecycle.rs index 9899326e..0656d4a1 100644 --- a/crates/tinymemory-integrations/tests/live_cortex_lifecycle.rs +++ b/crates/tinymemory-integrations/tests/live_cortex_lifecycle.rs @@ -18,11 +18,15 @@ use std::sync::Arc; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -use tinymemory_api::{ForgetTarget, MemoryEngine, MemoryMeta}; +use tinymemory_api::{ + ForgetTarget, LearningKind, MemoryEngine, MemoryMeta, MetaFilter, Namespace, Reach, StoreItem, +}; use tinymemory_integrations::brain::brain_document; use tinymemory_integrations::cortex::{CortexCredential, CortexEngine}; use tinymemory_integrations::documents::{ConverterChain, RawDocument}; -use tinymemory_tools::{AgentMemory, Brain, ContextPack, MemoryLayout, PostTurn, PreTurn}; +use tinymemory_tools::{ + AgentMemory, Brain, ContextPack, CoreScope, MemoryLayout, PostTurn, PreTurn, +}; const DEFAULT_KEY: &str = "tinymemory-cortex-test"; @@ -141,3 +145,90 @@ async fn live_an_agent_loop_runs_against_cortexdb() { .expect("forget"); assert!(forgotten.forgotten >= 4, "{forgotten:?}"); } + +#[tokio::test] +async fn live_core_scope_recall_and_promotion_respect_tenant_boundaries() { + let Some(engine) = live_engine() else { + eprintln!("TINYMEMORY_LIVE_CORTEXDB_URL unset; skipping"); + return; + }; + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock after the epoch") + .as_nanos(); + let company: Namespace = format!("project:core-{nanos}") + .parse() + .expect("valid namespace"); + let hive: Namespace = format!("project:core-{nanos}/team:hive") + .parse() + .expect("valid namespace"); + let other: Namespace = format!("project:core-{nanos}/team:other") + .parse() + .expect("valid namespace"); + let layout = MemoryLayout::new(hive).expect("valid layout"); + let agent = AgentMemory::new(engine.clone(), layout.clone(), "core-test") + .expect("agent") + .with_core(vec![CoreScope::new(company.clone(), "Company")]) + .expect("company ancestor scope"); + + agent + .promote( + &company, + StoreItem::learning( + "Quasar holidays close the support desk on Friday", + LearningKind::Fact, + 0.9, + MemoryMeta::default(), + ), + ) + .await + .expect("promote into company scope"); + let build = agent + .core_build(&company) + .expect("build job for configured company scope"); + agent + .run_background(build) + .await + .expect("run the company-scope belief build"); + engine + .store(StoreItem::learning( + "Quasar holidays reveal the other tenant's private schedule", + LearningKind::Fact, + 0.9, + MemoryMeta { + namespace: other.clone(), + ..MemoryMeta::default() + }, + )) + .await + .expect("store sibling fixture"); + + let pack = recall_until( + &agent, + "Quasar holidays", + &["Quasar holidays close the support desk on Friday"], + ) + .await; + assert!( + pack.markdown + .contains("## Company\n\n- Quasar holidays close the support desk on Friday"), + "company core appears in recall:\n{}", + pack.markdown + ); + assert!( + !pack.markdown.contains("other tenant's private schedule"), + "sibling item is excluded:\n{}", + pack.markdown + ); + + for namespace in [company, other] { + let forgotten = engine + .forget(ForgetTarget::Filter(MetaFilter { + reach: Some(Reach::exact(namespace)), + ..MetaFilter::default() + })) + .await + .expect("clean up test data"); + assert_eq!(forgotten.forgotten, 1, "{forgotten:?}"); + } +} diff --git a/crates/tinymemory-tools/src/context/compile/mod_tests.rs b/crates/tinymemory-tools/src/context/compile/mod_tests.rs index 1f56e4f5..570acd5d 100644 --- a/crates/tinymemory-tools/src/context/compile/mod_tests.rs +++ b/crates/tinymemory-tools/src/context/compile/mod_tests.rs @@ -193,3 +193,32 @@ async fn a_reach_keeps_the_document_to_one_agent_s_memory() { ); assert!(!doc.markdown.contains("writer habit"), "{}", doc.markdown); } + +#[tokio::test] +async fn a_core_brief_reads_only_the_company_node() { + let engine = ReferenceEngine::new(); + let mut company = learning("company holiday habit", 0.9, Some(1)); + company.meta_mut().namespace = "ws:acme".parse().unwrap(); + let mut sibling = learning("other team habit", 0.9, Some(1)); + sibling.meta_mut().namespace = "ws:acme/team:other".parse().unwrap(); + for item in [company, sibling] { + engine.store(item).await.unwrap(); + } + let core = crate::layout::CoreScope::new("ws:acme".parse().unwrap(), "Company"); + let spec = ContextSpec { + briefs: vec![core.brief("habit")], + learnings_limit: 0, + ..ContextSpec::default() + }; + let doc = ContextCompiler::at(at()) + .compile(&engine, &spec) + .await + .unwrap(); + assert!(doc.markdown.contains("## Company"), "{}", doc.markdown); + assert!(doc.markdown.contains("company holiday habit")); + assert!( + !doc.markdown.contains("other team habit"), + "{}", + doc.markdown + ); +} diff --git a/crates/tinymemory-tools/src/layout/mod.rs b/crates/tinymemory-tools/src/layout/mod.rs index 29b84144..b497cfcd 100644 --- a/crates/tinymemory-tools/src/layout/mod.rs +++ b/crates/tinymemory-tools/src/layout/mod.rs @@ -25,6 +25,11 @@ //! below a node of its own (`team:acme`), which keeps tenants apart on one //! engine. //! +//! **Core scopes** share memory beyond one layout. A host that nests every +//! tenant under one company node (`ws:acme/team:hive`) can name an ancestor +//! of the root as a [`CoreScope`]: a hive-wide core or a company brain that +//! every agent below it recalls, read exactly so sibling tenants stay apart. +//! //! # Example //! //! ``` @@ -43,10 +48,12 @@ //! ``` mod source; +mod types; use tinymemory_api::{Error, ItemKind, MetaFilter, Namespace, Reach, Result, Segment, SegmentKind}; pub use source::BrainSource; +pub use types::{CoreScope, DEFAULT_CORE_LIMIT}; /// Deepest a layout root may be: one level must remain for the brain's and /// the agents' nodes. @@ -80,6 +87,33 @@ impl MemoryLayout { &self.root } + /// The root's strict ancestors, root first: the nodes a [`CoreScope`] + /// may name. Empty for a layout at [`Namespace::ROOT`]. + #[must_use] + pub fn ancestors(&self) -> Vec { + let mut nodes = Reach::of(self.root.clone()).nodes(); + nodes.pop(); + nodes + } + + /// Checks `at` may be a core scope of this layout: a strict ancestor of + /// the root. + /// + /// # Errors + /// + /// [`Error::InvalidRequest`] for the root itself, a node below it, or a + /// node beside it. + pub fn admits_core(&self, at: &Namespace) -> Result<()> { + if at != &self.root && Reach::of(self.root.clone()).admits(at) { + Ok(()) + } else { + Err(Error::InvalidRequest(format!( + "a core scope must be an ancestor of the layout root `{}`, `{at}` is not", + self.root + ))) + } + } + /// The node `source`'s documents live at. /// /// # Errors diff --git a/crates/tinymemory-tools/src/layout/mod_tests.rs b/crates/tinymemory-tools/src/layout/mod_tests.rs index c8e5cb68..b08910c5 100644 --- a/crates/tinymemory-tools/src/layout/mod_tests.rs +++ b/crates/tinymemory-tools/src/layout/mod_tests.rs @@ -75,3 +75,62 @@ fn source_ids_round_trip() { assert!(" ".parse::().is_err()); assert_eq!(BrainSource::Github.source_kind(), SourceKind::Github); } + +#[test] +fn admits_only_a_strict_ancestor_as_core() { + let layout = MemoryLayout::new("ws:acme/team:hive".parse().unwrap()).unwrap(); + layout.admits_core(&Namespace::ROOT).unwrap(); + layout.admits_core(&"ws:acme".parse().unwrap()).unwrap(); + for refused in [ + "ws:acme/team:hive", + "ws:acme/team:other", + "ws:acme/team:hive/agent:a", + "ws:other", + ] { + assert!( + matches!( + layout.admits_core(&refused.parse().unwrap()), + Err(Error::InvalidRequest(_)) + ), + "{refused}" + ); + } + assert!( + MemoryLayout::default() + .admits_core(&Namespace::ROOT) + .is_err() + ); +} + +#[test] +fn ancestors_lists_root_first() { + let layout = MemoryLayout::new("ws:acme/team:hive".parse().unwrap()).unwrap(); + let ancestors: Vec = layout.ancestors().iter().map(ToString::to_string).collect(); + assert_eq!(ancestors, ["root", "ws:acme"]); + assert!(MemoryLayout::default().ancestors().is_empty()); +} + +#[test] +fn a_core_scope_reads_its_node_exactly() { + let company = CoreScope::new("ws:acme".parse().unwrap(), "Company") + .kinds([ItemKind::Learning]) + .limit(2); + let filter = company.filter(); + let reach = filter.reach.unwrap(); + assert!(reach.admits(&"ws:acme".parse().unwrap())); + assert!(!reach.admits(&Namespace::ROOT)); + assert!(!reach.admits(&"ws:acme/team:other".parse().unwrap())); + assert_eq!(filter.kinds, [ItemKind::Learning]); + assert_eq!(company.limit, 2); + + let brief = company.brief("What does the company know?"); + assert_eq!(brief.heading, "Company"); + assert_eq!(brief.filter, company.filter()); + + let parsed: CoreScope = + serde_json::from_value(serde_json::json!({"at": "ws:acme", "heading": "Company"})).unwrap(); + assert_eq!( + parsed, + CoreScope::new("ws:acme".parse().unwrap(), "Company") + ); +} diff --git a/crates/tinymemory-tools/src/layout/types.rs b/crates/tinymemory-tools/src/layout/types.rs new file mode 100644 index 00000000..266945e8 --- /dev/null +++ b/crates/tinymemory-tools/src/layout/types.rs @@ -0,0 +1,99 @@ +//! [`CoreScope`]: a shared node above a layout that agents recall from. + +use serde::{Deserialize, Serialize}; +use tinymemory_api::{ItemKind, MetaFilter, Namespace, Reach}; + +use crate::context::Brief; + +/// Default number of items a core section shows. +pub const DEFAULT_CORE_LIMIT: usize = 6; + +/// A node above a layout's root whose memory every agent below it recalls: +/// a hive-wide core, or a company brain shared by every team. +/// +/// The node is read exactly ([`Reach::exact`]), never as a subtree, so a +/// core read sees what was written at the node itself and nothing from a +/// sibling tenant below it. A host that shares two levels (say the root and +/// `ws:acme`) names two scopes. +/// +/// ``` +/// use tinymemory_api::{ItemKind, Namespace}; +/// use tinymemory_tools::CoreScope; +/// +/// let company = CoreScope::new("ws:acme".parse()?, "Company").limit(4); +/// let reach = company.filter().reach.unwrap(); +/// assert!(reach.admits(&"ws:acme".parse()?)); +/// assert!(!reach.admits(&"ws:acme/team:hive".parse()?)); +/// assert_eq!(company.kinds, [ItemKind::Learning, ItemKind::Document]); +/// # Ok::<(), tinymemory_api::Error>(()) +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CoreScope { + /// The shared node: a strict ancestor of the layout's root. + pub at: Namespace, + /// The heading its section carries in a pack. + pub heading: String, + /// The kinds of item read from it. + #[serde(default = "default_kinds")] + pub kinds: Vec, + /// The most items its section shows; `0` leaves the section out. + #[serde(default = "default_limit")] + pub limit: usize, +} + +fn default_kinds() -> Vec { + vec![ItemKind::Learning, ItemKind::Document] +} + +fn default_limit() -> usize { + DEFAULT_CORE_LIMIT +} + +impl CoreScope { + /// Learnings and documents at `at`, under `heading`, up to + /// [`DEFAULT_CORE_LIMIT`] items. + #[must_use] + pub fn new(at: Namespace, heading: impl Into) -> Self { + Self { + at, + heading: heading.into(), + kinds: default_kinds(), + limit: DEFAULT_CORE_LIMIT, + } + } + + /// The same scope reading only `kinds`; none reads every kind. + #[must_use] + pub fn kinds(mut self, kinds: impl IntoIterator) -> Self { + self.kinds = kinds.into_iter().collect(); + self + } + + /// The same scope showing at most `limit` items. + #[must_use] + pub fn limit(mut self, limit: usize) -> Self { + self.limit = limit; + self + } + + /// What the scope reads: its kinds, at exactly its node. + #[must_use] + pub fn filter(&self) -> MetaFilter { + MetaFilter { + reach: Some(Reach::exact(self.at.clone())), + ..MetaFilter::kinds(self.kinds.iter().copied()) + } + } + + /// A `context.md` section answering `question` from this scope alone. + /// Leave [`crate::context::ContextSpec::reach`] unset (or set it to an + /// agent's [`Reach::of`], which already covers its ancestors) or the + /// spec's reach replaces this brief's. + #[must_use] + pub fn brief(&self, question: impl Into) -> Brief { + Brief { + filter: self.filter(), + ..Brief::new(self.heading.clone(), question) + } + } +} diff --git a/crates/tinymemory-tools/src/lib.rs b/crates/tinymemory-tools/src/lib.rs index 831db703..465191a9 100644 --- a/crates/tinymemory-tools/src/lib.rs +++ b/crates/tinymemory-tools/src/lib.rs @@ -72,7 +72,7 @@ pub mod tools; pub use background::{BackgroundJob, BackgroundRunner, JobOutcome, JobReport}; pub use brain::{Brain, BrainBatch, BrainDocument, Ingested}; -pub use layout::{BrainSource, MemoryLayout}; +pub use layout::{BrainSource, CoreScope, DEFAULT_CORE_LIMIT, MemoryLayout}; pub use lifecycle::{ AgentMemory, Compaction, PostTurn, PostTurnReport, PreTurn, RecallPolicy, SessionStart, TurnContext, diff --git a/crates/tinymemory-tools/src/lifecycle/mod.rs b/crates/tinymemory-tools/src/lifecycle/mod.rs index 41327129..13db7698 100644 --- a/crates/tinymemory-tools/src/lifecycle/mod.rs +++ b/crates/tinymemory-tools/src/lifecycle/mod.rs @@ -21,9 +21,16 @@ //! part of the thread still in the prompt. //! //! A pack's sections, highest priority first (budget trimming takes from the -//! last): **Learnings**, **Brain**, **This agent's history**, and **Team -//! conversations** (other agents' turns). An item appears once, in the first -//! section that found it. +//! last): **Learnings**, one section per [`CoreScope`], **Brain**, **This +//! agent's history**, and **Team conversations** (other agents' turns). An +//! item appears once, in the first section that found it. +//! +//! Core scopes share memory above the layout, such as a hive-wide core or a +//! company brain. They are empty by default; [`AgentMemory::with_core`] sets +//! them, and because the memory is cheap to clone a host can override them +//! for one call: `memory.clone().with_core(scopes)?.pre_turn(turn)`. Only the +//! host writes there, through [`AgentMemory::promote`]; the model's tools +//! never choose a node. //! //! Each turn is stored as its own one-turn conversation item carrying the //! thread id, the turn's index, the agent id and the `conversation` source, @@ -69,12 +76,12 @@ use std::sync::Arc; use futures::future::join; use tinymemory_api::{ ConsolidateRequest, Error, ItemKind, MemoryEngine, MemoryMeta, MetaFilter, Namespace, Reach, - Result, Role, SourceKind, SourceRef, StoreItem, Turn, TurnRange, WriteOptions, + Result, Role, SourceKind, SourceRef, StoreItem, StoreReceipt, Turn, TurnRange, WriteOptions, }; use crate::background::{BackgroundJob, BackgroundRunner, JobReport}; use crate::brain::Brain; -use crate::layout::MemoryLayout; +use crate::layout::{CoreScope, MemoryLayout}; use crate::recall::{ ContextPack, HolisticRecall, ScopeSection, SectionQuery, ThreadWindow, holistic_recall, }; @@ -127,6 +134,7 @@ pub struct AgentMemory { agent_id: String, node: Namespace, policy: RecallPolicy, + core: Vec, } impl std::fmt::Debug for AgentMemory { @@ -136,6 +144,7 @@ impl std::fmt::Debug for AgentMemory { .field("agent_id", &self.agent_id) .field("node", &self.node.to_string()) .field("policy", &self.policy) + .field("core", &self.core) .finish() } } @@ -164,6 +173,7 @@ impl AgentMemory { layout, agent_id: agent_id.to_string(), policy: RecallPolicy::default(), + core: Vec::new(), }) } @@ -174,6 +184,89 @@ impl AgentMemory { self } + /// The same memory recalling `core` as well, replacing any core scopes it + /// had; an empty list drops them. Clone first to override them for one + /// call. + /// + /// # Errors + /// + /// [`Error::InvalidRequest`] for a scope that is not a strict ancestor of + /// the layout root ([`MemoryLayout::admits_core`]), a blank heading, or a + /// node named twice. + pub fn with_core(mut self, core: Vec) -> Result { + for (index, scope) in core.iter().enumerate() { + self.layout.admits_core(&scope.at)?; + if scope.heading.trim().is_empty() { + return Err(Error::InvalidRequest(format!( + "the core scope `{}` needs a heading", + scope.at + ))); + } + if core[..index].iter().any(|earlier| earlier.at == scope.at) { + return Err(Error::InvalidRequest(format!( + "the core scope `{}` is named twice", + scope.at + ))); + } + } + self.core = core; + Ok(self) + } + + /// The core scopes recalled alongside the layout, in section order. + #[must_use] + pub fn core(&self) -> &[CoreScope] { + &self.core + } + + /// Writes `item` into the core scope at `scope`, for every agent below it + /// to recall: a hive-wide learning or a company brain document. The + /// item's namespace is set to `scope` whatever it was. Waits as + /// [`MemoryEngine::store`] does. + /// + /// # Errors + /// + /// [`Error::InvalidRequest`] for a node that is not one of this memory's + /// core scopes, or a conversation (turns stay at the agent's node); the + /// engine's failure to store. + pub async fn promote(&self, scope: &Namespace, mut item: StoreItem) -> Result { + let Some(core) = self.core.iter().find(|core| &core.at == scope) else { + return Err(Error::InvalidRequest(format!( + "`{scope}` is not a core scope of this memory" + ))); + }; + let kind = item.kind(); + if kind == ItemKind::Conversation { + return Err(Error::InvalidRequest( + "only learnings and documents are promoted to a core scope".to_string(), + )); + } + if !core.kinds.is_empty() && !core.kinds.contains(&kind) { + return Err(Error::InvalidRequest(format!( + "the core scope `{scope}` does not read {kind:?} items" + ))); + } + item.meta_mut().namespace = scope.clone(); + self.engine.store(item).await + } + + /// A belief build of exactly the core scope at `scope`. + /// + /// # Errors + /// + /// [`Error::InvalidRequest`] for a node that is not one of this memory's + /// core scopes. + pub fn core_build(&self, scope: &Namespace) -> Result { + if !self.core.iter().any(|core| &core.at == scope) { + return Err(Error::InvalidRequest(format!( + "`{scope}` is not a core scope of this memory" + ))); + } + Ok(BackgroundJob::BuildBeliefs { + request: ConsolidateRequest::new(Reach::exact(scope.clone())), + }) + } + /// The agent's id. #[must_use] pub fn agent_id(&self) -> &str { @@ -217,7 +310,8 @@ impl AgentMemory { } /// The model-facing memory tools for this agent: writes land at its node, - /// reads see its node and the shared root ([`Reach::of`]). + /// reads see its node and every node above it ([`Reach::of`]), core + /// scopes included. #[must_use] pub fn tools(&self) -> MemoryTools { MemoryTools::new(self.engine.clone()).placed_at(self.node.clone()) @@ -408,16 +502,20 @@ impl AgentMemory { } } - /// Learnings, brain, this agent's history, then the team's, each filled - /// by fetch; a zero limit leaves its section out. + /// Learnings, each core scope, brain, this agent's history, then the + /// team's, each filled by fetch; a zero limit leaves its section out. fn standard_sections(&self) -> Vec { let policy = &self.policy; - [ - ( - LEARNINGS_HEADING, - self.layout.learnings_filter(), - policy.learnings_limit, - ), + let learnings = ( + LEARNINGS_HEADING, + self.layout.learnings_filter(), + policy.learnings_limit, + ); + let core = self + .core + .iter() + .map(|scope| (scope.heading.as_str(), scope.filter(), scope.limit)); + let layout = [ ( BRAIN_HEADING, self.layout.brain_filter(None), @@ -433,11 +531,13 @@ impl AgentMemory { self.layout.conversations_filter(None), policy.team_limit, ), - ] - .into_iter() - .filter(|(_, _, limit)| *limit > 0) - .map(|(heading, filter, limit)| ScopeSection::fetch(heading, filter, limit)) - .collect() + ]; + std::iter::once(learnings) + .chain(core) + .chain(layout) + .filter(|(_, _, limit)| *limit > 0) + .map(|(heading, filter, limit)| ScopeSection::fetch(heading, filter, limit)) + .collect() } fn request(&self, query: Option, sections: Vec) -> HolisticRecall { diff --git a/crates/tinymemory-tools/src/lifecycle/mod_tests.rs b/crates/tinymemory-tools/src/lifecycle/mod_tests.rs index edd337ba..1d22c61d 100644 --- a/crates/tinymemory-tools/src/lifecycle/mod_tests.rs +++ b/crates/tinymemory-tools/src/lifecycle/mod_tests.rs @@ -407,3 +407,216 @@ fn a_zero_limit_leaves_its_section_out() { [LEARNINGS_HEADING, BRAIN_HEADING, HISTORY_HEADING] ); } + +/// A hive below a company: a company fact, a root fact, a sibling tenant's +/// secret and the hive's own learning, with `a` in the hive. +async fn company() -> (Arc, AgentMemory) { + let engine = Arc::new(ReferenceEngine::new()); + for (at, text) in [ + ("ws:acme", "Acme closes for the holidays on Friday"), + ("root", "Every agent answers in English"), + ( + "ws:acme/team:other", + "The other team closes for the holidays on Monday", + ), + ("ws:acme/team:hive", "The hive ships on Mondays"), + ] { + engine + .store(StoreItem::learning( + text, + LearningKind::Fact, + 0.9, + MemoryMeta { + namespace: at.parse().unwrap(), + ..MemoryMeta::default() + }, + )) + .await + .unwrap(); + } + let layout = MemoryLayout::new("ws:acme/team:hive".parse().unwrap()).unwrap(); + let memory = AgentMemory::new(engine.clone(), layout, "a").unwrap(); + (engine, memory) +} + +fn acme() -> Namespace { + "ws:acme".parse().unwrap() +} + +#[tokio::test] +async fn a_core_scope_adds_its_section_after_learnings() { + let (_, memory) = company().await; + let without = memory.recall("").await.unwrap().markdown; + assert_eq!( + without, + "# Memory\n\n## Learnings\n\n- The hive ships on Mondays\n" + ); + assert!(!without.contains("holidays"), "{without}"); + + let memory = memory + .with_core(vec![CoreScope::new(acme(), "Company")]) + .unwrap(); + let md = memory.recall("").await.unwrap().markdown; + let learnings = md.find("## Learnings").unwrap(); + let company = md + .find("## Company\n\n- Acme closes for the holidays on Friday") + .unwrap(); + assert!(learnings < company, "{md}"); + assert!(md.contains("The hive ships on Mondays")); +} + +#[tokio::test] +async fn a_core_scope_never_reads_a_sibling_tenant() { + let (_, memory) = company().await; + let memory = memory + .with_core(vec![ + CoreScope::new(Namespace::ROOT, "Core"), + CoreScope::new(acme(), "Company"), + ]) + .unwrap(); + let md = memory.recall("").await.unwrap().markdown; + assert!( + md.contains("## Core\n\n- Every agent answers in English"), + "{md}" + ); + assert!(md.contains("Acme closes")); + assert!(!md.contains("Kestrel"), "{md}"); +} + +#[tokio::test] +async fn with_core_replaces_the_set_per_call() { + let (_, memory) = company().await; + let without = memory.recall("").await.unwrap().markdown; + let memory = memory + .with_core(vec![CoreScope::new(acme(), "Company")]) + .unwrap(); + let override_ = memory + .clone() + .with_core(vec![CoreScope::new(Namespace::ROOT, "Core")]) + .unwrap(); + let md = override_.recall("").await.unwrap().markdown; + assert!(md.contains("## Core") && !md.contains("## Company"), "{md}"); + assert_eq!(memory.core()[0].at, acme(), "the original keeps its set"); + + let dropped = memory.clone().with_core(Vec::new()).unwrap(); + assert!(dropped.core().is_empty()); + assert_eq!(dropped.recall("").await.unwrap().markdown, without); +} + +#[tokio::test] +async fn rejects_a_core_scope_outside_the_ancestors() { + let (_, memory) = company().await; + for at in [ + "ws:acme/team:hive", + "ws:acme/team:other", + "ws:acme/team:hive/agent:a", + ] { + let refused = memory + .clone() + .with_core(vec![CoreScope::new(at.parse().unwrap(), "Shared")]); + assert!(matches!(refused, Err(Error::InvalidRequest(_))), "{at}"); + } +} + +#[tokio::test] +async fn rejects_a_duplicate_core_node_or_a_blank_heading() { + let (_, memory) = company().await; + let twice = memory.clone().with_core(vec![ + CoreScope::new(acme(), "Company"), + CoreScope::new(acme(), "Again"), + ]); + assert!(matches!(twice, Err(Error::InvalidRequest(_)))); + let blank = memory.with_core(vec![CoreScope::new(acme(), " ")]); + assert!(matches!(blank, Err(Error::InvalidRequest(_)))); +} + +#[tokio::test] +async fn a_zero_limit_core_scope_is_left_out() { + let (_, memory) = company().await; + let memory = memory + .with_core(vec![CoreScope::new(acme(), "Company").limit(0)]) + .unwrap(); + let md = memory.recall("").await.unwrap().markdown; + assert!(!md.contains("## Company"), "{md}"); +} + +#[tokio::test] +async fn promote_writes_at_the_core_node() { + let (engine, memory) = company().await; + let memory = memory + .with_core(vec![CoreScope::new(acme(), "Company")]) + .unwrap(); + let receipt = memory + .promote( + &acme(), + StoreItem::document("The expense limit is 500 euros", MemoryMeta::default()), + ) + .await + .unwrap(); + let stored = engine + .list(ListRequest::new( + MetaFilter::kinds([ItemKind::Document]), + 10, + )) + .await + .unwrap(); + assert_eq!(stored.items[0].id, receipt.id); + assert_eq!(stored.items[0].meta.namespace, acme()); + + let other = AgentMemory::new( + engine.clone(), + MemoryLayout::new("ws:acme/team:hive".parse().unwrap()).unwrap(), + "b", + ) + .unwrap() + .with_core(vec![CoreScope::new(acme(), "Company")]) + .unwrap(); + let md = other.recall("expense limit").await.unwrap().markdown; + assert!(md.contains("The expense limit is 500 euros"), "{md}"); +} + +#[tokio::test] +async fn promote_rejects_a_conversation_and_an_unconfigured_node() { + let (_, memory) = company().await; + let learning = StoreItem::learning("x", LearningKind::Fact, 0.5, MemoryMeta::default()); + let unconfigured = memory.promote(&acme(), learning.clone()).await; + assert!(matches!(unconfigured, Err(Error::InvalidRequest(_)))); + + let memory = memory + .with_core(vec![CoreScope::new(acme(), "Company")]) + .unwrap(); + let conversation = StoreItem::Conversation { + meta: MemoryMeta::default(), + turns: vec![Turn::new(Role::User, "hello")], + }; + let refused = memory.promote(&acme(), conversation).await; + assert!(matches!(refused, Err(Error::InvalidRequest(_)))); + + let learning_only = memory + .with_core(vec![ + CoreScope::new(acme(), "Company").kinds([ItemKind::Learning]), + ]) + .unwrap(); + let document = StoreItem::document("x", MemoryMeta::default()); + assert!(matches!( + learning_only.promote(&acme(), document).await, + Err(Error::InvalidRequest(_)) + )); +} + +#[tokio::test] +async fn core_build_consolidates_exactly_the_core_node() { + let (_, memory) = company().await; + assert!(matches!( + memory.core_build(&acme()), + Err(Error::InvalidRequest(_)) + )); + let memory = memory + .with_core(vec![CoreScope::new(acme(), "Company")]) + .unwrap(); + let BackgroundJob::BuildBeliefs { request } = memory.core_build(&acme()).unwrap() else { + panic!("a core build is a belief build"); + }; + assert_eq!(request.reach, Reach::exact(acme())); + assert!(request.kinds.is_empty()); +} diff --git a/docs/architecture/lifecycle.md b/docs/architecture/lifecycle.md index 16b2a212..874d5cad 100644 --- a/docs/architecture/lifecycle.md +++ b/docs/architecture/lifecycle.md @@ -42,6 +42,7 @@ host AgentMemory engine │ ├─ store_with(turn, Accepted) ─▶ (≈ capture, no indexing wait) │ ├─ holistic recall, concurrently: │ │ Learnings fetch ──────────▶ + │ │ fetch ──────────▶ (one per CoreScope, exact node) │ │ Brain fetch ──────────▶ │ │ History fetch ──────────▶ │ │ Team fetch ──────────▶ diff --git a/docs/architecture/namespaces.md b/docs/architecture/namespaces.md index 03fa6579..27ae481b 100644 --- a/docs/architecture/namespaces.md +++ b/docs/architecture/namespaces.md @@ -161,6 +161,23 @@ Items exist at R, T, W, H and E. What each reach sees: | `subtree(root)` | R, T, W, H, E | | no reach (`None`) | R, T, W, H, E | +### A company above its tenants + +A layout reads the subtree of its root, never what lies above it. To share +memory across tenants, a host nests every tenant below one company node and +names that node (and, optionally, the root) as a **core scope** +([specs/core-scopes.md](../specs/core-scopes.md)): + +```text +root core: "Core" (optional) +└── ws:acme core: "Company" company brain + ├── team:hive layout root of the hive's agents + └── team:other another tenant, never read by the hive +``` + +A core scope reads its node with `Reach::exact`, so the hive sees the +company's own items but nothing from `team:other`. + ## What each operation does with reach | Operation | Namespace handling | diff --git a/docs/plans/core-scopes.md b/docs/plans/core-scopes.md new file mode 100644 index 00000000..d812a0eb --- /dev/null +++ b/docs/plans/core-scopes.md @@ -0,0 +1,41 @@ +# Plan: core scopes + +**Spec:** [../specs/core-scopes.md](../specs/core-scopes.md) + +The change is additive and confined to `tinymemory-tools`. There is no contract +change and no engine change. Each step starts from a failing test. + +1. **`CoreScope`** in `crates/tinymemory-tools/src/layout/types.rs`, exported + from `layout/mod.rs` and `src/lib.rs`. + - Test: `a_core_scope_reads_its_node_exactly` (`layout/mod_tests.rs`). +2. **Layout ancestry** in `layout/mod.rs`: `admits_core` and `ancestors`. + - Tests: `admits_only_a_strict_ancestor_as_core` and + `ancestors_lists_root_first`. +3. **`AgentMemory`** in `lifecycle/mod.rs`: + - the `core` field, `with_core` and `core`; + - core sections in `standard_sections`, after Learnings; + - `promote` and `core_build`. + - Tests in `lifecycle/mod_tests.rs`, from section order through the + sibling-tenant guard, per-call replacement, every refusal, promote and + the build job. +4. **context.md**: test `a_core_brief_reads_only_the_company_node` in + `context/compile/mod_tests.rs`. +5. **CortexDB**: test `a_core_scope_recalls_the_company_node_on_either_wire` + in `tinymemory-integrations/src/cortex/lifecycle_tests.rs`. +6. **Docs:** the spec, `agent-memory.md` (standard sections), + `architecture/lifecycle.md` (turn diagram) and + `architecture/namespaces.md` (a company above its tenants). + +## Verification + +```sh +cargo fmt --all -- --check +cargo clippy --all-targets --all-features -- -D warnings +cargo build --all-targets --all-features +cargo test --all-features +RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features +``` + +## Checklist + +- [x] Steps 1–6 diff --git a/docs/specs/README.md b/docs/specs/README.md index 126ca808..7f2673d8 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -7,6 +7,9 @@ source, per-agent conversations, learnings), holistic recall, pre- and post-turn calls, compaction and background belief builds. Accepted; builds on memory v2. +- [Core scopes](core-scopes.md) — shared memory above a layout (a hive-wide + core, a company brain) recalled as its own sections, set per agent or per + call, written by the host. Accepted; builds on the agent memory lifecycle. Specifications define what the system must do before implementation details take over. Create one for behavior that changes a public API, crosses module diff --git a/docs/specs/agent-memory.md b/docs/specs/agent-memory.md index 99880fd0..a35a84d5 100644 --- a/docs/specs/agent-memory.md +++ b/docs/specs/agent-memory.md @@ -166,8 +166,9 @@ skipped, engine }`. | `recall(query)` | — | the pre-turn read without logging | | `run_background(job)` | the job's | — | -- **Standard sections**, in priority order: Learnings (the whole tree), Brain - (all documents), this agent's history, and team conversations (every +- **Standard sections**, in priority order: Learnings (the whole tree), one + section per core scope ([core-scopes.md](core-scopes.md); none by default), + Brain (all documents), this agent's history, and team conversations (every agent). A zero limit in `RecallPolicy` leaves a section out. - **`pre_turn` never fails on an engine error.** A failed log is reported in `TurnContext::log_error` and the pack is still returned. diff --git a/docs/specs/core-scopes.md b/docs/specs/core-scopes.md new file mode 100644 index 00000000..3d80bb33 --- /dev/null +++ b/docs/specs/core-scopes.md @@ -0,0 +1,67 @@ +# Core scopes: shared memory above a layout + +**Status:** Accepted. **Builds on:** [agent-memory.md](agent-memory.md). +**Plan:** [../plans/core-scopes.md](../plans/core-scopes.md). + +## Problem + +An `AgentMemory` recalls only the subtree of its layout root. A multi-agent +host such as tinyhivemind needs memory shared more widely than one layout: + +- a hive-wide **core** that every agent recalls; +- a **company brain** that every team below one company node recalls. + +The host must be able to set that shared memory once, change it for a single +call, and write into it. Before this change none of that was possible. + +## Goals and non-goals + +- **Goal:** recall from shared nodes *above* the layout root, as their own + pack sections, set once or per call. +- **Goal:** a host-only way to write learnings and documents into a shared node. +- **Non-goal:** sharing between unrelated nodes. A shared node must be an + ancestor of the layout root, so a company that shares memory nests its + tenants below one node (`ws:acme/team:hive`). The `Reach` contract and the + engines are unchanged. +- **Non-goal:** letting the model choose a scope. Tool arguments still may not + name a namespace or reach. + +## Behavior (`tinymemory-tools`) + +- **`CoreScope { at, heading, kinds, limit }`** + - `CoreScope::new(at, heading)` reads learnings and documents, up to + `DEFAULT_CORE_LIMIT` (6). It has `kinds(..)` and `limit(..)` builders. + - `filter()` reads `at` **exactly** (`Reach::exact`). + - `brief(question)` gives a `context.md` section. +- **`MemoryLayout::admits_core(at)`** accepts only a strict ancestor of the + root. **`MemoryLayout::ancestors()`** lists those nodes, root first. +- **`AgentMemory::with_core(Vec) -> Result`** replaces the + core set; an empty list drops it. It refuses a node that is not a strict + ancestor, a blank heading, or a node named twice. `AgentMemory` is cheap to + clone, so `memory.clone().with_core(..)?` overrides the set for one call. + `core()` returns the current set. +- **Standard sections** are Learnings, then one section per core scope in + order, then Brain, this agent's history, and Team conversations. A zero + limit leaves a core section out. +- **`AgentMemory::promote(&scope, item)`** stores a learning or document at a + configured core node and overwrites the item's namespace. It refuses a + conversation or an unconfigured node. +- **`AgentMemory::core_build(&scope)`** returns the `BuildBeliefs` job for + exactly that node. +- **`AgentMemory::tools()`** is unchanged. `Reach::of(node)` already reads + every ancestor, core nodes included. + +## Invariants + +- A core read never admits a sibling tenant. It reads its node exactly, never + the subtree below it. +- With no core scopes configured, packs are unchanged byte for byte. +- Turns are always written at the agent's node. + +## Acceptance criteria + +- On the reference engine, a core section shows the company node's + learnings and never another team's. +- Over both CortexDB doubles, a core section recalls + `app:tinymemory/ws:acme/app:learnings`, and no scope below a sibling team. +- A `context.md` brief built from a core scope reads only that node.