Skip to main content

turul_a2a_patterns/
manifest.rs

1//! `SkillCard` projection of an Agent Skills `SKILL.md` and its helpers.
2//!
3//! Hosts the `SkillCard` struct, `ExecutionHints`, the projection from a
4//! loaded [`Skill`] (its `a2a-*` metadata keys and `assets/*.json` files),
5//! and the `to_agent_skill` / `render_prompt` / `validate_input` /
6//! `validate_output` methods.
7
8use 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
35/// Every metadata key this crate reads. Any other key with the `a2a-`
36/// prefix is rejected, so a misspelt key fails instead of being ignored.
37const 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/// The A2A view of an Agent Skills `SKILL.md`.
56///
57/// The skill's spec `name` is its catalog identity; `metadata.a2a-id`, when
58/// present, is the wire id (`AgentSkill.id`) and otherwise the `name` is.
59/// The other A2A fields come from `a2a-*` metadata keys, and the schemas and
60/// provider config from JSON files under the skill directory. LLM-backed
61/// skills supply execution hints and provider config; non-LLM skills omit
62/// them. The Markdown body is the prompt template (used only by LLM-backed
63/// skills); template grammar is `{{ name }}` with dotted paths.
64///
65/// [`SkillCard::from_embedded`], [`SkillCard::from_skill`] and
66/// `TryFrom<&Skill>` are the only constructors: the struct is `#[non_exhaustive]` and deliberately
67/// implements neither `Deserialize` nor `Default`, so every card has passed
68/// the metadata, strict-keyword and schema-compilation checks.
69#[derive(Debug, Clone)]
70#[non_exhaustive]
71pub struct SkillCard {
72    // AgentSkill discovery fields (all eight).
73    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    // Provider-neutral execution metadata (optional).
83    pub input_schema: Option<Value>,
84    pub output_schema: Option<Value>,
85    pub execution_hints: Option<ExecutionHints>,
86
87    // Opaque to this crate — the patterns layer does not interpret
88    // provider-specific configuration; that is the adopter's concern.
89    pub provider_config: Option<Value>,
90
91    /// Markdown body (prompt template for LLM-backed skills).
92    pub body: String,
93}
94
95/// Provider-neutral execution hints.
96#[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    /// Project a loaded skill onto a `SkillCard`, reading asset files
106    /// through `assets`.
107    ///
108    /// `assets` is called with the path a metadata key names, relative to
109    /// the skill directory (`assets/input.schema.json`), and returns the
110    /// file's text or `None` when it is absent. Use it for a skill embedded
111    /// in the binary; `SkillCard::try_from(&skill)` reads the files from the
112    /// directory the skill was loaded from.
113    ///
114    /// List keys hold a JSON array of strings, the execution hints a
115    /// decimal string, and the schema and provider-config keys a relative
116    /// path to a JSON file. An unknown `a2a-*` key, a path that leaves the
117    /// skill directory, a missing asset, or a value that does not parse is
118    /// a [`ManifestError::Parse`] at that key. Declared schemas must be
119    /// usable: unknown JSON Schema keywords and schemas the 2020-12
120    /// validator cannot compile are a [`ManifestError::Schema`], never
121    /// deferred to the first validate call.
122    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    /// Load a skill embedded in the binary and project it.
176    ///
177    /// `dir_name` is the skill's directory name, `skill_md` the text of its
178    /// `SKILL.md` and `assets` its asset files as `(relative path, text)`
179    /// pairs, typically all from `include_str!`. Embedded content is fixed at
180    /// build time, so any problem the loader reports — an unknown field, a
181    /// name that differs from `dir_name` — is an error here rather than a
182    /// diagnostic. Errors from loading are [`ManifestError::Parse`] at
183    /// `SKILL.md`; the rest are as for [`SkillCard::from_skill`].
184    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    /// Project the eight A2A discovery fields onto an `AgentSkill`.
208    ///
209    /// Schemas, execution hints, and provider config are intentionally NOT
210    /// projected — they are Turul-local planning metadata, not wire fields.
211    /// For manifest-backed skills, `params_schema` is derived from the
212    /// manifest's input schema, ensuring there is exactly one schema per
213    /// skill.
214    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            // The manifest representation of `securityRequirements` is a
224            // list of scheme names. Mapping to the proto's richer
225            // `SecurityRequirement` (scheme -> scopes) is
226            // empty-scope-by-default; empty list -> empty Vec.
227            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    /// Render the Markdown body against the given input parameters.
240    ///
241    /// Grammar: `{{ path }}` with dotted access (`{{ user.name }}`).
242    /// Literal `{{` escaped as `\{{`. Missing variable -> structured
243    /// `RenderError::MissingVariable` (no silent empty substitution).
244    pub fn render_prompt(&self, params: &Value) -> Result<String, RenderError> {
245        render_template(&self.body, params)
246    }
247
248    /// Validate input against the manifest's input schema. A card
249    /// without an input schema accepts any input. Error locations are
250    /// JSON pointers into the instance rooted at `#`, as in
251    /// [`validate_json`](crate::validate_json).
252    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    /// Validate output against the manifest's output schema. A card
260    /// without an output schema accepts any output. Error locations are
261    /// JSON pointers into the instance rooted at `#`, as in
262    /// [`validate_json`](crate::validate_json).
263    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
271/// Reads asset files from the directory the skill was loaded from. The
272/// resolved file must lie inside that directory, so a symlink pointing out
273/// of it is refused. A file that is absent, refused or unreadable fails as
274/// not found; so does every asset of a skill with no location (one that was
275/// built, or loaded from text).
276impl 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}