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/// The closing report's schema.
36#[must_use]
37pub fn schema() -> Value {
38    let strings = json!({ "type": "array", "items": { "type": "string" } });
39    json!({
40        "type": "object",
41        "properties": {
42            "summary": { "type": "string" },
43            "status": { "type": "string", "enum": ["done", "incomplete"] },
44            "files_written": strings,
45            "questions": strings,
46        },
47        "required": ["summary", "status", "files_written", "questions"],
48        "additionalProperties": false,
49    })
50}
51
52/// The run input.
53#[must_use]
54pub fn input(skill: &str, instruction: &str, root: &str, limits: (u32, u32), ask: bool) -> Value {
55    json!({
56        "skill": skill,
57        "instruction": instruction,
58        // With a person there, a question waits on a task they answer
59        // inline; without one the skill writes [NEEDS CLARIFICATION].
60        "settings": { "max_turns": limits.0, "max_tool_calls": limits.1, "asks": if ask { "tasks" } else { "stop" }, "root": root },
61    })
62}
63
64/// The coded skill.
65#[derive(Debug)]
66pub struct Host {
67    /// Prompt dialect.
68    pub style: super::model_config::PromptStyle,
69    /// Where live progress goes.
70    pub progress: Option<std::sync::Arc<dyn crate::progress::Progress>>,
71    /// The project's spec format.
72    pub format: &'static dyn crate::format::Adapter,
73    /// The sandbox, as a person asked about a command is told; live only.
74    pub present: crate::approvals::PresentContext,
75}
76
77#[async_trait]
78impl Skill for Host {
79    fn descriptor(&self) -> SkillDescriptor {
80        SkillDescriptor::new("speccoder.skill.run").provides(CAPABILITY)
81    }
82
83    async fn invoke(
84        &self,
85        cx: &mut StepCtx<'_>,
86        input: Tainted<Value>,
87    ) -> Result<Outcome, SkillError> {
88        let style = self.style;
89        let settings = input.peek()["settings"].clone();
90        let skill = input.peek()["skill"]
91            .as_str()
92            .unwrap_or_default()
93            .to_owned();
94        let limit = |key: &str, default: u32| {
95            settings[key]
96                .as_u64()
97                .map_or(default, |n| u32::try_from(n).unwrap_or(u32::MAX))
98        };
99        // Built from labelled parts, so `/system` stays speccoder's trusted
100        // instruction after the conversation has read tool output.
101        let label = input.label().clone();
102        let prompt = super::provenance::labelled_prompt(
103            {
104                let input = input.peek();
105                let text = format!(
106                    "# Skill `{}`\n\n{}",
107                    input["skill"].as_str().unwrap_or_default(),
108                    input["instruction"].as_str().unwrap_or_default()
109                );
110                match style {
111                    super::model_config::PromptStyle::OpenAi => json!({
112                        "system": policy(self.format.wording()),
113                        "input": [{ "role": "user", "content": [{ "type": "input_text", "text": text }] }],
114                    }),
115                    super::model_config::PromptStyle::Anthropic
116                    | super::model_config::PromptStyle::Chat => json!({
117                        "system": policy(self.format.wording()),
118                        "messages": [{ "role": "user", "content": [{ "type": "text", "text": text }] }],
119                    }),
120                }
121            },
122            &label,
123        );
124        let mut tools = crate::tools::declarations();
125        tools.push(ask_person());
126        let ask = Asker::from_settings(
127            &settings,
128            format!("skill/{skill}"),
129            skill.clone(),
130            self.present.clone(),
131        );
132        tool_loop(
133            cx,
134            &prompt,
135            LoopSpec {
136                // The account is shown for attempts; these read as documents.
137                reasoning: false,
138                tools,
139                // A format's own bug step writes only its bug's directory.
140                policy: Policy::new(&[], &[])
141                    .rules(if settings["scope"] == "bug" {
142                        self.format
143                            .bug_step()
144                            .map_or(self.format.rules(), |s| s.rules)
145                    } else {
146                        self.format.rules()
147                    })
148                    .authoring(),
149                max_turns: limit("max_turns", 30).max(1),
150                max_tool_calls: limit("max_tool_calls", 100),
151                schema: schema(),
152                routes: Vec::new(),
153                root: settings["root"].as_str().map(str::to_owned),
154                frozen_additions: false,
155                ask,
156                run_approved: Vec::new(),
157                once: Vec::new(),
158                run: None,
159                asking: super::attempt::Asking::default(),
160                parts: Vec::new(),
161                mcp_digests: std::collections::BTreeMap::new(),
162                observer: self.progress.clone().map(|p| (skill.clone(), p)),
163                finish: |report| {
164                    if report["status"] == "incomplete" {
165                        "incomplete"
166                    } else {
167                        "completed"
168                    }
169                },
170            },
171        )
172        .await
173    }
174}