Skip to main content

vtcode_skills/
container.rs

1//! Skill container for multi-skill execution
2//!
3//! Implements VT Code's container model for managing multiple skills
4//! in a single request with version support and container reuse.
5//!
6//! Up to 8 skills per container. Container IDs can be reused across
7//! multiple turns for state preservation.
8
9use hashbrown::HashSet;
10use serde::{Deserialize, Serialize};
11
12/// Skill source type (Anthropic-managed or custom)
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14pub enum SkillType {
15    /// Pre-built Anthropic skills (pptx, xlsx, docx, pdf, etc.)
16    #[serde(rename = "anthropic")]
17    Anthropic,
18    /// User-uploaded custom skills
19    #[serde(rename = "custom")]
20    Custom,
21}
22
23/// Skill version specification
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
25#[serde(untagged)]
26pub enum SkillVersion {
27    /// Always use latest version
28    #[serde(rename = "latest")]
29    #[default]
30    Latest,
31    /// Specific version by ID (epoch timestamp for custom skills, date for Anthropic)
32    Specific(String),
33}
34
35impl SkillVersion {
36    fn as_str(&self) -> &str {
37        match self {
38            SkillVersion::Latest => "latest",
39            SkillVersion::Specific(v) => v,
40        }
41    }
42
43    fn is_latest(&self) -> bool {
44        matches!(self, SkillVersion::Latest)
45    }
46}
47
48/// How a skill is referenced in a container
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(tag = "type", rename_all = "snake_case")]
51pub enum SkillSource {
52    /// Reference to a registered skill by ID
53    #[serde(rename = "skill_reference")]
54    Reference {
55        skill_id: String,
56        #[serde(default)]
57        version: SkillVersion,
58    },
59    /// Inline base64-encoded zip bundle (no pre-registration needed)
60    #[serde(rename = "inline")]
61    Inline {
62        /// Base64-encoded zip bundle
63        bundle_b64: String,
64        /// Optional SHA-256 hash for caching/deduplication
65        #[serde(skip_serializing_if = "Option::is_none")]
66        sha256: Option<String>,
67    },
68}
69
70/// Specification for a single skill in a container
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct SkillSpec {
73    /// Type of skill (anthropic or custom)
74    #[serde(rename = "type")]
75    skill_type: SkillType,
76    /// Skill identifier (short name for Anthropic, UUID for custom)
77    skill_id: String,
78    /// Version to use (latest or specific epoch timestamp)
79    #[serde(default)]
80    version: SkillVersion,
81}
82
83impl SkillSpec {
84    /// Create a new skill specification
85    fn new(skill_type: SkillType, skill_id: impl Into<String>) -> Self {
86        Self {
87            skill_type,
88            skill_id: skill_id.into(),
89            version: SkillVersion::Latest,
90        }
91    }
92
93    /// Create with specific version
94    fn with_version(mut self, version: SkillVersion) -> Self {
95        self.version = version;
96        self
97    }
98
99    /// Create Anthropic skill (predefined by Anthropic)
100    fn anthropic(skill_id: impl Into<String>) -> Self {
101        Self::new(SkillType::Anthropic, skill_id)
102    }
103
104    /// Create custom skill (user-uploaded)
105    fn custom(skill_id: impl Into<String>) -> Self {
106        Self::new(SkillType::Custom, skill_id)
107    }
108}
109
110/// Container for managing multiple skills in a request
111///
112/// Implements VT Code's container model for multi-skill execution.
113/// - Maximum 8 skills per container
114/// - Container ID can be reused across multiple turns
115/// - Each skill can have independent version pinning
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct SkillContainer {
118    /// Optional container ID for reuse across turns
119    #[serde(skip_serializing_if = "Option::is_none")]
120    id: Option<String>,
121    /// Skills to load in this container (max 8)
122    skills: Vec<SkillSpec>,
123    /// Inline skill bundles (base64-encoded zips, no pre-registration needed)
124    #[serde(default, skip_serializing_if = "Vec::is_empty")]
125    inline_bundles: Vec<SkillSource>,
126}
127
128impl SkillContainer {
129    /// Create a new skill container
130    fn new() -> Self {
131        Self {
132            id: None,
133            skills: Vec::with_capacity(8),
134            inline_bundles: Vec::new(),
135        }
136    }
137
138    /// Create with single skill
139    fn single(spec: SkillSpec) -> Self {
140        Self {
141            id: None,
142            skills: vec![spec],
143            inline_bundles: Vec::new(),
144        }
145    }
146
147    /// Create with container ID (for reuse)
148    fn with_id(id: impl Into<String>) -> Self {
149        Self {
150            id: Some(id.into()),
151            skills: Vec::with_capacity(8),
152            inline_bundles: Vec::new(),
153        }
154    }
155
156    /// Add a skill to the container
157    ///
158    /// # Errors
159    /// Returns error if adding skill would exceed maximum of 8 skills
160    fn add_skill(&mut self, spec: SkillSpec) -> anyhow::Result<()> {
161        if self.skills.len() >= 8 {
162            anyhow::bail!("Container already has maximum skills (8), cannot add '{}'", spec.skill_id);
163        }
164        self.skills.push(spec);
165        Ok(())
166    }
167
168    /// Add multiple skills
169    ///
170    /// # Errors
171    /// Returns error if total would exceed 8 skills
172    fn add_skills(&mut self, mut specs: Vec<SkillSpec>) -> anyhow::Result<()> {
173        let current_len = self.skills.len();
174        let new_len = current_len + specs.len();
175        if new_len > 8 {
176            anyhow::bail!(
177                "Adding {} skills would exceed maximum (8). Current: {}, requested: {}",
178                specs.len(),
179                current_len,
180                specs.len()
181            );
182        }
183        // Reserve capacity to avoid reallocations
184        if new_len > self.skills.capacity() {
185            self.skills.reserve(new_len - current_len);
186        }
187        self.skills.append(&mut specs);
188        Ok(())
189    }
190
191    /// Add Anthropic skill
192    fn add_anthropic(&mut self, skill_id: impl Into<String>) -> anyhow::Result<()> {
193        self.add_skill(SkillSpec::anthropic(skill_id))
194    }
195
196    /// Add custom skill
197    fn add_custom(&mut self, skill_id: impl Into<String>) -> anyhow::Result<()> {
198        self.add_skill(SkillSpec::custom(skill_id))
199    }
200
201    /// Add an inline skill bundle (base64-encoded zip, no pre-registration needed)
202    ///
203    /// Also registers a corresponding `SkillSpec` so the skill is tracked in `skills`.
204    ///
205    /// # Errors
206    /// Returns error if adding would exceed the maximum of 8 skills.
207    fn add_inline(&mut self, bundle_b64: String, sha256: Option<String>) -> anyhow::Result<()> {
208        if self.skills.len() >= 8 {
209            anyhow::bail!("Container already has maximum skills (8)");
210        }
211        let spec = SkillSpec {
212            skill_type: SkillType::Custom,
213            skill_id: sha256.clone().unwrap_or_else(|| format!("inline-{}", self.skills.len())),
214            version: SkillVersion::Latest,
215        };
216        self.skills.push(spec);
217        self.inline_bundles.push(SkillSource::Inline { bundle_b64, sha256 });
218        Ok(())
219    }
220
221    /// Get number of skills in container
222    fn len(&self) -> usize {
223        self.skills.len()
224    }
225
226    /// Check if container is empty
227    fn is_empty(&self) -> bool {
228        self.skills.is_empty()
229    }
230
231    /// Check if container has a specific skill
232    fn has_skill(&self, skill_id: &str) -> bool {
233        self.skills.iter().any(|s| s.skill_id == skill_id)
234    }
235
236    /// Get skill by ID
237    fn get_skill(&self, skill_id: &str) -> Option<&SkillSpec> {
238        self.skills.iter().find(|s| s.skill_id == skill_id)
239    }
240
241    /// Validate container
242    ///
243    /// Checks:
244    /// - No more than 8 skills
245    /// - No duplicate skill IDs
246    fn validate(&self) -> anyhow::Result<()> {
247        if self.skills.len() > 8 {
248            anyhow::bail!("Container has {} skills, maximum is 8", self.skills.len());
249        }
250
251        let mut seen_ids = HashSet::new();
252        for spec in &self.skills {
253            if !seen_ids.insert(&spec.skill_id) {
254                anyhow::bail!("Duplicate skill ID in container: '{}'", spec.skill_id);
255            }
256        }
257
258        Ok(())
259    }
260
261    /// Set container ID for reuse
262    fn set_id(&mut self, id: impl Into<String>) {
263        self.id = Some(id.into());
264    }
265
266    /// Clear container ID
267    pub fn clear_id(&mut self) {
268        self.id = None;
269    }
270
271    /// Get all skill IDs in container
272    fn skill_ids(&self) -> Vec<&str> {
273        self.skills.iter().map(|s| s.skill_id.as_str()).collect()
274    }
275
276    /// Get all skills of a specific type
277    fn skills_by_type(&self, skill_type: SkillType) -> Vec<&SkillSpec> {
278        self.skills.iter().filter(|s| s.skill_type == skill_type).collect()
279    }
280
281    /// Count anthropic skills
282    fn anthropic_count(&self) -> usize {
283        self.skills_by_type(SkillType::Anthropic).len()
284    }
285
286    /// Count custom skills
287    fn custom_count(&self) -> usize {
288        self.skills_by_type(SkillType::Custom).len()
289    }
290}
291
292impl Default for SkillContainer {
293    fn default() -> Self {
294        Self::new()
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    #[test]
303    fn test_skill_spec_new() {
304        let spec = SkillSpec::new(SkillType::Custom, "my-skill");
305        assert_eq!(spec.skill_id, "my-skill");
306        assert_eq!(spec.skill_type, SkillType::Custom);
307        assert!(spec.version.is_latest());
308    }
309
310    #[test]
311    fn test_skill_spec_anthropic() {
312        let spec = SkillSpec::anthropic("xlsx");
313        assert_eq!(spec.skill_id, "xlsx");
314        assert_eq!(spec.skill_type, SkillType::Anthropic);
315    }
316
317    #[test]
318    fn test_skill_spec_with_version() {
319        let spec = SkillSpec::custom("my-skill").with_version(SkillVersion::Specific("1759178010641129".to_string()));
320        assert_eq!(spec.version.as_str(), "1759178010641129");
321        assert!(!spec.version.is_latest());
322    }
323
324    #[test]
325    fn test_container_creation() {
326        let container = SkillContainer::new();
327        assert!(container.is_empty());
328        assert!(container.id.is_none());
329    }
330
331    #[test]
332    fn test_container_single_skill() {
333        let spec = SkillSpec::custom("test-skill");
334        let container = SkillContainer::single(spec.clone());
335        assert_eq!(container.len(), 1);
336        assert!(container.has_skill("test-skill"));
337        assert_eq!(container.get_skill("test-skill"), Some(&spec));
338    }
339
340    #[test]
341    fn test_container_add_skill() {
342        let mut container = SkillContainer::new();
343        let spec = SkillSpec::custom("skill1");
344        container.add_skill(spec).unwrap();
345        assert_eq!(container.len(), 1);
346    }
347
348    #[test]
349    fn test_container_max_skills() {
350        let mut container = SkillContainer::new();
351        for i in 0..8 {
352            let spec = SkillSpec::custom(format!("skill{i}"));
353            container.add_skill(spec).unwrap();
354        }
355        assert_eq!(container.len(), 8);
356
357        // Try to add 9th skill
358        let spec = SkillSpec::custom("skill9");
359        assert!(container.add_skill(spec).is_err());
360    }
361
362    #[test]
363    fn test_container_add_skills_batch() {
364        let mut container = SkillContainer::new();
365        let specs = vec![
366            SkillSpec::custom("skill1"),
367            SkillSpec::custom("skill2"),
368            SkillSpec::custom("skill3"),
369        ];
370        container.add_skills(specs).unwrap();
371        assert_eq!(container.len(), 3);
372    }
373
374    #[test]
375    fn test_container_add_skills_batch_overflow() {
376        let mut container = SkillContainer::new();
377        for i in 0..7 {
378            let spec = SkillSpec::custom(format!("skill{i}"));
379            container.add_skill(spec).ok();
380        }
381        assert_eq!(container.len(), 7);
382
383        let specs = vec![SkillSpec::custom("skill7"), SkillSpec::custom("skill8")];
384        assert!(container.add_skills(specs).is_err());
385    }
386
387    #[test]
388    fn test_container_duplicate_skill_ids() {
389        let mut container = SkillContainer::new();
390        container.add_skill(SkillSpec::custom("dup")).unwrap();
391        container.add_skill(SkillSpec::custom("dup")).unwrap();
392        assert!(container.validate().is_err());
393    }
394
395    #[test]
396    fn test_container_with_id() {
397        let container = SkillContainer::with_id("container-123");
398        assert_eq!(container.id, Some("container-123".to_string()));
399    }
400
401    #[test]
402    fn test_container_set_id() {
403        let mut container = SkillContainer::new();
404        container.set_id("new-id");
405        assert_eq!(container.id, Some("new-id".to_string()));
406    }
407
408    #[test]
409    fn test_container_skills_by_type() {
410        let mut container = SkillContainer::new();
411        container.add_anthropic("xlsx").ok();
412        container.add_anthropic("pptx").ok();
413        container.add_custom("my-skill").ok();
414
415        let anthropic = container.skills_by_type(SkillType::Anthropic);
416        assert_eq!(anthropic.len(), 2);
417
418        let custom = container.skills_by_type(SkillType::Custom);
419        assert_eq!(custom.len(), 1);
420
421        assert_eq!(container.anthropic_count(), 2);
422        assert_eq!(container.custom_count(), 1);
423    }
424
425    #[test]
426    fn test_container_skill_ids() {
427        let mut container = SkillContainer::new();
428        container.add_skill(SkillSpec::custom("skill1")).ok();
429        container.add_skill(SkillSpec::custom("skill2")).ok();
430        container.add_skill(SkillSpec::custom("skill3")).ok();
431
432        let ids = container.skill_ids();
433        assert_eq!(ids, vec!["skill1", "skill2", "skill3"]);
434    }
435
436    #[test]
437    fn test_container_validation() {
438        let mut container = SkillContainer::new();
439        for i in 0..8 {
440            container.add_skill(SkillSpec::custom(format!("skill{i}"))).ok();
441        }
442        container.validate().unwrap();
443    }
444
445    #[test]
446    fn test_skill_spec_roundtrip() {
447        // Test serialization/deserialization roundtrip
448        let spec = SkillSpec {
449            skill_type: SkillType::Custom,
450            skill_id: "my-skill".to_string(),
451            version: SkillVersion::Specific("1759178010641129".to_string()),
452        };
453
454        let json = serde_json::to_string(&spec).unwrap();
455        let deserialized: SkillSpec = serde_json::from_str(&json).unwrap();
456
457        assert_eq!(deserialized.skill_id, "my-skill");
458        assert_eq!(deserialized.skill_type, SkillType::Custom);
459        assert_eq!(deserialized.version, SkillVersion::Specific("1759178010641129".to_string()));
460    }
461
462    #[test]
463    fn test_container_serialization() {
464        let mut container = SkillContainer::new();
465        container.add_anthropic("xlsx").ok();
466        container.add_custom("my-skill").ok();
467
468        let json = serde_json::to_string(&container).unwrap();
469        let deserialized: SkillContainer = serde_json::from_str(&json).unwrap();
470
471        assert_eq!(deserialized.len(), 2);
472        assert!(deserialized.has_skill("xlsx"));
473        assert!(deserialized.has_skill("my-skill"));
474    }
475
476    #[test]
477    fn test_skill_source_reference_roundtrip() {
478        let source = SkillSource::Reference {
479            skill_id: "my-skill".to_string(),
480            version: SkillVersion::Latest,
481        };
482        let json = serde_json::to_string(&source).unwrap();
483        let deserialized: SkillSource = serde_json::from_str(&json).unwrap();
484        assert_eq!(source, deserialized);
485    }
486
487    #[test]
488    fn test_skill_source_inline_roundtrip() {
489        let source = SkillSource::Inline {
490            bundle_b64: "UEsFBgAAAAAAAA==".to_string(),
491            sha256: Some("abc123".to_string()),
492        };
493        let json = serde_json::to_string(&source).unwrap();
494        assert!(json.contains("\"type\":\"inline\""));
495        let deserialized: SkillSource = serde_json::from_str(&json).unwrap();
496        assert_eq!(source, deserialized);
497    }
498
499    #[test]
500    fn test_skill_source_inline_no_sha() {
501        let source = SkillSource::Inline {
502            bundle_b64: "UEsFBgAAAAAAAA==".to_string(),
503            sha256: None,
504        };
505        let json = serde_json::to_string(&source).unwrap();
506        assert!(!json.contains("sha256"));
507        let deserialized: SkillSource = serde_json::from_str(&json).unwrap();
508        assert_eq!(source, deserialized);
509    }
510
511    #[test]
512    fn test_add_inline_with_sha() {
513        let mut container = SkillContainer::new();
514        container
515            .add_inline("UEsFBgAAAAAAAA==".to_string(), Some("deadbeef".to_string()))
516            .unwrap();
517
518        assert_eq!(container.len(), 1);
519        assert!(container.has_skill("deadbeef"));
520        assert_eq!(container.inline_bundles.len(), 1);
521        assert!(matches!(
522            &container.inline_bundles[0],
523            SkillSource::Inline { sha256: Some(h), .. } if h == "deadbeef"
524        ));
525    }
526
527    #[test]
528    fn test_add_inline_without_sha() {
529        let mut container = SkillContainer::new();
530        container.add_inline("UEsFBgAAAAAAAA==".to_string(), None).unwrap();
531
532        assert_eq!(container.len(), 1);
533        assert!(container.has_skill("inline-0"));
534        assert_eq!(container.inline_bundles.len(), 1);
535    }
536
537    #[test]
538    fn test_add_inline_max_skills() {
539        let mut container = SkillContainer::new();
540        for i in 0..8 {
541            container.add_skill(SkillSpec::custom(format!("skill{i}"))).unwrap();
542        }
543        let result = container.add_inline("data".to_string(), None);
544        assert!(result.is_err());
545    }
546
547    #[test]
548    fn test_container_serialization_with_inline_bundles() {
549        let mut container = SkillContainer::new();
550        container.add_anthropic("xlsx").unwrap();
551        container
552            .add_inline("UEsFBgAAAAAAAA==".to_string(), Some("hash1".to_string()))
553            .unwrap();
554
555        let json = serde_json::to_string(&container).unwrap();
556        assert!(json.contains("inline_bundles"));
557
558        let deserialized: SkillContainer = serde_json::from_str(&json).unwrap();
559        assert_eq!(deserialized.len(), 2);
560        assert_eq!(deserialized.inline_bundles.len(), 1);
561    }
562
563    #[test]
564    fn test_container_serialization_omits_empty_inline_bundles() {
565        let mut container = SkillContainer::new();
566        container.add_anthropic("xlsx").unwrap();
567
568        let json = serde_json::to_string(&container).unwrap();
569        assert!(!json.contains("inline_bundles"));
570    }
571}