speccoder_runtime/substrate/
host.rs1use crate::format::Wording;
9use agentplane::prelude::*;
10use serde_json::{Value, json};
11
12use super::attempt::{Asker, LoopSpec, ask_person, tool_loop};
13use crate::tools::policy::Policy;
14
15pub const CAPABILITY: &str = "speccoder.skill.run";
17
18pub fn policy(w: &Wording) -> String {
20 format!(
21 "You are speccoder, running the Agent Skill below for a person in their {product} \
22project. Follow the skill's instructions. You can read the project, {authoring} with run_command \
23(argv, no shell). Do not write source code — implementation is a separate step that \
24speccoder runs with tests; other writes need a person's approval. When the skill says to \
25ask the user, call ask_person with one question at a time; if nobody can answer, follow \
26the skill's rule for unanswered questions. Skip steps about extension hooks, other \
27agents' slash commands, committing, branching or pushing unless {authoring_tool} does it. \
28Finish with the requested structured report.",
29 product = w.product,
30 authoring = w.authoring,
31 authoring_tool = w.authoring_tool,
32 )
33}
34
35pub fn explore_policy(w: &Wording) -> String {
37 format!(
38 "You are speccoder, exploring an idea with a person in their {product} project before any spec exists. You can read the project; you cannot write anything — the person chooses an approach afterwards, speccoder writes the brief, and *Start spec* writes the spec. When you need the person's view, call ask_person with one question at a time; if nobody can answer, list the question under open questions and go on. Look at how the project works today, then end with the structured report: approaches that really differ, each with what it gives up and what could go wrong, the constraints any of them must respect, and the questions still open. Your report is a proposal; you do not choose.",
39 product = w.product,
40 )
41}
42
43pub const EXPLORE_FALLBACK: &str = "Explore the idea below with the person. Read what the project already has that it touches (code, specs, docs). Ask the person at most a few questions, one at a time, only where the answer changes which approach fits. Then propose at least three approaches that differ in kind, not degree — e.g. the smallest change, the cleanest design, a different way altogether — each with its trade-offs and its risks. Name the constraints any approach must respect and the questions still open. Do not choose: the person does.";
46
47#[must_use]
51pub fn explore_schema() -> Value {
52 let strings = json!({ "type": "array", "items": { "type": "string" } });
53 let some = strings.clone();
54 json!({
55 "type": "object",
56 "properties": {
57 "summary": { "type": "string" },
58 "status": { "type": "string", "enum": ["done", "incomplete"] },
59 "approaches": {
60 "type": "array",
61 "items": {
62 "type": "object",
63 "properties": {
64 "title": { "type": "string" },
65 "summary": { "type": "string" },
66 "tradeoffs": some,
67 "risks": some,
68 },
69 "required": ["title", "summary", "tradeoffs", "risks"],
70 "additionalProperties": false,
71 },
72 },
73 "constraints": strings,
74 "open_questions": strings,
75 },
76 "required": ["summary", "status", "approaches", "constraints", "open_questions"],
77 "additionalProperties": false,
78 })
79}
80
81#[must_use]
83pub fn schema() -> Value {
84 let strings = json!({ "type": "array", "items": { "type": "string" } });
85 json!({
86 "type": "object",
87 "properties": {
88 "summary": { "type": "string" },
89 "status": { "type": "string", "enum": ["done", "incomplete"] },
90 "files_written": strings,
91 "questions": strings,
92 },
93 "required": ["summary", "status", "files_written", "questions"],
94 "additionalProperties": false,
95 })
96}
97
98#[must_use]
100pub fn input(skill: &str, instruction: &str, root: &str, limits: (u32, u32), ask: bool) -> Value {
101 json!({
102 "skill": skill,
103 "instruction": instruction,
104 "settings": { "max_turns": limits.0, "max_tool_calls": limits.1, "asks": if ask { "tasks" } else { "stop" }, "root": root },
107 })
108}
109
110#[derive(Debug)]
112pub struct Host {
113 pub style: super::model_config::PromptStyle,
115 pub progress: Option<std::sync::Arc<dyn crate::progress::Progress>>,
117 pub format: &'static dyn crate::format::Adapter,
119 pub present: crate::approvals::PresentContext,
121}
122
123#[async_trait]
124impl Skill for Host {
125 fn descriptor(&self) -> SkillDescriptor {
126 SkillDescriptor::new("speccoder.skill.run").provides(CAPABILITY)
127 }
128
129 async fn invoke(
130 &self,
131 cx: &mut StepCtx<'_>,
132 input: Tainted<Value>,
133 ) -> Result<Outcome, SkillError> {
134 let style = self.style;
135 let settings = input.peek()["settings"].clone();
136 let skill = input.peek()["skill"]
137 .as_str()
138 .unwrap_or_default()
139 .to_owned();
140 let limit = |key: &str, default: u32| {
141 settings[key]
142 .as_u64()
143 .map_or(default, |n| u32::try_from(n).unwrap_or(u32::MAX))
144 };
145 let label = input.label().clone();
148 let exploring = settings["mode"] == "explore";
149 let prompt = super::provenance::labelled_prompt(
150 {
151 let input = input.peek();
152 let text = format!(
153 "# Skill `{}`\n\n{}",
154 input["skill"].as_str().unwrap_or_default(),
155 input["instruction"].as_str().unwrap_or_default()
156 );
157 let system = if exploring {
158 explore_policy(self.format.wording())
159 } else {
160 policy(self.format.wording())
161 };
162 match style {
163 super::model_config::PromptStyle::OpenAi => json!({
164 "system": system,
165 "input": [{ "role": "user", "content": [{ "type": "input_text", "text": text }] }],
166 }),
167 super::model_config::PromptStyle::Anthropic
168 | super::model_config::PromptStyle::Chat => json!({
169 "system": system,
170 "messages": [{ "role": "user", "content": [{ "type": "text", "text": text }] }],
171 }),
172 super::model_config::PromptStyle::Parts { .. } => json!({
173 "system": system,
174 "input": [{ "type": "text", "text": text }],
175 }),
176 }
177 },
178 &label,
179 );
180 let mut tools = crate::tools::declarations();
181 tools.push(ask_person());
182 let ask = Asker::from_settings(
183 &settings,
184 format!("skill/{skill}"),
185 skill.clone(),
186 self.present.clone(),
187 );
188 tool_loop(
189 cx,
190 &prompt,
191 LoopSpec {
192 reasoning: false,
194 tools,
195 policy: if exploring {
198 let planning = self.format.planning();
199 Policy::new(&[], &[]).rules(self.format.rules()).exploring(
200 crate::tools::policy::Exploring {
201 paths: planning.explore_paths,
202 commands: planning.explore_commands,
203 },
204 )
205 } else {
206 Policy::new(&[], &[])
207 .rules(if settings["scope"] == "bug" {
208 self.format
209 .bug_step()
210 .map_or(self.format.rules(), |s| s.rules)
211 } else {
212 self.format.rules()
213 })
214 .authoring()
215 },
216 max_turns: limit("max_turns", 30).max(1),
217 max_tool_calls: limit("max_tool_calls", 100),
218 schema: Some(if exploring {
219 explore_schema()
220 } else {
221 schema()
222 }),
223 max_tokens: None,
224 reads: None,
225 halt: None,
226 routes: Vec::new(),
227 root: settings["root"].as_str().map(str::to_owned),
228 frozen_additions: false,
229 ask,
230 run_approved: Vec::new(),
231 once: Vec::new(),
232 run: None,
233 asking: super::attempt::Asking::default(),
234 parts: Vec::new(),
235 mcp_digests: std::collections::BTreeMap::new(),
236 stop: super::attempt::StopGate::default(),
237 observer: self.progress.clone().map(|p| (skill.clone(), p)),
238 finish: |report| {
239 if report["status"] == "incomplete" {
240 "incomplete"
241 } else {
242 "completed"
243 }
244 },
245 },
246 )
247 .await
248 }
249}