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
35#[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#[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 "settings": { "max_turns": limits.0, "max_tool_calls": limits.1, "asks": if ask { "tasks" } else { "stop" }, "root": root },
61 })
62}
63
64#[derive(Debug)]
66pub struct Host {
67 pub style: super::model_config::PromptStyle,
69 pub progress: Option<std::sync::Arc<dyn crate::progress::Progress>>,
71 pub format: &'static dyn crate::format::Adapter,
73 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 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 super::model_config::PromptStyle::Parts { .. } => json!({
121 "system": policy(self.format.wording()),
122 "input": [{ "type": "text", "text": text }],
123 }),
124 }
125 },
126 &label,
127 );
128 let mut tools = crate::tools::declarations();
129 tools.push(ask_person());
130 let ask = Asker::from_settings(
131 &settings,
132 format!("skill/{skill}"),
133 skill.clone(),
134 self.present.clone(),
135 );
136 tool_loop(
137 cx,
138 &prompt,
139 LoopSpec {
140 reasoning: false,
142 tools,
143 policy: Policy::new(&[], &[])
145 .rules(if settings["scope"] == "bug" {
146 self.format
147 .bug_step()
148 .map_or(self.format.rules(), |s| s.rules)
149 } else {
150 self.format.rules()
151 })
152 .authoring(),
153 max_turns: limit("max_turns", 30).max(1),
154 max_tool_calls: limit("max_tool_calls", 100),
155 schema: schema(),
156 routes: Vec::new(),
157 root: settings["root"].as_str().map(str::to_owned),
158 frozen_additions: false,
159 ask,
160 run_approved: Vec::new(),
161 once: Vec::new(),
162 run: None,
163 asking: super::attempt::Asking::default(),
164 parts: Vec::new(),
165 mcp_digests: std::collections::BTreeMap::new(),
166 observer: self.progress.clone().map(|p| (skill.clone(), p)),
167 finish: |report| {
168 if report["status"] == "incomplete" {
169 "incomplete"
170 } else {
171 "completed"
172 }
173 },
174 },
175 )
176 .await
177 }
178}