speccoder_runtime/substrate/
propose.rs1use crate::format::{Adapter, Wording};
5use agentplane::prelude::*;
6use serde_json::{Value, json};
7
8use super::model_config::{CacheTtl, PromptStyle};
9
10pub const CAPABILITY: &str = "speccoder.task.propose";
12
13pub fn policy(w: &Wording) -> String {
16 format!(
17 "You are the planning step of speccoder, a coding agent that executes \
18{product} tasks. Propose how to implement exactly one task. You cannot run tools in this \
19step and nothing you write is executed: a deterministic engine decides what happens, and \
20verification evidence — not your claims — decides when a task is done. Stay inside the task's \
21scope; name the files you expect to touch and the checks that would show the task works; say \
22what is unclear or risky instead of guessing. Answer only in the requested structure.",
23 product = w.product
24 )
25}
26
27#[must_use]
29pub fn schema() -> Value {
30 let strings = json!({ "type": "array", "items": { "type": "string" } });
31 json!({
32 "type": "object",
33 "properties": {
34 "summary": { "type": "string" },
35 "steps": strings,
36 "files": strings,
37 "checks": strings,
38 "risks": strings,
39 },
40 "required": ["summary", "steps", "files", "checks", "risks"],
41 "additionalProperties": false,
42 })
43}
44
45fn breakpoint(ttl: CacheTtl) -> Value {
46 match ttl {
47 CacheTtl::FiveMinutes => json!({ "type": "ephemeral" }),
48 CacheTtl::OneHour => json!({ "type": "ephemeral", "ttl": "1h" }),
49 }
50}
51
52#[must_use]
57pub fn oracle_text(bundle: &Value) -> String {
58 let o = &bundle["oracle"];
59 let red: Vec<String> = o["red"]
60 .as_array()
61 .into_iter()
62 .flatten()
63 .map(|r| {
64 let tests: Vec<&str> = r["tests"]
65 .as_array()
66 .into_iter()
67 .flatten()
68 .filter_map(Value::as_str)
69 .collect();
70 format!(
71 "- {}: {}{}",
72 r["id"].as_str().unwrap_or_default(),
73 r["contract"].as_str().unwrap_or("(as the spec states it)"),
74 if tests.is_empty() {
75 String::new()
76 } else {
77 format!(" — {}", tests.join(", "))
78 }
79 )
80 })
81 .collect();
82 if red.is_empty() && o.is_null() {
83 return String::new();
84 }
85 let links: Vec<&str> = o["links"]
86 .as_array()
87 .into_iter()
88 .flatten()
89 .filter_map(Value::as_str)
90 .collect();
91 let mut out =
92 String::from("\n# The story's acceptance tests (frozen: you cannot change them)\n\n");
93 if red.is_empty() {
94 out.push_str("Every scenario of the story passes already: keep them passing.\n");
95 } else {
96 out.push_str("Still failing — what must hold, and the tests that check it:\n");
97 out.push_str(&red.join("\n"));
98 out.push('\n');
99 }
100 if o["checkpoint"] == true {
101 out.push_str("\nThis is the story's last task: every scenario must pass after it.\n");
102 } else if !links.is_empty() {
103 out.push_str(&format!(
104 "\nThis task must make these pass: {}. The others belong to later tasks of the story: once yours pass, the task is done.\n",
105 links.join(", ")
106 ));
107 } else {
108 out.push_str("\nMake pass what this task's work makes hold; the rest belong to later tasks of the story, and their failing does not make this task incomplete. Never break one that passes.\n");
109 }
110 out.push_str("If a test asserts what the specification does not say, stop with report_blocked, kind wrong_test, naming the test — never work around it.\n");
111 out
112}
113
114#[must_use]
121pub fn layout(
122 bundle: &Value,
123 ttl: CacheTtl,
124 style: PromptStyle,
125 policy: &str,
126 format: &dyn Adapter,
127) -> Value {
128 let text = |key: &str| bundle[key].as_str().unwrap_or_default().to_owned();
129 let task = &bundle["task"];
130 let mut task_text = format!(
131 "# Task {}\n\n{}\n\nPhase: {}\n",
132 task["id"].as_str().unwrap_or_default(),
133 task["title"].as_str().unwrap_or_default(),
134 task["phase"].as_str().unwrap_or_default(),
135 );
136 for (key, label) in [("subsection", "Subsection"), ("story", "Story")] {
137 if let Some(v) = task[key].as_str().filter(|v| !v.is_empty()) {
138 task_text.push_str(&format!("{label}: {v}\n"));
139 }
140 }
141 if task["parallel"].as_bool() == Some(true) {
142 task_text.push_str("Marked [P]: may run in parallel with other [P] tasks.\n");
143 }
144 if let Some(deps) = bundle["dependencies"].as_array().filter(|d| !d.is_empty()) {
145 task_text.push_str("\nDepends on:\n");
146 for d in deps {
147 let mark = if d["done"].as_bool() == Some(true) {
148 "x"
149 } else {
150 " "
151 };
152 task_text.push_str(&format!(
153 "- [{mark}] {} {}\n",
154 d["id"].as_str().unwrap_or_default(),
155 d["title"].as_str().unwrap_or_default()
156 ));
157 }
158 }
159 if let Some(others) = bundle["story_tasks"].as_array().filter(|d| !d.is_empty()) {
160 task_text.push_str(
161 if bundle["task"]["story"]
162 .as_str()
163 .unwrap_or_default()
164 .is_empty()
165 {
166 "\nThe phase's other tasks (their work, not this task's):\n"
167 } else {
168 "\nThe story's other tasks (their work, not this task's):\n"
169 },
170 );
171 for d in others {
172 let mark = if d["done"].as_bool() == Some(true) {
173 "x"
174 } else {
175 " "
176 };
177 task_text.push_str(&format!(
178 "- [{mark}] {} {}\n",
179 d["id"].as_str().unwrap_or_default(),
180 d["title"].as_str().unwrap_or_default()
181 ));
182 }
183 }
184
185 task_text.push_str(&oracle_text(bundle));
186
187 let w = format.wording();
188 let constitution = format!("# {}\n\n{}", w.constitution, or_none(&text("constitution")));
189 let plan = format!(
190 "# {} ({})\n\n{}",
191 w.plan,
192 text("feature"),
193 or_none(&text("plan"))
194 );
195 let story = text("story");
196 let heading = if format.is_convergence(task["phase"].as_str().unwrap_or_default()) {
197 "# Specification (this task closes a gap found against it)".to_owned()
198 } else {
199 format!("# {}", format.wording().story)
200 };
201 let story = (!story.is_empty()).then(|| format!("{heading}\n\n{story}"));
202
203 if style == PromptStyle::OpenAi {
204 let mut parts = vec![plan];
205 parts.extend(story);
206 parts.push(task_text);
207 let content: Vec<Value> = parts
208 .into_iter()
209 .map(|t| json!({ "type": "input_text", "text": t }))
210 .collect();
211 return json!({
212 "system": format!("{policy}\n\n{constitution}"),
213 "input": [ { "role": "user", "content": content } ],
214 });
215 }
216
217 let mut user = vec![json!({
218 "type": "text",
219 "text": plan,
220 "cache_control": breakpoint(ttl),
221 })];
222 if let Some(story) = story {
223 user.push(json!({ "type": "text", "text": story }));
224 }
225 user.push(json!({ "type": "text", "text": task_text }));
226
227 let mut out = json!({
228 "system": [
229 { "type": "text", "text": policy },
230 { "type": "text", "text": constitution, "cache_control": breakpoint(ttl) },
231 ],
232 "messages": [ { "role": "user", "content": user } ],
233 });
234 if style == PromptStyle::Chat {
235 super::model_config::strip_cache_control(&mut out);
236 }
237 out
238}
239
240fn or_none(s: &str) -> &str {
241 if s.trim().is_empty() { "(none)" } else { s }
242}
243
244#[derive(Debug)]
246pub struct Propose {
247 pub cache_ttl: CacheTtl,
249 pub style: PromptStyle,
251 pub format: &'static dyn Adapter,
253}
254
255#[async_trait]
256impl Skill for Propose {
257 fn descriptor(&self) -> SkillDescriptor {
258 SkillDescriptor::new("speccoder.task.propose").provides(CAPABILITY)
259 }
260
261 async fn invoke(
262 &self,
263 cx: &mut StepCtx<'_>,
264 input: Tainted<Value>,
265 ) -> Result<Outcome, SkillError> {
266 let (ttl, style) = (self.cache_ttl, self.style);
267 let policy = policy(self.format.wording());
268 let prompt = input.map(|bundle| {
269 let mut p = layout(&bundle, ttl, style, &policy, self.format);
270 if let PromptStyle::Parts { caches } = style {
271 super::model_config::to_parts(&mut p, caches);
272 }
273 p
274 });
275 let completion = cx
276 .complete_with(&prompt, |call| call.expecting(schema()))
277 .await?;
278 Ok(Outcome::done(completion.map(|c| {
279 json!({
280 "proposal": c.structured,
281 "text": c.text,
282 "usage": serde_json::to_value(c.usage).unwrap_or(Value::Null),
283 })
284 })))
285 }
286}