use std::collections::BTreeMap;
use std::rc::Rc;
use crate::value::{VmError, VmValue};
use crate::vm::Vm;
fn vm_validate_registry(name: &str, dict: &BTreeMap<String, VmValue>) -> Result<(), VmError> {
match dict.get("_type") {
Some(VmValue::String(t)) if &**t == "skill_registry" => Ok(()),
_ => Err(VmError::Thrown(VmValue::String(Rc::from(format!(
"{name}: argument must be a skill registry (created with skill_registry())"
))))),
}
}
fn vm_get_skills(dict: &BTreeMap<String, VmValue>) -> &[VmValue] {
match dict.get("skills") {
Some(VmValue::List(list)) => list,
_ => &[],
}
}
fn vm_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,
}
}
fn vm_skill_catalog_entries(skills: &[VmValue]) -> Vec<VmValue> {
let mut catalog: Vec<(String, VmValue)> = Vec::new();
for skill in skills {
let Some(entry) = skill.as_dict() else {
continue;
};
let id = vm_skill_entry_id(entry);
if id.is_empty() {
continue;
}
let description = entry
.get("short")
.map(|v| v.display())
.filter(|value| !value.is_empty())
.or_else(|| {
entry
.get("description")
.map(|v| v.display())
.filter(|value| !value.is_empty())
})
.unwrap_or_default();
let when_to_use = entry
.get("when_to_use")
.map(|v| v.display())
.unwrap_or_default();
let mut rendered = BTreeMap::new();
rendered.insert("name".to_string(), VmValue::String(Rc::from(id.as_str())));
rendered.insert(
"description".to_string(),
VmValue::String(Rc::from(description.as_str())),
);
rendered.insert(
"when_to_use".to_string(),
VmValue::String(Rc::from(when_to_use.as_str())),
);
catalog.push((id, VmValue::Dict(Rc::new(rendered))));
}
catalog.sort_by(|a, b| a.0.cmp(&b.0));
catalog.into_iter().map(|(_, value)| value).collect()
}
fn render_catalog_entry(entry: &BTreeMap<String, VmValue>) -> Option<String> {
let name = entry.get("name").map(|v| v.display()).unwrap_or_default();
if name.is_empty() {
return None;
}
let description = entry
.get("short")
.map(|v| v.display())
.filter(|value| !value.is_empty())
.or_else(|| {
entry
.get("description")
.map(|v| v.display())
.filter(|value| !value.is_empty())
})
.unwrap_or_default();
let when_to_use = entry
.get("when_to_use")
.map(|v| v.display())
.unwrap_or_default();
let mut lines = Vec::new();
if description.is_empty() {
lines.push(format!("- `{name}`"));
} else {
lines.push(format!("- `{name}`: {description}"));
}
if !when_to_use.is_empty() {
lines.push(format!(" when: {when_to_use}"));
}
Some(lines.join("\n"))
}
fn render_catalog(entries: &[VmValue], budget: usize) -> String {
let header = concat!(
"## Available skills\n\n",
"These skills are available. Call `load_skill({ name: \"<skill-id>\" })` to load the full body of a skill when it becomes relevant.\n\n",
);
if entries.is_empty() {
return format!("{header}(none)");
}
let omission_template = "\n\n... 1 more skill(s) omitted to stay within budget.";
let budget = budget.max(header.len() + omission_template.len());
let mut blocks = Vec::new();
for entry in entries {
let Some(dict) = entry.as_dict() else {
continue;
};
let Some(block) = render_catalog_entry(dict) else {
continue;
};
blocks.push(block);
}
if blocks.is_empty() {
return format!("{header}(none)");
}
let mut visible = 0usize;
let mut rendered = String::from(header);
while visible < blocks.len() {
let candidate_len = rendered.len()
+ if visible == 0 {
blocks[visible].len()
} else {
1 + blocks[visible].len()
};
if candidate_len > budget {
break;
}
if visible > 0 {
rendered.push('\n');
}
rendered.push_str(&blocks[visible]);
visible += 1;
}
let mut omitted = blocks.len().saturating_sub(visible);
if omitted > 0 {
loop {
let suffix = format!("\n\n... {omitted} more skill(s) omitted to stay within budget.");
if rendered.len() + suffix.len() <= budget {
rendered.push_str(&suffix);
break;
}
if visible == 0 {
break;
}
visible -= 1;
omitted += 1;
rendered = String::from(header);
for (index, block) in blocks.iter().take(visible).enumerate() {
if index > 0 {
rendered.push('\n');
}
rendered.push_str(block);
}
}
}
rendered
}
pub(crate) fn register_skill_builtins(vm: &mut Vm) {
vm.register_builtin("skill_registry", |_args, _out| {
let mut registry = BTreeMap::new();
registry.insert(
"_type".to_string(),
VmValue::String(Rc::from("skill_registry")),
);
registry.insert("skills".to_string(), VmValue::List(Rc::new(Vec::new())));
Ok(VmValue::Dict(Rc::new(registry)))
});
vm.register_builtin("skill_define", |args, _out| {
if args.len() < 3 {
return Err(VmError::Thrown(VmValue::String(Rc::from(
"skill_define: requires registry, name, and config dict",
))));
}
let registry = match &args[0] {
VmValue::Dict(map) => (**map).clone(),
_ => {
return Err(VmError::Thrown(VmValue::String(Rc::from(
"skill_define: first argument must be a skill registry",
))));
}
};
vm_validate_registry("skill_define", ®istry)?;
let name = match &args[1] {
VmValue::String(s) => s.to_string(),
other => other.display(),
};
if name.is_empty() {
return Err(VmError::Thrown(VmValue::String(Rc::from(
"skill_define: skill name must be a non-empty string",
))));
}
let config = match &args[2] {
VmValue::Dict(map) => (**map).clone(),
VmValue::Nil => BTreeMap::new(),
_ => {
return Err(VmError::Thrown(VmValue::String(Rc::from(
"skill_define: third argument must be a config dict",
))));
}
};
for key in [
"short",
"description",
"when_to_use",
"prompt",
"invocation",
"model",
"effort",
] {
if let Some(value) = config.get(key) {
if !matches!(value, VmValue::String(_) | VmValue::Nil) {
return Err(VmError::Thrown(VmValue::String(Rc::from(format!(
"skill_define: '{key}' must be a string"
)))));
}
}
}
for key in ["paths", "allowed_tools", "mcp", "requires_mcp"] {
if let Some(value) = config.get(key) {
if !matches!(value, VmValue::List(_) | VmValue::Nil) {
return Err(VmError::Thrown(VmValue::String(Rc::from(format!(
"skill_define: '{key}' must be a list"
)))));
}
}
}
if let Some(VmValue::List(list)) = config.get("allowed_tools") {
for entry in list.iter() {
let rendered = entry.display();
if let Some(tag) = rendered.strip_prefix("namespace:") {
if tag.is_empty() {
return Err(VmError::Thrown(VmValue::String(Rc::from(
"skill_define: 'allowed_tools' entry 'namespace:' missing a tag after the colon",
))));
}
} else if rendered.contains(':') {
return Err(VmError::Thrown(VmValue::String(Rc::from(format!(
"skill_define: 'allowed_tools' entry '{rendered}' contains ':' — only the `namespace:<tag>` prefix is recognized"
)))));
}
}
}
let mut entry = BTreeMap::new();
entry.insert("name".to_string(), VmValue::String(Rc::from(name.as_str())));
if !config.contains_key("description") {
let fallback = config.get("short").map(|value| value.display()).unwrap_or_default();
entry.insert("description".to_string(), VmValue::String(Rc::from("")));
if !fallback.is_empty() {
entry.insert(
"description".to_string(),
VmValue::String(Rc::from(fallback.as_str())),
);
}
}
for (k, v) in config.iter() {
entry.insert(k.clone(), v.clone());
}
let entry_value = VmValue::Dict(Rc::new(entry));
let skills = vm_get_skills(®istry);
let mut new_skills: Vec<VmValue> = Vec::with_capacity(skills.len() + 1);
let mut replaced = false;
for existing in skills {
if let VmValue::Dict(dict) = existing {
if let Some(VmValue::String(existing_name)) = dict.get("name") {
if &**existing_name == name.as_str() {
new_skills.push(entry_value.clone());
replaced = true;
continue;
}
}
}
new_skills.push(existing.clone());
}
if !replaced {
new_skills.push(entry_value);
}
let mut new_registry = registry;
new_registry.insert("skills".to_string(), VmValue::List(Rc::new(new_skills)));
Ok(VmValue::Dict(Rc::new(new_registry)))
});
vm.register_builtin("skill_list", |args, _out| {
let registry = match args.first() {
Some(VmValue::Dict(map)) => map,
_ => {
return Err(VmError::Thrown(VmValue::String(Rc::from(
"skill_list: requires a skill registry",
))));
}
};
vm_validate_registry("skill_list", registry)?;
let skills = vm_get_skills(registry);
let mut result = Vec::new();
for skill in skills {
if let VmValue::Dict(entry) = skill {
let mut desc = BTreeMap::new();
for (key, value) in entry.iter() {
if matches!(value, VmValue::Closure(_) | VmValue::BuiltinRef(_)) {
continue;
}
desc.insert(key.clone(), value.clone());
}
result.push(VmValue::Dict(Rc::new(desc)));
}
}
Ok(VmValue::List(Rc::new(result)))
});
vm.register_builtin("skills_catalog_entries", |args, _out| {
let registry = match args.first() {
Some(VmValue::Dict(map)) => map,
_ => {
return Err(VmError::Thrown(VmValue::String(Rc::from(
"skills_catalog_entries: requires a skill registry",
))));
}
};
vm_validate_registry("skills_catalog_entries", registry)?;
Ok(VmValue::List(Rc::new(vm_skill_catalog_entries(
vm_get_skills(registry),
))))
});
vm.register_builtin("render_always_on_catalog", |args, _out| {
let entries: Vec<VmValue> = match args.first() {
Some(VmValue::List(list)) => list.iter().cloned().collect(),
Some(VmValue::Dict(map)) => {
vm_validate_registry("render_always_on_catalog", map)?;
vm_skill_catalog_entries(vm_get_skills(map))
}
_ => {
return Err(VmError::Thrown(VmValue::String(Rc::from(
"render_always_on_catalog: first argument must be a catalog entry list or skill registry",
))));
}
};
let budget = match args.get(1) {
Some(VmValue::Int(value)) if *value > 0 => *value as usize,
Some(VmValue::Nil) | None => 2000usize,
Some(_) => {
return Err(VmError::Thrown(VmValue::String(Rc::from(
"render_always_on_catalog: second argument must be a positive integer budget",
))));
}
};
let rendered = render_catalog(&entries, budget);
Ok(VmValue::String(Rc::from(rendered.as_str())))
});
vm.register_builtin("skill_find", |args, _out| {
if args.len() < 2 {
return Err(VmError::Thrown(VmValue::String(Rc::from(
"skill_find: requires registry and name",
))));
}
let registry = match &args[0] {
VmValue::Dict(map) => map,
_ => {
return Err(VmError::Thrown(VmValue::String(Rc::from(
"skill_find: first argument must be a skill registry",
))));
}
};
vm_validate_registry("skill_find", registry)?;
let target_name = args[1].display();
for skill in vm_get_skills(registry) {
if let VmValue::Dict(entry) = skill {
if let Some(VmValue::String(name)) = entry.get("name") {
if &**name == target_name.as_str() {
return Ok(skill.clone());
}
}
}
}
Ok(VmValue::Nil)
});
vm.register_builtin("skill_select", |args, _out| {
if args.len() < 2 {
return Err(VmError::Thrown(VmValue::String(Rc::from(
"skill_select: requires registry and names list",
))));
}
let registry = match &args[0] {
VmValue::Dict(map) => map,
_ => {
return Err(VmError::Thrown(VmValue::String(Rc::from(
"skill_select: first argument must be a skill registry",
))));
}
};
vm_validate_registry("skill_select", registry)?;
let names = match &args[1] {
VmValue::List(list) => list
.iter()
.map(|value| value.display())
.collect::<std::collections::BTreeSet<_>>(),
_ => {
return Err(VmError::Thrown(VmValue::String(Rc::from(
"skill_select: second argument must be a list of skill names",
))));
}
};
let selected: Vec<VmValue> = vm_get_skills(registry)
.iter()
.filter(|skill| {
skill
.as_dict()
.and_then(|entry| entry.get("name"))
.map(|name| names.contains(&name.display()))
.unwrap_or(false)
})
.cloned()
.collect();
let mut new_registry = (**registry).clone();
new_registry.insert("skills".to_string(), VmValue::List(Rc::new(selected)));
Ok(VmValue::Dict(Rc::new(new_registry)))
});
vm.register_builtin("skill_describe", |args, _out| {
let registry = match args.first() {
Some(VmValue::Dict(map)) => map,
_ => {
return Err(VmError::Thrown(VmValue::String(Rc::from(
"skill_describe: requires a skill registry",
))));
}
};
vm_validate_registry("skill_describe", registry)?;
let skills = vm_get_skills(registry);
if skills.is_empty() {
return Ok(VmValue::String(Rc::from("Available skills:\n(none)")));
}
let mut infos: Vec<(String, String, String)> = Vec::new();
for skill in skills {
if let VmValue::Dict(entry) = skill {
let name = entry.get("name").map(|v| v.display()).unwrap_or_default();
let description = entry
.get("description")
.map(|v| v.display())
.unwrap_or_default();
let when = entry
.get("when_to_use")
.map(|v| v.display())
.unwrap_or_default();
infos.push((name, description, when));
}
}
infos.sort_by(|a, b| a.0.cmp(&b.0));
let mut lines = vec!["Available skills:".to_string()];
for (name, desc, when) in &infos {
if desc.is_empty() {
lines.push(format!("- {name}"));
} else {
lines.push(format!("- {name}: {desc}"));
}
if !when.is_empty() {
lines.push(format!(" when: {when}"));
}
}
Ok(VmValue::String(Rc::from(lines.join("\n"))))
});
vm.register_builtin("skill_remove", |args, _out| {
if args.len() < 2 {
return Err(VmError::Thrown(VmValue::String(Rc::from(
"skill_remove: requires registry and name",
))));
}
let registry = match &args[0] {
VmValue::Dict(map) => (**map).clone(),
_ => {
return Err(VmError::Thrown(VmValue::String(Rc::from(
"skill_remove: first argument must be a skill registry",
))));
}
};
vm_validate_registry("skill_remove", ®istry)?;
let target_name = args[1].display();
let skills = vm_get_skills(®istry).to_vec();
let filtered: Vec<VmValue> = skills
.into_iter()
.filter(|skill| {
if let VmValue::Dict(entry) = skill {
if let Some(VmValue::String(name)) = entry.get("name") {
return &**name != target_name.as_str();
}
}
true
})
.collect();
let mut new_registry = registry;
new_registry.insert("skills".to_string(), VmValue::List(Rc::new(filtered)));
Ok(VmValue::Dict(Rc::new(new_registry)))
});
vm.register_builtin("skill_render", |args, _out| {
if args.is_empty() {
return Err(VmError::Thrown(VmValue::String(Rc::from(
"skill_render: requires a skill entry or body string",
))));
}
let (body, skill_dir) = match &args[0] {
VmValue::String(s) => (s.to_string(), None),
VmValue::Dict(map) => {
let body = map.get("body").map(|v| v.display()).unwrap_or_default();
if !body.is_empty() {
let dir = map
.get("skill_dir")
.map(|v| v.display())
.filter(|s| !s.is_empty());
(body, dir)
} else {
let skill_id = crate::skills::skill_entry_id(map);
let fetched = crate::skills::current_skill_registry()
.and_then(|binding| (binding.fetcher)(&skill_id).ok());
let dir = fetched
.as_ref()
.and_then(|skill| skill.skill_dir.as_ref())
.map(|path| path.display().to_string());
let body = fetched.map(|skill| skill.body).unwrap_or_default();
(body, dir)
}
}
_ => {
return Err(VmError::Thrown(VmValue::String(Rc::from(
"skill_render: first argument must be a skill entry or a string body",
))));
}
};
let arguments: Vec<String> = match args.get(1) {
Some(VmValue::List(list)) => list.iter().map(|v| v.display()).collect(),
Some(VmValue::Nil) | None => Vec::new(),
Some(_) => {
return Err(VmError::Thrown(VmValue::String(Rc::from(
"skill_render: second argument must be a list of strings",
))));
}
};
let ctx = crate::skills::SubstitutionContext {
arguments,
skill_dir,
session_id: std::env::var("HARN_SESSION_ID").ok(),
extra_env: Default::default(),
};
let rendered = crate::skills::substitute_skill_body(&body, &ctx);
Ok(VmValue::String(Rc::from(rendered.as_str())))
});
vm.register_async_builtin("load_skill", |args| async move {
let requested = match args.first() {
Some(VmValue::String(name)) if !name.is_empty() => name.to_string(),
Some(value) => {
let rendered = value.display();
if rendered.is_empty() {
return Err(crate::skills::skill_vm_error(
"load_skill: requires a non-empty skill name",
));
}
rendered
}
None => {
return Err(crate::skills::skill_vm_error(
"load_skill: requires a non-empty skill name",
));
}
};
let session_id = std::env::var("HARN_SESSION_ID").ok();
let loaded = crate::skills::load_bound_skill_by_name(&requested, session_id.as_deref())
.map_err(crate::skills::skill_vm_error)?;
Ok(VmValue::String(Rc::from(loaded.rendered_body.as_str())))
});
vm.register_builtin("skill_count", |args, _out| {
let registry = match args.first() {
Some(VmValue::Dict(map)) => map,
_ => {
return Err(VmError::Thrown(VmValue::String(Rc::from(
"skill_count: requires a skill registry",
))));
}
};
vm_validate_registry("skill_count", registry)?;
let count = vm_get_skills(registry).len();
Ok(VmValue::Int(count as i64))
});
}
#[cfg(test)]
mod tests {
use super::{render_catalog, vm_skill_catalog_entries};
use crate::value::VmValue;
use std::collections::BTreeMap;
use std::rc::Rc;
#[test]
fn catalog_entries_use_fully_qualified_ids_and_sort() {
let skills = vec![
VmValue::Dict(Rc::new(BTreeMap::from([
("name".to_string(), VmValue::String(Rc::from("beta"))),
(
"description".to_string(),
VmValue::String(Rc::from("Second skill")),
),
]))),
VmValue::Dict(Rc::new(BTreeMap::from([
("name".to_string(), VmValue::String(Rc::from("deploy"))),
(
"namespace".to_string(),
VmValue::String(Rc::from("acme/ops")),
),
(
"description".to_string(),
VmValue::String(Rc::from("Deploy service")),
),
(
"when_to_use".to_string(),
VmValue::String(Rc::from("Ship a release")),
),
]))),
];
let catalog = vm_skill_catalog_entries(&skills);
assert_eq!(catalog.len(), 2);
let first = catalog[0].as_dict().unwrap();
let second = catalog[1].as_dict().unwrap();
assert_eq!(first.get("name").unwrap().display(), "acme/ops/deploy");
assert_eq!(second.get("name").unwrap().display(), "beta");
}
#[test]
fn render_catalog_is_deterministic_and_budgeted() {
let entries = vec![
VmValue::Dict(Rc::new(BTreeMap::from([
("name".to_string(), VmValue::String(Rc::from("alpha"))),
(
"description".to_string(),
VmValue::String(Rc::from("First skill")),
),
(
"when_to_use".to_string(),
VmValue::String(Rc::from("Use alpha first")),
),
]))),
VmValue::Dict(Rc::new(BTreeMap::from([
("name".to_string(), VmValue::String(Rc::from("beta"))),
(
"description".to_string(),
VmValue::String(Rc::from("Second skill")),
),
(
"when_to_use".to_string(),
VmValue::String(Rc::from("Use beta second")),
),
]))),
];
let once = render_catalog(&entries, 10_000);
let twice = render_catalog(&entries, 10_000);
assert_eq!(once, twice);
assert!(once.contains("- `alpha`: First skill"));
let small = render_catalog(&entries, 160);
assert!(small.contains("omitted to stay within budget"));
}
}