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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
20#[serde(rename_all = "camelCase", deny_unknown_fields)]
21pub struct BlackboardSpec {
22 pub name: String,
24 #[serde(default, skip_serializing_if = "String::is_empty")]
26 pub description: String,
27 pub members: Vec<String>,
29 pub schedule: BlackboardSchedule,
31 #[serde(default, skip_serializing_if = "Vec::is_empty")]
33 pub transitions: Vec<BlackboardTransition>,
34 #[serde(default)]
36 pub policy: BlackboardPolicy,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
41#[serde(rename_all = "camelCase", tag = "strategy")]
42pub enum BlackboardSchedule {
43 RoundRobin,
45 Selector {
48 selector: String,
50 },
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
55#[serde(rename_all = "camelCase", deny_unknown_fields)]
56pub struct BlackboardTransition {
57 pub from: String,
59 pub to: String,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
65#[serde(rename_all = "camelCase", deny_unknown_fields)]
66pub struct BlackboardPolicy {
67 pub max_rounds: u32,
69 pub history: BlackboardHistoryPolicy,
71 #[serde(default)]
73 pub budget: TeamBudget,
74 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
92#[serde(rename_all = "camelCase", tag = "mode")]
93pub enum BlackboardHistoryPolicy {
94 Full,
96 Last {
98 max_messages: usize,
100 },
101}
102
103impl BlackboardSpec {
104 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 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
214pub 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 pub fn spec(&self) -> &BlackboardSpec {
226 &self.spec
227 }
228
229 pub fn execution_snapshot(&self, invocation_id: &str) -> Option<super::TeamExecutionSnapshot> {
231 self.runtime.snapshot(invocation_id)
232 }
233
234 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}