Skip to main content

awaken_runtime_contract/
skill_spec.rs

1//! Skill registry record managed through ConfigStore.
2//!
3//! `SkillSpec` is the structured, database-managed representation of a skill.
4//! It deliberately exposes editable skill fields rather than raw `SKILL.md`
5//! frontmatter so HTTP/config callers can validate and patch records without
6//! reparsing markdown.
7
8use serde::{Deserialize, Serialize};
9
10/// Execution mode for a config-managed skill.
11#[derive(
12    Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
13)]
14#[serde(rename_all = "snake_case")]
15pub enum SkillSpecContext {
16    /// Inject the skill into the current conversation context.
17    #[default]
18    Inline,
19    /// Reserve the skill for forked/sub-context execution.
20    Fork,
21}
22
23/// Formal argument metadata for a skill activation.
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
25#[serde(deny_unknown_fields)]
26pub struct SkillArgumentSpec {
27    pub name: String,
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub description: Option<String>,
30    #[serde(default)]
31    pub required: bool,
32}
33
34/// ConfigStore representation of a skill.
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
36#[serde(deny_unknown_fields)]
37pub struct SkillSpec {
38    /// Canonical skill id. Must be a valid skill-name token.
39    pub id: String,
40    /// Human-facing display name.
41    pub name: String,
42    /// Catalog description shown to the model/user.
43    pub description: String,
44    /// Markdown instructions injected when the skill is activated. This is the
45    /// SKILL.md body, not a full frontmatter document.
46    pub instructions_md: String,
47    /// Tool ids or matcher patterns granted when the skill is activated.
48    #[serde(default, skip_serializing_if = "Vec::is_empty")]
49    pub allowed_tools: Vec<String>,
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub when_to_use: Option<String>,
52    #[serde(default, skip_serializing_if = "Vec::is_empty")]
53    pub arguments: Vec<SkillArgumentSpec>,
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub argument_hint: Option<String>,
56    #[serde(default = "default_true")]
57    pub user_invocable: bool,
58    #[serde(default = "default_true")]
59    pub model_invocable: bool,
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub model_override: Option<String>,
62    #[serde(default)]
63    pub context: SkillSpecContext,
64    /// Reserved for future resource/conditional-activation support. Config
65    /// write validation currently requires this to be empty so DB-managed
66    /// skills do not advertise resource/path semantics they cannot serve.
67    #[serde(default, skip_serializing_if = "Vec::is_empty")]
68    pub paths: Vec<String>,
69}
70
71fn default_true() -> bool {
72    true
73}
74
75impl Default for SkillSpec {
76    fn default() -> Self {
77        Self {
78            id: String::new(),
79            name: String::new(),
80            description: String::new(),
81            instructions_md: String::new(),
82            allowed_tools: Vec::new(),
83            when_to_use: None,
84            arguments: Vec::new(),
85            argument_hint: None,
86            user_invocable: true,
87            model_invocable: true,
88            model_override: None,
89            context: SkillSpecContext::Inline,
90            paths: Vec::new(),
91        }
92    }
93}
94
95/// Prepared, validated skill registry replacement.
96///
97/// All fallible work must happen before this object is returned. `commit` is
98/// intentionally infallible so config runtime publish can prepare skills before
99/// replacing the core runtime registries and then finish the live skill swap
100/// without introducing a second fallible publish phase.
101pub trait PreparedSkillSpecs: Send {
102    fn commit(self: Box<Self>);
103}
104
105/// Sink used by runtime/config managers that publish DB-managed skill specs to
106/// a live skill registry without depending on the concrete skills extension.
107pub trait SkillSpecSink: Send + Sync {
108    /// Prepare a candidate replacement without changing the live registry.
109    fn prepare_skill_specs(
110        &self,
111        specs: Vec<SkillSpec>,
112    ) -> Result<Box<dyn PreparedSkillSpecs>, String>;
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use serde_json::json;
119
120    #[test]
121    fn round_trip_preserves_fields() {
122        let spec = SkillSpec {
123            id: "db-management".into(),
124            name: "Database Management".into(),
125            description: "Helps with database operations".into(),
126            instructions_md: "Inspect schema before writing queries.".into(),
127            allowed_tools: vec!["db_query".into()],
128            when_to_use: Some("When the user asks about a database".into()),
129            arguments: vec![SkillArgumentSpec {
130                name: "dialect".into(),
131                description: Some("SQL dialect".into()),
132                required: false,
133            }],
134            argument_hint: Some("dialect=postgres".into()),
135            user_invocable: true,
136            model_invocable: false,
137            model_override: Some("fast".into()),
138            context: SkillSpecContext::Fork,
139            paths: vec!["migrations/**".into()],
140        };
141        let value = serde_json::to_value(&spec).unwrap();
142        let back: SkillSpec = serde_json::from_value(value).unwrap();
143        assert_eq!(spec, back);
144    }
145
146    #[test]
147    fn serde_defaults_match_runtime_defaults() {
148        let spec: SkillSpec = serde_json::from_value(json!({
149            "id": "db-management",
150            "name": "Database Management",
151            "description": "Helps with database operations",
152            "instructions_md": "Inspect schema before writing queries."
153        }))
154        .unwrap();
155        assert!(spec.user_invocable);
156        assert!(spec.model_invocable);
157        assert_eq!(spec.context, SkillSpecContext::Inline);
158        assert!(spec.allowed_tools.is_empty());
159        assert!(spec.paths.is_empty());
160    }
161
162    #[test]
163    fn unknown_field_is_rejected() {
164        let bad = json!({
165            "id": "x",
166            "name": "x",
167            "description": "x",
168            "instructions_md": "x",
169            "garbage": true
170        });
171        assert!(serde_json::from_value::<SkillSpec>(bad).is_err());
172    }
173}