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    use super::answers::{NearMissKind, Variants, schema};
85    schema::<Variants<NearMissKind>>()
86}
87
88/// The answer's schema when only special-case variants are asked for.
89fn special_case_schema() -> Value {
90    use super::answers::{SpecialCaseKind, Variants, schema};
91    schema::<Variants<SpecialCaseKind>>()
92}
93
94/// The run input: the task's spec context (the person's), the changed
95/// files' final contents and the patch (the worktree's), and how many
96/// variants of each kind to write.
97#[must_use]
98pub fn input(
99    bundle: &Value,
100    changed: &[(String, String)],
101    patch: &str,
102    near_miss: u32,
103    permitted: u32,
104) -> Tainted<Value> {
105    let mut budget = MAX_SOURCES;
106    let files: Vec<Value> = changed
107        .iter()
108        .map(|(path, content)| {
109            let mut content = content.clone();
110            if content.len() > budget {
111                let mut end = budget;
112                while !content.is_char_boundary(end) {
113                    end -= 1;
114                }
115                content.truncate(end);
116            }
117            budget = budget.saturating_sub(content.len());
118            json!({ "path": path, "content": content })
119        })
120        .collect();
121    let mut patch = patch.to_owned();
122    if patch.len() > super::monitor::MAX_PATCH {
123        let mut end = super::monitor::MAX_PATCH;
124        while !patch.is_char_boundary(end) {
125            end -= 1;
126        }
127        patch.truncate(end);
128        patch.push_str("\n… (diff truncated)");
129    }
130    let worktree = || super::provenance::from(super::provenance::WORKTREE);
131    Tainted::object([
132        ("story", Tainted::trusted(bundle["story"].clone())),
133        ("plan", Tainted::trusted(bundle["plan"].clone())),
134        ("task", Tainted::trusted(bundle["task"].clone())),
135        (
136            "counts",
137            Tainted::trusted(json!({ "wrong": near_miss, "permitted": permitted })),
138        ),
139        ("files", Tainted::with_label(json!(files), worktree())),
140        ("patch", Tainted::with_label(json!(patch), worktree())),
141    ])
142}
143
144fn prompt_raw(input: &Value, ttl: CacheTtl, style: PromptStyle, w: &Wording) -> Value {
145    let special = input["set"] == "special_case";
146    let system_policy = if special {
147        special_case_policy(w)
148    } else {
149        policy(w)
150    };
151    let s = |v: &Value| v.as_str().unwrap_or_default().to_owned();
152    let files: Vec<String> = input["files"]
153        .as_array()
154        .into_iter()
155        .flatten()
156        .map(|f| format!("## {}\n\n```\n{}\n```", s(&f["path"]), s(&f["content"])))
157        .collect();
158    let parts = [
159        format!(
160            "# Task {}\n\n{}\n\n# Spec (the task's story)\n\n{}",
161            s(&input["task"]["id"]),
162            s(&input["task"]["title"]),
163            s(&input["story"])
164        ),
165        format!("# Plan\n\n{}", s(&input["plan"])),
166        format!(
167            "# The change (git diff)\n\n```diff\n{}\n```\n\n# The changed source files as they are now\n\n{}",
168            s(&input["patch"]),
169            files.join("\n\n")
170        ),
171        if special {
172            let shown: Vec<String> = input["reproductions"]
173                .as_array()
174                .into_iter()
175                .flatten()
176                .map(|r| {
177                    format!(
178                        "## {} ({})\n\n```\n{}\n```",
179                        s(&r["statement"]),
180                        s(&r["path"]),
181                        s(&r["content"])
182                    )
183                })
184                .collect();
185            format!(
186                "# The reproductions\n\n{}\n\nWrite one `special_case` variant per reproduction.",
187                shown.join("\n\n")
188            )
189        } else {
190            format!(
191                "Write {} `wrong` variant(s) and at most {} `permitted` variant(s).",
192                input["counts"]["wrong"], input["counts"]["permitted"]
193            )
194        },
195    ];
196    match style {
197        PromptStyle::OpenAi => json!({
198            "system": system_policy,
199            "input": [{ "role": "user", "content": parts.iter().map(|t| json!({ "type": "input_text", "text": t })).collect::<Vec<_>>() }],
200        }),
201        PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
202            let breakpoint = match ttl {
203                CacheTtl::FiveMinutes => json!({ "type": "ephemeral" }),
204                CacheTtl::OneHour => json!({ "type": "ephemeral", "ttl": "1h" }),
205            };
206            json!({
207                "system": system_policy,
208                "messages": [{ "role": "user", "content": parts.iter().enumerate().map(|(i, t)| {
209                    if i == 1 { json!({ "type": "text", "text": t, "cache_control": breakpoint }) }
210                    else { json!({ "type": "text", "text": t }) }
211                }).collect::<Vec<_>>() }],
212            })
213        }
214    }
215}
216
217fn prompt(input: &Value, ttl: CacheTtl, style: PromptStyle, w: &Wording) -> Value {
218    let mut out = prompt_raw(input, ttl, style, w);
219    if style == PromptStyle::Chat {
220        super::model_config::strip_cache_control(&mut out);
221    }
222    if let PromptStyle::Parts { caches } = style {
223        super::model_config::to_parts(&mut out, caches);
224    }
225    out
226}
227
228/// The coded skill.
229#[derive(Debug)]
230pub struct NearMiss {
231    /// Prompt dialect.
232    pub style: PromptStyle,
233    /// Cache breakpoint lifetime.
234    pub cache_ttl: CacheTtl,
235    /// The project's spec format.
236    pub format: &'static dyn crate::format::Adapter,
237}
238
239#[async_trait]
240impl Skill for NearMiss {
241    fn descriptor(&self) -> SkillDescriptor {
242        SkillDescriptor::new("speccoder.task.near_miss").provides(CAPABILITY)
243    }
244
245    async fn invoke(
246        &self,
247        cx: &mut StepCtx<'_>,
248        input: Tainted<Value>,
249    ) -> Result<Outcome, SkillError> {
250        let (ttl, style) = (self.cache_ttl, self.style);
251        let prompt = super::provenance::labelled_prompt(
252            prompt(input.peek(), ttl, style, self.format.wording()),
253            &super::provenance::from(super::provenance::WORKTREE),
254        );
255        let expecting = if input.peek()["set"] == "special_case" {
256            special_case_schema()
257        } else {
258            schema()
259        };
260        let completion = cx
261            .complete_with(&prompt, |call| call.expecting(expecting))
262            .await?;
263        Ok(Outcome::done(completion.map(|c| {
264            json!({
265                "answer": c.structured,
266                "usage": serde_json::to_value(c.usage).unwrap_or(Value::Null),
267            })
268        })))
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    #[test]
277    fn the_author_sees_the_spec_and_the_code_never_a_test() {
278        let bundle = json!({ "story": "Users log in.", "plan": "p", "task": { "id": "T005", "title": "login" } });
279        let input = input(
280            &bundle,
281            &[("src/lib.rs".to_owned(), "pub fn ok() {}\n".to_owned())],
282            "+++ b/src/lib.rs\n",
283            2,
284            1,
285        );
286        let v = input.peek();
287        assert_eq!(v["counts"], json!({ "wrong": 2, "permitted": 1 }));
288        let p = prompt(
289            v,
290            CacheTtl::FiveMinutes,
291            PromptStyle::Anthropic,
292            crate::format::at(std::path::Path::new(".")).wording(),
293        );
294        let text = p.to_string();
295        assert!(text.contains("pub fn ok()") && text.contains("Users log in."));
296        assert!(!text.contains("acceptance test file"));
297        assert_eq!(
298            schema()["$defs"]["NearMissKind"]["enum"],
299            json!(["wrong", "permitted"])
300        );
301        assert_eq!(
302            special_case_schema()["$defs"]["SpecialCaseKind"]["enum"],
303            json!(["special_case"])
304        );
305    }
306}