tirea_agentos/composition/
builder.rs

1use super::*;
2use crate::contracts::runtime::tool_call::Tool;
3use crate::contracts::runtime::AgentBehavior;
4use crate::contracts::storage::ThreadStore;
5#[cfg(feature = "skills")]
6use crate::extensions::skills::{
7    CompositeSkillRegistry, InMemorySkillRegistry, Skill, SkillRegistry, SkillRegistryManagerError,
8};
9#[cfg(feature = "skills")]
10use crate::runtime::resolve::SkillsSystemWiring;
11use crate::runtime::StopPolicy;
12use crate::runtime::{AgentOs, RuntimeServices};
13use genai::Client;
14use std::collections::HashMap;
15use std::sync::Arc;
16#[cfg(feature = "skills")]
17use std::time::Duration;
18
19pub struct AgentOsBuilder {
20    pub(crate) client: Option<Client>,
21    pub(crate) bundles: Vec<Arc<dyn RegistryBundle>>,
22    pub(crate) agents: HashMap<String, AgentDefinition>,
23    pub(crate) agent_registries: Vec<Arc<dyn AgentRegistry>>,
24    pub(crate) resolved_agents: HashMap<String, ResolvedAgent>,
25    pub(crate) agent_catalogs: Vec<Arc<dyn AgentCatalog>>,
26    pub(crate) base_tools: HashMap<String, Arc<dyn Tool>>,
27    pub(crate) base_tool_registries: Vec<Arc<dyn ToolRegistry>>,
28    pub(crate) behaviors: HashMap<String, Arc<dyn AgentBehavior>>,
29    pub(crate) behavior_registries: Vec<Arc<dyn BehaviorRegistry>>,
30    pub(crate) stop_policies: HashMap<String, Arc<dyn StopPolicy>>,
31    pub(crate) stop_policy_registries: Vec<Arc<dyn StopPolicyRegistry>>,
32    pub(crate) providers: HashMap<String, Client>,
33    pub(crate) provider_registries: Vec<Arc<dyn ProviderRegistry>>,
34    pub(crate) models: HashMap<String, ModelDefinition>,
35    pub(crate) model_registries: Vec<Arc<dyn ModelRegistry>>,
36    #[cfg(feature = "skills")]
37    pub(crate) skills: Vec<Arc<dyn Skill>>,
38    #[cfg(feature = "skills")]
39    pub(crate) skill_registries: Vec<Arc<dyn SkillRegistry>>,
40    #[cfg(feature = "skills")]
41    pub(crate) skills_refresh_interval: Option<Duration>,
42    #[cfg(feature = "skills")]
43    pub(crate) skills_config: SkillsConfig,
44    pub(crate) system_wirings: Vec<Arc<dyn SystemWiring>>,
45    pub(crate) agent_tools: AgentToolsConfig,
46    pub(crate) agent_state_store: Option<Arc<dyn ThreadStore>>,
47}
48
49fn merge_registry<R: ?Sized, M>(
50    memory: M,
51    mut external: Vec<Arc<R>>,
52    is_memory_empty: impl Fn(&M) -> bool,
53    into_memory_registry: impl FnOnce(M) -> Arc<R>,
54    compose: impl FnOnce(Vec<Arc<R>>) -> Result<Arc<R>, AgentOsBuildError>,
55) -> Result<Arc<R>, AgentOsBuildError> {
56    if external.is_empty() {
57        return Ok(into_memory_registry(memory));
58    }
59
60    let mut registries: Vec<Arc<R>> = Vec::new();
61    if !is_memory_empty(&memory) {
62        registries.push(into_memory_registry(memory));
63    }
64    registries.append(&mut external);
65
66    if registries.len() == 1 {
67        Ok(registries.pop().expect("single registry must exist"))
68    } else {
69        compose(registries)
70    }
71}
72
73impl std::fmt::Debug for AgentOs {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        let mut s = f.debug_struct("AgentOs");
76        s.field("default_client", &"[genai::Client]")
77            .field("agents", &self.agents.len())
78            .field("agent_catalog", &self.agent_catalog.len())
79            .field("base_tools", &self.base_tools.len())
80            .field("behaviors", &self.behaviors.len())
81            .field("stop_policies", &self.stop_policies.len())
82            .field("providers", &self.providers.len())
83            .field("models", &self.models.len());
84        #[cfg(feature = "skills")]
85        s.field(
86            "skills",
87            &self.skills_registry.as_ref().map(|registry| registry.len()),
88        );
89        s.field("system_wirings", &self.system_wirings.len())
90            .field("active_runs", &"[internal]")
91            .field("agent_tools", &self.agent_tools)
92            .field("agent_state_store", &self.agent_state_store.is_some())
93            .finish()
94    }
95}
96
97impl std::fmt::Debug for AgentOsBuilder {
98    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99        let mut s = f.debug_struct("AgentOsBuilder");
100        s.field("client", &self.client.is_some())
101            .field("bundles", &self.bundles.len())
102            .field("agents", &self.agents.len())
103            .field("resolved_agents", &self.resolved_agents.len())
104            .field("agent_catalogs", &self.agent_catalogs.len())
105            .field("base_tools", &self.base_tools.len())
106            .field("behaviors", &self.behaviors.len())
107            .field("stop_policies", &self.stop_policies.len())
108            .field("stop_policy_registries", &self.stop_policy_registries.len())
109            .field("providers", &self.providers.len())
110            .field("models", &self.models.len());
111        #[cfg(feature = "skills")]
112        {
113            s.field("skills", &self.skills.len())
114                .field("skill_registries", &self.skill_registries.len())
115                .field("skills_refresh_interval", &self.skills_refresh_interval)
116                .field("skills_config", &self.skills_config);
117        }
118        s.field("system_wirings", &self.system_wirings.len())
119            .field("agent_tools", &self.agent_tools)
120            .field("agent_state_store", &self.agent_state_store.is_some())
121            .finish()
122    }
123}
124
125impl AgentOsBuilder {
126    fn insert_local_agent_definition(&mut self, agent_id: String, mut definition: AgentDefinition) {
127        definition.id = agent_id.clone();
128        self.agents.insert(agent_id, definition);
129    }
130
131    fn insert_resolved_agent_definition(&mut self, agent_id: String, mut agent: ResolvedAgent) {
132        agent.descriptor.id = agent_id.clone();
133        if agent.descriptor.name.trim().is_empty() {
134            agent.descriptor.name = agent_id.clone();
135        }
136        self.resolved_agents.insert(agent_id, agent);
137    }
138
139    fn insert_agent_spec(&mut self, spec: AgentDefinitionSpec) {
140        match spec {
141            AgentDefinitionSpec::Local(definition) => {
142                let definition = *definition;
143                let agent_id = definition.id.clone();
144                self.insert_local_agent_definition(agent_id, definition);
145            }
146            AgentDefinitionSpec::Remote(definition) => {
147                let agent_id = definition.id().to_string();
148                self.insert_resolved_agent_definition(agent_id, definition.into_resolved_agent());
149            }
150        }
151    }
152
153    pub fn new() -> Self {
154        Self {
155            client: None,
156            bundles: Vec::new(),
157            agents: HashMap::new(),
158            agent_registries: Vec::new(),
159            resolved_agents: HashMap::new(),
160            agent_catalogs: Vec::new(),
161            base_tools: HashMap::new(),
162            base_tool_registries: Vec::new(),
163            behaviors: HashMap::new(),
164            behavior_registries: Vec::new(),
165            stop_policies: HashMap::new(),
166            stop_policy_registries: Vec::new(),
167            providers: HashMap::new(),
168            provider_registries: Vec::new(),
169            models: HashMap::new(),
170            model_registries: Vec::new(),
171            #[cfg(feature = "skills")]
172            skills: Vec::new(),
173            #[cfg(feature = "skills")]
174            skill_registries: Vec::new(),
175            #[cfg(feature = "skills")]
176            skills_refresh_interval: None,
177            #[cfg(feature = "skills")]
178            skills_config: SkillsConfig::default(),
179            system_wirings: Vec::new(),
180            agent_tools: AgentToolsConfig::default(),
181            agent_state_store: None,
182        }
183    }
184
185    pub fn with_client(mut self, client: Client) -> Self {
186        self.client = Some(client);
187        self
188    }
189
190    pub fn with_bundle(mut self, bundle: Arc<dyn RegistryBundle>) -> Self {
191        self.bundles.push(bundle);
192        self
193    }
194
195    pub fn with_agent_spec(mut self, spec: AgentDefinitionSpec) -> Self {
196        self.insert_agent_spec(spec);
197        self
198    }
199
200    pub fn with_agent_specs(
201        mut self,
202        specs: impl IntoIterator<Item = AgentDefinitionSpec>,
203    ) -> Self {
204        for spec in specs {
205            self = self.with_agent_spec(spec);
206        }
207        self
208    }
209
210    pub fn with_agent_registry(mut self, registry: Arc<dyn AgentRegistry>) -> Self {
211        self.agent_registries.push(registry);
212        self
213    }
214
215    pub fn with_agent_catalog(mut self, catalog: Arc<dyn AgentCatalog>) -> Self {
216        self.agent_catalogs.push(catalog);
217        self
218    }
219
220    pub fn with_tools(mut self, tools: HashMap<String, Arc<dyn Tool>>) -> Self {
221        self.base_tools = tools;
222        self
223    }
224
225    pub fn with_tool_registry(mut self, registry: Arc<dyn ToolRegistry>) -> Self {
226        self.base_tool_registries.push(registry);
227        self
228    }
229
230    pub fn with_registered_behavior(
231        mut self,
232        behavior_id: impl Into<String>,
233        behavior: Arc<dyn AgentBehavior>,
234    ) -> Self {
235        self.behaviors.insert(behavior_id.into(), behavior);
236        self
237    }
238
239    pub fn with_behavior_registry(mut self, registry: Arc<dyn BehaviorRegistry>) -> Self {
240        self.behavior_registries.push(registry);
241        self
242    }
243
244    pub fn with_stop_policy(mut self, id: impl Into<String>, policy: Arc<dyn StopPolicy>) -> Self {
245        self.stop_policies.insert(id.into(), policy);
246        self
247    }
248
249    pub fn with_stop_policy_registry(mut self, registry: Arc<dyn StopPolicyRegistry>) -> Self {
250        self.stop_policy_registries.push(registry);
251        self
252    }
253
254    pub fn with_provider(mut self, provider_id: impl Into<String>, client: Client) -> Self {
255        self.providers.insert(provider_id.into(), client);
256        self
257    }
258
259    pub fn with_provider_registry(mut self, registry: Arc<dyn ProviderRegistry>) -> Self {
260        self.provider_registries.push(registry);
261        self
262    }
263
264    pub fn with_model(mut self, model_id: impl Into<String>, def: ModelDefinition) -> Self {
265        self.models.insert(model_id.into(), def);
266        self
267    }
268
269    pub fn with_models(mut self, defs: HashMap<String, ModelDefinition>) -> Self {
270        self.models = defs;
271        self
272    }
273
274    pub fn with_model_registry(mut self, registry: Arc<dyn ModelRegistry>) -> Self {
275        self.model_registries.push(registry);
276        self
277    }
278
279    #[cfg(feature = "skills")]
280    pub fn with_skills(mut self, skills: Vec<Arc<dyn Skill>>) -> Self {
281        self.skills = skills;
282        self
283    }
284
285    #[cfg(feature = "skills")]
286    pub fn with_skill_registry(mut self, registry: Arc<dyn SkillRegistry>) -> Self {
287        self.skill_registries.push(registry);
288        self
289    }
290
291    #[cfg(feature = "skills")]
292    pub fn with_skill_registry_refresh_interval(mut self, interval: Duration) -> Self {
293        self.skills_refresh_interval = Some(interval);
294        self
295    }
296
297    #[cfg(feature = "skills")]
298    pub fn with_skills_config(mut self, cfg: SkillsConfig) -> Self {
299        self.skills_config = cfg;
300        self
301    }
302
303    /// Register a [`SystemWiring`] implementation for generic extension wiring.
304    pub fn with_system_wiring(mut self, wiring: Arc<dyn SystemWiring>) -> Self {
305        self.system_wirings.push(wiring);
306        self
307    }
308
309    pub fn with_agent_tools_config(mut self, cfg: AgentToolsConfig) -> Self {
310        self.agent_tools = cfg;
311        self
312    }
313
314    pub fn with_agent_state_store(mut self, agent_state_store: Arc<dyn ThreadStore>) -> Self {
315        self.agent_state_store = Some(agent_state_store);
316        self
317    }
318
319    pub fn build(self) -> Result<AgentOs, AgentOsBuildError> {
320        let AgentOsBuilder {
321            client,
322            bundles,
323            agents: mut agents_defs,
324            mut agent_registries,
325            resolved_agents: mut resolved_agent_defs,
326            mut agent_catalogs,
327            base_tools: mut base_tools_defs,
328            mut base_tool_registries,
329            behaviors: mut behavior_defs,
330            mut behavior_registries,
331            stop_policies: stop_policy_defs,
332            stop_policy_registries,
333            providers: mut provider_defs,
334            mut provider_registries,
335            models: mut model_defs,
336            mut model_registries,
337            #[cfg(feature = "skills")]
338            skills,
339            #[cfg(feature = "skills")]
340            mut skill_registries,
341            #[cfg(feature = "skills")]
342            skills_refresh_interval,
343            #[cfg(feature = "skills")]
344            skills_config,
345            system_wirings,
346            agent_tools,
347            agent_state_store,
348        } = self;
349
350        BundleComposer::apply(
351            &bundles,
352            BundleRegistryAccumulator {
353                agent_definitions: &mut agents_defs,
354                agent_registries: &mut agent_registries,
355                tool_definitions: &mut base_tools_defs,
356                tool_registries: &mut base_tool_registries,
357                behavior_definitions: &mut behavior_defs,
358                behavior_registries: &mut behavior_registries,
359                provider_definitions: &mut provider_defs,
360                provider_registries: &mut provider_registries,
361                model_definitions: &mut model_defs,
362                model_registries: &mut model_registries,
363            },
364        )?;
365
366        // --- Skills registry setup (feature-gated) ---
367        #[allow(unused_mut)]
368        let mut system_wirings = system_wirings;
369        #[cfg(feature = "skills")]
370        let skills_registry = {
371            if skills_config.enabled && skills.is_empty() && skill_registries.is_empty() {
372                return Err(AgentOsBuildError::SkillsNotConfigured);
373            }
374
375            let mut in_memory_skills = InMemorySkillRegistry::new();
376            in_memory_skills.extend_upsert(skills);
377
378            let registry = if in_memory_skills.is_empty() && skill_registries.is_empty() {
379                None
380            } else {
381                Some(merge_registry(
382                    in_memory_skills,
383                    std::mem::take(&mut skill_registries),
384                    |reg: &InMemorySkillRegistry| reg.is_empty(),
385                    |reg| Arc::new(reg),
386                    |regs| Ok(Arc::new(CompositeSkillRegistry::try_new(regs)?)),
387                )?)
388            };
389
390            if let (Some(r), Some(interval)) = (&registry, skills_refresh_interval) {
391                match r.start_periodic_refresh(interval) {
392                    Ok(()) | Err(SkillRegistryManagerError::PeriodicRefreshAlreadyRunning) => {}
393                    Err(err) => return Err(err.into()),
394                }
395            }
396
397            // If skills are configured+enabled, push the built-in SkillsSystemWiring.
398            if skills_config.enabled {
399                if let Some(ref reg) = registry {
400                    system_wirings.push(Arc::new(SkillsSystemWiring::new(
401                        reg.clone(),
402                        skills_config.clone(),
403                    )));
404                }
405            }
406
407            registry
408        };
409
410        let mut base_tools = InMemoryToolRegistry::new();
411        base_tools.extend_named(base_tools_defs)?;
412
413        let base_tools: Arc<dyn ToolRegistry> = merge_registry(
414            base_tools,
415            base_tool_registries,
416            |reg: &InMemoryToolRegistry| reg.is_empty(),
417            |reg| Arc::new(reg),
418            |regs| Ok(Arc::new(CompositeToolRegistry::try_new(regs)?)),
419        )?;
420
421        let mut behaviors = InMemoryBehaviorRegistry::new();
422        behaviors.extend_named(behavior_defs)?;
423
424        let behaviors: Arc<dyn BehaviorRegistry> = merge_registry(
425            behaviors,
426            behavior_registries,
427            |reg: &InMemoryBehaviorRegistry| reg.is_empty(),
428            |reg| Arc::new(reg),
429            |regs| Ok(Arc::new(CompositeBehaviorRegistry::try_new(regs)?)),
430        )?;
431
432        let mut stop_policies_mem = InMemoryStopPolicyRegistry::new();
433        stop_policies_mem.extend_named(stop_policy_defs)?;
434
435        let stop_policies: Arc<dyn StopPolicyRegistry> = merge_registry(
436            stop_policies_mem,
437            stop_policy_registries,
438            |reg: &InMemoryStopPolicyRegistry| reg.is_empty(),
439            |reg| Arc::new(reg),
440            |regs| Ok(Arc::new(CompositeStopPolicyRegistry::try_new(regs)?)),
441        )?;
442
443        // Fail-fast for builder-provided agents (external registries may be dynamic).
444        {
445            let reserved = AgentOs::reserved_behavior_ids(&system_wirings);
446            for (agent_id, def) in &agents_defs {
447                let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
448                for id in &def.behavior_ids {
449                    let id = id.trim();
450                    if id.is_empty() {
451                        return Err(AgentOsBuildError::AgentEmptyBehaviorRef {
452                            agent_id: agent_id.clone(),
453                        });
454                    }
455                    if reserved.contains(&id) {
456                        return Err(AgentOsBuildError::AgentReservedBehaviorId {
457                            agent_id: agent_id.clone(),
458                            behavior_id: id.to_string(),
459                        });
460                    }
461                    if !seen.insert(id.to_string()) {
462                        return Err(AgentOsBuildError::AgentDuplicateBehaviorRef {
463                            agent_id: agent_id.clone(),
464                            behavior_id: id.to_string(),
465                        });
466                    }
467                    if behaviors.get(id).is_none() {
468                        return Err(AgentOsBuildError::AgentBehaviorNotFound {
469                            agent_id: agent_id.clone(),
470                            behavior_id: id.to_string(),
471                        });
472                    }
473                }
474
475                // Validate stop_condition_ids
476                let mut sc_seen: std::collections::HashSet<String> =
477                    std::collections::HashSet::new();
478                for sc_id in &def.stop_condition_ids {
479                    let sc_id = sc_id.trim();
480                    if sc_id.is_empty() {
481                        return Err(AgentOsBuildError::AgentEmptyStopConditionRef {
482                            agent_id: agent_id.clone(),
483                        });
484                    }
485                    if !sc_seen.insert(sc_id.to_string()) {
486                        return Err(AgentOsBuildError::AgentDuplicateStopConditionRef {
487                            agent_id: agent_id.clone(),
488                            stop_condition_id: sc_id.to_string(),
489                        });
490                    }
491                    if stop_policies.get(sc_id).is_none() {
492                        return Err(AgentOsBuildError::AgentStopConditionNotFound {
493                            agent_id: agent_id.clone(),
494                            stop_condition_id: sc_id.to_string(),
495                        });
496                    }
497                }
498            }
499        }
500
501        let mut providers = InMemoryProviderRegistry::new();
502        providers.extend(provider_defs)?;
503
504        let providers: Arc<dyn ProviderRegistry> = merge_registry(
505            providers,
506            provider_registries,
507            |reg: &InMemoryProviderRegistry| reg.is_empty(),
508            |reg| Arc::new(reg),
509            |regs| Ok(Arc::new(CompositeProviderRegistry::try_new(regs)?)),
510        )?;
511
512        let mut models = InMemoryModelRegistry::new();
513        models.extend(model_defs.clone())?;
514
515        let models: Arc<dyn ModelRegistry> = merge_registry(
516            models,
517            model_registries,
518            |reg: &InMemoryModelRegistry| reg.is_empty(),
519            |reg| Arc::new(reg),
520            |regs| Ok(Arc::new(CompositeModelRegistry::try_new(regs)?)),
521        )?;
522
523        if !models.is_empty() && providers.is_empty() {
524            return Err(AgentOsBuildError::ProvidersNotConfigured);
525        }
526
527        for (model_id, def) in models.snapshot() {
528            if providers.get(&def.provider).is_none() {
529                return Err(AgentOsBuildError::ProviderNotFound {
530                    provider_id: def.provider,
531                    model_id,
532                });
533            }
534        }
535
536        let mut agents = InMemoryAgentRegistry::new();
537        agents.extend_upsert(agents_defs);
538
539        let agents: Arc<dyn AgentRegistry> = merge_registry(
540            agents,
541            agent_registries,
542            |reg: &InMemoryAgentRegistry| reg.is_empty(),
543            |reg| Arc::new(reg),
544            |regs| Ok(Arc::new(CompositeAgentRegistry::try_new(regs)?)),
545        )?;
546
547        let mut static_agents = InMemoryAgentCatalog::new();
548        static_agents.extend_upsert(std::mem::take(&mut resolved_agent_defs));
549        agent_catalogs.insert(
550            0,
551            Arc::new(HostedAgentCatalog::new(agents.clone())) as Arc<dyn AgentCatalog>,
552        );
553        let agent_catalog: Arc<dyn AgentCatalog> = merge_registry(
554            static_agents,
555            agent_catalogs,
556            |catalog: &InMemoryAgentCatalog| catalog.is_empty(),
557            |catalog| Arc::new(catalog),
558            |catalogs| Ok(Arc::new(CompositeAgentCatalog::try_new(catalogs)?)),
559        )?;
560
561        let registries = RegistrySet::new(
562            agents,
563            base_tools,
564            behaviors,
565            providers,
566            models,
567            stop_policies,
568            #[cfg(feature = "skills")]
569            skills_registry,
570        );
571        let services = RuntimeServices {
572            default_client: client.unwrap_or_default(),
573            system_wirings,
574            agent_tools,
575            agent_state_store,
576            agent_catalog,
577        };
578
579        Ok(AgentOs::from_registry_set(registries, services))
580    }
581}
582
583impl Default for AgentOsBuilder {
584    fn default() -> Self {
585        Self::new()
586    }
587}
588
589#[cfg(test)]
590mod tests {
591    use super::*;
592
593    #[test]
594    fn with_agent_spec_routes_local_and_remote_into_runtime_surfaces() {
595        let mut local_definition = AgentDefinition::new("mock");
596        local_definition.id = "local-worker".to_string();
597        let os = AgentOsBuilder::new()
598            .with_agent_spec(AgentDefinitionSpec::local(
599                local_definition
600                    .with_name("Local Worker")
601                    .with_description("Hosted locally"),
602            ))
603            .with_agent_spec(AgentDefinitionSpec::a2a(
604                AgentDescriptor::new("remote-worker")
605                    .with_name("Remote Worker")
606                    .with_description("Delegated over A2A"),
607                A2aAgentBinding::new("https://example.test/v1/a2a", "remote-worker"),
608            ))
609            .build()
610            .expect("builder should accept unified agent specs");
611
612        assert_eq!(
613            os.agent("local-worker")
614                .expect("local agent should be registered")
615                .display_name(),
616            "Local Worker"
617        );
618
619        let remote = os
620            .agent_catalog()
621            .get("remote-worker")
622            .expect("remote agent should be discoverable");
623        assert_eq!(remote.descriptor.name, "Remote Worker");
624        assert!(matches!(remote.binding, AgentBinding::A2a(_)));
625    }
626
627    #[test]
628    fn unified_builder_entrypoints_normalize_ids_and_names() {
629        let os = AgentOsBuilder::new()
630            .with_agent_spec(AgentDefinitionSpec::local_with_id(
631                "local-worker",
632                AgentDefinition::new("mock")
633                    .with_name("Local Worker")
634                    .with_description("Hosted locally"),
635            ))
636            .with_agent_spec(AgentDefinitionSpec::a2a(
637                AgentDescriptor::new("remote-worker")
638                    .with_name("   ")
639                    .with_description("Delegated over A2A"),
640                A2aAgentBinding::new("https://example.test/v1/a2a", "remote-worker"),
641            ))
642            .build()
643            .expect("unified builder entrypoints should build");
644
645        assert_eq!(
646            os.agent("local-worker")
647                .expect("local agent should be registered")
648                .display_name(),
649            "Local Worker"
650        );
651
652        let remote = os
653            .agent_catalog()
654            .get("remote-worker")
655            .expect("remote agent should be discoverable");
656        assert_eq!(remote.descriptor.id, "remote-worker");
657        assert_eq!(remote.descriptor.name, "remote-worker");
658        assert_eq!(remote.descriptor.description, "Delegated over A2A");
659    }
660
661    #[test]
662    fn build_rejects_duplicate_local_and_remote_agent_ids() {
663        let err = AgentOsBuilder::new()
664            .with_agent_spec(AgentDefinitionSpec::local_with_id(
665                "worker",
666                AgentDefinition::new("mock"),
667            ))
668            .with_agent_spec(AgentDefinitionSpec::a2a_with_id(
669                "worker",
670                A2aAgentBinding::new("https://example.test/v1/a2a", "worker"),
671            ))
672            .build()
673            .expect_err("duplicate local/remote ids should be rejected");
674
675        assert!(matches!(
676            err,
677            AgentOsBuildError::AgentCatalog(AgentCatalogError::AgentIdConflict(id)) if id == "worker"
678        ));
679    }
680}