Skip to main content

scud/commands/spawn/
agent.rs

1//! Agent prompt generation for Claude Code sessions
2//!
3//! Creates prompts that provide task context and instructions for Claude Code agents.
4
5use 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/// Resolved configuration for spawning an agent
12#[derive(Debug, Clone)]
13pub struct ResolvedAgentConfig {
14    /// The harness to use (claude or opencode)
15    pub harness: Harness,
16    /// The model to use (if specified)
17    pub model: Option<String>,
18    /// The prompt to send to the agent
19    pub prompt: String,
20    /// Whether this config came from an agent definition
21    pub from_agent_def: bool,
22    /// The agent type name (if from agent def)
23    pub agent_type: Option<String>,
24}
25
26/// Resolve the agent configuration for a task.
27///
28/// If the task has an `agent_type`, attempts to load the agent definition
29/// from `.scud/agents/<agent_type>.toml` and uses its harness, model, and
30/// prompt template. Falls back to defaults if the agent definition is not found.
31///
32/// # Arguments
33/// * `task` - The task to resolve configuration for
34/// * `tag` - The task's tag/phase name
35/// * `default_harness` - Harness to use if no agent definition specifies one
36/// * `default_model` - Model to use if no agent definition specifies one
37/// * `working_dir` - Project root for loading agent definitions
38pub 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        // Try to load agent definition
47        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                // Use custom prompt template if available
56                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                // Agent type specified but no definition found - use defaults
71                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        // No agent type - use defaults
82        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    /// Get a display string for logging (e.g., "opencode:grok-code-fast-1@fast-builder")
94    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
109/// Generate a prompt for Claude Code with task context
110pub 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    // Add details if present
130    if let Some(ref details) = task.details {
131        prompt.push_str(&format!(
132            r#"
133Technical Details:
134{}
135"#,
136            details
137        ));
138    }
139
140    // Add test strategy if present
141    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    // Add dependencies info if any
152    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    // Add instructions
163    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
188/// Generate a shorter prompt for tasks with less context
189pub 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
208/// Generate a prompt using a custom template
209///
210/// Template placeholders:
211/// - {task.id} - Task ID
212/// - {task.title} - Task title
213/// - {task.description} - Task description
214/// - {task.complexity} - Complexity score
215/// - {task.priority} - Priority level
216/// - {task.details} - Technical details (empty if none)
217/// - {task.test_strategy} - Test strategy (empty if none)
218/// - {task.dependencies} - Comma-separated dependencies
219/// - {tag} - Phase/tag name
220pub 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
239/// Generate a prompt for wave review
240pub fn generate_review_prompt(
241    summary: &WaveSummary,
242    tasks: &[(String, String)], // (task_id, title)
243    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        // Sample: first task, last task, and one random middle task
253        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
307/// Generate a prompt for repair agent
308pub 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        // Should only include first, middle, and last (3 tasks sampled)
490        assert!(prompt.contains("t:1"));
491        assert!(prompt.contains("t:3")); // middle
492        assert!(prompt.contains("t:5")); // last
493                                         // t:2 and t:4 should not be present
494        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    // =========================================================================
519    // Tests for resolve_agent_config
520    // =========================================================================
521
522    #[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        // Should fall back to defaults when agent def not found
558        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        // Create a fast-builder agent definition with opencode harness
571        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 = "xai/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        // Default harness is claude, but agent def should override to opencode
590        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("xai/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        // Agent def with harness but no model
606        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        // Harness comes from agent def, model falls back to default
626        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("xai/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:xai/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}