harn-vm 0.8.0

Async bytecode virtual machine for the Harn programming language
Documentation
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::rc::Rc;
use std::sync::Arc;

use crate::value::{ErrorCategory, VmError, VmValue};

use super::{skill_entry_to_vm, substitute_skill_body, Skill, SubstitutionContext};

pub type SkillFetcher = Arc<dyn Fn(&str) -> Result<Skill, String> + Send + Sync>;

#[derive(Clone)]
pub struct BoundSkillRegistry {
    pub registry: VmValue,
    pub fetcher: SkillFetcher,
}

pub struct LoadedSkill {
    pub id: String,
    pub entry: BTreeMap<String, VmValue>,
    pub rendered_body: String,
}

#[derive(Debug, Clone, Default)]
pub struct LoadSkillOptions {
    pub session_id: Option<String>,
    pub require_signature: bool,
    pub model_invocation: bool,
}

thread_local! {
    static CURRENT_SKILL_REGISTRY: RefCell<Option<BoundSkillRegistry>> = const { RefCell::new(None) };
}

pub fn install_current_skill_registry(
    binding: Option<BoundSkillRegistry>,
) -> Option<BoundSkillRegistry> {
    CURRENT_SKILL_REGISTRY.with(|slot| slot.replace(binding))
}

pub fn current_skill_registry() -> Option<BoundSkillRegistry> {
    CURRENT_SKILL_REGISTRY.with(|slot| slot.borrow().clone())
}

pub fn clear_current_skill_registry() {
    CURRENT_SKILL_REGISTRY.with(|slot| {
        *slot.borrow_mut() = None;
    });
}

pub fn skill_entry_id(entry: &BTreeMap<String, VmValue>) -> String {
    let name = entry.get("name").map(|v| v.display()).unwrap_or_default();
    let namespace = entry
        .get("namespace")
        .map(|v| v.display())
        .filter(|value| !value.is_empty());
    match namespace {
        Some(ns) => format!("{ns}/{name}"),
        None => name,
    }
}

pub fn resolve_skill_entry(
    registry: &VmValue,
    target: &str,
    builtin_name: &str,
) -> Result<BTreeMap<String, VmValue>, String> {
    let dict = registry
        .as_dict()
        .ok_or_else(|| format!("{builtin_name}: bound skill registry is not a dict"))?;
    let skills = match dict.get("skills") {
        Some(VmValue::List(list)) => list,
        _ => {
            return Err(format!("{builtin_name}: bound skill registry is malformed"));
        }
    };

    let mut bare_matches: Vec<BTreeMap<String, VmValue>> = Vec::new();
    for skill in skills.iter() {
        let Some(entry) = skill.as_dict() else {
            continue;
        };
        if skill_entry_id(entry) == target {
            return Ok(entry.clone());
        }
        if entry
            .get("name")
            .map(|value| value.display())
            .is_some_and(|name| name == target)
        {
            bare_matches.push(entry.clone());
        }
    }

    match bare_matches.len() {
        1 => Ok(bare_matches.remove(0)),
        0 => Err(format!("skill_not_found: skill '{target}' not found")),
        _ => Err(format!(
            "skill '{target}' is ambiguous; use the fully qualified id from the catalog"
        )),
    }
}

fn entry_has_inline_body(entry: &BTreeMap<String, VmValue>) -> bool {
    entry
        .get("body")
        .map(|value| value.display())
        .filter(|value| !value.is_empty())
        .is_some()
        || entry
            .get("prompt")
            .map(|value| value.display())
            .filter(|value| !value.is_empty())
            .is_some()
}

fn body_from_entry(entry: &BTreeMap<String, VmValue>) -> String {
    entry
        .get("body")
        .map(|value| value.display())
        .filter(|value| !value.is_empty())
        .or_else(|| {
            entry
                .get("prompt")
                .map(|value| value.display())
                .filter(|value| !value.is_empty())
        })
        .unwrap_or_default()
}

fn hydrate_skill_entry(
    entry: BTreeMap<String, VmValue>,
    fetcher: Option<&SkillFetcher>,
    builtin_name: &str,
) -> Result<BTreeMap<String, VmValue>, String> {
    if entry_has_inline_body(&entry) {
        return Ok(entry);
    }

    let skill_id = skill_entry_id(&entry);
    let Some(fetcher) = fetcher else {
        return Err(format!(
            "{builtin_name}: skill '{skill_id}' is not lazily loadable in this scope"
        ));
    };

    let loaded = fetcher(&skill_id)?;
    match skill_entry_to_vm(&loaded) {
        VmValue::Dict(dict) => {
            let mut hydrated = (*dict).clone();
            for (key, value) in entry {
                hydrated.entry(key).or_insert(value);
            }
            Ok(hydrated)
        }
        _ => Err(format!(
            "{builtin_name}: failed to hydrate skill '{skill_id}'"
        )),
    }
}

fn render_skill_entry(entry: &BTreeMap<String, VmValue>, session_id: Option<&str>) -> String {
    let skill_dir = entry
        .get("skill_dir")
        .map(|value| value.display())
        .filter(|value| !value.is_empty());
    substitute_skill_body(
        &body_from_entry(entry),
        &SubstitutionContext {
            arguments: Vec::new(),
            skill_dir,
            session_id: session_id.map(str::to_string),
            extra_env: Default::default(),
        },
    )
}

