1use std::collections::BTreeSet;
8
9use chio_core::capability::scope::MonetaryAmount;
10use serde::{Deserialize, Serialize};
11
12pub const SKILL_MANIFEST_SCHEMA: &str = "chio.skill-manifest.v1";
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct SkillManifest {
18 pub schema: String,
20
21 pub skill_id: String,
23
24 pub version: String,
26
27 pub name: String,
29
30 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub description: Option<String>,
33
34 pub steps: Vec<SkillStep>,
36
37 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub budget_envelope: Option<MonetaryAmount>,
40
41 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub max_duration_secs: Option<u64>,
44
45 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub author: Option<String>,
48}
49
50impl SkillManifest {
51 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 #[must_use]
68 pub fn step_count(&self) -> usize {
69 self.steps.len()
70 }
71
72 #[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 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#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct SkillStep {
114 pub index: usize,
116
117 pub server_id: String,
119
120 pub tool_name: String,
122
123 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub label: Option<String>,
126
127 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub input_contract: Option<IoContract>,
130
131 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub output_contract: Option<IoContract>,
134
135 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub budget_limit: Option<MonetaryAmount>,
138
139 #[serde(default)]
141 pub retryable: bool,
142
143 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub max_retries: Option<u32>,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct IoContract {
151 #[serde(default)]
153 pub required_fields: Vec<String>,
154
155 #[serde(default)]
158 pub produced_fields: Vec<String>,
159
160 #[serde(default)]
162 pub optional_fields: Vec<String>,
163
164 #[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 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}