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, issues::PipelineIssue, 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    /// The default branch name (e.g. "main" or "master"). Used by the merger
89    /// to diff against the correct base.
90    pub base_branch: String,
91}
92
93/// An invocation ready to be sent to the process runner.
94pub struct AgentInvocation {
95    pub role: AgentRole,
96    pub prompt: String,
97    pub working_dir: PathBuf,
98    pub max_turns: Option<u32>,
99}
100
101/// Invoke an agent via the command runner.
102pub async fn invoke_agent<R: CommandRunner>(
103    runner: &R,
104    invocation: &AgentInvocation,
105) -> Result<crate::process::AgentResult> {
106    runner
107        .run_claude(
108            &invocation.prompt,
109            &invocation.role.tools_as_strings(),
110            &invocation.working_dir,
111            invocation.max_turns,
112        )
113        .await
114}
115
116/// Complexity classification from the planner agent.
117#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
118#[serde(rename_all = "lowercase")]
119pub enum Complexity {
120    Simple,
121    Full,
122}
123
124impl std::fmt::Display for Complexity {
125    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126        f.write_str(match self {
127            Self::Simple => "simple",
128            Self::Full => "full",
129        })
130    }
131}
132
133impl std::str::FromStr for Complexity {
134    type Err = anyhow::Error;
135
136    fn from_str(s: &str) -> Result<Self, Self::Err> {
137        match s {
138            "simple" => Ok(Self::Simple),
139            "full" => Ok(Self::Full),
140            other => anyhow::bail!("unknown complexity: {other}"),
141        }
142    }
143}
144
145/// Metadata about an issue currently running through the pipeline.
146///
147/// Passed to the planner so it can reason about conflicts with in-flight work.
148#[derive(Debug, Clone)]
149pub struct InFlightIssue {
150    pub number: u32,
151    pub title: String,
152    pub area: String,
153    pub predicted_files: Vec<String>,
154    pub has_migration: bool,
155    pub complexity: Complexity,
156}
157
158impl From<&PlannedIssue> for InFlightIssue {
159    fn from(pi: &PlannedIssue) -> Self {
160        Self {
161            number: pi.number,
162            title: pi.title.clone(),
163            area: pi.area.clone(),
164            predicted_files: pi.predicted_files.clone(),
165            has_migration: pi.has_migration,
166            complexity: pi.complexity.clone(),
167        }
168    }
169}
170
171impl InFlightIssue {
172    /// Fallback constructor when the planner hasn't classified this issue.
173    pub fn from_issue(issue: &PipelineIssue) -> Self {
174        Self {
175            number: issue.number,
176            title: issue.title.clone(),
177            area: String::new(),
178            predicted_files: Vec::new(),
179            has_migration: false,
180            complexity: Complexity::Full,
181        }
182    }
183}
184
185/// Structured output from the planner agent.
186#[derive(Debug, Deserialize)]
187pub struct PlannerOutput {
188    pub batches: Vec<Batch>,
189    #[serde(default)]
190    pub total_issues: u32,
191    #[serde(default)]
192    pub parallel_capacity: u32,
193}
194
195#[derive(Debug, Deserialize)]
196pub struct Batch {
197    pub batch: u32,
198    pub issues: Vec<PlannedIssue>,
199    #[serde(default)]
200    pub reasoning: String,
201}
202
203#[derive(Debug, Deserialize)]
204pub struct PlannedIssue {
205    pub number: u32,
206    #[serde(default)]
207    pub title: String,
208    #[serde(default)]
209    pub area: String,
210    #[serde(default)]
211    pub predicted_files: Vec<String>,
212    #[serde(default)]
213    pub has_migration: bool,
214    #[serde(default = "default_full")]
215    pub complexity: Complexity,
216}
217
218const fn default_full() -> Complexity {
219    Complexity::Full
220}
221
222/// Parse structured planner output from the planner's text response.
223///
224/// Falls back to `None` if the output is unparseable.
225pub fn parse_planner_output(text: &str) -> Option<PlannerOutput> {
226    extract_json(text)
227}
228
229/// Structured output from the reviewer agent.
230#[derive(Debug, Deserialize)]
231pub struct ReviewOutput {
232    pub findings: Vec<Finding>,
233    #[serde(default)]
234    pub summary: String,
235}
236
237#[derive(Debug, Deserialize)]
238pub struct Finding {
239    pub severity: Severity,
240    pub category: String,
241    #[serde(default)]
242    pub file_path: Option<String>,
243    #[serde(default)]
244    pub line_number: Option<u32>,
245    pub message: String,
246}
247
248#[derive(Debug, Deserialize, PartialEq, Eq)]
249#[serde(rename_all = "lowercase")]
250pub enum Severity {
251    Critical,
252    Warning,
253    Info,
254}
255
256impl Severity {
257    pub const fn as_str(&self) -> &str {
258        match self {
259            Self::Critical => "critical",
260            Self::Warning => "warning",
261            Self::Info => "info",
262        }
263    }
264}
265
266impl std::fmt::Display for Severity {
267    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
268        f.write_str(self.as_str())
269    }
270}
271
272/// Parse structured review output from the reviewer's text response.
273///
274/// The JSON may be wrapped in markdown code fences. Returns an error if the
275/// output is unparseable -- callers should treat this as a review failure,
276/// not a clean pass (which could let unreviewed code through to merge).
277pub fn parse_review_output(text: &str) -> Result<ReviewOutput> {
278    extract_json(text).context("reviewer returned unparseable output (no valid JSON found)")
279}
280
281/// Try to extract a JSON object of type `T` from text that may contain prose,
282/// code fences, or raw JSON.
283///
284/// Attempts three strategies in order:
285/// 1. Direct `serde_json::from_str`
286/// 2. JSON inside markdown code fences
287/// 3. First `{` to last `}` in the text
288fn extract_json<T: DeserializeOwned>(text: &str) -> Option<T> {
289    if let Ok(val) = serde_json::from_str::<T>(text) {
290        return Some(val);
291    }
292
293    if let Some(json_str) = extract_json_from_fences(text) {
294        if let Ok(val) = serde_json::from_str::<T>(json_str) {
295            return Some(val);
296        }
297    }
298
299    let start = text.find('{')?;
300    let end = text.rfind('}')?;
301    if end > start { serde_json::from_str::<T>(&text[start..=end]).ok() } else { None }
302}
303
304fn extract_json_from_fences(text: &str) -> Option<&str> {
305    let start_markers = ["```json\n", "```json\r\n", "```\n", "```\r\n"];
306    for marker in &start_markers {
307        if let Some(start) = text.find(marker) {
308            let content_start = start + marker.len();
309            if let Some(end) = text[content_start..].find("```") {
310                return Some(&text[content_start..content_start + end]);
311            }
312        }
313    }
314    None
315}
316
317#[cfg(test)]
318mod tests {
319    use proptest::prelude::*;
320
321    use super::*;
322
323    const ALL_ROLES: [AgentRole; 5] = [
324        AgentRole::Planner,
325        AgentRole::Implementer,
326        AgentRole::Reviewer,
327        AgentRole::Fixer,
328        AgentRole::Merger,
329    ];
330
331    proptest! {
332        #[test]
333        fn agent_role_display_fromstr_roundtrip(idx in 0..5usize) {
334            let role = ALL_ROLES[idx];
335            let s = role.to_string();
336            let parsed: AgentRole = s.parse().unwrap();
337            assert_eq!(role, parsed);
338        }
339
340        #[test]
341        fn arbitrary_strings_never_panic_on_role_parse(s in "\\PC{1,50}") {
342            let _ = s.parse::<AgentRole>();
343        }
344
345        #[test]
346        fn parse_review_output_never_panics(text in "\\PC{0,500}") {
347            // parse_review_output should never panic on any input (may return Err)
348            let _ = parse_review_output(&text);
349        }
350
351        #[test]
352        fn valid_review_json_always_parses(
353            severity in prop_oneof!["critical", "warning", "info"],
354            category in "[a-z]{3,15}",
355            message in "[a-zA-Z0-9 ]{1,50}",
356        ) {
357            let json = format!(
358                r#"{{"findings":[{{"severity":"{severity}","category":"{category}","message":"{message}"}}],"summary":"test"}}"#
359            );
360            let output = parse_review_output(&json).unwrap();
361            assert_eq!(output.findings.len(), 1);
362            assert_eq!(output.findings[0].category, category);
363        }
364
365        #[test]
366        fn review_json_in_fences_parses(
367            severity in prop_oneof!["critical", "warning", "info"],
368            category in "[a-z]{3,15}",
369            message in "[a-zA-Z0-9 ]{1,50}",
370            prefix in "[a-zA-Z ]{0,30}",
371            suffix in "[a-zA-Z ]{0,30}",
372        ) {
373            let json = format!(
374                r#"{{"findings":[{{"severity":"{severity}","category":"{category}","message":"{message}"}}],"summary":"ok"}}"#
375            );
376            let text = format!("{prefix}\n```json\n{json}\n```\n{suffix}");
377            let output = parse_review_output(&text).unwrap();
378            assert_eq!(output.findings.len(), 1);
379        }
380    }
381
382    #[test]
383    fn tool_scoping_per_role() {
384        assert_eq!(AgentRole::Planner.allowed_tools(), &["Read", "Glob", "Grep"]);
385        assert_eq!(
386            AgentRole::Implementer.allowed_tools(),
387            &["Read", "Write", "Edit", "Glob", "Grep", "Bash"]
388        );
389        assert_eq!(AgentRole::Reviewer.allowed_tools(), &["Read", "Glob", "Grep"]);
390        assert_eq!(
391            AgentRole::Fixer.allowed_tools(),
392            &["Read", "Write", "Edit", "Glob", "Grep", "Bash"]
393        );
394        assert_eq!(AgentRole::Merger.allowed_tools(), &["Bash"]);
395    }
396
397    #[test]
398    fn role_display_roundtrip() {
399        let roles = [
400            AgentRole::Planner,
401            AgentRole::Implementer,
402            AgentRole::Reviewer,
403            AgentRole::Fixer,
404            AgentRole::Merger,
405        ];
406        for role in roles {
407            let s = role.to_string();
408            let parsed: AgentRole = s.parse().unwrap();
409            assert_eq!(role, parsed);
410        }
411    }
412
413    #[test]
414    fn parse_review_output_valid_json() {
415        let json = r#"{"findings":[{"severity":"critical","category":"bug","file_path":"src/main.rs","line_number":10,"message":"null pointer"}],"summary":"one issue found"}"#;
416        let output = parse_review_output(json).unwrap();
417        assert_eq!(output.findings.len(), 1);
418        assert_eq!(output.findings[0].severity, Severity::Critical);
419        assert_eq!(output.findings[0].message, "null pointer");
420        assert_eq!(output.summary, "one issue found");
421    }
422
423    #[test]
424    fn parse_review_output_in_code_fences() {
425        let text = r#"Here are my findings:
426
427```json
428{"findings":[{"severity":"warning","category":"style","message":"missing docs"}],"summary":"ok"}
429```
430
431That's it."#;
432        let output = parse_review_output(text).unwrap();
433        assert_eq!(output.findings.len(), 1);
434        assert_eq!(output.findings[0].severity, Severity::Warning);
435    }
436
437    #[test]
438    fn parse_review_output_embedded_json() {
439        let text = r#"I reviewed the code and found: {"findings":[{"severity":"info","category":"note","message":"looks fine"}],"summary":"clean"} end of review"#;
440        let output = parse_review_output(text).unwrap();
441        assert_eq!(output.findings.len(), 1);
442    }
443
444    #[test]
445    fn parse_review_output_no_json_returns_error() {
446        let text = "The code looks great, no issues found.";
447        let result = parse_review_output(text);
448        assert!(result.is_err());
449        assert!(result.unwrap_err().to_string().contains("unparseable"));
450    }
451
452    #[test]
453    fn parse_review_output_malformed_json_returns_error() {
454        let text = r#"{"findings": [{"broken json"#;
455        let result = parse_review_output(text);
456        assert!(result.is_err());
457    }
458
459    // --- Planner output parsing tests ---
460
461    #[test]
462    fn parse_planner_output_valid_json() {
463        let json = r#"{
464            "batches": [{
465                "batch": 1,
466                "issues": [{
467                    "number": 42,
468                    "title": "Add login",
469                    "area": "auth",
470                    "predicted_files": ["src/auth.rs"],
471                    "has_migration": false,
472                    "complexity": "simple"
473                }],
474                "reasoning": "standalone issue"
475            }],
476            "total_issues": 1,
477            "parallel_capacity": 1
478        }"#;
479        let output = parse_planner_output(json).unwrap();
480        assert_eq!(output.batches.len(), 1);
481        assert_eq!(output.batches[0].issues.len(), 1);
482        assert_eq!(output.batches[0].issues[0].number, 42);
483        assert_eq!(output.batches[0].issues[0].complexity, Complexity::Simple);
484        assert!(!output.batches[0].issues[0].has_migration);
485    }
486
487    #[test]
488    fn parse_planner_output_in_code_fences() {
489        let text = r#"Here's the plan:
490
491```json
492{
493    "batches": [{"batch": 1, "issues": [{"number": 1, "complexity": "full"}], "reasoning": "ok"}],
494    "total_issues": 1,
495    "parallel_capacity": 1
496}
497```
498
499That's the plan."#;
500        let output = parse_planner_output(text).unwrap();
501        assert_eq!(output.batches.len(), 1);
502        assert_eq!(output.batches[0].issues[0].complexity, Complexity::Full);
503    }
504
505    #[test]
506    fn parse_planner_output_malformed_returns_none() {
507        assert!(parse_planner_output("not json at all").is_none());
508        assert!(parse_planner_output(r#"{"batches": "broken"}"#).is_none());
509        assert!(parse_planner_output("").is_none());
510    }
511
512    #[test]
513    fn complexity_deserializes_from_strings() {
514        let simple: Complexity = serde_json::from_str(r#""simple""#).unwrap();
515        assert_eq!(simple, Complexity::Simple);
516        let full: Complexity = serde_json::from_str(r#""full""#).unwrap();
517        assert_eq!(full, Complexity::Full);
518    }
519
520    #[test]
521    fn complexity_display_roundtrip() {
522        for c in [Complexity::Simple, Complexity::Full] {
523            let s = c.to_string();
524            let parsed: Complexity = s.parse().unwrap();
525            assert_eq!(c, parsed);
526        }
527    }
528
529    #[test]
530    fn planner_output_defaults_complexity_to_full() {
531        let json = r#"{"batches": [{"batch": 1, "issues": [{"number": 5}], "reasoning": ""}], "total_issues": 1, "parallel_capacity": 1}"#;
532        let output = parse_planner_output(json).unwrap();
533        assert_eq!(output.batches[0].issues[0].complexity, Complexity::Full);
534    }
535
536    #[test]
537    fn planner_output_with_multiple_batches() {
538        let json = r#"{
539            "batches": [
540                {"batch": 1, "issues": [{"number": 1, "complexity": "simple"}, {"number": 2, "complexity": "simple"}], "reasoning": "independent"},
541                {"batch": 2, "issues": [{"number": 3, "complexity": "full"}], "reasoning": "depends on batch 1"}
542            ],
543            "total_issues": 3,
544            "parallel_capacity": 2
545        }"#;
546        let output = parse_planner_output(json).unwrap();
547        assert_eq!(output.batches.len(), 2);
548        assert_eq!(output.batches[0].issues.len(), 2);
549        assert_eq!(output.batches[1].issues.len(), 1);
550        assert_eq!(output.total_issues, 3);
551    }
552}