Skip to main content

orchestral_runtime/skill/
runtime.rs

1//! Immutable Skill catalog and context-loading runtime for the Generic Agent.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use orchestral_core::agent_protocol::wire::{Digest, ResourceId, RunId};
8use orchestral_core::agent_session::{AgentSessionEvent, AgentSessionRecord};
9use orchestral_core::skill_protocol::{
10    SkillCatalogDescriptor, SkillCompatibility, SkillDependencies, SkillDescriptor, SkillId,
11    SkillLoad, SkillPackage, SkillSource, SkillSourceKind,
12};
13use serde::Deserialize;
14
15const MAX_DISCOVERY_DEPTH: usize = 4;
16
17/// One Host-selected discovery root. Larger precedence wins; ties use the
18/// canonical Skill path as a stable final ordering.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct SkillRoot {
21    pub path: PathBuf,
22    pub source_kind: SkillSourceKind,
23    pub precedence: u32,
24    pub required: bool,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct SkillConflict {
29    pub name: String,
30    pub selected_source: String,
31    pub shadowed_source: String,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum SkillLoadOutcome {
36    Loaded(SkillLoad),
37    AlreadyLoaded(SkillDescriptor),
38}
39
40#[derive(Debug, Clone, Default, PartialEq, Eq)]
41pub struct LoadedSkillSet {
42    by_id: BTreeMap<SkillId, Digest>,
43}
44
45impl LoadedSkillSet {
46    /// Rebuilds the immutable Skill loads visible to one Run. Skill
47    /// instructions are task-local working context: a later Run in the same
48    /// Session starts from the catalog and must explicitly load what it needs.
49    pub fn replay_for_run(
50        records: &[AgentSessionRecord],
51        run_id: &RunId,
52    ) -> Result<Self, SkillRuntimeError> {
53        let mut set = Self::default();
54        for record in records {
55            if record.run_id != *run_id {
56                continue;
57            }
58            let AgentSessionEvent::SkillLoaded { load } = &record.payload else {
59                continue;
60            };
61            load.validate()
62                .map_err(|error| SkillRuntimeError::InvalidPackage(error.to_string()))?;
63            let descriptor = &load.package.descriptor;
64            match set.by_id.get(&descriptor.skill_id) {
65                None => {
66                    set.by_id
67                        .insert(descriptor.skill_id.clone(), descriptor.digest.clone());
68                }
69                Some(previous) if previous == &descriptor.digest => {}
70                Some(_) => {
71                    return Err(SkillRuntimeError::DigestChanged {
72                        name: descriptor.name.clone(),
73                    })
74                }
75            }
76        }
77        Ok(set)
78    }
79
80    pub fn digest_for(&self, skill_id: &SkillId) -> Option<&Digest> {
81        self.by_id.get(skill_id)
82    }
83}
84
85#[derive(Debug, Clone)]
86pub struct SkillRuntime {
87    catalog: SkillCatalogDescriptor,
88    packages_by_name: BTreeMap<String, SkillPackage>,
89    conflicts: Vec<SkillConflict>,
90}
91
92impl SkillRuntime {
93    pub fn from_packages(
94        resource_id: ResourceId,
95        packages: Vec<SkillPackage>,
96    ) -> Result<Self, SkillRuntimeError> {
97        Self::from_selected(resource_id, packages, Vec::new())
98    }
99
100    pub fn discover(
101        resource_id: ResourceId,
102        roots: &[SkillRoot],
103    ) -> Result<Self, SkillRuntimeError> {
104        let mut candidates = Vec::new();
105        let mut seen_files = BTreeSet::new();
106        for root in roots {
107            let canonical_root = match root.path.canonicalize() {
108                Ok(path) => path,
109                Err(error) if !root.required && error.kind() == std::io::ErrorKind::NotFound => {
110                    continue
111                }
112                Err(error) => {
113                    return Err(SkillRuntimeError::Discovery(format!(
114                        "could not resolve Skill root '{}': {error}",
115                        root.path.display()
116                    )))
117                }
118            };
119            if !canonical_root.is_dir() {
120                return Err(SkillRuntimeError::Discovery(format!(
121                    "Skill root is not a directory: {}",
122                    canonical_root.display()
123                )));
124            }
125            let mut files = Vec::new();
126            collect_skill_files(&canonical_root, MAX_DISCOVERY_DEPTH, &mut files)?;
127            files.sort();
128            for file in files {
129                let canonical_file = file.canonicalize().map_err(|error| {
130                    SkillRuntimeError::Discovery(format!(
131                        "could not resolve Skill file '{}': {error}",
132                        file.display()
133                    ))
134                })?;
135                if !canonical_file.starts_with(&canonical_root)
136                    || !seen_files.insert(canonical_file.clone())
137                {
138                    continue;
139                }
140                candidates.push(DiscoveredPackage {
141                    package: parse_skill_file(&canonical_file, root)?,
142                    precedence: root.precedence,
143                    canonical_source: canonical_file.to_string_lossy().to_string(),
144                });
145            }
146        }
147        candidates.sort_by(|left, right| {
148            right
149                .precedence
150                .cmp(&left.precedence)
151                .then_with(|| left.canonical_source.cmp(&right.canonical_source))
152        });
153
154        let mut selected = BTreeMap::<String, SkillPackage>::new();
155        let mut conflicts = Vec::new();
156        for candidate in candidates {
157            let name = candidate.package.descriptor.name.clone();
158            if let Some(existing) = selected.get(&name) {
159                conflicts.push(SkillConflict {
160                    name,
161                    selected_source: existing.descriptor.source.locator.clone(),
162                    shadowed_source: candidate.package.descriptor.source.locator.clone(),
163                });
164            } else {
165                selected.insert(name, candidate.package);
166            }
167        }
168        Self::from_selected(resource_id, selected.into_values().collect(), conflicts)
169    }
170
171    fn from_selected(
172        resource_id: ResourceId,
173        packages: Vec<SkillPackage>,
174        conflicts: Vec<SkillConflict>,
175    ) -> Result<Self, SkillRuntimeError> {
176        let mut packages_by_name = BTreeMap::new();
177        for package in packages {
178            package
179                .validate()
180                .map_err(|error| SkillRuntimeError::InvalidPackage(error.to_string()))?;
181            let name = package.descriptor.name.clone();
182            if packages_by_name.insert(name.clone(), package).is_some() {
183                return Err(SkillRuntimeError::Conflict(format!(
184                    "duplicate Skill name without resolved precedence: {name}"
185                )));
186            }
187        }
188        let catalog = SkillCatalogDescriptor::seal(
189            resource_id,
190            packages_by_name
191                .values()
192                .map(|package| package.descriptor.clone())
193                .collect(),
194        )
195        .map_err(|error| SkillRuntimeError::InvalidPackage(error.to_string()))?;
196        Ok(Self {
197            catalog,
198            packages_by_name,
199            conflicts,
200        })
201    }
202
203    pub fn catalog(&self) -> &SkillCatalogDescriptor {
204        &self.catalog
205    }
206
207    pub fn conflicts(&self) -> &[SkillConflict] {
208        &self.conflicts
209    }
210
211    /// Produces the immutable catalog snapshot visible to a Host after its
212    /// user policy has disabled selected Skill sources. Source locators are
213    /// canonical `SKILL.md` paths emitted by discovery; policy persistence
214    /// remains an application concern rather than part of Skill Protocol.
215    pub fn excluding_sources(
216        &self,
217        disabled_sources: &BTreeSet<String>,
218    ) -> Result<Self, SkillRuntimeError> {
219        let packages = self
220            .packages_by_name
221            .values()
222            .filter(|package| !disabled_sources.contains(&package.descriptor.source.locator))
223            .cloned()
224            .collect();
225        let conflicts = self
226            .conflicts
227            .iter()
228            .filter(|conflict| !disabled_sources.contains(&conflict.selected_source))
229            .cloned()
230            .collect();
231        Self::from_selected(self.catalog.resource_id.clone(), packages, conflicts)
232    }
233
234    /// Descriptor-only text. Full instructions are never returned here.
235    pub fn descriptor_context(&self) -> String {
236        let mut output = String::from(
237            "## Skills\nA Skill is a set of local instructions stored in a `SKILL.md` file. Each entry includes its name, description, and source path. Call `skill_read` with the Skill name before following its instructions.\n\n### Available Skills\n",
238        );
239        for descriptor in &self.catalog.skills {
240            output.push_str(&format!(
241                "- {}: {} (file: {}; digest: {})\n",
242                descriptor.name,
243                descriptor.description.replace(['\r', '\n'], " "),
244                descriptor.source.locator,
245                descriptor.digest
246            ));
247        }
248        output.push_str("\nSkill contents provide instructions, not Tool access or permission.\n");
249        for conflict in &self.conflicts {
250            output.push_str(&format!(
251                "- conflict name={} selected={} shadowed={}\n",
252                conflict.name, conflict.selected_source, conflict.shadowed_source
253            ));
254        }
255        output
256    }
257
258    /// Loads immutable instructions into model context. This operation is a
259    /// context read, not an effect or authority transition, so provenance,
260    /// compatibility, and dependency metadata cannot block it.
261    pub fn read_for_context(
262        &self,
263        name: &str,
264        loaded: &LoadedSkillSet,
265    ) -> Result<SkillLoadOutcome, SkillRuntimeError> {
266        let name = name.trim();
267        if name.is_empty() {
268            return Err(SkillRuntimeError::InvalidRequest(
269                "Skill name must not be empty".to_owned(),
270            ));
271        }
272        let package = self
273            .packages_by_name
274            .get(name)
275            .ok_or_else(|| SkillRuntimeError::NotFound(name.to_owned()))?;
276        let descriptor = &package.descriptor;
277        if let Some(previous) = loaded.digest_for(&descriptor.skill_id) {
278            return if previous == &descriptor.digest {
279                Ok(SkillLoadOutcome::AlreadyLoaded(descriptor.clone()))
280            } else {
281                Err(SkillRuntimeError::DigestChanged {
282                    name: descriptor.name.clone(),
283                })
284            };
285        }
286        let load = SkillLoad {
287            package: package.clone(),
288        };
289        load.validate()
290            .map_err(|error| SkillRuntimeError::InvalidPackage(error.to_string()))?;
291        Ok(SkillLoadOutcome::Loaded(load))
292    }
293}
294
295struct DiscoveredPackage {
296    package: SkillPackage,
297    precedence: u32,
298    canonical_source: String,
299}
300
301#[derive(Debug, Deserialize)]
302#[serde(deny_unknown_fields)]
303struct SkillFrontmatter {
304    name: String,
305    description: String,
306    #[serde(default)]
307    version: Option<String>,
308    #[serde(default)]
309    compatibility: SkillCompatibility,
310    #[serde(default)]
311    dependencies: SkillDependencies,
312    #[serde(default)]
313    license: Option<String>,
314    #[serde(default)]
315    metadata: BTreeMap<String, serde_yaml::Value>,
316}
317
318fn parse_skill_file(path: &Path, root: &SkillRoot) -> Result<SkillPackage, SkillRuntimeError> {
319    let content = fs::read_to_string(path).map_err(|error| {
320        SkillRuntimeError::Discovery(format!("could not read '{}': {error}", path.display()))
321    })?;
322    let rest = content.strip_prefix("---\n").ok_or_else(|| {
323        SkillRuntimeError::Parse(format!(
324            "Skill '{}' requires YAML frontmatter",
325            path.display()
326        ))
327    })?;
328    let (frontmatter, body) = rest.split_once("\n---\n").ok_or_else(|| {
329        SkillRuntimeError::Parse(format!(
330            "Skill '{}' has unterminated YAML frontmatter",
331            path.display()
332        ))
333    })?;
334    let parsed = serde_yaml::from_str::<SkillFrontmatter>(frontmatter).map_err(|error| {
335        SkillRuntimeError::Parse(format!(
336            "Skill '{}' frontmatter is invalid: {error}",
337            path.display()
338        ))
339    })?;
340    let version = parsed.version.or_else(|| {
341        parsed
342            .metadata
343            .get("version")
344            .and_then(serde_yaml::Value::as_str)
345            .map(str::to_owned)
346    });
347    // License is provenance metadata and never execution authority.
348    let _ = &parsed.license;
349    SkillPackage::seal(
350        SkillId::new(parsed.name.clone()),
351        parsed.name,
352        parsed.description,
353        version,
354        SkillSource {
355            kind: root.source_kind,
356            locator: path.to_string_lossy().to_string(),
357        },
358        parsed.compatibility,
359        parsed.dependencies,
360        body.trim(),
361    )
362    .map_err(|error| SkillRuntimeError::InvalidPackage(format!("{}: {error}", path.display())))
363}
364
365fn collect_skill_files(
366    directory: &Path,
367    depth: usize,
368    output: &mut Vec<PathBuf>,
369) -> Result<(), SkillRuntimeError> {
370    if depth == 0 {
371        return Ok(());
372    }
373    let mut entries = fs::read_dir(directory)
374        .map_err(|error| {
375            SkillRuntimeError::Discovery(format!(
376                "could not scan Skill directory '{}': {error}",
377                directory.display()
378            ))
379        })?
380        .collect::<Result<Vec<_>, _>>()
381        .map_err(|error| SkillRuntimeError::Discovery(error.to_string()))?;
382    entries.sort_by_key(std::fs::DirEntry::path);
383    for entry in entries {
384        let path = entry.path();
385        let file_type = entry.file_type().map_err(|error| {
386            SkillRuntimeError::Discovery(format!(
387                "could not inspect Skill path '{}': {error}",
388                path.display()
389            ))
390        })?;
391        if file_type.is_symlink() {
392            continue;
393        }
394        if file_type.is_dir() {
395            collect_skill_files(&path, depth - 1, output)?;
396        } else if path
397            .file_name()
398            .and_then(|name| name.to_str())
399            .is_some_and(|name| name.eq_ignore_ascii_case("SKILL.md"))
400        {
401            output.push(path);
402        }
403    }
404    Ok(())
405}
406
407#[derive(Debug, thiserror::Error)]
408#[non_exhaustive]
409pub enum SkillRuntimeError {
410    #[error("Skill discovery failed: {0}")]
411    Discovery(String),
412    #[error("Skill parse failed: {0}")]
413    Parse(String),
414    #[error("invalid Skill package: {0}")]
415    InvalidPackage(String),
416    #[error("Skill conflict: {0}")]
417    Conflict(String),
418    #[error("invalid Skill read request: {0}")]
419    InvalidRequest(String),
420    #[error("Skill not found: {0}")]
421    NotFound(String),
422    #[error("Skill '{name}' changed digest within one immutable catalog binding")]
423    DigestChanged { name: String },
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429    use orchestral_core::agent_protocol::wire::{AgentSessionId, RunId};
430    use orchestral_core::agent_session::{AgentSessionEventDraft, AgentSessionEventId};
431    use orchestral_core::tool_protocol::{HostToolPolicy, RunToolGrant, ToolPolicyBounds};
432    use std::time::{SystemTime, UNIX_EPOCH};
433
434    fn temp_dir(label: &str) -> PathBuf {
435        let nonce = SystemTime::now()
436            .duration_since(UNIX_EPOCH)
437            .unwrap_or_default()
438            .as_nanos();
439        let path = std::env::temp_dir().join(format!(
440            "orchestral-skill-runtime-{label}-{}-{nonce}",
441            std::process::id()
442        ));
443        fs::create_dir_all(&path).unwrap();
444        path
445    }
446
447    fn write_skill(root: &Path, directory: &str, name: &str, body: &str) {
448        let directory = root.join(directory);
449        fs::create_dir_all(&directory).unwrap();
450        fs::write(
451            directory.join("SKILL.md"),
452            format!(
453                "---\nname: {name}\ndescription: {name} description\nversion: 1.0.0\n---\n{body}\n"
454            ),
455        )
456        .unwrap();
457    }
458
459    #[test]
460    fn one_thousand_conflict_resolutions_are_deterministic_and_visible() {
461        let low = temp_dir("low");
462        let high = temp_dir("high");
463        write_skill(&low, "demo", "demo", "low instructions");
464        write_skill(&high, "demo", "demo", "high instructions");
465        let roots = vec![
466            SkillRoot {
467                path: low.clone(),
468                source_kind: SkillSourceKind::Workspace,
469                precedence: 10,
470                required: true,
471            },
472            SkillRoot {
473                path: high.clone(),
474                source_kind: SkillSourceKind::UserConfigured,
475                precedence: 20,
476                required: true,
477            },
478        ];
479        let baseline = SkillRuntime::discover(ResourceId::new("skills"), &roots).unwrap();
480        for _ in 0..1_000 {
481            let observed = SkillRuntime::discover(ResourceId::new("skills"), &roots).unwrap();
482            assert_eq!(baseline.catalog(), observed.catalog());
483            assert_eq!(baseline.conflicts(), observed.conflicts());
484        }
485        assert_eq!(baseline.conflicts().len(), 1);
486        let canonical_high = high.canonicalize().unwrap();
487        assert!(baseline.conflicts()[0]
488            .selected_source
489            .starts_with(canonical_high.to_string_lossy().as_ref()));
490        let _ = fs::remove_dir_all(low);
491        let _ = fs::remove_dir_all(high);
492    }
493
494    #[test]
495    fn disabled_source_is_absent_from_catalog_and_context_reads() {
496        let root = temp_dir("disabled-source");
497        write_skill(&root, "demo", "demo", "private demo instructions");
498        write_skill(&root, "other", "other", "other instructions");
499        let discovered = SkillRuntime::discover(
500            ResourceId::new("skills"),
501            &[SkillRoot {
502                path: root.clone(),
503                source_kind: SkillSourceKind::Workspace,
504                precedence: 1,
505                required: true,
506            }],
507        )
508        .unwrap();
509        let disabled_path = root
510            .join("demo/SKILL.md")
511            .canonicalize()
512            .unwrap()
513            .to_string_lossy()
514            .into_owned();
515
516        let effective = discovered
517            .excluding_sources(&BTreeSet::from([disabled_path.clone()]))
518            .unwrap();
519
520        assert_eq!(effective.catalog().skills.len(), 1);
521        assert_eq!(effective.catalog().skills[0].name, "other");
522        assert!(!effective.descriptor_context().contains(&disabled_path));
523        assert!(matches!(
524            effective.read_for_context("demo", &LoadedSkillSet::default()),
525            Err(SkillRuntimeError::NotFound(name)) if name == "demo"
526        ));
527        let _ = fs::remove_dir_all(root);
528    }
529
530    #[test]
531    fn free_text_compatibility_is_rejected_instead_of_downgraded() {
532        let root = temp_dir("compatibility");
533        let directory = root.join("demo");
534        fs::create_dir_all(&directory).unwrap();
535        fs::write(
536            directory.join("SKILL.md"),
537            "---\nname: demo\ndescription: demo\ncompatibility: Requires Python\n---\nbody\n",
538        )
539        .unwrap();
540        let result = SkillRuntime::discover(
541            ResourceId::new("skills"),
542            &[SkillRoot {
543                path: root.clone(),
544                source_kind: SkillSourceKind::Workspace,
545                precedence: 1,
546                required: true,
547            }],
548        );
549        assert!(matches!(result, Err(SkillRuntimeError::Parse(_))));
550        let _ = fs::remove_dir_all(root);
551    }
552
553    #[test]
554    fn one_thousand_loads_are_complete_descriptor_only_and_never_expand_authority() {
555        for index in 0..1_000 {
556            let name = format!("skill-{index}");
557            let tool = format!("tool-{index}");
558            let mcp_server = format!("mcp-{index}");
559            let instructions = format!("FULL-INSTRUCTIONS-SENTINEL-{index}");
560            let locator = format!("configured:/skills/{name}/SKILL.md");
561            let package = SkillPackage::seal(
562                SkillId::new(&name),
563                &name,
564                format!("descriptor-{index}"),
565                Some(format!("1.0.{index}")),
566                SkillSource {
567                    kind: SkillSourceKind::UserConfigured,
568                    locator: locator.clone(),
569                },
570                SkillCompatibility {
571                    operating_systems: BTreeSet::from([format!("other-os-{index}")]),
572                    required_programs: BTreeSet::from([format!("missing-program-{index}")]),
573                    required_environment: BTreeSet::from([format!("MISSING_ENV_{index}")]),
574                    ..SkillCompatibility::default()
575                },
576                SkillDependencies {
577                    tools: BTreeSet::from([tool]),
578                    mcp_servers: BTreeSet::from([mcp_server]),
579                },
580                &instructions,
581            )
582            .unwrap();
583            let expected_digest = package.descriptor.digest.clone();
584            let expected_skill_id = package.descriptor.skill_id.clone();
585            let runtime = SkillRuntime::from_packages(
586                ResourceId::new(format!("catalog-{index}")),
587                vec![package],
588            )
589            .unwrap();
590
591            let descriptor_context = runtime.descriptor_context();
592            assert!(descriptor_context.contains(&name));
593            assert!(descriptor_context.contains(expected_digest.as_str()));
594            assert!(!descriptor_context.contains(&instructions));
595
596            let mut authority = ToolPolicyBounds::default();
597            authority
598                .allowed_credentials
599                .insert(format!("credential-{index}"));
600            authority
601                .environment
602                .allowed_variables
603                .insert(format!("ENV_{index}"));
604            let host_policy = HostToolPolicy {
605                bounds: authority.clone(),
606            };
607            let run_grant = RunToolGrant { bounds: authority };
608            let host_policy_before = host_policy.clone();
609            let run_grant_before = run_grant.clone();
610
611            let outcome = runtime
612                .read_for_context(&name, &LoadedSkillSet::default())
613                .unwrap();
614            assert_eq!(host_policy, host_policy_before);
615            assert_eq!(run_grant, run_grant_before);
616
617            let SkillLoadOutcome::Loaded(load) = outcome else {
618                panic!("fresh Skill unexpectedly reported AlreadyLoaded");
619            };
620            assert_eq!(load.package.descriptor.skill_id, expected_skill_id);
621            assert_eq!(load.package.descriptor.source.locator, locator);
622            assert_eq!(
623                load.package.descriptor.version.as_deref(),
624                Some(format!("1.0.{index}").as_str())
625            );
626            assert_eq!(load.package.descriptor.digest, expected_digest);
627
628            let record = AgentSessionRecord::seal(
629                AgentSessionEventDraft {
630                    event_id: AgentSessionEventId::new(format!("skill-loaded-{index}")),
631                    session_id: AgentSessionId::new(format!("session-{index}")),
632                    run_id: RunId::new(format!("run-{index}")),
633                    payload: AgentSessionEvent::SkillLoaded {
634                        load: Box::new(load),
635                    },
636                },
637                1,
638            )
639            .unwrap();
640            record.validate().unwrap();
641            assert!(matches!(
642                record.payload,
643                AgentSessionEvent::SkillLoaded { .. }
644            ));
645        }
646    }
647
648    #[test]
649    fn one_thousand_digest_changes_are_rejected_within_a_loaded_set() {
650        for index in 0..1_000 {
651            let name = format!("skill-{index}");
652            let previous = test_package(
653                &name,
654                SkillCompatibility::default(),
655                "previous instructions",
656            );
657            let replacement = test_package(
658                &name,
659                SkillCompatibility::default(),
660                "replacement instructions",
661            );
662            let mut loaded = LoadedSkillSet::default();
663            loaded.by_id.insert(
664                previous.descriptor.skill_id.clone(),
665                previous.descriptor.digest,
666            );
667            let replacement_runtime = SkillRuntime::from_packages(
668                ResourceId::new(format!("replacement-catalog-{index}")),
669                vec![replacement],
670            )
671            .unwrap();
672            assert!(matches!(
673                replacement_runtime.read_for_context(&name, &loaded),
674                Err(SkillRuntimeError::DigestChanged { .. })
675            ));
676        }
677    }
678
679    fn test_package(
680        name: &str,
681        compatibility: SkillCompatibility,
682        instructions: &str,
683    ) -> SkillPackage {
684        SkillPackage::seal(
685            SkillId::new(name),
686            name,
687            format!("{name} description"),
688            Some("1.0.0".to_owned()),
689            SkillSource {
690                kind: SkillSourceKind::Workspace,
691                locator: format!("configured:/skills/{name}/SKILL.md"),
692            },
693            compatibility,
694            SkillDependencies::default(),
695            instructions,
696        )
697        .unwrap()
698    }
699}