use std::collections::BTreeMap;
use std::path::{Component, Path};
use std::str::FromStr;
use serde_json::Value;
use turul_agent_skills::{Skill, load_str};
use turul_a2a_proto::AgentSkill;
use crate::error::{ManifestError, RenderError, ValidationError};
use crate::schema::{compile_schema, validate_json};
use crate::template::render_template;
const ID: &str = "a2a-id";
const NAME: &str = "a2a-name";
const TAGS: &str = "a2a-tags";
const EXAMPLES: &str = "a2a-examples";
const INPUT_MODES: &str = "a2a-input-modes";
const OUTPUT_MODES: &str = "a2a-output-modes";
const SECURITY_REQUIREMENTS: &str = "a2a-security-requirements";
const INPUT_SCHEMA: &str = "a2a-input-schema";
const OUTPUT_SCHEMA: &str = "a2a-output-schema";
const MAX_TOKENS: &str = "a2a-max-tokens";
const TEMPERATURE: &str = "a2a-temperature";
const TOP_P: &str = "a2a-top-p";
const PROVIDER_CONFIG: &str = "a2a-provider-config";
const KEYS: [&str; 13] = [
ID,
NAME,
TAGS,
EXAMPLES,
INPUT_MODES,
OUTPUT_MODES,
SECURITY_REQUIREMENTS,
INPUT_SCHEMA,
OUTPUT_SCHEMA,
MAX_TOKENS,
TEMPERATURE,
TOP_P,
PROVIDER_CONFIG,
];
const PREFIX: &str = "a2a-";
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct SkillCard {
pub id: String,
pub name: String,
pub description: String,
pub tags: Vec<String>,
pub examples: Vec<String>,
pub input_modes: Vec<String>,
pub output_modes: Vec<String>,
pub security_requirements: Vec<String>,
pub input_schema: Option<Value>,
pub output_schema: Option<Value>,
pub execution_hints: Option<ExecutionHints>,
pub provider_config: Option<Value>,
pub body: String,
}
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct ExecutionHints {
pub max_tokens: Option<u32>,
pub temperature: Option<f32>,
pub top_p: Option<f32>,
}
impl SkillCard {
pub fn from_skill(
skill: &Skill,
assets: impl Fn(&str) -> Option<String>,
) -> Result<Self, ManifestError> {
let metadata = &skill.properties.metadata;
if let Some(key) = metadata
.keys()
.find(|key| key.starts_with(PREFIX) && !KEYS.contains(&key.as_str()))
{
return Err(parse_error(key, "unknown metadata key"));
}
let input_schema = asset(metadata, INPUT_SCHEMA, &assets)?;
let output_schema = asset(metadata, OUTPUT_SCHEMA, &assets)?;
for (schema, key) in [
(&input_schema, INPUT_SCHEMA),
(&output_schema, OUTPUT_SCHEMA),
] {
if let Some(schema) = schema {
compile_schema(schema).map_err(|e| ManifestError::Schema {
location: location(key),
reason: e.to_string(),
})?;
}
}
let hints = ExecutionHints {
max_tokens: number(metadata, MAX_TOKENS)?,
temperature: number(metadata, TEMPERATURE)?,
top_p: number(metadata, TOP_P)?,
};
let execution_hints =
(hints.max_tokens.is_some() || hints.temperature.is_some() || hints.top_p.is_some())
.then_some(hints);
let name = skill.properties.name.as_str();
Ok(SkillCard {
id: metadata.get(ID).map_or(name, String::as_str).to_string(),
name: metadata.get(NAME).map_or(name, String::as_str).to_string(),
description: skill.properties.description.clone(),
tags: list(metadata, TAGS)?,
examples: list(metadata, EXAMPLES)?,
input_modes: list(metadata, INPUT_MODES)?,
output_modes: list(metadata, OUTPUT_MODES)?,
security_requirements: list(metadata, SECURITY_REQUIREMENTS)?,
input_schema,
output_schema,
execution_hints,
provider_config: asset(metadata, PROVIDER_CONFIG, &assets)?,
body: skill.body.clone(),
})
}
pub fn from_embedded(
dir_name: &str,
skill_md: &str,
assets: &[(&str, &str)],
) -> Result<Self, ManifestError> {
let loaded = load_str(dir_name, skill_md).map_err(|skip| ManifestError::Parse {
location: "SKILL.md".to_string(),
reason: skip.to_string(),
})?;
if let Some(problem) = loaded.diagnostics.first() {
return Err(ManifestError::Parse {
location: "SKILL.md".to_string(),
reason: problem.message().to_string(),
});
}
SkillCard::from_skill(&loaded.skill, |path| {
assets
.iter()
.find(|(asset, _)| *asset == path)
.map(|(_, text)| text.to_string())
})
}
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> {
match &self.input_schema {
Some(schema) => validate_json(schema, input),
None => Ok(()),
}
}
pub fn validate_output(&self, output: &Value) -> Result<(), ValidationError> {
match &self.output_schema {
Some(schema) => validate_json(schema, output),
None => Ok(()),
}
}
}
impl TryFrom<&Skill> for SkillCard {
type Error = ManifestError;
fn try_from(skill: &Skill) -> Result<Self, ManifestError> {
let dir = skill.location().and_then(Path::parent);
SkillCard::from_skill(skill, |path| {
let dir = dir?;
let file = dir.join(path).canonicalize().ok()?;
if !file.starts_with(dir) {
return None;
}
std::fs::read_to_string(file).ok()
})
}
}
fn location(key: &str) -> String {
format!("metadata.{key}")
}
fn parse_error(key: &str, reason: impl Into<String>) -> ManifestError {
ManifestError::Parse {
location: location(key),
reason: reason.into(),
}
}
fn list(metadata: &BTreeMap<String, String>, key: &str) -> Result<Vec<String>, ManifestError> {
match metadata.get(key) {
Some(value) => serde_json::from_str(value)
.map_err(|e| parse_error(key, format!("expected a JSON array of strings: {e}"))),
None => Ok(Vec::new()),
}
}
fn number<T: FromStr>(
metadata: &BTreeMap<String, String>,
key: &str,
) -> Result<Option<T>, ManifestError>
where
T::Err: std::fmt::Display,
{
metadata
.get(key)
.map(|value| {
value
.parse()
.map_err(|e| parse_error(key, format!("expected a number: {e}")))
})
.transpose()
}
fn asset(
metadata: &BTreeMap<String, String>,
key: &str,
assets: &impl Fn(&str) -> Option<String>,
) -> Result<Option<Value>, ManifestError> {
let Some(path) = metadata.get(key) else {
return Ok(None);
};
let inside = Path::new(path)
.components()
.all(|c| matches!(c, Component::Normal(_)));
if path.is_empty() || !inside {
return Err(parse_error(
key,
format!("`{path}` is not a relative path inside the skill directory"),
));
}
let text = assets(path).ok_or_else(|| parse_error(key, format!("asset `{path}` not found")))?;
serde_json::from_str(&text)
.map(Some)
.map_err(|e| parse_error(key, format!("asset `{path}` is not JSON: {e}")))
}