ito_templates/
agents.rs

1//! Agent template handling for Ito agent tiers
2//!
3//! This module provides utilities for loading and rendering agent templates
4//! with placeholder resolution for model configuration.
5
6use std::collections::HashMap;
7
8/// Agent configuration with model and optional extended options
9#[derive(Debug, Clone, Default)]
10pub struct AgentConfig {
11    /// Model ID (e.g., "anthropic/claude-haiku-4-5" or "haiku" for Claude Code)
12    pub model: String,
13    /// Optional variant (e.g., "high", "xhigh")
14    pub variant: Option<String>,
15    /// Optional temperature
16    pub temperature: Option<f64>,
17    /// Optional reasoning effort (for OpenAI models)
18    pub reasoning_effort: Option<String>,
19}
20
21/// Harness identifier
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
23pub enum Harness {
24    /// OpenCode (`opencode`) harness.
25    OpenCode,
26    /// Claude Code harness.
27    ClaudeCode,
28    /// Codex harness.
29    Codex,
30    /// GitHub Copilot harness.
31    GitHubCopilot,
32}
33
34impl Harness {
35    /// Get the directory name in the assets/agents/ directory
36    pub fn dir_name(&self) -> &'static str {
37        match self {
38            Self::OpenCode => "opencode",
39            Self::ClaudeCode => "claude-code",
40            Self::Codex => "codex",
41            Self::GitHubCopilot => "github-copilot",
42        }
43    }
44
45    /// Get the target installation path for project agents
46    pub fn project_agent_path(&self) -> &'static str {
47        match self {
48            Self::OpenCode => ".opencode/agent",
49            Self::ClaudeCode => ".claude/agents",
50            Self::Codex => ".agents/skills",
51            Self::GitHubCopilot => ".github/agents",
52        }
53    }
54
55    /// All supported harnesses
56    pub fn all() -> &'static [Harness] {
57        &[
58            Self::OpenCode,
59            Self::ClaudeCode,
60            Self::Codex,
61            Self::GitHubCopilot,
62        ]
63    }
64}
65
66/// Agent tier
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
68pub enum AgentTier {
69    /// Fast/cheap tier for simple tasks.
70    Quick,
71    /// Default tier for typical development tasks.
72    General,
73    /// Highest reasoning tier for complex work.
74    Thinking,
75}
76
77impl AgentTier {
78    /// Get the file name (without extension)
79    pub fn file_name(&self) -> &'static str {
80        match self {
81            Self::Quick => "ito-quick",
82            Self::General => "ito-general",
83            Self::Thinking => "ito-thinking",
84        }
85    }
86
87    /// All tiers
88    pub fn all() -> &'static [AgentTier] {
89        &[Self::Quick, Self::General, Self::Thinking]
90    }
91}
92
93/// Default model configurations per harness and tier
94pub fn default_agent_configs() -> HashMap<(Harness, AgentTier), AgentConfig> {
95    let mut configs = HashMap::new();
96
97    // OpenCode defaults
98    configs.insert(
99        (Harness::OpenCode, AgentTier::Quick),
100        AgentConfig {
101            model: "anthropic/claude-haiku-4-5".to_string(),
102            temperature: Some(0.3),
103            ..Default::default()
104        },
105    );
106    configs.insert(
107        (Harness::OpenCode, AgentTier::General),
108        AgentConfig {
109            model: "openai/gpt-5.2-codex".to_string(),
110            variant: Some("high".to_string()),
111            temperature: Some(0.3),
112            ..Default::default()
113        },
114    );
115    configs.insert(
116        (Harness::OpenCode, AgentTier::Thinking),
117        AgentConfig {
118            model: "openai/gpt-5.2-codex".to_string(),
119            variant: Some("xhigh".to_string()),
120            temperature: Some(0.5),
121            ..Default::default()
122        },
123    );
124
125    // Claude Code defaults (uses simplified model names)
126    configs.insert(
127        (Harness::ClaudeCode, AgentTier::Quick),
128        AgentConfig {
129            model: "haiku".to_string(),
130            ..Default::default()
131        },
132    );
133    configs.insert(
134        (Harness::ClaudeCode, AgentTier::General),
135        AgentConfig {
136            model: "sonnet".to_string(),
137            ..Default::default()
138        },
139    );
140    configs.insert(
141        (Harness::ClaudeCode, AgentTier::Thinking),
142        AgentConfig {
143            model: "opus".to_string(),
144            ..Default::default()
145        },
146    );
147
148    // Codex defaults
149    configs.insert(
150        (Harness::Codex, AgentTier::Quick),
151        AgentConfig {
152            model: "openai/gpt-5.1-codex-mini".to_string(),
153            ..Default::default()
154        },
155    );
156    configs.insert(
157        (Harness::Codex, AgentTier::General),
158        AgentConfig {
159            model: "openai/gpt-5.2-codex".to_string(),
160            reasoning_effort: Some("high".to_string()),
161            ..Default::default()
162        },
163    );
164    configs.insert(
165        (Harness::Codex, AgentTier::Thinking),
166        AgentConfig {
167            model: "openai/gpt-5.2-codex".to_string(),
168            reasoning_effort: Some("xhigh".to_string()),
169            ..Default::default()
170        },
171    );
172
173    // GitHub Copilot defaults
174    configs.insert(
175        (Harness::GitHubCopilot, AgentTier::Quick),
176        AgentConfig {
177            model: "github-copilot/claude-haiku-4.5".to_string(),
178            ..Default::default()
179        },
180    );
181    configs.insert(
182        (Harness::GitHubCopilot, AgentTier::General),
183        AgentConfig {
184            model: "github-copilot/gpt-5.2-codex".to_string(),
185            ..Default::default()
186        },
187    );
188    configs.insert(
189        (Harness::GitHubCopilot, AgentTier::Thinking),
190        AgentConfig {
191            model: "github-copilot/gpt-5.2-codex".to_string(),
192            ..Default::default()
193        },
194    );
195
196    configs
197}
198
199/// Render an agent template by replacing placeholders with actual values
200pub fn render_agent_template(template: &str, config: &AgentConfig) -> String {
201    let mut result = template.to_string();
202
203    // Replace model placeholder
204    result = result.replace("{{model}}", &config.model);
205
206    // Replace variant placeholder (or remove line if not set)
207    if let Some(variant) = &config.variant {
208        result = result.replace("{{variant}}", variant);
209    } else {
210        // Remove lines containing {{variant}} if no variant is set
211        result = result
212            .lines()
213            .filter(|line| !line.contains("{{variant}}"))
214            .collect::<Vec<_>>()
215            .join("\n");
216    }
217
218    result
219}
220
221/// Get agent template files for a specific harness
222pub fn get_agent_files(harness: Harness) -> Vec<(&'static str, &'static [u8])> {
223    let dir_name = harness.dir_name();
224    let agents_dir = &super::AGENTS_DIR;
225
226    let mut files = Vec::new();
227
228    if let Some(harness_dir) = agents_dir.get_dir(dir_name) {
229        for file in harness_dir.files() {
230            if let Some(name) = file.path().file_name().and_then(|n| n.to_str()) {
231                files.push((name, file.contents()));
232            }
233        }
234
235        // Also check subdirectories (for Codex SKILL.md format)
236        for subdir in harness_dir.dirs() {
237            if let Some(skill_file) = subdir.get_file("SKILL.md") {
238                let dir_name = subdir.path().file_name().and_then(|n| n.to_str());
239                if let Some(name) = dir_name {
240                    // Return as "dirname/SKILL.md"
241                    let path = format!("{}/SKILL.md", name);
242                    // We need to leak the string to get a static lifetime
243                    // This is acceptable since these are loaded once at startup
244                    let leaked: &'static str = Box::leak(path.into_boxed_str());
245                    files.push((leaked, skill_file.contents()));
246                }
247            }
248        }
249    }
250
251    files
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    #[test]
259    fn render_template_replaces_model() {
260        let template = r#"---
261model: "{{model}}"
262---
263Instructions"#;
264
265        let config = AgentConfig {
266            model: "anthropic/claude-haiku-4-5".to_string(),
267            ..Default::default()
268        };
269
270        let result = render_agent_template(template, &config);
271        assert!(result.contains("model: \"anthropic/claude-haiku-4-5\""));
272    }
273
274    #[test]
275    fn render_template_replaces_variant() {
276        let template = r#"---
277model: "{{model}}"
278variant: "{{variant}}"
279---"#;
280
281        let config = AgentConfig {
282            model: "openai/gpt-5.2-codex".to_string(),
283            variant: Some("high".to_string()),
284            ..Default::default()
285        };
286
287        let result = render_agent_template(template, &config);
288        assert!(result.contains("variant: \"high\""));
289    }
290
291    #[test]
292    fn render_template_removes_variant_line_if_not_set() {
293        let template = r#"---
294model: "{{model}}"
295variant: "{{variant}}"
296---"#;
297
298        let config = AgentConfig {
299            model: "anthropic/claude-haiku-4-5".to_string(),
300            variant: None,
301            ..Default::default()
302        };
303
304        let result = render_agent_template(template, &config);
305        assert!(!result.contains("variant"));
306    }
307
308    #[test]
309    fn default_configs_has_all_combinations() {
310        let configs = default_agent_configs();
311
312        for harness in Harness::all() {
313            for tier in AgentTier::all() {
314                assert!(
315                    configs.contains_key(&(*harness, *tier)),
316                    "Missing config for {:?}/{:?}",
317                    harness,
318                    tier
319                );
320            }
321        }
322    }
323}