feat(skills): preserve all skills in prompt via compact fallback before dropping (#47553)

* feat(skills): add compact format fallback for skill catalog truncation

When the full-format skill catalog exceeds the character budget,
applySkillsPromptLimits now tries a compact format (name + location
only, no description) before binary-searching for the largest fitting
prefix. This preserves full model awareness of registered skills in
the common overflow case.

Three-tier strategy:
1. Full format fits → use as-is
2. Compact format fits → switch to compact, keep all skills
3. Compact still too large → binary search largest compact prefix

Other changes:
- escapeXml() utility for safe XML attribute values
- formatSkillsCompact() emits same XML structure minus <description>
- Compact char-budget check reserves 150 chars for the warning line
  the caller prepends, preventing prompt overflow at the boundary
- 13 tests covering all tiers, edge cases, and budget reservation
- docs/.generated/config-baseline.json: fix pre-existing oxfmt issue

* docs: document compact skill prompt fallback

---------

Co-authored-by: Frank Yang <frank.ekn@gmail.com>
This commit is contained in:
Hung-Che Lo
2026-03-16 22:12:15 +08:00
committed by GitHub
parent 1f1a93a1dc
commit f8bcfb9d73
3 changed files with 310 additions and 25 deletions

View File

@@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai
- Docs/Zalo: clarify the Marketplace-bot support matrix and config guidance so the Zalo channel docs match current Bot Creator behavior more closely. (#47552) Thanks @No898.
- secrets: harden read-only SecretRef command paths and diagnostics. (#47794) Thanks @joshavant.
- Browser/existing-session: support `browser.profiles.<name>.userDataDir` so Chrome DevTools MCP can attach to Brave, Edge, and other Chromium-based browsers through their own user data directories. (#48170) thanks @velvet-shark.
- Skills/prompt budget: preserve all registered skills via a compact catalog fallback before dropping entries when the full prompt format exceeds `maxSkillsPromptChars`. (#47553) Thanks @snese.
### Breaking

View File

@@ -0,0 +1,230 @@
import os from "node:os";
import { formatSkillsForPrompt, type Skill } from "@mariozechner/pi-coding-agent";
import { describe, expect, it } from "vitest";
import type { SkillEntry } from "./types.js";
import {
formatSkillsCompact,
buildWorkspaceSkillsPrompt,
buildWorkspaceSkillSnapshot,
} from "./workspace.js";
function makeSkill(name: string, desc = "A skill", filePath = `/skills/${name}/SKILL.md`): Skill {
return {
name,
description: desc,
filePath,
baseDir: `/skills/${name}`,
source: "workspace",
disableModelInvocation: false,
};
}
function makeEntry(skill: Skill): SkillEntry {
return { skill, frontmatter: {} };
}
function buildPrompt(
skills: Skill[],
limits: { maxChars?: number; maxCount?: number } = {},
): string {
return buildWorkspaceSkillsPrompt("/fake", {
entries: skills.map(makeEntry),
config: {
skills: {
limits: {
...(limits.maxChars !== undefined && { maxSkillsPromptChars: limits.maxChars }),
...(limits.maxCount !== undefined && { maxSkillsInPrompt: limits.maxCount }),
},
},
} as any,
});
}
describe("formatSkillsCompact", () => {
it("returns empty string for no skills", () => {
expect(formatSkillsCompact([])).toBe("");
});
it("omits description, keeps name and location", () => {
const out = formatSkillsCompact([makeSkill("weather", "Get weather data")]);
expect(out).toContain("<name>weather</name>");
expect(out).toContain("<location>/skills/weather/SKILL.md</location>");
expect(out).not.toContain("Get weather data");
expect(out).not.toContain("<description>");
});
it("filters out disableModelInvocation skills", () => {
const hidden: Skill = { ...makeSkill("hidden"), disableModelInvocation: true };
const out = formatSkillsCompact([makeSkill("visible"), hidden]);
expect(out).toContain("visible");
expect(out).not.toContain("hidden");
});
it("escapes XML special characters", () => {
const out = formatSkillsCompact([makeSkill("a<b&c")]);
expect(out).toContain("a&lt;b&amp;c");
});
it("is significantly smaller than full format", () => {
const skills = Array.from({ length: 50 }, (_, i) =>
makeSkill(`skill-${i}`, "A moderately long description that takes up space in the prompt"),
);
const compact = formatSkillsCompact(skills);
expect(compact.length).toBeLessThan(6000);
});
});
describe("applySkillsPromptLimits (via buildWorkspaceSkillsPrompt)", () => {
it("tier 1: uses full format when under budget", () => {
const skills = [makeSkill("weather", "Get weather data")];
const prompt = buildPrompt(skills, { maxChars: 50_000 });
expect(prompt).toContain("<description>");
expect(prompt).toContain("Get weather data");
expect(prompt).not.toContain("⚠️");
});
it("tier 2: compact when full exceeds budget but compact fits", () => {
const skills = Array.from({ length: 20 }, (_, i) => makeSkill(`skill-${i}`, "A".repeat(200)));
const fullLen = formatSkillsForPrompt(skills).length;
const compactLen = formatSkillsCompact(skills).length;
const budget = Math.floor((fullLen + compactLen) / 2);
// Verify preconditions: full exceeds budget, compact fits within overhead-adjusted budget
expect(fullLen).toBeGreaterThan(budget);
expect(compactLen + 150).toBeLessThan(budget);
const prompt = buildPrompt(skills, { maxChars: budget });
expect(prompt).not.toContain("<description>");
// All skills preserved — distinct message, no "included X of Y"
expect(prompt).toContain("compact format (descriptions omitted)");
expect(prompt).not.toContain("included");
expect(prompt).toContain("skill-0");
expect(prompt).toContain("skill-19");
});
it("tier 3: compact + binary search when compact also exceeds budget", () => {
const skills = Array.from({ length: 100 }, (_, i) => makeSkill(`skill-${i}`, "description"));
const prompt = buildPrompt(skills, { maxChars: 2000 });
expect(prompt).toContain("compact format, descriptions omitted");
expect(prompt).not.toContain("<description>");
expect(prompt).toContain("skill-0");
const match = prompt.match(/included (\d+) of (\d+)/);
expect(match).toBeTruthy();
expect(Number(match![1])).toBeLessThan(Number(match![2]));
expect(Number(match![1])).toBeGreaterThan(0);
});
it("compact preserves all skills where full format would drop some", () => {
const skills = Array.from({ length: 50 }, (_, i) => makeSkill(`skill-${i}`, "A".repeat(200)));
const compactLen = formatSkillsCompact(skills).length;
const budget = compactLen + 250;
// Verify precondition: full format must not fit so tier 2 is actually exercised
expect(formatSkillsForPrompt(skills).length).toBeGreaterThan(budget);
const prompt = buildPrompt(skills, { maxChars: budget });
// All 50 fit in compact — no truncation, just compact notice
expect(prompt).toContain("compact format");
expect(prompt).not.toContain("included");
expect(prompt).toContain("skill-0");
expect(prompt).toContain("skill-49");
});
it("count truncation + compact: shows included X of Y with compact note", () => {
// 30 skills but maxCount=10, and full format of 10 exceeds budget
const skills = Array.from({ length: 30 }, (_, i) => makeSkill(`skill-${i}`, "A".repeat(200)));
const tenSkills = skills.slice(0, 10);
const fullLen = formatSkillsForPrompt(tenSkills).length;
const compactLen = formatSkillsCompact(tenSkills).length;
const budget = compactLen + 200;
// Verify precondition: full format of 10 skills exceeds budget
expect(fullLen).toBeGreaterThan(budget);
const prompt = buildPrompt(skills, { maxChars: budget, maxCount: 10 });
// Count-truncated (30→10) AND compact (full format of 10 exceeds budget)
expect(prompt).toContain("included 10 of 30");
expect(prompt).toContain("compact format, descriptions omitted");
expect(prompt).not.toContain("<description>");
});
it("extreme budget: even a single compact skill overflows", () => {
const skills = [makeSkill("only-one", "desc")];
// Budget so small that even one compact skill can't fit
const prompt = buildPrompt(skills, { maxChars: 10 });
expect(prompt).not.toContain("only-one");
const match = prompt.match(/included (\d+) of (\d+)/);
expect(match).toBeTruthy();
expect(Number(match![1])).toBe(0);
});
it("count truncation only: shows included X of Y without compact note", () => {
const skills = Array.from({ length: 20 }, (_, i) => makeSkill(`skill-${i}`, "short"));
const prompt = buildPrompt(skills, { maxChars: 50_000, maxCount: 5 });
expect(prompt).toContain("included 5 of 20");
expect(prompt).not.toContain("compact");
expect(prompt).toContain("<description>");
});
it("compact budget reserves space for the warning line", () => {
// Build skills whose compact output exactly equals the char budget.
// Without overhead reservation the compact block would fit, but the
// warning line prepended by the caller would push the total over budget.
const skills = Array.from({ length: 50 }, (_, i) => makeSkill(`s-${i}`, "A".repeat(200)));
const compactLen = formatSkillsCompact(skills).length;
// Set budget = compactLen + 50 — less than the 150-char overhead reserve.
// The function should NOT choose compact-only because the warning wouldn't fit.
const prompt = buildPrompt(skills, { maxChars: compactLen + 50 });
// Should fall through to compact + binary search (some skills dropped)
expect(prompt).toContain("included");
expect(prompt).not.toContain("<description>");
});
it("budget check uses compacted home-dir paths, not canonical paths", () => {
// Skills with home-dir prefix get compacted (e.g. /home/user/... → ~/...).
// Budget check must use the compacted length, not the longer canonical path.
// If it used canonical paths, it would overestimate and potentially drop
// skills that actually fit after compaction.
const home = os.homedir();
const skills = Array.from({ length: 30 }, (_, i) =>
makeSkill(
`skill-${i}`,
"A".repeat(200),
`${home}/.openclaw/workspace/skills/skill-${i}/SKILL.md`,
),
);
// Compute compacted lengths (what the prompt will actually contain)
const compactedSkills = skills.map((s) => ({
...s,
filePath: s.filePath.replace(home, "~"),
}));
const compactedCompactLen = formatSkillsCompact(compactedSkills).length;
const canonicalCompactLen = formatSkillsCompact(skills).length;
// Sanity: canonical paths are longer than compacted paths
expect(canonicalCompactLen).toBeGreaterThan(compactedCompactLen);
// Set budget between compacted and canonical lengths — only fits if
// budget check uses compacted paths (correct) not canonical (wrong).
const budget = Math.floor((compactedCompactLen + canonicalCompactLen) / 2) + 150;
const prompt = buildPrompt(skills, { maxChars: budget });
// All 30 skills should be preserved in compact form (tier 2, no dropping)
expect(prompt).toContain("skill-0");
expect(prompt).toContain("skill-29");
expect(prompt).not.toContain("included");
expect(prompt).toContain("compact format");
// Verify paths in output are compacted
expect(prompt).toContain("~/");
expect(prompt).not.toContain(home);
});
it("resolvedSkills in snapshot keeps canonical paths, not compacted", () => {
const home = os.homedir();
const skills = Array.from({ length: 5 }, (_, i) =>
makeSkill(`skill-${i}`, "A skill", `${home}/.openclaw/workspace/skills/skill-${i}/SKILL.md`),
);
const snapshot = buildWorkspaceSkillSnapshot("/fake", {
entries: skills.map(makeEntry),
});
// Prompt should use compacted paths
expect(snapshot.prompt).toContain("~/");
// resolvedSkills should preserve canonical (absolute) paths
expect(snapshot.resolvedSkills).toBeDefined();
for (const skill of snapshot.resolvedSkills!) {
expect(skill.filePath).toContain(home);
expect(skill.filePath).not.toMatch(/^~\//);
}
});
});

View File

@@ -526,10 +526,47 @@ function loadSkillEntries(
return skillEntries;
}
function escapeXml(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
/**
* Compact skill catalog: name + location only (no description).
* Used as a fallback when the full format exceeds the char budget,
* preserving awareness of all skills before resorting to dropping.
*/
export function formatSkillsCompact(skills: Skill[]): string {
const visible = skills.filter((s) => !s.disableModelInvocation);
if (visible.length === 0) return "";
const lines = [
"\n\nThe following skills provide specialized instructions for specific tasks.",
"Use the read tool to load a skill's file when the task matches its name.",
"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.",
"",
"<available_skills>",
];
for (const skill of visible) {
lines.push(" <skill>");
lines.push(` <name>${escapeXml(skill.name)}</name>`);
lines.push(` <location>${escapeXml(skill.filePath)}</location>`);
lines.push(" </skill>");
}
lines.push("</available_skills>");
return lines.join("\n");
}
// Budget reserved for the compact-mode warning line prepended by the caller.
const COMPACT_WARNING_OVERHEAD = 150;
function applySkillsPromptLimits(params: { skills: Skill[]; config?: OpenClawConfig }): {
skillsForPrompt: Skill[];
truncated: boolean;
truncatedReason: "count" | "chars" | null;
compact: boolean;
} {
const limits = resolveSkillsLimits(params.config);
const total = params.skills.length;
@@ -537,31 +574,41 @@ function applySkillsPromptLimits(params: { skills: Skill[]; config?: OpenClawCon
let skillsForPrompt = byCount;
let truncated = total > byCount.length;
let truncatedReason: "count" | "chars" | null = truncated ? "count" : null;
let compact = false;
const fits = (skills: Skill[]): boolean => {
const block = formatSkillsForPrompt(skills);
return block.length <= limits.maxSkillsPromptChars;
};
const fitsFull = (skills: Skill[]): boolean =>
formatSkillsForPrompt(skills).length <= limits.maxSkillsPromptChars;
if (!fits(skillsForPrompt)) {
// Binary search the largest prefix that fits in the char budget.
let lo = 0;
let hi = skillsForPrompt.length;
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2);
if (fits(skillsForPrompt.slice(0, mid))) {
lo = mid;
} else {
hi = mid - 1;
// Reserve space for the warning line the caller prepends in compact mode.
const compactBudget = limits.maxSkillsPromptChars - COMPACT_WARNING_OVERHEAD;
const fitsCompact = (skills: Skill[]): boolean =>
formatSkillsCompact(skills).length <= compactBudget;
if (!fitsFull(skillsForPrompt)) {
// Full format exceeds budget. Try compact (name + location, no description)
// to preserve awareness of all skills before dropping any.
if (fitsCompact(skillsForPrompt)) {
compact = true;
// No skills dropped — only format downgraded. Preserve existing truncated state.
} else {
// Compact still too large — binary search the largest prefix that fits.
compact = true;
let lo = 0;
let hi = skillsForPrompt.length;
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2);
if (fitsCompact(skillsForPrompt.slice(0, mid))) {
lo = mid;
} else {
hi = mid - 1;
}
}
skillsForPrompt = skillsForPrompt.slice(0, lo);
truncated = true;
}
skillsForPrompt = skillsForPrompt.slice(0, lo);
truncated = true;
truncatedReason = "chars";
}
return { skillsForPrompt, truncated, truncatedReason };
return { skillsForPrompt, truncated, compact };
}
export function buildWorkspaceSkillSnapshot(
@@ -620,17 +667,24 @@ function resolveWorkspaceSkillPromptState(
);
const remoteNote = opts?.eligibility?.remote?.note?.trim();
const resolvedSkills = promptEntries.map((entry) => entry.skill);
const { skillsForPrompt, truncated } = applySkillsPromptLimits({
skills: resolvedSkills,
// Derive prompt-facing skills with compacted paths (e.g. ~/...) once.
// 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 { skillsForPrompt, truncated, compact } = applySkillsPromptLimits({
skills: promptSkills,
config: opts?.config,
});
const truncationNote = truncated
? `⚠️ Skills truncated: included ${skillsForPrompt.length} of ${resolvedSkills.length}. Run \`openclaw skills check\` to audit.`
: "";
? `⚠️ Skills truncated: included ${skillsForPrompt.length} of ${resolvedSkills.length}${compact ? " (compact format, descriptions omitted)" : ""}. Run \`openclaw skills check\` to audit.`
: compact
? `⚠️ Skills catalog using compact format (descriptions omitted). Run \`openclaw skills check\` to audit.`
: "";
const prompt = [
remoteNote,
truncationNote,
formatSkillsForPrompt(compactSkillPaths(skillsForPrompt)),
compact ? formatSkillsCompact(skillsForPrompt) : formatSkillsForPrompt(skillsForPrompt),
]
.filter(Boolean)
.join("\n");