update: Agent skills実装のレビュー・対応
This commit is contained in:
+11
-11
@@ -45,21 +45,21 @@ pub struct PodManifest {
|
||||
#[serde(default)]
|
||||
pub memory: Option<MemoryConfig>,
|
||||
/// External Agent Skills (`SKILL.md`) directories to ingest as
|
||||
/// Workflows in addition to the user-level `$config_dir/skills/`.
|
||||
/// Each entry is a path to a skills *root* (i.e. a directory whose
|
||||
/// children are individual `<name>/SKILL.md` skill bundles). Paths
|
||||
/// are resolved against the manifest's base directory like other
|
||||
/// path fields.
|
||||
/// Workflows. Each entry is a path to a skills *root* (i.e. a
|
||||
/// directory whose children are individual `<name>/SKILL.md` skill
|
||||
/// bundles). Paths are resolved against the manifest's base
|
||||
/// directory like other path fields. Absent ⇒ no skills loaded;
|
||||
/// there is no implicit `$config_dir/skills/` or builtin probe.
|
||||
#[serde(default)]
|
||||
pub skills: Option<SkillsConfig>,
|
||||
}
|
||||
|
||||
/// External Agent Skills (`SKILL.md`) ingest configuration. Off by
|
||||
/// default at the workspace level; user-level `$config_dir/skills/` is
|
||||
/// always probed regardless of this field. The intent of `directories`
|
||||
/// is to surface skills that already live in `.claude/skills/`,
|
||||
/// `.cursor/skills/`, etc. without duplicating them under the insomnia
|
||||
/// memory tree.
|
||||
/// External Agent Skills (`SKILL.md`) ingest configuration. Skills are
|
||||
/// loaded *only* from the directories listed here — there is no
|
||||
/// implicit `$config_dir/skills/` or builtin probe. Cascade-merged
|
||||
/// across manifest layers, so a user-level manifest can declare a
|
||||
/// shared skill root once while a project manifest adds its own
|
||||
/// `.claude/skills/` / `.cursor/skills/` paths on top.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct SkillsConfig {
|
||||
/// Skills *roots*. Children of each root must be individual
|
||||
|
||||
@@ -79,12 +79,6 @@ pub fn user_prompts_dir() -> Option<PathBuf> {
|
||||
Some(config_dir()?.join("prompts"))
|
||||
}
|
||||
|
||||
/// `<config_dir>/skills/` — user-level Agent Skills ライブラリ。
|
||||
/// 配下は `<name>/SKILL.md` の集合として読まれる。
|
||||
pub fn user_skills_dir() -> Option<PathBuf> {
|
||||
Some(config_dir()?.join("skills"))
|
||||
}
|
||||
|
||||
/// `<config_dir>/prompts.toml` — user prompt pack。
|
||||
pub fn user_pack_file() -> Option<PathBuf> {
|
||||
Some(config_dir()?.join("prompts.toml"))
|
||||
|
||||
@@ -422,7 +422,7 @@ mod tests {
|
||||
"Steps\n",
|
||||
);
|
||||
let record = parse_skill_md(&path).unwrap();
|
||||
let wf = record.into_workflow_record(WorkflowSource::UserSkill {
|
||||
let wf = record.into_workflow_record(WorkflowSource::Skill {
|
||||
dir: dir.path().to_path_buf(),
|
||||
});
|
||||
assert_eq!(wf.slug.as_str(), "x");
|
||||
@@ -431,7 +431,7 @@ mod tests {
|
||||
assert!(wf.user_invocable);
|
||||
assert!(wf.requires.is_empty());
|
||||
assert_eq!(wf.body, "Steps\n");
|
||||
assert!(matches!(wf.source, WorkflowSource::UserSkill { .. }));
|
||||
assert!(matches!(wf.source, WorkflowSource::Skill { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -23,19 +23,16 @@ pub const WORKFLOW_DESCRIPTION_HARD_CAP: usize = 1024;
|
||||
|
||||
/// Origin of a [`WorkflowRecord`]. Used to break ties when the same slug
|
||||
/// is provided by multiple sources: workspace-authored Workflows always
|
||||
/// win over external skills, and workspace skills win over user skills.
|
||||
/// win over external skills.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum WorkflowSource {
|
||||
/// `<workspace>/.insomnia/memory/workflow/<slug>.md`. Authored
|
||||
/// in-tree by the project.
|
||||
WorkspaceWorkflow,
|
||||
/// SKILL.md ingested from a `[skills] directories` entry in the
|
||||
/// project manifest. `dir` is the skills root that contained
|
||||
/// manifest. `dir` is the skills root that contained
|
||||
/// `<slug>/SKILL.md`.
|
||||
WorkspaceSkill { dir: PathBuf },
|
||||
/// SKILL.md ingested from `$user/skills/`. `dir` is the user-level
|
||||
/// skills root.
|
||||
UserSkill { dir: PathBuf },
|
||||
Skill { dir: PathBuf },
|
||||
}
|
||||
|
||||
impl WorkflowSource {
|
||||
@@ -43,8 +40,7 @@ impl WorkflowSource {
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::WorkspaceWorkflow => "workspace workflow",
|
||||
Self::WorkspaceSkill { .. } => "workspace skill",
|
||||
Self::UserSkill { .. } => "user skill",
|
||||
Self::Skill { .. } => "skill",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,11 +134,11 @@ impl WorkflowRegistry {
|
||||
}
|
||||
|
||||
/// Insert a skill-derived record. If an existing record (internal
|
||||
/// Workflow or higher-priority skill) already owns the slug, the
|
||||
/// Workflow or earlier-fed skill) already owns the slug, the
|
||||
/// incoming record is dropped and a [`ShadowedSkill`] describing the
|
||||
/// collision is returned. Callers must invoke this in
|
||||
/// **descending-priority order** (workspace skills before user
|
||||
/// skills); the registry does not re-rank afterwards.
|
||||
/// collision is returned. Callers feed records in priority order
|
||||
/// (highest first); the registry is "first-insert wins" and does
|
||||
/// not re-rank.
|
||||
pub fn merge_skill(&mut self, record: WorkflowRecord) -> Option<ShadowedSkill> {
|
||||
if let Some(existing) = self.records.get(&record.slug) {
|
||||
return Some(ShadowedSkill {
|
||||
@@ -386,7 +382,7 @@ mod tests {
|
||||
requires: Vec::new(),
|
||||
body: format!("body for {slug}"),
|
||||
path: path.to_path_buf(),
|
||||
source: WorkflowSource::WorkspaceSkill {
|
||||
source: WorkflowSource::Skill {
|
||||
dir: path.parent().unwrap().parent().unwrap().to_path_buf(),
|
||||
},
|
||||
}
|
||||
@@ -417,14 +413,14 @@ mod tests {
|
||||
requires: Vec::new(),
|
||||
body: "skill body".into(),
|
||||
path: skill_path.clone(),
|
||||
source: WorkflowSource::UserSkill {
|
||||
source: WorkflowSource::Skill {
|
||||
dir: dir.path().join("user-skills"),
|
||||
},
|
||||
};
|
||||
let shadow = reg.merge_skill(incoming).expect("expected shadow");
|
||||
assert_eq!(shadow.slug.as_str(), "shared");
|
||||
assert!(matches!(shadow.kept_source, WorkflowSource::WorkspaceWorkflow));
|
||||
assert!(matches!(shadow.shadowed_source, WorkflowSource::UserSkill { .. }));
|
||||
assert!(matches!(shadow.shadowed_source, WorkflowSource::Skill { .. }));
|
||||
// The kept record is still the workspace workflow.
|
||||
let kept = reg.get(&Slug::parse("shared").unwrap()).unwrap();
|
||||
assert!(matches!(kept.source, WorkflowSource::WorkspaceWorkflow));
|
||||
@@ -432,40 +428,42 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_skill_priority_workspace_over_user() {
|
||||
fn merge_skill_first_fed_wins_on_collision() {
|
||||
let mut reg = WorkflowRegistry::empty();
|
||||
let ws_path = std::path::PathBuf::from("/ws/skills/x/SKILL.md");
|
||||
let user_path = std::path::PathBuf::from("/user/skills/x/SKILL.md");
|
||||
let ws_record = WorkflowRecord {
|
||||
let first_path = std::path::PathBuf::from("/a/skills/x/SKILL.md");
|
||||
let second_path = std::path::PathBuf::from("/b/skills/x/SKILL.md");
|
||||
let first = WorkflowRecord {
|
||||
slug: Slug::parse("x").unwrap(),
|
||||
description: "ws".into(),
|
||||
description: "first".into(),
|
||||
model_invokation: true,
|
||||
user_invocable: true,
|
||||
requires: Vec::new(),
|
||||
body: "ws body".into(),
|
||||
path: ws_path.clone(),
|
||||
source: WorkflowSource::WorkspaceSkill {
|
||||
dir: std::path::PathBuf::from("/ws/skills"),
|
||||
body: "first body".into(),
|
||||
path: first_path.clone(),
|
||||
source: WorkflowSource::Skill {
|
||||
dir: std::path::PathBuf::from("/a/skills"),
|
||||
},
|
||||
};
|
||||
let user_record = WorkflowRecord {
|
||||
let second = WorkflowRecord {
|
||||
slug: Slug::parse("x").unwrap(),
|
||||
description: "user".into(),
|
||||
description: "second".into(),
|
||||
model_invokation: true,
|
||||
user_invocable: true,
|
||||
requires: Vec::new(),
|
||||
body: "user body".into(),
|
||||
path: user_path.clone(),
|
||||
source: WorkflowSource::UserSkill {
|
||||
dir: std::path::PathBuf::from("/user/skills"),
|
||||
body: "second body".into(),
|
||||
path: second_path.clone(),
|
||||
source: WorkflowSource::Skill {
|
||||
dir: std::path::PathBuf::from("/b/skills"),
|
||||
},
|
||||
};
|
||||
// Caller is required to feed in priority order: workspace first,
|
||||
// user second. The user-side record then gets shadowed.
|
||||
assert!(reg.merge_skill(ws_record).is_none());
|
||||
let shadow = reg.merge_skill(user_record).expect("user should shadow");
|
||||
assert_eq!(shadow.kept_path, ws_path);
|
||||
assert!(matches!(shadow.kept_source, WorkflowSource::WorkspaceSkill { .. }));
|
||||
// Caller is responsible for feeding in priority order; the
|
||||
// registry just keeps whichever arrives first.
|
||||
assert!(reg.merge_skill(first).is_none());
|
||||
let shadow = reg
|
||||
.merge_skill(second)
|
||||
.expect("later-fed skill must shadow");
|
||||
assert_eq!(shadow.kept_path, first_path);
|
||||
assert!(matches!(shadow.kept_source, WorkflowSource::Skill { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -474,15 +472,15 @@ mod tests {
|
||||
slug: Slug::parse("x").unwrap(),
|
||||
kept_source: WorkflowSource::WorkspaceWorkflow,
|
||||
kept_path: std::path::PathBuf::from("/ws/.insomnia/memory/workflow/x.md"),
|
||||
shadowed_source: WorkflowSource::UserSkill {
|
||||
dir: std::path::PathBuf::from("/user/skills"),
|
||||
shadowed_source: WorkflowSource::Skill {
|
||||
dir: std::path::PathBuf::from("/skills"),
|
||||
},
|
||||
shadowed_path: std::path::PathBuf::from("/user/skills/x/SKILL.md"),
|
||||
shadowed_path: std::path::PathBuf::from("/skills/x/SKILL.md"),
|
||||
};
|
||||
let msg = s.message();
|
||||
assert!(msg.contains("/x"));
|
||||
assert!(msg.contains("workspace workflow"));
|
||||
assert!(msg.contains("user skill"));
|
||||
assert!(msg.contains("skill"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+36
-52
@@ -2824,36 +2824,23 @@ fn prepare_pod_common_from_scope(
|
||||
|
||||
/// Ingest external SKILL.md sources into the workflow registry.
|
||||
///
|
||||
/// Sources are tried in descending priority so the registry's "first
|
||||
/// insert wins" semantics line up with the spec's collision order:
|
||||
/// 1. workspace-authored Workflows (already in `registry` from
|
||||
/// [`memory::load_workflows`])
|
||||
/// 2. workspace skills declared by the manifest's `[skills] directories`
|
||||
/// 3. user skills under `$config_dir/skills/`
|
||||
///
|
||||
/// Returns the list of shadowed-skill events the Pod should later push
|
||||
/// onto its notification buffer.
|
||||
/// Skills come exclusively from the manifest's `[skills] directories`
|
||||
/// list (resolved against the manifest base directory). Internal
|
||||
/// Workflows already loaded via [`memory::load_workflows`] take priority
|
||||
/// over skills sharing the same slug; collisions are surfaced as
|
||||
/// [`memory::ShadowedSkill`] events that the caller pushes onto the
|
||||
/// Pod's notification buffer.
|
||||
fn ingest_skills(
|
||||
registry: &mut memory::WorkflowRegistry,
|
||||
manifest: &PodManifest,
|
||||
) -> Vec<memory::ShadowedSkill> {
|
||||
let mut shadows = Vec::new();
|
||||
if let Some(skills_cfg) = manifest.skills.as_ref() {
|
||||
for dir in &skills_cfg.directories {
|
||||
for skill in memory::load_skills_from_dir(dir) {
|
||||
let source = memory::WorkflowSource::WorkspaceSkill { dir: dir.clone() };
|
||||
let record = skill.into_workflow_record(source);
|
||||
if let Some(shadow) = registry.merge_skill(record) {
|
||||
shadows.push(shadow);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(user_dir) = manifest::paths::user_skills_dir() {
|
||||
for skill in memory::load_skills_from_dir(&user_dir) {
|
||||
let source = memory::WorkflowSource::UserSkill {
|
||||
dir: user_dir.clone(),
|
||||
};
|
||||
let Some(skills_cfg) = manifest.skills.as_ref() else {
|
||||
return shadows;
|
||||
};
|
||||
for dir in &skills_cfg.directories {
|
||||
for skill in memory::load_skills_from_dir(dir) {
|
||||
let source = memory::WorkflowSource::Skill { dir: dir.clone() };
|
||||
let record = skill.into_workflow_record(source);
|
||||
if let Some(shadow) = registry.merge_skill(record) {
|
||||
shadows.push(shadow);
|
||||
@@ -2895,29 +2882,22 @@ fn build_scope_with_memory(manifest: &PodManifest, pwd: &Path) -> Result<Scope,
|
||||
}
|
||||
|
||||
/// Allow-rules granting `Read` access to every skill directory the Pod
|
||||
/// will ingest: the `[skills] directories` from the manifest plus the
|
||||
/// user-level `$config_dir/skills/`. Returned rules are recursive so
|
||||
/// the entire skill bundle (`SKILL.md` + `scripts/` + `references/` +
|
||||
/// `assets/`) is readable.
|
||||
/// will ingest from the manifest's `[skills] directories`. Returned
|
||||
/// rules are recursive so the entire skill bundle (`SKILL.md` +
|
||||
/// `scripts/` + `references/` + `assets/`) is readable.
|
||||
fn skill_dir_read_rules(manifest: &PodManifest) -> Vec<ScopeRule> {
|
||||
let mut rules = Vec::new();
|
||||
if let Some(skills_cfg) = manifest.skills.as_ref() {
|
||||
for dir in &skills_cfg.directories {
|
||||
rules.push(ScopeRule {
|
||||
target: dir.clone(),
|
||||
permission: Permission::Read,
|
||||
recursive: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(user_dir) = manifest::paths::user_skills_dir() {
|
||||
rules.push(ScopeRule {
|
||||
target: user_dir,
|
||||
let Some(skills_cfg) = manifest.skills.as_ref() else {
|
||||
return Vec::new();
|
||||
};
|
||||
skills_cfg
|
||||
.directories
|
||||
.iter()
|
||||
.map(|dir| ScopeRule {
|
||||
target: dir.clone(),
|
||||
permission: Permission::Read,
|
||||
recursive: true,
|
||||
});
|
||||
}
|
||||
rules
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Snapshot the process's current working directory as the Pod's pwd,
|
||||
@@ -3054,15 +3034,19 @@ permission = "write"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_dir_read_rules_ignores_missing_skills_section() {
|
||||
fn skill_dir_read_rules_empty_when_skills_section_missing() {
|
||||
let manifest = minimal_manifest_with_skills(vec![]);
|
||||
let rules = skill_dir_read_rules(&manifest);
|
||||
// Whatever rules we get must all be Read+recursive (the user
|
||||
// skills directory may or may not resolve depending on env).
|
||||
for rule in &rules {
|
||||
assert_eq!(rule.permission, Permission::Read);
|
||||
assert!(rule.recursive);
|
||||
}
|
||||
assert!(rules.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ingest_skills_returns_empty_when_skills_section_missing() {
|
||||
let manifest = minimal_manifest_with_skills(vec![]);
|
||||
let mut registry = memory::WorkflowRegistry::empty();
|
||||
let shadows = ingest_skills(&mut registry, &manifest);
|
||||
assert!(shadows.is_empty());
|
||||
assert!(registry.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user