Skip to content

Commit fd71bc0

Browse files
KimGLeealtaywtf
andauthored
fix(skills): unify runtime inclusion and available_skills exposure policy (#60852)
Merged via squash. Prepared head SHA: 2b48b3a Co-authored-by: KimGLee <[email protected]> Co-authored-by: altaywtf <[email protected]> Reviewed-by: @altaywtf
1 parent 70015be commit fd71bc0

8 files changed

Lines changed: 140 additions & 23 deletions

src/agents/skills.buildworkspaceskillsnapshot.test.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,10 +104,8 @@ describe("buildWorkspaceSkillSnapshot", () => {
104104

105105
expect(snapshot.prompt).toContain("visible-skill");
106106
expect(snapshot.prompt).not.toContain("hidden-skill");
107-
expect(snapshot.skills.map((skill) => skill.name).toSorted()).toEqual([
108-
"hidden-skill",
109-
"visible-skill",
110-
]);
107+
expect(snapshot.skills.map((skill) => skill.name)).toContain("hidden-skill");
108+
expect(snapshot.skills.map((skill) => skill.name)).toContain("visible-skill");
111109
});
112110

113111
it("keeps prompt output aligned with buildWorkspaceSkillsPrompt", async () => {

src/agents/skills.loadworkspaceskillentries.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,26 @@ describe("loadWorkspaceSkillEntries", () => {
149149
expect(entries.map((entry) => entry.skill.name)).toContain("fallback-name");
150150
});
151151

152+
it("marks disable-model-invocation skills as hidden in exposure metadata for newly loaded entries", async () => {
153+
const workspaceDir = await createTempWorkspaceDir();
154+
await writeSkill({
155+
dir: path.join(workspaceDir, "skills", "hidden-skill"),
156+
name: "hidden-skill",
157+
description: "Hidden prompt entry",
158+
frontmatterExtra: "disable-model-invocation: true",
159+
});
160+
161+
const entries = loadWorkspaceSkillEntries(workspaceDir, {
162+
managedSkillsDir: path.join(workspaceDir, ".managed"),
163+
bundledSkillsDir: path.join(workspaceDir, ".bundled"),
164+
});
165+
166+
const hiddenEntry = entries.find((entry) => entry.skill.name === "hidden-skill");
167+
168+
expect(hiddenEntry?.invocation?.disableModelInvocation).toBe(true);
169+
expect(hiddenEntry?.exposure?.includeInAvailableSkillsPrompt).toBe(false);
170+
});
171+
152172
it("inherits agents.defaults.skills when an agent omits skills", async () => {
153173
const workspaceDir = await createTempWorkspaceDir();
154174
await writeSkill({

src/agents/skills.resolveskillspromptforrun.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,27 @@ describe("resolveSkillsPromptForRun", () => {
3030
expect(prompt).toContain("/app/skills/demo-skill/SKILL.md");
3131
});
3232

33+
it("keeps legacy entries with disableModelInvocation hidden when exposure metadata is absent", () => {
34+
const hidden: SkillEntry = {
35+
skill: createFixtureSkill({
36+
name: "hidden-skill",
37+
description: "Hidden",
38+
filePath: "/app/skills/hidden-skill/SKILL.md",
39+
baseDir: "/app/skills/hidden-skill",
40+
source: "openclaw-workspace",
41+
disableModelInvocation: true,
42+
}),
43+
frontmatter: {},
44+
};
45+
46+
const prompt = resolveSkillsPromptForRun({
47+
entries: [hidden],
48+
workspaceDir: "/tmp/openclaw",
49+
});
50+
51+
expect(prompt).not.toContain("/app/skills/hidden-skill/SKILL.md");
52+
});
53+
3354
it("inherits agents.defaults.skills when rebuilding prompt for an agent", () => {
3455
const visible: SkillEntry = {
3556
skill: createFixtureSkill({
@@ -117,6 +138,7 @@ function createFixtureSkill(params: {
117138
filePath: string;
118139
baseDir: string;
119140
source: string;
141+
disableModelInvocation?: boolean;
120142
}): SkillEntry["skill"] {
121143
return createCanonicalFixtureSkill(params);
122144
}

src/agents/skills.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,24 @@ describe("buildWorkspaceSkillsPrompt", () => {
332332
expect(prompt).toContain("Does demo things");
333333
expect(prompt).toContain(path.join(skillDir, "SKILL.md"));
334334
});
335+
336+
it("omits disable-model-invocation skills from available_skills for freshly loaded entries", async () => {
337+
const workspaceDir = await makeWorkspace();
338+
const skillDir = path.join(workspaceDir, "skills", "hidden-skill");
339+
340+
await writeSkill({
341+
dir: skillDir,
342+
name: "hidden-skill",
343+
description: "Hidden from the prompt",
344+
frontmatterExtra: "disable-model-invocation: true",
345+
});
346+
347+
const prompt = buildWorkspaceSkillsPrompt(workspaceDir, resolveTestSkillDirs(workspaceDir));
348+
349+
expect(prompt).not.toContain("hidden-skill");
350+
expect(prompt).not.toContain("Hidden from the prompt");
351+
expect(prompt).not.toContain(path.join(skillDir, "SKILL.md"));
352+
});
335353
});
336354

337355
describe("applySkillEnvOverrides", () => {

src/agents/skills/compact-format.test.ts

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,15 @@ function makeSkill(name: string, desc = "A skill", filePath = `/skills/${name}/S
2222
}
2323

2424
function makeEntry(skill: Skill): SkillEntry {
25-
return { skill, frontmatter: {} };
25+
return {
26+
skill,
27+
frontmatter: {},
28+
exposure: {
29+
includeInRuntimeRegistry: true,
30+
includeInAvailableSkillsPrompt: true,
31+
userInvocable: true,
32+
},
33+
};
2634
}
2735

2836
function buildPrompt(
@@ -43,16 +51,21 @@ function buildPrompt(
4351
}
4452

4553
describe("formatSkillsCompact", () => {
46-
it("keeps the full-format XML output aligned with the upstream formatter", () => {
47-
const hidden: Skill = { ...makeSkill("hidden"), disableModelInvocation: true };
54+
it("keeps the full-format XML output aligned with the upstream formatter for visible skills", () => {
4855
const skills = [
4956
makeSkill("weather", "Get weather <data> & forecasts"),
5057
makeSkill("notes", "Summarize notes", "/tmp/notes/SKILL.md"),
51-
hidden,
5258
];
5359
expect(formatSkillsForPrompt(skills)).toBe(upstreamFormatSkillsForPrompt(skills));
5460
});
5561

62+
it("renders all passed skills in the full formatter without reapplying visibility policy", () => {
63+
const hidden: Skill = { ...makeSkill("hidden"), disableModelInvocation: true };
64+
const out = formatSkillsForPrompt([makeSkill("visible"), hidden]);
65+
expect(out).toContain("visible");
66+
expect(out).toContain("hidden");
67+
});
68+
5669
it("returns empty string for no skills", () => {
5770
expect(formatSkillsCompact([])).toBe("");
5871
});
@@ -65,11 +78,11 @@ describe("formatSkillsCompact", () => {
6578
expect(out).not.toContain("<description>");
6679
});
6780

68-
it("filters out disableModelInvocation skills", () => {
81+
it("renders all passed skills without reapplying visibility policy", () => {
6982
const hidden: Skill = { ...makeSkill("hidden"), disableModelInvocation: true };
7083
const out = formatSkillsCompact([makeSkill("visible"), hidden]);
7184
expect(out).toContain("visible");
72-
expect(out).not.toContain("hidden");
85+
expect(out).toContain("hidden");
7386
});
7487

7588
it("escapes XML special characters", () => {
@@ -87,6 +100,29 @@ describe("formatSkillsCompact", () => {
87100
});
88101

89102
describe("applySkillsPromptLimits (via buildWorkspaceSkillsPrompt)", () => {
103+
it("respects explicit exposure metadata before compact formatting", () => {
104+
const hidden = makeEntry({ ...makeSkill("hidden"), disableModelInvocation: true });
105+
hidden.exposure = {
106+
includeInRuntimeRegistry: true,
107+
includeInAvailableSkillsPrompt: false,
108+
userInvocable: true,
109+
};
110+
111+
const prompt = buildWorkspaceSkillsPrompt("/fake", {
112+
entries: [makeEntry(makeSkill("visible")), hidden],
113+
config: {
114+
skills: {
115+
limits: {
116+
maxSkillsPromptChars: 4_000,
117+
},
118+
},
119+
} satisfies OpenClawConfig,
120+
});
121+
122+
expect(prompt).toContain("visible");
123+
expect(prompt).not.toContain("hidden");
124+
});
125+
90126
it("tier 1: uses full format when under budget", () => {
91127
const skills = [makeSkill("weather", "Get weather data")];
92128
const prompt = buildPrompt(skills, { maxChars: 50_000 });

src/agents/skills/skill-contract.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,13 @@ function escapeXml(str: string): string {
3636
}
3737

3838
/**
39-
* Keep this formatter byte-for-byte aligned with the upstream Agent Skills XML
40-
* layout so we can avoid importing the full pi-coding-agent package root on the
41-
* cold skills path.
39+
* Keep this formatter's XML layout byte-for-byte aligned with the upstream
40+
* Agent Skills formatter so we can avoid importing the full pi-coding-agent
41+
* package root on the cold skills path. Visibility policy is applied upstream
42+
* before calling this helper.
4243
*/
4344
export function formatSkillsForPrompt(skills: Skill[]): string {
44-
const visibleSkills = skills.filter((skill) => !skill.disableModelInvocation);
45-
if (visibleSkills.length === 0) {
45+
if (skills.length === 0) {
4646
return "";
4747
}
4848
const lines = [
@@ -52,7 +52,7 @@ export function formatSkillsForPrompt(skills: Skill[]): string {
5252
"",
5353
"<available_skills>",
5454
];
55-
for (const skill of visibleSkills) {
55+
for (const skill of skills) {
5656
lines.push(" <skill>");
5757
lines.push(` <name>${escapeXml(skill.name)}</name>`);
5858
lines.push(` <description>${escapeXml(skill.description)}</description>`);

src/agents/skills/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,11 +67,18 @@ export type SkillsInstallPreferences = {
6767

6868
export type ParsedSkillFrontmatter = Record<string, string>;
6969

70+
export type SkillExposure = {
71+
includeInRuntimeRegistry: boolean;
72+
includeInAvailableSkillsPrompt: boolean;
73+
userInvocable: boolean;
74+
};
75+
7076
export type SkillEntry = {
7177
skill: Skill;
7278
frontmatter: ParsedSkillFrontmatter;
7379
metadata?: OpenClawSkillMetadata;
7480
invocation?: SkillInvocationPolicy;
81+
exposure?: SkillExposure;
7582
};
7683

7784
export type SkillEligibilityContext = {

src/agents/skills/workspace.ts

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,16 @@ function compactSkillPaths(skills: Skill[]): Skill[] {
4545
}));
4646
}
4747

48+
function isSkillVisibleInAvailableSkillsPrompt(entry: SkillEntry): boolean {
49+
if (entry.exposure) {
50+
return entry.exposure.includeInAvailableSkillsPrompt !== false;
51+
}
52+
if (entry.invocation) {
53+
return entry.invocation.disableModelInvocation !== true;
54+
}
55+
return entry.skill.disableModelInvocation !== true;
56+
}
57+
4858
function filterSkillEntries(
4959
entries: SkillEntry[],
5060
config?: OpenClawConfig,
@@ -469,11 +479,20 @@ function loadSkillEntries(
469479
filePath: skill.filePath,
470480
maxBytes: limits.maxSkillFileBytes,
471481
}) ?? ({} as ParsedSkillFrontmatter);
482+
const invocation = resolveSkillInvocationPolicy(frontmatter);
472483
return {
473484
skill,
474485
frontmatter,
475486
metadata: resolveOpenClawMetadata(frontmatter),
476-
invocation: resolveSkillInvocationPolicy(frontmatter),
487+
invocation,
488+
exposure: {
489+
includeInRuntimeRegistry: true,
490+
// Freshly loaded entries preserve the documented disable-model-invocation
491+
// contract, while legacy entries without exposure metadata still use the
492+
// fallback in isSkillVisibleInAvailableSkillsPrompt().
493+
includeInAvailableSkillsPrompt: invocation.disableModelInvocation !== true,
494+
userInvocable: invocation.userInvocable !== false,
495+
},
477496
};
478497
});
479498
return skillEntries;
@@ -494,16 +513,15 @@ function escapeXml(str: string): string {
494513
* preserving awareness of all skills before resorting to dropping.
495514
*/
496515
export function formatSkillsCompact(skills: Skill[]): string {
497-
const visible = skills.filter((s) => !s.disableModelInvocation);
498-
if (visible.length === 0) return "";
516+
if (skills.length === 0) return "";
499517
const lines = [
500518
"\n\nThe following skills provide specialized instructions for specific tasks.",
501519
"Use the read tool to load a skill's file when the task matches its name.",
502520
"When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.",
503521
"",
504522
"<available_skills>",
505523
];
506-
for (const skill of visible) {
524+
for (const skill of skills) {
507525
lines.push(" <skill>");
508526
lines.push(` <name>${escapeXml(skill.name)}</name>`);
509527
lines.push(` <location>${escapeXml(skill.filePath)}</location>`);
@@ -629,9 +647,7 @@ function resolveWorkspaceSkillPromptState(
629647
effectiveSkillFilter,
630648
opts?.eligibility,
631649
);
632-
const promptEntries = eligible.filter(
633-
(entry) => entry.invocation?.disableModelInvocation !== true,
634-
);
650+
const promptEntries = eligible.filter((entry) => isSkillVisibleInAvailableSkillsPrompt(entry));
635651
const remoteNote = opts?.eligibility?.remote?.note?.trim();
636652
const resolvedSkills = promptEntries.map((entry) => entry.skill);
637653
// Derive prompt-facing skills with compacted paths (e.g. ~/...) once.

0 commit comments

Comments
 (0)