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