1use std::collections::BTreeMap;
9use std::path::{Component, Path};
10use std::str::FromStr;
11
12use serde_json::Value;
13use turul_agent_skills::{Skill, load_str};
14
15use turul_a2a_proto::AgentSkill;
16
17use crate::error::{ManifestError, RenderError, ValidationError};
18use crate::schema::{compile_schema, validate_json};
19use crate::template::render_template;
20
21const ID: &str = "a2a-id";
22const NAME: &str = "a2a-name";
23const TAGS: &str = "a2a-tags";
24const EXAMPLES: &str = "a2a-examples";
25const INPUT_MODES: &str = "a2a-input-modes";
26const OUTPUT_MODES: &str = "a2a-output-modes";
27const SECURITY_REQUIREMENTS: &str = "a2a-security-requirements";
28const INPUT_SCHEMA: &str = "a2a-input-schema";
29const OUTPUT_SCHEMA: &str = "a2a-output-schema";
30const MAX_TOKENS: &str = "a2a-max-tokens";
31const TEMPERATURE: &str = "a2a-temperature";
32const TOP_P: &str = "a2a-top-p";
33const PROVIDER_CONFIG: &str = "a2a-provider-config";
34
35const KEYS: [&str; 13] = [
38 ID,
39 NAME,
40 TAGS,
41 EXAMPLES,
42 INPUT_MODES,
43 OUTPUT_MODES,
44 SECURITY_REQUIREMENTS,
45 INPUT_SCHEMA,
46 OUTPUT_SCHEMA,
47 MAX_TOKENS,
48 TEMPERATURE,
49 TOP_P,
50 PROVIDER_CONFIG,
51];
52
53const PREFIX: &str = "a2a-";
54
55#[derive(Debug, Clone)]
70#[non_exhaustive]
71pub struct SkillCard {
72 pub id: String,
74 pub name: String,
75 pub description: String,
76 pub tags: Vec<String>,
77 pub examples: Vec<String>,
78 pub input_modes: Vec<String>,
79 pub output_modes: Vec<String>,
80 pub security_requirements: Vec<String>,
81
82 pub input_schema: Option<Value>,
84 pub output_schema: Option<Value>,
85 pub execution_hints: Option<ExecutionHints>,
86
87 pub provider_config: Option<Value>,
90
91 pub body: String,
93}
94
95#[derive(Debug, Clone, Default)]
97#[non_exhaustive]
98pub struct ExecutionHints {
99 pub max_tokens: Option<u32>,
100 pub temperature: Option<f32>,
101 pub top_p: Option<f32>,
102}
103
104impl SkillCard {
105 pub fn from_skill(
123 skill: &Skill,
124 assets: impl Fn(&str) -> Option<String>,
125 ) -> Result<Self, ManifestError> {
126 let metadata = &skill.properties.metadata;
127 if let Some(key) = metadata
128 .keys()
129 .find(|key| key.starts_with(PREFIX) && !KEYS.contains(&key.as_str()))
130 {
131 return Err(parse_error(key, "unknown metadata key"));
132 }
133
134 let input_schema = asset(metadata, INPUT_SCHEMA, &assets)?;
135 let output_schema = asset(metadata, OUTPUT_SCHEMA, &assets)?;
136 for (schema, key) in [
137 (&input_schema, INPUT_SCHEMA),
138 (&output_schema, OUTPUT_SCHEMA),
139 ] {
140 if let Some(schema) = schema {
141 compile_schema(schema).map_err(|e| ManifestError::Schema {
142 location: location(key),
143 reason: e.to_string(),
144 })?;
145 }
146 }
147
148 let hints = ExecutionHints {
149 max_tokens: number(metadata, MAX_TOKENS)?,
150 temperature: number(metadata, TEMPERATURE)?,
151 top_p: number(metadata, TOP_P)?,
152 };
153 let execution_hints =
154 (hints.max_tokens.is_some() || hints.temperature.is_some() || hints.top_p.is_some())
155 .then_some(hints);
156
157 let name = skill.properties.name.as_str();
158 Ok(SkillCard {
159 id: metadata.get(ID).map_or(name, String::as_str).to_string(),
160 name: metadata.get(NAME).map_or(name, String::as_str).to_string(),
161 description: skill.properties.description.clone(),
162 tags: list(metadata, TAGS)?,
163 examples: list(metadata, EXAMPLES)?,
164 input_modes: list(metadata, INPUT_MODES)?,
165 output_modes: list(metadata, OUTPUT_MODES)?,
166 security_requirements: list(metadata, SECURITY_REQUIREMENTS)?,
167 input_schema,
168 output_schema,
169 execution_hints,
170 provider_config: asset(metadata, PROVIDER_CONFIG, &assets)?,
171 body: skill.body.clone(),
172 })
173 }
174
175 pub fn from_embedded(
185 dir_name: &str,
186 skill_md: &str,
187 assets: &[(&str, &str)],
188 ) -> Result<Self, ManifestError> {
189 let loaded = load_str(dir_name, skill_md).map_err(|skip| ManifestError::Parse {
190 location: "SKILL.md".to_string(),
191 reason: skip.to_string(),
192 })?;
193 if let Some(problem) = loaded.diagnostics.first() {
194 return Err(ManifestError::Parse {
195 location: "SKILL.md".to_string(),
196 reason: problem.message().to_string(),
197 });
198 }
199 SkillCard::from_skill(&loaded.skill, |path| {
200 assets
201 .iter()
202 .find(|(asset, _)| *asset == path)
203 .map(|(_, text)| text.to_string())
204 })
205 }
206
207 pub fn to_agent_skill(&self) -> AgentSkill {
215 AgentSkill {
216 id: self.id.clone(),
217 name: self.name.clone(),
218 description: self.description.clone(),
219 tags: self.tags.clone(),
220 examples: self.examples.clone(),
221 input_modes: self.input_modes.clone(),
222 output_modes: self.output_modes.clone(),
223 security_requirements: self
228 .security_requirements
229 .iter()
230 .map(|scheme| {
231 let mut schemes = std::collections::HashMap::new();
232 schemes.insert(scheme.clone(), turul_a2a_proto::StringList { list: vec![] });
233 turul_a2a_proto::SecurityRequirement { schemes }
234 })
235 .collect(),
236 }
237 }
238
239 pub fn render_prompt(&self, params: &Value) -> Result<String, RenderError> {
245 render_template(&self.body, params)
246 }
247
248 pub fn validate_input(&self, input: &Value) -> Result<(), ValidationError> {
253 match &self.input_schema {
254 Some(schema) => validate_json(schema, input),
255 None => Ok(()),
256 }
257 }
258
259 pub fn validate_output(&self, output: &Value) -> Result<(), ValidationError> {
264 match &self.output_schema {
265 Some(schema) => validate_json(schema, output),
266 None => Ok(()),
267 }
268 }
269}
270
271impl TryFrom<&Skill> for SkillCard {
277 type Error = ManifestError;
278
279 fn try_from(skill: &Skill) -> Result<Self, ManifestError> {
280 let dir = skill.location().and_then(Path::parent);
281 SkillCard::from_skill(skill, |path| {
282 let dir = dir?;
283 let file = dir.join(path).canonicalize().ok()?;
284 if !file.starts_with(dir) {
285 return None;
286 }
287 std::fs::read_to_string(file).ok()
288 })
289 }
290}
291
292fn location(key: &str) -> String {
293 format!("metadata.{key}")
294}
295
296fn parse_error(key: &str, reason: impl Into<String>) -> ManifestError {
297 ManifestError::Parse {
298 location: location(key),
299 reason: reason.into(),
300 }
301}
302
303fn list(metadata: &BTreeMap<String, String>, key: &str) -> Result<Vec<String>, ManifestError> {
304 match metadata.get(key) {
305 Some(value) => serde_json::from_str(value)
306 .map_err(|e| parse_error(key, format!("expected a JSON array of strings: {e}"))),
307 None => Ok(Vec::new()),
308 }
309}
310
311fn number<T: FromStr>(
312 metadata: &BTreeMap<String, String>,
313 key: &str,
314) -> Result<Option<T>, ManifestError>
315where
316 T::Err: std::fmt::Display,
317{
318 metadata
319 .get(key)
320 .map(|value| {
321 value
322 .parse()
323 .map_err(|e| parse_error(key, format!("expected a number: {e}")))
324 })
325 .transpose()
326}
327
328fn asset(
329 metadata: &BTreeMap<String, String>,
330 key: &str,
331 assets: &impl Fn(&str) -> Option<String>,
332) -> Result<Option<Value>, ManifestError> {
333 let Some(path) = metadata.get(key) else {
334 return Ok(None);
335 };
336 let inside = Path::new(path)
337 .components()
338 .all(|c| matches!(c, Component::Normal(_)));
339 if path.is_empty() || !inside {
340 return Err(parse_error(
341 key,
342 format!("`{path}` is not a relative path inside the skill directory"),
343 ));
344 }
345 let text = assets(path).ok_or_else(|| parse_error(key, format!("asset `{path}` not found")))?;
346 serde_json::from_str(&text)
347 .map(Some)
348 .map_err(|e| parse_error(key, format!("asset `{path}` is not JSON: {e}")))
349}