meerkat-mobkit 0.7.31

Companion orchestration platform for the Meerkat multi-agent runtime
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
//! Classic-mob agent memory (docs/design/agent-memory-architecture.md §8.2,
//! §9.1) without the identity-first orchestration layer.
//!
//! Memory is keyed by [`AgentIdentity`], which every mob member already has —
//! it does not require the continuity/roster machinery. This module carries
//! the BASIC memory surface onto the classic mob path through meerkat-mob's
//! pre-build seam ([`meerkat_mob::SpawnMemberCustomizer`], applied to every
//! member spawn including resume restores): the Recorder `memory` tool and
//! the echo-safe build-time injection block. The ADVANCED lifecycle features
//! (distill-on-respawn/reset/retire, exit interviews, per-turn ambient
//! injection) stay bound to `IdentityRuntime`.
//!
//! Per-turn injection on the classic path is a deliberate no-op: the P0.1
//! echo-safety default is `AgentMemoryPerTurnInjection::Off`, and the classic
//! send path has no injection hook yet. A classic-mob per-turn hook is a
//! scoped follow-up (see the design doc §9.1); until then `budgeted` only
//! takes effect on identity-first members.

use std::collections::BTreeSet;
use std::sync::Arc;

use meerkat_mob::{
    MobError, SpawnCustomizationContext, SpawnMemberCustomizer, SpawnMemberSpec, ids::MobId,
};

use crate::identity_first::AgentIdentity;
use crate::identity_first::agent_memory::{
    AgentMemoryConfig, AgentMemoryError, AgentMemoryPerTurnInjection, AgentMemoryProvider,
    MemoryRecorder, RECORDER_PROTOCOL_INSTRUCTIONS, RecorderToolDispatcher, compact_whitespace,
    insert_terms,
};
use crate::memory::coordinator::RecallCoordinator;

/// Per-spawn memory customizer for classic (roster-less) mobs. One instance
/// serves the whole mob runtime; recorder dispatchers are re-created per
/// spawn, so the tool surface stays restore-safe exactly like the
/// identity-first `customize_build` path.
pub struct MemorySpawnCustomizer {
    coordinator: RecallCoordinator,
}

impl MemorySpawnCustomizer {
    pub fn new(provider: Arc<dyn AgentMemoryProvider>, config: AgentMemoryConfig) -> Self {
        if config.per_turn_injection == AgentMemoryPerTurnInjection::Budgeted {
            // `Budgeted` is now the platform default (ask 1 made ambient
            // injection echo-safe), so this is no longer a user misconfig —
            // it is an as-designed limitation of the classic path, which
            // still injects at build time only (§9.1). Debug, not warn, to
            // avoid spamming every roster-less mob that just takes defaults.
            tracing::debug!(
                "agent_memory.per_turn_injection = budgeted: ambient per-turn injection is \
                 identity-first-only for now; the classic mob path injects at build time only \
                 (the recorder tool and build-time injection are unaffected)"
            );
        }
        Self {
            coordinator: RecallCoordinator::new(provider, config),
        }
    }

