From a4b94f77b9182234b96dda21bdc5307df1f3ee4b Mon Sep 17 00:00:00 2001 From: Bartok Moltbot Date: Fri, 10 Apr 2026 04:37:39 -0400 Subject: [PATCH] fix(skills): sort available_skills alphabetically for prompt cache stability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/agents/skills/compact-format.test.ts | 14 +++++++ src/agents/skills/workspace.ts | 52 +++++++++++++----------- 2 files changed, 42 insertions(+), 24 deletions(-) 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,