Skip to main content

brainos_delegate/
registry.rs

1//! AgentRegistry — the orchestrator's lookup table for delegates.
2
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6
7use serde::{Deserialize, Serialize};
8
9use crate::discovery::{DiscoveredBinary, DiscoveryStatus, InvocationTemplate};
10use crate::subprocess::{SubprocessAgentConfig, SubprocessAgentDelegate};
11use crate::traits::{AgentCapabilities, AgentDelegate, AgentError};
12
13/// Operator-configured override for a fingerprinted agent. All fields
14/// are optional; unset ones fall through to discovery defaults.
15#[derive(Debug, Clone, Default, Serialize, Deserialize)]
16pub struct AgentOverride {
17    /// Override the binary path found on `$PATH`.
18    pub binary: Option<PathBuf>,
19    /// Exclude this agent from the registry entirely.
20    #[serde(default)]
21    pub disabled: bool,
22    /// Replace the fingerprint's default capabilities.
23    pub capabilities: Option<AgentCapabilities>,
24    /// Replace the invocation args. `{prompt}` and `{task_id}` are
25    /// substituted at spawn time.
26    pub args: Option<Vec<String>>,
27    /// Force stdin vs. argv prompt delivery.
28    pub prompt_via_stdin: Option<bool>,
29}
30
31/// Declarative registration for an agent with no built-in fingerprint.
32/// Custom agents always go through `SubprocessAgentDelegate`.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct CustomAgentSpec {
35    pub id: String,
36    pub binary: PathBuf,
37    #[serde(default)]
38    pub args: Vec<String>,
39    #[serde(default = "default_true")]
40    pub prompt_via_stdin: bool,
41    #[serde(default)]
42    pub capabilities: AgentCapabilities,
43    /// Working directory the delegate cd's into before spawning the child.
44    /// Task-level workdir (set by the orchestrator on the `AgentTask`) wins
45    /// when present; this is the static default for the binary.
46    #[serde(default)]
47    pub workdir: Option<PathBuf>,
48    /// Optional alias registered alongside `id`. Lets one canonical agent
49    /// receive routing under a shorthand name too.
50    #[serde(default)]
51    pub alias: Option<String>,
52}
53
54fn default_true() -> bool {
55    true
56}
57
58/// Full delegate config sourced from user settings. Empty default is
59/// fine — pure discovery with no overrides.
60#[derive(Debug, Clone, Default, Serialize, Deserialize)]
61pub struct DelegateOverrides {
62    /// Toggle fingerprinted auto-discovery; custom entries still register.
63    #[serde(default = "default_true")]
64    pub auto_discovery: bool,
65    /// Keyed by canonical agent id.
66    #[serde(default)]
67    pub overrides: HashMap<String, AgentOverride>,
68    #[serde(default)]
69    pub custom: Vec<CustomAgentSpec>,
70}
71
72impl DelegateOverrides {
73    pub fn new() -> Self {
74        Self {
75            auto_discovery: true,
76            ..Self::default()
77        }
78    }
79}
80
81/// What the registry knows about a candidate agent — populated by
82/// [`AgentRegistry::populate_from_discovery`] so callers (e.g.
83/// `brain doctor`, `QueryAgents` intent) can explain availability.
84#[derive(Debug, Clone)]
85pub enum RegistryAgentStatus {
86    Registered {
87        binary: PathBuf,
88        version: Option<String>,
89        source: AgentSource,
90    },
91    DisabledByConfig,
92    Unavailable {
93        binary: PathBuf,
94        reason: String,
95    },
96}
97
98/// Where a registered agent came from. Distinguishes auto-discovered
99/// entries from hand-registered / custom ones so the operator surface
100/// can be honest about "why is this here".
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum AgentSource {
103    Discovered,
104    Custom,
105    Manual,
106}
107
108/// Holds every known delegate keyed by `name()`. Additional aliases can
109/// be registered to route shorthand request names to canonical entries.
110#[derive(Default)]
111pub struct AgentRegistry {
112    delegates: HashMap<String, Arc<dyn AgentDelegate>>,
113    aliases: HashMap<String, String>,
114    agent_status: HashMap<String, RegistryAgentStatus>,
115}
116
117impl AgentRegistry {
118    pub fn new() -> Self {
119        Self::default()
120    }
121
122    /// Register a delegate under its declared `name()`. Last write wins.
123    pub fn register(&mut self, delegate: Arc<dyn AgentDelegate>) {
124        let name = delegate.name().to_string();
125        self.delegates.insert(name, delegate);
126    }
127
128    /// Register a delegate that came from hand-written `agents.delegates[]`
129    /// config. Records `agent_status` as [`AgentSource::Manual`] so the
130    /// operator surface (`QueryAgents`, doctor) sees the entry instead of
131    /// silently diverging from what the orchestrator will actually run.
132    pub fn register_manual(
133        &mut self,
134        delegate: Arc<dyn AgentDelegate>,
135        binary: PathBuf,
136        version: Option<String>,
137    ) {
138        let name = delegate.name().to_string();
139        self.agent_status.insert(
140            name.clone(),
141            RegistryAgentStatus::Registered {
142                binary,
143                version,
144                source: AgentSource::Manual,
145            },
146        );
147        self.delegates.insert(name, delegate);
148    }
149
150    /// Build a `SubprocessAgentDelegate` from a [`CustomAgentSpec`] and
151    /// register it under `spec.id` with the given source attribution.
152    /// Returns the binary path so callers can reuse it for status/log
153    /// messages.
154    ///
155    /// This is the canonical entry point for both the YAML
156    /// `agents.delegates[]` flow (source = [`AgentSource::Manual`]) and the
157    /// programmatic [`DelegateOverrides::custom`] flow
158    /// (source = [`AgentSource::Custom`]). Centralising the
159    /// `SubprocessAgentConfig` construction here keeps the two paths from
160    /// drifting — every new field on [`CustomAgentSpec`] is honoured by
161    /// both.
162    pub fn register_subprocess_spec(
163        &mut self,
164        spec: &CustomAgentSpec,
165        source: AgentSource,
166    ) -> PathBuf {
167        let cfg = SubprocessAgentConfig {
168            name: spec.id.clone(),
169            binary: spec.binary.to_string_lossy().into_owned(),
170            args: spec.args.clone(),
171            workdir: spec.workdir.clone(),
172            capabilities: spec.capabilities.clone(),
173            prompt_via_stdin: spec.prompt_via_stdin,
174            version_args: vec!["--version".to_string()],
175        };
176        let delegate: Arc<dyn AgentDelegate> = Arc::new(SubprocessAgentDelegate::new(cfg));
177        let binary = spec.binary.clone();
178        self.agent_status.insert(
179            spec.id.clone(),
180            RegistryAgentStatus::Registered {
181                binary: binary.clone(),
182                version: None,
183                source,
184            },
185        );
186        if let Some(alias) = &spec.alias {
187            self.aliases.insert(alias.clone(), spec.id.clone());
188        }
189        self.delegates.insert(spec.id.clone(), delegate);
190        binary
191    }
192
193    /// Add an alias: `alias -> canonical_name`. If `canonical_name` isn't
194    /// registered yet, the alias is still stored — resolved lazily.
195    pub fn alias(&mut self, alias: impl Into<String>, canonical: impl Into<String>) {
196        self.aliases.insert(alias.into(), canonical.into());
197    }
198
199    pub fn get(&self, name: &str) -> Result<Arc<dyn AgentDelegate>, AgentError> {
200        let resolved = self.aliases.get(name).map(String::as_str).unwrap_or(name);
201        self.delegates
202            .get(resolved)
203            .cloned()
204            .ok_or_else(|| AgentError::NotFound(name.to_string()))
205    }
206
207    pub fn contains(&self, name: &str) -> bool {
208        let resolved = self.aliases.get(name).map(String::as_str).unwrap_or(name);
209        self.delegates.contains_key(resolved)
210    }
211
212    /// Ordered list of canonical delegate names.
213    pub fn list(&self) -> Vec<String> {
214        let mut names: Vec<String> = self.delegates.keys().cloned().collect();
215        names.sort();
216        names
217    }
218
219    pub fn is_empty(&self) -> bool {
220        self.delegates.is_empty()
221    }
222
223    /// Known state for an agent id — including ones we saw but did not
224    /// register (disabled, probe-failed). Returns `None` for ids the
225    /// discovery pass never encountered.
226    pub fn agent_status(&self, id: &str) -> Option<&RegistryAgentStatus> {
227        self.agent_status.get(id)
228    }
229
230    /// Every agent id we've learned about, whether registered or not.
231    pub fn known_agents(&self) -> Vec<(&str, &RegistryAgentStatus)> {
232        let mut v: Vec<_> = self
233            .agent_status
234            .iter()
235            .map(|(k, v)| (k.as_str(), v))
236            .collect();
237        v.sort_by_key(|(k, _)| *k);
238        v
239    }
240
241    /// Consume a discovery pass + user overrides and register runnable
242    /// delegates for every `Available` agent that isn't disabled.
243    ///
244    /// Unavailable or disabled agents are *not* registered but their
245    /// reason is retained in `agent_status` so the operator surface can
246    /// answer "why isn't X available". Existing manual registrations
247    /// are left untouched.
248    pub fn populate_from_discovery(
249        &mut self,
250        discovered: Vec<DiscoveredBinary>,
251        overrides: &DelegateOverrides,
252    ) {
253        if overrides.auto_discovery {
254            for d in discovered {
255                let ov = overrides
256                    .overrides
257                    .get(&d.agent_id)
258                    .cloned()
259                    .unwrap_or_default();
260                if ov.disabled {
261                    self.agent_status
262                        .insert(d.agent_id.clone(), RegistryAgentStatus::DisabledByConfig);
263                    continue;
264                }
265                match &d.status {
266                    DiscoveryStatus::Available => {
267                        let binary = ov.binary.clone().unwrap_or_else(|| d.path.clone());
268                        let caps = ov
269                            .capabilities
270                            .clone()
271                            .unwrap_or_else(|| d.capabilities.clone());
272                        let delegate = build_from_template(
273                            &d.agent_id,
274                            &binary,
275                            &d.invocation,
276                            &d.version_args,
277                            &ov,
278                            caps,
279                        );
280                        self.agent_status.insert(
281                            d.agent_id.clone(),
282                            RegistryAgentStatus::Registered {
283                                binary,
284                                version: d.version.clone(),
285                                source: AgentSource::Discovered,
286                            },
287                        );
288                        self.register(delegate);
289                    }
290                    DiscoveryStatus::Unavailable(reason) => {
291                        self.agent_status.insert(
292                            d.agent_id.clone(),
293                            RegistryAgentStatus::Unavailable {
294                                binary: d.path.clone(),
295                                reason: reason.clone(),
296                            },
297                        );
298                    }
299                }
300            }
301        }
302
303        for spec in &overrides.custom {
304            self.register_subprocess_spec(spec, AgentSource::Custom);
305        }
306    }
307}
308
309fn build_from_template(
310    agent_id: &str,
311    binary: &Path,
312    default_invocation: &InvocationTemplate,
313    version_args: &[String],
314    ov: &AgentOverride,
315    capabilities: AgentCapabilities,
316) -> Arc<dyn AgentDelegate> {
317    let args = ov.args.clone().unwrap_or_else(|| {
318        default_invocation
319            .args
320            .iter()
321            .map(|s| (*s).to_string())
322            .collect::<Vec<_>>()
323    });
324    let prompt_via_stdin = ov
325        .prompt_via_stdin
326        .unwrap_or(default_invocation.prompt_via_stdin);
327    let cfg = SubprocessAgentConfig {
328        name: agent_id.to_string(),
329        binary: binary.to_string_lossy().into_owned(),
330        args,
331        workdir: None,
332        capabilities,
333        prompt_via_stdin,
334        version_args: version_args.to_vec(),
335    };
336    Arc::new(SubprocessAgentDelegate::new(cfg))
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342    use crate::traits::{AgentCapabilities, AgentResult, AgentTask, AgentTaskStatus};
343    use async_trait::async_trait;
344    use chrono::Utc;
345
346    struct MockAgent {
347        name: String,
348    }
349
350    #[async_trait]
351    impl AgentDelegate for MockAgent {
352        fn name(&self) -> &str {
353            &self.name
354        }
355        fn capabilities(&self) -> AgentCapabilities {
356            AgentCapabilities::default()
357        }
358        async fn delegate(&self, task: AgentTask) -> Result<AgentResult, AgentError> {
359            let now = Utc::now();
360            Ok(AgentResult {
361                task_id: task.id,
362                status: AgentTaskStatus::Succeeded,
363                summary: format!("{} ran: {}", self.name, task.description),
364                artifacts: vec![],
365                stdout: String::new(),
366                stderr: String::new(),
367                exit_code: Some(0),
368                started_at: now,
369                completed_at: now,
370            })
371        }
372    }
373
374    #[test]
375    fn register_and_get() {
376        let mut reg = AgentRegistry::new();
377        reg.register(Arc::new(MockAgent {
378            name: "mock".to_string(),
379        }));
380        let d = reg.get("mock").unwrap();
381        assert_eq!(d.name(), "mock");
382    }
383
384    #[test]
385    fn alias_resolves() {
386        let mut reg = AgentRegistry::new();
387        reg.register(Arc::new(MockAgent {
388            name: "claude-code".to_string(),
389        }));
390        reg.alias("claude", "claude-code");
391        assert!(reg.contains("claude"));
392        assert_eq!(reg.get("claude").unwrap().name(), "claude-code");
393    }
394
395    #[test]
396    fn missing_delegate_errors() {
397        let reg = AgentRegistry::new();
398        assert!(matches!(reg.get("nope"), Err(AgentError::NotFound(_))));
399    }
400
401    #[test]
402    fn list_returns_sorted_names() {
403        let mut reg = AgentRegistry::new();
404        reg.register(Arc::new(MockAgent {
405            name: "b".to_string(),
406        }));
407        reg.register(Arc::new(MockAgent {
408            name: "a".to_string(),
409        }));
410        assert_eq!(reg.list(), vec!["a".to_string(), "b".to_string()]);
411    }
412
413    fn discovered(agent_id: &str, status: DiscoveryStatus) -> DiscoveredBinary {
414        DiscoveredBinary {
415            agent_id: agent_id.to_string(),
416            binary_name: agent_id.to_string(),
417            path: PathBuf::from(format!("/usr/local/bin/{agent_id}")),
418            version: Some(format!("{agent_id} 1.0")),
419            status,
420            capabilities: AgentCapabilities::default(),
421            invocation: InvocationTemplate {
422                args: &[],
423                prompt_via_stdin: true,
424            },
425            version_args: vec!["--version".to_string()],
426        }
427    }
428
429    #[test]
430    fn populate_registers_available_agents() {
431        let mut reg = AgentRegistry::new();
432        let discovered = vec![
433            discovered("codex", DiscoveryStatus::Available),
434            discovered("aider", DiscoveryStatus::Available),
435        ];
436        reg.populate_from_discovery(discovered, &DelegateOverrides::new());
437        assert!(reg.contains("codex"));
438        assert!(reg.contains("aider"));
439        assert!(matches!(
440            reg.agent_status("codex"),
441            Some(RegistryAgentStatus::Registered { .. })
442        ));
443    }
444
445    #[test]
446    fn populate_skips_unavailable_but_records_reason() {
447        let mut reg = AgentRegistry::new();
448        let discovered = vec![discovered(
449            "broken",
450            DiscoveryStatus::Unavailable("missing API key".into()),
451        )];
452        reg.populate_from_discovery(discovered, &DelegateOverrides::new());
453        assert!(!reg.contains("broken"));
454        match reg.agent_status("broken") {
455            Some(RegistryAgentStatus::Unavailable { reason, .. }) => {
456                assert!(reason.contains("missing API key"));
457            }
458            other => panic!("expected Unavailable, got {other:?}"),
459        }
460    }
461
462    #[test]
463    fn disabled_override_blocks_registration() {
464        let mut reg = AgentRegistry::new();
465        let mut overrides = DelegateOverrides::new();
466        overrides.overrides.insert(
467            "aider".to_string(),
468            AgentOverride {
469                disabled: true,
470                ..Default::default()
471            },
472        );
473        reg.populate_from_discovery(
474            vec![discovered("aider", DiscoveryStatus::Available)],
475            &overrides,
476        );
477        assert!(!reg.contains("aider"));
478        assert!(matches!(
479            reg.agent_status("aider"),
480            Some(RegistryAgentStatus::DisabledByConfig)
481        ));
482    }
483
484    #[test]
485    fn auto_discovery_off_still_registers_custom() {
486        let mut reg = AgentRegistry::new();
487        let overrides = DelegateOverrides {
488            auto_discovery: false,
489            overrides: HashMap::new(),
490            custom: vec![CustomAgentSpec {
491                id: "mine".to_string(),
492                binary: PathBuf::from("/usr/bin/true"),
493                args: vec![],
494                prompt_via_stdin: true,
495                capabilities: AgentCapabilities::default(),
496                workdir: None,
497                alias: None,
498            }],
499        };
500        reg.populate_from_discovery(
501            vec![discovered("aider", DiscoveryStatus::Available)],
502            &overrides,
503        );
504        assert!(!reg.contains("aider"));
505        assert!(reg.contains("mine"));
506        match reg.agent_status("mine") {
507            Some(RegistryAgentStatus::Registered { source, .. }) => {
508                assert_eq!(*source, AgentSource::Custom);
509            }
510            other => panic!("expected Registered, got {other:?}"),
511        }
512    }
513
514    #[test]
515    fn override_binary_path_takes_precedence() {
516        let mut reg = AgentRegistry::new();
517        let mut overrides = DelegateOverrides::new();
518        overrides.overrides.insert(
519            "aider".to_string(),
520            AgentOverride {
521                binary: Some(PathBuf::from("/opt/custom/aider")),
522                ..Default::default()
523            },
524        );
525        reg.populate_from_discovery(
526            vec![discovered("aider", DiscoveryStatus::Available)],
527            &overrides,
528        );
529        match reg.agent_status("aider") {
530            Some(RegistryAgentStatus::Registered { binary, .. }) => {
531                assert_eq!(binary, &PathBuf::from("/opt/custom/aider"));
532            }
533            other => panic!("expected Registered, got {other:?}"),
534        }
535    }
536
537    #[test]
538    fn register_manual_records_status_with_manual_source() {
539        let mut reg = AgentRegistry::new();
540        reg.register_manual(
541            Arc::new(MockAgent {
542                name: "hand-wired".to_string(),
543            }),
544            PathBuf::from("/opt/local/bin/hand-wired"),
545            Some("1.2.3".to_string()),
546        );
547        assert!(reg.contains("hand-wired"));
548        match reg.agent_status("hand-wired") {
549            Some(RegistryAgentStatus::Registered {
550                source,
551                binary,
552                version,
553            }) => {
554                assert_eq!(*source, AgentSource::Manual);
555                assert_eq!(binary, &PathBuf::from("/opt/local/bin/hand-wired"));
556                assert_eq!(version.as_deref(), Some("1.2.3"));
557            }
558            other => panic!("expected Registered, got {other:?}"),
559        }
560    }
561
562    #[test]
563    fn register_subprocess_spec_honours_alias_and_workdir() {
564        let mut reg = AgentRegistry::new();
565        let spec = CustomAgentSpec {
566            id: "canon".to_string(),
567            binary: PathBuf::from("/usr/local/bin/canon"),
568            args: vec!["--task".to_string(), "{task_id}".to_string()],
569            prompt_via_stdin: false,
570            capabilities: AgentCapabilities {
571                tags: vec!["plan".to_string()],
572                languages: vec!["rust".to_string()],
573                max_concurrency: 3,
574                needs_network: false,
575            },
576            workdir: Some(PathBuf::from("/var/work")),
577            alias: Some("alt".to_string()),
578        };
579
580        let binary = reg.register_subprocess_spec(&spec, AgentSource::Manual);
581        assert_eq!(binary, PathBuf::from("/usr/local/bin/canon"));
582        assert!(reg.contains("canon"), "canonical id must resolve");
583        assert!(
584            reg.contains("alt"),
585            "alias registered inside spec must resolve to the same delegate"
586        );
587        match reg.agent_status("canon") {
588            Some(RegistryAgentStatus::Registered { source, binary, .. }) => {
589                assert_eq!(*source, AgentSource::Manual);
590                assert_eq!(binary, &PathBuf::from("/usr/local/bin/canon"));
591            }
592            other => panic!("expected Registered, got {other:?}"),
593        }
594    }
595
596    #[test]
597    fn register_subprocess_spec_can_record_custom_source() {
598        let mut reg = AgentRegistry::new();
599        let spec = CustomAgentSpec {
600            id: "via_overrides".to_string(),
601            binary: PathBuf::from("/opt/x"),
602            args: vec![],
603            prompt_via_stdin: true,
604            capabilities: AgentCapabilities::default(),
605            workdir: None,
606            alias: None,
607        };
608        reg.register_subprocess_spec(&spec, AgentSource::Custom);
609        match reg.agent_status("via_overrides") {
610            Some(RegistryAgentStatus::Registered { source, .. }) => {
611                assert_eq!(*source, AgentSource::Custom);
612            }
613            other => panic!("expected Registered, got {other:?}"),
614        }
615    }
616}