Skip to content

Commit b89810c

Browse files
authored
Merge pull request #55 from efecnc/feat/skills-rescan-on-miss
feat(skills): rescan-on-miss so runtime-added skills load without restart
2 parents 54bb46c + b9fc059 commit b89810c

1 file changed

Lines changed: 91 additions & 6 deletions

File tree

src/agent/mod.rs

Lines changed: 91 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3509,10 +3509,8 @@ impl Tool for LoadSkillTool {
35093509
.and_then(|v| v.as_str())
35103510
.unwrap_or("load");
35113511

3512-
let registry = self.registry.read().await;
3513-
35143512
if action == "list" {
3515-
return Ok(registry.format_skill_directory());
3513+
return Ok(self.registry.read().await.format_skill_directory());
35163514
}
35173515

35183516
let skill_name = args
@@ -3525,11 +3523,35 @@ impl Tool for LoadSkillTool {
35253523
.and_then(|v| v.as_str())
35263524
.unwrap_or("full");
35273525

3528-
if detail == "metadata" {
3529-
return registry.get_skill_metadata(skill_name);
3526+
// First attempt against the registry as it was last scanned. The read guard is scoped to
3527+
// this block and dropped before the rescan path below takes the write lock, so we never
3528+
// hold a read guard across a write acquisition on the same RwLock (which would deadlock).
3529+
let first = {
3530+
let registry = self.registry.read().await;
3531+
if detail == "metadata" {
3532+
registry.get_skill_metadata(skill_name)
3533+
} else {
3534+
registry.get_skill_instructions(skill_name)
3535+
}
3536+
};
3537+
if first.is_ok() {
3538+
return first;
35303539
}
35313540

3532-
registry.get_skill_instructions(skill_name)
3541+
// Miss: a SKILL.md may have been dropped into the skills directory since the registry was
3542+
// last scanned (it is scanned once at startup). Rescan once and re-resolve before reporting
3543+
// the skill missing, so a freshly added skill is loadable without restarting the agent. The
3544+
// rescan is paid only on an actual miss, so the common path (skill already present) is
3545+
// unchanged. Hold a single write guard for both the rescan and the follow-up lookup (the
3546+
// guard derefs to the registry for the immutable getters), avoiding a redundant drop-then-
3547+
// reacquire and closing the gap between scan and read.
3548+
let mut registry = self.registry.write().await;
3549+
registry.scan_for_skills();
3550+
if detail == "metadata" {
3551+
registry.get_skill_metadata(skill_name)
3552+
} else {
3553+
registry.get_skill_instructions(skill_name)
3554+
}
35333555
}
35343556
}
35353557

@@ -4699,4 +4721,67 @@ mod tests {
46994721
.unwrap();
47004722
assert!(full.contains("do things"));
47014723
}
4724+
4725+
#[tokio::test]
4726+
async fn load_skill_rescans_on_miss_to_pick_up_a_new_skill() {
4727+
let root = LocalTempDir::new();
4728+
let skills_root = root.path().join("skills");
4729+
4730+
// One skill present when the registry is first scanned (at startup).
4731+
let first = skills_root.join("first_skill");
4732+
std::fs::create_dir_all(&first).unwrap();
4733+
std::fs::write(
4734+
first.join("SKILL.md"),
4735+
"---\nname: first_skill\ndescription: present at startup\n---\n\nalpha body\n",
4736+
)
4737+
.unwrap();
4738+
4739+
let reg = Arc::new(tokio::sync::RwLock::new(SkillRegistry::new(
4740+
skills_root.clone(),
4741+
)));
4742+
let tool = super::LoadSkillTool {
4743+
registry: reg.clone(),
4744+
};
4745+
4746+
// A skill dropped into the directory AFTER the startup scan: the in-memory registry has
4747+
// never seen it.
4748+
let late = skills_root.join("late_skill");
4749+
std::fs::create_dir_all(&late).unwrap();
4750+
std::fs::write(
4751+
late.join("SKILL.md"),
4752+
"---\nname: late_skill\ndescription: added after scan\n---\n\nomega instructions\n",
4753+
)
4754+
.unwrap();
4755+
4756+
// Without rescan-on-miss this would error "skill not found"; the on-miss rescan must pick
4757+
// the new skill up and return its instructions without an agent restart.
4758+
let loaded = tool
4759+
.execute(serde_json::json!({ "skill_name": "late_skill", "detail": "full" }))
4760+
.await
4761+
.expect("late skill should be loadable after the on-miss rescan");
4762+
assert!(loaded.contains("omega instructions"), "{loaded}");
4763+
4764+
// metadata path also benefits from the rescan (covers the detail == "metadata" branch).
4765+
let late_meta = tool
4766+
.execute(serde_json::json!({ "skill_name": "late_skill", "detail": "metadata" }))
4767+
.await
4768+
.expect("late skill metadata after rescan");
4769+
assert!(late_meta.contains("Available: true"), "{late_meta}");
4770+
4771+
// The originally-present skill still loads.
4772+
let alpha = tool
4773+
.execute(serde_json::json!({ "skill_name": "first_skill", "detail": "full" }))
4774+
.await
4775+
.expect("first skill still loads");
4776+
assert!(alpha.contains("alpha body"), "{alpha}");
4777+
4778+
// A genuinely non-existent skill still errors after a rescan turns up nothing new.
4779+
let missing = tool
4780+
.execute(serde_json::json!({ "skill_name": "no_such_skill" }))
4781+
.await;
4782+
assert!(
4783+
missing.is_err(),
4784+
"unknown skill should still error after rescan: {missing:?}"
4785+
);
4786+
}
47024787
}

0 commit comments

Comments
 (0)