1use 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
34pub 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 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 fn apply(
65 &self,
66 mob_id: &MobId,
67 spawner_identity: Option<&meerkat_mob::ids::AgentIdentity>,
68 spec: &mut SpawnMemberSpec,
69 ) -> Result<(), MobError> {
70 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 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
142fn 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
181fn 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 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 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 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}