Skip to content

Commit 8ca1f6d

Browse files
ottodengOtto DengvincentkocOtto Deng
authored
fix(skills): scan grouped skill directories
* fix(skills): scan nested subdirectories for grouped skill layouts Previously, skill discovery only checked immediate children of the skills root for SKILL.md files. Skills organized in subdirectories (e.g. ~/.openclaw/skills/coze/koze-retrieval/SKILL.md) were silently ignored. Now, when an immediate child directory does not contain a SKILL.md, its own children are checked one level deeper. This supports grouped skill layouts while keeping the scan depth bounded (max 2 levels) to avoid unbounded filesystem traversal. The existing per-source skill count limits and containment checks still apply to nested discoveries. Fixes #56915 * test(skills): cover nested grouped skill discovery * fix(skills): cache contained-path checks and cap nested scans - Reuse skillDirRealPath captured during the collection phase so the load loop no longer re-runs resolveContainedSkillPath on the same directory. - Apply the per-root candidate cap (and the matching warning log) when descending into nested grouped skill directories, matching the outer scan's behavior. Addresses Greptile P2 feedback on PR #72534. * fix(skills): load grouped skill directories under skills roots * fix(clownfish): address review for ghcrawl-156697-autonomous-smoke (1) --------- Co-authored-by: Otto Deng <otto@ottodeng.com> Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com> Co-authored-by: Otto Deng <ottodeng2@github.local>
1 parent d18fdec commit 8ca1f6d

4 files changed

