mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:40:44 +00:00
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:
committed by
Peter Steinberger
parent
77d9fd693f
commit
a4b94f77b9
@@ -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) =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user