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