Lines changed: 223 additions & 61 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,7 @@ Docs: https://docs.openclaw.ai
296296
- Pairing/doctor: bootstrap `commands.ownerAllowFrom` from the first approved DM pairing when no command owner exists, and have doctor explain missing owners so privileged slash commands are not accidentally unusable after onboarding. Thanks @pashpashpash.
297297
- Telegram/exec: infer native exec approvers from `commands.ownerAllowFrom` and auto-enable the Telegram approval client when an owner is resolvable, so owner-only commands such as `/diagnostics` can be approved in Telegram without duplicate per-channel approver config. Thanks @pashpashpash.
298298
- Auto-reply/session: carry the tail of user/assistant turns into the freshly-rotated transcript on silent in-reply session resets (compaction failure, role-ordering conflict) so direct-chat continuity survives the rebind. Fixes #70853. (#70898) Thanks @neeravmakwana.
299+
- Skills: load grouped skill directories such as `skills/<group>/<skill>/SKILL.md` from configured skill roots while keeping grouped discovery capped for large directories. Fixes #56915. (#72534) Thanks @ottodeng, @MoerAI, and @i010542.
299300
- Config: skip malformed non-string `env.vars` entries before env-reference checks, so config loading no longer crashes on JSON values like numbers or booleans. (#42402) Thanks @MiltonHeYan.
300301
- Docker Compose: default missing config and workspace bind mounts to `${HOME:-/tmp}/.openclaw` so manual compose runs do not create invalid empty-source volume specs. (#64485) Thanks @jlapenna.
301302
- Agents/context engines: preserve the child agent's configured `agentDir` when subagent cleanup re-resolves a context engine, so `onSubagentEnded` hooks keep operating on the correct per-agent state. (#67243) Thanks @jarimustonen.

docs/tools/skills.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,9 @@ Native `openclaw skills install` installs into the active workspace
130130
`./skills` under your current working directory (or falls back to the
131131
configured OpenClaw workspace). OpenClaw picks that up as
132132
`<workspace>/skills` on the next session.
133+
Configured skill roots also support one grouping level, such as
134+
`skills/<group>/<skill>/SKILL.md`, so related third-party skills can be
135+
kept under a shared folder without broad recursive scanning.
133136

134137
ClawHub skill pages expose the latest security scan state before install,
135138
with scanner detail pages for VirusTotal, ClawScan, and static analysis.

src/agents/skills.loadworkspaceskillentries.test.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,4 +412,121 @@ describe("loadWorkspaceSkillEntries", () => {
412412
});
413413
},
414414
);
415+
416+
describe("nested skill subdirectories", () => {
417+
it("discovers SKILL.md two levels deep under a grouping subfolder", async () => {
418+
const workspaceDir = await createTempWorkspaceDir();
419+
// Grouped layout: skills/group/skill/SKILL.md (no SKILL.md at skills/group/).
420+
await writeSkill({
421+
dir: path.join(workspaceDir, "skills", "group", "nested-skill"),
422+
name: "nested-skill",
423+
description: "Nested under a group folder",
424+
});
425+
426+
const entries = loadTestWorkspaceSkillEntries(workspaceDir);
427+
const names = entries.map((entry) => entry.skill.name);
428+
expect(names).toContain("nested-skill");
429+
});
430+
431+
it("keeps loading direct skills (skills/skill/SKILL.md) unchanged", async () => {
432+
const workspaceDir = await createTempWorkspaceDir();
433+
await writeSkill({
434+
dir: path.join(workspaceDir, "skills", "direct-skill"),
435+
name: "direct-skill",
436+
description: "Direct skill at first level",
437+
});
438+
// Sibling group with a deeper skill.
439+
await writeSkill({
440+
dir: path.join(workspaceDir, "skills", "group", "grouped-skill"),
441+
name: "grouped-skill",
442+
description: "Skill nested under a group",
443+
});
444+
445+
const names = loadTestWorkspaceSkillEntries(workspaceDir).map((entry) => entry.skill.name);
446+
expect(names).toEqual(expect.arrayContaining(["direct-skill", "grouped-skill"]));
447+
});
448+
449+
it("does not descend more than two levels (skills/a/b/c/SKILL.md is ignored)", async () => {
450+
const workspaceDir = await createTempWorkspaceDir();
451+
await writeSkill({
452+
dir: path.join(workspaceDir, "skills", "a", "b", "c"),
453+
name: "too-deep",
454+
description: "Should not be discovered (depth 3)",
455+
});
456+
457+
const names = loadTestWorkspaceSkillEntries(workspaceDir).map((entry) => entry.skill.name);
458+
expect(names).not.toContain("too-deep");
459+
});
460+
461+
it("does not fall through to child skills when an immediate SKILL.md is invalid", async () => {
462+
const workspaceDir = await createTempWorkspaceDir();
463+
const parentDir = path.join(workspaceDir, "skills", "group", "parent");
464+
await fs.mkdir(parentDir, { recursive: true });
465+
await fs.writeFile(path.join(parentDir, "SKILL.md"), "---\nname: parent\n---\n", "utf-8");
466+
await writeSkill({
467+
dir: path.join(parentDir, "child"),
468+
name: "too-deep",
469+
description: "Should not be discovered through invalid parent fallback",
470+
});
471+
472+
const names = loadTestWorkspaceSkillEntries(workspaceDir).map((entry) => entry.skill.name);
473+
expect(names).not.toContain("too-deep");
474+
});
475+
476+
it("prefers the immediate SKILL.md and does not descend when one is present", async () => {
477+
const workspaceDir = await createTempWorkspaceDir();
478+
// skills/group/SKILL.md exists -> treat group as the skill itself.
479+
await writeSkill({
480+
dir: path.join(workspaceDir, "skills", "group"),
481+
name: "group",
482+
description: "Direct skill at the group level",
483+
});
484+
// skills/group/inner/SKILL.md should NOT be loaded as a separate skill.
485+
await writeSkill({
486+
dir: path.join(workspaceDir, "skills", "group", "inner"),
487+
name: "inner",
488+
description: "Should be ignored when parent is itself a skill",
489+
});
490+
491+
const names = loadTestWorkspaceSkillEntries(workspaceDir).map((entry) => entry.skill.name);
492+
expect(names).toContain("group");
493+
expect(names).not.toContain("inner");
494+
});
495+
496+
it("warns and caps discovery in large grouping subfolders", async () => {
497+
const workspaceDir = await createTempWorkspaceDir();
498+
for (let i = 0; i < 3; i += 1) {
499+
const name = `nested-skill-${i}`;
500+
await writeSkill({
501+
dir: path.join(workspaceDir, "skills", "group", name),
502+
name,
503+
description: `Nested skill ${i}`,
504+
});
505+
}
506+
const warn = captureWarningLogger();
507+
508+
const names = loadTestWorkspaceSkillEntries(workspaceDir, {
509+
config: {
510+
skills: {
511+
limits: {
512+
maxCandidatesPerRoot: 2,
513+
maxSkillsLoadedPerSource: 10,
514+
},
515+
},
516+
},
517+
}).map((entry) => entry.skill.name);
518+
519+
expect(names).toEqual(expect.arrayContaining(["nested-skill-0", "nested-skill-1"]));
520+
expect(names).not.toContain("nested-skill-2");
521+
expect(
522+
warn.mock.calls
523+
.map(([line]) => String(line))
524+
.some((line) =>
525+
line.includes(
526+
"Nested skills directory looks suspiciously large, truncating discovery.",
527+
),
528+
),
529+
).toBe(true);
530+
});
531+
});
415532
});

src/agents/skills/workspace.ts

Lines changed: 102 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,12 @@ type LoadedSkillRecord = {
140140
frontmatter?: ParsedSkillFrontmatter;
141141
};
142142

