Skip to main content

adk_agent/team/
blackboard.rs

1use std::collections::{BTreeSet, HashMap};
2use std::sync::{Arc, RwLock};
3
4use adk_core::{
5    Agent, Artifacts, CallbackContext, Content, EventStream, InvocationContext, Memory,
6    ReadonlyContext, Result, RunConfig, Session, State,
7};
8use async_trait::async_trait;
9use futures::StreamExt;
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12
13use super::{
14    EventDisposition, RelationshipKind, ResolvedTeamMember, TeamBudget, TeamEdgeStart, TeamError,
15    TeamRuntimeRegistry, TeamTerminationPolicy,
16};
17
18/// Portable, governed shared-transcript team definition.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
20#[serde(rename_all = "camelCase", deny_unknown_fields)]
21pub struct BlackboardSpec {
22    /// Executable root name.
23    pub name: String,
24    /// Human-readable purpose.
25    #[serde(default, skip_serializing_if = "String::is_empty")]
26    pub description: String,
27    /// Exact participant names. Order is meaningful for round-robin scheduling.
28    pub members: Vec<String>,
29    /// Speaker selection strategy.
30    pub schedule: BlackboardSchedule,
31    /// Exact permitted speaker transitions for model-selected scheduling.
32    #[serde(default, skip_serializing_if = "Vec::is_empty")]
33    pub transitions: Vec<BlackboardTransition>,
34    /// Bounded execution and transcript policy.
35    #[serde(default)]
36    pub policy: BlackboardPolicy,
37}
38
39/// Speaker scheduling for a blackboard team.
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
41#[serde(rename_all = "camelCase", tag = "strategy")]
42pub enum BlackboardSchedule {
43    /// Every member speaks once per round in declared order.
44    RoundRobin,
45    /// A designated member selects one permitted speaker per round by emitting
46    /// a normal agent transfer event. The transfer is consumed internally.
47    Selector {
48        /// Selector member name.
49        selector: String,
50    },
51}
52
53/// One exact permitted speaker transition.
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
55#[serde(rename_all = "camelCase", deny_unknown_fields)]
56pub struct BlackboardTransition {
57    /// Current speaker or selector.
58    pub from: String,
59    /// Next speaker.
60    pub to: String,
61}
62
63/// Bounded blackboard execution policy.
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
65#[serde(rename_all = "camelCase", deny_unknown_fields)]
66pub struct BlackboardPolicy {
67    /// Maximum complete scheduling rounds.
68    pub max_rounds: u32,
69    /// Maximum messages visible to each next speaker.
70    pub history: BlackboardHistoryPolicy,
71    /// Aggregate resource budget.
72    #[serde(default)]
73    pub budget: TeamBudget,
74    /// Clean termination conditions.
75    #[serde(default)]
76    pub termination: TeamTerminationPolicy,
77}
78
79impl Default for BlackboardPolicy {
80    fn default() -> Self {
81        Self {
82            max_rounds: 4,
83            history: BlackboardHistoryPolicy::Last { max_messages: 32 },
84            budget: TeamBudget { max_events: Some(128), ..TeamBudget::default() },
85            termination: TeamTerminationPolicy::default(),
86        }
87    }
88}
89
90/// Transcript projection visible to each blackboard speaker.
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
92#[serde(rename_all = "camelCase", tag = "mode")]
93pub enum BlackboardHistoryPolicy {
94    /// Broadcast the full transcript.
95    Full,
96    /// Broadcast only the most recent messages.
97    Last {
98        /// Maximum visible messages.
99        max_messages: usize,
100    },
101}
102
103impl BlackboardSpec {
104    /// Validates members, bounds, schedule, and exact transitions.
105    pub fn validate(&self) -> std::result::Result<(), TeamError> {
106        if self.name.trim().is_empty() {
107            return Err(TeamError::EmptyName { field: "blackboard.name" });
108        }
109        if self.members.is_empty() {
110            return Err(TeamError::InvalidPolicy("blackboard.members"));
111        }
112        if self.policy.max_rounds == 0 {
113            return Err(TeamError::InvalidPolicy("blackboard.maxRounds"));
114        }
115        if matches!(self.policy.history, BlackboardHistoryPolicy::Last { max_messages: 0 }) {
116            return Err(TeamError::InvalidPolicy("blackboard.history.maxMessages"));
117        }
118        let mut names = BTreeSet::new();
119        for member in &self.members {
120            if member.trim().is_empty() {
121                return Err(TeamError::EmptyName { field: "blackboard.member" });
122            }
123            if !names.insert(member.as_str()) {
124                return Err(TeamError::DuplicateMember(member.clone()));
125            }
126        }
127        if names.contains(self.name.as_str()) {
128            return Err(TeamError::TeamNameCollision(self.name.clone()));
129        }
130        if let BlackboardSchedule::Selector { selector } = &self.schedule {
131            if !names.contains(selector.as_str()) {
132                return Err(TeamError::UnknownCoordinator(selector.clone()));
133            }
134            if !self.transitions.iter().any(|transition| transition.from == *selector) {
135                return Err(TeamError::InvalidPolicy("blackboard.selectorTransitions"));
136            }
137        }
138        let mut transitions = BTreeSet::new();
139        for transition in &self.transitions {
140            if !names.contains(transition.from.as_str()) {
141                return Err(TeamError::UnknownRelationshipMember {
142                    endpoint: "source",
143                    name: transition.from.clone(),
144                });
145            }
146            if !names.contains(transition.to.as_str()) {
147                return Err(TeamError::UnknownRelationshipMember {
148                    endpoint: "target",
149                    name: transition.to.clone(),
150                });
151            }
152            if transition.from == transition.to {
153                return Err(TeamError::SelfRelationship(transition.from.clone()));
154            }
155            if !transitions.insert((transition.from.as_str(), transition.to.as_str())) {
156                return Err(TeamError::InvalidPolicy("blackboard.duplicateTransition"));
157            }
158        }
159        Ok(())
160    }
161
162    /// Binds exact member names and returns an executable blackboard root.
163    pub fn compile(
164        &self,
165        agents: impl IntoIterator<Item = Arc<dyn Agent>>,
166    ) -> std::result::Result<CompiledBlackboardTeam, TeamError> {
167        self.validate()?;
168        let declared: BTreeSet<&str> = self.members.iter().map(String::as_str).collect();
169        let mut registry = HashMap::new();
170        for agent in agents {
171            let name = agent.name().to_string();
172            if !declared.contains(name.as_str()) {
173                return Err(TeamError::UnexpectedAgentBinding(name));
174            }
175            if registry.insert(name.clone(), agent).is_some() {
176                return Err(TeamError::DuplicateAgentBinding(name));
177            }
178        }
179        let members: Vec<Arc<dyn Agent>> = self
180            .members
181            .iter()
182            .map(|name| {
183                registry.get(name).cloned().ok_or_else(|| TeamError::MissingAgent(name.clone()))
184            })
185            .collect::<std::result::Result<_, _>>()?;
186        let roster = self
187            .members
188            .iter()
189            .map(|name| ResolvedTeamMember {
190                member: name.clone(),
191                binding: name.clone(),
192                capabilities: Vec::new(),
193                version: None,
194                digest: None,
195                trust_labels: Vec::new(),
196            })
197            .collect();
198        Ok(CompiledBlackboardTeam {
199            spec: self.clone(),
200            name: self.name.clone(),
201            description: self.description.clone(),
202            members,
203            runtime: Arc::new(TeamRuntimeRegistry::new(
204                self.name.clone(),
205                roster,
206                self.policy.budget.clone(),
207                self.policy.termination.clone(),
208                Vec::new(),
209            )),
210        })
211    }
212}
213
214/// Executable blackboard/group-chat root.
215pub struct CompiledBlackboardTeam {
216    spec: BlackboardSpec,
217    name: String,
218    description: String,
219    members: Vec<Arc<dyn Agent>>,
220    runtime: Arc<TeamRuntimeRegistry>,
221}
222
223impl CompiledBlackboardTeam {
224    /// Returns the portable blackboard definition.
225    pub fn spec(&self) -> &BlackboardSpec {
226        &self.spec
227    }
228
229    /// Returns the latest serializable execution receipt.
230    pub fn execution_snapshot(&self, invocation_id: &str) -> Option<super::TeamExecutionSnapshot> {
231        self.runtime.snapshot(invocation_id)
232    }
233
234    /// Restores a persisted execution receipt for a matching team and roster.
235    pub fn restore_execution_snapshot(
236        &self,
237        snapshot: super::TeamExecutionSnapshot,
238    ) -> std::result::Result<(), TeamError> {
239        self.runtime.restore(snapshot)
240    }
241}
242
243impl std::fmt::Debug for CompiledBlackboardTeam {
244    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
245        formatter
246            .debug_struct("CompiledBlackboardTeam")
247            .field("name", &self.name)
248            .field("members", &self.spec.members)
249            .finish()
250    }
251}
252
253#[async_trait]
254impl Agent for CompiledBlackboardTeam {
255    fn name(&self) -> &str {
256        &self.name
257    }
258
259    fn description(&self) -> &str {
260        &self.description
261    }
262
263    fn sub_agents(&self) -> &[Arc<dyn Agent>] {
264        &self.members
265    }
266
267    fn supports_agent_transfer(&self) -> bool {
268        false
269    }
270
271    async fn run(&self, ctx: Arc<dyn InvocationContext>) -> Result<EventStream> {
272        let spec = self.spec.clone();
273        let members = self.members.clone();
274        let runtime = self.runtime.clone();
275        let root_invocation_id = ctx.orchestration_root_invocation_id().to_string();
276        runtime.check_budget(&root_invocation_id)?;
277        let stream = async_stream::stream! {
278            for _round in 0..spec.policy.max_rounds {
279                let scheduled: Vec<Arc<dyn Agent>> = match &spec.schedule {
280                    BlackboardSchedule::RoundRobin => members.clone(),
281                    BlackboardSchedule::Selector { selector } => {
282                        let Some(selector_agent) = members.iter().find(|member| member.name() == selector).cloned() else {
283                            yield Err(adk_core::AdkError::agent(format!("blackboard selector '{selector}' is unavailable")));
284                            return;
285                        };
286                        vec![selector_agent]
287                    }
288                };
289                let mut selected = None;
290                for speaker in scheduled {
291                    let projected = Arc::new(BlackboardContext::new(
292                        ctx.clone(),
293                        speaker.clone(),
294                        spec.policy.history,
295                    ));
296                    let mut events = match speaker.run(projected).await {
297                        Ok(events) => events,
298                        Err(error) => {
299                            runtime.fail(&root_invocation_id, error.to_string());
300                            yield Err(error);
301                            return;
302                        }
303                    };
304                    while let Some(result) = events.next().await {
305                        let mut event = match result {
306                            Ok(event) => event,
307                            Err(error) => {
308                                runtime.fail(&root_invocation_id, error.to_string());
309                                yield Err(error);
310                                return;
311                            }
312                        };
313                        if event.author.is_empty() {
314                            event.author = speaker.name().to_string();
315                        }
316                        if let Some(target) = event.actions.transfer_to_agent.take() {
317                            let allowed = spec.transitions.iter().any(|transition| {
318                                transition.from == speaker.name() && transition.to == target
319                            });
320                            if !allowed {
321                                let error = adk_core::AdkError::agent(format!(
322                                    "blackboard speaker '{}' cannot select '{}'; transition is not declared",
323                                    speaker.name(), target
324                                ));
325                                runtime.fail(&root_invocation_id, error.to_string());
326                                yield Err(error);
327                                return;
328                            }
329                            let edge_id = match runtime.start_edge(
330                                &root_invocation_id,
331                                TeamEdgeStart {
332                                    execution_id: None,
333                                    parent_id: ctx.orchestration_edge_id().map(str::to_string),
334                                    from: speaker.name(),
335                                    to: &target,
336                                    kind: RelationshipKind::Handoff,
337                                    attempt: 1,
338                                },
339                            ) {
340                                Ok(edge_id) => edge_id,
341                                Err(error) => {
342                                    yield Err(error);
343                                    return;
344                                }
345                            };
346                            runtime.finish_edge(&root_invocation_id, &edge_id, None);
347                            selected = Some(target);
348                        }
349                        match runtime.record_event(
350                            &root_invocation_id,
351                            ctx.orchestration_edge_id(),
352                            &mut event,
353                        ) {
354                            Ok(EventDisposition::Continue) => yield Ok(event),
355                            Ok(EventDisposition::Terminate) => {
356                                ctx.end_invocation();
357                                yield Ok(event);
358                                return;
359                            }
360                            Err(error) => {
361                                yield Err(error);
362                                return;
363                            }
364                        }
365                    }
366                }
367
368                if let BlackboardSchedule::Selector { selector } = &spec.schedule {
369                    let Some(target) = selected.take() else {
370                        let error = adk_core::AdkError::agent(format!(
371                            "blackboard selector '{selector}' did not select a permitted speaker"
372                        ));
373                        runtime.fail(&root_invocation_id, error.to_string());
374                        yield Err(error);
375                        return;
376                    };
377                    let Some(speaker) = members.iter().find(|member| member.name() == target).cloned() else {
378                        yield Err(adk_core::AdkError::agent(format!("selected blackboard speaker '{target}' is unavailable")));
379                        return;
380                    };
381                    let projected = Arc::new(BlackboardContext::new(
382                        ctx.clone(),
383                        speaker.clone(),
384                        spec.policy.history,
385                    ));
386                    let mut events = match speaker.run(projected).await {
387                        Ok(events) => events,
388                        Err(error) => {
389                            runtime.fail(&root_invocation_id, error.to_string());
390                            yield Err(error);
391                            return;
392                        }
393                    };
394                    while let Some(result) = events.next().await {
395                        let mut event = match result {
396                            Ok(event) => event,
397                            Err(error) => {
398                                runtime.fail(&root_invocation_id, error.to_string());
399                                yield Err(error);
400                                return;
401                            }
402                        };
403                        if event.author.is_empty() {
404                            event.author = speaker.name().to_string();
405                        }
406                        if event.actions.transfer_to_agent.is_some() {
407                            let error = adk_core::AdkError::agent(format!(
408                                "selected blackboard speaker '{}' cannot transfer during a selector-managed turn",
409                                speaker.name()
410                            ));
411                            runtime.fail(&root_invocation_id, error.to_string());
412                            yield Err(error);
413                            return;
414                        }
415                        match runtime.record_event(&root_invocation_id, None, &mut event) {
416                            Ok(EventDisposition::Continue) => yield Ok(event),
417                            Ok(EventDisposition::Terminate) => {
418                                ctx.end_invocation();
419                                yield Ok(event);
420                                return;
421                            }
422                            Err(error) => {
423                                yield Err(error);
424                                return;
425                            }
426                        }
427                    }
428                }
429            }
430        };
431        Ok(Box::pin(stream))
432    }
433}
434
435struct BlackboardContext {
436    inner: Arc<dyn InvocationContext>,
437    agent: Arc<dyn Agent>,
438    session: ProjectedSession,
439}
440
441impl BlackboardContext {
442    fn new(
443        inner: Arc<dyn InvocationContext>,
444        agent: Arc<dyn Agent>,
445        history_policy: BlackboardHistoryPolicy,
446    ) -> Self {
447        let mut history = inner.session().conversation_history();
448        if let BlackboardHistoryPolicy::Last { max_messages } = history_policy
449            && history.len() > max_messages
450        {
451            history.drain(..history.len() - max_messages);
452        }
453        Self {
454            session: ProjectedSession {
455                id: inner.session().id().to_string(),
456                app_name: inner.session().app_name().to_string(),
457                user_id: inner.session().user_id().to_string(),
458                state: SnapshotState::new(inner.session().state().all()),
459                history,
460            },
461            inner,
462            agent,
463        }
464    }
465}
466
467#[async_trait]
468impl ReadonlyContext for BlackboardContext {
469    fn invocation_id(&self) -> &str {
470        self.inner.invocation_id()
471    }
472    fn agent_name(&self) -> &str {
473        self.agent.name()
474    }
475    fn user_id(&self) -> &str {
476        self.inner.user_id()
477    }
478    fn app_name(&self) -> &str {
479        self.inner.app_name()
480    }
481    fn session_id(&self) -> &str {
482        self.inner.session_id()
483    }
484    fn branch(&self) -> &str {
485        self.inner.branch()
486    }
487    fn user_content(&self) -> &Content {
488        self.inner.user_content()
489    }
490}
491
492#[async_trait]
493impl CallbackContext for BlackboardContext {
494    fn artifacts(&self) -> Option<Arc<dyn Artifacts>> {
495        self.inner.artifacts()
496    }
497    fn tool_outcome(&self) -> Option<adk_core::ToolOutcome> {
498        self.inner.tool_outcome()
499    }
500    fn tool_name(&self) -> Option<&str> {
501        self.inner.tool_name()
502    }
503    fn tool_input(&self) -> Option<&serde_json::Value> {
504        self.inner.tool_input()
505    }
506    fn shared_state(&self) -> Option<Arc<adk_core::SharedState>> {
507        self.inner.shared_state()
508    }
509}
510
511#[async_trait]
512impl InvocationContext for BlackboardContext {
513    fn agent(&self) -> Arc<dyn Agent> {
514        self.agent.clone()
515    }
516    fn memory(&self) -> Option<Arc<dyn Memory>> {
517        self.inner.memory()
518    }
519    fn session(&self) -> &dyn Session {
520        &self.session
521    }
522    fn run_config(&self) -> &RunConfig {
523        self.inner.run_config()
524    }
525    fn end_invocation(&self) {
526        self.inner.end_invocation();
527    }
528    fn ended(&self) -> bool {
529        self.inner.ended()
530    }
531    fn is_cancelled(&self) -> bool {
532        self.inner.is_cancelled()
533    }
534    fn user_scopes(&self) -> Vec<String> {
535        self.inner.user_scopes()
536    }
537    fn request_metadata(&self) -> HashMap<String, serde_json::Value> {
538        self.inner.request_metadata()
539    }
540    fn authoritative_transfer_targets(&self) -> bool {
541        true
542    }
543    fn delegation_depth(&self) -> u32 {
544        self.inner.delegation_depth()
545    }
546    fn max_delegation_depth(&self) -> Option<u32> {
547        self.inner.max_delegation_depth()
548    }
549    fn orchestration_root_invocation_id(&self) -> &str {
550        self.inner.orchestration_root_invocation_id()
551    }
552    fn orchestration_edge_id(&self) -> Option<&str> {
553        self.inner.orchestration_edge_id()
554    }
555    fn requires_tool_confirmation(&self, tool_name: &str) -> bool {
556        self.inner.requires_tool_confirmation(tool_name)
557    }
558    async fn get_secret(&self, name: &str) -> Result<Option<String>> {
559        self.inner.get_secret(name).await
560    }
561    async fn get_secret_for(&self, request: &adk_core::SecretRequest) -> Result<Option<String>> {
562        self.inner.get_secret_for(request).await
563    }
564}
565
566struct ProjectedSession {
567    id: String,
568    app_name: String,
569    user_id: String,
570    state: SnapshotState,
571    history: Vec<Content>,
572}
573
574impl Session for ProjectedSession {
575    fn id(&self) -> &str {
576        &self.id
577    }
578    fn app_name(&self) -> &str {
579        &self.app_name
580    }
581    fn user_id(&self) -> &str {
582        &self.user_id
583    }
584    fn state(&self) -> &dyn State {
585        &self.state
586    }
587    fn conversation_history(&self) -> Vec<Content> {
588        self.history.clone()
589    }
590}
591
592struct SnapshotState(RwLock<HashMap<String, serde_json::Value>>);
593
594impl SnapshotState {
595    fn new(values: HashMap<String, serde_json::Value>) -> Self {
596        Self(RwLock::new(values))
597    }
598}
599
600impl State for SnapshotState {
601    fn get(&self, key: &str) -> Option<serde_json::Value> {
602        self.0.read().unwrap_or_else(|error| error.into_inner()).get(key).cloned()
603    }
604
605    fn set(&mut self, key: String, value: serde_json::Value) {
606        if adk_core::validate_state_key(&key).is_ok() {
607            self.0.write().unwrap_or_else(|error| error.into_inner()).insert(key, value);
608        }
609    }
610
611    fn all(&self) -> HashMap<String, serde_json::Value> {
612        self.0.read().unwrap_or_else(|error| error.into_inner()).clone()
613    }
614}
615
616#[cfg(test)]
617mod tests {
618    use super::*;
619    use adk_core::Event;
620    use std::sync::atomic::{AtomicUsize, Ordering};
621
622    struct EmptyState;
623
624    impl State for EmptyState {
625        fn get(&self, _key: &str) -> Option<serde_json::Value> {
626            None
627        }
628
629        fn set(&mut self, _key: String, _value: serde_json::Value) {}
630
631        fn all(&self) -> HashMap<String, serde_json::Value> {
632            HashMap::new()
633        }
634    }
635
636    struct TestSession;
637
638    impl Session for TestSession {
639        fn id(&self) -> &str {
640            "blackboard-session"
641        }
642
643        fn app_name(&self) -> &str {
644            "blackboard-app"
645        }
646
647        fn user_id(&self) -> &str {
648            "blackboard-user"
649        }
650
651        fn state(&self) -> &dyn State {
652            &EmptyState
653        }
654
655        fn conversation_history(&self) -> Vec<Content> {
656            (0..4).map(|index| Content::new("user").with_text(format!("message-{index}"))).collect()
657        }
658    }
659
660    struct TestContext {
661        content: Content,
662        config: RunConfig,
663        session: TestSession,
664    }
665
666    #[async_trait]
667    impl ReadonlyContext for TestContext {
668        fn invocation_id(&self) -> &str {
669            "blackboard-invocation"
670        }
671
672        fn agent_name(&self) -> &str {
673            "blackboard"
674        }
675
676        fn user_id(&self) -> &str {
677            "blackboard-user"
678        }
679
680        fn app_name(&self) -> &str {
681            "blackboard-app"
682        }
683
684        fn session_id(&self) -> &str {
685            "blackboard-session"
686        }
687
688        fn branch(&self) -> &str {
689            ""
690        }
691
692        fn user_content(&self) -> &Content {
693            &self.content
694        }
695    }
696
697    #[async_trait]
698    impl CallbackContext for TestContext {
699        fn artifacts(&self) -> Option<Arc<dyn Artifacts>> {
700            None
701        }
702    }
703
704    #[async_trait]
705    impl InvocationContext for TestContext {
706        fn agent(&self) -> Arc<dyn Agent> {
707            panic!("not used")
708        }
709
710        fn memory(&self) -> Option<Arc<dyn Memory>> {
711            None
712        }
713
714        fn session(&self) -> &dyn Session {
715            &self.session
716        }
717
718        fn run_config(&self) -> &RunConfig {
719            &self.config
720        }
721
722        fn end_invocation(&self) {}
723
724        fn ended(&self) -> bool {
725            false
726        }
727    }
728
729    struct Speaker {
730        name: String,
731        transfer: Option<String>,
732        expected_history: usize,
733        runs: Arc<AtomicUsize>,
734    }
735
736    #[async_trait]
737    impl Agent for Speaker {
738        fn name(&self) -> &str {
739            &self.name
740        }
741
742        fn description(&self) -> &str {
743            "deterministic blackboard speaker"
744        }
745
746        fn sub_agents(&self) -> &[Arc<dyn Agent>] {
747            &[]
748        }
749
750        async fn run(&self, ctx: Arc<dyn InvocationContext>) -> Result<EventStream> {
751            assert_eq!(ctx.session().conversation_history().len(), self.expected_history);
752            self.runs.fetch_add(1, Ordering::SeqCst);
753            let mut event = Event::new(ctx.invocation_id());
754            event.author = self.name.clone();
755            event.actions.transfer_to_agent.clone_from(&self.transfer);
756            event.llm_response.content = Some(Content::new("model").with_text(&self.name));
757            Ok(Box::pin(futures::stream::once(async { Ok(event) })))
758        }
759    }
760
761    fn context() -> Arc<TestContext> {
762        Arc::new(TestContext {
763            content: Content::new("user").with_text("discuss"),
764            config: RunConfig::default(),
765            session: TestSession,
766        })
767    }
768
769    #[tokio::test]
770    async fn round_robin_bounds_rounds_and_projects_history() {
771        let first_runs = Arc::new(AtomicUsize::new(0));
772        let second_runs = Arc::new(AtomicUsize::new(0));
773        let spec = BlackboardSpec {
774            name: "blackboard".to_string(),
775            description: String::new(),
776            members: vec!["first".to_string(), "second".to_string()],
777            schedule: BlackboardSchedule::RoundRobin,
778            transitions: Vec::new(),
779            policy: BlackboardPolicy {
780                max_rounds: 2,
781                history: BlackboardHistoryPolicy::Last { max_messages: 2 },
782                ..BlackboardPolicy::default()
783            },
784        };
785        let team = spec
786            .compile([
787                Arc::new(Speaker {
788                    name: "first".to_string(),
789                    transfer: None,
790                    expected_history: 2,
791                    runs: first_runs.clone(),
792                }) as Arc<dyn Agent>,
793                Arc::new(Speaker {
794                    name: "second".to_string(),
795                    transfer: None,
796                    expected_history: 2,
797                    runs: second_runs.clone(),
798                }),
799            ])
800            .unwrap();
801        let events = team.run(context()).await.unwrap().collect::<Vec<_>>().await;
802        assert_eq!(events.len(), 4);
803        assert_eq!(first_runs.load(Ordering::SeqCst), 2);
804        assert_eq!(second_runs.load(Ordering::SeqCst), 2);
805    }
806
807    #[tokio::test]
808    async fn selector_enforces_exact_transition_allowlist() {
809        let spec = BlackboardSpec {
810            name: "blackboard".to_string(),
811            description: String::new(),
812            members: vec!["selector".to_string(), "speaker".to_string()],
813            schedule: BlackboardSchedule::Selector { selector: "selector".to_string() },
814            transitions: vec![BlackboardTransition {
815                from: "selector".to_string(),
816                to: "speaker".to_string(),
817            }],
818            policy: BlackboardPolicy { max_rounds: 1, ..BlackboardPolicy::default() },
819        };
820        let team = spec
821            .compile([
822                Arc::new(Speaker {
823                    name: "selector".to_string(),
824                    transfer: Some("undeclared".to_string()),
825                    expected_history: 4,
826                    runs: Arc::new(AtomicUsize::new(0)),
827                }) as Arc<dyn Agent>,
828                Arc::new(Speaker {
829                    name: "speaker".to_string(),
830                    transfer: None,
831                    expected_history: 4,
832                    runs: Arc::new(AtomicUsize::new(0)),
833                }),
834            ])
835            .unwrap();
836        let events = team.run(context()).await.unwrap().collect::<Vec<_>>().await;
837        assert!(events[0].as_ref().unwrap_err().to_string().contains("not declared"));
838    }
839}