spec_ai_core/
spec.rs

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