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#[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}
89
90pub struct AgentInvocation {
92 pub role: AgentRole,
93 pub prompt: String,
94 pub working_dir: PathBuf,
95 pub max_turns: Option<u32>,
96}
97
98pub 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#[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#[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
179pub fn parse_planner_output(text: &str) -> Option<PlannerOutput> {
183 extract_json(text)
184}
185
186#[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
229pub fn parse_review_output(text: &str) -> Result<ReviewOutput> {
235 extract_json(text).context("reviewer returned unparseable output (no valid JSON found)")
236}
237
238fn 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 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 #[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}