1use std::cell::RefCell;
2use std::collections::BTreeMap;
3use std::rc::Rc;
4use std::sync::Arc;
5
6use crate::value::{ErrorCategory, 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
24#[derive(Debug, Clone, Default)]
25pub struct LoadSkillOptions {
26 pub session_id: Option<String>,
27 pub require_signature: bool,
28 pub model_invocation: bool,
29}
30
31thread_local! {
32 static CURRENT_SKILL_REGISTRY: RefCell<Option<BoundSkillRegistry>> = const { RefCell::new(None) };
33}
34
35pub fn install_current_skill_registry(
36 binding: Option<BoundSkillRegistry>,
37) -> Option<BoundSkillRegistry> {
38 CURRENT_SKILL_REGISTRY.with(|slot| slot.replace(binding))
39}
40
41pub fn current_skill_registry() -> Option<BoundSkillRegistry> {
42 CURRENT_SKILL_REGISTRY.with(|slot| slot.borrow().clone())
43}
44
45pub fn clear_current_skill_registry() {
46 CURRENT_SKILL_REGISTRY.with(|slot| {
47 *slot.borrow_mut() = None;
48 });
49}
50
51pub fn skill_entry_id(entry: &BTreeMap<String, VmValue>) -> String {
52 let name = entry.get("name").map(|v| v.display()).unwrap_or_default();
53 let namespace = entry
54 .get("namespace")
55 .map(|v| v.display())
56 .filter(|value| !value.is_empty());
57 match namespace {
58 Some(ns) => format!("{ns}/{name}"),
59 None => name,
60 }
61}
62
63pub fn resolve_skill_entry(
64 registry: &VmValue,
65 target: &str,
66 builtin_name: &str,
67) -> Result<BTreeMap<String, VmValue>, String> {
68 let dict = registry
69 .as_dict()
70 .ok_or_else(|| format!("{builtin_name}: bound skill registry is not a dict"))?;
71 let skills = match dict.get("skills") {
72 Some(VmValue::List(list)) => list,
73 _ => {
74 return Err(format!("{builtin_name}: bound skill registry is malformed"));
75 }
76 };
77
78 let mut bare_matches: Vec<BTreeMap<String, VmValue>> = Vec::new();
79 for skill in skills.iter() {
80 let Some(entry) = skill.as_dict() else {
81 continue;
82 };
83 if skill_entry_id(entry) == target {
84 return Ok(entry.clone());
85 }
86 if entry
87 .get("name")
88 .map(|value| value.display())
89 .is_some_and(|name| name == target)
90 {
91 bare_matches.push(entry.clone());
92 }
93 }
94
95 match bare_matches.len() {
96 1 => Ok(bare_matches.remove(0)),
97 0 => Err(format!("skill_not_found: skill '{target}' not found")),
98 _ => Err(format!(
99 "skill '{target}' is ambiguous; use the fully qualified id from the catalog"
100 )),
101 }
102}
103
104fn entry_has_inline_body(entry: &BTreeMap<String, VmValue>) -> bool {
105 entry
106 .get("body")
107 .map(|value| value.display())
108 .filter(|value| !value.is_empty())
109 .is_some()
110 || entry
111 .get("prompt")
112 .map(|value| value.display())
113 .filter(|value| !value.is_empty())
114 .is_some()
115}
116
117fn body_from_entry(entry: &BTreeMap<String, VmValue>) -> String {
118 entry
119 .get("body")
120 .map(|value| value.display())
121 .filter(|value| !value.is_empty())
122 .or_else(|| {
123 entry
124 .get("prompt")
125 .map(|value| value.display())
126 .filter(|value| !value.is_empty())
127 })
128 .unwrap_or_default()
129}
130
131fn hydrate_skill_entry(
132 entry: BTreeMap<String, VmValue>,
133 fetcher: Option<&SkillFetcher>,
134 builtin_name: &str,
135) -> Result<BTreeMap<String, VmValue>, String> {
136 if entry_has_inline_body(&entry) {
137 return Ok(entry);
138 }
139
140 let skill_id = skill_entry_id(&entry);
141 let Some(fetcher) = fetcher else {
142 return Err(format!(
143 "{builtin_name}: skill '{skill_id}' is not lazily loadable in this scope"
144 ));
145 };
146
147 let loaded = fetcher(&skill_id)?;
148 match skill_entry_to_vm(&loaded) {
149 VmValue::Dict(dict) => {
150 let mut hydrated = (*dict).clone();
151 for (key, value) in entry {
152 hydrated.entry(key).or_insert(value);
153 }
154 Ok(hydrated)
155 }
156 _ => Err(format!(
157 "{builtin_name}: failed to hydrate skill '{skill_id}'"
158 )),
159 }
160}
161
162fn render_skill_entry(entry: &BTreeMap<String, VmValue>, session_id: Option<&str>) -> String {
163 let skill_dir = entry
164 .get("skill_dir")
165 .map(|value| value.display())
166 .filter(|value| !value.is_empty());
167 substitute_skill_body(
168 &body_from_entry(entry),
169 &SubstitutionContext {
170 arguments: Vec::new(),
171 skill_dir,
172 session_id: session_id.map(str::to_string),
173 extra_env: Default::default(),
174 },
175 )
176}
177
178pub fn load_bound_skill_by_name(
179 requested: &str,
180 session_id: Option<&str>,
181) -> Result<LoadedSkill, String> {
182 load_bound_skill_by_name_with_options(
183 requested,
184 LoadSkillOptions {
185 session_id: session_id.map(str::to_string),
186 require_signature: false,
187 model_invocation: false,
188 },
189 )
190}
191
192pub fn load_bound_skill_by_name_with_options(
193 requested: &str,
194 options: LoadSkillOptions,
195) -> Result<LoadedSkill, String> {
196 let Some(binding) = current_skill_registry() else {
197 return Err(
198 "load_skill: no skill registry is bound to this scope. Start the VM with discovered skills first."
199 .to_string(),
200 );
201 };
202 load_skill_from_registry_with_options(
203 &binding.registry,
204 Some(&binding.fetcher),
205 requested,
206 options,
207 "load_skill",
208 )
209}
210
211pub fn load_skill_from_registry(
212 registry: &VmValue,
213 fetcher: Option<&SkillFetcher>,
214 requested: &str,
215 session_id: Option<&str>,
216 builtin_name: &str,
217) -> Result<LoadedSkill, String> {
218 load_skill_from_registry_with_options(
219 registry,
220 fetcher,
221 requested,
222 LoadSkillOptions {
223 session_id: session_id.map(str::to_string),
224 require_signature: false,
225 model_invocation: false,
226 },
227 builtin_name,
228 )
229}
230
231pub fn load_skill_from_registry_with_options(
232 registry: &VmValue,
233 fetcher: Option<&SkillFetcher>,
234 requested: &str,
235 options: LoadSkillOptions,
236 builtin_name: &str,
237) -> Result<LoadedSkill, String> {
238 let entry = resolve_skill_entry(registry, requested, builtin_name)?;
239 let id = skill_entry_id(&entry);
240 if options.model_invocation && vm_bool_field(&entry, "disable_model_invocation") {
241 return Err(format!(
242 "skill_model_invocation_disabled: skill '{id}' cannot be loaded by a model"
243 ));
244 }
245 let require_signature = options.require_signature || vm_bool_field(&entry, "require_signature");
246 if require_signature {
247 let signed = vm_provenance_bool(&entry, "signed");
248 let trusted = vm_provenance_bool(&entry, "trusted");
249 if !signed || !trusted {
250 record_skill_loaded_event(
251 options.session_id.as_deref(),
252 &id,
253 &entry,
254 Some("UnsignedSkillError"),
255 );
256 return Err(format!(
257 "UnsignedSkillError: skill '{id}' requires a trusted signature"
258 ));
259 }
260 }
261 let entry = hydrate_skill_entry(entry, fetcher, builtin_name)?;
262 record_skill_loaded_event(options.session_id.as_deref(), &id, &entry, None);
263 let rendered_body = render_skill_entry(&entry, options.session_id.as_deref());
264 Ok(LoadedSkill {
265 id,
266 entry,
267 rendered_body,
268 })
269}
270
271fn vm_bool_field(entry: &BTreeMap<String, VmValue>, key: &str) -> bool {
272 matches!(entry.get(key), Some(VmValue::Bool(true)))
273}
274
275fn vm_provenance(entry: &BTreeMap<String, VmValue>) -> Option<&BTreeMap<String, VmValue>> {
276 entry.get("provenance").and_then(VmValue::as_dict)
277}
278
279fn vm_provenance_bool(entry: &BTreeMap<String, VmValue>, key: &str) -> bool {
280 vm_provenance(entry)
281 .and_then(|provenance| provenance.get(key))
282 .is_some_and(|value| matches!(value, VmValue::Bool(true)))
283}
284
285fn record_skill_loaded_event(
286 session_id: Option<&str>,
287 skill_id: &str,
288 entry: &BTreeMap<String, VmValue>,
289 error: Option<&str>,
290) {
291 let Some(session_id) = session_id.filter(|value| !value.is_empty()) else {
292 return;
293 };
294 let provenance = vm_provenance(entry);
295 let signed = provenance
296 .and_then(|metadata| metadata.get("signed"))
297 .is_some_and(|value| matches!(value, VmValue::Bool(true)));
298 let trusted = provenance
299 .and_then(|metadata| metadata.get("trusted"))
300 .is_some_and(|value| matches!(value, VmValue::Bool(true)));
301 let mut metadata = serde_json::Map::new();
302 metadata.insert("skill_id".to_string(), serde_json::json!(skill_id));
303 metadata.insert("signed".to_string(), serde_json::json!(signed));
304 metadata.insert("trusted".to_string(), serde_json::json!(trusted));
305 if let Some(provenance) = provenance {
306 for key in [
307 "status",
308 "signer_fingerprint",
309 "skill_sha256",
310 "author",
311 "endorsements",
312 "trust_policy_input",
313 ] {
314 if let Some(value) = provenance.get(key) {
315 metadata.insert(key.to_string(), crate::llm::vm_value_to_json(value));
316 }
317 }
318 }
319 if let Some(error) = error {
320 metadata.insert("error".to_string(), serde_json::json!(error));
321 }
322 let event = crate::llm::helpers::transcript_event(
323 "skill.loaded",
324 "system",
325 "internal",
326 &match error {
327 Some(error) => format!("Skill load blocked for {skill_id}: {error}"),
328 None => format!("Loaded skill {skill_id}"),
329 },
330 Some(serde_json::Value::Object(metadata)),
331 );
332 let _ = crate::agent_sessions::append_event(session_id, event);
333}
334
335pub fn vm_error(message: impl Into<String>) -> VmError {
336 VmError::Thrown(VmValue::String(Rc::from(message.into())))
337}
338
339pub fn tool_rejected_error(message: impl Into<String>) -> VmError {
340 VmError::CategorizedError {
341 message: message.into(),
342 category: ErrorCategory::ToolRejected,
343 }
344}