Skip to main content

ralph_workflow/agents/
validation.rs

1//! Startup validation for `OpenCode` agent references.
2//!
3//! This module provides validation logic for checking that all `opencode/*`
4//! agent references in configured agent chains are valid (i.e., the provider
5//! and model exist in the `OpenCode` API catalog).
6//!
7//! Validation errors include helpful suggestions for typos using Levenshtein
8//! distance matching.
9
10use crate::agents::fallback::{AgentDrain, FallbackConfig, ResolvedDrainConfig};
11use crate::agents::opencode_api::ApiCatalog;
12use crate::agents::opencode_resolver::OpenCodeResolver;
13use std::collections::BTreeSet;
14
15/// Validate all `OpenCode` agent references in resolved drain bindings.
16///
17/// This function checks that all `opencode/provider/model` references in the
18/// configured drain bindings have valid providers and models in the API catalog.
19///
20/// Returns `Ok(())` if all references are valid, or `Err(String)` with a
21/// user-friendly error message if any validation fails.
22///
23/// # Errors
24///
25/// Returns error if the operation fails.
26pub fn validate_opencode_agents(
27    resolved: &ResolvedDrainConfig,
28    catalog: &ApiCatalog,
29) -> Result<(), String> {
30    validate_opencode_agents_in_resolved_drains(resolved, catalog)
31}
32
33/// Validate all `OpenCode` agent references from the legacy fallback config.
34///
35/// This compatibility wrapper exists at the config boundary only.
36///
37/// # Errors
38///
39/// Returns error if the operation fails.
40pub fn validate_opencode_agents_legacy(
41    fallback: &FallbackConfig,
42    catalog: &ApiCatalog,
43) -> Result<(), String> {
44    validate_opencode_agents_in_resolved_drains(&fallback.resolve_drains(), catalog)
45}
46
47/// Validate all `OpenCode` agent references in resolved drain bindings.
48///
49/// # Errors
50///
51/// Returns error if the operation fails.
52pub fn validate_opencode_agents_in_resolved_drains(
53    resolved: &ResolvedDrainConfig,
54    catalog: &ApiCatalog,
55) -> Result<(), String> {
56    let opencode_resolver = OpenCodeResolver::new(catalog.clone());
57    let mut errors = Vec::new();
58
59    let all_agents = get_opencode_refs_in_resolved_drains(resolved);
60
61    // Validate each opencode/* agent
62    for agent_name in all_agents {
63        if let Some((provider, model)) = parse_opencode_ref(&agent_name) {
64            if let Err(e) = opencode_resolver.validate(&provider, &model) {
65                let msg = opencode_resolver.format_error(&e, &agent_name);
66                errors.push(msg);
67            }
68        }
69    }
70
71    if errors.is_empty() {
72        Ok(())
73    } else {
74        Err(errors.join("\n\n"))
75    }
76}
77
78/// Parse an `opencode/provider/model` reference into `(provider, model)`.
79///
80/// Returns `None` if the reference doesn't match the expected pattern.
81fn parse_opencode_ref(agent_name: &str) -> Option<(String, String)> {
82    if !agent_name.starts_with("opencode/") {
83        return None;
84    }
85
86    let parts: Vec<&str> = agent_name.split('/').collect();
87    if parts.len() != 3 {
88        return None;
89    }
90
91    let provider = parts[1].to_string();
92    let model = parts[2].to_string();
93
94    Some((provider, model))
95}
96
97/// Get all `OpenCode` agent references from resolved drain bindings.
98#[must_use]
99pub fn get_opencode_refs(resolved: &ResolvedDrainConfig) -> Vec<String> {
100    get_opencode_refs_in_resolved_drains(resolved)
101}
102
103/// Get all `OpenCode` agent references from the legacy fallback configuration.
104#[must_use]
105pub fn get_opencode_refs_legacy(fallback: &FallbackConfig) -> Vec<String> {
106    get_opencode_refs_in_resolved_drains(&fallback.resolve_drains())
107}
108
109/// Get all `OpenCode` agent references from resolved drain bindings.
110#[must_use]
111pub fn get_opencode_refs_in_resolved_drains(resolved: &ResolvedDrainConfig) -> Vec<String> {
112    let unique = AgentDrain::all()
113        .into_iter()
114        .filter_map(|drain| resolved.binding(drain))
115        .flat_map(|binding| binding.agents.iter().cloned())
116        .filter(|name| name.starts_with("opencode/"))
117        .collect::<BTreeSet<_>>();
118
119    unique.into_iter().collect()
120}
121
122/// Count the number of `OpenCode` agent references in the resolved drain bindings.
123#[cfg(test)]
124fn count_opencode_refs(resolved: &ResolvedDrainConfig) -> usize {
125    AgentDrain::all()
126        .into_iter()
127        .filter_map(|drain| resolved.binding(drain))
128        .flat_map(|binding| binding.agents.iter())
129        .filter(|name| name.starts_with("opencode/"))
130        .cloned()
131        .collect::<BTreeSet<_>>()
132        .len()
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use crate::agents::opencode_api::{Model, Provider};
139    use std::collections::HashMap;
140
141    fn mock_catalog() -> ApiCatalog {
142        let mut providers = HashMap::new();
143        providers.insert(
144            "anthropic".to_string(),
145            Provider {
146                id: "anthropic".to_string(),
147                name: "Anthropic".to_string(),
148                description: "Anthropic Claude models".to_string(),
149            },
150        );
151
152        let mut models = HashMap::new();
153        models.insert(
154            "anthropic".to_string(),
155            vec![Model {
156                id: "claude-sonnet-4-5".to_string(),
157                name: "Claude Sonnet 4.5".to_string(),
158                description: "Latest Claude Sonnet".to_string(),
159                context_length: Some(200_000),
160            }],
161        );
162
163        ApiCatalog {
164            providers,
165            models,
166            cached_at: Some(chrono::Utc::now()),
167            ttl_seconds: 86400,
168        }
169    }
170
171    fn create_fallback_with_refs(refs: &[&str]) -> FallbackConfig {
172        FallbackConfig {
173            developer: refs.iter().map(|s| (*s).to_string()).collect(),
174            ..FallbackConfig::default()
175        }
176    }
177
178    #[test]
179    fn test_parse_opencode_ref_valid() {
180        let result = parse_opencode_ref("opencode/anthropic/claude-sonnet-4-5");
181        assert_eq!(
182            result,
183            Some(("anthropic".to_string(), "claude-sonnet-4-5".to_string()))
184        );
185    }
186
187    #[test]
188    fn test_parse_opencode_ref_invalid() {
189        assert_eq!(parse_opencode_ref("claude"), None);
190        assert_eq!(parse_opencode_ref("opencode"), None);
191        assert_eq!(parse_opencode_ref("opencode/anthropic"), None);
192        assert_eq!(parse_opencode_ref("ccs/glm"), None);
193    }
194
195    #[test]
196    fn test_validate_opencode_agents_valid() {
197        let catalog = mock_catalog();
198        let fallback = create_fallback_with_refs(&["opencode/anthropic/claude-sonnet-4-5"]);
199        let resolved = fallback.resolve_drains();
200
201        let result = validate_opencode_agents(&resolved, &catalog);
202        assert!(result.is_ok());
203    }
204
205    #[test]
206    fn test_validate_opencode_agents_invalid_provider() {
207        let catalog = mock_catalog();
208        let fallback = create_fallback_with_refs(&["opencode/unknown/claude-sonnet-4-5"]);
209        let resolved = fallback.resolve_drains();
210
211        let result = validate_opencode_agents(&resolved, &catalog);
212        assert!(result.is_err());
213        assert!(result.unwrap_err().contains("unknown"));
214    }
215
216    #[test]
217    fn test_validate_opencode_agents_invalid_model() {
218        let catalog = mock_catalog();
219        let fallback = create_fallback_with_refs(&["opencode/anthropic/unknown-model"]);
220        let resolved = fallback.resolve_drains();
221
222        let result = validate_opencode_agents(&resolved, &catalog);
223        assert!(result.is_err());
224        assert!(result.unwrap_err().contains("unknown-model"));
225    }
226
227    #[test]
228    fn test_count_opencode_refs() {
229        let fallback = create_fallback_with_refs(&[
230            "opencode/anthropic/claude-sonnet-4-5",
231            "claude",
232            "opencode/openai/gpt-4",
233        ]);
234        let resolved = fallback.resolve_drains();
235
236        let count = count_opencode_refs(&resolved);
237        assert_eq!(count, 2);
238    }
239
240    #[test]
241    fn test_get_opencode_refs() {
242        let fallback = create_fallback_with_refs(&[
243            "opencode/anthropic/claude-sonnet-4-5",
244            "claude",
245            "opencode/openai/gpt-4",
246        ]);
247        let resolved = fallback.resolve_drains();
248
249        let refs = get_opencode_refs(&resolved);
250        assert_eq!(refs.len(), 2);
251        assert!(refs.contains(&"opencode/anthropic/claude-sonnet-4-5".to_string()));
252        assert!(refs.contains(&"opencode/openai/gpt-4".to_string()));
253    }
254}