Skip to main content

meerkat_mobkit/memory/
spawn_customizer.rs

1//! Classic-mob agent memory (docs/design/agent-memory-architecture.md §8.2,
2//! §9.1) without the identity-first orchestration layer.
3//!
4//! Memory is keyed by [`AgentIdentity`], which every mob member already has —
5//! it does not require the continuity/roster machinery. This module carries
6//! the BASIC memory surface onto the classic mob path through meerkat-mob's
7//! pre-build seam ([`meerkat_mob::SpawnMemberCustomizer`], applied to every
8//! member spawn including resume restores): the Recorder `memory` tool and
9//! the echo-safe build-time injection block. The ADVANCED lifecycle features
10//! (distill-on-respawn/reset/retire, exit interviews, per-turn ambient
11//! injection) stay bound to `IdentityRuntime`.
12//!
13//! Per-turn injection on the classic path is a deliberate no-op: the P0.1
14//! echo-safety default is `AgentMemoryPerTurnInjection::Off`, and the classic
15//! send path has no injection hook yet. A classic-mob per-turn hook is a
16//! scoped follow-up (see the design doc §9.1); until then `budgeted` only
17//! takes effect on identity-first members.
18
19use std::collections::BTreeSet;
20use std::sync::Arc;
21
22use meerkat_mob::{
23    MobError, SpawnCustomizationContext, SpawnMemberCustomizer, SpawnMemberSpec, ids::MobId,
24};
25
26use crate::identity_first::AgentIdentity;
27use crate::identity_first::agent_memory::{
28    AgentMemoryConfig, AgentMemoryError, AgentMemoryPerTurnInjection, AgentMemoryProvider,
29    MemoryRecorder, RECORDER_PROTOCOL_INSTRUCTIONS, RecorderToolDispatcher, compact_whitespace,
30    insert_terms,
31};
32use crate::memory::coordinator::RecallCoordinator;
33
34/// Per-spawn memory customizer for classic (roster-less) mobs. One instance
35/// serves the whole mob runtime; recorder dispatchers are re-created per
36/// spawn, so the tool surface stays restore-safe exactly like the
37/// identity-first `customize_build` path.
38pub struct MemorySpawnCustomizer {
39    coordinator: RecallCoordinator,
40}
41
42impl MemorySpawnCustomizer {
43    pub fn new(provider: Arc<dyn AgentMemoryProvider>, config: AgentMemoryConfig) -> Self {
44        if config.per_turn_injection == AgentMemoryPerTurnInjection::Budgeted {
45            // `Budgeted` is now the platform default (ask 1 made ambient
46            // injection echo-safe), so this is no longer a user misconfig —
47            // it is an as-designed limitation of the classic path, which
48            // still injects at build time only (§9.1). Debug, not warn, to
49            // avoid spamming every roster-less mob that just takes defaults.
50            tracing::debug!(
51                "agent_memory.per_turn_injection = budgeted: ambient per-turn injection is \
52                 identity-first-only for now; the classic mob path injects at build time only \
53                 (the recorder tool and build-time injection are unaffected)"
54            );
55        }
56        Self {
57            coordinator: RecallCoordinator::new(provider, config),
58        }
59    }
60
61    /// The customizer body, factored off the trait so tests can drive it:
62    /// `SpawnCustomizationContext` is `#[non_exhaustive]` and only meerkat-mob
63    /// constructs it.
64    fn apply(
65        &self,
66        mob_id: &MobId,
67        spawner_identity: Option<&meerkat_mob::ids::AgentIdentity>,
68        spec: &mut SpawnMemberSpec,
69    ) -> Result<(), MobError> {
70        // Memory scope keys pin to the PUBLIC member alias — the identity
71        // space the console, panel filters, and persisted records speak —
72        // not the comms-safe roster encoding meerkat-mob spawns with
73        // (`agent:mem` arrives here as `mk--agent_cmem`). A member alias
74        // that fails validation gets no memory surface — loudly, never
75        // silently — instead of blocking the spawn.
76        let alias = crate::member_comms_id::runtime_alias_str(spec.identity.as_str());
77        let identity = match AgentIdentity::parse(alias.as_ref()) {
78            Ok(identity) => identity,
79            Err(err) => {
80                tracing::warn!(
81                    identity = %spec.identity,
82                    error = %err,
83                    "agent memory skipped for member: identity fails memory-scope validation"
84                );
85                return Ok(());
86            }
87        };
88
89        let injection = block_on_build_injection(
90            &self.coordinator,
91            &identity,
92            spawn_query_text(mob_id, &identity, spec),
93            spawn_query_terms(mob_id, &identity, spawner_identity, spec),
94        )
95        .map_err(|err| {
96            MobError::Internal(format!(
97                "agent memory build injection failed for '{}': {err}",
98                identity.as_str()
99            ))
100        })?;
101        if let Some(injection) = injection
102            && !injection.is_empty()
103        {
104            spec.additional_instructions
105                .get_or_insert_with(Vec::new)
106                .push(injection);
107        }
108
109        // §8.2 Recorder: same capability gate as the identity-first path.
110        // The dispatcher composes over any per-spawn external-tool overlay
111        // already on the spec; meerkat-mob then composes the result with
112        // profile bundles and mob-wide defaults (profile tools win name
113        // collisions).
114        let config = self.coordinator.config();
115        let provider = self.coordinator.provider();
116        if config.recorder_tool && provider.supports_authored_writes() {
117            let recorder =
118                MemoryRecorder::new(provider, config, identity, Some(mob_id.to_string()));
119            let inner = spec.external_tools.take();
120            spec.external_tools = Some(Arc::new(RecorderToolDispatcher::new(inner, recorder)));
121            spec.additional_instructions
122                .get_or_insert_with(Vec::new)
123                .push(RECORDER_PROTOCOL_INSTRUCTIONS.to_string());
124        }
125        Ok(())
126    }
127}
128
129impl SpawnMemberCustomizer for MemorySpawnCustomizer {
130    fn customize_spawn(
131        &self,
132        ctx: &SpawnCustomizationContext,
133        spec: &mut SpawnMemberSpec,
134    ) -> Result<(), MobError> {
135        self.apply(&ctx.mob_id, ctx.spawner_identity.as_ref(), spec)
136    }
137}
138
139/// Run the async build-injection assembly from meerkat-mob's synchronous
140/// `customize_spawn` seam. A dedicated thread with its own current-thread
141/// runtime keeps this correct on any caller runtime flavor (no
142/// `block_in_place` panic on current-thread runtimes, and the coordinator's
143/// internal `tokio::time::timeout`s get a live timer driver). Bounded by the
144/// coordinator's own recall budget (2× `recall_timeout_ms` for the selector
145/// stage), so a spawn never hangs on memory.
146fn block_on_build_injection(
147    coordinator: &RecallCoordinator,
148    identity: &AgentIdentity,
149    query_text: Option<String>,
150    query_terms: Vec<String>,
151) -> Result<Option<String>, AgentMemoryError> {
152    std::thread::scope(|scope| {
153        scope
154            .spawn(|| {
155                let runtime = tokio::runtime::Builder::new_current_thread()
156                    .enable_all()
157                    .build()
158                    .map_err(|err| {
159                        AgentMemoryError::Io(format!(
160                            "memory recall runtime failed to start: {err}"
161                        ))
162                    })?;
163                runtime.block_on(coordinator.assemble_build_injection(
164                    identity,
165                    query_text,
166                    query_terms,
167                ))
168            })
169            .join()
170            .unwrap_or_else(|_| {
171                Err(AgentMemoryError::Io(
172                    "memory build-injection assembly panicked".to_string(),
173                ))
174            })
175    })
176}
177
178/// Classic-path counterpart of the identity-first `build_query_text`: the
179/// spawn spec has no continuity context (no active peers or managed edges),
180/// so the query composes identity + profile + labels + mob.
181fn spawn_query_text(
182    mob_id: &MobId,
183    identity: &AgentIdentity,
184    spec: &SpawnMemberSpec,
185) -> Option<String> {
186    let mut parts = vec![
187        format!("identity {}", identity.as_str()),
188        format!("profile {}", spec.role_name),
189        format!("mob {mob_id}"),
190    ];
191    if let Some(labels) = spec.labels.as_ref() {
192        for (key, value) in labels {
193            parts.push(format!("label {key} {value}"));
194        }
195    }
196    let text = compact_whitespace(&parts.join(" "));
197    (!text.is_empty()).then_some(text)
198}
199
200fn spawn_query_terms(
201    mob_id: &MobId,
202    identity: &AgentIdentity,
203    spawner_identity: Option<&meerkat_mob::ids::AgentIdentity>,
204    spec: &SpawnMemberSpec,
205) -> Vec<String> {
206    let mut terms = BTreeSet::new();
207    insert_terms(&mut terms, identity.as_str());
208    insert_terms(&mut terms, spec.role_name.as_str());
209    insert_terms(&mut terms, mob_id.as_str());
210    if let Some(spawner) = spawner_identity {
211        insert_terms(&mut terms, spawner.as_str());
212    }
213    if let Some(labels) = spec.labels.as_ref() {
214        for (key, value) in labels {
215            insert_terms(&mut terms, key);
216            insert_terms(&mut terms, value);
217        }
218    }
219    terms.into_iter().collect()
220}
221
222#[cfg(test)]
223#[allow(clippy::expect_used, clippy::unwrap_used)]
224mod tests {
225    use super::*;
226    use crate::memory::records::MemoryAuthor;
227    use crate::memory::sqlite_store::SqliteAgentMemoryStore;
228    use meerkat_core::agent::AgentToolDispatcher;
229    use meerkat_mob::ProfileName;
230    use meerkat_mob::ids::AgentIdentity as MobIdentity;
231
232    fn sqlite_store(dir: &std::path::Path) -> Arc<SqliteAgentMemoryStore> {
233        Arc::new(SqliteAgentMemoryStore::open(dir).expect("open sqlite store"))
234    }
235
236    fn spec_for(identity: &str) -> SpawnMemberSpec {
237        SpawnMemberSpec::new(ProfileName::from("worker"), MobIdentity::from(identity))
238    }
239
240    async fn seed_record(store: &SqliteAgentMemoryStore, identity: &str, title: &str, body: &str) {
241        let scope = crate::memory::records::MemoryScope::Identity {
242            realm: "default".to_string(),
243            identity: identity.to_string(),
244        };
245        store
246            .remember_authored(
247                &scope,
248                crate::memory::records::NewMemoryRecord {
249                    kind: crate::memory::records::MemoryKind::Fact,
250                    title: title.to_string(),
251                    description: title.to_string(),
252                    body: body.to_string(),
253                    tags: vec![],
254                    evidence: vec![],
255                    verification: None,
256                },
257                MemoryAuthor::Operator,
258            )
259            .await
260            .expect("seed memory record");
261    }
262
263    #[test]
264    fn registers_recorder_tool_and_protocol_on_spawn_spec() {
265        let dir = tempfile::tempdir().expect("temp dir");
266        let store = sqlite_store(dir.path());
267        let customizer = MemorySpawnCustomizer::new(store, AgentMemoryConfig::default());
268
269        let mut spec = spec_for("agent:mem");
270        customizer
271            .apply(&MobId::from("test-mob"), None, &mut spec)
272            .expect("apply succeeds");
273
274        let tools = spec.external_tools.as_ref().expect("recorder registered");
275        assert!(
276            tools
277                .tools()
278                .iter()
279                .any(|tool| tool.name.as_ref() == crate::identity_first::MEMORY_TOOL_NAME),
280            "memory tool must be registered on the spawn spec"
281        );
282        let instructions = spec.additional_instructions.as_ref().expect("instructions");
283        assert!(
284            instructions
285                .iter()
286                .any(|section| section.contains("Memory recorder protocol")),
287            "recorder protocol instructions must be injected: {instructions:#?}"
288        );
289    }
290
291    #[tokio::test]
292    async fn injects_build_time_memory_block_for_seeded_identity() {
293        let dir = tempfile::tempdir().expect("temp dir");
294        let store = sqlite_store(dir.path());
295        seed_record(
296            &store,
297            "agent:mem",
298            "Deploy window",
299            "Deploys are frozen on Fridays.",
300        )
301        .await;
302        let customizer = MemorySpawnCustomizer::new(
303            store.clone(),
304            AgentMemoryConfig {
305                selection: crate::identity_first::AgentMemorySelection::Always,
306                ..AgentMemoryConfig::default()
307            },
308        );
309
310        // Spawn specs arrive with the comms-safe roster encoding; memory
311        // must key on the decoded public alias (`agent:mem`).
312        let mut spec = spec_for(crate::member_comms_id::mob_member_id_str("agent:mem").as_ref());
313        assert_eq!(spec.identity.as_str(), "mk--agent_cmem");
314        customizer
315            .apply(&MobId::from("test-mob"), None, &mut spec)
316            .expect("apply succeeds");
317
318        let instructions = spec.additional_instructions.as_ref().expect("instructions");
319        assert!(
320            instructions
321                .iter()
322                .any(|section| section.contains("Deploys are frozen on Fridays.")),
323            "build-time injection must carry the seeded record body: {instructions:#?}"
324        );
325    }
326
327    #[test]
328    fn recorder_composes_over_existing_per_spawn_overlay() {
329        struct EchoDispatcher;
330        #[async_trait::async_trait]
331        impl AgentToolDispatcher for EchoDispatcher {
332            fn tools(&self) -> Arc<[Arc<meerkat_core::ToolDef>]> {
333                vec![Arc::new(meerkat_core::ToolDef {
334                    name: "echo".into(),
335                    description: "echo".to_string(),
336                    input_schema: serde_json::json!({"type": "object"}),
337                    provenance: None,
338                })]
339                .into()
340            }
341            async fn dispatch(
342                &self,
343                call: meerkat_core::types::ToolCallView<'_>,
344            ) -> Result<meerkat_core::ops::ToolDispatchOutcome, meerkat_core::error::ToolError>
345            {
346                Ok(meerkat_core::ToolResult {
347                    tool_use_id: call.id.to_string(),
348                    content: vec![],
349                    is_error: false,
350                }
351                .into())
352            }
353        }
354
355        let dir = tempfile::tempdir().expect("temp dir");
356        let store = sqlite_store(dir.path());
357        let customizer = MemorySpawnCustomizer::new(store, AgentMemoryConfig::default());
358
359        let mut spec = spec_for("agent:mem");
360        spec.external_tools = Some(Arc::new(EchoDispatcher));
361        customizer
362            .apply(&MobId::from("test-mob"), None, &mut spec)
363            .expect("apply succeeds");
364
365        let tools = spec.external_tools.as_ref().expect("dispatcher present");
366        let names: Vec<String> = tools
367            .tools()
368            .iter()
369            .map(|tool| tool.name.to_string())
370            .collect();
371        assert!(names.contains(&"echo".to_string()), "{names:?}");
372        assert!(names.contains(&"memory".to_string()), "{names:?}");
373    }
374
375    #[test]
376    fn recorder_skipped_when_disabled_or_provider_read_only() {
377        let dir = tempfile::tempdir().expect("temp dir");
378        let store = sqlite_store(dir.path());
379        let customizer = MemorySpawnCustomizer::new(
380            store,
381            AgentMemoryConfig {
382                recorder_tool: false,
383                ..AgentMemoryConfig::default()
384            },
385        );
386        let mut spec = spec_for("agent:mem");
387        customizer
388            .apply(&MobId::from("test-mob"), None, &mut spec)
389            .expect("apply succeeds");
390        assert!(spec.external_tools.is_none(), "recorder_tool=false");
391
392        // Markdown store: no authored-write support, so injection-only.
393        let md_dir = tempfile::tempdir().expect("temp dir");
394        let markdown = Arc::new(
395            crate::identity_first::MarkdownAgentMemoryStore::open(md_dir.path())
396                .expect("markdown store"),
397        );
398        let customizer = MemorySpawnCustomizer::new(markdown, AgentMemoryConfig::default());
399        let mut spec = spec_for("agent:mem");
400        customizer
401            .apply(&MobId::from("test-mob"), None, &mut spec)
402            .expect("apply succeeds");
403        assert!(
404            spec.external_tools.is_none(),
405            "read-only provider must not register the recorder"
406        );
407    }
408
409    #[test]
410    fn invalid_memory_identity_skips_without_failing_spawn() {
411        let dir = tempfile::tempdir().expect("temp dir");
412        let store = sqlite_store(dir.path());
413        let customizer = MemorySpawnCustomizer::new(store, AgentMemoryConfig::default());
414
415        // Whitespace fails the memory-scope identity validation.
416        let mut spec = spec_for("agent with spaces");
417        customizer
418            .apply(&MobId::from("test-mob"), None, &mut spec)
419            .expect("apply must not fail the spawn");
420        assert!(spec.external_tools.is_none());
421        assert!(spec.additional_instructions.is_none());
422    }
423}