143+
type CandidateSkillDir = {
144+
skillDir: string;
145+
name: string;
146+
skillMdRealPath: string;
147+
};
148+
143149
function resolveSkillsLimits(config?: OpenClawConfig, agentId?: string): ResolvedSkillsLimits {
144150
const limits = config?.skills?.limits;
145151
const agentSkillsLimits = resolveEffectiveAgentSkillsLimits(config, agentId);
@@ -288,32 +294,6 @@ function resolveContainedSkillPath(params: {
288294
return null;
289295
}
290296

291-
function filterLoadedSkillRecordsInsideRoot(params: {
292-
records: LoadedSkillRecord[];
293-
source: string;
294-
rootDir: string;
295-
rootRealPath: string;
296-
}): LoadedSkillRecord[] {
297-
return params.records.filter(({ skill }) => {
298-
const baseDirRealPath = resolveContainedSkillPath({
299-
source: params.source,
300-
rootDir: params.rootDir,
301-
rootRealPath: params.rootRealPath,
302-
candidatePath: skill.baseDir,
303-
});
304-
if (!baseDirRealPath) {
305-
return false;
306-
}
307-
const skillFileRealPath = resolveContainedSkillPath({
308-
source: params.source,
309-
rootDir: params.rootDir,
310-
rootRealPath: params.rootRealPath,
311-
candidatePath: skill.filePath,
312-
});
313-
return Boolean(skillFileRealPath);
314-
});
315-
}
316-
317297
function resolveNestedSkillsRoot(
318298
dir: string,
319299
opts?: {
@@ -365,6 +345,22 @@ function unwrapLoadedSkillRecords(loaded: unknown): LoadedSkillRecord[] {
365345
return [];
366346
}
367347

348+
function loadContainedSkillRecords(params: {
349+
skillDir: string;
350+
source: string;
351+
maxSkillFileBytes: number;
352+
}): LoadedSkillRecord[] {
353+
const expectedBaseDir = path.resolve(params.skillDir);
354+
const loaded = loadSkillsFromDirSafe({
355+
dir: params.skillDir,
356+
source: params.source,
357+
maxBytes: params.maxSkillFileBytes,
358+
});
359+
return unwrapLoadedSkillRecords(loaded).filter(
360+
(record) => path.resolve(record.skill.baseDir) === expectedBaseDir,
361+
);
362+
}
363+
368364
function loadSkillEntries(
369365
workspaceDir: string,
370366
opts?: {
@@ -423,23 +419,19 @@ function loadSkillEntries(
423419
return [];
424420
}
425421

426-
const loaded = loadSkillsFromDirSafe({
427-
dir: baseDir,
428-
source: params.source,
429-
maxBytes: limits.maxSkillFileBytes,
430-
});
431-
return filterLoadedSkillRecordsInsideRoot({
432-
records: unwrapLoadedSkillRecords(loaded),
422+
return loadContainedSkillRecords({
423+
skillDir: baseDir,
433424
source: params.source,
434-
rootDir,
435-
rootRealPath: baseDirRealPath,
425+
maxSkillFileBytes: limits.maxSkillFileBytes,
436426
});
437427
}
438428

439429
const childDirs = listChildDirectories(baseDir);
440-
const suspicious = childDirs.length > limits.maxCandidatesPerRoot;
430+
const maxCandidatesPerRoot = Math.max(0, limits.maxCandidatesPerRoot);
431+
const maxSkillsLoadedPerSource = Math.max(0, limits.maxSkillsLoadedPerSource);
432+
const suspicious = childDirs.length > maxCandidatesPerRoot;
441433

442-
const maxCandidates = Math.max(0, limits.maxSkillsLoadedPerSource);
434+
const maxCandidates = Math.min(maxCandidatesPerRoot, maxSkillsLoadedPerSource);
443435
const limitedChildren = childDirs.toSorted().slice(0, maxCandidates);
444436

445437
if (suspicious) {
@@ -461,7 +453,10 @@ function loadSkillEntries(
461453

462454
const loadedSkills: LoadedSkillRecord[] = [];
463455

464-
// Only consider immediate subfolders that look like skills (have SKILL.md) and are under size cap.
456+
// Consider immediate subfolders that look like skills (have SKILL.md) and are under size cap.
457+
// When an immediate subfolder does NOT have a SKILL.md, check one level deeper for grouped
458+
// skill directories (e.g. ~/.openclaw/skills/coze/koze-retrieval/SKILL.md).
459+
const candidateDirs: CandidateSkillDir[] = [];
465460
for (const name of limitedChildren) {
466461
const skillDir = path.join(baseDir, name);
467462
const skillDirRealPath = resolveContainedSkillPath({
@@ -474,24 +469,76 @@ function loadSkillEntries(
474469
continue;
475470
}
476471
const skillMd = path.join(skillDir, "SKILL.md");
477-
if (!fs.existsSync(skillMd)) {
478-
continue;
472+
if (fs.existsSync(skillMd)) {
473+
const skillMdRealPath = resolveContainedSkillPath({
474+
source: params.source,
475+
rootDir,
476+
rootRealPath: baseDirRealPath,
477+
candidatePath: skillMd,
478+
});
479+
if (skillMdRealPath) {
480+
candidateDirs.push({ skillDir, name, skillMdRealPath });
481+
}
482+
} else {
483+
// No SKILL.md here — check one level deeper for grouped skill directories.
484+
// Apply the same per-root cap as the outer scan to avoid scanning huge nested trees.
485+
const nestedChildren = listChildDirectories(skillDir);
486+
const nestedSuspicious = nestedChildren.length > maxCandidatesPerRoot;
487+
if (nestedSuspicious) {
488+
skillsLogger.warn(
489+
"Nested skills directory looks suspiciously large, truncating discovery.",
490+
{
491+
dir: params.dir,
492+
baseDir,
493+
nestedDir: skillDir,
494+
nestedChildDirCount: nestedChildren.length,
495+
maxCandidatesPerRoot: limits.maxCandidatesPerRoot,
496+
maxSkillsLoadedPerSource: limits.maxSkillsLoadedPerSource,
497+
},
498+
);
499+
}
500+
const limitedNested = nestedChildren.toSorted().slice(0, maxCandidatesPerRoot);
501+
for (const nestedName of limitedNested) {
502+
const nestedDir = path.join(skillDir, nestedName);
503+
const nestedSkillMd = path.join(nestedDir, "SKILL.md");
504+
if (fs.existsSync(nestedSkillMd)) {
505+
const nestedDirRealPath = resolveContainedSkillPath({
506+
source: params.source,
507+
rootDir,
508+
rootRealPath: baseDirRealPath,
509+
candidatePath: nestedDir,
510+
});
511+
const nestedSkillMdRealPath = resolveContainedSkillPath({
512+
source: params.source,
513+
rootDir,
514+
rootRealPath: baseDirRealPath,
515+
candidatePath: nestedSkillMd,
516+
});
517+
if (nestedDirRealPath && nestedSkillMdRealPath) {
518+
candidateDirs.push({
519+
skillDir: nestedDir,
520+
name: `${name}/${nestedName}`,
521+
skillMdRealPath: nestedSkillMdRealPath,
522+
});
523+
}
524+
}
525+
if (candidateDirs.length >= maxSkillsLoadedPerSource) {
526+
break;
527+
}
528+
}
479529
}
480-
const skillMdRealPath = resolveContainedSkillPath({
481-
source: params.source,
482-
rootDir,
483-
rootRealPath: baseDirRealPath,
484-
candidatePath: skillMd,
485-
});
486-
if (!skillMdRealPath) {
487-
continue;
530+
if (candidateDirs.length >= maxSkillsLoadedPerSource) {
531+
break;
488532
}
533+
}
534+
535+
for (const { skillDir, name, skillMdRealPath } of candidateDirs) {
489536
try {
490537
const size = fs.statSync(skillMdRealPath).size;
491538
if (size > limits.maxSkillFileBytes) {
492539
skillsLogger.warn("Skipping skill due to oversized SKILL.md.", {
493540
skill: name,
494-
filePath: skillMd,
541+
filePath: path.join(skillDir, "SKILL.md"),
495542
size,
496543
maxSkillFileBytes: limits.maxSkillFileBytes,
497544
});
@@ -501,30 +548,24 @@ function loadSkillEntries(
501548
continue;
502549
}
503550

504-
const loaded = loadSkillsFromDirSafe({
505-
dir: skillDir,
506-
source: params.source,
507-
maxBytes: limits.maxSkillFileBytes,
508-
});
509551
loadedSkills.push(
510-
...filterLoadedSkillRecordsInsideRoot({
511-
records: unwrapLoadedSkillRecords(loaded),
552+
...loadContainedSkillRecords({
553+
skillDir,
512554
source: params.source,
513-
rootDir,
514-
rootRealPath: baseDirRealPath,
555+
maxSkillFileBytes: limits.maxSkillFileBytes,
515556
}),
516557
);
517558

518-
if (loadedSkills.length >= limits.maxSkillsLoadedPerSource) {
559+
if (loadedSkills.length >= maxSkillsLoadedPerSource) {
519560
break;
520561
}
521562
}
522563

523-
if (loadedSkills.length > limits.maxSkillsLoadedPerSource) {
564+
if (loadedSkills.length > maxSkillsLoadedPerSource) {
524565
return loadedSkills
525566
.slice()
526567
.sort((a, b) => a.skill.name.localeCompare(b.skill.name, "en"))
527-
.slice(0, limits.maxSkillsLoadedPerSource);
568+
.slice(0, maxSkillsLoadedPerSource);
528569
}
529570

530571
return loadedSkills;

0 commit comments

Comments
 (0)