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 prompt for a bundle (the run's input `{bundle, cache_ttl}`).
53///
54/// Order is stability order: policy and constitution (identical for the whole
55/// project), then the plan (identical for the feature), then the task. A
56/// breakpoint closes each stable part. For [`PromptStyle::Parts`] this is
57/// the Anthropic layout; callers convert it once they have appended to it.
58#[must_use]
59pub fn layout(
60    bundle: &Value,
61    ttl: CacheTtl,
62    style: PromptStyle,
63    policy: &str,
64    format: &dyn Adapter,
65) -> Value {
66    let text = |key: &str| bundle[key].as_str().unwrap_or_default().to_owned();
67    let task = &bundle["task"];
68    let mut task_text = format!(
69        "# Task {}\n\n{}\n\nPhase: {}\n",
70        task["id"].as_str().unwrap_or_default(),
71        task["title"].as_str().unwrap_or_default(),
72        task["phase"].as_str().unwrap_or_default(),
73    );
74    for (key, label) in [("subsection", "Subsection"), ("story", "Story")] {
75        if let Some(v) = task[key].as_str().filter(|v| !v.is_empty()) {
76            task_text.push_str(&format!("{label}: {v}\n"));
77        }
78    }
79    if task["parallel"].as_bool() == Some(true) {
80        task_text.push_str("Marked [P]: may run in parallel with other [P] tasks.\n");
81    }
82    if let Some(deps) = bundle["dependencies"].as_array().filter(|d| !d.is_empty()) {
83        task_text.push_str("\nDepends on:\n");
84        for d in deps {
85            let mark = if d["done"].as_bool() == Some(true) {
86                "x"
87            } else {
88                " "
89            };
90            task_text.push_str(&format!(
91                "- [{mark}] {} {}\n",
92                d["id"].as_str().unwrap_or_default(),
93                d["title"].as_str().unwrap_or_default()
94            ));
95        }
96    }
97    if let Some(others) = bundle["story_tasks"].as_array().filter(|d| !d.is_empty()) {
98        task_text.push_str("\nThe story's other tasks (their work, not this task's):\n");
99        for d in others {
100            let mark = if d["done"].as_bool() == Some(true) {
101                "x"
102            } else {
103                " "
104            };
105            task_text.push_str(&format!(
106                "- [{mark}] {} {}\n",
107                d["id"].as_str().unwrap_or_default(),
108                d["title"].as_str().unwrap_or_default()
109            ));
110        }
111    }
112
113    let w = format.wording();
114    let constitution = format!("# {}\n\n{}", w.constitution, or_none(&text("constitution")));
115    let plan = format!(
116        "# {} ({})\n\n{}",
117        w.plan,
118        text("feature"),
119        or_none(&text("plan"))
120    );
121    let story = text("story");
122    let heading = if format.is_convergence(task["phase"].as_str().unwrap_or_default()) {
123        "# Specification (this task closes a gap found against it)".to_owned()
124    } else {
125        format!("# {}", format.wording().story)
126    };
127    let story = (!story.is_empty()).then(|| format!("{heading}\n\n{story}"));
128
129    if style == PromptStyle::OpenAi {
130        let mut parts = vec![plan];
131        parts.extend(story);
132        parts.push(task_text);
133        let content: Vec<Value> = parts
134            .into_iter()
135            .map(|t| json!({ "type": "input_text", "text": t }))
136            .collect();
137        return json!({
138            "system": format!("{policy}\n\n{constitution}"),
139            "input": [ { "role": "user", "content": content } ],
140        });
141    }
142
143    let mut user = vec![json!({
144        "type": "text",
145        "text": plan,
146        "cache_control": breakpoint(ttl),
147    })];
148    if let Some(story) = story {
149        user.push(json!({ "type": "text", "text": story }));
150    }
151    user.push(json!({ "type": "text", "text": task_text }));
152
153    let mut out = json!({
154        "system": [
155            { "type": "text", "text": policy },
156            { "type": "text", "text": constitution, "cache_control": breakpoint(ttl) },
157        ],
158        "messages": [ { "role": "user", "content": user } ],
159    });
160    if style == PromptStyle::Chat {
161        super::model_config::strip_cache_control(&mut out);
162    }
163    out
164}
165
166fn or_none(s: &str) -> &str {
167    if s.trim().is_empty() { "(none)" } else { s }
168}
169
170/// The coded skill.
171#[derive(Debug)]
172pub struct Propose {
173    /// Lifetime of the prompt's cache breakpoints.
174    pub cache_ttl: CacheTtl,
175    /// The provider's prompt dialect.
176    pub style: PromptStyle,
177    /// The project's spec format.
178    pub format: &'static dyn Adapter,
179}
180
181#[async_trait]
182impl Skill for Propose {
183    fn descriptor(&self) -> SkillDescriptor {
184        SkillDescriptor::new("speccoder.task.propose").provides(CAPABILITY)
185    }
186
187    async fn invoke(
188        &self,
189        cx: &mut StepCtx<'_>,
190        input: Tainted<Value>,
191    ) -> Result<Outcome, SkillError> {
192        let (ttl, style) = (self.cache_ttl, self.style);
193        let policy = policy(self.format.wording());
194        let prompt = input.map(|bundle| {
195            let mut p = layout(&bundle, ttl, style, &policy, self.format);
196            if let PromptStyle::Parts { caches } = style {
197                super::model_config::to_parts(&mut p, caches);
198            }
199            p
200        });
201        let completion = cx
202            .complete_with(&prompt, |call| call.expecting(schema()))
203            .await?;
204        Ok(Outcome::done(completion.map(|c| {
205            json!({
206                "proposal": c.structured,
207                "text": c.text,
208                "usage": serde_json::to_value(c.usage).unwrap_or(Value::Null),
209            })
210        })))
211    }
212}