Skip to main content

ai_agents_runtime/spawner/
registry.rs

1//! Agent registry for tracking and messaging spawned agents.
2
3use std::collections::{HashMap, HashSet};
4use std::sync::Arc;
5
6use async_trait::async_trait;
7use chrono::{DateTime, Utc};
8use parking_lot::RwLock;
9use serde::{Deserialize, Serialize};
10use tracing::{debug, info, warn};
11
12use crate::spec::AgentSpec;
13use crate::{Agent, RuntimeAgent, TurnActorContext};
14use ai_agents_core::{AgentError, AgentResponse, Result};
15use ai_agents_observability::{current_observation_context, with_observation_context};
16
17use super::spawner::SpawnedAgent;
18
19/// Summary information for a registered agent, returned by `list()`.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct SpawnedAgentInfo {
22    pub id: String,
23    pub name: String,
24    pub spawned_at: DateTime<Utc>,
25}
26
27/// Tracks spawned agents and provides inter-agent messaging.
28pub struct AgentRegistry {
29    agents: RwLock<HashMap<String, Arc<SpawnedAgent>>>,
30    hooks: Option<Arc<dyn RegistryHooks>>,
31    /// When true, `send()` prefixes messages with `[From {sender}]: `.
32    send_with_context: bool,
33}
34
35impl AgentRegistry {
36    pub fn new() -> Self {
37        Self {
38            agents: RwLock::new(HashMap::new()),
39            hooks: None,
40            send_with_context: true,
41        }
42    }
43
44    /// Attach lifecycle hooks to the registry.
45    pub fn with_hooks(mut self, hooks: Arc<dyn RegistryHooks>) -> Self {
46        self.hooks = Some(hooks);
47        self
48    }
49
50    /// Configure whether `send()` injects sender identity into messages.
51    pub fn with_send_context(mut self, enabled: bool) -> Self {
52        self.send_with_context = enabled;
53        self
54    }
55
56    /// Register a spawned agent. Returns error if the ID already exists.
57    pub async fn register(&self, agent: SpawnedAgent) -> Result<()> {
58        self.register_batch(vec![agent]).await
59    }
60
61    /// Register all spawned agents atomically after checking every ID under one lock.
62    pub async fn register_batch(&self, batch: Vec<SpawnedAgent>) -> Result<()> {
63        let hook_entries = {
64            let mut agents = self.agents.write();
65            let mut batch_ids = HashSet::with_capacity(batch.len());
66
67            for agent in &batch {
68                if !batch_ids.insert(agent.id.clone()) {
69                    return Err(AgentError::Config(format!(
70                        "Duplicate agent ID in registration batch: {}",
71                        agent.id
72                    )));
73                }
74                if agents.contains_key(&agent.id) {
75                    return Err(AgentError::Config(format!(
76                        "Agent already registered: {}",
77                        agent.id
78                    )));
79                }
80            }
81
82            let hook_entries: Vec<(String, AgentSpec)> = batch
83                .iter()
84                .map(|agent| (agent.id.clone(), agent.spec.clone()))
85                .collect();
86            for agent in batch {
87                agents.insert(agent.id.clone(), Arc::new(agent));
88            }
89            hook_entries
90        };
91
92        for (id, spec) in hook_entries {
93            info!(agent_id = %id, "Agent registered in registry");
94            if let Some(ref hooks) = self.hooks {
95                hooks.on_agent_spawned(&id, &spec).await;
96            }
97        }
98        Ok(())
99    }
100
101    pub(crate) async fn reconcile(
102        &self,
103        target_ids: &HashSet<String>,
104        additions: Vec<SpawnedAgent>,
105    ) -> Result<()> {
106        let (removed, added_hooks) = {
107            let mut agents = self.agents.write();
108            let mut addition_ids = HashSet::with_capacity(additions.len());
109            for addition in &additions {
110                if !target_ids.contains(&addition.id) {
111                    return Err(AgentError::Config(format!(
112                        "Restored agent is absent from target topology: {}",
113                        addition.id
114                    )));
115                }
116                if !addition_ids.insert(addition.id.clone()) || agents.contains_key(&addition.id) {
117                    return Err(AgentError::Config(format!(
118                        "Agent already registered during topology restore: {}",
119                        addition.id
120                    )));
121                }
122            }
123            for id in target_ids {
124                if !agents.contains_key(id) && !addition_ids.contains(id) {
125                    return Err(AgentError::Config(format!(
126                        "Target topology has no retained or staged agent: {}",
127                        id
128                    )));
129                }
130            }
131
132            //
133            // Build the complete replacement map before swapping it so validation failures preserve the prior topology.
134            //
135            let mut next = agents.clone();
136            let removed_ids = next
137                .keys()
138                .filter(|id| !target_ids.contains(*id))
139                .cloned()
140                .collect::<Vec<_>>();
141            let removed = removed_ids
142                .into_iter()
143                .filter_map(|id| next.remove(&id))
144                .collect::<Vec<_>>();
145            let added_hooks = additions
146                .iter()
147                .map(|agent| (agent.id.clone(), agent.spec.clone()))
148                .collect::<Vec<_>>();
149            for addition in additions {
150                next.insert(addition.id.clone(), Arc::new(addition));
151            }
152            *agents = next;
153            (removed, added_hooks)
154        };
155
156        for agent in removed {
157            agent.release_capacity();
158            info!(agent_id = %agent.id, "Agent removed during topology restore");
159            if let Some(ref hooks) = self.hooks {
160                hooks.on_agent_removed(&agent.id).await;
161            }
162        }
163        for (id, spec) in added_hooks {
164            info!(agent_id = %id, "Agent registered during topology restore");
165            if let Some(ref hooks) = self.hooks {
166                hooks.on_agent_spawned(&id, &spec).await;
167            }
168        }
169        Ok(())
170    }
171
172    /// Clone an Arc handle to a registered agent's RuntimeAgent.
173    pub fn get(&self, id: &str) -> Option<Arc<RuntimeAgent>> {
174        let agents = self.agents.read();
175        agents.get(id).map(|sa| Arc::clone(&sa.agent))
176    }
177
178    /// Get the full SpawnedAgent metadata (agent + spec + timestamp).
179    pub fn get_spawned(&self, id: &str) -> Option<Arc<SpawnedAgent>> {
180        let agents = self.agents.read();
181        agents.get(id).cloned()
182    }
183
184    /// List metadata for all registered agents.
185    pub fn list(&self) -> Vec<SpawnedAgentInfo> {
186        let agents = self.agents.read();
187        agents
188            .values()
189            .map(|sa| SpawnedAgentInfo {
190                id: sa.id.clone(),
191                name: sa.spec.name.clone(),
192                spawned_at: sa.spawned_at,
193            })
194            .collect()
195    }
196
197    /// List all registered agents with their specs serialized as YAML for session persistence.
198    pub fn list_with_specs(&self) -> Vec<ai_agents_core::SpawnedAgentEntry> {
199        let agents = self.agents.read();
200        agents
201            .values()
202            .filter_map(|sa| {
203                let spec_yaml = match serde_yaml::to_string(&sa.spec) {
204                    Ok(y) => y,
205                    Err(e) => {
206                        warn!(agent_id = %sa.id, error = %e, "Failed to serialize agent spec");
207                        return None;
208                    }
209                };
210                Some(ai_agents_core::SpawnedAgentEntry {
211                    id: sa.id.clone(),
212                    name: sa.spec.name.clone(),
213                    spec_yaml,
214                })
215            })
216            .collect()
217    }
218
219    /// Remove an agent from the registry and return it.
220    pub async fn remove(&self, id: &str) -> Option<Arc<SpawnedAgent>> {
221        let removed = {
222            let mut agents = self.agents.write();
223            agents.remove(id)
224        };
225        if let Some(agent) = removed.as_ref() {
226            // Registry removal is the ownership boundary for an active slot. The reservation also releases on drop, so external Arc handles cannot double-decrement it.
227            agent.release_capacity();
228            info!(agent_id = %id, "Agent removed from registry");
229            if let Some(ref hooks) = self.hooks {
230                hooks.on_agent_removed(id).await;
231            }
232        } else {
233            debug!(agent_id = %id, "Attempted to remove non-existent agent");
234        }
235        removed
236    }
237
238    /// Send a message from one agent to another and return the response.
239    pub async fn send(&self, from: &str, to: &str, message: &str) -> Result<AgentResponse> {
240        self.send_inner(from, to, message, None).await
241    }
242
243    /// Send a message with structured actor context for actor-scoped memory.
244    pub async fn send_with_actor_context(
245        &self,
246        from: &str,
247        to: &str,
248        message: &str,
249        actor_context: TurnActorContext,
250    ) -> Result<AgentResponse> {
251        self.send_inner(from, to, message, Some(actor_context))
252            .await
253    }
254
255    async fn send_inner(
256        &self,
257        from: &str,
258        to: &str,
259        message: &str,
260        actor_context: Option<TurnActorContext>,
261    ) -> Result<AgentResponse> {
262        let target = {
263            // The read lock is held only long enough to clone the target Arc, then released before the async `chat()` call.
264            let agents = self.agents.read();
265            agents.get(to).map(|sa| Arc::clone(&sa.agent))
266        };
267        let target =
268            target.ok_or_else(|| AgentError::Other(format!("Target agent not found: {}", to)))?;
269
270        if let Some(ref hooks) = self.hooks {
271            hooks.on_message_sent(from, to, message).await;
272        }
273
274        let formatted = if self.send_with_context {
275            format!("[From {}]: {}", from, message)
276        } else {
277            message.to_string()
278        };
279
280        debug!(from = %from, to = %to, has_actor_context = actor_context.is_some(), "Sending inter-agent message");
281        if let Some(context) = actor_context {
282            target.chat_with_actor_context(&formatted, context).await
283        } else {
284            target.chat(&formatted).await
285        }
286    }
287
288    /// Broadcast a message to all agents except the sender.
289    ///
290    /// Clones all target Arcs under a single brief read lock, then drives all `chat()` calls concurrently after releasing the lock.
291    pub async fn broadcast(
292        &self,
293        from: &str,
294        message: &str,
295    ) -> Vec<(String, Result<AgentResponse>)> {
296        self.broadcast_inner(from, message, None).await
297    }
298
299    /// Broadcast a message with structured actor context for actor-scoped memory.
300    pub async fn broadcast_with_actor_context(
301        &self,
302        from: &str,
303        message: &str,
304        actor_context: TurnActorContext,
305    ) -> Vec<(String, Result<AgentResponse>)> {
306        self.broadcast_inner(from, message, Some(actor_context))
307            .await
308    }
309
310    async fn broadcast_inner(
311        &self,
312        from: &str,
313        message: &str,
314        actor_context: Option<TurnActorContext>,
315    ) -> Vec<(String, Result<AgentResponse>)> {
316        let targets: Vec<(String, Arc<RuntimeAgent>)> = {
317            let agents = self.agents.read();
318            agents
319                .iter()
320                .filter(|(id, _)| id.as_str() != from)
321                .map(|(id, sa)| (id.clone(), Arc::clone(&sa.agent)))
322                .collect()
323        };
324
325        if targets.is_empty() {
326            return Vec::new();
327        }
328
329        let formatted = if self.send_with_context {
330            format!("[From {}]: {}", from, message)
331        } else {
332            message.to_string()
333        };
334
335        debug!(
336            from = %from,
337            target_count = targets.len(),
338            has_actor_context = actor_context.is_some(),
339            "Broadcasting message"
340        );
341
342        let mut handles = Vec::with_capacity(targets.len());
343        let observation_context = current_observation_context();
344        for (id, agent) in targets {
345            let msg = formatted.clone();
346            let context = actor_context.clone();
347            let observation_context = observation_context.clone();
348            handles.push(tokio::spawn(async move {
349                let run = async move {
350                    if let Some(context) = context {
351                        agent.chat_with_actor_context(&msg, context).await
352                    } else {
353                        agent.chat(&msg).await
354                    }
355                };
356                let result = if let Some(context) = observation_context {
357                    with_observation_context(context, run).await
358                } else {
359                    run.await
360                };
361                (id, result)
362            }));
363        }
364
365        let mut results = Vec::new();
366        for handle in handles {
367            match handle.await {
368                Ok((id, res)) => results.push((id, res)),
369                Err(e) => {
370                    warn!(error = %e, "Broadcast task panicked");
371                }
372            }
373        }
374        results
375    }
376
377    /// Number of currently registered agents.
378    pub fn count(&self) -> usize {
379        self.agents.read().len()
380    }
381
382    /// Returns true if the registry contains an agent with this ID.
383    pub fn contains(&self, id: &str) -> bool {
384        self.agents.read().contains_key(id)
385    }
386}
387
388impl Default for AgentRegistry {
389    fn default() -> Self {
390        Self::new()
391    }
392}
393
394// Debug impl avoids printing agent internals.
395impl std::fmt::Debug for AgentRegistry {
396    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
397        let count = self.agents.read().len();
398        f.debug_struct("AgentRegistry")
399            .field("agent_count", &count)
400            .field("send_with_context", &self.send_with_context)
401            .field("has_hooks", &self.hooks.is_some())
402            .finish()
403    }
404}
405
406/// Optional lifecycle hooks for registry events.
407#[async_trait]
408pub trait RegistryHooks: Send + Sync {
409    /// Called after an agent is successfully registered.
410    async fn on_agent_spawned(&self, _id: &str, _spec: &AgentSpec) {}
411
412    /// Called after an agent is removed from the registry.
413    async fn on_agent_removed(&self, _id: &str) {}
414
415    /// Called before a message is delivered via `send()`.
416    async fn on_message_sent(&self, _from: &str, _to: &str, _message: &str) {}
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422    use crate::AgentBuilder;
423    use ai_agents_core::{
424        ChatMessage, FinishReason, LLMChunk, LLMConfig, LLMError, LLMFeature, LLMProvider,
425        LLMResponse,
426    };
427    use ai_agents_llm::LLMRegistry;
428    use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
429    use std::sync::{Mutex, Weak};
430
431    struct EchoProvider;
432
433    #[async_trait]
434    impl LLMProvider for EchoProvider {
435        async fn complete(
436            &self,
437            messages: &[ChatMessage],
438            _config: Option<&LLMConfig>,
439        ) -> std::result::Result<LLMResponse, LLMError> {
440            let last = messages
441                .last()
442                .map(|m| m.content.clone())
443                .unwrap_or_default();
444            Ok(LLMResponse::new(
445                format!("Echo: {}", last),
446                FinishReason::Stop,
447            ))
448        }
449
450        async fn complete_stream(
451            &self,
452            _messages: &[ChatMessage],
453            _config: Option<&LLMConfig>,
454        ) -> std::result::Result<
455            Box<dyn futures::Stream<Item = std::result::Result<LLMChunk, LLMError>> + Unpin + Send>,
456            LLMError,
457        > {
458            Err(LLMError::Other("not implemented".into()))
459        }
460
461        fn provider_name(&self) -> &str {
462            "echo"
463        }
464
465        fn supports(&self, _feature: LLMFeature) -> bool {
466            false
467        }
468    }
469
470    fn make_test_agent(name: &str) -> RuntimeAgent {
471        let mut registry = LLMRegistry::new();
472        registry.register("default", Arc::new(EchoProvider));
473
474        AgentBuilder::new()
475            .system_prompt(format!("You are {}.", name))
476            .llm_registry(registry)
477            .build()
478            .unwrap()
479    }
480
481    fn make_spawned(id: &str) -> SpawnedAgent {
482        let agent = make_test_agent(id);
483        SpawnedAgent::untracked(
484            id.to_string(),
485            agent,
486            AgentSpec {
487                name: id.to_string(),
488                ..AgentSpec::default()
489            },
490        )
491    }
492
493    #[tokio::test]
494    async fn test_register_and_get() {
495        let registry = AgentRegistry::new();
496        registry.register(make_spawned("agent_a")).await.unwrap();
497
498        assert!(registry.get("agent_a").is_some());
499        assert!(registry.get("agent_b").is_none());
500        assert_eq!(registry.count(), 1);
501    }
502
503    #[tokio::test]
504    async fn test_duplicate_register() {
505        let registry = AgentRegistry::new();
506        registry.register(make_spawned("dup")).await.unwrap();
507        let result = registry.register(make_spawned("dup")).await;
508        assert!(result.is_err());
509    }
510
511    #[tokio::test]
512    async fn test_register_batch_inserts_all_agents() {
513        let registry = AgentRegistry::new();
514        registry
515            .register_batch(vec![make_spawned("a"), make_spawned("b")])
516            .await
517            .unwrap();
518
519        assert!(registry.contains("a"));
520        assert!(registry.contains("b"));
521        assert_eq!(registry.count(), 2);
522    }
523
524    #[tokio::test]
525    async fn test_register_batch_rejects_duplicate_ids_without_inserting() {
526        let registry = AgentRegistry::new();
527        let result = registry
528            .register_batch(vec![make_spawned("dup"), make_spawned("dup")])
529            .await;
530
531        assert!(result.is_err());
532        assert!(!registry.contains("dup"));
533        assert_eq!(registry.count(), 0);
534    }
535
536    #[tokio::test]
537    async fn test_register_batch_rejects_existing_collision_without_inserting() {
538        let registry = AgentRegistry::new();
539        registry.register(make_spawned("existing")).await.unwrap();
540
541        let result = registry
542            .register_batch(vec![make_spawned("new"), make_spawned("existing")])
543            .await;
544
545        assert!(result.is_err());
546        assert!(!registry.contains("new"));
547        assert_eq!(registry.count(), 1);
548    }
549
550    #[tokio::test]
551    async fn reconcile_commits_additions_retained_agents_and_removals_together() {
552        let registry = AgentRegistry::new();
553        registry
554            .register_batch(vec![make_spawned("a"), make_spawned("b")])
555            .await
556            .unwrap();
557        let retained = registry.get("b").unwrap();
558
559        registry
560            .reconcile(
561                &HashSet::from(["b".to_string(), "c".to_string()]),
562                vec![make_spawned("c")],
563            )
564            .await
565            .unwrap();
566
567        assert!(!registry.contains("a"));
568        assert!(Arc::ptr_eq(&registry.get("b").unwrap(), &retained));
569        assert!(registry.contains("c"));
570        assert_eq!(registry.count(), 2);
571
572        registry
573            .reconcile(&HashSet::new(), Vec::new())
574            .await
575            .unwrap();
576        assert_eq!(registry.count(), 0);
577    }
578
579    #[tokio::test]
580    async fn reconcile_validation_failure_preserves_prior_topology() {
581        let registry = AgentRegistry::new();
582        registry
583            .register_batch(vec![make_spawned("a"), make_spawned("b")])
584            .await
585            .unwrap();
586        let before_a = registry.get("a").unwrap();
587        let before_b = registry.get("b").unwrap();
588
589        let result = registry
590            .reconcile(
591                &HashSet::from(["a".to_string(), "missing".to_string()]),
592                Vec::new(),
593            )
594            .await;
595
596        assert!(result.is_err());
597        assert!(Arc::ptr_eq(&registry.get("a").unwrap(), &before_a));
598        assert!(Arc::ptr_eq(&registry.get("b").unwrap(), &before_b));
599        assert_eq!(registry.count(), 2);
600    }
601
602    #[tokio::test]
603    async fn test_list_and_remove() {
604        let registry = AgentRegistry::new();
605        registry.register(make_spawned("a")).await.unwrap();
606        registry.register(make_spawned("b")).await.unwrap();
607
608        assert_eq!(registry.list().len(), 2);
609
610        let removed = registry.remove("a").await;
611        assert!(removed.is_some());
612        assert_eq!(registry.count(), 1);
613        assert!(registry.get("a").is_none());
614    }
615
616    #[tokio::test]
617    async fn test_send_agent_message() {
618        let registry = AgentRegistry::new();
619        registry.register(make_spawned("sender")).await.unwrap();
620        registry.register(make_spawned("receiver")).await.unwrap();
621
622        let response = registry.send("sender", "receiver", "hello").await.unwrap();
623        assert!(response.content.contains("hello"));
624    }
625
626    #[tokio::test]
627    async fn test_send_to_missing() {
628        let registry = AgentRegistry::new();
629        registry.register(make_spawned("sender")).await.unwrap();
630
631        let result = registry.send("sender", "nobody", "hello").await;
632        assert!(result.is_err());
633    }
634
635    #[tokio::test]
636    async fn test_broadcast() {
637        let registry = AgentRegistry::new();
638        registry
639            .register(make_spawned("broadcaster"))
640            .await
641            .unwrap();
642        registry.register(make_spawned("listener_1")).await.unwrap();
643        registry.register(make_spawned("listener_2")).await.unwrap();
644
645        let results = registry.broadcast("broadcaster", "hey everyone").await;
646        // Should have 2 results (excluding broadcaster)
647        assert_eq!(results.len(), 2);
648        for (_, res) in &results {
649            assert!(res.is_ok());
650        }
651    }
652
653    #[tokio::test]
654    async fn test_batch_hooks_run_only_after_successful_commit() {
655        struct CommitObservingHooks {
656            registry: Mutex<Option<Weak<AgentRegistry>>>,
657            spawned: AtomicU32,
658            observed_full_batch: AtomicBool,
659        }
660
661        #[async_trait]
662        impl RegistryHooks for CommitObservingHooks {
663            async fn on_agent_spawned(&self, _id: &str, _spec: &AgentSpec) {
664                self.spawned.fetch_add(1, Ordering::Relaxed);
665                let registry = self
666                    .registry
667                    .lock()
668                    .unwrap()
669                    .as_ref()
670                    .unwrap()
671                    .upgrade()
672                    .unwrap();
673                if registry.contains("a") && registry.contains("b") {
674                    self.observed_full_batch.store(true, Ordering::Relaxed);
675                }
676            }
677        }
678
679        let hooks = Arc::new(CommitObservingHooks {
680            registry: Mutex::new(None),
681            spawned: AtomicU32::new(0),
682            observed_full_batch: AtomicBool::new(false),
683        });
684        let registry = Arc::new(AgentRegistry::new().with_hooks(hooks.clone()));
685        *hooks.registry.lock().unwrap() = Some(Arc::downgrade(&registry));
686
687        assert!(
688            registry
689                .register_batch(vec![make_spawned("dup"), make_spawned("dup")])
690                .await
691                .is_err()
692        );
693        assert_eq!(hooks.spawned.load(Ordering::Relaxed), 0);
694
695        registry
696            .register_batch(vec![make_spawned("a"), make_spawned("b")])
697            .await
698            .unwrap();
699        assert_eq!(registry.count(), 2);
700        assert_eq!(hooks.spawned.load(Ordering::Relaxed), 2);
701        assert!(hooks.observed_full_batch.load(Ordering::Relaxed));
702    }
703
704    #[tokio::test]
705    async fn test_hooks() {
706        struct CountingHooks {
707            spawned: AtomicU32,
708            removed: AtomicU32,
709            sent: AtomicU32,
710        }
711
712        #[async_trait]
713        impl RegistryHooks for CountingHooks {
714            async fn on_agent_spawned(&self, _id: &str, _spec: &AgentSpec) {
715                self.spawned.fetch_add(1, Ordering::Relaxed);
716            }
717            async fn on_agent_removed(&self, _id: &str) {
718                self.removed.fetch_add(1, Ordering::Relaxed);
719            }
720            async fn on_message_sent(&self, _from: &str, _to: &str, _msg: &str) {
721                self.sent.fetch_add(1, Ordering::Relaxed);
722            }
723        }
724
725        let hooks = Arc::new(CountingHooks {
726            spawned: AtomicU32::new(0),
727            removed: AtomicU32::new(0),
728            sent: AtomicU32::new(0),
729        });
730
731        let registry = AgentRegistry::new().with_hooks(hooks.clone());
732        registry.register(make_spawned("h1")).await.unwrap();
733        registry.register(make_spawned("h2")).await.unwrap();
734        assert_eq!(hooks.spawned.load(Ordering::Relaxed), 2);
735
736        registry.send("h1", "h2", "ping").await.unwrap();
737        assert_eq!(hooks.sent.load(Ordering::Relaxed), 1);
738
739        registry.remove("h1").await;
740        assert_eq!(hooks.removed.load(Ordering::Relaxed), 1);
741    }
742
743    #[tokio::test]
744    async fn test_contains() {
745        let registry = AgentRegistry::new();
746        assert!(!registry.contains("x"));
747        registry.register(make_spawned("x")).await.unwrap();
748        assert!(registry.contains("x"));
749    }
750
751    #[tokio::test]
752    async fn test_send_without_context() {
753        let registry = AgentRegistry::new().with_send_context(false);
754        registry.register(make_spawned("a")).await.unwrap();
755        registry.register(make_spawned("b")).await.unwrap();
756
757        let response = registry.send("a", "b", "raw msg").await.unwrap();
758        // Without context prefix, the message should be passed as-is
759        assert!(response.content.contains("raw msg"));
760        assert!(!response.content.contains("[From"));
761    }
762}