Skip to main content

speccoder_runtime/substrate/
host.rs

1//! `speccoder.skill.run`: an installed Agent Skill, hosted.
2//!
3//! The skill's `SKILL.md` body is the instruction; speccoder's governed tool
4//! loop runs it with the planning policy (Spec Kit's artifacts and scripts;
5//! anything else needs approval) and an `ask_person` tool for clarifying
6//! questions. Everything it decides by is in the journaled input.
7
8use 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
15/// The capability the skill provides.
16pub const CAPABILITY: &str = "speccoder.skill.run";
17
18/// What the host tells the model, before the skill itself.
19pub 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
35/// What the host tells the model when it explores (`spec explore`).
36pub 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
43/// The instruction `speccoder.explore` runs when the format has no
44/// exploration skill of its own.
45pub 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/// The closing report's schema when exploring. Strict decoding takes no
48/// item counts, so how many approaches a report owes is checked after the
49/// run (`explore::validate_fallback`), never trusted to the schema.
50#[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/// The closing report's schema.
82#[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/// The run input.
99#[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        // With a person there, a question waits on a task they answer
105        // inline; without one the skill writes [NEEDS CLARIFICATION].
106        "settings": { "max_turns": limits.0, "max_tool_calls": limits.1, "asks": if ask { "tasks" } else { "stop" }, "root": root },
107    })
108}
109
110/// The coded skill.
111#[derive(Debug)]
112pub struct Host {
113    /// Prompt dialect.
114    pub style: super::model_config::PromptStyle,
115    /// Where live progress goes.
116    pub progress: Option<std::sync::Arc<dyn crate::progress::Progress>>,
117    /// The project's spec format.
118    pub format: &'static dyn crate::format::Adapter,
119    /// The sandbox, as a person asked about a command is told; live only.
120    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        // Built from labelled parts, so `/system` stays speccoder's trusted
146        // instruction after the conversation has read tool output.
147        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                // The account is shown for attempts; these read as documents.
193                reasoning: false,
194                tools,
195                // A format's own bug step writes only its bug's directory;
196                // exploring writes nothing but the format's own artifacts.
197                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}