Skip to main content

ralph_workflow/agents/registry/
management.rs

1// Registry management and lookup operations.
2// Includes the AgentRegistry struct definition and core lookup/management methods.
3
4/// Typed error for agent chain validation failures.
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub enum AgentChainValidationError {
7    /// No agent chain configured at all (no drain has any binding with agents).
8    NoChainConfigured { searched_sources: String },
9    /// A specific drain has no binding.
10    NoDrainBinding {
11        drain: String,
12        searched_sources: String,
13    },
14    /// A specific drain's bound chain is empty.
15    EmptyDrainChain {
16        drain: String,
17        searched_sources: String,
18    },
19    /// All agents in a drain have `can_commit=false`.
20    NoWorkflowCapableAgents { drain: String },
21}
22
23impl std::fmt::Display for AgentChainValidationError {
24    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25        match self {
26            Self::NoChainConfigured { searched_sources } => write!(
27                f,
28                "No agent chain configured. \
29                Searched: {searched_sources}.\n\
30                Please add [agent_chains] and [agent_drains] sections to your config.\n\
31                Legacy [agent_chain] input is deprecated but still accepted with default drain bindings.\n\
32                Run 'ralph --init-global' to create a default configuration."
33            ),
34            Self::NoDrainBinding { drain, searched_sources } => write!(
35                f,
36                "No {drain} agent chain configured. \
37                Searched: {searched_sources}.\n\
38                Bind the {drain} drain in [agent_drains] to a chain from [agent_chains].\n\
39                Use --list-agents to see available agents."
40            ),
41            Self::EmptyDrainChain { drain, searched_sources } => write!(
42                f,
43                "No {drain} agent chain configured. \
44                Searched: {searched_sources}.\n\
45                Bind the {drain} drain in [agent_drains] to a non-empty chain from [agent_chains].\n\
46                Use --list-agents to see available agents."
47            ),
48            Self::NoWorkflowCapableAgents { drain } => write!(
49                f,
50                "No workflow-capable agents found for {drain}.\n\
51                All agents in the {drain} drain binding have can_commit=false.\n\
52                Fix: set can_commit=true for at least one agent or update [agent_chains]/[agent_drains]."
53            ),
54        }
55    }
56}
57
58impl std::error::Error for AgentChainValidationError {}
59
60/// Agent registry with CCS alias and `OpenCode` dynamic provider/model support.
61///
62/// CCS aliases are eagerly resolved and registered as regular agents
63/// when set via `set_ccs_aliases()`. This allows `get()` to work
64/// uniformly for both regular agents and CCS aliases.
65///
66/// `OpenCode` provider/model combinations are resolved on-the-fly using
67/// the `opencode/` prefix.
68#[derive(Debug)]
69pub struct AgentRegistry {
70    agents: HashMap<String, AgentConfig>,
71    resolved_drains: crate::agents::fallback::ResolvedDrainConfig,
72    /// CCS alias resolver for `ccs/alias` syntax.
73    ccs_resolver: CcsAliasResolver,
74    /// `OpenCode` resolver for `opencode/provider/model` syntax.
75    opencode_resolver: Option<OpenCodeResolver>,
76    /// Retry timer provider for controlling sleep behavior in retry logic.
77    retry_timer: Arc<dyn RetryTimerProviderDebug>,
78}
79
80impl AgentRegistry {
81    /// Create a new registry with default agents.
82    ///
83    /// # Errors
84    ///
85    /// Returns error if the operation fails.
86    pub fn new() -> Result<Self, AgentConfigError> {
87        let config: AgentsConfigFile =
88            toml::from_str(DEFAULT_AGENTS_TOML).map_err(AgentConfigError::DefaultTemplateToml)?;
89        let resolved_drains = config.resolve_drains_checked()?.unwrap_or_else(|| {
90            crate::agents::fallback::ResolvedDrainConfig::from_legacy(&FallbackConfig::default())
91        });
92        let agents = config.agents;
93
94        // Create agents map functionally
95        let agents: HashMap<_, _> = agents
96            .into_iter()
97            .map(|(name, agent_toml)| (name, AgentConfig::from(agent_toml)))
98            .collect();
99
100        Ok(Self {
101            agents,
102            resolved_drains,
103            ccs_resolver: CcsAliasResolver::empty(),
104            opencode_resolver: None,
105            retry_timer: production_timer(),
106        })
107    }
108
109    /// Set the `OpenCode` API catalog for dynamic provider/model resolution.
110    ///
111    /// This enables resolution of `opencode/provider/model` agent references.
112    pub fn set_opencode_catalog(&mut self, catalog: ApiCatalog) {
113        self.opencode_resolver = Some(OpenCodeResolver::new(catalog));
114    }
115
116    /// Returns a new registry with the `OpenCode` catalog set while keeping the original immutably.
117    #[must_use]
118    pub fn with_opencode_catalog(mut self, catalog: ApiCatalog) -> Self {
119        self.set_opencode_catalog(catalog);
120        self
121    }
122
123    /// Set CCS aliases for the registry.
124    ///
125    /// This eagerly registers CCS aliases as agents so they can be
126    /// resolved with `resolve_config()`.
127    pub fn set_ccs_aliases(
128        self,
129        aliases: &HashMap<String, CcsAliasConfig>,
130        defaults: CcsConfig,
131    ) -> Self {
132        let ccs_resolver = CcsAliasResolver::new(aliases.clone(), defaults);
133        // Compute all agents to insert - functional style with chain
134        let new_agents: HashMap<_, _> = aliases
135            .keys()
136            .filter_map(|alias_name| {
137                let agent_name = format!("ccs/{alias_name}");
138                ccs_resolver
139                    .try_resolve(&agent_name)
140                    .map(|config| (agent_name, config))
141            })
142            .collect();
143        // Rebuild with chain - functional style
144        let agents = self.agents.into_iter().chain(new_agents).collect();
145        Self {
146            agents,
147            resolved_drains: self.resolved_drains,
148            ccs_resolver,
149            opencode_resolver: self.opencode_resolver,
150            retry_timer: self.retry_timer,
151        }
152    }
153
154    /// Register a new agent.
155    pub fn register(self, name: &str, config: AgentConfig) -> Self {
156        // Rebuild with chain - functional style instead of mutating insert
157        let agents = self
158            .agents
159            .into_iter()
160            .chain(std::iter::once((name.to_string(), config)))
161            .collect();
162        Self {
163            agents,
164            resolved_drains: self.resolved_drains,
165            ccs_resolver: self.ccs_resolver,
166            opencode_resolver: self.opencode_resolver,
167            retry_timer: self.retry_timer,
168        }
169    }
170
171    /// Create a registry with only built-in agents (no config file loading).
172    ///
173    /// This is useful for integration tests that need a minimal registry
174    /// without loading from config files or environment variables.
175    ///
176    /// # Test-Utils Only
177    ///
178    /// # Panics
179    ///
180    /// Panics if invariants are violated.
181    ///
182    /// This function is only available when the `test-utils` feature is enabled.
183    #[cfg(feature = "test-utils")]
184    #[must_use]
185    #[expect(
186        clippy::expect_used,
187        reason = "built-in agents are hardcoded and always valid"
188    )]
189    pub fn with_builtins_only() -> Self {
190        Self::new().expect("Built-in agents should always be valid")
191    }
192
193    /// Resolve an agent's configuration, including on-the-fly CCS and `OpenCode` references.
194    ///
195    /// CCS supports direct execution via `ccs/<alias>` even when the alias isn't
196    /// pre-registered in config; those are resolved lazily here.
197    ///
198    /// `OpenCode` supports dynamic provider/model via `opencode/provider/model` syntax;
199    /// those are validated against the API catalog and resolved lazily here.
200    #[must_use]
201    pub fn resolve_config(&self, name: &str) -> Option<AgentConfig> {
202        self.agents
203            .get(name)
204            .cloned()
205            .or_else(|| self.ccs_resolver.try_resolve(name))
206            .or_else(|| {
207                self.opencode_resolver
208                    .as_ref()
209                    .and_then(|r| r.try_resolve(name))
210            })
211    }
212
213    /// Get display name for an agent.
214    ///
215    /// Returns the agent's custom display name if set (e.g., "ccs-glm" for CCS aliases),
216    /// otherwise returns the agent's registry name.
217    ///
218    /// # Arguments
219    ///
220    /// * `name` - The agent's registry name (e.g., "ccs/glm", "claude")
221    ///
222    /// # Examples
223    ///
224    /// ```ignore
225    /// assert_eq!(registry.display_name("ccs/glm"), "ccs-glm");
226    /// assert_eq!(registry.display_name("claude"), "claude");
227    /// ```
228    #[must_use]
229    pub fn display_name(&self, name: &str) -> String {
230        self.resolve_config(name)
231            .and_then(|config| config.display_name)
232            .unwrap_or_else(|| name.to_string())
233    }
234
235    /// Find the registry name for an agent given its log file name.
236    ///
237    /// Log file names use a sanitized form of the registry name where `/` is
238    /// replaced with `-` to avoid creating subdirectories. This function
239    /// reverses that sanitization to find the original registry name.
240    ///
241    /// This is used for session continuation, where the agent name is extracted
242    /// from log file names (e.g., "ccs-glm", "opencode-anthropic-claude-sonnet-4")
243    /// but we need to look up the agent in the registry (which uses names like
244    /// "ccs/glm", "opencode/anthropic/claude-sonnet-4").
245    ///
246    /// # Strategy
247    ///
248    /// 1. Check if the name is already a valid registry key (no sanitization needed)
249    /// 2. Search registered agents for one whose sanitized name matches
250    /// 3. Try common patterns like "ccs-X" → "ccs/X", "opencode-X-Y" → "opencode/X/Y"
251    ///
252    /// # Arguments
253    ///
254    /// * `logfile_name` - The agent name extracted from a log file (e.g., "ccs-glm")
255    ///
256    /// # Returns
257    ///
258    /// The registry name if found (e.g., "ccs/glm"), or `None` if no match.
259    ///
260    /// # Examples
261    ///
262    /// ```ignore
263    /// assert_eq!(registry.resolve_from_logfile_name("ccs-glm"), Some("ccs/glm".to_string()));
264    /// assert_eq!(registry.resolve_from_logfile_name("claude"), Some("claude".to_string()));
265    /// assert_eq!(registry.resolve_from_logfile_name("opencode-anthropic-claude-sonnet-4"),
266    ///            Some("opencode/anthropic/claude-sonnet-4".to_string()));
267    /// ```
268    #[must_use]
269    #[expect(
270        clippy::arithmetic_side_effects,
271        reason = "bounds-checked index arithmetic"
272    )]
273    pub fn resolve_from_logfile_name(&self, logfile_name: &str) -> Option<String> {
274        // First check if the name is exactly a registry name (no sanitization was needed)
275        if self.agents.contains_key(logfile_name) {
276            return Some(logfile_name.to_string());
277        }
278
279        // Search registered agents for one whose sanitized name matches - use iterator
280        let match_result = self
281            .agents
282            .keys()
283            .find(|name| name.replace('/', "-") == logfile_name);
284        if let Some(name) = match_result {
285            return Some(name.clone());
286        }
287
288        // Try to resolve dynamically for unregistered agents
289        // CCS pattern: "ccs-alias" → "ccs/alias"
290        if let Some(alias) = logfile_name.strip_prefix("ccs-") {
291            let registry_name = format!("ccs/{alias}");
292            // CCS agents can be resolved dynamically even if not pre-registered
293            return Some(registry_name);
294        }
295
296        // OpenCode pattern: "opencode-provider-model" → "opencode/provider/model"
297        // Note: This is a best-effort heuristic for log file name parsing.
298        // Provider names may contain hyphens (e.g., "zai-coding-plan"), making it
299        // impossible to reliably split "opencode-zai-coding-plan-glm-4.7".
300        // The preferred approach is to pass the original agent name through
301        // SessionInfo rather than relying on log file name parsing.
302        if let Some(rest) = logfile_name.strip_prefix("opencode-") {
303            if let Some(first_hyphen) = rest.find('-') {
304                let provider = &rest[..first_hyphen];
305                let model = &rest[first_hyphen + 1..];
306                let registry_name = format!("opencode/{provider}/{model}");
307                return Some(registry_name);
308            }
309        }
310
311        // No match found
312        None
313    }
314
315    /// Resolve a fuzzy agent name to a canonical agent name.
316    ///
317    /// This handles common typos and alternative forms:
318    /// - `ccs/<unregistered>`: Returns the name as-is for direct CCS execution
319    /// - `opencode/provider/model`: Returns the name as-is for dynamic resolution
320    /// - Other fuzzy matches: Returns the canonical name if a match is found
321    /// - Exact matches: Returns the name as-is
322    ///
323    /// Returns `None` if the name cannot be resolved to any known agent.
324    #[must_use]
325    pub fn resolve_fuzzy(&self, name: &str) -> Option<String> {
326        // First check if it's an exact match
327        if self.agents.contains_key(name) {
328            return Some(name.to_string());
329        }
330
331        // Handle ccs/<unregistered> pattern - return as-is for direct CCS execution
332        if name.starts_with("ccs/") {
333            return Some(name.to_string());
334        }
335
336        // Handle opencode/provider/model pattern - return as-is for dynamic resolution
337        if name.starts_with("opencode/") {
338            // Validate that it has the right format (opencode/provider/model)
339            let parts: Vec<&str> = name.split('/').collect();
340            if parts.len() == 3 && parts.first().is_some_and(|p| *p == "opencode") {
341                return Some(name.to_string());
342            }
343        }
344
345        // Handle common typos/alternatives
346        let normalized = name.to_lowercase();
347        let alternatives = Self::get_fuzzy_alternatives(&normalized);
348
349        // Find first matching alternative
350        alternatives.into_iter().find_map(|alt| {
351            // If it's a ccs/ pattern, return it for direct CCS execution
352            if alt.starts_with("ccs/") {
353                return Some(alt);
354            }
355            // If it's an opencode/ pattern, validate the format
356            if alt.starts_with("opencode/") {
357                let parts: Vec<&str> = alt.split('/').collect();
358                if parts.len() == 3 && parts.first().is_some_and(|p| *p == "opencode") {
359                    return Some(alt);
360                }
361            }
362            // Otherwise check if it exists in the registry
363            if self.agents.contains_key(&alt) {
364                return Some(alt);
365            }
366            None
367        })
368    }
369
370    /// Get fuzzy alternatives for a given agent name.
371    ///
372    /// Returns a list of potential canonical names to try, in order of preference.
373    pub(crate) fn get_fuzzy_alternatives(name: &str) -> Vec<String> {
374        // Start with exact match first
375        let alternatives = std::iter::once(name.to_string());
376
377        // Handle common typos and variations
378        let variations: Vec<String> = match name {
379            // ccs variations
380            n if n.starts_with("ccs-") => vec![name.replace("ccs-", "ccs/")],
381            n if n.contains('_') => vec![name.replace('_', "-"), name.replace('_', "/")],
382
383            // claude variations
384            "claud" | "cloud" => vec!["claude".to_string()],
385
386            // codex variations
387            "codeex" | "code-x" => vec!["codex".to_string()],
388
389            // cursor variations
390            "crusor" => vec!["cursor".to_string()],
391
392            // opencode variations
393            "opencode" | "open-code" => vec!["opencode".to_string()],
394
395            // gemini variations
396            "gemeni" | "gemni" => vec!["gemini".to_string()],
397
398            // qwen variations
399            "quen" | "quwen" => vec!["qwen".to_string()],
400
401            // aider variations
402            "ader" => vec!["aider".to_string()],
403
404            // vibe variations
405            "vib" => vec!["vibe".to_string()],
406
407            // cline variations
408            "kline" => vec!["cline".to_string()],
409
410            _ => vec![],
411        };
412
413        alternatives.chain(variations).collect()
414    }
415
416    /// List all registered agents.
417    #[must_use]
418    pub fn list(&self) -> Vec<(&str, &AgentConfig)> {
419        self.agents.iter().map(|(k, v)| (k.as_str(), v)).collect()
420    }
421
422    /// Get command for developer role.
423    #[must_use]
424    pub fn developer_cmd(&self, agent_name: &str) -> Option<String> {
425        self.resolve_config(agent_name)
426            .map(|c| c.build_cmd(true, true, true))
427    }
428
429    /// Get command for reviewer role.
430    #[must_use]
431    pub fn reviewer_cmd(&self, agent_name: &str) -> Option<String> {
432        self.resolve_config(agent_name)
433            .map(|c| c.build_cmd(true, true, false))
434    }
435
436    #[must_use]
437    pub fn resolved_drain(
438        &self,
439        drain: crate::agents::AgentDrain,
440    ) -> Option<&crate::agents::fallback::ResolvedDrainBinding> {
441        self.resolved_drains.binding(drain)
442    }
443
444    #[must_use]
445    pub const fn resolved_drains(&self) -> &crate::agents::fallback::ResolvedDrainConfig {
446        &self.resolved_drains
447    }
448
449    /// Get a compatibility projection of the resolved drain bindings.
450    ///
451    /// Runtime code should prefer `resolved_drains()` / `resolved_drain()`.
452    #[must_use]
453    pub fn fallback_config(&self) -> FallbackConfig {
454        self.resolved_drains.to_legacy_fallback()
455    }
456
457    /// Get the retry timer provider.
458    #[must_use]
459    pub fn retry_timer(&self) -> Arc<dyn RetryTimerProvider> {
460        Arc::clone(&self.retry_timer) as Arc<dyn RetryTimerProvider>
461    }
462
463    /// Set the retry timer provider (for testing purposes).
464    ///
465    /// This is used to inject a test timer that doesn't actually sleep,
466    /// enabling fast test execution without waiting for retry delays.
467    #[cfg(any(test, feature = "test-utils"))]
468    pub fn set_retry_timer(&mut self, timer: Arc<dyn RetryTimerProviderDebug>) {
469        self.retry_timer = timer;
470    }
471
472    /// Get all fallback agents for a role that are registered in this registry.
473    #[must_use]
474    pub fn available_fallbacks(&self, role: AgentRole) -> Vec<&str> {
475        self.available_fallbacks_for_drain(crate::agents::AgentDrain::from(role))
476    }
477
478    /// Get all fallback agents for a drain that are registered in this registry.
479    #[must_use]
480    pub fn available_fallbacks_for_drain(&self, drain: crate::agents::AgentDrain) -> Vec<&str> {
481        self.resolved_drain(drain)
482            .map_or(&[][..], |binding| binding.agents.as_slice())
483            .iter()
484            .filter(|name| self.is_agent_available(name))
485            // Agents with can_commit=false are chat-only / non-tool agents and will stall Ralph.
486            .filter(|name| {
487                self.resolve_config(name.as_str())
488                    .is_some_and(|cfg| cfg.can_commit)
489            })
490            .map(std::string::String::as_str)
491            .collect()
492    }
493
494    /// Validate that every built-in runtime drain has workflow-capable coverage.
495    ///
496    /// # Errors
497    ///
498    /// Returns error if the operation fails.
499    pub fn validate_agent_chains(
500        &self,
501        searched_sources: &str,
502    ) -> Result<(), AgentChainValidationError> {
503        let drain_bindings: Vec<_> = crate::agents::AgentDrain::all()
504            .into_iter()
505            .map(|drain| (drain, self.resolved_drain(drain)))
506            .collect();
507
508        let has_any_binding = drain_bindings
509            .iter()
510            .any(|(_, binding)| binding.is_some_and(|binding| !binding.agents.is_empty()));
511
512        if !has_any_binding {
513            return Err(AgentChainValidationError::NoChainConfigured {
514                searched_sources: searched_sources.to_string(),
515            });
516        }
517
518        // Validate each drain - fail on first error
519        drain_bindings.iter().try_fold((), |(), (drain, binding)| {
520            let binding = binding.ok_or_else(|| AgentChainValidationError::NoDrainBinding {
521                drain: drain.to_string(),
522                searched_sources: searched_sources.to_string(),
523            })?;
524
525            if binding.agents.is_empty() {
526                return Err(AgentChainValidationError::EmptyDrainChain {
527                    drain: drain.to_string(),
528                    searched_sources: searched_sources.to_string(),
529                });
530            }
531
532            let has_capable = binding
533                .agents
534                .iter()
535                .any(|name| self.resolve_config(name).is_some_and(|cfg| cfg.can_commit));
536            if !has_capable {
537                return Err(AgentChainValidationError::NoWorkflowCapableAgents {
538                    drain: drain.to_string(),
539                });
540            }
541
542            Ok(())
543        })
544    }
545
546    /// Check if an agent is available (command exists and is executable).
547    #[must_use]
548    pub fn is_agent_available(&self, name: &str) -> bool {
549        if let Some(config) = self.resolve_config(name) {
550            let Ok(parts) = crate::common::split_command(&config.cmd) else {
551                return false;
552            };
553            let Some(base_cmd) = parts.first() else {
554                return false;
555            };
556
557            // Check if the command exists in PATH
558            which::which(base_cmd).is_ok()
559        } else {
560            false
561        }
562    }
563
564    /// List all available (installed) agents.
565    pub fn list_available(&self) -> Vec<&str> {
566        self.agents
567            .keys()
568            .filter(|name| self.is_agent_available(name))
569            .map(std::string::String::as_str)
570            .collect()
571    }
572}