harn_vm/skills/
runtime.rs1use std::cell::RefCell;
2use std::collections::BTreeMap;
3use std::rc::Rc;
4use std::sync::Arc;
5
6use crate::value::{VmError, VmValue};
7
8use super::{skill_entry_to_vm, substitute_skill_body, Skill, SubstitutionContext};
9
10pub type SkillFetcher = Arc<dyn Fn(&str) -> Result<Skill, String> + Send + Sync>;
11
12#[derive(Clone)]
13pub struct BoundSkillRegistry {
14 pub registry: VmValue,
15 pub fetcher: SkillFetcher,
16}
17
18pub struct LoadedSkill {
19 pub id: String,
20 pub entry: BTreeMap<String, VmValue>,
21 pub rendered_body: String,
22}
23
24thread_local! {
25 static CURRENT_SKILL_REGISTRY: RefCell<Option<BoundSkillRegistry>> = const { RefCell::new(None) };
26}
27
28pub fn install_current_skill_registry(
29 binding: Option<BoundSkillRegistry>,
30) -> Option<BoundSkillRegistry> {
31 CURRENT_SKILL_REGISTRY.with(|slot| slot.replace(binding))
32}
33
34pub fn current_skill_registry() -> Option<BoundSkillRegistry> {
35 CURRENT_SKILL_REGISTRY.with(|slot| slot.borrow().clone())
36}
37
38pub fn clear_current_skill_registry() {
39 CURRENT_SKILL_REGISTRY.with(|slot| {
40 *slot.borrow_mut() = None;
41 });
42}
43
44pub fn skill_entry_id(entry: &BTreeMap<String, VmValue>) -> String {
45 let name = entry.get("name").map(|v| v.display()).unwrap_or_default();
46 let namespace = entry
47 .get("namespace")
48 .map(|v| v.display())
49 .filter(|value| !value.is_empty());
50 match namespace {
51 Some(ns) => format!("{ns}/{name}"),
52 None => name,
53 }
54}
55
56pub fn resolve_skill_entry(
57 registry: &VmValue,
58 target: &str,
59 builtin_name: &str,
60) -> Result<BTreeMap<String, VmValue>, String> {
61 let dict = registry
62 .as_dict()
63 .ok_or_else(|| format!("{builtin_name}: bound skill registry is not a dict"))?;
64 let skills = match dict.get("skills") {
65 Some(VmValue::List(list)) => list,
66 _ => {
67 return Err(format!("{builtin_name}: bound skill registry is malformed"));
68 }
69 };
70
71 let mut bare_matches: Vec<BTreeMap<String, VmValue>> = Vec::new();
72 for skill in skills.iter() {
73 let Some(entry) = skill.as_dict() else {
74 continue;
75 };
76 if skill_entry_id(entry) == target {
77 return Ok(entry.clone());
78 }
79 if entry
80 .get("name")
81 .map(|value| value.display())
82 .is_some_and(|name| name == target)
83 {
84 bare_matches.push(entry.clone());
85 }
86 }
87
88 match bare_matches.len() {
89 1 => Ok(bare_matches.remove(0)),
90 0 => Err(format!("skill '{target}' not found")),
91 _ => Err(format!(
92 "skill '{target}' is ambiguous; use the fully qualified id from the catalog"
93 )),
94 }
95}
96
97fn entry_has_inline_body(entry: &BTreeMap<String, VmValue>) -> bool {
98 entry
99 .get("body")
100 .map(|value| value.display())
101 .filter(|value| !value.is_empty())
102 .is_some()
103 || entry
104 .get("prompt")
105 .map(|value| value.display())
106 .filter(|value| !value.is_empty())
107 .is_some()
108}
109
110fn body_from_entry(entry: &BTreeMap<String, VmValue>) -> String {
111 entry
112 .get("body")
113 .map(|value| value.display())
114 .filter(|value| !value.is_empty())
115 .or_else(|| {
116 entry
117 .get("prompt")
118 .map(|value| value.display())
119 .filter(|value| !value.is_empty())
120 })
121 .unwrap_or_default()
122}
123
124fn hydrate_skill_entry(
125 entry: BTreeMap<String, VmValue>,
126 fetcher: Option<&SkillFetcher>,
127 builtin_name: &str,
128) -> Result<BTreeMap<String, VmValue>, String> {
129 if entry_has_inline_body(&entry) {
130 return Ok(entry);
131 }
132
133 let skill_id = skill_entry_id(&entry);
134 let Some(fetcher) = fetcher else {
135 return Err(format!(
136 "{builtin_name}: skill '{skill_id}' is not lazily loadable in this scope"
137 ));
138 };
139
140 let loaded = fetcher(&skill_id)?;
141 match skill_entry_to_vm(&loaded) {
142 VmValue::Dict(dict) => Ok((*dict).clone()),
143 _ => Err(format!(
144 "{builtin_name}: failed to hydrate skill '{skill_id}'"
145 )),
146 }
147}
148
149fn render_skill_entry(entry: &BTreeMap<String, VmValue>, session_id: Option<&str>) -> String {
150 let skill_dir = entry
151 .get("skill_dir")
152 .map(|value| value.display())
153 .filter(|value| !value.is_empty());
154 substitute_skill_body(
155 &body_from_entry(entry),
156 &SubstitutionContext {
157 arguments: Vec::new(),
158 skill_dir,
159 session_id: session_id.map(str::to_string),
160 extra_env: Default::default(),
161 },
162 )
163}
164
165pub fn load_bound_skill_by_name(
166 requested: &str,
167 session_id: Option<&str>,
168) -> Result<LoadedSkill, String> {
169 let Some(binding) = current_skill_registry() else {
170 return Err(
171 "load_skill: no skill registry is bound to this scope. Start the VM with discovered skills first."
172 .to_string(),
173 );
174 };
175 load_skill_from_registry(
176 &binding.registry,
177 Some(&binding.fetcher),
178 requested,
179 session_id,
180 "load_skill",
181 )
182}
183
184pub fn load_skill_from_registry(
185 registry: &VmValue,
186 fetcher: Option<&SkillFetcher>,
187 requested: &str,
188 session_id: Option<&str>,
189 builtin_name: &str,
190) -> Result<LoadedSkill, String> {
191 let entry = resolve_skill_entry(registry, requested, builtin_name)?;
192 let entry = hydrate_skill_entry(entry, fetcher, builtin_name)?;
193 let id = skill_entry_id(&entry);
194 let rendered_body = render_skill_entry(&entry, session_id);
195 Ok(LoadedSkill {
196 id,
197 entry,
198 rendered_body,
199 })
200}
201
202pub fn vm_error(message: impl Into<String>) -> VmError {
203 VmError::Thrown(VmValue::String(Rc::from(message.into())))
204}