fix(skills): sort available_skills alphabetically for prompt cache stability

Sort the merged skill entries by name before rendering into the
available_skills prompt block. Previously the order depended on
Map insertion order which varies with skills.load.extraDirs config,
causing identical deployments to produce different prompts and bypass
LLM prompt caching.

Two sort points added:
1. loadSkillEntries — canonical ordering at the source
2. resolveWorkspaceSkillPromptState — ensures prompt stability even
   when callers pass pre-built entry arrays

Fixes #64167
This commit is contained in:
Bartok Moltbot
2026-04-10 04:37:39 -04:00
committed by Peter Steinberger
parent 77d9fd693f
commit a4b94f77b9
2 changed files with 42 additions and 24 deletions

View File

@@ -271,6 +271,20 @@ describe("applySkillsPromptLimits (via buildWorkspaceSkillsPrompt)", () => {
expect(prompt).not.toContain(home);
});
it("skills are sorted alphabetically regardless of entry insertion order", () => {
// Entries provided in reverse alphabetical order should still produce
// an alphabetically sorted prompt (fixes #64167).
const entries = ["zoo", "apple", "mango", "banana"].map((n) =>
makeEntry(makeSkill(n, `${n} skill`)),
);
const prompt = buildWorkspaceSkillsPrompt("/fake", {
entries,
config: { skills: { limits: { maxSkillsPromptChars: 50_000 } } } satisfies OpenClawConfig,
});
const nameMatches = [...prompt.matchAll(/<name>(\w+)<\/name>/g)].map((m) => m[1]);
expect(nameMatches).toEqual(["apple", "banana", "mango", "zoo"]);
});
it("resolvedSkills in snapshot keeps canonical paths, not compacted", () => {
const home = os.homedir();
const skills = Array.from({ length: 5 }, (_, i) =>

View File

@@ -574,29 +574,31 @@ function loadSkillEntries(
merged.set(skill.name, skill);
}
const skillEntries: SkillEntry[] = Array.from(merged.values()).map((skill) => {
const frontmatter =
readSkillFrontmatterSafe({
rootDir: skill.baseDir,
filePath: skill.filePath,
maxBytes: limits.maxSkillFileBytes,
}) ?? ({} as ParsedSkillFrontmatter);
const invocation = resolveSkillInvocationPolicy(frontmatter);
return {
skill,
frontmatter,
metadata: resolveOpenClawMetadata(frontmatter),
invocation,
exposure: {
includeInRuntimeRegistry: true,
// Freshly loaded entries preserve the documented disable-model-invocation
// contract, while legacy entries without exposure metadata still use the
// fallback in isSkillVisibleInAvailableSkillsPrompt().
includeInAvailableSkillsPrompt: invocation.disableModelInvocation !== true,
userInvocable: invocation.userInvocable !== false,
},
};
});
const skillEntries: SkillEntry[] = Array.from(merged.values())
.sort((a, b) => a.name.localeCompare(b.name))
.map((skill) => {
const frontmatter =
readSkillFrontmatterSafe({
rootDir: skill.baseDir,
filePath: skill.filePath,
maxBytes: limits.maxSkillFileBytes,
}) ?? ({} as ParsedSkillFrontmatter);
const invocation = resolveSkillInvocationPolicy(frontmatter);
return {
skill,
frontmatter,
metadata: resolveOpenClawMetadata(frontmatter),
invocation,
exposure: {
includeInRuntimeRegistry: true,
// Freshly loaded entries preserve the documented disable-model-invocation
// contract, while legacy entries without exposure metadata still use the
// fallback in isSkillVisibleInAvailableSkillsPrompt().
includeInAvailableSkillsPrompt: invocation.disableModelInvocation !== true,
userInvocable: invocation.userInvocable !== false,
},
};
});
return skillEntries;
}
@@ -760,7 +762,9 @@ function resolveWorkspaceSkillPromptState(
// Budget checks and final render both use this same representation so the
// tier decision is based on the exact strings that end up in the prompt.
// resolvedSkills keeps canonical paths for snapshot / runtime consumers.
const promptSkills = compactSkillPaths(resolvedSkills);
const promptSkills = compactSkillPaths(resolvedSkills)
.slice()
.sort((a, b) => a.name.localeCompare(b.name));
const { skillsForPrompt, truncated, compact } = applySkillsPromptLimits({
skills: promptSkills,
config: opts?.config,