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::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 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
139fn 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
178fn 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 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 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 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}