use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use turul_a2a_proto::AgentSkill;
use crate::error::{ManifestError, RenderError, ValidationError};
use crate::schema::strict_keyword_check;
use crate::template::render_template;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[non_exhaustive]
pub struct SkillCard {
pub id: String,
pub name: String,
pub description: String,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub examples: Vec<String>,
#[serde(default)]
pub input_modes: Vec<String>,
#[serde(default)]
pub output_modes: Vec<String>,
#[serde(default)]
pub security_requirements: Vec<String>,
#[serde(default)]
pub input_schema: Option<Value>,
#[serde(default)]
pub output_schema: Option<Value>,
#[serde(default)]
pub execution_hints: Option<ExecutionHints>,
#[serde(default)]
pub provider_config: Option<Value>,
#[serde(skip)]
pub body: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[non_exhaustive]
pub struct ExecutionHints {
#[serde(default)]
pub max_tokens: Option<u32>,
#[serde(default)]
pub temperature: Option<f32>,
#[serde(default)]
pub top_p: Option<f32>,
}
fn yaml_to_json(value: serde_yaml::Value) -> Result<Value, ManifestError> {
use serde_yaml::Value as Y;
Ok(match value {
Y::Null => Value::Null,
Y::Bool(b) => Value::Bool(b),
Y::Number(n) => {
if let Some(i) = n.as_i64() {
Value::from(i)
} else if let Some(u) = n.as_u64() {
Value::from(u)
} else if let Some(f) = n.as_f64() {
serde_json::Number::from_f64(f)
.map(Value::Number)
.unwrap_or(Value::Null)
} else {
Value::Null
}
}
Y::String(s) => Value::String(s),
Y::Sequence(seq) => Value::Array(
seq.into_iter()
.map(yaml_to_json)
.collect::<Result<Vec<_>, _>>()?,
),
Y::Mapping(map) => {
let mut obj = Map::new();
for (k, v) in map {
let key = match k {
Y::String(s) => s,
other => {
return Err(ManifestError::Parse {
location: "frontmatter".to_string(),
reason: format!("non-string mapping key: {other:?}"),
});
}
};
obj.insert(key, yaml_to_json(v)?);
}
Value::Object(obj)
}
Y::Tagged(tagged) => yaml_to_json(tagged.value)?,
})
}
impl SkillCard {
pub fn parse(text: &str) -> Result<Self, ManifestError> {
let (frontmatter, body) = split_frontmatter(text).ok_or(ManifestError::Parse {
location: "document".to_string(),
reason: "missing `---` frontmatter delimiters".to_string(),
})?;
let yaml: serde_yaml::Value =
serde_yaml::from_str(frontmatter).map_err(|e| ManifestError::Parse {
location: "frontmatter".to_string(),
reason: e.to_string(),
})?;
let json = yaml_to_json(yaml)?;
let mut card: SkillCard =
serde_json::from_value(json).map_err(|e| ManifestError::Parse {
location: "frontmatter".to_string(),
reason: e.to_string(),
})?;
card.body = body.to_string();
if let Some(schema) = &card.input_schema {
strict_keyword_check(schema, "/inputSchema").map_err(|e| match e {
ValidationError::UnsupportedKeyword { keyword } => ManifestError::Schema {
location: "/inputSchema".to_string(),
reason: format!("unsupported JSON Schema keyword `{keyword}`"),
},
ValidationError::Invalid { location, reason } => {
ManifestError::Schema { location, reason }
}
})?;
}
if let Some(schema) = &card.output_schema {
strict_keyword_check(schema, "/outputSchema").map_err(|e| match e {
ValidationError::UnsupportedKeyword { keyword } => ManifestError::Schema {
location: "/outputSchema".to_string(),
reason: format!("unsupported JSON Schema keyword `{keyword}`"),
},
ValidationError::Invalid { location, reason } => {
ManifestError::Schema { location, reason }
}
})?;
}
Ok(card)
}
pub fn to_agent_skill(&self) -> AgentSkill {
AgentSkill {
id: self.id.clone(),
name: self.name.clone(),
description: self.description.clone(),
tags: self.tags.clone(),
examples: self.examples.clone(),
input_modes: self.input_modes.clone(),
output_modes: self.output_modes.clone(),
security_requirements: self
.security_requirements
.iter()
.map(|scheme| {
let mut schemes = std::collections::HashMap::new();
schemes.insert(scheme.clone(), turul_a2a_proto::StringList { list: vec![] });
turul_a2a_proto::SecurityRequirement { schemes }
})
.collect(),
}
}
pub fn render_prompt(&self, params: &Value) -> Result<String, RenderError> {
render_template(&self.body, params)
}
pub fn validate_input(&self, input: &Value) -> Result<(), ValidationError> {
validate_against(&self.input_schema, input, "/inputSchema")
}
pub fn validate_output(&self, output: &Value) -> Result<(), ValidationError> {
validate_against(&self.output_schema, output, "/outputSchema")
}
}
fn validate_against(
schema: &Option<Value>,
instance: &Value,
location_root: &str,
) -> Result<(), ValidationError> {
let Some(schema) = schema else {
return Ok(());
};
strict_keyword_check(schema, location_root)?;
let validator = jsonschema::draft202012::new(schema).map_err(|e| ValidationError::Invalid {
location: location_root.to_string(),
reason: format!("invalid schema: {e}"),
})?;
if let Some(err) = validator.iter_errors(instance).next() {
return Err(ValidationError::Invalid {
location: err.instance_path().to_string(),
reason: err.to_string(),
});
}
Ok(())
}
fn split_frontmatter(text: &str) -> Option<(&str, &str)> {
let rest = text.strip_prefix("---")?;
let after_open_nl = rest.find('\n')?;
let body_start = &rest[after_open_nl + 1..];
let close = body_start.find("\n---")?;
let frontmatter = &body_start[..close];
let after_close = &body_start[close + 4..]; let body = after_close.strip_prefix('\n').unwrap_or(after_close);
Some((frontmatter, body))
}