Skip to main content

perspt_agent/
prompt_compiler.rs

1//! PSP-7 §5: Typed prompt compiler.
2//!
3//! Maps `(PromptIntent, PromptEvidence) → CompiledPrompt` with full provenance
4//! tracking.  This module is the single entry-point for all prompt assembly;
5//! callers build a [`PromptEvidence`] and select a [`PromptIntent`], and the
6//! compiler returns a [`CompiledPrompt`] with the final text and provenance.
7
8use perspt_core::types::{CompiledPrompt, PromptEvidence, PromptIntent, PromptProvenance};
9
10/// Verifier analysis preamble used in the two-stage correction flow.
11pub(crate) const VERIFIER_ANALYSIS_PREAMBLE: &str = "\
12You are a Verifier agent. Analyze the following correction request and produce \
13concise, structured guidance for the code fixer. Identify:\n\
141. Root cause of each failure\n\
152. Which specific functions/lines need changes\n\
163. Constraints that must be preserved\n\
17Do NOT produce code — only analysis and guidance.\n\n";
18
19/// Compile a prompt from a typed intent and gathered evidence.
20///
21/// The evidence struct carries all possible inputs; each intent family reads
22/// only the fields it needs and ignores the rest.
23pub fn compile(intent: PromptIntent, evidence: &PromptEvidence) -> CompiledPrompt {
24    let mut sources: Vec<String> = Vec::new();
25
26    let text = match intent {
27        PromptIntent::ArchitectExisting => {
28            sources.push("architect_existing_template".into());
29            if evidence.project_summary.is_some() {
30                sources.push("project_summary".into());
31            }
32            compile_architect(crate::prompts::ARCHITECT_EXISTING, evidence)
33        }
34        PromptIntent::ArchitectGreenfield => {
35            sources.push("architect_greenfield_template".into());
36            compile_architect(crate::prompts::ARCHITECT_GREENFIELD, evidence)
37        }
38        PromptIntent::ActuatorMultiOutput => {
39            sources.push("actuator_multi_output".into());
40            compile_actuator(evidence, true)
41        }
42        PromptIntent::ActuatorSingleOutput => {
43            sources.push("actuator_single_output".into());
44            compile_actuator(evidence, false)
45        }
46        PromptIntent::VerifierAnalysis => {
47            sources.push("verifier_check_template".into());
48            compile_verifier(evidence)
49        }
50        PromptIntent::CorrectionRetry => {
51            sources.push("correction_retry".into());
52            if evidence.verifier_diagnostics.is_some() {
53                sources.push("verifier_diagnostics".into());
54            }
55            if !evidence.existing_file_contents.is_empty() {
56                sources.push("existing_file_contents".into());
57            }
58            if evidence.plugin_correction_fragment.is_some() {
59                sources.push("plugin_correction_fragment".into());
60            }
61            compile_correction(evidence)
62        }
63        PromptIntent::BundleRetarget => {
64            sources.push("bundle_retarget_template".into());
65            compile_bundle_retarget(evidence)
66        }
67        PromptIntent::SpeculatorBasic => {
68            sources.push("speculator_basic".into());
69            let goal = evidence.node_goal.as_deref().unwrap_or("");
70            crate::prompts::SPECULATOR_BASIC.replace("{goal}", goal)
71        }
72        PromptIntent::SpeculatorLookahead => {
73            sources.push("speculator_lookahead".into());
74            compile_speculator_lookahead(evidence)
75        }
76        PromptIntent::SoloGenerate => {
77            sources.push("solo_generate_template".into());
78            let task = evidence.user_goal.as_deref().unwrap_or("");
79            crate::prompts::SOLO_GENERATE.replace("{task}", task)
80        }
81        PromptIntent::SoloCorrect => {
82            sources.push("solo_correction_template".into());
83            if evidence.verifier_diagnostics.is_some() {
84                sources.push("verifier_diagnostics".into());
85            }
86            compile_solo_correction(evidence)
87        }
88        PromptIntent::ProjectNameSuggest => {
89            sources.push("project_name_suggest".into());
90            let task = evidence.user_goal.as_deref().unwrap_or("");
91            crate::prompts::PROJECT_NAME_SUGGEST.replace("{task}", task)
92        }
93    };
94
95    let plugin_fragment_source = evidence
96        .plugin_correction_fragment
97        .as_ref()
98        .map(|_| "owner_plugin".to_string());
99
100    CompiledPrompt {
101        text,
102        provenance: PromptProvenance {
103            intent,
104            plugin_fragment_source,
105            evidence_sources: sources,
106            compiled_at: epoch_seconds(),
107        },
108    }
109}
110
111// ---------------------------------------------------------------------------
112// Per-family compilation helpers
113// ---------------------------------------------------------------------------
114
115fn compile_architect(template: &str, ev: &PromptEvidence) -> String {
116    let task = ev.user_goal.as_deref().unwrap_or("");
117    let project_context = ev.project_summary.as_deref().unwrap_or("");
118    let error_feedback = ev.error_feedback.as_deref().unwrap_or("");
119    let evidence_section = ev.evidence_section.as_deref().unwrap_or("");
120    let working_dir = ev.working_dir.as_deref().unwrap_or(".");
121
122    render_architect(
123        template,
124        task,
125        std::path::Path::new(working_dir),
126        project_context,
127        error_feedback,
128        evidence_section,
129        &ev.active_plugins,
130    )
131}
132
133fn compile_actuator(ev: &PromptEvidence, is_multi: bool) -> String {
134    let goal = ev.node_goal.as_deref().unwrap_or("");
135    let target_file = ev
136        .output_files
137        .first()
138        .map(|s| s.as_str())
139        .unwrap_or("main.py");
140    let allowed_output_paths = format!("{:?}", ev.output_files);
141    let context_files = format!("{:?}", ev.context_files);
142    let interface = ev.interface_signature.as_deref().unwrap_or("");
143    let invariants = ev.invariants.as_deref().unwrap_or("");
144    let forbidden = ev.forbidden_patterns.as_deref().unwrap_or("");
145    let working_dir = ev.working_dir.as_deref().unwrap_or(".");
146    let hints = ev.workspace_import_hints.as_deref().unwrap_or("");
147
148    render_actuator(
149        goal,
150        interface,
151        invariants,
152        forbidden,
153        working_dir,
154        &context_files,
155        target_file,
156        &allowed_output_paths,
157        hints,
158        is_multi,
159    )
160}
161
162fn compile_verifier(ev: &PromptEvidence) -> String {
163    let implementation = ev
164        .existing_file_contents
165        .first()
166        .map(|(_, content)| content.as_str())
167        .unwrap_or("");
168    let interface = ev.interface_signature.as_deref().unwrap_or("");
169    let invariants = ev.invariants.as_deref().unwrap_or("");
170    let forbidden = ev.forbidden_patterns.as_deref().unwrap_or("");
171    let weighted_tests = ev.weighted_tests.as_deref().unwrap_or("");
172
173    render_verifier(
174        interface,
175        invariants,
176        forbidden,
177        weighted_tests,
178        implementation,
179    )
180}
181
182fn compile_correction(ev: &PromptEvidence) -> String {
183    let goal = ev.node_goal.as_deref().unwrap_or("");
184    let diagnostics = ev
185        .verifier_diagnostics
186        .as_deref()
187        .unwrap_or("No specific errors captured.");
188    let owner_plugin = ev.owner_plugin.as_deref().unwrap_or("");
189
190    // Detect language from first file extension for code fences
191    let lang = ev
192        .existing_file_contents
193        .first()
194        .map(|(p, _)| p.as_str())
195        .and_then(|p| std::path::Path::new(p).extension())
196        .and_then(|e| e.to_str())
197        .map(|ext| match ext {
198            "py" => "python",
199            "rs" => "rust",
200            "ts" | "tsx" => "typescript",
201            "js" | "jsx" => "javascript",
202            "go" => "go",
203            "java" => "java",
204            "rb" => "ruby",
205            "c" | "h" => "c",
206            "cpp" | "cc" | "cxx" | "hpp" => "cpp",
207            "cs" => "csharp",
208            other => other,
209        })
210        .unwrap_or("text");
211
212    let mut prompt = format!(
213        "## Code Correction Required\n\n\
214         The code you generated has errors detected by the language toolchain.\n\
215         Your task is to fix ALL errors and return the complete corrected file(s).\n\n\
216         ### Original Goal\n{}\n\n\
217         ### Current Code (with errors)\n",
218        goal,
219    );
220
221    // Include all affected files
222    for (path, content) in &ev.existing_file_contents {
223        prompt.push_str(&format!(
224            "File: {}\n```{}\n{}\n```\n\n",
225            path, lang, content
226        ));
227    }
228
229    // Diagnostics section (pre-formatted by caller with fix directions if available)
230    if let Some(v_syn) = ev.energy_v_syn {
231        prompt.push_str(&format!(
232            "### Detected Errors (V_syn = {:.2})\n{}\n",
233            v_syn, diagnostics
234        ));
235    } else {
236        prompt.push_str(&format!("### Detected Errors\n{}\n", diagnostics));
237    }
238
239    if let Some(ref fragment) = ev.plugin_correction_fragment {
240        prompt.push_str(&format!("\n### Plugin Guidance\n{}\n", fragment));
241    }
242
243    let attempt_count = if !ev.previous_attempts.is_empty() {
244        ev.previous_attempts.len()
245    } else {
246        ev.previous_attempt_count
247    };
248    if attempt_count > 0 {
249        prompt.push_str(&format!(
250            "\n### Previous Correction Attempts: {}\n\
251             The previous attempts did not fully resolve the errors. \
252             Please try a different approach.\n",
253            attempt_count
254        ));
255    }
256
257    // Restriction map context for structural dependencies
258    if let Some(ref ctx) = ev.restriction_map_context {
259        if !ctx.is_empty() {
260            prompt.push_str(&format!("\n### Restriction Map Context\n\n{}\n", ctx));
261        }
262    }
263
264    // Project file tree for path awareness
265    if let Some(ref tree) = ev.project_file_tree {
266        if !tree.is_empty() {
267            prompt.push_str(&format!(
268                "\n### Current Project Tree\n\n```\n{}\n```\n",
269                tree
270            ));
271        }
272    }
273
274    // Build/test output from plugin verification
275    if let Some(ref output) = ev.build_test_output {
276        if !output.is_empty() {
277            prompt.push_str(&format!(
278                "\n### Build / Test Output\nThe following is the raw output from the build toolchain (e.g. `cargo check` / `cargo build`). \
279                 Use this to identify missing dependencies, unresolved imports, or type errors:\n```\n{}\n```\n",
280                output
281            ));
282        }
283    }
284
285    let target_paths = ev
286        .existing_file_contents
287        .iter()
288        .map(|(path, _)| path.as_str())
289        .collect::<Vec<_>>()
290        .join(", ");
291
292    // Generate language-specific dependency command examples
293    let commands_example = match owner_plugin {
294        "rust" => "cargo add thiserror\ncargo add clap --features derive",
295        "python" => "uv add httpx\nuv add --dev pytest",
296        "javascript" => "npm install express\nnpm install --save-dev jest",
297        _ => "cargo add thiserror\nuv add httpx",
298    };
299
300    prompt.push_str(&format!(
301        r#"
302### Fix Requirements
3031. Fix ALL errors listed above - do not leave any unfixed
3042. Maintain the original functionality and goal
3053. Follow {} language conventions and idioms
3064. Import any missing modules or dependencies
3075. Return a JSON artifact bundle targeting these exact path(s): {}
3086. If errors mention missing crates/packages (e.g. "can't find crate", "unresolved import" for an external dependency, "ModuleNotFoundError", "No module named"), list the required install commands
309
310### Output Format
311Return only this JSON object shape. Do not wrap it in markdown unless the provider requires a fenced json block.
312
313```json
314{{
315    "artifacts": [
316        {{
317            "operation": "write",
318            "path": "path/from/list/above",
319            "content": "complete corrected file contents"
320        }}
321    ],
322    "commands": [
323        "optional dependency command, for example: {}"
324    ]
325}}
326```
327
328Use an empty commands array when no dependency command is needed.
329"#,
330                lang,
331                if target_paths.is_empty() { "the original file(s)" } else { &target_paths },
332                commands_example.lines().next().unwrap_or("")
333    ));
334
335    prompt
336}
337
338fn compile_bundle_retarget(ev: &PromptEvidence) -> String {
339    let expected = ev.output_files.join(", ");
340    let dropped = ev.rejected_bundle_summary.as_deref().unwrap_or("(unknown)");
341    let original_prompt = ev.node_goal.as_deref().unwrap_or("");
342
343    render_bundle_retarget(&expected, dropped, original_prompt)
344}
345
346fn compile_speculator_lookahead(ev: &PromptEvidence) -> String {
347    let node_id = ev
348        .context_files
349        .first()
350        .map(|s| s.as_str())
351        .unwrap_or("current");
352    let goal = ev.node_goal.as_deref().unwrap_or("");
353    let downstream = ev
354        .output_files
355        .iter()
356        .map(|s| format!("- {}", s))
357        .collect::<Vec<_>>()
358        .join("\n");
359
360    render_speculator_lookahead(node_id, goal, &downstream)
361}
362
363fn compile_solo_correction(ev: &PromptEvidence) -> String {
364    let task = ev.user_goal.as_deref().unwrap_or("");
365    let filename = ev.solo_file_path.as_deref().unwrap_or("script.py");
366    let current_code = ev
367        .existing_file_contents
368        .first()
369        .map(|(_, c)| c.as_str())
370        .unwrap_or("");
371    let error_list = ev
372        .verifier_diagnostics
373        .as_deref()
374        .unwrap_or("No specific errors captured, but energy is still too high.");
375
376    render_solo_correction(
377        task,
378        filename,
379        current_code,
380        "0.00", // v_syn — caller provides via diagnostics text
381        "0.00", // v_log
382        "0.00", // v_boot
383        error_list,
384    )
385}
386
387// ---------------------------------------------------------------------------
388// Render helpers (moved from prompts.rs — private to the compiler)
389// ---------------------------------------------------------------------------
390
391/// JSON brace escapes for templates that contain `{OPEN_BRACE}` / `{CLOSE_BRACE}`.
392const OPEN_BRACE: &str = "{";
393const CLOSE_BRACE: &str = "}";
394
395fn render_architect(
396    template: &str,
397    task: &str,
398    working_dir: &std::path::Path,
399    project_context: &str,
400    error_feedback: &str,
401    evidence_section: &str,
402    active_plugins: &[String],
403) -> String {
404    let plugin_section = if active_plugins.is_empty() {
405        String::new()
406    } else {
407        format!(
408            "\n## Detected Toolchain\nActive language plugins: {}\nPlan verification-aware nodes that align with these plugins' build/test capabilities.\n",
409            active_plugins.join(", ")
410        )
411    };
412    let enriched_context = if plugin_section.is_empty() {
413        project_context.to_string()
414    } else {
415        format!("{}{}", project_context, plugin_section)
416    };
417    template
418        .replace("{task}", task)
419        .replace("{working_dir}", &working_dir.display().to_string())
420        .replace("{project_context}", &enriched_context)
421        .replace("{error_feedback}", error_feedback)
422        .replace("{evidence_section}", evidence_section)
423        .replace("{OPEN_BRACE}", OPEN_BRACE)
424        .replace("{CLOSE_BRACE}", CLOSE_BRACE)
425}
426
427#[allow(clippy::too_many_arguments)]
428fn render_actuator(
429    goal: &str,
430    interface: &str,
431    invariants: &str,
432    forbidden: &str,
433    working_dir: &str,
434    context_files: &str,
435    target_file: &str,
436    allowed_output_paths: &str,
437    workspace_import_hints: &str,
438    is_multi_output: bool,
439) -> String {
440    let output_format = if is_multi_output {
441        crate::prompts::ACTUATOR_MULTI_OUTPUT
442            .replace("{target_file}", target_file)
443            .replace("{OPEN_BRACE}", OPEN_BRACE)
444            .replace("{CLOSE_BRACE}", CLOSE_BRACE)
445    } else {
446        crate::prompts::ACTUATOR_SINGLE_OUTPUT.replace("{target_file}", target_file)
447    };
448
449    crate::prompts::ACTUATOR_CODING
450        .replace("{goal}", goal)
451        .replace("{interface}", interface)
452        .replace("{invariants}", invariants)
453        .replace("{forbidden}", forbidden)
454        .replace("{working_dir}", working_dir)
455        .replace("{context_files}", context_files)
456        .replace("{target_file}", target_file)
457        .replace("{allowed_output_paths}", allowed_output_paths)
458        .replace("{workspace_import_hints}", workspace_import_hints)
459        .replace("{output_format}", &output_format)
460}
461
462fn render_verifier(
463    interface: &str,
464    invariants: &str,
465    forbidden: &str,
466    weighted_tests: &str,
467    implementation: &str,
468) -> String {
469    crate::prompts::VERIFIER_CHECK
470        .replace("{interface}", interface)
471        .replace("{invariants}", invariants)
472        .replace("{forbidden}", forbidden)
473        .replace("{weighted_tests}", weighted_tests)
474        .replace("{implementation}", implementation)
475}
476
477fn render_speculator_lookahead(node_id: &str, goal: &str, downstream: &str) -> String {
478    crate::prompts::SPECULATOR_LOOKAHEAD
479        .replace("{node_id}", node_id)
480        .replace("{goal}", goal)
481        .replace("{downstream}", downstream)
482}
483
484fn render_solo_correction(
485    task: &str,
486    filename: &str,
487    current_code: &str,
488    v_syn: &str,
489    v_log: &str,
490    v_boot: &str,
491    error_list: &str,
492) -> String {
493    crate::prompts::SOLO_CORRECTION
494        .replace("{task}", task)
495        .replace("{filename}", filename)
496        .replace("{current_code}", current_code)
497        .replace("{v_syn}", v_syn)
498        .replace("{v_log}", v_log)
499        .replace("{v_boot}", v_boot)
500        .replace("{error_list}", error_list)
501}
502
503fn render_bundle_retarget(
504    expected_files: &str,
505    dropped_files: &str,
506    original_prompt: &str,
507) -> String {
508    crate::prompts::BUNDLE_RETARGET
509        .replace("{expected_files}", expected_files)
510        .replace("{dropped_files}", dropped_files)
511        .replace("{original_prompt}", original_prompt)
512}
513
514fn epoch_seconds() -> i64 {
515    std::time::SystemTime::now()
516        .duration_since(std::time::UNIX_EPOCH)
517        .unwrap_or_default()
518        .as_secs() as i64
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524
525    #[test]
526    fn test_compile_solo_generate() {
527        let ev = PromptEvidence {
528            user_goal: Some("Calculate fibonacci numbers".into()),
529            ..Default::default()
530        };
531        let compiled = compile(PromptIntent::SoloGenerate, &ev);
532        assert!(compiled.text.contains("fibonacci"));
533        assert_eq!(compiled.provenance.intent, PromptIntent::SoloGenerate);
534        assert!(compiled
535            .provenance
536            .evidence_sources
537            .contains(&"solo_generate_template".to_string()));
538    }
539
540    #[test]
541    fn test_compile_project_name_suggest() {
542        let ev = PromptEvidence {
543            user_goal: Some("Build a REST API for user management".into()),
544            ..Default::default()
545        };
546        let compiled = compile(PromptIntent::ProjectNameSuggest, &ev);
547        assert!(compiled.text.contains("REST API for user management"));
548        assert_eq!(compiled.provenance.intent, PromptIntent::ProjectNameSuggest);
549    }
550
551    #[test]
552    fn test_compile_correction_with_files() {
553        let ev = PromptEvidence {
554            node_goal: Some("Implement calculator".into()),
555            verifier_diagnostics: Some("error[E0308]: mismatched types".into()),
556            existing_file_contents: vec![(
557                "src/calc.rs".into(),
558                "fn add(a: i32) -> i32 { a }".into(),
559            )],
560            plugin_correction_fragment: Some("Use cargo check for Rust".into()),
561            ..Default::default()
562        };
563        let compiled = compile(PromptIntent::CorrectionRetry, &ev);
564        assert!(compiled.text.contains("calculator"));
565        assert!(compiled.text.contains("mismatched types"));
566        assert!(compiled.text.contains("src/calc.rs"));
567        assert!(compiled.text.contains("Plugin Guidance"));
568        assert!(compiled
569            .provenance
570            .evidence_sources
571            .contains(&"verifier_diagnostics".to_string()));
572        assert!(compiled
573            .provenance
574            .evidence_sources
575            .contains(&"plugin_correction_fragment".to_string()));
576    }
577
578    #[test]
579    fn test_compile_bundle_retarget() {
580        let ev = PromptEvidence {
581            node_goal: Some("Build HTTP server".into()),
582            output_files: vec!["src/server.rs".into(), "src/main.rs".into()],
583            rejected_bundle_summary: Some("config.json, README.md".into()),
584            ..Default::default()
585        };
586        let compiled = compile(PromptIntent::BundleRetarget, &ev);
587        assert!(compiled.text.contains("src/server.rs"));
588        assert!(compiled.text.contains("config.json"));
589        assert!(compiled.text.contains("REJECTED"));
590    }
591
592    #[test]
593    fn test_compile_speculator_basic() {
594        let ev = PromptEvidence {
595            node_goal: Some("Parse JSON config".into()),
596            ..Default::default()
597        };
598        let compiled = compile(PromptIntent::SpeculatorBasic, &ev);
599        assert!(compiled.text.contains("Parse JSON config"));
600    }
601
602    #[test]
603    fn test_compile_architect_existing() {
604        let ev = PromptEvidence {
605            user_goal: Some("Add logging module".into()),
606            project_summary: Some("Rust workspace with 3 crates".into()),
607            working_dir: Some("/tmp/project".into()),
608            active_plugins: vec!["rust".into()],
609            ..Default::default()
610        };
611        let compiled = compile(PromptIntent::ArchitectExisting, &ev);
612        assert!(compiled.text.contains("logging module"));
613        assert!(compiled
614            .provenance
615            .evidence_sources
616            .contains(&"project_summary".to_string()));
617    }
618
619    #[test]
620    fn test_compile_actuator_multi() {
621        let ev = PromptEvidence {
622            node_goal: Some("Implement auth module".into()),
623            output_files: vec!["src/auth.rs".into(), "tests/test_auth.rs".into()],
624            ..Default::default()
625        };
626        let compiled = compile(PromptIntent::ActuatorMultiOutput, &ev);
627        assert!(compiled.text.contains("auth module"));
628        assert!(compiled.text.contains("Multi-Artifact Bundle"));
629    }
630
631    #[test]
632    fn test_compile_actuator_single() {
633        let ev = PromptEvidence {
634            node_goal: Some("Implement utils".into()),
635            output_files: vec!["src/utils.py".into()],
636            ..Default::default()
637        };
638        let compiled = compile(PromptIntent::ActuatorSingleOutput, &ev);
639        assert!(compiled.text.contains("utils"));
640        assert!(!compiled.text.contains("Multi-Artifact Bundle"));
641    }
642
643    #[test]
644    fn test_compile_solo_correction() {
645        let ev = PromptEvidence {
646            user_goal: Some("Sort a list".into()),
647            solo_file_path: Some("sort_list.py".into()),
648            existing_file_contents: vec![("sort_list.py".into(), "def sort(l): pass".into())],
649            verifier_diagnostics: Some("NameError: name 'x' is not defined".into()),
650            ..Default::default()
651        };
652        let compiled = compile(PromptIntent::SoloCorrect, &ev);
653        assert!(compiled.text.contains("sort_list.py"));
654        assert!(compiled.text.contains("NameError"));
655    }
656
657    #[test]
658    fn test_provenance_records_timestamp() {
659        let ev = PromptEvidence::default();
660        let compiled = compile(PromptIntent::SpeculatorBasic, &ev);
661        assert!(compiled.provenance.compiled_at > 0);
662    }
663
664    #[test]
665    fn test_correction_with_previous_attempts() {
666        let ev = PromptEvidence {
667            node_goal: Some("Fix parser".into()),
668            previous_attempts: vec![perspt_core::types::CorrectionAttemptRecord {
669                attempt: 1,
670                parse_state: perspt_core::types::ParseResultState::NoStructuredPayload,
671                retry_classification: None,
672                response_fingerprint: "abc123".into(),
673                response_length: 100,
674                energy_after: None,
675                accepted: false,
676                rejection_reason: Some("Failed to parse".into()),
677                created_at: 0,
678            }],
679            ..Default::default()
680        };
681        let compiled = compile(PromptIntent::CorrectionRetry, &ev);
682        assert!(compiled.text.contains("Previous Correction Attempts: 1"));
683    }
684}