1use 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#[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 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 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 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 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 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}