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}
44
45fn default_true() -> bool {
46    true
47}
48
49/// Full delegate config sourced from user settings. Empty default is
50/// fine — pure discovery with no overrides.
51#[derive(Debug, Clone, Default, Serialize, Deserialize)]
52pub struct DelegateOverrides {
53    /// Toggle fingerprinted auto-discovery; custom entries still register.
54    #[serde(default = "default_true")]
55    pub auto_discovery: bool,
56    /// Keyed by canonical agent id.
57    #[serde(default)]
58    pub overrides: HashMap<String, AgentOverride>,
59    #[serde(default)]
60    pub custom: Vec<CustomAgentSpec>,
61}
62
63impl DelegateOverrides {
64    pub fn new() -> Self {
65        Self {
66            auto_discovery: true,
67            ..Self::default()
68        }
69    }
70}
71
72/// What the registry knows about a candidate agent — populated by
73/// [`AgentRegistry::populate_from_discovery`] so callers (e.g.
74/// `brain doctor`, `QueryAgents` intent) can explain availability.
75#[derive(Debug, Clone)]
76pub enum RegistryAgentStatus {
77    Registered {
78        binary: PathBuf,
79        version: Option<String>,
80        source: AgentSource,
81    },
82    DisabledByConfig,
83    Unavailable {
84        binary: PathBuf,
85        reason: String,
86    },
87}
88
89/// Where a registered agent came from. Distinguishes auto-discovered
90/// entries from hand-registered / custom ones so the operator surface
91/// can be honest about "why is this here".
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum AgentSource {
94    Discovered,
95    Custom,
96    Manual,
97}
98
99/// Holds every known delegate keyed by `name()`. Additional aliases can
100/// be registered to route shorthand request names to canonical entries.
101#[derive(Default)]
102pub struct AgentRegistry {
103    delegates: HashMap<String, Arc<dyn AgentDelegate>>,
104    aliases: HashMap<String, String>,
105    agent_status: HashMap<String, RegistryAgentStatus>,
106}
107
108impl AgentRegistry {
109    pub fn new() -> Self {
110        Self::default()
111    }
112
113    /// Register a delegate under its declared `name()`. Last write wins.
114    pub fn register(&mut self, delegate: Arc<dyn AgentDelegate>) {
115        let name = delegate.name().to_string();
116        self.delegates.insert(name, delegate);
117    }
118
119    /// Register a delegate that came from hand-written `agents.delegates[]`
120    /// config. Records `agent_status` as [`AgentSource::Manual`] so the
121    /// operator surface (`QueryAgents`, doctor) sees the entry instead of
122    /// silently diverging from what the orchestrator will actually run.
123    pub fn register_manual(
124        &mut self,
125        delegate: Arc<dyn AgentDelegate>,
126        binary: PathBuf,
127        version: Option<String>,
128    ) {
129        let name = delegate.name().to_string();
130        self.agent_status.insert(
131            name.clone(),
132            RegistryAgentStatus::Registered {
133                binary,
134                version,
135                source: AgentSource::Manual,
136            },
137        );
138        self.delegates.insert(name, delegate);
139    }
140
141    /// Add an alias: `alias -> canonical_name`. If `canonical_name` isn't
142    /// registered yet, the alias is still stored — resolved lazily.
143    pub fn alias(&mut self, alias: impl Into<String>, canonical: impl Into<String>) {
144        self.aliases.insert(alias.into(), canonical.into());
145    }
146
147    pub fn get(&self, name: &str) -> Result<Arc<dyn AgentDelegate>, AgentError> {
148        let resolved = self.aliases.get(name).map(String::as_str).unwrap_or(name);
149        self.delegates
150            .get(resolved)
151            .cloned()
152            .ok_or_else(|| AgentError::NotFound(name.to_string()))
153    }
154
155    pub fn contains(&self, name: &str) -> bool {
156        let resolved = self.aliases.get(name).map(String::as_str).unwrap_or(name);
157        self.delegates.contains_key(resolved)
158    }
159
160    /// Ordered list of canonical delegate names.
161    pub fn list(&self) -> Vec<String> {
162        let mut names: Vec<String> = self.delegates.keys().cloned().collect();
163        names.sort();
164        names
165    }
166
167    pub fn is_empty(&self) -> bool {
168        self.delegates.is_empty()
169    }
170
171    /// Known state for an agent id — including ones we saw but did not
172    /// register (disabled, probe-failed). Returns `None` for ids the
173    /// discovery pass never encountered.
174    pub fn agent_status(&self, id: &str) -> Option<&RegistryAgentStatus> {
175        self.agent_status.get(id)
176    }
177
178    /// Every agent id we've learned about, whether registered or not.
179    pub fn known_agents(&self) -> Vec<(&str, &RegistryAgentStatus)> {
180        let mut v: Vec<_> = self
181            .agent_status
182            .iter()
183            .map(|(k, v)| (k.as_str(), v))
184            .collect();
185        v.sort_by_key(|(k, _)| *k);
186        v
187    }
188
189    /// Consume a discovery pass + user overrides and register runnable
190    /// delegates for every `Available` agent that isn't disabled.
191    ///
192    /// Unavailable or disabled agents are *not* registered but their
193    /// reason is retained in `agent_status` so the operator surface can
194    /// answer "why isn't X available". Existing manual registrations
195    /// are left untouched.
196    pub fn populate_from_discovery(
197        &mut self,
198        discovered: Vec<DiscoveredBinary>,
199        overrides: &DelegateOverrides,
200    ) {
201        if overrides.auto_discovery {
202            for d in discovered {
203                let ov = overrides
204                    .overrides
205                    .get(&d.agent_id)
206                    .cloned()
207                    .unwrap_or_default();
208                if ov.disabled {
209                    self.agent_status
210                        .insert(d.agent_id.clone(), RegistryAgentStatus::DisabledByConfig);
211                    continue;
212                }
213                match &d.status {
214                    DiscoveryStatus::Available => {
215                        let binary = ov.binary.clone().unwrap_or_else(|| d.path.clone());
216                        let caps = ov
217                            .capabilities
218                            .clone()
219                            .unwrap_or_else(|| d.capabilities.clone());
220                        let delegate = build_from_template(
221                            &d.agent_id,
222                            &binary,
223                            &d.invocation,
224                            &d.version_args,
225                            &ov,
226                            caps,
227                        );
228                        self.agent_status.insert(
229                            d.agent_id.clone(),
230                            RegistryAgentStatus::Registered {
231                                binary,
232                                version: d.version.clone(),
233                                source: AgentSource::Discovered,
234                            },
235                        );
236                        self.register(delegate);
237                    }
238                    DiscoveryStatus::Unavailable(reason) => {
239                        self.agent_status.insert(
240                            d.agent_id.clone(),
241                            RegistryAgentStatus::Unavailable {
242                                binary: d.path.clone(),
243                                reason: reason.clone(),
244                            },
245                        );
246                    }
247                }
248            }
249        }
250
251        for spec in &overrides.custom {
252            let caps = spec.capabilities.clone();
253            let cfg = SubprocessAgentConfig {
254                name: spec.id.clone(),
255                binary: spec.binary.to_string_lossy().into_owned(),
256                args: spec.args.clone(),
257                workdir: None,
258                capabilities: caps,
259                prompt_via_stdin: spec.prompt_via_stdin,
260                version_args: vec!["--version".to_string()],
261            };
262            let delegate: Arc<dyn AgentDelegate> = Arc::new(SubprocessAgentDelegate::new(cfg));
263            self.agent_status.insert(
264                spec.id.clone(),
265                RegistryAgentStatus::Registered {
266                    binary: spec.binary.clone(),
267                    version: None,
268                    source: AgentSource::Custom,
269                },
270            );
271            self.register(delegate);
272        }
273    }
274}
275
276fn build_from_template(
277    agent_id: &str,
278    binary: &Path,
279    default_invocation: &InvocationTemplate,
280    version_args: &[String],
281    ov: &AgentOverride,
282    capabilities: AgentCapabilities,
283) -> Arc<dyn AgentDelegate> {
284    let args = ov.args.clone().unwrap_or_else(|| {
285        default_invocation
286            .args
287            .iter()
288            .map(|s| (*s).to_string())
289            .collect::<Vec<_>>()
290    });
291    let prompt_via_stdin = ov
292        .prompt_via_stdin
293        .unwrap_or(default_invocation.prompt_via_stdin);
294    let cfg = SubprocessAgentConfig {
295        name: agent_id.to_string(),
296        binary: binary.to_string_lossy().into_owned(),
297        args,
298        workdir: None,
299        capabilities,
300        prompt_via_stdin,
301        version_args: version_args.to_vec(),
302    };
303    Arc::new(SubprocessAgentDelegate::new(cfg))
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309    use crate::traits::{AgentCapabilities, AgentResult, AgentTask, AgentTaskStatus};
310    use async_trait::async_trait;
311    use chrono::Utc;
312
313    struct MockAgent {
314        name: String,
315    }
316
317    #[async_trait]
318    impl AgentDelegate for MockAgent {
319        fn name(&self) -> &str {
320            &self.name
321        }
322        fn capabilities(&self) -> AgentCapabilities {
323            AgentCapabilities::default()
324        }
325        async fn delegate(&self, task: AgentTask) -> Result<AgentResult, AgentError> {
326            let now = Utc::now();
327            Ok(AgentResult {
328                task_id: task.id,
329                status: AgentTaskStatus::Succeeded,
330                summary: format!("{} ran: {}", self.name, task.description),
331                artifacts: vec![],
332                stdout: String::new(),
333                stderr: String::new(),
334                exit_code: Some(0),
335                started_at: now,
336                completed_at: now,
337            })
338        }
339    }
340
341    #[test]
342    fn register_and_get() {
343        let mut reg = AgentRegistry::new();
344        reg.register(Arc::new(MockAgent {
345            name: "mock".to_string(),
346        }));
347        let d = reg.get("mock").unwrap();
348        assert_eq!(d.name(), "mock");
349    }
350
351    #[test]
352    fn alias_resolves() {
353        let mut reg = AgentRegistry::new();
354        reg.register(Arc::new(MockAgent {
355            name: "claude-code".to_string(),
356        }));
357        reg.alias("claude", "claude-code");
358        assert!(reg.contains("claude"));
359        assert_eq!(reg.get("claude").unwrap().name(), "claude-code");
360    }
361
362    #[test]
363    fn missing_delegate_errors() {
364        let reg = AgentRegistry::new();
365        assert!(matches!(reg.get("nope"), Err(AgentError::NotFound(_))));
366    }
367
368    #[test]
369    fn list_returns_sorted_names() {
370        let mut reg = AgentRegistry::new();
371        reg.register(Arc::new(MockAgent {
372            name: "b".to_string(),
373        }));
374        reg.register(Arc::new(MockAgent {
375            name: "a".to_string(),
376        }));
377        assert_eq!(reg.list(), vec!["a".to_string(), "b".to_string()]);
378    }
379
380    fn discovered(agent_id: &str, status: DiscoveryStatus) -> DiscoveredBinary {
381        DiscoveredBinary {
382            agent_id: agent_id.to_string(),
383            binary_name: agent_id.to_string(),
384            path: PathBuf::from(format!("/usr/local/bin/{agent_id}")),
385            version: Some(format!("{agent_id} 1.0")),
386            status,
387            capabilities: AgentCapabilities::default(),
388            invocation: InvocationTemplate {
389                args: &[],
390                prompt_via_stdin: true,
391            },
392            version_args: vec!["--version".to_string()],
393        }
394    }
395
396    #[test]
397    fn populate_registers_available_agents() {
398        let mut reg = AgentRegistry::new();
399        let discovered = vec![
400            discovered("codex", DiscoveryStatus::Available),
401            discovered("aider", DiscoveryStatus::Available),
402        ];
403        reg.populate_from_discovery(discovered, &DelegateOverrides::new());
404        assert!(reg.contains("codex"));
405        assert!(reg.contains("aider"));
406        assert!(matches!(
407            reg.agent_status("codex"),
408            Some(RegistryAgentStatus::Registered { .. })
409        ));
410    }
411
412    #[test]
413    fn populate_skips_unavailable_but_records_reason() {
414        let mut reg = AgentRegistry::new();
415        let discovered = vec![discovered(
416            "broken",
417            DiscoveryStatus::Unavailable("missing API key".into()),
418        )];
419        reg.populate_from_discovery(discovered, &DelegateOverrides::new());
420        assert!(!reg.contains("broken"));
421        match reg.agent_status("broken") {
422            Some(RegistryAgentStatus::Unavailable { reason, .. }) => {
423                assert!(reason.contains("missing API key"));
424            }
425            other => panic!("expected Unavailable, got {other:?}"),
426        }
427    }
428
429    #[test]
430    fn disabled_override_blocks_registration() {
431        let mut reg = AgentRegistry::new();
432        let mut overrides = DelegateOverrides::new();
433        overrides.overrides.insert(
434            "aider".to_string(),
435            AgentOverride {
436                disabled: true,
437                ..Default::default()
438            },
439        );
440        reg.populate_from_discovery(
441            vec![discovered("aider", DiscoveryStatus::Available)],
442            &overrides,
443        );
444        assert!(!reg.contains("aider"));
445        assert!(matches!(
446            reg.agent_status("aider"),
447            Some(RegistryAgentStatus::DisabledByConfig)
448        ));
449    }
450
451    #[test]
452    fn auto_discovery_off_still_registers_custom() {
453        let mut reg = AgentRegistry::new();
454        let overrides = DelegateOverrides {
455            auto_discovery: false,
456            overrides: HashMap::new(),
457            custom: vec![CustomAgentSpec {
458                id: "mine".to_string(),
459                binary: PathBuf::from("/usr/bin/true"),
460                args: vec![],
461                prompt_via_stdin: true,
462                capabilities: AgentCapabilities::default(),
463            }],
464        };
465        reg.populate_from_discovery(
466            vec![discovered("aider", DiscoveryStatus::Available)],
467            &overrides,
468        );
469        assert!(!reg.contains("aider"));
470        assert!(reg.contains("mine"));
471        match reg.agent_status("mine") {
472            Some(RegistryAgentStatus::Registered { source, .. }) => {
473                assert_eq!(*source, AgentSource::Custom);
474            }
475            other => panic!("expected Registered, got {other:?}"),
476        }
477    }
478
479    #[test]
480    fn override_binary_path_takes_precedence() {
481        let mut reg = AgentRegistry::new();
482        let mut overrides = DelegateOverrides::new();
483        overrides.overrides.insert(
484            "aider".to_string(),
485            AgentOverride {
486                binary: Some(PathBuf::from("/opt/custom/aider")),
487                ..Default::default()
488            },
489        );
490        reg.populate_from_discovery(
491            vec![discovered("aider", DiscoveryStatus::Available)],
492            &overrides,
493        );
494        match reg.agent_status("aider") {
495            Some(RegistryAgentStatus::Registered { binary, .. }) => {
496                assert_eq!(binary, &PathBuf::from("/opt/custom/aider"));
497            }
498            other => panic!("expected Registered, got {other:?}"),
499        }
500    }
501
502    #[test]
503    fn register_manual_records_status_with_manual_source() {
504        let mut reg = AgentRegistry::new();
505        reg.register_manual(
506            Arc::new(MockAgent {
507                name: "hand-wired".to_string(),
508            }),
509            PathBuf::from("/opt/local/bin/hand-wired"),
510            Some("1.2.3".to_string()),
511        );
512        assert!(reg.contains("hand-wired"));
513        match reg.agent_status("hand-wired") {
514            Some(RegistryAgentStatus::Registered {
515                source,
516                binary,
517                version,
518            }) => {
519                assert_eq!(*source, AgentSource::Manual);
520                assert_eq!(binary, &PathBuf::from("/opt/local/bin/hand-wired"));
521                assert_eq!(version.as_deref(), Some("1.2.3"));
522            }
523            other => panic!("expected Registered, got {other:?}"),
524        }
525    }
526}