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]
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#[derive(Debug)]
172pub struct Propose {
173 pub cache_ttl: CacheTtl,
175 pub style: PromptStyle,
177 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}