use std::collections::BTreeSet;
use chio_core::capability::scope::MonetaryAmount;
use serde::{Deserialize, Serialize};
pub const SKILL_MANIFEST_SCHEMA: &str = "chio.skill-manifest.v1";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillManifest {
pub schema: String,
pub skill_id: String,
pub version: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub steps: Vec<SkillStep>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub budget_envelope: Option<MonetaryAmount>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_duration_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
}
impl SkillManifest {
pub fn new(skill_id: String, version: String, name: String, steps: Vec<SkillStep>) -> Self {
Self {
schema: SKILL_MANIFEST_SCHEMA.to_string(),
skill_id,
version,
name,
description: None,
steps,
budget_envelope: None,
max_duration_secs: None,
author: None,
}
}
#[must_use]
pub fn step_count(&self) -> usize {
self.steps.len()
}
#[must_use]
pub fn tool_dependencies(&self) -> Vec<String> {
self.steps
.iter()
.map(|s| format!("{}:{}", s.server_id, s.tool_name))
.collect()
}
pub fn validate_io_contracts(&self) -> Result<(), String> {
let mut available_outputs: BTreeSet<&str> = BTreeSet::new();
for (idx, step) in self.steps.iter().enumerate() {
if let Some(ref input) = step.input_contract {
for required in &input.required_fields {
if idx > 0 && !available_outputs.contains(required.as_str()) {
return Err(format!(
"step {} ({}) requires input field '{}' not produced by any preceding step",
idx, step.tool_name, required
));
}
}
}
if let Some(ref output) = step.output_contract {
for field in &output.produced_fields {
available_outputs.insert(field);
}
}
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillStep {
pub index: usize,
pub server_id: String,
pub tool_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_contract: Option<IoContract>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_contract: Option<IoContract>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub budget_limit: Option<MonetaryAmount>,
#[serde(default)]
pub retryable: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_retries: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IoContract {
#[serde(default)]
pub required_fields: Vec<String>,
#[serde(default)]
pub produced_fields: Vec<String>,
#[serde(default)]
pub optional_fields: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub json_schema: Option<serde_json::Value>,
}
#[cfg(test)]
mod tests {
use super::*;
fn make_step(
index: usize,
server: &str,
tool: &str,
inputs: Vec<&str>,
outputs: Vec<&str>,
) -> SkillStep {
SkillStep {
index,
server_id: server.to_string(),
tool_name: tool.to_string(),
label: None,
input_contract: if inputs.is_empty() {
None
} else {
Some(IoContract {
required_fields: inputs.iter().map(|s| s.to_string()).collect(),
produced_fields: vec![],
optional_fields: vec![],
json_schema: None,
})
},
output_contract: if outputs.is_empty() {
None
} else {
Some(IoContract {
required_fields: vec![],
produced_fields: outputs.iter().map(|s| s.to_string()).collect(),
optional_fields: vec![],
json_schema: None,
})
},
budget_limit: None,
retryable: false,
max_retries: None,
}
}
#[test]
fn manifest_roundtrip() {
let manifest = SkillManifest::new(
"search-summarize".to_string(),
"1.0.0".to_string(),
"Search and Summarize".to_string(),
vec![
make_step(0, "search-srv", "search", vec![], vec!["results"]),
make_step(1, "llm-srv", "summarize", vec!["results"], vec!["summary"]),
],
);
let json = serde_json::to_string(&manifest).unwrap();
let deserialized: SkillManifest = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.skill_id, "search-summarize");
assert_eq!(deserialized.step_count(), 2);
}
#[test]
fn tool_dependencies() {
let manifest = SkillManifest::new(
"s1".to_string(),
"1.0".to_string(),
"S1".to_string(),
vec![
make_step(0, "a", "t1", vec![], vec![]),
make_step(1, "b", "t2", vec![], vec![]),
],
);
let deps = manifest.tool_dependencies();
assert_eq!(deps, vec!["a:t1", "b:t2"]);
}
#[test]
fn valid_io_contracts() {
let manifest = SkillManifest::new(
"s1".to_string(),
"1.0".to_string(),
"S1".to_string(),
vec![
make_step(0, "a", "t1", vec![], vec!["data"]),
make_step(1, "b", "t2", vec!["data"], vec!["result"]),
],
);
assert!(manifest.validate_io_contracts().is_ok());
}
#[test]
fn invalid_io_contracts() {
let manifest = SkillManifest::new(
"s1".to_string(),
"1.0".to_string(),
"S1".to_string(),
vec![
make_step(0, "a", "t1", vec![], vec!["data"]),
make_step(1, "b", "t2", vec!["missing_field"], vec!["result"]),
],
);
let result = manifest.validate_io_contracts();
assert!(result.is_err());
let err = result.err().unwrap();
assert!(err.contains("missing_field"));
}
#[test]
fn first_step_inputs_are_not_validated() {
let manifest = SkillManifest::new(
"s1".to_string(),
"1.0".to_string(),
"S1".to_string(),
vec![make_step(0, "a", "t1", vec!["query"], vec!["data"])],
);
assert!(manifest.validate_io_contracts().is_ok());
}
}