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/// Agent registry with CCS alias and OpenCode dynamic provider/model support.
5///
6/// CCS aliases are eagerly resolved and registered as regular agents
7/// when set via `set_ccs_aliases()`. This allows `get()` to work
8/// uniformly for both regular agents and CCS aliases.
9///
10/// OpenCode provider/model combinations are resolved on-the-fly using
11/// the `opencode/` prefix.
12pub struct AgentRegistry {
13    agents: HashMap<String, AgentConfig>,
14    fallback: FallbackConfig,
15    /// CCS alias resolver for `ccs/alias` syntax.
16    ccs_resolver: CcsAliasResolver,
17    /// OpenCode resolver for `opencode/provider/model` syntax.
18    opencode_resolver: Option<OpenCodeResolver>,
19    /// Retry timer provider for controlling sleep behavior in retry logic.
20    retry_timer: Arc<dyn RetryTimerProvider>,
21}
22
23impl AgentRegistry {
24    /// Create a new registry with default agents.
25    pub fn new() -> Result<Self, AgentConfigError> {
26        let AgentsConfigFile { agents, fallback } =
27            toml::from_str(DEFAULT_AGENTS_TOML).map_err(AgentConfigError::DefaultTemplateToml)?;
28
29        let mut registry = Self {
30            agents: HashMap::new(),
31            fallback,
32            ccs_resolver: CcsAliasResolver::empty(),
33            opencode_resolver: None,
34            retry_timer: production_timer(),
35        };
36
37        for (name, agent_toml) in agents {
38            registry.register(&name, AgentConfig::from(agent_toml));
39        }
40
41        Ok(registry)
42    }
43
44    /// Set the OpenCode API catalog for dynamic provider/model resolution.
45    ///
46    /// This enables resolution of `opencode/provider/model` agent references.
47    pub fn set_opencode_catalog(&mut self, catalog: ApiCatalog) {
48        self.opencode_resolver = Some(OpenCodeResolver::new(catalog));
49    }
50
51    /// Set CCS aliases for the registry.
52    ///
53    /// This eagerly registers CCS aliases as agents so they can be
54    /// resolved with `resolve_config()`.
55    pub fn set_ccs_aliases(
56        &mut self,
57        aliases: &HashMap<String, CcsAliasConfig>,
58        defaults: CcsConfig,
59    ) {
60        self.ccs_resolver = CcsAliasResolver::new(aliases.clone(), defaults);
61        // Eagerly register CCS aliases as agents
62        for alias_name in aliases.keys() {
63            let agent_name = format!("ccs/{alias_name}");
64            if let Some(config) = self.ccs_resolver.try_resolve(&agent_name) {
65                self.agents.insert(agent_name, config);
66            }
67        }
68    }
69
70    /// Register a new agent.
71    pub fn register(&mut self, name: &str, config: AgentConfig) {
72        self.agents.insert(name.to_string(), config);
73    }
74
75    /// Create a registry with only built-in agents (no config file loading).
76    ///
77    /// This is useful for integration tests that need a minimal registry
78    /// without loading from config files or environment variables.
79    ///
80    /// # Test-Utils Only
81    ///
82    /// This function is only available when the `test-utils` feature is enabled.
83    #[cfg(feature = "test-utils")]
84    #[must_use]
85    pub fn with_builtins_only() -> Self {
86        Self::new().expect("Built-in agents should always be valid")
87    }
88
89    /// Resolve an agent's configuration, including on-the-fly CCS and OpenCode references.
90    ///
91    /// CCS supports direct execution via `ccs/<alias>` even when the alias isn't
92    /// pre-registered in config; those are resolved lazily here.
93    ///
94    /// OpenCode supports dynamic provider/model via `opencode/provider/model` syntax;
95    /// those are validated against the API catalog and resolved lazily here.
96    pub fn resolve_config(&self, name: &str) -> Option<AgentConfig> {
97        self.agents
98            .get(name)
99            .cloned()
100            .or_else(|| self.ccs_resolver.try_resolve(name))
101            .or_else(|| {
102                self.opencode_resolver
103                    .as_ref()
104                    .and_then(|r| r.try_resolve(name))
105            })
106    }
107
108    /// Get display name for an agent.
109    ///
110    /// Returns the agent's custom display name if set (e.g., "ccs-glm" for CCS aliases),
111    /// otherwise returns the agent's registry name.
112    ///
113    /// # Arguments
114    ///
115    /// * `name` - The agent's registry name (e.g., "ccs/glm", "claude")
116    ///
117    /// # Examples
118    ///
119    /// ```ignore
120    /// assert_eq!(registry.display_name("ccs/glm"), "ccs-glm");
121    /// assert_eq!(registry.display_name("claude"), "claude");
122    /// ```
123    pub fn display_name(&self, name: &str) -> String {
124        self.resolve_config(name)
125            .and_then(|config| config.display_name)
126            .unwrap_or_else(|| name.to_string())
127    }
128
129    /// Find the registry name for an agent given its log file name.
130    ///
131    /// Log file names use a sanitized form of the registry name where `/` is
132    /// replaced with `-` to avoid creating subdirectories. This function
133    /// reverses that sanitization to find the original registry name.
134    ///
135    /// This is used for session continuation, where the agent name is extracted
136    /// from log file names (e.g., "ccs-glm", "opencode-anthropic-claude-sonnet-4")
137    /// but we need to look up the agent in the registry (which uses names like
138    /// "ccs/glm", "opencode/anthropic/claude-sonnet-4").
139    ///
140    /// # Strategy
141    ///
142    /// 1. Check if the name is already a valid registry key (no sanitization needed)
143    /// 2. Search registered agents for one whose sanitized name matches
144    /// 3. Try common patterns like "ccs-X" → "ccs/X", "opencode-X-Y" → "opencode/X/Y"
145    ///
146    /// # Arguments
147    ///
148    /// * `logfile_name` - The agent name extracted from a log file (e.g., "ccs-glm")
149    ///
150    /// # Returns
151    ///
152    /// The registry name if found (e.g., "ccs/glm"), or `None` if no match.
153    ///
154    /// # Examples
155    ///
156    /// ```ignore
157    /// assert_eq!(registry.resolve_from_logfile_name("ccs-glm"), Some("ccs/glm".to_string()));
158    /// assert_eq!(registry.resolve_from_logfile_name("claude"), Some("claude".to_string()));
159    /// assert_eq!(registry.resolve_from_logfile_name("opencode-anthropic-claude-sonnet-4"),
160    ///            Some("opencode/anthropic/claude-sonnet-4".to_string()));
161    /// ```
162    pub fn resolve_from_logfile_name(&self, logfile_name: &str) -> Option<String> {
163        // First check if the name is exactly a registry name (no sanitization was needed)
164        if self.agents.contains_key(logfile_name) {
165            return Some(logfile_name.to_string());
166        }
167
168        // Search registered agents for one whose sanitized name matches
169        for name in self.agents.keys() {
170            let sanitized = name.replace('/', "-");
171            if sanitized == logfile_name {
172                return Some(name.clone());
173            }
174        }
175
176        // Try to resolve dynamically for unregistered agents
177        // CCS pattern: "ccs-alias" → "ccs/alias"
178        if let Some(alias) = logfile_name.strip_prefix("ccs-") {
179            let registry_name = format!("ccs/{}", alias);
180            // CCS agents can be resolved dynamically even if not pre-registered
181            return Some(registry_name);
182        }
183
184        // OpenCode pattern: "opencode-provider-model" → "opencode/provider/model"
185        // Note: This is a best-effort heuristic for log file name parsing.
186        // Provider names may contain hyphens (e.g., "zai-coding-plan"), making it
187        // impossible to reliably split "opencode-zai-coding-plan-glm-4.7".
188        // The preferred approach is to pass the original agent name through
189        // SessionInfo rather than relying on log file name parsing.
190        if let Some(rest) = logfile_name.strip_prefix("opencode-") {
191            if let Some(first_hyphen) = rest.find('-') {
192                let provider = &rest[..first_hyphen];
193                let model = &rest[first_hyphen + 1..];
194                let registry_name = format!("opencode/{}/{}", provider, model);
195                return Some(registry_name);
196            }
197        }
198
199        // No match found
200        None
201    }
202
203    /// Resolve a fuzzy agent name to a canonical agent name.
204    ///
205    /// This handles common typos and alternative forms:
206    /// - `ccs/<unregistered>`: Returns the name as-is for direct CCS execution
207    /// - `opencode/provider/model`: Returns the name as-is for dynamic resolution
208    /// - Other fuzzy matches: Returns the canonical name if a match is found
209    /// - Exact matches: Returns the name as-is
210    ///
211    /// Returns `None` if the name cannot be resolved to any known agent.
212    pub fn resolve_fuzzy(&self, name: &str) -> Option<String> {
213        // First check if it's an exact match
214        if self.agents.contains_key(name) {
215            return Some(name.to_string());
216        }
217
218        // Handle ccs/<unregistered> pattern - return as-is for direct CCS execution
219        if name.starts_with("ccs/") {
220            return Some(name.to_string());
221        }
222
223        // Handle opencode/provider/model pattern - return as-is for dynamic resolution
224        if name.starts_with("opencode/") {
225            // Validate that it has the right format (opencode/provider/model)
226            let parts: Vec<&str> = name.split('/').collect();
227            if parts.len() == 3 && parts[0] == "opencode" {
228                return Some(name.to_string());
229            }
230        }
231
232        // Handle common typos/alternatives
233        let normalized = name.to_lowercase();
234        let alternatives = Self::get_fuzzy_alternatives(&normalized);
235
236        for alt in alternatives {
237            // If it's a ccs/ pattern, return it for direct CCS execution
238            if alt.starts_with("ccs/") {
239                return Some(alt);
240            }
241            // If it's an opencode/ pattern, validate the format
242            if alt.starts_with("opencode/") {
243                let parts: Vec<&str> = alt.split('/').collect();
244                if parts.len() == 3 && parts[0] == "opencode" {
245                    return Some(alt);
246                }
247            }
248            // Otherwise check if it exists in the registry
249            if self.agents.contains_key(&alt) {
250                return Some(alt);
251            }
252        }
253
254        None
255    }
256
257    /// Get fuzzy alternatives for a given agent name.
258    ///
259    /// Returns a list of potential canonical names to try, in order of preference.
260    pub(crate) fn get_fuzzy_alternatives(name: &str) -> Vec<String> {
261        let mut alternatives = Vec::new();
262
263        // Add exact match first
264        alternatives.push(name.to_string());
265
266        // Handle common typos and variations
267        match name {
268            // ccs variations
269            n if n.starts_with("ccs-") => {
270                alternatives.push(name.replace("ccs-", "ccs/"));
271            }
272            n if n.contains('_') => {
273                alternatives.push(name.replace('_', "-"));
274                alternatives.push(name.replace('_', "/"));
275            }
276
277            // claude variations
278            "claud" | "cloud" => alternatives.push("claude".to_string()),
279
280            // codex variations
281            "codeex" | "code-x" => alternatives.push("codex".to_string()),
282
283            // cursor variations
284            "crusor" => alternatives.push("cursor".to_string()),
285
286            // opencode variations
287            "opencode" | "open-code" => alternatives.push("opencode".to_string()),
288
289            // gemini variations
290            "gemeni" | "gemni" => alternatives.push("gemini".to_string()),
291
292            // qwen variations
293            "quen" | "quwen" => alternatives.push("qwen".to_string()),
294
295            // aider variations
296            "ader" => alternatives.push("aider".to_string()),
297
298            // vibe variations
299            "vib" => alternatives.push("vibe".to_string()),
300
301            // cline variations
302            "kline" => alternatives.push("cline".to_string()),
303
304            _ => {}
305        }
306
307        alternatives
308    }
309
310    /// List all registered agents.
311    pub fn list(&self) -> Vec<(&str, &AgentConfig)> {
312        self.agents.iter().map(|(k, v)| (k.as_str(), v)).collect()
313    }
314
315    /// Get command for developer role.
316    pub fn developer_cmd(&self, agent_name: &str) -> Option<String> {
317        self.resolve_config(agent_name)
318            .map(|c| c.build_cmd(true, true, true))
319    }
320
321    /// Get command for reviewer role.
322    pub fn reviewer_cmd(&self, agent_name: &str) -> Option<String> {
323        self.resolve_config(agent_name)
324            .map(|c| c.build_cmd(true, true, false))
325    }
326
327    /// Get the fallback configuration.
328    pub const fn fallback_config(&self) -> &FallbackConfig {
329        &self.fallback
330    }
331
332    /// Get the retry timer provider.
333    pub fn retry_timer(&self) -> Arc<dyn RetryTimerProvider> {
334        Arc::clone(&self.retry_timer)
335    }
336
337    /// Set the retry timer provider (for testing purposes).
338    ///
339    /// This is used to inject a test timer that doesn't actually sleep,
340    /// enabling fast test execution without waiting for retry delays.
341    #[cfg(any(test, feature = "test-utils"))]
342    pub fn set_retry_timer(&mut self, timer: Arc<dyn RetryTimerProvider>) {
343        self.retry_timer = timer;
344    }
345
346    /// Get all fallback agents for a role that are registered in this registry.
347    pub fn available_fallbacks(&self, role: AgentRole) -> Vec<&str> {
348        self.fallback
349            .get_fallbacks(role)
350            .iter()
351            .filter(|name| self.is_agent_available(name))
352            // Agents with can_commit=false are chat-only / non-tool agents and will stall Ralph.
353            .filter(|name| {
354                self.resolve_config(name.as_str())
355                    .is_some_and(|cfg| cfg.can_commit)
356            })
357            .map(std::string::String::as_str)
358            .collect()
359    }
360
361    /// Validate that agent chains are configured for both roles.
362    pub fn validate_agent_chains(&self) -> Result<(), String> {
363        let has_developer = self.fallback.has_fallbacks(AgentRole::Developer);
364        let has_reviewer = self.fallback.has_fallbacks(AgentRole::Reviewer);
365
366        if !has_developer && !has_reviewer {
367            return Err("No agent chain configured.\n\
368                Please add an [agent_chain] section to ~/.config/ralph-workflow.toml.\n\
369                Run 'ralph --init-global' to create a default configuration."
370                .to_string());
371        }
372
373        if !has_developer {
374            return Err("No developer agent chain configured.\n\
375                Add 'developer = [\"your-agent\", ...]' to your [agent_chain] section.\n\
376                Use --list-agents to see available agents."
377                .to_string());
378        }
379
380        if !has_reviewer {
381            return Err("No reviewer agent chain configured.\n\
382                Add 'reviewer = [\"your-agent\", ...]' to your [agent_chain] section.\n\
383                Use --list-agents to see available agents."
384                .to_string());
385        }
386
387        // Sanity check: ensure there is at least one workflow-capable agent per role.
388        for role in [AgentRole::Developer, AgentRole::Reviewer] {
389            let chain = self.fallback.get_fallbacks(role);
390            let has_capable = chain
391                .iter()
392                .any(|name| self.resolve_config(name).is_some_and(|cfg| cfg.can_commit));
393            if !has_capable {
394                return Err(format!(
395                    "No workflow-capable agents found for {role}.\n\
396                    All agents in the {role} chain have can_commit=false.\n\
397                    Fix: set can_commit=true for at least one agent or update [agent_chain]."
398                ));
399            }
400        }
401
402        Ok(())
403    }
404
405    /// Check if an agent is available (command exists and is executable).
406    pub fn is_agent_available(&self, name: &str) -> bool {
407        if let Some(config) = self.resolve_config(name) {
408            let Ok(parts) = crate::common::split_command(&config.cmd) else {
409                return false;
410            };
411            let Some(base_cmd) = parts.first() else {
412                return false;
413            };
414
415            // Check if the command exists in PATH
416            which::which(base_cmd).is_ok()
417        } else {
418            false
419        }
420    }
421
422    /// List all available (installed) agents.
423    pub fn list_available(&self) -> Vec<&str> {
424        self.agents
425            .keys()
426            .filter(|name| self.is_agent_available(name))
427            .map(std::string::String::as_str)
428            .collect()
429    }
430}