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