Skip to main content

roder_api/
skills.rs

1use serde::{Deserialize, Serialize};
2use time::OffsetDateTime;
3
4use crate::events::{ThreadId, TurnId};
5
6pub type SkillId = String;
7pub type SkillName = String;
8pub type SkillCanonicalPath = String;
9pub type SkillDiagnosticMessage = String;
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
12#[serde(rename_all = "camelCase")]
13pub enum SkillSource {
14    Workspace,
15    User,
16    Plugin { plugin_id: String },
17    Imported { import_id: String },
18    BuiltIn,
19}
20
21#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
22#[serde(rename_all = "snake_case")]
23pub enum SkillExposure {
24    Global,
25    DirectOnly,
26}
27
28#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
29#[serde(rename_all = "snake_case")]
30pub enum SkillActivationState {
31    Enabled,
32    Disabled,
33    Experimental,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
37#[serde(rename_all = "camelCase")]
38pub enum SkillSelector {
39    Name { name: SkillName },
40    Path { path: SkillCanonicalPath },
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
44#[serde(rename_all = "camelCase")]
45pub enum SkillActivationReason {
46    DirectInvocation,
47    FeatureBinding,
48    GlobalIndex,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
52#[serde(rename_all = "camelCase")]
53pub struct FeatureSkillBinding {
54    pub feature_id: String,
55    pub skill_selector: SkillSelector,
56    pub required: bool,
57    pub activation_reason: SkillActivationReason,
58}
59
60#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
61#[serde(rename_all = "camelCase")]
62pub struct SkillAgentMetadata {
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub interface: Option<String>,
65    #[serde(default, skip_serializing_if = "Vec::is_empty")]
66    pub dependencies: Vec<String>,
67    #[serde(default, skip_serializing_if = "Vec::is_empty")]
68    pub policies: Vec<String>,
69    #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
70    pub raw: serde_json::Value,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
74#[serde(rename_all = "camelCase")]
75pub struct SkillDescriptor {
76    pub id: SkillId,
77    pub name: SkillName,
78    pub canonical_path: SkillCanonicalPath,
79    pub source: SkillSource,
80    pub exposure: SkillExposure,
81    pub activation: SkillActivationState,
82    pub description: String,
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub short_description: Option<String>,
85    #[serde(default)]
86    pub experimental: bool,
87    #[serde(default, skip_serializing_if = "Vec::is_empty")]
88    pub diagnostics: Vec<SkillDiagnosticMessage>,
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub agent_metadata: Option<SkillAgentMetadata>,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
94#[serde(rename_all = "camelCase")]
95pub struct Skill {
96    pub descriptor: SkillDescriptor,
97    pub body: String,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
101#[serde(rename_all = "camelCase")]
102pub struct SkillsCatalogLoaded {
103    pub descriptors: Vec<SkillDescriptor>,
104    #[serde(default, skip_serializing_if = "Vec::is_empty")]
105    pub diagnostics: Vec<SkillDiagnosticMessage>,
106    #[serde(with = "time::serde::rfc3339")]
107    pub timestamp: OffsetDateTime,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
111#[serde(rename_all = "camelCase")]
112pub struct SkillConfigApplied {
113    pub descriptor: SkillDescriptor,
114    pub previous_activation: SkillActivationState,
115    pub activation: SkillActivationState,
116    pub previous_exposure: SkillExposure,
117    pub exposure: SkillExposure,
118    #[serde(default, skip_serializing_if = "Vec::is_empty")]
119    pub diagnostics: Vec<SkillDiagnosticMessage>,
120    #[serde(with = "time::serde::rfc3339")]
121    pub timestamp: OffsetDateTime,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
125#[serde(rename_all = "camelCase")]
126pub struct SkillActivationResolved {
127    pub thread_id: ThreadId,
128    pub turn_id: TurnId,
129    pub selector: SkillSelector,
130    pub activation_reason: SkillActivationReason,
131    pub activated: bool,
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub descriptor: Option<SkillDescriptor>,
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub diagnostic: Option<SkillDiagnosticMessage>,
136    #[serde(with = "time::serde::rfc3339")]
137    pub timestamp: OffsetDateTime,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
141#[serde(rename_all = "camelCase")]
142pub struct SkillIndexRendered {
143    pub thread_id: ThreadId,
144    pub turn_id: TurnId,
145    pub rendered_count: u64,
146    pub hidden_count: u64,
147    pub estimated_tokens: u32,
148    #[serde(with = "time::serde::rfc3339")]
149    pub timestamp: OffsetDateTime,
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
153#[serde(rename_all = "camelCase")]
154pub struct SkillInvoked {
155    pub thread_id: ThreadId,
156    pub turn_id: TurnId,
157    pub selector: SkillSelector,
158    pub descriptor: SkillDescriptor,
159    #[serde(with = "time::serde::rfc3339")]
160    pub timestamp: OffsetDateTime,
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
164#[serde(rename_all = "camelCase")]
165pub struct SkillAutoActivated {
166    pub thread_id: ThreadId,
167    pub turn_id: TurnId,
168    pub feature_id: String,
169    pub descriptor: SkillDescriptor,
170    #[serde(with = "time::serde::rfc3339")]
171    pub timestamp: OffsetDateTime,
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
175#[serde(rename_all = "camelCase")]
176pub struct SkillSkipped {
177    pub thread_id: ThreadId,
178    pub turn_id: TurnId,
179    pub selector: SkillSelector,
180    pub reason: String,
181    #[serde(with = "time::serde::rfc3339")]
182    pub timestamp: OffsetDateTime,
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn skill_descriptor_serializes_source_and_exposure() {
191        let descriptor = SkillDescriptor {
192            id: "builtin:commit".to_string(),
193            name: "commit".to_string(),
194            canonical_path: "roder-builtin://commit/SKILL.md".to_string(),
195            source: SkillSource::BuiltIn,
196            exposure: SkillExposure::DirectOnly,
197            activation: SkillActivationState::Enabled,
198            description: "Commit staged changes safely.".to_string(),
199            short_description: Some("Commit safely".to_string()),
200            experimental: false,
201            diagnostics: Vec::new(),
202            agent_metadata: Some(SkillAgentMetadata {
203                interface: Some("openai".to_string()),
204                dependencies: vec!["git".to_string()],
205                policies: vec!["do-not-stage-unrequested-files".to_string()],
206                raw: serde_json::json!({ "interface": "openai" }),
207            }),
208        };
209
210        let value = serde_json::to_value(&descriptor).unwrap();
211        assert_eq!(value["canonicalPath"], "roder-builtin://commit/SKILL.md");
212        assert_eq!(value["source"], "builtIn");
213        assert_eq!(value["exposure"], "direct_only");
214        assert_eq!(value["agentMetadata"]["dependencies"][0], "git");
215        let round_trip: SkillDescriptor = serde_json::from_value(value).unwrap();
216        assert_eq!(round_trip, descriptor);
217    }
218
219    #[test]
220    fn feature_skill_binding_targets_name_or_path() {
221        let binding = FeatureSkillBinding {
222            feature_id: "command:commit".to_string(),
223            skill_selector: SkillSelector::Name {
224                name: "commit".to_string(),
225            },
226            required: true,
227            activation_reason: SkillActivationReason::FeatureBinding,
228        };
229
230        let value = serde_json::to_value(binding).unwrap();
231        assert_eq!(value["skillSelector"]["name"]["name"], "commit");
232        assert_eq!(value["activationReason"], "featureBinding");
233    }
234
235    #[test]
236    fn skill_events_round_trip_public_shapes() {
237        let descriptor = SkillDescriptor {
238            id: "builtin:commit".to_string(),
239            name: "commit".to_string(),
240            canonical_path: "roder-builtin://commit/SKILL.md".to_string(),
241            source: SkillSource::BuiltIn,
242            exposure: SkillExposure::DirectOnly,
243            activation: SkillActivationState::Enabled,
244            description: "Commit staged changes safely.".to_string(),
245            short_description: Some("Commit safely".to_string()),
246            experimental: false,
247            diagnostics: Vec::new(),
248            agent_metadata: None,
249        };
250        let event = SkillActivationResolved {
251            thread_id: "thread-a".to_string(),
252            turn_id: "turn-a".to_string(),
253            selector: SkillSelector::Name {
254                name: "commit".to_string(),
255            },
256            activation_reason: SkillActivationReason::DirectInvocation,
257            activated: true,
258            descriptor: Some(descriptor),
259            diagnostic: None,
260            timestamp: OffsetDateTime::UNIX_EPOCH,
261        };
262
263        let value = serde_json::to_value(&event).unwrap();
264        assert_eq!(value["timestamp"], "1970-01-01T00:00:00Z");
265        assert_eq!(value["selector"]["name"]["name"], "commit");
266        let round_trip: SkillActivationResolved = serde_json::from_value(value).unwrap();
267        assert!(round_trip.activated);
268    }
269}