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}