Skip to content

Commit 96415ec

Browse files
authored
fix(skills): wire two_stage_matching and confusability_threshold at startup; migrate legacy bundled skills (#2410)
AgentBuilder gains with_two_stage_matching and with_confusability_threshold builder methods. Both are wired in runner.rs and daemon.rs alongside with_disambiguation_threshold, so config values are applied at startup rather than only after hot-reload. Closes #2404. provision_bundled_skills now detects legacy bundled skills (dirs without a .bundled marker whose SKILL.md content matches the embedded version) and re-provisions them, adding the marker and updated category field without overwriting user-modified skills. Closes #2403.
1 parent 573ce23 commit 96415ec

File tree

5 files changed

+199
-3
lines changed

5 files changed

+199
-3
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
1919

2020
- fix(classifiers): sha2 0.11 hex formatting — replace `format!("{:x}", ...)` with `hex::encode(...)` in `verify_sha256` and its test helper (#2401)
2121
- fix(deps): bump sha2 0.10→0.11, ordered-float 5.1→5.3, proptest 1.10→1.11, toml 1.0→1.1, uuid 1.22→1.23 (#2401)
22+
- fix(skills): `two_stage_matching` and `confusability_threshold` config fields are now applied at agent startup; `AgentBuilder` gains `with_two_stage_matching` and `with_confusability_threshold` builder methods wired in `runner.rs` and `daemon.rs` (closes #2404)
23+
- fix(skills): bundled skills provisioned before the `.bundled` marker system are now migrated on upgrade — `provision_bundled_skills` re-provisions skills whose `SKILL.md` matches the embedded version, restoring the `category` field without overwriting user-modified skills (closes #2403)
2224
- fix(memory): correct ESCAPE clause in spreading activation BFS alias query — `ESCAPE '\\\\'` (2 chars) changed to `ESCAPE '\\'` (1 char) as required by SQLite (closes #2393)
2325
- fix(llm): call `save_bandit_state()` in `save_router_state()` to persist PILOT LinUCB bandit state across restarts (closes #2394)
2426
- fix(classifiers): use Metal/CUDA device when available in candle classifiers — falls back to CPU (#2396)

crates/zeph-core/src/agent/builder.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,18 @@ impl<C: Channel> Agent<C> {
263263
self
264264
}
265265

266+
#[must_use]
267+
pub fn with_two_stage_matching(mut self, enabled: bool) -> Self {
268+
self.skill_state.two_stage_matching = enabled;
269+
self
270+
}
271+
272+
#[must_use]
273+
pub fn with_confusability_threshold(mut self, threshold: f32) -> Self {
274+
self.skill_state.confusability_threshold = threshold.clamp(0.0, 1.0);
275+
self
276+
}
277+
266278
#[must_use]
267279
pub fn with_skill_prompt_mode(mut self, mode: crate::config::SkillPromptMode) -> Self {
268280
self.skill_state.prompt_mode = mode;
@@ -1632,4 +1644,40 @@ mod tests {
16321644
"apply_session_config must not create anomaly_detector when disabled"
16331645
);
16341646
}
1647+
1648+
#[test]
1649+
fn with_two_stage_matching_sets_flag() {
1650+
let agent = make_agent().with_two_stage_matching(true);
1651+
assert!(
1652+
agent.skill_state.two_stage_matching,
1653+
"with_two_stage_matching(true) must enable two_stage_matching"
1654+
);
1655+
1656+
let agent = make_agent().with_two_stage_matching(false);
1657+
assert!(
1658+
!agent.skill_state.two_stage_matching,
1659+
"with_two_stage_matching(false) must disable two_stage_matching"
1660+
);
1661+
}
1662+
1663+
#[test]
1664+
fn with_confusability_threshold_sets_and_clamps() {
1665+
let agent = make_agent().with_confusability_threshold(0.85);
1666+
assert!(
1667+
(agent.skill_state.confusability_threshold - 0.85).abs() < f32::EPSILON,
1668+
"with_confusability_threshold must store the provided value"
1669+
);
1670+
1671+
let agent = make_agent().with_confusability_threshold(1.5);
1672+
assert_eq!(
1673+
agent.skill_state.confusability_threshold, 1.0,
1674+
"with_confusability_threshold must clamp values above 1.0"
1675+
);
1676+
1677+
let agent = make_agent().with_confusability_threshold(-0.1);
1678+
assert_eq!(
1679+
agent.skill_state.confusability_threshold, 0.0,
1680+
"with_confusability_threshold must clamp values below 0.0"
1681+
);
1682+
}
16351683
}

crates/zeph-skills/src/bundled.rs

Lines changed: 145 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,28 @@ pub fn provision_bundled_skills(managed_dir: &Path) -> Result<ProvisionReport, s
9999
// Skill dir exists — check marker.
100100
match read_marker_version(&marker_path) {
101101
MarkerState::NoMarker => {
102-
// User-owned skill — never overwrite.
103-
debug!(skill = %skill_name, "skipping user-owned skill (no .bundled marker)");
104-
report.skipped.push(skill_name);
102+
// Check if this is a legacy bundled skill (provisioned before the
103+
// .bundled marker system): compare on-disk SKILL.md to embedded.
104+
if is_legacy_bundled(&target_dir, &skill_name) {
105+
match write_skill(skill_dir, &target_dir, &marker_path, &embedded_version) {
106+
Ok(()) => {
107+
info!(
108+
skill = %skill_name,
109+
to = %embedded_version,
110+
"migrated legacy bundled skill (added .bundled marker)"
111+
);
112+
report.updated.push(skill_name);
113+
}
114+
Err(e) => {
115+
warn!(skill = %skill_name, error = %e, "failed to migrate legacy bundled skill");
116+
report.failed.push((skill_name, e.to_string()));
117+
}
118+
}
119+
} else {
120+
// User-owned skill — never overwrite.
121+
debug!(skill = %skill_name, "skipping user-owned skill (no .bundled marker)");
122+
report.skipped.push(skill_name);
123+
}
105124
}
106125
MarkerState::CorruptMarker => {
107126
warn!(
@@ -255,6 +274,26 @@ fn extract_embedded_version(skill_dir: &include_dir::Dir<'_>) -> String {
255274
parse_frontmatter_version(content).unwrap_or_else(|| "1.0".to_owned())
256275
}
257276

277+
/// Check whether an on-disk skill dir (without a `.bundled` marker) matches the
278+
/// embedded version — indicating it was provisioned before the marker system.
279+
///
280+
/// Returns `true` only when the on-disk `SKILL.md` content (trimmed) equals the
281+
/// embedded `SKILL.md` content (trimmed). A mismatch means the user modified the
282+
/// file, so we treat the skill as user-owned.
283+
fn is_legacy_bundled(target_dir: &Path, skill_name: &str) -> bool {
284+
let embedded_path = format!("{skill_name}/SKILL.md");
285+
let Some(embedded_file) = BUNDLED_SKILLS_DIR.get_file(&embedded_path) else {
286+
return false;
287+
};
288+
let Ok(embedded_content) = std::str::from_utf8(embedded_file.contents()) else {
289+
return false;
290+
};
291+
match fs::read_to_string(target_dir.join("SKILL.md")) {
292+
Ok(on_disk) => on_disk.trim() == embedded_content.trim(),
293+
Err(_) => false,
294+
}
295+
}
296+
258297
/// Parse the `version:` key from the `metadata:` block in SKILL.md frontmatter.
259298
///
260299
/// Frontmatter is delimited by `---` lines. Within `metadata:`, lines of the
@@ -349,6 +388,109 @@ mod tests {
349388
));
350389
}
351390

391+
/// `is_legacy_bundled` returns true when on-disk SKILL.md matches embedded content (trimmed).
392+
#[test]
393+
fn is_legacy_bundled_matches_identical_content() {
394+
let tmp = TempDir::new().unwrap();
395+
let skill_dir = tmp.path().join("test-skill");
396+
fs::create_dir_all(&skill_dir).unwrap();
397+
398+
// Write the same content that would be in an embedded skill.
399+
let skill_md_content = make_skill_md("1.0");
400+
fs::write(skill_dir.join("SKILL.md"), &skill_md_content).unwrap();
401+
402+
// We can't directly call is_legacy_bundled with a real include_dir::Dir,
403+
// so we test the provision path: provision to empty dir first (installs),
404+
// then remove the .bundled marker and re-provision — the skill must be
405+
// migrated (moved to updated), not skipped.
406+
let managed = TempDir::new().unwrap();
407+
let report1 = provision_bundled_skills(managed.path()).expect("first provision");
408+
assert!(report1.failed.is_empty());
409+
410+
// Remove all .bundled markers to simulate pre-marker state.
411+
for name in &report1.installed {
412+
let marker = managed.path().join(name).join(".bundled");
413+
if marker.exists() {
414+
fs::remove_file(&marker).unwrap();
415+
}
416+
}
417+
418+
// Re-provision: skills whose SKILL.md matches embedded → migrate (updated).
419+
// Skills whose SKILL.md was modified → skip.
420+
let report2 = provision_bundled_skills(managed.path()).expect("second provision");
421+
assert!(
422+
report2.failed.is_empty(),
423+
"no failures on re-provision: {:?}",
424+
report2.failed
425+
);
426+
// All skills without markers should be migrated (updated), none skipped.
427+
assert!(
428+
report2.installed.is_empty(),
429+
"no new installs expected on re-provision"
430+
);
431+
assert!(
432+
report2.skipped.is_empty(),
433+
"no skills should be skipped when content matches embedded"
434+
);
435+
assert!(
436+
!report2.updated.is_empty(),
437+
"all skills without marker must be migrated to updated"
438+
);
439+
440+
// After migration, each skill must have a .bundled marker.
441+
for name in &report2.updated {
442+
let marker = managed.path().join(name).join(".bundled");
443+
assert!(
444+
marker.exists(),
445+
"{name}: .bundled marker missing after migration"
446+
);
447+
}
448+
}
449+
450+
/// `is_legacy_bundled` returns false when on-disk SKILL.md differs from embedded.
451+
#[test]
452+
fn is_legacy_bundled_skips_modified_skill() {
453+
let managed = TempDir::new().unwrap();
454+
let report1 = provision_bundled_skills(managed.path()).expect("first provision");
455+
assert!(report1.failed.is_empty());
456+
assert!(!report1.installed.is_empty());
457+
458+
// Remove .bundled markers AND modify SKILL.md to simulate user edits.
459+
for name in &report1.installed {
460+
let skill_dir = managed.path().join(name);
461+
let marker = skill_dir.join(".bundled");
462+
if marker.exists() {
463+
fs::remove_file(&marker).unwrap();
464+
}
465+
let skill_md = skill_dir.join("SKILL.md");
466+
if skill_md.exists() {
467+
let mut content = fs::read_to_string(&skill_md).unwrap();
468+
content.push_str("\n# user modification\n");
469+
fs::write(&skill_md, content).unwrap();
470+
}
471+
}
472+
473+
let report2 = provision_bundled_skills(managed.path()).expect("second provision");
474+
assert!(
475+
report2.failed.is_empty(),
476+
"no failures: {:?}",
477+
report2.failed
478+
);
479+
// All modified skills must be skipped (treated as user-owned).
480+
assert!(
481+
report2.updated.is_empty(),
482+
"modified skills must not be updated"
483+
);
484+
assert!(
485+
report2.installed.is_empty(),
486+
"no re-installs expected when dir exists"
487+
);
488+
assert!(
489+
!report2.skipped.is_empty(),
490+
"modified skills must be skipped"
491+
);
492+
}
493+
352494
/// Provision to an empty managed dir: all bundled skills are installed and
353495
/// each gets a `.bundled` marker file containing the skill version.
354496
#[test]

src/daemon.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,8 @@ pub(crate) async fn run_daemon(
374374
)
375375
.apply_session_config(session_config)
376376
.with_disambiguation_threshold(config.skills.disambiguation_threshold)
377+
.with_two_stage_matching(config.skills.two_stage_matching)
378+
.with_confusability_threshold(config.skills.confusability_threshold)
377379
.with_skill_reload(skill_paths, reload_rx)
378380
.with_managed_skills_dir(zeph_core::bootstrap::managed_skills_dir())
379381
.with_memory(

src/runner.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -976,6 +976,8 @@ pub(crate) async fn run(cli: Cli) -> anyhow::Result<()> {
976976
zeph_core::config::ProviderEntry::effective_name,
977977
))
978978
.with_disambiguation_threshold(config.skills.disambiguation_threshold)
979+
.with_two_stage_matching(config.skills.two_stage_matching)
980+
.with_confusability_threshold(config.skills.confusability_threshold)
979981
.with_skill_reload(skill_paths.clone(), reload_rx)
980982
.with_managed_skills_dir(zeph_core::bootstrap::managed_skills_dir())
981983
.with_trust_config(config.skills.trust.clone())

0 commit comments

Comments
 (0)