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