Skip to main content

speccoder_runtime/substrate/
propose.rs

1//! `speccoder.task.propose`: one governed model call that proposes how to do
2//! a task. The answer is recorded, never executed.
3
4use crate::format::{Adapter, Wording};
5use agentplane::prelude::*;
6use serde_json::{Value, json};
7
8use super::model_config::{CacheTtl, PromptStyle};
9
10/// The capability the skill provides.
11pub const CAPABILITY: &str = "speccoder.task.propose";
12
13/// What speccoder tells every model call first. Stable across tasks, so it
14/// sits inside the cached prefix.
15pub 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/// The proposal's JSON schema.
28#[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/// The story's oracle as the implementer is told it (`bundle.oracle`):
41/// the scenarios still red with what must hold and the tests that check
42/// them, what this task must turn green, and that a test contradicting the
43/// spec is reported, never worked around. Empty outside a story.
44#[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/// The prompt for a bundle (the run's input `{bundle, cache_ttl}`).
103///
104/// Order is stability order: policy and constitution (identical for the whole
105/// project), then the plan (identical for the feature), then the task. A
106/// breakpoint closes each stable part. For [`PromptStyle::Parts`] this is
107/// the Anthropic layout; callers convert it once they have appended to it.
108#[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/// The coded skill.
233#[derive(Debug)]
234pub struct Propose {
235    /// Lifetime of the prompt's cache breakpoints.
236    pub cache_ttl: CacheTtl,
237    /// The provider's prompt dialect.
238    pub style: PromptStyle,
239    /// The project's spec format.
240    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}