diff --git a/src/agents/skills/compact-format.test.ts b/src/agents/skills/compact-format.test.ts index ff1e1bd7d72..3fbfa486b8f 100644 --- a/src/agents/skills/compact-format.test.ts +++ b/src/agents/skills/compact-format.test.ts @@ -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(/(\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) => diff --git a/src/agents/skills/workspace.ts b/src/agents/skills/workspace.ts index 68d15f088fa..f82175ed88c 100644 --- a/src/agents/skills/workspace.ts +++ b/src/agents/skills/workspace.ts @@ -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,