speccoder_runtime/substrate/
near_miss.rs1use crate::format::Wording;
14use agentplane::prelude::*;
15use serde_json::{Value, json};
16
17use super::model_config::{CacheTtl, PromptStyle};
18
19pub const CAPABILITY: &str = "speccoder.task.near_miss";
21
22pub const MAX_SOURCES: usize = 60_000;
24
25pub 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
47pub 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#[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#[must_use]
83pub fn schema() -> Value {
84 use super::answers::{NearMissKind, Variants, schema};
85 schema::<Variants<NearMissKind>>()
86}
87
88fn special_case_schema() -> Value {
90 use super::answers::{SpecialCaseKind, Variants, schema};
91 schema::<Variants<SpecialCaseKind>>()
92}
93
94#[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#[derive(Debug)]
230pub struct NearMiss {
231 pub style: PromptStyle,
233 pub cache_ttl: CacheTtl,
235 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}