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}