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
47#[must_use]
49pub fn schema() -> Value {
50 json!({
51 "type": "object",
52 "properties": {
53 "variants": {
54 "type": "array",
55 "items": {
56 "type": "object",
57 "properties": {
58 "kind": { "type": "string", "enum": ["wrong", "permitted"] },
59 "scenario": { "type": "string" },
60 "rationale": { "type": "string" },
61 "files": {
62 "type": "array",
63 "items": {
64 "type": "object",
65 "properties": {
66 "path": { "type": "string" },
67 "content": { "type": "string" },
68 },
69 "required": ["path", "content"],
70 "additionalProperties": false,
71 },
72 },
73 },
74 "required": ["kind", "scenario", "rationale", "files"],
75 "additionalProperties": false,
76 },
77 },
78 "notes": { "type": "array", "items": { "type": "string" } },
79 },
80 "required": ["variants", "notes"],
81 "additionalProperties": false,
82 })
83}
84
85#[must_use]
89pub fn input(
90 bundle: &Value,
91 changed: &[(String, String)],
92 patch: &str,
93 near_miss: u32,
94 permitted: u32,
95) -> Tainted<Value> {
96 let mut budget = MAX_SOURCES;
97 let files: Vec<Value> = changed
98 .iter()
99 .map(|(path, content)| {
100 let mut content = content.clone();
101 if content.len() > budget {
102 let mut end = budget;
103 while !content.is_char_boundary(end) {
104 end -= 1;
105 }
106 content.truncate(end);
107 }
108 budget = budget.saturating_sub(content.len());
109 json!({ "path": path, "content": content })
110 })
111 .collect();
112 let mut patch = patch.to_owned();
113 if patch.len() > super::monitor::MAX_PATCH {
114 let mut end = super::monitor::MAX_PATCH;
115 while !patch.is_char_boundary(end) {
116 end -= 1;
117 }
118 patch.truncate(end);
119 patch.push_str("\n… (diff truncated)");
120 }
121 let worktree = || super::provenance::from(super::provenance::WORKTREE);
122 Tainted::object([
123 ("story", Tainted::trusted(bundle["story"].clone())),
124 ("plan", Tainted::trusted(bundle["plan"].clone())),
125 ("task", Tainted::trusted(bundle["task"].clone())),
126 (
127 "counts",
128 Tainted::trusted(json!({ "wrong": near_miss, "permitted": permitted })),
129 ),
130 ("files", Tainted::with_label(json!(files), worktree())),
131 ("patch", Tainted::with_label(json!(patch), worktree())),
132 ])
133}
134
135fn prompt_raw(input: &Value, ttl: CacheTtl, style: PromptStyle, w: &Wording) -> Value {
136 let system_policy = policy(w);
137 let s = |v: &Value| v.as_str().unwrap_or_default().to_owned();
138 let files: Vec<String> = input["files"]
139 .as_array()
140 .into_iter()
141 .flatten()
142 .map(|f| format!("## {}\n\n```\n{}\n```", s(&f["path"]), s(&f["content"])))
143 .collect();
144 let parts = [
145 format!(
146 "# Task {}\n\n{}\n\n# Spec (the task's story)\n\n{}",
147 s(&input["task"]["id"]),
148 s(&input["task"]["title"]),
149 s(&input["story"])
150 ),
151 format!("# Plan\n\n{}", s(&input["plan"])),
152 format!(
153 "# The change (git diff)\n\n```diff\n{}\n```\n\n# The changed source files as they are now\n\n{}",
154 s(&input["patch"]),
155 files.join("\n\n")
156 ),
157 format!(
158 "Write {} `wrong` variant(s) and at most {} `permitted` variant(s).",
159 input["counts"]["wrong"], input["counts"]["permitted"]
160 ),
161 ];
162 match style {
163 PromptStyle::OpenAi => json!({
164 "system": system_policy,
165 "input": [{ "role": "user", "content": parts.iter().map(|t| json!({ "type": "input_text", "text": t })).collect::<Vec<_>>() }],
166 }),
167 PromptStyle::Anthropic | PromptStyle::Chat => {
168 let breakpoint = match ttl {
169 CacheTtl::FiveMinutes => json!({ "type": "ephemeral" }),
170 CacheTtl::OneHour => json!({ "type": "ephemeral", "ttl": "1h" }),
171 };
172 json!({
173 "system": system_policy,
174 "messages": [{ "role": "user", "content": parts.iter().enumerate().map(|(i, t)| {
175 if i == 1 { json!({ "type": "text", "text": t, "cache_control": breakpoint }) }
176 else { json!({ "type": "text", "text": t }) }
177 }).collect::<Vec<_>>() }],
178 })
179 }
180 }
181}
182
183fn prompt(input: &Value, ttl: CacheTtl, style: PromptStyle, w: &Wording) -> Value {
184 let mut out = prompt_raw(input, ttl, style, w);
185 if style == PromptStyle::Chat {
186 super::model_config::strip_cache_control(&mut out);
187 }
188 out
189}
190
191#[derive(Debug)]
193pub struct NearMiss {
194 pub style: PromptStyle,
196 pub cache_ttl: CacheTtl,
198 pub format: &'static dyn crate::format::Adapter,
200}
201
202#[async_trait]
203impl Skill for NearMiss {
204 fn descriptor(&self) -> SkillDescriptor {
205 SkillDescriptor::new("speccoder.task.near_miss").provides(CAPABILITY)
206 }
207
208 async fn invoke(
209 &self,
210 cx: &mut StepCtx<'_>,
211 input: Tainted<Value>,
212 ) -> Result<Outcome, SkillError> {
213 let (ttl, style) = (self.cache_ttl, self.style);
214 let prompt = super::provenance::labelled_prompt(
215 prompt(input.peek(), ttl, style, self.format.wording()),
216 &super::provenance::from(super::provenance::WORKTREE),
217 );
218 let completion = cx
219 .complete_with(&prompt, |call| call.expecting(schema()))
220 .await?;
221 Ok(Outcome::done(completion.map(|c| {
222 json!({
223 "answer": c.structured,
224 "usage": serde_json::to_value(c.usage).unwrap_or(Value::Null),
225 })
226 })))
227 }
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233
234 #[test]
235 fn the_author_sees_the_spec_and_the_code_never_a_test() {
236 let bundle = json!({ "story": "Users log in.", "plan": "p", "task": { "id": "T005", "title": "login" } });
237 let input = input(
238 &bundle,
239 &[("src/lib.rs".to_owned(), "pub fn ok() {}\n".to_owned())],
240 "+++ b/src/lib.rs\n",
241 2,
242 1,
243 );
244 let v = input.peek();
245 assert_eq!(v["counts"], json!({ "wrong": 2, "permitted": 1 }));
246 let p = prompt(
247 v,
248 CacheTtl::FiveMinutes,
249 PromptStyle::Anthropic,
250 crate::format::at(std::path::Path::new(".")).wording(),
251 );
252 let text = p.to_string();
253 assert!(text.contains("pub fn ok()") && text.contains("Users log in."));
254 assert!(!text.contains("acceptance test file"));
255 assert_eq!(
256 schema()["properties"]["variants"]["items"]["properties"]["kind"]["enum"],
257 json!(["wrong", "permitted"])
258 );
259 }
260}