1use crate::agents::AgentDef;
6use crate::commands::spawn::terminal::Harness;
7use crate::commands::swarm::session::WaveSummary;
8use crate::models::task::Task;
9use std::path::Path;
10
11#[derive(Debug, Clone)]
13pub struct ResolvedAgentConfig {
14 pub harness: Harness,
16 pub model: Option<String>,
18 pub prompt: String,
20 pub from_agent_def: bool,
22 pub agent_type: Option<String>,
24}
25
26pub fn resolve_agent_config(
39 task: &Task,
40 tag: &str,
41 default_harness: Harness,
42 default_model: Option<&str>,
43 working_dir: &Path,
44) -> ResolvedAgentConfig {
45 if let Some(ref agent_type) = task.agent_type {
46 match AgentDef::try_load(agent_type, working_dir) {
48 Some(agent_def) => {
49 let harness = agent_def.harness().unwrap_or(default_harness);
50 let model = agent_def
51 .model()
52 .map(String::from)
53 .or_else(|| default_model.map(String::from));
54
55 let prompt = match agent_def.prompt_template(working_dir) {
57 Some(template) => generate_prompt_with_template(task, tag, &template),
58 None => generate_prompt(task, tag),
59 };
60
61 ResolvedAgentConfig {
62 harness,
63 model,
64 prompt,
65 from_agent_def: true,
66 agent_type: Some(agent_type.clone()),
67 }
68 }
69 None => {
70 ResolvedAgentConfig {
72 harness: default_harness,
73 model: default_model.map(String::from),
74 prompt: generate_prompt(task, tag),
75 from_agent_def: false,
76 agent_type: Some(agent_type.clone()),
77 }
78 }
79 }
80 } else {
81 ResolvedAgentConfig {
83 harness: default_harness,
84 model: default_model.map(String::from),
85 prompt: generate_prompt(task, tag),
86 from_agent_def: false,
87 agent_type: None,
88 }
89 }
90}
91
92impl ResolvedAgentConfig {
93 pub fn display_info(&self) -> String {
95 let model_str = self
96 .model
97 .as_deref()
98 .map(|m| format!(":{}", m))
99 .unwrap_or_default();
100
101 if let Some(ref agent_type) = self.agent_type {
102 format!("{}{}@{}", self.harness.name(), model_str, agent_type)
103 } else {
104 format!("{}{}", self.harness.name(), model_str)
105 }
106 }
107}
108
109pub fn generate_prompt(task: &Task, tag: &str) -> String {
111 let mut prompt = format!(
112 r#"You are working on SCUD task {id}: {title}
113
114Tag: {tag}
115Complexity: {complexity}
116Priority: {priority:?}
117
118Description:
119{description}
120"#,
121 id = task.id,
122 title = task.title,
123 tag = tag,
124 complexity = task.complexity,
125 priority = task.priority,
126 description = task.description,
127 );
128
129 if let Some(ref details) = task.details {
131 prompt.push_str(&format!(
132 r#"
133Technical Details:
134{}
135"#,
136 details
137 ));
138 }
139
140 if let Some(ref test_strategy) = task.test_strategy {
142 prompt.push_str(&format!(
143 r#"
144Test Strategy:
145{}
146"#,
147 test_strategy
148 ));
149 }
150
151 if !task.dependencies.is_empty() {
153 prompt.push_str(&format!(
154 r#"
155Dependencies (should be done):
156{}
157"#,
158 task.dependencies.join(", ")
159 ));
160 }
161
162 prompt.push_str(&format!(
164 r#"
165Instructions:
1661. Check for discoveries from other agents: scud log-all --limit 10
1672. Explore the codebase to understand the context for this task
1683. Implement the task following project conventions and patterns
1694. Log important discoveries to share with other agents:
170 scud log {id} "Found X in Y, useful for Z"
1715. Write tests if applicable based on the test strategy
1726. When complete, run: scud set-status {id} done
1737. If blocked by issues, run: scud set-status {id} blocked
174
175Discovery Logging:
176- Log findings that other agents might benefit from (file locations, patterns, gotchas)
177- Keep logs concise but informative (1-3 sentences)
178- Example: scud log {id} "Auth helpers are in lib/auth.rs, not utils/"
179
180Begin by checking recent logs and exploring relevant code.
181"#,
182 id = task.id
183 ));
184
185 prompt
186}
187
188pub fn generate_minimal_prompt(task: &Task, tag: &str) -> String {
190 format!(
191 r#"SCUD Task {id}: {title}
192
193Tag: {tag}
194Description: {description}
195
196First: scud log-all --limit 5 (check recent discoveries)
197Log findings: scud log {id} "your discovery"
198When done: scud set-status {id} done
199If blocked: scud set-status {id} blocked
200"#,
201 id = task.id,
202 title = task.title,
203 tag = tag,
204 description = task.description
205 )
206}
207
208pub fn generate_prompt_with_template(task: &Task, tag: &str, template: &str) -> String {
221 let mut result = template.to_string();
222
223 result = result.replace("{task.id}", &task.id);
224 result = result.replace("{task.title}", &task.title);
225 result = result.replace("{task.description}", &task.description);
226 result = result.replace("{task.complexity}", &task.complexity.to_string());
227 result = result.replace("{task.priority}", &format!("{:?}", task.priority));
228 result = result.replace("{task.details}", task.details.as_deref().unwrap_or(""));
229 result = result.replace(
230 "{task.test_strategy}",
231 task.test_strategy.as_deref().unwrap_or(""),
232 );
233 result = result.replace("{task.dependencies}", &task.dependencies.join(", "));
234 result = result.replace("{tag}", tag);
235
236 result
237}
238
239pub fn generate_review_prompt(
241 summary: &WaveSummary,
242 tasks: &[(String, String)], review_all: bool,
244) -> String {
245 let tasks_str = if review_all {
246 tasks
247 .iter()
248 .map(|(id, title)| format!("- {} | {}", id, title))
249 .collect::<Vec<_>>()
250 .join("\n")
251 } else {
252 let sample: Vec<_> = if tasks.len() <= 3 {
254 tasks.iter().collect()
255 } else {
256 vec![&tasks[0], &tasks[tasks.len() / 2], &tasks[tasks.len() - 1]]
257 };
258 sample
259 .iter()
260 .map(|(id, title)| format!("- {} | {}", id, title))
261 .collect::<Vec<_>>()
262 .join("\n")
263 };
264
265 let files_str = if summary.files_changed.len() <= 10 {
266 summary.files_changed.join("\n")
267 } else {
268 let mut s = summary.files_changed[..10].join("\n");
269 s.push_str(&format!(
270 "\n... and {} more files",
271 summary.files_changed.len() - 10
272 ));
273 s
274 };
275
276 format!(
277 r#"You are reviewing SCUD wave {wave_number}.
278
279## Tasks to Review
280{tasks}
281
282## Files Changed
283{files}
284
285## Review Process
2861. For each task, run: scud show <task_id>
2872. Read the changed files relevant to each task
2883. Check implementation quality and correctness
289
290## Output Format
291For each task:
292 PASS: <task_id> - looks good
293 IMPROVE: <task_id> - <specific issue>
294
295When complete, create marker file:
296 echo "REVIEW_COMPLETE: ALL_PASS" > .scud/review-complete-{wave_number}
297Or if improvements needed:
298 echo "REVIEW_COMPLETE: IMPROVEMENTS_NEEDED" > .scud/review-complete-{wave_number}
299 echo "IMPROVE_TASKS: <comma-separated task IDs>" >> .scud/review-complete-{wave_number}
300"#,
301 wave_number = summary.wave_number,
302 tasks = tasks_str,
303 files = files_str,
304 )
305}
306
307pub fn generate_repair_prompt(
309 task_id: &str,
310 task_title: &str,
311 failed_command: &str,
312 error_output: &str,
313 task_files: &[String],
314 error_files: &[String],
315) -> String {
316 let task_files_str = task_files.join(", ");
317 let error_files_str = error_files.join(", ");
318
319 format!(
320 r#"You are a repair agent fixing validation failures for SCUD task {task_id}: {task_title}
321
322## Validation Failure
323The following validation command failed:
324{failed_command}
325
326Error output:
327{error_output}
328
329## Attribution
330This failure has been attributed to task {task_id} based on git blame analysis.
331Files changed by this task: {task_files}
332
333## Your Mission
3341. Analyze the error output to understand what went wrong
3352. Read the relevant files: {error_files}
3363. Fix the issue while preserving the task's intended functionality
3374. Run the validation command to verify the fix: {failed_command}
338
339## Important
340- Focus on fixing the specific error, don't refactor unrelated code
341- If the fix requires changes to other tasks' code, note it but don't modify
342- After fixing, commit with: scud commit -m "fix: {task_id} - <description>"
343- Log what you fixed for other agents: scud log {task_id} "Fixed: <brief description>"
344
345When the validation passes:
346 scud log {task_id} "Repair successful: <what was fixed>"
347 scud set-status {task_id} done
348 echo "REPAIR_COMPLETE: SUCCESS" > .scud/repair-complete-{task_id}
349
350If you cannot fix it:
351 scud log {task_id} "Repair blocked: <reason>"
352 scud set-status {task_id} blocked
353 echo "REPAIR_COMPLETE: BLOCKED" > .scud/repair-complete-{task_id}
354 echo "REASON: <explanation>" >> .scud/repair-complete-{task_id}
355"#,
356 task_id = task_id,
357 task_title = task_title,
358 failed_command = failed_command,
359 error_output = error_output,
360 task_files = task_files_str,
361 error_files = error_files_str,
362 )
363}
364
365pub fn generate_batch_repair_prompt(
367 tasks: &[(String, String, Vec<String>)], failed_command: &str,
369 error_output: &str,
370 error_locations: &[(String, Option<u32>)], ) -> String {
372 let tasks_str = tasks
373 .iter()
374 .map(|(id, title, files)| format!("- {} | {}\n Files: {}", id, title, files.join(", ")))
375 .collect::<Vec<_>>()
376 .join("\n");
377
378 let error_locations_str = error_locations
379 .iter()
380 .take(20) .map(|(file, line)| match line {
382 Some(l) => format!(" {}:{}", file, l),
383 None => format!(" {}", file),
384 })
385 .collect::<Vec<_>>()
386 .join("\n");
387
388 format!(
389 r#"You are a batch repair agent fixing validation failures for multiple SCUD tasks.
390
391## Validation Failure
392The following validation command failed:
393{failed_command}
394
395Error output:
396{error_output}
397
398## Error Locations
399{error_locations}
400
401## Responsible Tasks
402Based on git blame analysis, these tasks may be responsible:
403{tasks}
404
405## Your Mission
4061. Analyze the error output to understand ALL the issues
4072. Read the relevant files and understand what each task was trying to do
4083. Fix issues systematically - some errors may be related
4094. Run the validation command after each fix to check progress: {failed_command}
410
411## Process
412For each issue:
4131. Identify which task introduced it
4142. Read the task details: scud show <task_id>
4153. Fix the issue while preserving intended functionality
4164. Commit: scud commit -m "fix: <task_id> - <description>"
4175. Log: scud log <task_id> "Fixed: <brief description>"
418
419## Important
420- Fix ALL issues before signaling completion
421- Some issues may cascade - fix root causes first
422- If you cannot fix an issue, document why
423- Iterate until validation passes or you're truly blocked
424
425## Completion
426When ALL validation passes:
427 echo "BATCH_REPAIR_COMPLETE: SUCCESS" > .scud/batch-repair-complete
428 echo "FIXED_TASKS: <comma-separated task IDs that were fixed>" >> .scud/batch-repair-complete
429
430If blocked on some tasks:
431 echo "BATCH_REPAIR_COMPLETE: PARTIAL" > .scud/batch-repair-complete
432 echo "FIXED_TASKS: <task IDs fixed>" >> .scud/batch-repair-complete
433 echo "BLOCKED_TASKS: <task IDs blocked>" >> .scud/batch-repair-complete
434 echo "BLOCK_REASON: <explanation>" >> .scud/batch-repair-complete
435
436If completely blocked:
437 echo "BATCH_REPAIR_COMPLETE: BLOCKED" > .scud/batch-repair-complete
438 echo "REASON: <explanation>" >> .scud/batch-repair-complete
439"#,
440 failed_command = failed_command,
441 error_output = error_output,
442 error_locations = error_locations_str,
443 tasks = tasks_str,
444 )
445}
446
447#[cfg(test)]
448mod tests {
449 use super::*;
450 use crate::models::task::Task;
451
452 #[test]
453 fn test_generate_prompt_basic() {
454 let task = Task::new(
455 "auth:1".to_string(),
456 "Implement login".to_string(),
457 "Add user authentication flow".to_string(),
458 );
459
460 let prompt = generate_prompt(&task, "auth");
461
462 assert!(prompt.contains("auth:1"));
463 assert!(prompt.contains("Implement login"));
464 assert!(prompt.contains("Tag: auth"));
465 assert!(prompt.contains("scud set-status auth:1 done"));
466 }
467
468 #[test]
469 fn test_generate_prompt_with_details() {
470 let mut task = Task::new(
471 "api:2".to_string(),
472 "Add endpoint".to_string(),
473 "Create REST endpoint".to_string(),
474 );
475 task.details = Some("Use Express.js router pattern".to_string());
476 task.test_strategy = Some("Unit test with Jest".to_string());
477
478 let prompt = generate_prompt(&task, "api");
479
480 assert!(prompt.contains("Technical Details:"));
481 assert!(prompt.contains("Express.js router"));
482 assert!(prompt.contains("Test Strategy:"));
483 assert!(prompt.contains("Unit test with Jest"));
484 }
485
486 #[test]
487 fn test_generate_minimal_prompt() {
488 let task = Task::new(
489 "fix:1".to_string(),
490 "Quick fix".to_string(),
491 "Fix typo".to_string(),
492 );
493
494 let prompt = generate_minimal_prompt(&task, "fix");
495
496 assert!(prompt.contains("fix:1"));
497 assert!(prompt.contains("Quick fix"));
498 assert!(!prompt.contains("Technical Details"));
499 }
500
501 #[test]
502 fn test_generate_prompt_with_template() {
503 let mut task = Task::new(
504 "auth:1".to_string(),
505 "Login Feature".to_string(),
506 "Implement login".to_string(),
507 );
508 task.complexity = 5;
509 task.details = Some("Use OAuth".to_string());
510
511 let template = "Task: {task.id} - {task.title}\nTag: {tag}\nDetails: {task.details}";
512 let prompt = generate_prompt_with_template(&task, "auth", template);
513
514 assert_eq!(
515 prompt,
516 "Task: auth:1 - Login Feature\nTag: auth\nDetails: Use OAuth"
517 );
518 }
519
520 #[test]
521 fn test_generate_prompt_with_template_missing_fields() {
522 let task = Task::new("1".to_string(), "Title".to_string(), "Desc".to_string());
523
524 let template = "Details: {task.details} | Strategy: {task.test_strategy}";
525 let prompt = generate_prompt_with_template(&task, "test", template);
526
527 assert_eq!(prompt, "Details: | Strategy: ");
528 }
529
530 #[test]
531 fn test_generate_review_prompt_all() {
532 let summary = WaveSummary {
533 wave_number: 1,
534 tasks_completed: vec!["auth:1".to_string(), "auth:2".to_string()],
535 files_changed: vec!["src/auth.rs".to_string(), "src/main.rs".to_string()],
536 };
537
538 let tasks = vec![
539 ("auth:1".to_string(), "Add login".to_string()),
540 ("auth:2".to_string(), "Add logout".to_string()),
541 ];
542
543 let prompt = generate_review_prompt(&summary, &tasks, true);
544
545 assert!(prompt.contains("wave 1"));
546 assert!(prompt.contains("auth:1 | Add login"));
547 assert!(prompt.contains("auth:2 | Add logout"));
548 assert!(prompt.contains("src/auth.rs"));
549 }
550
551 #[test]
552 fn test_generate_review_prompt_sampled() {
553 let summary = WaveSummary {
554 wave_number: 2,
555 tasks_completed: vec![
556 "t:1".to_string(),
557 "t:2".to_string(),
558 "t:3".to_string(),
559 "t:4".to_string(),
560 "t:5".to_string(),
561 ],
562 files_changed: vec!["a.rs".to_string()],
563 };
564
565 let tasks: Vec<_> = (1..=5)
566 .map(|i| (format!("t:{}", i), format!("Task {}", i)))
567 .collect();
568
569 let prompt = generate_review_prompt(&summary, &tasks, false);
570
571 assert!(prompt.contains("t:1"));
573 assert!(prompt.contains("t:3")); assert!(prompt.contains("t:5")); assert!(!prompt.contains("t:2 | Task 2"));
577 assert!(!prompt.contains("t:4 | Task 4"));
578 }
579
580 #[test]
581 fn test_generate_repair_prompt() {
582 let prompt = generate_repair_prompt(
583 "auth:1",
584 "Add login",
585 "cargo build",
586 "error: mismatched types at src/main.rs:42",
587 &["src/auth.rs".to_string()],
588 &["src/main.rs".to_string()],
589 );
590
591 assert!(prompt.contains("auth:1"));
592 assert!(prompt.contains("Add login"));
593 assert!(prompt.contains("cargo build"));
594 assert!(prompt.contains("mismatched types"));
595 assert!(prompt.contains("src/auth.rs"));
596 assert!(prompt.contains("src/main.rs"));
597 assert!(prompt.contains("REPAIR_COMPLETE"));
598 }
599
600 #[test]
605 fn test_resolve_agent_config_no_agent_type() {
606 let temp = tempfile::TempDir::new().unwrap();
607 let task = Task::new("1".to_string(), "Test".to_string(), "Desc".to_string());
608
609 let config = resolve_agent_config(&task, "test", Harness::Claude, None, temp.path());
610
611 assert_eq!(config.harness, Harness::Claude);
612 assert_eq!(config.model, None);
613 assert!(!config.from_agent_def);
614 assert!(config.agent_type.is_none());
615 }
616
617 #[test]
618 fn test_resolve_agent_config_uses_default_model() {
619 let temp = tempfile::TempDir::new().unwrap();
620 let task = Task::new("1".to_string(), "Test".to_string(), "Desc".to_string());
621
622 let config =
623 resolve_agent_config(&task, "test", Harness::Claude, Some("opus"), temp.path());
624
625 assert_eq!(config.harness, Harness::Claude);
626 assert_eq!(config.model, Some("opus".to_string()));
627 assert!(!config.from_agent_def);
628 }
629
630 #[test]
631 fn test_resolve_agent_config_agent_type_not_found() {
632 let temp = tempfile::TempDir::new().unwrap();
633 let mut task = Task::new("1".to_string(), "Test".to_string(), "Desc".to_string());
634 task.agent_type = Some("nonexistent".to_string());
635
636 let config =
637 resolve_agent_config(&task, "test", Harness::Claude, Some("sonnet"), temp.path());
638
639 assert_eq!(config.harness, Harness::Claude);
641 assert_eq!(config.model, Some("sonnet".to_string()));
642 assert!(!config.from_agent_def);
643 assert_eq!(config.agent_type, Some("nonexistent".to_string()));
644 }
645
646 #[test]
647 fn test_resolve_agent_config_from_agent_def() {
648 let temp = tempfile::TempDir::new().unwrap();
649 let agents_dir = temp.path().join(".scud").join("agents");
650 std::fs::create_dir_all(&agents_dir).unwrap();
651
652 let agent_file = agents_dir.join("fast-builder.toml");
654 std::fs::write(
655 &agent_file,
656 r#"
657[agent]
658name = "fast-builder"
659description = "Fast builder"
660
661[model]
662harness = "opencode"
663model = "xai/grok-code-fast-1"
664"#,
665 )
666 .unwrap();
667
668 let mut task = Task::new("1".to_string(), "Test".to_string(), "Desc".to_string());
669 task.agent_type = Some("fast-builder".to_string());
670
671 let config =
673 resolve_agent_config(&task, "test", Harness::Claude, Some("opus"), temp.path());
674
675 assert_eq!(config.harness, Harness::OpenCode);
676 assert_eq!(config.model, Some("xai/grok-code-fast-1".to_string()));
677 assert!(config.from_agent_def);
678 assert_eq!(config.agent_type, Some("fast-builder".to_string()));
679 }
680
681 #[test]
682 fn test_resolve_agent_config_agent_def_without_model_uses_default() {
683 let temp = tempfile::TempDir::new().unwrap();
684 let agents_dir = temp.path().join(".scud").join("agents");
685 std::fs::create_dir_all(&agents_dir).unwrap();
686
687 let agent_file = agents_dir.join("custom.toml");
689 std::fs::write(
690 &agent_file,
691 r#"
692[agent]
693name = "custom"
694
695[model]
696harness = "opencode"
697"#,
698 )
699 .unwrap();
700
701 let mut task = Task::new("1".to_string(), "Test".to_string(), "Desc".to_string());
702 task.agent_type = Some("custom".to_string());
703
704 let config =
705 resolve_agent_config(&task, "test", Harness::Claude, Some("opus"), temp.path());
706
707 assert_eq!(config.harness, Harness::OpenCode);
709 assert_eq!(config.model, Some("opus".to_string()));
710 assert!(config.from_agent_def);
711 }
712
713 #[test]
714 fn test_resolve_agent_config_uses_custom_prompt_template() {
715 let temp = tempfile::TempDir::new().unwrap();
716 let agents_dir = temp.path().join(".scud").join("agents");
717 std::fs::create_dir_all(&agents_dir).unwrap();
718
719 let agent_file = agents_dir.join("templated.toml");
720 std::fs::write(
721 &agent_file,
722 r#"
723[agent]
724name = "templated"
725
726[model]
727harness = "claude"
728
729[prompt]
730template = "Custom: {task.title} in {tag}"
731"#,
732 )
733 .unwrap();
734
735 let mut task = Task::new("1".to_string(), "My Task".to_string(), "Desc".to_string());
736 task.agent_type = Some("templated".to_string());
737
738 let config = resolve_agent_config(&task, "my-tag", Harness::Claude, None, temp.path());
739
740 assert_eq!(config.prompt, "Custom: My Task in my-tag");
741 assert!(config.from_agent_def);
742 }
743
744 #[test]
745 fn test_resolved_agent_config_display_info() {
746 let config = ResolvedAgentConfig {
747 harness: Harness::OpenCode,
748 model: Some("xai/grok-code-fast-1".to_string()),
749 prompt: "test".to_string(),
750 from_agent_def: true,
751 agent_type: Some("fast-builder".to_string()),
752 };
753
754 assert_eq!(
755 config.display_info(),
756 "opencode:xai/grok-code-fast-1@fast-builder"
757 );
758
759 let config_no_model = ResolvedAgentConfig {
760 harness: Harness::Claude,
761 model: None,
762 prompt: "test".to_string(),
763 from_agent_def: false,
764 agent_type: None,
765 };
766
767 assert_eq!(config_no_model.display_info(), "claude");
768 }
769}