Skip to main content

harn_vm/skills/
runtime.rs

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}