1use 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 schema_of(&["wrong", "permitted"])
85}
86
87fn 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#[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 | PromptStyle::Parts { .. } => {
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 if let PromptStyle::Parts { caches } = style {
253 super::model_config::to_parts(&mut out, caches);
254 }
255 out
256}
257
258#[derive(Debug)]
260pub struct NearMiss {
261 pub style: PromptStyle,
263 pub cache_ttl: CacheTtl,
265 pub format: &'static dyn crate::format::Adapter,
267}
268
269#[async_trait]
270impl Skill for NearMiss {
271 fn descriptor(&self) -> SkillDescriptor {
272 SkillDescriptor::new("speccoder.task.near_miss").provides(CAPABILITY)
273 }
274
275 async fn invoke(
276 &self,
277 cx: &mut StepCtx<'_>,
278 input: Tainted<Value>,
279 ) -> Result<Outcome, SkillError> {
280 let (ttl, style) = (self.cache_ttl, self.style);
281 let prompt = super::provenance::labelled_prompt(
282 prompt(input.peek(), ttl, style, self.format.wording()),
283 &super::provenance::from(super::provenance::WORKTREE),
284 );
285 let expecting = if input.peek()["set"] == "special_case" {
286 schema_of(&["special_case"])
287 } else {
288 schema()
289 };
290 let completion = cx
291 .complete_with(&prompt, |call| call.expecting(expecting))
292 .await?;
293 Ok(Outcome::done(completion.map(|c| {
294 json!({
295 "answer": c.structured,
296 "usage": serde_json::to_value(c.usage).unwrap_or(Value::Null),
297 })
298 })))
299 }
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305
306 #[test]
307 fn the_author_sees_the_spec_and_the_code_never_a_test() {
308 let bundle = json!({ "story": "Users log in.", "plan": "p", "task": { "id": "T005", "title": "login" } });
309 let input = input(
310 &bundle,
311 &[("src/lib.rs".to_owned(), "pub fn ok() {}\n".to_owned())],
312 "+++ b/src/lib.rs\n",
313 2,
314 1,
315 );
316 let v = input.peek();
317 assert_eq!(v["counts"], json!({ "wrong": 2, "permitted": 1 }));
318 let p = prompt(
319 v,
320 CacheTtl::FiveMinutes,
321 PromptStyle::Anthropic,
322 crate::format::at(std::path::Path::new(".")).wording(),
323 );
324 let text = p.to_string();
325 assert!(text.contains("pub fn ok()") && text.contains("Users log in."));
326 assert!(!text.contains("acceptance test file"));
327 assert_eq!(
328 schema()["properties"]["variants"]["items"]["properties"]["kind"]["enum"],
329 json!(["wrong", "permitted"])
330 );
331 }
332}