Skip to main content

newt_coder/
prompt.rs

1//! Build the LLM prompt for a coder task.
2//!
3//! Strategy (per the failure-mode taxonomy in
4//! `~/workspaces/knowledge/board/drake/2026-05-29_newt-coder-failure-mode-taxonomy.md`):
5//!
6//! 1. Scan the workspace for files the task references (or every
7//!    source file in small workspaces — see `workspace_scan`).
8//! 2. Inject each file's verbatim contents into the user message
9//!    under `FILE: <path>` / `END-FILE` separators.
10//! 3. Ask the model to emit the **complete updated file contents**,
11//!    not a diff — the S5 strategy that won the bake-off
12//!    (`golden_match=true` on qwen3-coder:30b, 224 tokens, 5.6 s).
13//!
14//! Total file context is capped at `DEFAULT_CONTEXT_CAP_CHARS`
15//! (~8K tokens) to keep prompts within local-model context budgets.
16//! When the cap is reached we drop the remaining files and emit a
17//! `tracing::warn!` — the operator should see this and either narrow
18//! the task or split it.
19
20use std::path::{Path, PathBuf};
21
22use crate::error::{CoderError, Result};
23use crate::workspace_scan::scan_workspace_for_files;
24
25/// Maximum total chars of file context to inject into a single prompt.
26/// ~8K tokens at ~4 chars/token (English-ish average). Bake-off case
27/// 001-rename-function used 224 tokens of context; this leaves a wide
28/// margin for multi-file refactors before the prompt becomes
29/// prohibitively expensive on local hardware.
30pub const DEFAULT_CONTEXT_CAP_CHARS: usize = 32_000;
31
32/// The S5 system prompt — whole-file emit, no diffs, no fences.
33///
34/// Pinning the exact wording matters: the bake-off (wf_ecc784ea-aa2)
35/// showed that subtle changes ("emit ONLY" vs "respond with") flip
36/// qwen3-coder between perfect rewrite and prose-with-fence. Don't
37/// edit casually; if you change it, re-run the strategy bake-off.
38pub const WHOLE_FILE_SYSTEM_PROMPT: &str = "\
39You are a coding assistant editing files. \
40For each file you change, emit ONLY the complete updated file contents. \
41Do not include diffs, code fences, prose, or explanations. \
42Start each file with a single line:  FILE: <relative path>\n\
43Then the verbatim updated file contents, followed by a line containing only END-FILE. \
44Output the COMPLETE file body only; do NOT repeat the FILE: line inside the body, \
45and do NOT emit a unified diff. \
46If you do not change a file, do not emit it. \
47Do not invent files that don't exist.\
48";
49
50/// A built prompt — `system` + `user` strings ready for
51/// `ChatRequest`, plus the list of files actually injected (useful
52/// for audit logs and the foreman's scorecard).
53#[derive(Debug, Clone)]
54pub struct CoderPrompt {
55    pub system: String,
56    pub user: String,
57    pub included_files: Vec<PathBuf>,
58}
59
60/// Build a prompt for `task` against `workspace`. The workspace is
61/// scanned for relevant source files (see `scan_workspace_for_files`)
62/// and their contents are injected verbatim into the user message
63/// under `FILE:`/`END-FILE` separators.
64pub fn build_prompt(workspace: &Path, task: &str) -> Result<CoderPrompt> {
65    let files = scan_workspace_for_files(workspace, task)?;
66    let (file_block, included) = render_files_block(workspace, &files, DEFAULT_CONTEXT_CAP_CHARS)?;
67
68    let user = format!(
69        "Task:\n{}\n\nFiles in the workspace (verbatim contents):\n{}",
70        task.trim(),
71        file_block,
72    );
73
74    Ok(CoderPrompt {
75        system: WHOLE_FILE_SYSTEM_PROMPT.to_string(),
76        user,
77        included_files: included,
78    })
79}
80
81/// Build a **re-prompt** for the single-retry whole-file fallback.
82///
83/// Weak local models non-deterministically emit a unified diff even
84/// under the whole-file directive, and those diffs sometimes fail to
85/// apply. When that happens, [`crate::coder::Coder::run`] asks the model
86/// one more time — this time with an emphatic, focused reminder to emit
87/// the COMPLETE file(s) in `FILE:`/`END-FILE` form, never a diff.
88///
89/// We reuse the pinned [`WHOLE_FILE_SYSTEM_PROMPT`] verbatim (its exact
90/// wording is load-bearing per the bake-off) and re-inject the same
91/// workspace file context so the model still has the source in front of
92/// it. The user message leads with the explicit corrective instruction,
93/// then restates the task and the files.
94pub fn build_reprompt(workspace: &Path, task: &str) -> Result<CoderPrompt> {
95    let files = scan_workspace_for_files(workspace, task)?;
96    let (file_block, included) = render_files_block(workspace, &files, DEFAULT_CONTEXT_CAP_CHARS)?;
97
98    let user = format!(
99        "Your previous reply could not be applied (it was a unified diff or a \
100         diff that did not match the file). Do NOT emit a diff this time.\n\n\
101         For EACH file you change, output the COMPLETE updated file contents as:\n\
102         FILE: <relative path>\n<the entire file body>\nEND-FILE\n\n\
103         No diffs, no code fences, no prose.\n\n\
104         Task:\n{}\n\nFiles in the workspace (verbatim contents):\n{}",
105        task.trim(),
106        file_block,
107    );
108
109    Ok(CoderPrompt {
110        system: WHOLE_FILE_SYSTEM_PROMPT.to_string(),
111        user,
112        included_files: included,
113    })
114}
115
116/// Render the `FILE:` / `END-FILE` block for `files`, dropping any
117/// file that would push the block past `cap_chars` and logging a
118/// warning. Returns the rendered block + the subset actually
119/// included.
120fn render_files_block(
121    workspace: &Path,
122    files: &[PathBuf],
123    cap_chars: usize,
124) -> Result<(String, Vec<PathBuf>)> {
125    let mut out = String::new();
126    let mut included = Vec::new();
127    for path in files {
128        let abs = workspace.join(path);
129        let content = std::fs::read_to_string(&abs)
130            .map_err(|e| CoderError::Workspace(format!("read {}: {e}", abs.display())))?;
131        let block = format!("FILE: {}\n{}\nEND-FILE\n\n", path.display(), content);
132        if out.len() + block.len() > cap_chars {
133            tracing::warn!(
134                included = included.len(),
135                total = files.len(),
136                cap = cap_chars,
137                "newt-coder context cap reached; dropping remaining files"
138            );
139            break;
140        }
141        out.push_str(&block);
142        included.push(path.clone());
143    }
144    Ok((out, included))
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use std::fs;
151    use tempfile::TempDir;
152
153    fn write(dir: &Path, rel: &str, contents: &str) {
154        let abs = dir.join(rel);
155        if let Some(parent) = abs.parent() {
156            fs::create_dir_all(parent).unwrap();
157        }
158        fs::write(abs, contents).unwrap();
159    }
160
161    #[test]
162    fn build_prompt_injects_mentioned_file_contents() {
163        let tmp = TempDir::new().unwrap();
164        write(tmp.path(), "src/lib.rs", "pub fn greet() {}\n");
165        write(tmp.path(), "src/unused.rs", "pub fn other() {}\n");
166
167        let p = build_prompt(tmp.path(), "Rename greet to hello in src/lib.rs").unwrap();
168        assert_eq!(p.system, WHOLE_FILE_SYSTEM_PROMPT);
169        assert!(p.user.contains("FILE: src/lib.rs"));
170        assert!(p.user.contains("pub fn greet() {}"));
171        assert!(p.user.contains("END-FILE"));
172        // Unmentioned file must not leak into the prompt.
173        assert!(!p.user.contains("src/unused.rs"));
174        assert_eq!(p.included_files.len(), 1);
175    }
176
177    #[test]
178    fn build_prompt_includes_task_text_verbatim() {
179        let tmp = TempDir::new().unwrap();
180        write(tmp.path(), "src/lib.rs", "pub fn x() {}\n");
181
182        let task = "Add a panic to src/lib.rs";
183        let p = build_prompt(tmp.path(), task).unwrap();
184        assert!(p.user.contains(task));
185    }
186
187    #[test]
188    fn render_files_block_caps_at_budget() {
189        let tmp = TempDir::new().unwrap();
190        // Three 100-char files. Cap at 250 so the third one drops.
191        let body = "X".repeat(50);
192        write(tmp.path(), "a.rs", &body);
193        write(tmp.path(), "b.rs", &body);
194        write(tmp.path(), "c.rs", &body);
195
196        let files = vec![
197            PathBuf::from("a.rs"),
198            PathBuf::from("b.rs"),
199            PathBuf::from("c.rs"),
200        ];
201        let (block, included) = render_files_block(tmp.path(), &files, 200).unwrap();
202        assert!(
203            included.len() < files.len(),
204            "cap should have dropped at least one file"
205        );
206        assert!(
207            block.len() <= 200,
208            "block {} bytes exceeded cap 200",
209            block.len()
210        );
211    }
212
213    #[test]
214    fn render_files_block_propagates_read_error() {
215        let tmp = TempDir::new().unwrap();
216        let files = vec![PathBuf::from("does-not-exist.rs")];
217        let err = render_files_block(tmp.path(), &files, 1000).unwrap_err();
218        assert!(matches!(err, CoderError::Workspace(_)));
219    }
220
221    #[test]
222    fn whole_file_system_prompt_pins_directive() {
223        // Regression: the exact wording of the directive is load-bearing
224        // (per the bake-off card). Pin it so a casual edit fails CI.
225        assert!(WHOLE_FILE_SYSTEM_PROMPT.contains("ONLY the complete updated file contents"));
226        assert!(WHOLE_FILE_SYSTEM_PROMPT.contains("FILE: <relative path>"));
227        assert!(WHOLE_FILE_SYSTEM_PROMPT.contains("END-FILE"));
228        assert!(WHOLE_FILE_SYSTEM_PROMPT.contains("Do not include diffs"));
229    }
230}