pub fn load_bound_skill_by_name(
    requested: &str,
    session_id: Option<&str>,
) -> Result<LoadedSkill, String> {
    load_bound_skill_by_name_with_options(
        requested,
        LoadSkillOptions {
            session_id: session_id.map(str::to_string),
            require_signature: false,
            model_invocation: false,
        },
    )
}

pub fn load_bound_skill_by_name_with_options(
    requested: &str,
    options: LoadSkillOptions,
) -> Result<LoadedSkill, String> {
    let Some(binding) = current_skill_registry() else {
        return Err(
            "load_skill: no skill registry is bound to this scope. Start the VM with discovered skills first."
                .to_string(),
        );
    };
    load_skill_from_registry_with_options(
        &binding.registry,
        Some(&binding.fetcher),
        requested,
        options,
        "load_skill",
    )
}

pub fn load_skill_from_registry(
    registry: &VmValue,
    fetcher: Option<&SkillFetcher>,
    requested: &str,
    session_id: Option<&str>,
    builtin_name: &str,
) -> Result<LoadedSkill, String> {
    load_skill_from_registry_with_options(
        registry,
        fetcher,
        requested,
        LoadSkillOptions {
            session_id: session_id.map(str::to_string),
            require_signature: false,
            model_invocation: false,
        },
        builtin_name,
    )
}

pub fn load_skill_from_registry_with_options(
    registry: &VmValue,
    fetcher: Option<&SkillFetcher>,
    requested: &str,
    options: LoadSkillOptions,
    builtin_name: &str,
) -> Result<LoadedSkill, String> {
    let entry = resolve_skill_entry(registry, requested, builtin_name)?;
    let id = skill_entry_id(&entry);
    if options.model_invocation && vm_bool_field(&entry, "disable_model_invocation") {
        return Err(format!(
            "skill_model_invocation_disabled: skill '{id}' cannot be loaded by a model"
        ));
    }
    let require_signature = options.require_signature || vm_bool_field(&entry, "require_signature");
    if require_signature {
        let signed = vm_provenance_bool(&entry, "signed");
        let trusted = vm_provenance_bool(&entry, "trusted");
        if !signed || !trusted {
            record_skill_loaded_event(
                options.session_id.as_deref(),
                &id,
                &entry,
                Some("UnsignedSkillError"),
            );
            return Err(format!(
                "UnsignedSkillError: skill '{id}' requires a trusted signature"
            ));
        }
    }
    let entry = hydrate_skill_entry(entry, fetcher, builtin_name)?;
    record_skill_loaded_event(options.session_id.as_deref(), &id, &entry, None);
    let rendered_body = render_skill_entry(&entry, options.session_id.as_deref());
    Ok(LoadedSkill {
        id,
        entry,
        rendered_body,
    })
}

fn vm_bool_field(entry: &BTreeMap<String, VmValue>, key: &str) -> bool {
    matches!(entry.get(key), Some(VmValue::Bool(true)))
}

fn vm_provenance(entry: &BTreeMap<String, VmValue>) -> Option<&BTreeMap<String, VmValue>> {
    entry.get("provenance").and_then(VmValue::as_dict)
}

fn vm_provenance_bool(entry: &BTreeMap<String, VmValue>, key: &str) -> bool {
    vm_provenance(entry)
        .and_then(|provenance| provenance.get(key))
        .is_some_and(|value| matches!(value, VmValue::Bool(true)))
}

fn record_skill_loaded_event(
    session_id: Option<&str>,
    skill_id: &str,
    entry: &BTreeMap<String, VmValue>,
    error: Option<&str>,
) {
    let Some(session_id) = session_id.filter(|value| !value.is_empty()) else {
        return;
    };
    let provenance = vm_provenance(entry);
    let signed = provenance
        .and_then(|metadata| metadata.get("signed"))
        .is_some_and(|value| matches!(value, VmValue::Bool(true)));
    let trusted = provenance
        .and_then(|metadata| metadata.get("trusted"))
        .is_some_and(|value| matches!(value, VmValue::Bool(true)));
    let mut metadata = serde_json::Map::new();
    metadata.insert("skill_id".to_string(), serde_json::json!(skill_id));
    metadata.insert("signed".to_string(), serde_json::json!(signed));
    metadata.insert("trusted".to_string(), serde_json::json!(trusted));
    if let Some(provenance) = provenance {
        for key in [
            "status",
            "signer_fingerprint",
            "skill_sha256",
            "author",
            "endorsements",
            "trust_policy_input",
        ] {
            if let Some(value) = provenance.get(key) {
                metadata.insert(key.to_string(), crate::llm::vm_value_to_json(value));
            }
        }
    }
    if let Some(error) = error {
        metadata.insert("error".to_string(), serde_json::json!(error));
    }
    let event = crate::llm::helpers::transcript_event(
        "skill.loaded",
        "system",
        "internal",
        &match error {
            Some(error) => format!("Skill load blocked for {skill_id}: {error}"),
            None => format!("Loaded skill {skill_id}"),
        },
        Some(serde_json::Value::Object(metadata)),
    );
    let _ = crate::agent_sessions::append_event(session_id, event);
}

pub fn vm_error(message: impl Into<String>) -> VmError {
    VmError::Thrown(VmValue::String(Rc::from(message.into())))
}

pub fn tool_rejected_error(message: impl Into<String>) -> VmError {
    VmError::CategorizedError {
        message: message.into(),
        category: ErrorCategory::ToolRejected,
    }
}