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 => {
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#[derive(Debug)]
257pub struct NearMiss {
258 pub style: PromptStyle,
260 pub cache_ttl: CacheTtl,
262 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}