Skip to main content

spec_ai/spec_ai_core/
spec.rs

1use anyhow::{Context, Result, bail};
2use serde::Deserialize;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6/// Structured spec describing a full agent run.
7#[derive(Debug, Clone, Deserialize)]
8pub struct AgentSpec {
9    /// Optional friendly name for the spec.
10    pub name: Option<String>,
11    /// Primary objective for the run (required).
12    pub goal: String,
13    /// Additional background/context for the task.
14    pub context: Option<String>,
15    /// Ordered tasks the agent should complete.
16    #[serde(default)]
17    pub tasks: Vec<String>,
18    /// Expected outputs for the run.
19    #[serde(default)]
20    pub deliverables: Vec<String>,
21    /// Constraints/guardrails the agent should respect.
22    #[serde(default)]
23    pub constraints: Vec<String>,
24    /// Source path for this spec when loaded from disk.
25    #[serde(skip)]
26    source: Option<PathBuf>,
27}
28
29impl AgentSpec {
30    /// Load a spec from a `.spec` TOML file.
31    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    /// Parse a spec from TOML content.
51    #[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    /// Convert the structured spec into a model prompt.
59    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(&sections.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    /// Short textual preview for CLI output.
94    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    /// Display-friendly name for this spec.
118    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    /// Source path if loaded from disk.
129    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}