Skip to main content

chio_workflow/
manifest.rs

1//! Skill manifests describing tool dependencies, I/O contracts, and budgets.
2//!
3//! A `SkillManifest` is authored by the skill developer and declares the
4//! tools the skill needs, the order they run, what data flows between steps,
5//! and the budget envelope required for execution.
6
7use std::collections::BTreeSet;
8
9use chio_core::capability::scope::MonetaryAmount;
10use serde::{Deserialize, Serialize};
11
12/// Schema identifier for skill manifests.
13pub const SKILL_MANIFEST_SCHEMA: &str = "chio.skill-manifest.v1";
14
15/// A skill manifest describing a multi-step tool composition.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct SkillManifest {
18    /// Schema version.
19    pub schema: String,
20
21    /// Unique skill identifier.
22    pub skill_id: String,
23
24    /// Semantic version of the skill.
25    pub version: String,
26
27    /// Human-readable name.
28    pub name: String,
29
30    /// Human-readable description.
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub description: Option<String>,
33
34    /// Ordered steps in the skill.
35    pub steps: Vec<SkillStep>,
36
37    /// Budget envelope for a single execution.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub budget_envelope: Option<MonetaryAmount>,
40
41    /// Maximum wall-clock duration (seconds) for the entire skill.
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub max_duration_secs: Option<u64>,
44
45    /// Author identifier.
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub author: Option<String>,
48}
49
50impl SkillManifest {
51    /// Create a new skill manifest.
52    pub fn new(skill_id: String, version: String, name: String, steps: Vec<SkillStep>) -> Self {
53        Self {
54            schema: SKILL_MANIFEST_SCHEMA.to_string(),
55            skill_id,
56            version,
57            name,
58            description: None,
59            steps,
60            budget_envelope: None,
61            max_duration_secs: None,
62            author: None,
63        }
64    }
65
66    /// Return the total number of steps.
67    #[must_use]
68    pub fn step_count(&self) -> usize {
69        self.steps.len()
70    }
71
72    /// Get the list of tool dependencies as "server_id:tool_name".
73    #[must_use]
74    pub fn tool_dependencies(&self) -> Vec<String> {
75        self.steps
76            .iter()
77            .map(|s| format!("{}:{}", s.server_id, s.tool_name))
78            .collect()
79    }
80
81    /// Validate that all step I/O contracts are consistent.
82    ///
83    /// Each step's required inputs (except the first) must be
84    /// produced by a preceding step's outputs.
85    pub fn validate_io_contracts(&self) -> Result<(), String> {
86        let mut available_outputs: BTreeSet<&str> = BTreeSet::new();
87
88        for (idx, step) in self.steps.iter().enumerate() {
89            if let Some(ref input) = step.input_contract {
90                for required in &input.required_fields {
91                    if idx > 0 && !available_outputs.contains(required.as_str()) {
92                        return Err(format!(
93                            "step {} ({}) requires input field '{}' not produced by any preceding step",
94                            idx, step.tool_name, required
95                        ));
96                    }
97                }
98            }
99
100            if let Some(ref output) = step.output_contract {
101                for field in &output.produced_fields {
102                    available_outputs.insert(field);
103                }
104            }
105        }
106
107        Ok(())
108    }
109}
110
111/// A single step in a skill execution.
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct SkillStep {
114    /// Step index (0-based, for ordering).
115    pub index: usize,
116
117    /// Tool server hosting this step's tool.
118    pub server_id: String,
119
120    /// Tool to invoke in this step.
121    pub tool_name: String,
122
123    /// Human-readable step label.
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub label: Option<String>,
126
127    /// Input contract for this step.
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub input_contract: Option<IoContract>,
130
131    /// Output contract for this step.
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub output_contract: Option<IoContract>,
134
135    /// Per-step budget limit.
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub budget_limit: Option<MonetaryAmount>,
138
139    /// Whether this step can be retried on failure.
140    #[serde(default)]
141    pub retryable: bool,
142
143    /// Maximum number of retries (only relevant if retryable is true).
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub max_retries: Option<u32>,
146}
147
148/// I/O contract describing what data a step consumes or produces.
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct IoContract {
151    /// Field names required by the step (for inputs) or guaranteed (for outputs).
152    #[serde(default)]
153    pub required_fields: Vec<String>,
154
155    /// Field names produced by this step (used for output contracts to track
156    /// data flow to subsequent steps).
157    #[serde(default)]
158    pub produced_fields: Vec<String>,
159
160    /// Optional field names.
161    #[serde(default)]
162    pub optional_fields: Vec<String>,
163
164    /// JSON Schema for the data, if available.
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub json_schema: Option<serde_json::Value>,
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    fn make_step(
174        index: usize,
175        server: &str,
176        tool: &str,
177        inputs: Vec<&str>,
178        outputs: Vec<&str>,
179    ) -> SkillStep {
180        SkillStep {
181            index,
182            server_id: server.to_string(),
183            tool_name: tool.to_string(),
184            label: None,
185            input_contract: if inputs.is_empty() {
186                None
187            } else {
188                Some(IoContract {
189                    required_fields: inputs.iter().map(|s| s.to_string()).collect(),
190                    produced_fields: vec![],
191                    optional_fields: vec![],
192                    json_schema: None,
193                })
194            },
195            output_contract: if outputs.is_empty() {
196                None
197            } else {
198                Some(IoContract {
199                    required_fields: vec![],
200                    produced_fields: outputs.iter().map(|s| s.to_string()).collect(),
201                    optional_fields: vec![],
202                    json_schema: None,
203                })
204            },
205            budget_limit: None,
206            retryable: false,
207            max_retries: None,
208        }
209    }
210
211    #[test]
212    fn manifest_roundtrip() {
213        let manifest = SkillManifest::new(
214            "search-summarize".to_string(),
215            "1.0.0".to_string(),
216            "Search and Summarize".to_string(),
217            vec![
218                make_step(0, "search-srv", "search", vec![], vec!["results"]),
219                make_step(1, "llm-srv", "summarize", vec!["results"], vec!["summary"]),
220            ],
221        );
222
223        let json = serde_json::to_string(&manifest).unwrap();
224        let deserialized: SkillManifest = serde_json::from_str(&json).unwrap();
225        assert_eq!(deserialized.skill_id, "search-summarize");
226        assert_eq!(deserialized.step_count(), 2);
227    }
228
229    #[test]
230    fn tool_dependencies() {
231        let manifest = SkillManifest::new(
232            "s1".to_string(),
233            "1.0".to_string(),
234            "S1".to_string(),
235            vec![
236                make_step(0, "a", "t1", vec![], vec![]),
237                make_step(1, "b", "t2", vec![], vec![]),
238            ],
239        );
240        let deps = manifest.tool_dependencies();
241        assert_eq!(deps, vec!["a:t1", "b:t2"]);
242    }
243
244    #[test]
245    fn valid_io_contracts() {
246        let manifest = SkillManifest::new(
247            "s1".to_string(),
248            "1.0".to_string(),
249            "S1".to_string(),
250            vec![
251                make_step(0, "a", "t1", vec![], vec!["data"]),
252                make_step(1, "b", "t2", vec!["data"], vec!["result"]),
253            ],
254        );
255        assert!(manifest.validate_io_contracts().is_ok());
256    }
257
258    #[test]
259    fn invalid_io_contracts() {
260        let manifest = SkillManifest::new(
261            "s1".to_string(),
262            "1.0".to_string(),
263            "S1".to_string(),
264            vec![
265                make_step(0, "a", "t1", vec![], vec!["data"]),
266                make_step(1, "b", "t2", vec!["missing_field"], vec!["result"]),
267            ],
268        );
269        let result = manifest.validate_io_contracts();
270        assert!(result.is_err());
271        let err = result.err().unwrap();
272        assert!(err.contains("missing_field"));
273    }
274
275    #[test]
276    fn first_step_inputs_are_not_validated() {
277        // First step inputs come from the caller, not from preceding steps
278        let manifest = SkillManifest::new(
279            "s1".to_string(),
280            "1.0".to_string(),
281            "S1".to_string(),
282            vec![make_step(0, "a", "t1", vec!["query"], vec!["data"])],
283        );
284        assert!(manifest.validate_io_contracts().is_ok());
285    }
286}