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