Skip to main content

a3s_code_core/capability/
sdk_batch.rs

1//! Cross-language capability batch wire format (SDK-CAP1).
2//!
3//! Hosts that cannot transport Rust trait objects stage serializable Skill
4//! values into one atomic [`SessionCapabilityBatch`]. Tool, Hook, MCP, and
5//! other callback-backed kinds remain Rust-host-only until typed adapters land.
6
7use std::sync::Arc;
8
9use serde::{Deserialize, Serialize};
10use sha2::{Digest, Sha256};
11
12use crate::skills::{Skill, SkillKind};
13
14use super::{
15    CapabilityContribution, CapabilityDescriptor, CapabilityKind, CapabilityRuntimeError,
16    CapabilitySet, CapabilitySetError, CapabilitySource, CapabilityValue, CodeCatalogGeneration,
17    SessionCapabilityBatch, Sha256Digest,
18};
19
20pub const SDK_CAPABILITY_BATCH_SCHEMA: &str = "a3s.code.sdk_capability_batch.v1";
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "camelCase")]
24pub struct SdkCapabilityBatchV1 {
25    pub schema_version: u32,
26    pub generation: u64,
27    pub source_id: String,
28    #[serde(default)]
29    pub skills: Vec<SdkSkillCapabilityV1>,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "camelCase")]
34pub struct SdkSkillCapabilityV1 {
35    pub local_id: String,
36    pub name: String,
37    #[serde(default)]
38    pub kind: String,
39    pub content: String,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "camelCase")]
44pub struct SdkCapabilityCommitReceiptV1 {
45    pub previous_generation: u64,
46    pub committed_generation: u64,
47    pub previous_digest: String,
48    pub committed_digest: String,
49}
50
51impl SdkCapabilityCommitReceiptV1 {
52    pub fn from_receipt(receipt: &super::CapabilityCommitReceipt) -> Self {
53        Self {
54            previous_generation: receipt.previous().generation().get(),
55            committed_generation: receipt.committed().generation().get(),
56            previous_digest: receipt.previous().digest().as_str().to_string(),
57            committed_digest: receipt.committed().digest().as_str().to_string(),
58        }
59    }
60}
61
62#[derive(Debug, thiserror::Error)]
63pub enum SdkCapabilityBatchError {
64    #[error("unsupported SDK capability batch schema version {found}; expected 1")]
65    UnsupportedSchema { found: u32 },
66    #[error("SDK capability batch must stage at least one Skill")]
67    EmptyBatch,
68    #[error("SDK skill name must not be empty")]
69    EmptySkillName,
70    #[error("unknown skill kind '{kind}'; use instruction, persona, or tool")]
71    UnknownSkillKind { kind: String },
72    #[error(transparent)]
73    CapabilitySet(#[from] CapabilitySetError),
74    #[error(transparent)]
75    Runtime(#[from] CapabilityRuntimeError),
76}
77
78impl SdkCapabilityBatchV1 {
79    pub fn into_session_batch(self) -> Result<SessionCapabilityBatch, SdkCapabilityBatchError> {
80        if self.schema_version != 1 {
81            return Err(SdkCapabilityBatchError::UnsupportedSchema {
82                found: self.schema_version,
83            });
84        }
85        if self.skills.is_empty() {
86            return Err(SdkCapabilityBatchError::EmptyBatch);
87        }
88
89        let source_revision = digest_bytes(self.source_id.as_bytes())?;
90        let source = CapabilitySource::host(self.source_id.trim(), source_revision)?;
91        let mut descriptors = Vec::with_capacity(self.skills.len());
92        let mut staged = Vec::with_capacity(self.skills.len());
93
94        for skill_spec in self.skills {
95            let skill = skill_from_sdk(skill_spec)?;
96            let local_id = skill.name.clone();
97            let public_name = skill.name.clone();
98            let surface = digest_bytes(
99                format!("{}:{}:{}", skill.name, skill.kind_as_str(), skill.content).as_bytes(),
100            )?;
101            let descriptor = CapabilityDescriptor::new(
102                &source,
103                CapabilityKind::Skill,
104                local_id,
105                public_name,
106                surface,
107                [],
108            )?;
109            let id = descriptor.id().clone();
110            descriptors.push(descriptor);
111            staged.push((id, CapabilityValue::Skill(skill)));
112        }
113
114        let set = CapabilitySet::from_contributions(
115            CodeCatalogGeneration::new(self.generation),
116            [CapabilityContribution::new(source, descriptors)?],
117        )?;
118        let mut batch = SessionCapabilityBatch::new(set)?;
119        for (id, value) in staged {
120            batch.stage_value(id, value)?;
121        }
122        Ok(batch)
123    }
124}
125
126fn skill_from_sdk(spec: SdkSkillCapabilityV1) -> Result<Arc<Skill>, SdkCapabilityBatchError> {
127    let name = if spec.name.trim().is_empty() {
128        spec.local_id.trim().to_string()
129    } else {
130        spec.name.trim().to_string()
131    };
132    if name.is_empty() {
133        return Err(SdkCapabilityBatchError::EmptySkillName);
134    }
135    let kind = match spec.kind.trim() {
136        "" | "instruction" => SkillKind::Instruction,
137        "persona" => SkillKind::Persona,
138        "tool" => SkillKind::Tool,
139        other => {
140            return Err(SdkCapabilityBatchError::UnknownSkillKind {
141                kind: other.to_string(),
142            })
143        }
144    };
145    Ok(Arc::new(Skill {
146        name,
147        description: String::new(),
148        allowed_tools: None,
149        disable_model_invocation: false,
150        kind,
151        content: spec.content,
152        tags: Vec::new(),
153        version: None,
154    }))
155}
156
157trait SkillKindLabel {
158    fn kind_as_str(&self) -> &'static str;
159}
160
161impl SkillKindLabel for Skill {
162    fn kind_as_str(&self) -> &'static str {
163        match self.kind {
164            SkillKind::Instruction => "instruction",
165            SkillKind::Persona => "persona",
166            SkillKind::Tool => "tool",
167        }
168    }
169}
170
171fn digest_bytes(bytes: &[u8]) -> Result<Sha256Digest, CapabilitySetError> {
172    Sha256Digest::new(format!("sha256:{:x}", Sha256::digest(bytes)))
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn sdk_skill_batch_builds_session_capability_batch() {
181        let batch = SdkCapabilityBatchV1 {
182            schema_version: 1,
183            generation: 3,
184            source_id: "sdk-host".to_string(),
185            skills: vec![SdkSkillCapabilityV1 {
186                local_id: "type-hints".to_string(),
187                name: "type-hints".to_string(),
188                kind: "instruction".to_string(),
189                content: "Prefer explicit types.".to_string(),
190            }],
191        }
192        .into_session_batch()
193        .expect("skill batch must build");
194        assert_eq!(batch.len(), 1);
195        assert_eq!(batch.target().generation().get(), 3);
196    }
197
198    #[test]
199    fn sdk_batch_rejects_empty_and_bad_schema() {
200        let empty = SdkCapabilityBatchV1 {
201            schema_version: 1,
202            generation: 1,
203            source_id: "sdk-host".to_string(),
204            skills: Vec::new(),
205        };
206        assert!(matches!(
207            empty.into_session_batch(),
208            Err(SdkCapabilityBatchError::EmptyBatch)
209        ));
210        let bad = SdkCapabilityBatchV1 {
211            schema_version: 9,
212            generation: 1,
213            source_id: "sdk-host".to_string(),
214            skills: vec![SdkSkillCapabilityV1 {
215                local_id: "x".to_string(),
216                name: "x".to_string(),
217                kind: "instruction".to_string(),
218                content: "x".to_string(),
219            }],
220        };
221        assert!(matches!(
222            bad.into_session_batch(),
223            Err(SdkCapabilityBatchError::UnsupportedSchema { found: 9 })
224        ));
225    }
226}