Skip to main content

awaken_runtime_contract/
skill_spec_patch.rs

1//! Field-level override for [`SkillSpec`](crate::skill_spec::SkillSpec).
2//!
3//! Stored as JSON inside `RecordMeta::user_overrides` for built-in skill
4//! records. Missing fields inherit from the built-in skill; JSON `null` clears
5//! nullable metadata fields.
6
7use serde::{Deserialize, Serialize};
8
9use crate::skill_spec::{SkillArgumentSpec, SkillSpec, SkillSpecContext};
10
11pub type NullablePatch<T> = Option<Option<T>>;
12
13#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
14#[serde(default, deny_unknown_fields)]
15pub struct SkillSpecPatch {
16    #[serde(skip_serializing_if = "Option::is_none")]
17    pub name: Option<String>,
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub description: Option<String>,
20    #[serde(skip_serializing_if = "Option::is_none")]
21    pub instructions_md: Option<String>,
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub allowed_tools: Option<Vec<String>>,
24    #[serde(
25        default,
26        deserialize_with = "nullable_patch::deserialize",
27        serialize_with = "nullable_patch::serialize",
28        skip_serializing_if = "nullable_patch::is_missing"
29    )]
30    pub when_to_use: NullablePatch<String>,
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub arguments: Option<Vec<SkillArgumentSpec>>,
33    #[serde(
34        default,
35        deserialize_with = "nullable_patch::deserialize",
36        serialize_with = "nullable_patch::serialize",
37        skip_serializing_if = "nullable_patch::is_missing"
38    )]
39    pub argument_hint: NullablePatch<String>,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub user_invocable: Option<bool>,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub model_invocable: Option<bool>,
44    #[serde(
45        default,
46        deserialize_with = "nullable_patch::deserialize",
47        serialize_with = "nullable_patch::serialize",
48        skip_serializing_if = "nullable_patch::is_missing"
49    )]
50    pub model_override: NullablePatch<String>,
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub context: Option<SkillSpecContext>,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub paths: Option<Vec<String>>,
55}
56
57impl SkillSpecPatch {
58    pub fn is_empty(&self) -> bool {
59        self.name.is_none()
60            && self.description.is_none()
61            && self.instructions_md.is_none()
62            && self.allowed_tools.is_none()
63            && self.when_to_use.is_none()
64            && self.arguments.is_none()
65            && self.argument_hint.is_none()
66            && self.user_invocable.is_none()
67            && self.model_invocable.is_none()
68            && self.model_override.is_none()
69            && self.context.is_none()
70            && self.paths.is_none()
71    }
72}
73
74pub fn merge_skill_spec(base: SkillSpec, patch: SkillSpecPatch) -> SkillSpec {
75    SkillSpec {
76        id: base.id,
77        name: patch.name.unwrap_or(base.name),
78        description: patch.description.unwrap_or(base.description),
79        instructions_md: patch.instructions_md.unwrap_or(base.instructions_md),
80        allowed_tools: patch.allowed_tools.unwrap_or(base.allowed_tools),
81        when_to_use: merge_nullable(base.when_to_use, patch.when_to_use),
82        arguments: patch.arguments.unwrap_or(base.arguments),
83        argument_hint: merge_nullable(base.argument_hint, patch.argument_hint),
84        user_invocable: patch.user_invocable.unwrap_or(base.user_invocable),
85        model_invocable: patch.model_invocable.unwrap_or(base.model_invocable),
86        model_override: merge_nullable(base.model_override, patch.model_override),
87        context: patch.context.unwrap_or(base.context),
88        paths: patch.paths.unwrap_or(base.paths),
89    }
90}
91
92fn merge_nullable<T>(base: Option<T>, patch: NullablePatch<T>) -> Option<T> {
93    patch.unwrap_or(base)
94}
95
96mod nullable_patch {
97    use serde::{Deserialize, Deserializer, Serialize, Serializer};
98
99    pub fn serialize<S, T>(value: &Option<Option<T>>, serializer: S) -> Result<S::Ok, S::Error>
100    where
101        S: Serializer,
102        T: Serialize,
103    {
104        match value {
105            None => serializer.serialize_none(),
106            Some(inner) => inner.serialize(serializer),
107        }
108    }
109
110    pub fn deserialize<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
111    where
112        D: Deserializer<'de>,
113        T: Deserialize<'de>,
114    {
115        Option::<T>::deserialize(deserializer).map(Some)
116    }
117
118    pub fn is_missing<T>(value: &Option<Option<T>>) -> bool {
119        value.is_none()
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use serde_json::json;
127
128    fn base() -> SkillSpec {
129        SkillSpec {
130            id: "db-management".into(),
131            name: "Database Management".into(),
132            description: "stock".into(),
133            instructions_md: "Use stock instructions.".into(),
134            when_to_use: Some("stock hint".into()),
135            argument_hint: Some("dialect=postgres".into()),
136            model_override: Some("fast".into()),
137            ..Default::default()
138        }
139    }
140
141    #[test]
142    fn empty_patch_keeps_base() {
143        assert_eq!(merge_skill_spec(base(), SkillSpecPatch::default()), base());
144    }
145
146    #[test]
147    fn scalar_patch_replaces_base() {
148        let merged = merge_skill_spec(
149            base(),
150            SkillSpecPatch {
151                description: Some("custom".into()),
152                instructions_md: Some("Custom.".into()),
153                model_invocable: Some(false),
154                ..Default::default()
155            },
156        );
157        assert_eq!(merged.description, "custom");
158        assert_eq!(merged.instructions_md, "Custom.");
159        assert!(!merged.model_invocable);
160    }
161
162    #[test]
163    fn nullable_patch_clears_values() {
164        let patch: SkillSpecPatch = serde_json::from_value(json!({
165            "when_to_use": null,
166            "argument_hint": null,
167            "model_override": null
168        }))
169        .unwrap();
170        let merged = merge_skill_spec(base(), patch);
171        assert_eq!(merged.when_to_use, None);
172        assert_eq!(merged.argument_hint, None);
173        assert_eq!(merged.model_override, None);
174    }
175
176    #[test]
177    fn unknown_field_is_rejected() {
178        let bad = json!({"description": "x", "id": "renamed"});
179        assert!(serde_json::from_value::<SkillSpecPatch>(bad).is_err());
180    }
181}