1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct SpawnedAgentInfo {
23 pub id: String,
24 pub name: String,
25 pub spawned_at: DateTime<Utc>,
26}
27
28pub struct AgentRegistry {
30 agents: RwLock<HashMap<String, Arc<SpawnedAgent>>>,
31 hooks: Option<Arc<dyn RegistryHooks>>,
32 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 pub fn with_hooks(mut self, hooks: Arc<dyn RegistryHooks>) -> Self {
47 self.hooks = Some(hooks);
48 self
49 }
50
51 pub fn with_send_context(mut self, enabled: bool) -> Self {
53 self.send_with_context = enabled;
54 self
55 }
56
57 pub async fn register(&self, agent: SpawnedAgent) -> Result<()> {
59 self.register_batch(vec![agent]).await
60 }
61
62 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 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 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 pub fn get_spawned(&self, id: &str) -> Option<Arc<SpawnedAgent>> {
181 let agents = self.agents.read();
182 agents.get(id).cloned()
183 }
184
185 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 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 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 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 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 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 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 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 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 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 pub fn count(&self) -> usize {
388 self.agents.read().len()
389 }
390
391 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
403impl 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#[async_trait]
417pub trait RegistryHooks: Send + Sync {
418 async fn on_agent_spawned(&self, _id: &str, _spec: &AgentSpec) {}
420
421 async fn on_agent_removed(&self, _id: &str) {}
423
424 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 type RecordedBroadcastResults = Vec<(String, std::result::Result<String, String>)>;
445
446 struct BroadcastResponseHooks {
448 registry: Weak<AgentRegistry>,
449 invoked: AtomicBool,
450 results: Mutex<Option<RecordedBroadcastResults>>,
451 }
452
453 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 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 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(®istry.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(®istry.get("a").unwrap(), &before_a));
677 assert!(Arc::ptr_eq(®istry.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 assert_eq!(results.len(), 2);
727 for (_, res) in &results {
728 assert!(res.is_ok());
729 }
730 }
731
732 #[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(®istry),
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(®istry));
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 assert!(response.content.contains("raw msg"));
926 assert!(!response.content.contains("[From"));
927 }
928}