    /// The customizer body, factored off the trait so tests can drive it:
    /// `SpawnCustomizationContext` is `#[non_exhaustive]` and only meerkat-mob
    /// constructs it.
    fn apply(
        &self,
        mob_id: &MobId,
        spawner_identity: Option<&meerkat_mob::ids::AgentIdentity>,
        spec: &mut SpawnMemberSpec,
    ) -> Result<(), MobError> {
        // Memory scope keys pin to the PUBLIC member alias — the identity
        // space the console, panel filters, and persisted records speak —
        // not the comms-safe roster encoding meerkat-mob spawns with
        // (`agent:mem` arrives here as `mk--agent_cmem`). A member alias
        // that fails validation gets no memory surface — loudly, never
        // silently — instead of blocking the spawn.
        let alias = crate::member_comms_id::runtime_alias_str(spec.identity.as_str());
        let identity = match AgentIdentity::parse(alias.as_ref()) {
            Ok(identity) => identity,
            Err(err) => {
                tracing::warn!(
                    identity = %spec.identity,
                    error = %err,
                    "agent memory skipped for member: identity fails memory-scope validation"
                );
                return Ok(());
            }
        };

        let injection = block_on_build_injection(
            &self.coordinator,
            &identity,
            spawn_query_text(mob_id, &identity, spec),
            spawn_query_terms(mob_id, &identity, spawner_identity, spec),
        )
        .map_err(|err| {
            MobError::Internal(format!(
                "agent memory build injection failed for '{}': {err}",
                identity.as_str()
            ))
        })?;
        if let Some(injection) = injection
            && !injection.is_empty()
        {
            spec.additional_instructions
                .get_or_insert_with(Vec::new)
                .push(injection);
        }

        // §8.2 Recorder: same capability gate as the identity-first path.
        // The dispatcher composes over any per-spawn external-tool overlay
        // already on the spec; meerkat-mob then composes the result with
        // profile bundles and mob-wide defaults (profile tools win name
        // collisions).
        let config = self.coordinator.config();
        let provider = self.coordinator.provider();
        if config.recorder_tool && provider.supports_authored_writes() {
            let recorder =
                MemoryRecorder::new(provider, config, identity, Some(mob_id.to_string()));
            let inner = spec.external_tools.take();
            spec.external_tools = Some(Arc::new(RecorderToolDispatcher::new(inner, recorder)));
            spec.additional_instructions
                .get_or_insert_with(Vec::new)
                .push(RECORDER_PROTOCOL_INSTRUCTIONS.to_string());
        }
        Ok(())
    }
}

impl SpawnMemberCustomizer for MemorySpawnCustomizer {
    fn customize_spawn(
        &self,
        ctx: &SpawnCustomizationContext,
        spec: &mut SpawnMemberSpec,
    ) -> Result<(), MobError> {
        self.apply(&ctx.mob_id, ctx.spawner_identity.as_ref(), spec)
    }
}

/// Run the async build-injection assembly from meerkat-mob's synchronous
/// `customize_spawn` seam. A dedicated thread with its own current-thread
/// runtime keeps this correct on any caller runtime flavor (no
/// `block_in_place` panic on current-thread runtimes, and the coordinator's
/// internal `tokio::time::timeout`s get a live timer driver). Bounded by the
/// coordinator's own recall budget (2× `recall_timeout_ms` for the selector
/// stage), so a spawn never hangs on memory.
fn block_on_build_injection(
    coordinator: &RecallCoordinator,
    identity: &AgentIdentity,
    query_text: Option<String>,
    query_terms: Vec<String>,
) -> Result<Option<String>, AgentMemoryError> {
    std::thread::scope(|scope| {
        scope
            .spawn(|| {
                let runtime = tokio::runtime::Builder::new_current_thread()
                    .enable_all()
                    .build()
                    .map_err(|err| {
                        AgentMemoryError::Io(format!(
                            "memory recall runtime failed to start: {err}"
                        ))
                    })?;
                runtime.block_on(coordinator.assemble_build_injection(
                    identity,
                    query_text,
                    query_terms,
                ))
            })
            .join()
            .unwrap_or_else(|_| {
                Err(AgentMemoryError::Io(
                    "memory build-injection assembly panicked".to_string(),
                ))
            })
    })
}

/// Classic-path counterpart of the identity-first `build_query_text`: the
/// spawn spec has no continuity context (no active peers or managed edges),
/// so the query composes identity + profile + labels + mob.
fn spawn_query_text(
    mob_id: &MobId,
    identity: &AgentIdentity,
    spec: &SpawnMemberSpec,
) -> Option<String> {
    let mut parts = vec![
        format!("identity {}", identity.as_str()),
        format!("profile {}", spec.role_name),
        format!("mob {mob_id}"),
    ];
    if let Some(labels) = spec.labels.as_ref() {
        for (key, value) in labels {
            parts.push(format!("label {key} {value}"));
        }
    }
    let text = compact_whitespace(&parts.join(" "));
    (!text.is_empty()).then_some(text)
}

