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 LOGICAL identity (task #53) - the
71        // identity space the console, panel filters, persisted records, and
72        // the SDK agent_memory surface speak - never the comms-safe roster
73        // encoding meerkat-mob spawns with (`agent:mem` arrives here as
74        // `mk--agent_cmem`) and never a generated runtime alias
75        // (`rt:{identity}:{generation}` strips to the durable identity, so
76        // respawn generations share one scope). A member alias that fails
77        // validation gets no memory surface - loudly, never silently -
78        // instead of blocking the spawn.
79        let alias = crate::member_comms_id::logical_memory_identity(spec.identity.as_str());
80        let identity = match AgentIdentity::parse(&alias) {
81            Ok(identity) => identity,
82            Err(err) => {
83                tracing::warn!(
84                    identity = %spec.identity,
85                    error = %err,
86                    "agent memory skipped for member: identity fails memory-scope validation"
87                );
88                return Ok(());
89            }
90        };
91
92        let injection = block_on_build_injection(
93            &self.coordinator,
94            &identity,
95            spawn_query_text(mob_id, &identity, spec),
96            spawn_query_terms(mob_id, &identity, spawner_identity, spec),
97        )
98        .map_err(|err| {
99            MobError::Internal(format!(
100                "agent memory build injection failed for '{}': {err}",
101                identity.as_str()
102            ))
103        })?;
104        if let Some(injection) = injection
105            && !injection.is_empty()
106        {
107            spec.additional_instructions
108                .get_or_insert_with(Vec::new)
109                .push(injection);
110        }
111
112        // §8.2 Recorder: same capability gate as the identity-first path.
113        // The dispatcher composes over any per-spawn external-tool overlay
114        // already on the spec; meerkat-mob then composes the result with
115        // profile bundles and mob-wide defaults (profile tools win name
116        // collisions).
117        let config = self.coordinator.config();
118        let provider = self.coordinator.provider();
119        if config.recorder_tool && provider.supports_authored_writes() {
120            let recorder =
121                MemoryRecorder::new(provider, config, identity, Some(mob_id.to_string()));
122            let inner = spec.external_tools.take();
123            spec.external_tools = Some(Arc::new(RecorderToolDispatcher::new(inner, recorder)));
124            spec.additional_instructions
125                .get_or_insert_with(Vec::new)
126                .push(RECORDER_PROTOCOL_INSTRUCTIONS.to_string());
127        }
128        Ok(())
129    }
130}
131
132impl SpawnMemberCustomizer for MemorySpawnCustomizer {
133    fn customize_spawn(
134        &self,
135        ctx: &SpawnCustomizationContext,
136        spec: &mut SpawnMemberSpec,
137    ) -> Result<(), MobError> {
138        self.apply(&ctx.mob_id, ctx.spawner_identity.as_ref(), spec)
139    }
140}
141
142/// Run the async build-injection assembly from meerkat-mob's synchronous
143/// `customize_spawn` seam. A dedicated thread with its own current-thread
144/// runtime keeps this correct on any caller runtime flavor (no
145/// `block_in_place` panic on current-thread runtimes, and the coordinator's
146/// internal `tokio::time::timeout`s get a live timer driver). Bounded by the
147/// coordinator's own recall budget (2× `recall_timeout_ms` for the selector
148/// stage), so a spawn never hangs on memory.
149fn block_on_build_injection(
150    coordinator: &RecallCoordinator,
151    identity: &AgentIdentity,
152    query_text: Option<String>,
153    query_terms: Vec<String>,
154) -> Result<Option<String>, AgentMemoryError> {
155    std::thread::scope(|scope| {
156        scope
157            .spawn(|| {
158                let runtime = tokio::runtime::Builder::new_current_thread()
159                    .enable_all()
160                    .build()
161                    .map_err(|err| {
162                        AgentMemoryError::Io(format!(
163                            "memory recall runtime failed to start: {err}"
164                        ))
165                    })?;
166                runtime.block_on(coordinator.assemble_build_injection(
167                    identity,
168                    query_text,
169                    query_terms,
170                ))
171            })
172            .join()
173            .unwrap_or_else(|_| {
174                Err(AgentMemoryError::Io(
175                    "memory build-injection assembly panicked".to_string(),
176                ))
177            })
178    })
179}
180
181/// Classic-path counterpart of the identity-first `build_query_text`: the
182/// spawn spec has no continuity context (no active peers or managed edges),
183/// so the query composes identity + profile + labels + mob.
184fn spawn_query_text(
185    mob_id: &MobId,
186    identity: &AgentIdentity,
187    spec: &SpawnMemberSpec,
188) -> Option<String> {
189    let mut parts = vec![
190        format!("identity {}", identity.as_str()),
191        format!("profile {}", spec.role_name),
192        format!("mob {mob_id}"),
193    ];
194    if let Some(labels) = spec.labels.as_ref() {
195        for (key, value) in labels {
196            parts.push(format!("label {key} {value}"));
197        }
198    }
199    let text = compact_whitespace(&parts.join(" "));
200    (!text.is_empty()).then_some(text)
201}
202
203fn spawn_query_terms(
204    mob_id: &MobId,
205    identity: &AgentIdentity,
206    spawner_identity: Option<&meerkat_mob::ids::AgentIdentity>,
207    spec: &SpawnMemberSpec,
208) -> Vec<String> {
209    let mut terms = BTreeSet::new();
210    insert_terms(&mut terms, identity.as_str());
211    insert_terms(&mut terms, spec.role_name.as_str());
212    insert_terms(&mut terms, mob_id.as_str());
213    if let Some(spawner) = spawner_identity {
214        insert_terms(&mut terms, spawner.as_str());
215    }
216    if let Some(labels) = spec.labels.as_ref() {
217        for (key, value) in labels {
218            insert_terms(&mut terms, key);
219            insert_terms(&mut terms, value);
220        }
221    }
222    terms.into_iter().collect()
223}
224
225#[cfg(test)]
226#[allow(clippy::expect_used, clippy::unwrap_used)]
227mod tests {
228    use super::*;
229    use crate::memory::records::MemoryAuthor;
230    use crate::memory::sqlite_store::SqliteAgentMemoryStore;
231    use meerkat_core::agent::AgentToolDispatcher;
232    use meerkat_mob::ProfileName;
233    use meerkat_mob::ids::AgentIdentity as MobIdentity;
234
235    fn sqlite_store(dir: &std::path::Path) -> Arc<SqliteAgentMemoryStore> {
236        Arc::new(SqliteAgentMemoryStore::open(dir).expect("open sqlite store"))
237    }
238
239    fn spec_for(identity: &str) -> SpawnMemberSpec {
240        SpawnMemberSpec::new(ProfileName::from("worker"), MobIdentity::from(identity))
241    }
242
243    async fn seed_record(store: &SqliteAgentMemoryStore, identity: &str, title: &str, body: &str) {
244        let scope = crate::memory::records::MemoryScope::Identity {
245            realm: "default".to_string(),
246            identity: identity.to_string(),
247        };
248        store
249            .remember_authored(
250                &scope,
251                crate::memory::records::NewMemoryRecord {
252                    kind: crate::memory::records::MemoryKind::Fact,
253                    title: title.to_string(),
254                    description: title.to_string(),
255                    body: body.to_string(),
256                    tags: vec![],
257                    evidence: vec![],
258                    verification: None,
259                },
260                MemoryAuthor::Operator,
261            )
262            .await
263            .expect("seed memory record");
264    }
265
266    #[test]
267    fn registers_recorder_tool_and_protocol_on_spawn_spec() {
268        let dir = tempfile::tempdir().expect("temp dir");
269        let store = sqlite_store(dir.path());
270        let customizer = MemorySpawnCustomizer::new(store, AgentMemoryConfig::default());
271
272        let mut spec = spec_for("agent:mem");
273        customizer
274            .apply(&MobId::from("test-mob"), None, &mut spec)
275            .expect("apply succeeds");
276
277        let tools = spec.external_tools.as_ref().expect("recorder registered");
278        assert!(
279            tools
280                .tools()
281                .iter()
282                .any(|tool| tool.name.as_ref() == crate::identity_first::MEMORY_TOOL_NAME),
283            "memory tool must be registered on the spawn spec"
284        );
285        let instructions = spec.additional_instructions.as_ref().expect("instructions");
286        assert!(
287            instructions
288                .iter()
289                .any(|section| section.contains("Memory recorder protocol")),
290            "recorder protocol instructions must be injected: {instructions:#?}"
291        );
292    }
293
294    #[tokio::test]
295    async fn injects_build_time_memory_block_for_seeded_identity() {
296        let dir = tempfile::tempdir().expect("temp dir");
297        let store = sqlite_store(dir.path());
298        seed_record(
299            &store,
300            "agent:mem",
301            "Deploy window",
302            "Deploys are frozen on Fridays.",
303        )
304        .await;
305        let customizer = MemorySpawnCustomizer::new(
306            store.clone(),
307            AgentMemoryConfig {
308                selection: crate::identity_first::AgentMemorySelection::Always,
309                ..AgentMemoryConfig::default()
310            },
311        );
312
313        // Spawn specs arrive with the comms-safe roster encoding; memory
314        // must key on the decoded public alias (`agent:mem`).
315        let mut spec = spec_for(crate::member_comms_id::mob_member_id_str("agent:mem").as_ref());
316        assert_eq!(spec.identity.as_str(), "mk--agent_cmem");
317        customizer
318            .apply(&MobId::from("test-mob"), None, &mut spec)
319            .expect("apply succeeds");
320
321        let instructions = spec.additional_instructions.as_ref().expect("instructions");
322        assert!(
323            instructions
324                .iter()
325                .any(|section| section.contains("Deploys are frozen on Fridays.")),
326            "build-time injection must carry the seeded record body: {instructions:#?}"
327        );
328    }
329
330    #[test]
331    fn recorder_composes_over_existing_per_spawn_overlay() {
332        struct EchoDispatcher;
333        #[async_trait::async_trait]
334        impl AgentToolDispatcher for EchoDispatcher {
335            fn tools(&self) -> Arc<[Arc<meerkat_core::ToolDef>]> {
336                vec![Arc::new(meerkat_core::ToolDef {
337                    name: "echo".into(),
338                    description: "echo".to_string(),
339                    input_schema: serde_json::json!({"type": "object"}),
340                    provenance: None,
341                })]
342                .into()
343            }
344            async fn dispatch(
345                &self,
346                call: meerkat_core::types::ToolCallView<'_>,
347            ) -> Result<meerkat_core::ops::ToolDispatchOutcome, meerkat_core::error::ToolError>
348            {
349                Ok(meerkat_core::ToolResult {
350                    tool_use_id: call.id.to_string(),
351                    content: vec![],
352                    is_error: false,
353                }
354                .into())
355            }
356        }
357
358        let dir = tempfile::tempdir().expect("temp dir");
359        let store = sqlite_store(dir.path());
360        let customizer = MemorySpawnCustomizer::new(store, AgentMemoryConfig::default());
361
362        let mut spec = spec_for("agent:mem");
363        spec.external_tools = Some(Arc::new(EchoDispatcher));
364        customizer
365            .apply(&MobId::from("test-mob"), None, &mut spec)
366            .expect("apply succeeds");
367
368        let tools = spec.external_tools.as_ref().expect("dispatcher present");
369        let names: Vec<String> = tools
370            .tools()
371            .iter()
372            .map(|tool| tool.name.to_string())
373            .collect();
374        assert!(names.contains(&"echo".to_string()), "{names:?}");
375        assert!(names.contains(&"memory".to_string()), "{names:?}");
376    }
377
378    #[test]
379    fn recorder_skipped_when_disabled_or_provider_read_only() {
380        let dir = tempfile::tempdir().expect("temp dir");
381        let store = sqlite_store(dir.path());
382        let customizer = MemorySpawnCustomizer::new(
383            store,
384            AgentMemoryConfig {
385                recorder_tool: false,
386                ..AgentMemoryConfig::default()
387            },
388        );
389        let mut spec = spec_for("agent:mem");
390        customizer
391            .apply(&MobId::from("test-mob"), None, &mut spec)
392            .expect("apply succeeds");
393        assert!(spec.external_tools.is_none(), "recorder_tool=false");
394
395        // Markdown store: no authored-write support, so injection-only.
396        let md_dir = tempfile::tempdir().expect("temp dir");
397        let markdown = Arc::new(
398            crate::identity_first::MarkdownAgentMemoryStore::open(md_dir.path())
399                .expect("markdown store"),
400        );
401        let customizer = MemorySpawnCustomizer::new(markdown, AgentMemoryConfig::default());
402        let mut spec = spec_for("agent:mem");
403        customizer
404            .apply(&MobId::from("test-mob"), None, &mut spec)
405            .expect("apply succeeds");
406        assert!(
407            spec.external_tools.is_none(),
408            "read-only provider must not register the recorder"
409        );
410    }
411
412    #[test]
413    fn invalid_memory_identity_skips_without_failing_spawn() {
414        let dir = tempfile::tempdir().expect("temp dir");
415        let store = sqlite_store(dir.path());
416        let customizer = MemorySpawnCustomizer::new(store, AgentMemoryConfig::default());
417
418        // Whitespace fails the memory-scope identity validation.
419        let mut spec = spec_for("agent with spaces");
420        customizer
421            .apply(&MobId::from("test-mob"), None, &mut spec)
422            .expect("apply must not fail the spawn");
423        assert!(spec.external_tools.is_none());
424        assert!(spec.additional_instructions.is_none());
425    }
426}