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    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/// The story's oracle as the implementer is told it (`bundle.oracle`):
53/// the scenarios still red with what must hold and the tests that check
54/// them, what this task must turn green, and that a test contradicting the
55/// spec is reported, never worked around. Empty outside a story.
56#[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/// The prompt for a bundle (the run's input `{bundle, cache_ttl}`).
115///
116/// Order is stability order: policy and constitution (identical for the whole
117/// project), then the plan (identical for the feature), then the task. A
118/// breakpoint closes each stable part. For [`PromptStyle::Parts`] this is
119/// the Anthropic layout; callers convert it once they have appended to it.
120#[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/// The coded skill.
245#[derive(Debug)]
246pub struct Propose {
247    /// Lifetime of the prompt's cache breakpoints.
248    pub cache_ttl: CacheTtl,
249    /// The provider's prompt dialect.
250    pub style: PromptStyle,
251    /// The project's spec format.
252    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}