Skip to main content

oven_cli/agents/
mod.rs

1pub mod fixer;
2pub mod implementer;
3pub mod merger;
4pub mod planner;
5pub mod reviewer;
6
7use std::path::PathBuf;
8
9use anyhow::{Context, Result};
10use serde::{Deserialize, de::DeserializeOwned};
11
12use crate::{db::ReviewFinding, process::CommandRunner};
13
14/// The five agent roles in the pipeline.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16pub enum AgentRole {
17    Planner,
18    Implementer,
19    Reviewer,
20    Fixer,
21    Merger,
22}
23
24impl AgentRole {
25    pub const fn allowed_tools(&self) -> &[&str] {
26        match self {
27            Self::Planner | Self::Reviewer => &["Read", "Glob", "Grep"],
28            Self::Implementer | Self::Fixer => &["Read", "Write", "Edit", "Glob", "Grep", "Bash"],
29            Self::Merger => &["Bash"],
30        }
31    }
32
33    pub const fn as_str(&self) -> &str {
34        match self {
35            Self::Planner => "planner",
36            Self::Implementer => "implementer",
37            Self::Reviewer => "reviewer",
38            Self::Fixer => "fixer",
39            Self::Merger => "merger",
40        }
41    }
42
43    pub fn tools_as_strings(&self) -> Vec<String> {
44        self.allowed_tools().iter().map(|s| (*s).to_string()).collect()
45    }
46}
47
48impl std::fmt::Display for AgentRole {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        f.write_str(self.as_str())
51    }
52}
53
54impl std::str::FromStr for AgentRole {
55    type Err = anyhow::Error;
56
57    fn from_str(s: &str) -> Result<Self, Self::Err> {
58        match s {
59            "planner" => Ok(Self::Planner),
60            "implementer" => Ok(Self::Implementer),
61            "reviewer" => Ok(Self::Reviewer),
62            "fixer" => Ok(Self::Fixer),
63            "merger" => Ok(Self::Merger),
64            other => anyhow::bail!("unknown agent role: {other}"),
65        }
66    }
67}
68
69/// Context passed to agent prompt builders.
70#[derive(Debug, Clone)]
71pub struct AgentContext {
72    pub issue_number: u32,
73    pub issue_title: String,
74    pub issue_body: String,
75    pub branch: String,
76    pub pr_number: Option<u32>,
77    pub test_command: Option<String>,
78    pub lint_command: Option<String>,
79    pub review_findings: Option<Vec<ReviewFinding>>,
80    pub cycle: u32,
81    /// When set, indicates this is a multi-repo pipeline where the PR lives in a
82    /// different repo than the issue. The merger should skip closing the issue
83    /// (the executor handles it).
84    pub target_repo: Option<String>,
85    /// Issue source: "github" or "local". The merger skips `gh issue close`
86    /// for local issues since they're not on GitHub.
87    pub issue_source: String,
88}
89
90/// An invocation ready to be sent to the process runner.
91pub struct AgentInvocation {
92    pub role: AgentRole,
93    pub prompt: String,
94    pub working_dir: PathBuf,
95    pub max_turns: Option<u32>,
96}
97
98/// Invoke an agent via the command runner.
99pub async fn invoke_agent<R: CommandRunner>(
100    runner: &R,
101    invocation: &AgentInvocation,
102) -> Result<crate::process::AgentResult> {
103    runner
104        .run_claude(
105            &invocation.prompt,
106            &invocation.role.tools_as_strings(),
107            &invocation.working_dir,
108            invocation.max_turns,
109        )
110        .await
111}
112
113/// Complexity classification from the planner agent.
114#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
115#[serde(rename_all = "lowercase")]
116pub enum Complexity {
117    Simple,
118    Full,
119}
120
121impl std::fmt::Display for Complexity {
122    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123        f.write_str(match self {
124            Self::Simple => "simple",
125            Self::Full => "full",
126        })
127    }
128}
129
130impl std::str::FromStr for Complexity {
131    type Err = anyhow::Error;
132
133    fn from_str(s: &str) -> Result<Self, Self::Err> {
134        match s {
135            "simple" => Ok(Self::Simple),
136            "full" => Ok(Self::Full),
137            other => anyhow::bail!("unknown complexity: {other}"),
138        }
139    }
140}
141
142/// Structured output from the planner agent.
143#[derive(Debug, Deserialize)]
144pub struct PlannerOutput {
145    pub batches: Vec<Batch>,
146    #[serde(default)]
147    pub total_issues: u32,
148    #[serde(default)]
149    pub parallel_capacity: u32,
150}
151
152#[derive(Debug, Deserialize)]
153pub struct Batch {
154    pub batch: u32,
155    pub issues: Vec<PlannedIssue>,
156    #[serde(default)]
157    pub reasoning: String,
158}
159
160#[derive(Debug, Deserialize)]
161pub struct PlannedIssue {
162    pub number: u32,
163    #[serde(default)]
164    pub title: String,
165    #[serde(default)]
166    pub area: String,
167    #[serde(default)]
168    pub predicted_files: Vec<String>,
169    #[serde(default)]
170    pub has_migration: bool,
171    #[serde(default = "default_full")]
172    pub complexity: Complexity,
173}
174
175const fn default_full() -> Complexity {
176    Complexity::Full
177}
178
179/// Parse structured planner output from the planner's text response.
180///
181/// Falls back to `None` if the output is unparseable.
182pub fn parse_planner_output(text: &str) -> Option<PlannerOutput> {
183    extract_json(text)
184}
185
186/// Structured output from the reviewer agent.
187#[derive(Debug, Deserialize)]
188pub struct ReviewOutput {
189    pub findings: Vec<Finding>,
190    #[serde(default)]
191    pub summary: String,
192}
193
194#[derive(Debug, Deserialize)]
195pub struct Finding {
196    pub severity: Severity,
197    pub category: String,
198    #[serde(default)]
199    pub file_path: Option<String>,
200    #[serde(default)]
201    pub line_number: Option<u32>,
202    pub message: String,
203}
204
205#[derive(Debug, Deserialize, PartialEq, Eq)]
206#[serde(rename_all = "lowercase")]
207pub enum Severity {
208    Critical,
209    Warning,
210    Info,
211}
212
213impl Severity {
214    pub const fn as_str(&self) -> &str {
215        match self {
216            Self::Critical => "critical",
217            Self::Warning => "warning",
218            Self::Info => "info",
219        }
220    }
221}
222
223impl std::fmt::Display for Severity {
224    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
225        f.write_str(self.as_str())
226    }
227}
228
229/// Parse structured review output from the reviewer's text response.
230///
231/// The JSON may be wrapped in markdown code fences. Returns an error if the
232/// output is unparseable -- callers should treat this as a review failure,
233/// not a clean pass (which could let unreviewed code through to merge).
234pub fn parse_review_output(text: &str) -> Result<ReviewOutput> {
235    extract_json(text).context("reviewer returned unparseable output (no valid JSON found)")
236}
237
238/// Try to extract a JSON object of type `T` from text that may contain prose,
239/// code fences, or raw JSON.
240///
241/// Attempts three strategies in order:
242/// 1. Direct `serde_json::from_str`
243/// 2. JSON inside markdown code fences
244/// 3. First `{` to last `}` in the text
245fn extract_json<T: DeserializeOwned>(text: &str) -> Option<T> {
246    if let Ok(val) = serde_json::from_str::<T>(text) {
247        return Some(val);
248    }
249
250    if let Some(json_str) = extract_json_from_fences(text) {
251        if let Ok(val) = serde_json::from_str::<T>(json_str) {
252            return Some(val);
253        }
254    }
255
256    let start = text.find('{')?;
257    let end = text.rfind('}')?;
258    if end > start { serde_json::from_str::<T>(&text[start..=end]).ok() } else { None }
259}
260
261fn extract_json_from_fences(text: &str) -> Option<&str> {
262    let start_markers = ["```json\n", "```json\r\n", "```\n", "```\r\n"];
263    for marker in &start_markers {
264        if let Some(start) = text.find(marker) {
265            let content_start = start + marker.len();
266            if let Some(end) = text[content_start..].find("```") {
267                return Some(&text[content_start..content_start + end]);
268            }
269        }
270    }
271    None
272}
273
274#[cfg(test)]
275mod tests {
276    use proptest::prelude::*;
277
278    use super::*;
279
280    const ALL_ROLES: [AgentRole; 5] = [
281        AgentRole::Planner,
282        AgentRole::Implementer,
283        AgentRole::Reviewer,
284        AgentRole::Fixer,
285        AgentRole::Merger,
286    ];
287
288    proptest! {
289        #[test]
290        fn agent_role_display_fromstr_roundtrip(idx in 0..5usize) {
291            let role = ALL_ROLES[idx];
292            let s = role.to_string();
293            let parsed: AgentRole = s.parse().unwrap();
294            assert_eq!(role, parsed);
295        }
296
297        #[test]
298        fn arbitrary_strings_never_panic_on_role_parse(s in "\\PC{1,50}") {
299            let _ = s.parse::<AgentRole>();
300        }
301
302        #[test]
303        fn parse_review_output_never_panics(text in "\\PC{0,500}") {
304            // parse_review_output should never panic on any input (may return Err)
305            let _ = parse_review_output(&text);
306        }
307
308        #[test]
309        fn valid_review_json_always_parses(
310            severity in prop_oneof!["critical", "warning", "info"],
311            category in "[a-z]{3,15}",
312            message in "[a-zA-Z0-9 ]{1,50}",
313        ) {
314            let json = format!(
315                r#"{{"findings":[{{"severity":"{severity}","category":"{category}","message":"{message}"}}],"summary":"test"}}"#
316            );
317            let output = parse_review_output(&json).unwrap();
318            assert_eq!(output.findings.len(), 1);
319            assert_eq!(output.findings[0].category, category);
320        }
321
322        #[test]
323        fn review_json_in_fences_parses(
324            severity in prop_oneof!["critical", "warning", "info"],
325            category in "[a-z]{3,15}",
326            message in "[a-zA-Z0-9 ]{1,50}",
327            prefix in "[a-zA-Z ]{0,30}",
328            suffix in "[a-zA-Z ]{0,30}",
329        ) {
330            let json = format!(
331                r#"{{"findings":[{{"severity":"{severity}","category":"{category}","message":"{message}"}}],"summary":"ok"}}"#
332            );
333            let text = format!("{prefix}\n```json\n{json}\n```\n{suffix}");
334            let output = parse_review_output(&text).unwrap();
335            assert_eq!(output.findings.len(), 1);
336        }
337    }
338
339    #[test]
340    fn tool_scoping_per_role() {
341        assert_eq!(AgentRole::Planner.allowed_tools(), &["Read", "Glob", "Grep"]);
342        assert_eq!(
343            AgentRole::Implementer.allowed_tools(),
344            &["Read", "Write", "Edit", "Glob", "Grep", "Bash"]
345        );
346        assert_eq!(AgentRole::Reviewer.allowed_tools(), &["Read", "Glob", "Grep"]);
347        assert_eq!(
348            AgentRole::Fixer.allowed_tools(),
349            &["Read", "Write", "Edit", "Glob", "Grep", "Bash"]
350        );
351        assert_eq!(AgentRole::Merger.allowed_tools(), &["Bash"]);
352    }
353
354    #[test]
355    fn role_display_roundtrip() {
356        let roles = [
357            AgentRole::Planner,
358            AgentRole::Implementer,
359            AgentRole::Reviewer,
360            AgentRole::Fixer,
361            AgentRole::Merger,
362        ];
363        for role in roles {
364            let s = role.to_string();
365            let parsed: AgentRole = s.parse().unwrap();
366            assert_eq!(role, parsed);
367        }
368    }
369
370    #[test]
371    fn parse_review_output_valid_json() {
372        let json = r#"{"findings":[{"severity":"critical","category":"bug","file_path":"src/main.rs","line_number":10,"message":"null pointer"}],"summary":"one issue found"}"#;
373        let output = parse_review_output(json).unwrap();
374        assert_eq!(output.findings.len(), 1);
375        assert_eq!(output.findings[0].severity, Severity::Critical);
376        assert_eq!(output.findings[0].message, "null pointer");
377        assert_eq!(output.summary, "one issue found");
378    }
379
380    #[test]
381    fn parse_review_output_in_code_fences() {
382        let text = r#"Here are my findings:
383
384```json
385{"findings":[{"severity":"warning","category":"style","message":"missing docs"}],"summary":"ok"}
386```
387
388That's it."#;
389        let output = parse_review_output(text).unwrap();
390        assert_eq!(output.findings.len(), 1);
391        assert_eq!(output.findings[0].severity, Severity::Warning);
392    }
393
394    #[test]
395    fn parse_review_output_embedded_json() {
396        let text = r#"I reviewed the code and found: {"findings":[{"severity":"info","category":"note","message":"looks fine"}],"summary":"clean"} end of review"#;
397        let output = parse_review_output(text).unwrap();
398        assert_eq!(output.findings.len(), 1);
399    }
400
401    #[test]
402    fn parse_review_output_no_json_returns_error() {
403        let text = "The code looks great, no issues found.";
404        let result = parse_review_output(text);
405        assert!(result.is_err());
406        assert!(result.unwrap_err().to_string().contains("unparseable"));
407    }
408
409    #[test]
410    fn parse_review_output_malformed_json_returns_error() {
411        let text = r#"{"findings": [{"broken json"#;
412        let result = parse_review_output(text);
413        assert!(result.is_err());
414    }
415
416    // --- Planner output parsing tests ---
417
418    #[test]
419    fn parse_planner_output_valid_json() {
420        let json = r#"{
421            "batches": [{
422                "batch": 1,
423                "issues": [{
424                    "number": 42,
425                    "title": "Add login",
426                    "area": "auth",
427                    "predicted_files": ["src/auth.rs"],
428                    "has_migration": false,
429                    "complexity": "simple"
430                }],
431                "reasoning": "standalone issue"
432            }],
433            "total_issues": 1,
434            "parallel_capacity": 1
435        }"#;
436        let output = parse_planner_output(json).unwrap();
437        assert_eq!(output.batches.len(), 1);
438        assert_eq!(output.batches[0].issues.len(), 1);
439        assert_eq!(output.batches[0].issues[0].number, 42);
440        assert_eq!(output.batches[0].issues[0].complexity, Complexity::Simple);
441        assert!(!output.batches[0].issues[0].has_migration);
442    }
443
444    #[test]
445    fn parse_planner_output_in_code_fences() {
446        let text = r#"Here's the plan:
447
448```json
449{
450    "batches": [{"batch": 1, "issues": [{"number": 1, "complexity": "full"}], "reasoning": "ok"}],
451    "total_issues": 1,
452    "parallel_capacity": 1
453}
454```
455
456That's the plan."#;
457        let output = parse_planner_output(text).unwrap();
458        assert_eq!(output.batches.len(), 1);
459        assert_eq!(output.batches[0].issues[0].complexity, Complexity::Full);
460    }
461
462    #[test]
463    fn parse_planner_output_malformed_returns_none() {
464        assert!(parse_planner_output("not json at all").is_none());
465        assert!(parse_planner_output(r#"{"batches": "broken"}"#).is_none());
466        assert!(parse_planner_output("").is_none());
467    }
468
469    #[test]
470    fn complexity_deserializes_from_strings() {
471        let simple: Complexity = serde_json::from_str(r#""simple""#).unwrap();
472        assert_eq!(simple, Complexity::Simple);
473        let full: Complexity = serde_json::from_str(r#""full""#).unwrap();
474        assert_eq!(full, Complexity::Full);
475    }
476
477    #[test]
478    fn complexity_display_roundtrip() {
479        for c in [Complexity::Simple, Complexity::Full] {
480            let s = c.to_string();
481            let parsed: Complexity = s.parse().unwrap();
482            assert_eq!(c, parsed);
483        }
484    }
485
486    #[test]
487    fn planner_output_defaults_complexity_to_full() {
488        let json = r#"{"batches": [{"batch": 1, "issues": [{"number": 5}], "reasoning": ""}], "total_issues": 1, "parallel_capacity": 1}"#;
489        let output = parse_planner_output(json).unwrap();
490        assert_eq!(output.batches[0].issues[0].complexity, Complexity::Full);
491    }
492
493    #[test]
494    fn planner_output_with_multiple_batches() {
495        let json = r#"{
496            "batches": [
497                {"batch": 1, "issues": [{"number": 1, "complexity": "simple"}, {"number": 2, "complexity": "simple"}], "reasoning": "independent"},
498                {"batch": 2, "issues": [{"number": 3, "complexity": "full"}], "reasoning": "depends on batch 1"}
499            ],
500            "total_issues": 3,
501            "parallel_capacity": 2
502        }"#;
503        let output = parse_planner_output(json).unwrap();
504        assert_eq!(output.batches.len(), 2);
505        assert_eq!(output.batches[0].issues.len(), 2);
506        assert_eq!(output.batches[1].issues.len(), 1);
507        assert_eq!(output.total_issues, 3);
508    }
509}