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
365#[cfg(test)]
366mod tests {
367 use super::*;
368 use crate::models::task::Task;
369
370 #[test]
371 fn test_generate_prompt_basic() {
372 let task = Task::new(
373 "auth:1".to_string(),
374 "Implement login".to_string(),
375 "Add user authentication flow".to_string(),
376 );
377
378 let prompt = generate_prompt(&task, "auth");
379
380 assert!(prompt.contains("auth:1"));
381 assert!(prompt.contains("Implement login"));
382 assert!(prompt.contains("Tag: auth"));
383 assert!(prompt.contains("scud set-status auth:1 done"));
384 }
385
386 #[test]
387 fn test_generate_prompt_with_details() {
388 let mut task = Task::new(
389 "api:2".to_string(),
390 "Add endpoint".to_string(),
391 "Create REST endpoint".to_string(),
392 );
393 task.details = Some("Use Express.js router pattern".to_string());
394 task.test_strategy = Some("Unit test with Jest".to_string());
395
396 let prompt = generate_prompt(&task, "api");
397
398 assert!(prompt.contains("Technical Details:"));
399 assert!(prompt.contains("Express.js router"));
400 assert!(prompt.contains("Test Strategy:"));
401 assert!(prompt.contains("Unit test with Jest"));
402 }
403
404 #[test]
405 fn test_generate_minimal_prompt() {
406 let task = Task::new(
407 "fix:1".to_string(),
408 "Quick fix".to_string(),
409 "Fix typo".to_string(),
410 );
411
412 let prompt = generate_minimal_prompt(&task, "fix");
413
414 assert!(prompt.contains("fix:1"));
415 assert!(prompt.contains("Quick fix"));
416 assert!(!prompt.contains("Technical Details"));
417 }
418
419 #[test]
420 fn test_generate_prompt_with_template() {
421 let mut task = Task::new(
422 "auth:1".to_string(),
423 "Login Feature".to_string(),
424 "Implement login".to_string(),
425 );
426 task.complexity = 5;
427 task.details = Some("Use OAuth".to_string());
428
429 let template = "Task: {task.id} - {task.title}\nTag: {tag}\nDetails: {task.details}";
430 let prompt = generate_prompt_with_template(&task, "auth", template);
431
432 assert_eq!(
433 prompt,
434 "Task: auth:1 - Login Feature\nTag: auth\nDetails: Use OAuth"
435 );
436 }
437
438 #[test]
439 fn test_generate_prompt_with_template_missing_fields() {
440 let task = Task::new("1".to_string(), "Title".to_string(), "Desc".to_string());
441
442 let template = "Details: {task.details} | Strategy: {task.test_strategy}";
443 let prompt = generate_prompt_with_template(&task, "test", template);
444
445 assert_eq!(prompt, "Details: | Strategy: ");
446 }
447
448 #[test]
449 fn test_generate_review_prompt_all() {
450 let summary = WaveSummary {
451 wave_number: 1,
452 tasks_completed: vec!["auth:1".to_string(), "auth:2".to_string()],
453 files_changed: vec!["src/auth.rs".to_string(), "src/main.rs".to_string()],
454 };
455
456 let tasks = vec![
457 ("auth:1".to_string(), "Add login".to_string()),
458 ("auth:2".to_string(), "Add logout".to_string()),
459 ];
460
461 let prompt = generate_review_prompt(&summary, &tasks, true);
462
463 assert!(prompt.contains("wave 1"));
464 assert!(prompt.contains("auth:1 | Add login"));
465 assert!(prompt.contains("auth:2 | Add logout"));
466 assert!(prompt.contains("src/auth.rs"));
467 }
468
469 #[test]
470 fn test_generate_review_prompt_sampled() {
471 let summary = WaveSummary {
472 wave_number: 2,
473 tasks_completed: vec![
474 "t:1".to_string(),
475 "t:2".to_string(),
476 "t:3".to_string(),
477 "t:4".to_string(),
478 "t:5".to_string(),
479 ],
480 files_changed: vec!["a.rs".to_string()],
481 };
482
483 let tasks: Vec<_> = (1..=5)
484 .map(|i| (format!("t:{}", i), format!("Task {}", i)))
485 .collect();
486
487 let prompt = generate_review_prompt(&summary, &tasks, false);
488
489 assert!(prompt.contains("t:1"));
491 assert!(prompt.contains("t:3")); assert!(prompt.contains("t:5")); assert!(!prompt.contains("t:2 | Task 2"));
495 assert!(!prompt.contains("t:4 | Task 4"));
496 }
497
498 #[test]
499 fn test_generate_repair_prompt() {
500 let prompt = generate_repair_prompt(
501 "auth:1",
502 "Add login",
503 "cargo build",
504 "error: mismatched types at src/main.rs:42",
505 &["src/auth.rs".to_string()],
506 &["src/main.rs".to_string()],
507 );
508
509 assert!(prompt.contains("auth:1"));
510 assert!(prompt.contains("Add login"));
511 assert!(prompt.contains("cargo build"));
512 assert!(prompt.contains("mismatched types"));
513 assert!(prompt.contains("src/auth.rs"));
514 assert!(prompt.contains("src/main.rs"));
515 assert!(prompt.contains("REPAIR_COMPLETE"));
516 }
517
518 #[test]
523 fn test_resolve_agent_config_no_agent_type() {
524 let temp = tempfile::TempDir::new().unwrap();
525 let task = Task::new("1".to_string(), "Test".to_string(), "Desc".to_string());
526
527 let config = resolve_agent_config(&task, "test", Harness::Claude, None, temp.path());
528
529 assert_eq!(config.harness, Harness::Claude);
530 assert_eq!(config.model, None);
531 assert!(!config.from_agent_def);
532 assert!(config.agent_type.is_none());
533 }
534
535 #[test]
536 fn test_resolve_agent_config_uses_default_model() {
537 let temp = tempfile::TempDir::new().unwrap();
538 let task = Task::new("1".to_string(), "Test".to_string(), "Desc".to_string());
539
540 let config =
541 resolve_agent_config(&task, "test", Harness::Claude, Some("opus"), temp.path());
542
543 assert_eq!(config.harness, Harness::Claude);
544 assert_eq!(config.model, Some("opus".to_string()));
545 assert!(!config.from_agent_def);
546 }
547
548 #[test]
549 fn test_resolve_agent_config_agent_type_not_found() {
550 let temp = tempfile::TempDir::new().unwrap();
551 let mut task = Task::new("1".to_string(), "Test".to_string(), "Desc".to_string());
552 task.agent_type = Some("nonexistent".to_string());
553
554 let config =
555 resolve_agent_config(&task, "test", Harness::Claude, Some("sonnet"), temp.path());
556
557 assert_eq!(config.harness, Harness::Claude);
559 assert_eq!(config.model, Some("sonnet".to_string()));
560 assert!(!config.from_agent_def);
561 assert_eq!(config.agent_type, Some("nonexistent".to_string()));
562 }
563
564 #[test]
565 fn test_resolve_agent_config_from_agent_def() {
566 let temp = tempfile::TempDir::new().unwrap();
567 let agents_dir = temp.path().join(".scud").join("agents");
568 std::fs::create_dir_all(&agents_dir).unwrap();
569
570 let agent_file = agents_dir.join("fast-builder.toml");
572 std::fs::write(
573 &agent_file,
574 r#"
575[agent]
576name = "fast-builder"
577description = "Fast builder"
578
579[model]
580harness = "opencode"
581model = "grok-code-fast-1"
582"#,
583 )
584 .unwrap();
585
586 let mut task = Task::new("1".to_string(), "Test".to_string(), "Desc".to_string());
587 task.agent_type = Some("fast-builder".to_string());
588
589 let config =
591 resolve_agent_config(&task, "test", Harness::Claude, Some("opus"), temp.path());
592
593 assert_eq!(config.harness, Harness::OpenCode);
594 assert_eq!(config.model, Some("grok-code-fast-1".to_string()));
595 assert!(config.from_agent_def);
596 assert_eq!(config.agent_type, Some("fast-builder".to_string()));
597 }
598
599 #[test]
600 fn test_resolve_agent_config_agent_def_without_model_uses_default() {
601 let temp = tempfile::TempDir::new().unwrap();
602 let agents_dir = temp.path().join(".scud").join("agents");
603 std::fs::create_dir_all(&agents_dir).unwrap();
604
605 let agent_file = agents_dir.join("custom.toml");
607 std::fs::write(
608 &agent_file,
609 r#"
610[agent]
611name = "custom"
612
613[model]
614harness = "opencode"
615"#,
616 )
617 .unwrap();
618
619 let mut task = Task::new("1".to_string(), "Test".to_string(), "Desc".to_string());
620 task.agent_type = Some("custom".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::OpenCode);
627 assert_eq!(config.model, Some("opus".to_string()));
628 assert!(config.from_agent_def);
629 }
630
631 #[test]
632 fn test_resolve_agent_config_uses_custom_prompt_template() {
633 let temp = tempfile::TempDir::new().unwrap();
634 let agents_dir = temp.path().join(".scud").join("agents");
635 std::fs::create_dir_all(&agents_dir).unwrap();
636
637 let agent_file = agents_dir.join("templated.toml");
638 std::fs::write(
639 &agent_file,
640 r#"
641[agent]
642name = "templated"
643
644[model]
645harness = "claude"
646
647[prompt]
648template = "Custom: {task.title} in {tag}"
649"#,
650 )
651 .unwrap();
652
653 let mut task = Task::new("1".to_string(), "My Task".to_string(), "Desc".to_string());
654 task.agent_type = Some("templated".to_string());
655
656 let config = resolve_agent_config(&task, "my-tag", Harness::Claude, None, temp.path());
657
658 assert_eq!(config.prompt, "Custom: My Task in my-tag");
659 assert!(config.from_agent_def);
660 }
661
662 #[test]
663 fn test_resolved_agent_config_display_info() {
664 let config = ResolvedAgentConfig {
665 harness: Harness::OpenCode,
666 model: Some("grok-code-fast-1".to_string()),
667 prompt: "test".to_string(),
668 from_agent_def: true,
669 agent_type: Some("fast-builder".to_string()),
670 };
671
672 assert_eq!(
673 config.display_info(),
674 "opencode:grok-code-fast-1@fast-builder"
675 );
676
677 let config_no_model = ResolvedAgentConfig {
678 harness: Harness::Claude,
679 model: None,
680 prompt: "test".to_string(),
681 from_agent_def: false,
682 agent_type: None,
683 };
684
685 assert_eq!(config_no_model.display_info(), "claude");
686 }
687}