1use 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
16pub struct AgentOverride {
17 pub binary: Option<PathBuf>,
19 #[serde(default)]
21 pub disabled: bool,
22 pub capabilities: Option<AgentCapabilities>,
24 pub args: Option<Vec<String>>,
27 pub prompt_via_stdin: Option<bool>,
29}
30
31#[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 #[serde(default)]
47 pub workdir: Option<PathBuf>,
48 #[serde(default)]
51 pub alias: Option<String>,
52}
53
54fn default_true() -> bool {
55 true
56}
57
58#[derive(Debug, Clone, Default, Serialize, Deserialize)]
61pub struct DelegateOverrides {
62 #[serde(default = "default_true")]
64 pub auto_discovery: bool,
65 #[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum AgentSource {
103 Discovered,
104 Custom,
105 Manual,
106}
107
108#[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 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 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 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 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 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 pub fn agent_status(&self, id: &str) -> Option<&RegistryAgentStatus> {
227 self.agent_status.get(id)
228 }
229
230 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 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}