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#[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#[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 pub target_repo: Option<String>,
85 pub issue_source: String,
88 pub base_branch: String,
91}
92
93pub struct AgentInvocation {
95 pub role: AgentRole,
96 pub prompt: String,
97 pub working_dir: PathBuf,
98 pub max_turns: Option<u32>,
99}
100
101pub 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#[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#[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 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#[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
222pub fn parse_planner_output(text: &str) -> Option<PlannerOutput> {
226 extract_json(text)
227}
228
229#[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
272pub fn parse_review_output(text: &str) -> Result<ReviewOutput> {
278 extract_json(text).context("reviewer returned unparseable output (no valid JSON found)")
279}
280
281fn 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 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 #[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}