Skip to main content

speccoder_runtime/substrate/
near_miss.rs

1//! `speccoder.task.near_miss`: wrong and permitted variants of a task's
2//! change, to test its acceptance tests.
3//!
4//! A judge-side call after a task was verified. It sees the task's spec and
5//! the final contents of the changed source files — never an acceptance
6//! test, visible or held-out, so its variants measure the tests
7//! independently. It writes near misses (plausible, and wrong about exactly
8//! one stated scenario) and, where the spec leaves a choice open,
9//! alternatives the spec permits. It has no tools and no say: code applies
10//! each variant, runs the tests and counts. The implementer never sees any
11//! of it.
12
13use crate::format::Wording;
14use agentplane::prelude::*;
15use serde_json::{Value, json};
16
17use super::model_config::{CacheTtl, PromptStyle};
18
19/// The capability the skill provides.
20pub const CAPABILITY: &str = "speccoder.task.near_miss";
21
22/// The changed files' contents the author reads, at most.
23pub const MAX_SOURCES: usize = 60_000;
24
25/// What the author is told.
26pub fn policy(w: &Wording) -> String {
27    format!(
28        "You help test the acceptance tests of a {product} task. The task's change is final \
29and correct by its checks. Write variants of the changed source files, each a complete \
30replacement of one or more of those files:\n\
31- `wrong`: a plausible implementation a careful developer could believe correct, that \
32violates exactly ONE scenario or rule the spec states (an off-by-one at a stated boundary, \
33an ignored second example, a missed stated error case). Keep everything else identical; \
34it must still build. Never make a variant wrong by breaking behaviour the spec does not \
35mention.\n\
36- `permitted`: only where the spec leaves a choice open (an order it does not fix, wording \
37it does not quote, a tie it does not break), an implementation that makes a different \
38allowed choice and still satisfies every stated scenario. If the spec leaves nothing \
39open, return no permitted variant.\n\
40Name the scenario each variant concerns and say in one line what it gets wrong or which \
41freedom it uses. Change only the files you are shown. Answer only in the requested \
42structure.",
43        product = w.product,
44    )
45}
46
47/// What the author is told for a bug task's special cases: fixes that
48/// recognise a reproduction's literal inputs and keep the old behaviour
49/// everywhere else — what a held-out variant with other inputs must catch.
50pub fn special_case_policy(w: &Wording) -> String {
51    format!(
52        "You help test the reproduction tests of a {product} bug fix. The fix is final. For EACH reproduction shown, write one `special_case` variant: a replacement of the changed source files that makes exactly that reproduction pass by recognising its literal inputs (the values, strings or shapes the test uses) and otherwise keeps the code's behaviour from BEFORE the fix. It must build. Name the statement it targets (`E1`, …) as its scenario and say in one line which input it special-cases. Change only the files you are shown. Answer only in the requested structure.",
53        product = w.product,
54    )
55}
56
57/// The special-case author's input: the spec context, the changed files,
58/// the change, and the visible reproductions (never a held-out variant).
59#[must_use]
60pub fn special_case_input(
61    bundle: &Value,
62    changed: &[(String, String)],
63    patch: &str,
64    reproductions: &[(String, String, String)],
65    context: &speccoder_core::context::ContextManifest,
66) -> Tainted<Value> {
67    let base = input(bundle, changed, patch, 0, 0);
68    let mut v = base.peek().clone();
69    v["set"] = json!("special_case");
70    v["counts"] = json!({ "special_case": reproductions.len() });
71    v["reproductions"] = json!(
72        reproductions
73            .iter()
74            .map(|(statement, path, content)| json!({ "statement": statement, "path": path, "content": content }))
75            .collect::<Vec<_>>()
76    );
77    v["context"] = json!(context);
78    Tainted::with_label(v, super::provenance::from(super::provenance::WORKTREE))
79}
80
81/// The answer's schema.
82#[must_use]
83pub fn schema() -> Value {
84    schema_of(&["wrong", "permitted"])
85}
86
87/// The schema with the kinds `kinds`.
88fn schema_of(kinds: &[&str]) -> Value {
89    json!({
90        "type": "object",
91        "properties": {
92            "variants": {
93                "type": "array",
94                "items": {
95                    "type": "object",
96                    "properties": {
97                        "kind": { "type": "string", "enum": kinds },
98                        "scenario": { "type": "string" },
99                        "rationale": { "type": "string" },
100                        "files": {
101                            "type": "array",
102                            "items": {
103                                "type": "object",
104                                "properties": {
105                                    "path": { "type": "string" },
106                                    "content": { "type": "string" },
107                                },
108                                "required": ["path", "content"],
109                                "additionalProperties": false,
110                            },
111                        },
112                    },
113                    "required": ["kind", "scenario", "rationale", "files"],
114                    "additionalProperties": false,
115                },
116            },
117            "notes": { "type": "array", "items": { "type": "string" } },
118        },
119        "required": ["variants", "notes"],
120        "additionalProperties": false,
121    })
122}
123
124/// The run input: the task's spec context (the person's), the changed
125/// files' final contents and the patch (the worktree's), and how many
126/// variants of each kind to write.
127#[must_use]
128pub fn input(
129    bundle: &Value,
130    changed: &[(String, String)],
131    patch: &str,
132    near_miss: u32,
133    permitted: u32,
134) -> Tainted<Value> {
135    let mut budget = MAX_SOURCES;
136    let files: Vec<Value> = changed
137        .iter()
138        .map(|(path, content)| {
139            let mut content = content.clone();
140            if content.len() > budget {
141                let mut end = budget;
142                while !content.is_char_boundary(end) {
143                    end -= 1;
144                }
145                content.truncate(end);
146            }
147            budget = budget.saturating_sub(content.len());
148            json!({ "path": path, "content": content })
149        })
150        .collect();
151    let mut patch = patch.to_owned();
152    if patch.len() > super::monitor::MAX_PATCH {
153        let mut end = super::monitor::MAX_PATCH;
154        while !patch.is_char_boundary(end) {
155            end -= 1;
156        }
157        patch.truncate(end);
158        patch.push_str("\n… (diff truncated)");
159    }
160    let worktree = || super::provenance::from(super::provenance::WORKTREE);
161    Tainted::object([
162        ("story", Tainted::trusted(bundle["story"].clone())),
163        ("plan", Tainted::trusted(bundle["plan"].clone())),
164        ("task", Tainted::trusted(bundle["task"].clone())),
165        (
166            "counts",
167            Tainted::trusted(json!({ "wrong": near_miss, "permitted": permitted })),
168        ),
169        ("files", Tainted::with_label(json!(files), worktree())),
170        ("patch", Tainted::with_label(json!(patch), worktree())),
171    ])
172}
173
174fn prompt_raw(input: &Value, ttl: CacheTtl, style: PromptStyle, w: &Wording) -> Value {
175    let special = input["set"] == "special_case";
176    let system_policy = if special {
177        special_case_policy(w)
178    } else {
179        policy(w)
180    };
181    let s = |v: &Value| v.as_str().unwrap_or_default().to_owned();
182    let files: Vec<String> = input["files"]
183        .as_array()
184        .into_iter()
185        .flatten()
186        .map(|f| format!("## {}\n\n```\n{}\n```", s(&f["path"]), s(&f["content"])))
187        .collect();
188    let parts = [
189        format!(
190            "# Task {}\n\n{}\n\n# Spec (the task's story)\n\n{}",
191            s(&input["task"]["id"]),
192            s(&input["task"]["title"]),
193            s(&input["story"])
194        ),
195        format!("# Plan\n\n{}", s(&input["plan"])),
196        format!(
197            "# The change (git diff)\n\n```diff\n{}\n```\n\n# The changed source files as they are now\n\n{}",
198            s(&input["patch"]),
199            files.join("\n\n")
200        ),
201        if special {
202            let shown: Vec<String> = input["reproductions"]
203                .as_array()
204                .into_iter()
205                .flatten()
206                .map(|r| {
207                    format!(
208                        "## {} ({})\n\n```\n{}\n```",
209                        s(&r["statement"]),
210                        s(&r["path"]),
211                        s(&r["content"])
212                    )
213                })
214                .collect();
215            format!(
216                "# The reproductions\n\n{}\n\nWrite one `special_case` variant per reproduction.",
217                shown.join("\n\n")
218            )
219        } else {
220            format!(
221                "Write {} `wrong` variant(s) and at most {} `permitted` variant(s).",
222                input["counts"]["wrong"], input["counts"]["permitted"]
223            )
224        },
225    ];
226    match style {
227        PromptStyle::OpenAi => json!({
228            "system": system_policy,
229            "input": [{ "role": "user", "content": parts.iter().map(|t| json!({ "type": "input_text", "text": t })).collect::<Vec<_>>() }],
230        }),
231        PromptStyle::Anthropic | PromptStyle::Chat => {
232            let breakpoint = match ttl {
233                CacheTtl::FiveMinutes => json!({ "type": "ephemeral" }),
234                CacheTtl::OneHour => json!({ "type": "ephemeral", "ttl": "1h" }),
235            };
236            json!({
237                "system": system_policy,
238                "messages": [{ "role": "user", "content": parts.iter().enumerate().map(|(i, t)| {
239                    if i == 1 { json!({ "type": "text", "text": t, "cache_control": breakpoint }) }
240                    else { json!({ "type": "text", "text": t }) }
241                }).collect::<Vec<_>>() }],
242            })
243        }
244    }
245}
246
247fn prompt(input: &Value, ttl: CacheTtl, style: PromptStyle, w: &Wording) -> Value {
248    let mut out = prompt_raw(input, ttl, style, w);
249    if style == PromptStyle::Chat {
250        super::model_config::strip_cache_control(&mut out);
251    }
252    out
253}
254
255/// The coded skill.
256#[derive(Debug)]
257pub struct NearMiss {
258    /// Prompt dialect.
259    pub style: PromptStyle,
260    /// Cache breakpoint lifetime.
261    pub cache_ttl: CacheTtl,
262    /// The project's spec format.
263    pub format: &'static dyn crate::format::Adapter,
264}
265
266#[async_trait]
267impl Skill for NearMiss {
268    fn descriptor(&self) -> SkillDescriptor {
269        SkillDescriptor::new("speccoder.task.near_miss").provides(CAPABILITY)
270    }
271
272    async fn invoke(
273        &self,
274        cx: &mut StepCtx<'_>,
275        input: Tainted<Value>,
276    ) -> Result<Outcome, SkillError> {
277        let (ttl, style) = (self.cache_ttl, self.style);
278        let prompt = super::provenance::labelled_prompt(
279            prompt(input.peek(), ttl, style, self.format.wording()),
280            &super::provenance::from(super::provenance::WORKTREE),
281        );
282        let expecting = if input.peek()["set"] == "special_case" {
283            schema_of(&["special_case"])
284        } else {
285            schema()
286        };
287        let completion = cx
288            .complete_with(&prompt, |call| call.expecting(expecting))
289            .await?;
290        Ok(Outcome::done(completion.map(|c| {
291            json!({
292                "answer": c.structured,
293                "usage": serde_json::to_value(c.usage).unwrap_or(Value::Null),
294            })
295        })))
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    #[test]
304    fn the_author_sees_the_spec_and_the_code_never_a_test() {
305        let bundle = json!({ "story": "Users log in.", "plan": "p", "task": { "id": "T005", "title": "login" } });
306        let input = input(
307            &bundle,
308            &[("src/lib.rs".to_owned(), "pub fn ok() {}\n".to_owned())],
309            "+++ b/src/lib.rs\n",
310            2,
311            1,
312        );
313        let v = input.peek();
314        assert_eq!(v["counts"], json!({ "wrong": 2, "permitted": 1 }));
315        let p = prompt(
316            v,
317            CacheTtl::FiveMinutes,
318            PromptStyle::Anthropic,
319            crate::format::at(std::path::Path::new(".")).wording(),
320        );
321        let text = p.to_string();
322        assert!(text.contains("pub fn ok()") && text.contains("Users log in."));
323        assert!(!text.contains("acceptance test file"));
324        assert_eq!(
325            schema()["properties"]["variants"]["items"]["properties"]["kind"]["enum"],
326            json!(["wrong", "permitted"])
327        );
328    }
329}