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 super::answers::schema::<super::answers::Proposal>()
31}
32
33fn breakpoint(ttl: CacheTtl) -> Value {
34 match ttl {
35 CacheTtl::FiveMinutes => json!({ "type": "ephemeral" }),
36 CacheTtl::OneHour => json!({ "type": "ephemeral", "ttl": "1h" }),
37 }
38}
39
40#[must_use]
45pub fn oracle_text(bundle: &Value) -> String {
46 let o = &bundle["oracle"];
47 let red: Vec<String> = o["red"]
48 .as_array()
49 .into_iter()
50 .flatten()
51 .map(|r| {
52 let tests: Vec<&str> = r["tests"]
53 .as_array()
54 .into_iter()
55 .flatten()
56 .filter_map(Value::as_str)
57 .collect();
58 format!(
59 "- {}: {}{}",
60 r["id"].as_str().unwrap_or_default(),
61 r["contract"].as_str().unwrap_or("(as the spec states it)"),
62 if tests.is_empty() {
63 String::new()
64 } else {
65 format!(" — {}", tests.join(", "))
66 }
67 )
68 })
69 .collect();
70 if red.is_empty() && o.is_null() {
71 return String::new();
72 }
73 let links: Vec<&str> = o["links"]
74 .as_array()
75 .into_iter()
76 .flatten()
77 .filter_map(Value::as_str)
78 .collect();
79 let mut out =
80 String::from("\n# The story's acceptance tests (frozen: you cannot change them)\n\n");
81 if red.is_empty() {
82 out.push_str("Every scenario of the story passes already: keep them passing.\n");
83 } else {
84 out.push_str("Still failing — what must hold, and the tests that check it:\n");
85 out.push_str(&red.join("\n"));
86 out.push('\n');
87 }
88 if o["checkpoint"] == true {
89 out.push_str("\nThis is the story's last task: every scenario must pass after it.\n");
90 } else if !links.is_empty() {
91 out.push_str(&format!(
92 "\nThis task must make these pass: {}. The others belong to later tasks of the story: once yours pass, the task is done.\n",
93 links.join(", ")
94 ));
95 } else {
96 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");
97 }
98 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");
99 out
100}
101
102#[must_use]
109pub fn layout(
110 bundle: &Value,
111 ttl: CacheTtl,
112 style: PromptStyle,
113 policy: &str,
114 format: &dyn Adapter,
115) -> Value {
116 let text = |key: &str| bundle[key].as_str().unwrap_or_default().to_owned();
117 let task = &bundle["task"];
118 let mut task_text = format!(
119 "# Task {}\n\n{}\n\nPhase: {}\n",
120 task["id"].as_str().unwrap_or_default(),
121 task["title"].as_str().unwrap_or_default(),
122 task["phase"].as_str().unwrap_or_default(),
123 );
124 for (key, label) in [("subsection", "Subsection"), ("story", "Story")] {
125 if let Some(v) = task[key].as_str().filter(|v| !v.is_empty()) {
126 task_text.push_str(&format!("{label}: {v}\n"));
127 }
128 }
129 if task["parallel"].as_bool() == Some(true) {
130 task_text.push_str("Marked [P]: may run in parallel with other [P] tasks.\n");
131 }
132 if let Some(deps) = bundle["dependencies"].as_array().filter(|d| !d.is_empty()) {
133 task_text.push_str("\nDepends on:\n");
134 for d in deps {
135 let mark = if d["done"].as_bool() == Some(true) {
136 "x"
137 } else {
138 " "
139 };
140 task_text.push_str(&format!(
141 "- [{mark}] {} {}\n",
142 d["id"].as_str().unwrap_or_default(),
143 d["title"].as_str().unwrap_or_default()
144 ));
145 }
146 }
147 if let Some(others) = bundle["story_tasks"].as_array().filter(|d| !d.is_empty()) {
148 task_text.push_str(
149 if bundle["task"]["story"]
150 .as_str()
151 .unwrap_or_default()
152 .is_empty()
153 {
154 "\nThe phase's other tasks (their work, not this task's):\n"
155 } else {
156 "\nThe story's other tasks (their work, not this task's):\n"
157 },
158 );
159 for d in others {
160 let mark = if d["done"].as_bool() == Some(true) {
161 "x"
162 } else {
163 " "
164 };
165 task_text.push_str(&format!(
166 "- [{mark}] {} {}\n",
167 d["id"].as_str().unwrap_or_default(),
168 d["title"].as_str().unwrap_or_default()
169 ));
170 }
171 }
172
173 task_text.push_str(&oracle_text(bundle));
174
175 let w = format.wording();
176 let constitution = format!("# {}\n\n{}", w.constitution, or_none(&text("constitution")));
177 let plan = format!(
178 "# {} ({})\n\n{}",
179 w.plan,
180 text("feature"),
181 or_none(&text("plan"))
182 );
183 let story = text("story");
184 let heading = if format.is_convergence(task["phase"].as_str().unwrap_or_default()) {
185 "# Specification (this task closes a gap found against it)".to_owned()
186 } else {
187 format!("# {}", format.wording().story)
188 };
189 let story = (!story.is_empty()).then(|| format!("{heading}\n\n{story}"));
190
191 if style == PromptStyle::OpenAi {
192 let mut parts = vec![plan];
193 parts.extend(story);
194 parts.push(task_text);
195 let content: Vec<Value> = parts
196 .into_iter()
197 .map(|t| json!({ "type": "input_text", "text": t }))
198 .collect();
199 return json!({
200 "system": format!("{policy}\n\n{constitution}"),
201 "input": [ { "role": "user", "content": content } ],
202 });
203 }
204
205 let mut user = vec![json!({
206 "type": "text",
207 "text": plan,
208 "cache_control": breakpoint(ttl),
209 })];
210 if let Some(story) = story {
211 user.push(json!({ "type": "text", "text": story }));
212 }
213 user.push(json!({ "type": "text", "text": task_text }));
214
215 let mut out = json!({
216 "system": [
217 { "type": "text", "text": policy },
218 { "type": "text", "text": constitution, "cache_control": breakpoint(ttl) },
219 ],
220 "messages": [ { "role": "user", "content": user } ],
221 });
222 if style == PromptStyle::Chat {
223 super::model_config::strip_cache_control(&mut out);
224 }
225 out
226}
227
228fn or_none(s: &str) -> &str {
229 if s.trim().is_empty() { "(none)" } else { s }
230}
231
232#[derive(Debug)]
234pub struct Propose {
235 pub cache_ttl: CacheTtl,
237 pub style: PromptStyle,
239 pub format: &'static dyn Adapter,
241}
242
243#[async_trait]
244impl Skill for Propose {
245 fn descriptor(&self) -> SkillDescriptor {
246 SkillDescriptor::new("speccoder.task.propose").provides(CAPABILITY)
247 }
248
249 async fn invoke(
250 &self,
251 cx: &mut StepCtx<'_>,
252 input: Tainted<Value>,
253 ) -> Result<Outcome, SkillError> {
254 let (ttl, style) = (self.cache_ttl, self.style);
255 let policy = policy(self.format.wording());
256 let prompt = input.map(|bundle| {
257 let mut p = layout(&bundle, ttl, style, &policy, self.format);
258 if let PromptStyle::Parts { caches } = style {
259 super::model_config::to_parts(&mut p, caches);
260 }
261 p
262 });
263 let completion = cx
264 .complete_with(&prompt, |call| call.expecting(schema()))
265 .await?;
266 Ok(Outcome::done(completion.map(|c| {
267 json!({
268 "proposal": c.structured,
269 "text": c.text,
270 "usage": serde_json::to_value(c.usage).unwrap_or(Value::Null),
271 })
272 })))
273 }
274}