spec_ai/spec_ai_core/
spec.rs1use anyhow::{Context, Result, bail};
2use serde::Deserialize;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6#[derive(Debug, Clone, Deserialize)]
8pub struct AgentSpec {
9 pub name: Option<String>,
11 pub goal: String,
13 pub context: Option<String>,
15 #[serde(default)]
17 pub tasks: Vec<String>,
18 #[serde(default)]
20 pub deliverables: Vec<String>,
21 #[serde(default)]
23 pub constraints: Vec<String>,
24 #[serde(skip)]
26 source: Option<PathBuf>,
27}
28
29impl AgentSpec {
30 pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
32 let path = path.as_ref();
33 if !path.exists() {
34 bail!("spec file '{}' was not found", path.display());
35 }
36 if !Self::is_spec_extension(path) {
37 bail!(
38 "spec files must use the `.spec` extension (got '{}')",
39 path.display()
40 );
41 }
42
43 let raw = fs::read_to_string(path)
44 .with_context(|| format!("failed reading spec file '{}'", path.display()))?;
45 let mut spec = Self::from_str(&raw)?;
46 spec.source = Some(path.to_path_buf());
47 Ok(spec)
48 }
49
50 #[allow(clippy::should_implement_trait)]
52 pub fn from_str(contents: &str) -> Result<Self> {
53 let spec: AgentSpec = toml::from_str(contents).context("failed to parse spec TOML")?;
54 spec.validate()?;
55 Ok(spec)
56 }
57
58 pub fn to_prompt(&self) -> String {
60 let mut sections = Vec::new();
61 if let Some(name) = &self.name {
62 if !name.trim().is_empty() {
63 sections.push(format!("Spec Name: {}", name.trim()));
64 }
65 }
66 sections.push(format!("Primary Goal:\n{}", self.goal.trim()));
67
68 if let Some(ctx) = self.context_text() {
69 sections.push(format!("Context:\n{}", ctx));
70 }
71
72 if let Some(tasks) = self.formatted_list("Tasks", &self.tasks, true) {
73 sections.push(tasks);
74 }
75 if let Some(deliverables) = self.formatted_list("Deliverables", &self.deliverables, true) {
76 sections.push(deliverables);
77 }
78 if let Some(constraints) = self.formatted_list("Constraints", &self.constraints, false) {
79 sections.push(constraints);
80 }
81
82 let mut prompt = String::from(
83 "You have been provided with a structured execution spec from the user.\n\
84 Follow every goal, task, and deliverable precisely. Reference section names when responding.\n\n",
85 );
86 prompt.push_str(§ions.join("\n\n"));
87 prompt.push_str(
88 "\n\nWhen complete, explicitly explain how each deliverable was satisfied and call out any blockers.",
89 );
90 prompt
91 }
92
93 pub fn preview(&self) -> String {
95 let mut preview = Vec::new();
96 if let Some(name) = &self.name {
97 if !name.trim().is_empty() {
98 preview.push(format!("Name: {}", name.trim()));
99 }
100 }
101 preview.push(format!("Goal: {}", self.goal.trim()));
102 if let Some(ctx) = self.context_preview(2) {
103 preview.push(format!("Context: {}", ctx));
104 }
105 if let Some(tasks) = self.preview_list("Tasks", &self.tasks) {
106 preview.push(tasks);
107 }
108 if let Some(deliverables) = self.preview_list("Deliverables", &self.deliverables) {
109 preview.push(deliverables);
110 }
111 if let Some(constraints) = self.preview_list("Constraints", &self.constraints) {
112 preview.push(constraints);
113 }
114 preview.join("\n")
115 }
116
117 pub fn display_name(&self) -> &str {
119 if let Some(name) = &self.name {
120 let trimmed = name.trim();
121 if !trimmed.is_empty() {
122 return trimmed;
123 }
124 }
125 self.goal.trim()
126 }
127
128 pub fn source_path(&self) -> Option<&Path> {
130 self.source.as_deref()
131 }
132
133 fn context_text(&self) -> Option<String> {
134 self.context
135 .as_ref()
136 .map(|ctx| ctx.trim())
137 .filter(|ctx| !ctx.is_empty())
138 .map(|ctx| ctx.to_string())
139 }
140
141 fn formatted_list(&self, label: &str, items: &[String], number_items: bool) -> Option<String> {
142 let normalized = Self::normalized_items(items);
143 if normalized.is_empty() {
144 return None;
145 }
146
147 let formatted = normalized
148 .iter()
149 .enumerate()
150 .map(|(idx, item)| {
151 if number_items {
152 format!("{}. {}", idx + 1, item)
153 } else {
154 format!("- {}", item)
155 }
156 })
157 .collect::<Vec<_>>()
158 .join("\n");
159 Some(format!("{}:\n{}", label, formatted))
160 }
161
162 fn preview_list(&self, label: &str, items: &[String]) -> Option<String> {
163 let normalized = Self::normalized_items(items);
164 if normalized.is_empty() {
165 return None;
166 }
167
168 let mut lines = normalized
169 .iter()
170 .take(3)
171 .enumerate()
172 .map(|(idx, item)| format!(" {}. {}", idx + 1, item))
173 .collect::<Vec<_>>();
174
175 if normalized.len() > 3 {
176 lines.push(format!(" ... ({} more)", normalized.len() - 3));
177 }
178
179 Some(format!("{}:\n{}", label, lines.join("\n")))
180 }
181
182 fn context_preview(&self, max_lines: usize) -> Option<String> {
183 self.context_text().map(|ctx| {
184 let lines: Vec<&str> = ctx
185 .lines()
186 .map(str::trim)
187 .filter(|l| !l.is_empty())
188 .collect();
189 if lines.is_empty() {
190 return ctx;
191 }
192
193 lines
194 .into_iter()
195 .take(max_lines)
196 .collect::<Vec<_>>()
197 .join(" / ")
198 })
199 }
200
201 fn normalized_items(items: &[String]) -> Vec<String> {
202 items
203 .iter()
204 .map(|item| item.trim())
205 .filter(|item| !item.is_empty())
206 .map(|item| item.to_string())
207 .collect()
208 }
209
210 fn validate(&self) -> Result<()> {
211 if self.goal.trim().is_empty() {
212 bail!("spec goal must be provided");
213 }
214
215 let has_tasks = !Self::normalized_items(&self.tasks).is_empty();
216 let has_deliverables = !Self::normalized_items(&self.deliverables).is_empty();
217 if !has_tasks && !has_deliverables {
218 bail!("spec must include at least one task or deliverable");
219 }
220
221 Ok(())
222 }
223
224 fn is_spec_extension(path: &Path) -> bool {
225 path.extension()
226 .and_then(|ext| ext.to_str())
227 .map(|ext| ext.eq_ignore_ascii_case("spec"))
228 .unwrap_or(false)
229 }
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235
236 #[test]
237 fn parses_valid_spec_and_generates_prompt() {
238 let contents = r#"
239name = "Docs refresh"
240goal = "Update README to mention the new CLI command"
241context = "Ensure we mention the spec workflow."
242
243tasks = [
244 "Document the new command",
245 "Provide an example spec file"
246]
247
248deliverables = [
249 "README update summary"
250]
251 "#;
252
253 let spec = AgentSpec::from_str(contents).expect("spec should parse");
254 assert_eq!(spec.display_name(), "Docs refresh");
255 assert!(spec.preview().contains("Goal: Update README"));
256
257 let prompt = spec.to_prompt();
258 assert!(prompt.contains("Primary Goal"));
259 assert!(prompt.contains("Tasks"));
260 assert!(prompt.contains("Deliverables"));
261 }
262
263 #[test]
264 fn rejects_spec_without_goal() {
265 let contents = r#"
266tasks = ["Do the thing"]
267 "#;
268 let err = AgentSpec::from_str(contents).unwrap_err();
269 let message = format!("{:?}", err);
270 assert!(message.contains("goal"));
271 }
272
273 #[test]
274 fn rejects_spec_without_tasks_or_deliverables() {
275 let contents = r#"
276goal = "Just saying hi"
277 "#;
278 let err = AgentSpec::from_str(contents).unwrap_err();
279 assert!(format!("{}", err).contains("task"));
280 }
281}