fn spawn_query_terms(
    mob_id: &MobId,
    identity: &AgentIdentity,
    spawner_identity: Option<&meerkat_mob::ids::AgentIdentity>,
    spec: &SpawnMemberSpec,
) -> Vec<String> {
    let mut terms = BTreeSet::new();
    insert_terms(&mut terms, identity.as_str());
    insert_terms(&mut terms, spec.role_name.as_str());
    insert_terms(&mut terms, mob_id.as_str());
    if let Some(spawner) = spawner_identity {
        insert_terms(&mut terms, spawner.as_str());
    }
    if let Some(labels) = spec.labels.as_ref() {
        for (key, value) in labels {
            insert_terms(&mut terms, key);
            insert_terms(&mut terms, value);
        }
    }
    terms.into_iter().collect()
}

#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
    use super::*;
    use crate::memory::records::MemoryAuthor;
    use crate::memory::sqlite_store::SqliteAgentMemoryStore;
    use meerkat_core::agent::AgentToolDispatcher;
    use meerkat_mob::ProfileName;
    use meerkat_mob::ids::AgentIdentity as MobIdentity;

    fn sqlite_store(dir: &std::path::Path) -> Arc<SqliteAgentMemoryStore> {
        Arc::new(SqliteAgentMemoryStore::open(dir).expect("open sqlite store"))
    }

    fn spec_for(identity: &str) -> SpawnMemberSpec {
        SpawnMemberSpec::new(ProfileName::from("worker"), MobIdentity::from(identity))
    }

    async fn seed_record(store: &SqliteAgentMemoryStore, identity: &str, title: &str, body: &str) {
        let scope = crate::memory::records::MemoryScope::Identity {
            realm: "default".to_string(),
            identity: identity.to_string(),
        };
        store
            .remember_authored(
                &scope,
                crate::memory::records::NewMemoryRecord {
                    kind: crate::memory::records::MemoryKind::Fact,
                    title: title.to_string(),
                    description: title.to_string(),
                    body: body.to_string(),
                    tags: vec![],
                    evidence: vec![],
                    verification: None,
                },
                MemoryAuthor::Operator,
            )
            .await
            .expect("seed memory record");
    }

    #[test]
    fn registers_recorder_tool_and_protocol_on_spawn_spec() {
        let dir = tempfile::tempdir().expect("temp dir");
        let store = sqlite_store(dir.path());
        let customizer = MemorySpawnCustomizer::new(store, AgentMemoryConfig::default());

        let mut spec = spec_for("agent:mem");
        customizer
            .apply(&MobId::from("test-mob"), None, &mut spec)
            .expect("apply succeeds");

        let tools = spec.external_tools.as_ref().expect("recorder registered");
        assert!(
            tools
                .tools()
                .iter()
                .any(|tool| tool.name.as_ref() == crate::identity_first::MEMORY_TOOL_NAME),
            "memory tool must be registered on the spawn spec"
        );
        let instructions = spec.additional_instructions.as_ref().expect("instructions");
        assert!(
            instructions
                .iter()
                .any(|section| section.contains("Memory recorder protocol")),
            "recorder protocol instructions must be injected: {instructions:#?}"
        );
    }

    #[tokio::test]
    async fn injects_build_time_memory_block_for_seeded_identity() {
        let dir = tempfile::tempdir().expect("temp dir");
        let store = sqlite_store(dir.path());
        seed_record(
            &store,
            "agent:mem",
            "Deploy window",
            "Deploys are frozen on Fridays.",
        )
        .await;
        let customizer = MemorySpawnCustomizer::new(
            store.clone(),
            AgentMemoryConfig {
                selection: crate::identity_first::AgentMemorySelection::Always,
                ..AgentMemoryConfig::default()
            },
        );

        // Spawn specs arrive with the comms-safe roster encoding; memory
        // must key on the decoded public alias (`agent:mem`).
        let mut spec = spec_for(crate::member_comms_id::mob_member_id_str("agent:mem").as_ref());
        assert_eq!(spec.identity.as_str(), "mk--agent_cmem");
        customizer
            .apply(&MobId::from("test-mob"), None, &mut spec)
            .expect("apply succeeds");

        let instructions = spec.additional_instructions.as_ref().expect("instructions");
        assert!(
            instructions
                .iter()
                .any(|section| section.contains("Deploys are frozen on Fridays.")),
            "build-time injection must carry the seeded record body: {instructions:#?}"
        );
    }

    #[test]
    fn recorder_composes_over_existing_per_spawn_overlay() {
        struct EchoDispatcher;
        #[async_trait::async_trait]
        impl AgentToolDispatcher for EchoDispatcher {
            fn tools(&self) -> Arc<[Arc<meerkat_core::ToolDef>]> {
                vec![Arc::new(meerkat_core::ToolDef {
                    name: "echo".into(),
                    description: "echo".to_string(),
                    input_schema: serde_json::json!({"type": "object"}),
                    provenance: None,
                })]
                .into()
            }
            async fn dispatch(
                &self,
                call: meerkat_core::types::ToolCallView<'_>,
            ) -> Result<meerkat_core::ops::ToolDispatchOutcome, meerkat_core::error::ToolError>
            {
                Ok(meerkat_core::ToolResult {
                    tool_use_id: call.id.to_string(),
                    content: vec![],
                    is_error: false,
                }
                .into())
            }
        }

        let dir = tempfile::tempdir().expect("temp dir");
        let store = sqlite_store(dir.path());
        let customizer = MemorySpawnCustomizer::new(store, AgentMemoryConfig::default());

        let mut spec = spec_for("agent:mem");
        spec.external_tools = Some(Arc::new(EchoDispatcher));
        customizer
            .apply(&MobId::from("test-mob"), None, &mut spec)
            .expect("apply succeeds");

        let tools = spec.external_tools.as_ref().expect("dispatcher present");
        let names: Vec<String> = tools
            .tools()
            .iter()
            .map(|tool| tool.name.to_string())
            .collect();
        assert!(names.contains(&"echo".to_string()), "{names:?}");
        assert!(names.contains(&"memory".to_string()), "{names:?}");
    }

    #[test]
    fn recorder_skipped_when_disabled_or_provider_read_only() {
        let dir = tempfile::tempdir().expect("temp dir");
        let store = sqlite_store(dir.path());
        let customizer = MemorySpawnCustomizer::new(
            store,
            AgentMemoryConfig {
                recorder_tool: false,
                ..AgentMemoryConfig::default()
            },
        );
        let mut spec = spec_for("agent:mem");
        customizer
            .apply(&MobId::from("test-mob"), None, &mut spec)
            .expect("apply succeeds");
        assert!(spec.external_tools.is_none(), "recorder_tool=false");

        // Markdown store: no authored-write support, so injection-only.
        let md_dir = tempfile::tempdir().expect("temp dir");
        let markdown = Arc::new(
            crate::identity_first::MarkdownAgentMemoryStore::open(md_dir.path())
                .expect("markdown store"),
        );
        let customizer = MemorySpawnCustomizer::new(markdown, AgentMemoryConfig::default());
        let mut spec = spec_for("agent:mem");
        customizer
            .apply(&MobId::from("test-mob"), None, &mut spec)
            .expect("apply succeeds");
        assert!(
            spec.external_tools.is_none(),
            "read-only provider must not register the recorder"
        );
    }

    #[test]
    fn invalid_memory_identity_skips_without_failing_spawn() {
        let dir = tempfile::tempdir().expect("temp dir");
        let store = sqlite_store(dir.path());
        let customizer = MemorySpawnCustomizer::new(store, AgentMemoryConfig::default());

        // Whitespace fails the memory-scope identity validation.
        let mut spec = spec_for("agent with spaces");
        customizer
            .apply(&MobId::from("test-mob"), None, &mut spec)
            .expect("apply must not fail the spawn");
        assert!(spec.external_tools.is_none());
        assert!(spec.additional_instructions.is_none());
    }
}