Skip to main content

vtcode_core/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    pub fn as_str(&self) -> &str {
37        match self {
38            SkillVersion::Latest => "latest",
39            SkillVersion::Specific(v) => v,
40        }
41    }
42
43    pub 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    pub skill_type: SkillType,
76    /// Skill identifier (short name for Anthropic, UUID for custom)
77    pub skill_id: String,
78    /// Version to use (latest or specific epoch timestamp)
79    #[serde(default)]
80    pub version: SkillVersion,
81}
82
83impl SkillSpec {
84    /// Create a new skill specification
85    pub 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    pub fn with_version(mut self, version: SkillVersion) -> Self {
95        self.version = version;
96        self
97    }
98
99    /// Create Anthropic skill (predefined by Anthropic)
100    pub fn anthropic(skill_id: impl Into<String>) -> Self {
101        Self::new(SkillType::Anthropic, skill_id)
102    }
103
104    /// Create custom skill (user-uploaded)
105    pub 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    pub id: Option<String>,
121    /// Skills to load in this container (max 8)
122    pub skills: Vec<SkillSpec>,
123    /// Inline skill bundles (base64-encoded zips, no pre-registration needed)
124    #[serde(default, skip_serializing_if = "Vec::is_empty")]
125    pub inline_bundles: Vec<SkillSource>,
126}
127
128impl SkillContainer {
129    /// Create a new skill container
130    pub 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    pub 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    pub 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    pub fn add_skill(&mut self, spec: SkillSpec) -> anyhow::Result<()> {
161        if self.skills.len() >= 8 {
162            anyhow::bail!(
163                "Container already has maximum skills (8), cannot add '{}'",
164                spec.skill_id
165            );
166        }
167        self.skills.push(spec);
168        Ok(())
169    }
170
171    /// Add multiple skills
172    ///
173    /// # Errors
174    /// Returns error if total would exceed 8 skills
175    pub fn add_skills(&mut self, mut specs: Vec<SkillSpec>) -> anyhow::Result<()> {
176        let current_len = self.skills.len();
177        let new_len = current_len + specs.len();
178        if new_len > 8 {
179            anyhow::bail!(
180                "Adding {} skills would exceed maximum (8). Current: {}, requested: {}",
181                specs.len(),
182                current_len,
183                specs.len()
184            );
185        }
186        // Reserve capacity to avoid reallocations
187        if new_len > self.skills.capacity() {
188            self.skills.reserve(new_len - current_len);
189        }
190        self.skills.append(&mut specs);
191        Ok(())
192    }
193
194    /// Add Anthropic skill
195    pub fn add_anthropic(&mut self, skill_id: impl Into<String>) -> anyhow::Result<()> {
196        self.add_skill(SkillSpec::anthropic(skill_id))
197    }
198
199    /// Add custom skill
200    pub fn add_custom(&mut self, skill_id: impl Into<String>) -> anyhow::Result<()> {
201        self.add_skill(SkillSpec::custom(skill_id))
202    }
203
204    /// Add an inline skill bundle (base64-encoded zip, no pre-registration needed)
205    ///
206    /// Also registers a corresponding `SkillSpec` so the skill is tracked in `skills`.
207    ///
208    /// # Errors
209    /// Returns error if adding would exceed the maximum of 8 skills.
210    pub fn add_inline(&mut self, bundle_b64: String, sha256: Option<String>) -> anyhow::Result<()> {
211        if self.skills.len() >= 8 {
212            anyhow::bail!("Container already has maximum skills (8)");
213        }
214        let spec = SkillSpec {
215            skill_type: SkillType::Custom,
216            skill_id: sha256
217                .clone()
218                .unwrap_or_else(|| format!("inline-{}", self.skills.len())),
219            version: SkillVersion::Latest,
220        };
221        self.skills.push(spec);
222        self.inline_bundles
223            .push(SkillSource::Inline { bundle_b64, sha256 });
224        Ok(())
225    }
226
227    /// Get number of skills in container
228    pub fn len(&self) -> usize {
229        self.skills.len()
230    }
231
232    /// Check if container is empty
233    pub fn is_empty(&self) -> bool {
234        self.skills.is_empty()
235    }
236
237    /// Check if container has a specific skill
238    pub fn has_skill(&self, skill_id: &str) -> bool {
239        self.skills.iter().any(|s| s.skill_id == skill_id)
240    }
241
242    /// Get skill by ID
243    pub fn get_skill(&self, skill_id: &str) -> Option<&SkillSpec> {
244        self.skills.iter().find(|s| s.skill_id == skill_id)
245    }
246
247    /// Validate container
248    ///
249    /// Checks:
250    /// - No more than 8 skills
251    /// - No duplicate skill IDs
252    pub fn validate(&self) -> anyhow::Result<()> {
253        if self.skills.len() > 8 {
254            anyhow::bail!("Container has {} skills, maximum is 8", self.skills.len());
255        }
256
257        let mut seen_ids = HashSet::new();
258        for spec in &self.skills {
259            if !seen_ids.insert(&spec.skill_id) {
260                anyhow::bail!("Duplicate skill ID in container: '{}'", spec.skill_id);
261            }
262        }
263
264        Ok(())
265    }
266
267    /// Set container ID for reuse
268    pub fn set_id(&mut self, id: impl Into<String>) {
269        self.id = Some(id.into());
270    }
271
272    /// Clear container ID
273    pub fn clear_id(&mut self) {
274        self.id = None;
275    }
276
277    /// Get all skill IDs in container
278    pub fn skill_ids(&self) -> Vec<&str> {
279        self.skills.iter().map(|s| s.skill_id.as_str()).collect()
280    }
281
282    /// Get all skills of a specific type
283    pub fn skills_by_type(&self, skill_type: SkillType) -> Vec<&SkillSpec> {
284        self.skills
285            .iter()
286            .filter(|s| s.skill_type == skill_type)
287            .collect()
288    }
289
290    /// Count anthropic skills
291    pub fn anthropic_count(&self) -> usize {
292        self.skills_by_type(SkillType::Anthropic).len()
293    }
294
295    /// Count custom skills
296    pub fn custom_count(&self) -> usize {
297        self.skills_by_type(SkillType::Custom).len()
298    }
299}
300
301impl Default for SkillContainer {
302    fn default() -> Self {
303        Self::new()
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    #[test]
312    fn test_skill_spec_new() {
313        let spec = SkillSpec::new(SkillType::Custom, "my-skill");
314        assert_eq!(spec.skill_id, "my-skill");
315        assert_eq!(spec.skill_type, SkillType::Custom);
316        assert!(spec.version.is_latest());
317    }
318
319    #[test]
320    fn test_skill_spec_anthropic() {
321        let spec = SkillSpec::anthropic("xlsx");
322        assert_eq!(spec.skill_id, "xlsx");
323        assert_eq!(spec.skill_type, SkillType::Anthropic);
324    }
325
326    #[test]
327    fn test_skill_spec_with_version() {
328        let spec = SkillSpec::custom("my-skill")
329            .with_version(SkillVersion::Specific("1759178010641129".to_string()));
330        assert_eq!(spec.version.as_str(), "1759178010641129");
331        assert!(!spec.version.is_latest());
332    }
333
334    #[test]
335    fn test_container_creation() {
336        let container = SkillContainer::new();
337        assert!(container.is_empty());
338        assert!(container.id.is_none());
339    }
340
341    #[test]
342    fn test_container_single_skill() {
343        let spec = SkillSpec::custom("test-skill");
344        let container = SkillContainer::single(spec.clone());
345        assert_eq!(container.len(), 1);
346        assert!(container.has_skill("test-skill"));
347        assert_eq!(container.get_skill("test-skill"), Some(&spec));
348    }
349
350    #[test]
351    fn test_container_add_skill() {
352        let mut container = SkillContainer::new();
353        let spec = SkillSpec::custom("skill1");
354        container.add_skill(spec).unwrap();
355        assert_eq!(container.len(), 1);
356    }
357
358    #[test]
359    fn test_container_max_skills() {
360        let mut container = SkillContainer::new();
361        for i in 0..8 {
362            let spec = SkillSpec::custom(format!("skill{}", i));
363            container.add_skill(spec).unwrap();
364        }
365        assert_eq!(container.len(), 8);
366
367        // Try to add 9th skill
368        let spec = SkillSpec::custom("skill9");
369        assert!(container.add_skill(spec).is_err());
370    }
371
372    #[test]
373    fn test_container_add_skills_batch() {
374        let mut container = SkillContainer::new();
375        let specs = vec![
376            SkillSpec::custom("skill1"),
377            SkillSpec::custom("skill2"),
378            SkillSpec::custom("skill3"),
379        ];
380        container.add_skills(specs).unwrap();
381        assert_eq!(container.len(), 3);
382    }
383
384    #[test]
385    fn test_container_add_skills_batch_overflow() {
386        let mut container = SkillContainer::new();
387        for i in 0..7 {
388            let spec = SkillSpec::custom(format!("skill{}", i));
389            container.add_skill(spec).ok();
390        }
391        assert_eq!(container.len(), 7);
392
393        let specs = vec![SkillSpec::custom("skill7"), SkillSpec::custom("skill8")];
394        assert!(container.add_skills(specs).is_err());
395    }
396
397    #[test]
398    fn test_container_duplicate_skill_ids() {
399        let mut container = SkillContainer::new();
400        container.add_skill(SkillSpec::custom("dup")).unwrap();
401        container.add_skill(SkillSpec::custom("dup")).unwrap();
402        assert!(container.validate().is_err());
403    }
404
405    #[test]
406    fn test_container_with_id() {
407        let container = SkillContainer::with_id("container-123");
408        assert_eq!(container.id, Some("container-123".to_string()));
409    }
410
411    #[test]
412    fn test_container_set_id() {
413        let mut container = SkillContainer::new();
414        container.set_id("new-id");
415        assert_eq!(container.id, Some("new-id".to_string()));
416    }
417
418    #[test]
419    fn test_container_skills_by_type() {
420        let mut container = SkillContainer::new();
421        container.add_anthropic("xlsx").ok();
422        container.add_anthropic("pptx").ok();
423        container.add_custom("my-skill").ok();
424
425        let anthropic = container.skills_by_type(SkillType::Anthropic);
426        assert_eq!(anthropic.len(), 2);
427
428        let custom = container.skills_by_type(SkillType::Custom);
429        assert_eq!(custom.len(), 1);
430
431        assert_eq!(container.anthropic_count(), 2);
432        assert_eq!(container.custom_count(), 1);
433    }
434
435    #[test]
436    fn test_container_skill_ids() {
437        let mut container = SkillContainer::new();
438        container.add_skill(SkillSpec::custom("skill1")).ok();
439        container.add_skill(SkillSpec::custom("skill2")).ok();
440        container.add_skill(SkillSpec::custom("skill3")).ok();
441
442        let ids = container.skill_ids();
443        assert_eq!(ids, vec!["skill1", "skill2", "skill3"]);
444    }
445
446    #[test]
447    fn test_container_validation() {
448        let mut container = SkillContainer::new();
449        for i in 0..8 {
450            container
451                .add_skill(SkillSpec::custom(format!("skill{}", i)))
452                .ok();
453        }
454        container.validate().unwrap();
455    }
456
457    #[test]
458    fn test_skill_spec_roundtrip() {
459        // Test serialization/deserialization roundtrip
460        let spec = SkillSpec {
461            skill_type: SkillType::Custom,
462            skill_id: "my-skill".to_string(),
463            version: SkillVersion::Specific("1759178010641129".to_string()),
464        };
465
466        let json = serde_json::to_string(&spec).unwrap();
467        let deserialized: SkillSpec = serde_json::from_str(&json).unwrap();
468
469        assert_eq!(deserialized.skill_id, "my-skill");
470        assert_eq!(deserialized.skill_type, SkillType::Custom);
471        assert_eq!(
472            deserialized.version,
473            SkillVersion::Specific("1759178010641129".to_string())
474        );
475    }
476
477    #[test]
478    fn test_container_serialization() {
479        let mut container = SkillContainer::new();
480        container.add_anthropic("xlsx").ok();
481        container.add_custom("my-skill").ok();
482
483        let json = serde_json::to_string(&container).unwrap();
484        let deserialized: SkillContainer = serde_json::from_str(&json).unwrap();
485
486        assert_eq!(deserialized.len(), 2);
487        assert!(deserialized.has_skill("xlsx"));
488        assert!(deserialized.has_skill("my-skill"));
489    }
490
491    #[test]
492    fn test_skill_source_reference_roundtrip() {
493        let source = SkillSource::Reference {
494            skill_id: "my-skill".to_string(),
495            version: SkillVersion::Latest,
496        };
497        let json = serde_json::to_string(&source).unwrap();
498        let deserialized: SkillSource = serde_json::from_str(&json).unwrap();
499        assert_eq!(source, deserialized);
500    }
501
502    #[test]
503    fn test_skill_source_inline_roundtrip() {
504        let source = SkillSource::Inline {
505            bundle_b64: "UEsFBgAAAAAAAA==".to_string(),
506            sha256: Some("abc123".to_string()),
507        };
508        let json = serde_json::to_string(&source).unwrap();
509        assert!(json.contains("\"type\":\"inline\""));
510        let deserialized: SkillSource = serde_json::from_str(&json).unwrap();
511        assert_eq!(source, deserialized);
512    }
513
514    #[test]
515    fn test_skill_source_inline_no_sha() {
516        let source = SkillSource::Inline {
517            bundle_b64: "UEsFBgAAAAAAAA==".to_string(),
518            sha256: None,
519        };
520        let json = serde_json::to_string(&source).unwrap();
521        assert!(!json.contains("sha256"));
522        let deserialized: SkillSource = serde_json::from_str(&json).unwrap();
523        assert_eq!(source, deserialized);
524    }
525
526    #[test]
527    fn test_add_inline_with_sha() {
528        let mut container = SkillContainer::new();
529        container
530            .add_inline("UEsFBgAAAAAAAA==".to_string(), Some("deadbeef".to_string()))
531            .unwrap();
532
533        assert_eq!(container.len(), 1);
534        assert!(container.has_skill("deadbeef"));
535        assert_eq!(container.inline_bundles.len(), 1);
536        assert!(matches!(
537            &container.inline_bundles[0],
538            SkillSource::Inline { sha256: Some(h), .. } if h == "deadbeef"
539        ));
540    }
541
542    #[test]
543    fn test_add_inline_without_sha() {
544        let mut container = SkillContainer::new();
545        container
546            .add_inline("UEsFBgAAAAAAAA==".to_string(), None)
547            .unwrap();
548
549        assert_eq!(container.len(), 1);
550        assert!(container.has_skill("inline-0"));
551        assert_eq!(container.inline_bundles.len(), 1);
552    }
553
554    #[test]
555    fn test_add_inline_max_skills() {
556        let mut container = SkillContainer::new();
557        for i in 0..8 {
558            container
559                .add_skill(SkillSpec::custom(format!("skill{i}")))
560                .unwrap();
561        }
562        let result = container.add_inline("data".to_string(), None);
563        assert!(result.is_err());
564    }
565
566    #[test]
567    fn test_container_serialization_with_inline_bundles() {
568        let mut container = SkillContainer::new();
569        container.add_anthropic("xlsx").unwrap();
570        container
571            .add_inline("UEsFBgAAAAAAAA==".to_string(), Some("hash1".to_string()))
572            .unwrap();
573
574        let json = serde_json::to_string(&container).unwrap();
575        assert!(json.contains("inline_bundles"));
576
577        let deserialized: SkillContainer = serde_json::from_str(&json).unwrap();
578        assert_eq!(deserialized.len(), 2);
579        assert_eq!(deserialized.inline_bundles.len(), 1);
580    }
581
582    #[test]
583    fn test_container_serialization_omits_empty_inline_bundles() {
584        let mut container = SkillContainer::new();
585        container.add_anthropic("xlsx").unwrap();
586
587        let json = serde_json::to_string(&container).unwrap();
588        assert!(!json.contains("inline_bundles"));
589    }
590}