mirror of https://github.com/openclaw/openclaw.git
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:
parent
1f1a93a1dc
commit
f8bcfb9d73
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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<b&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(/^~\//);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -526,10 +526,47 @@ function loadSkillEntries(
|
|||
return skillEntries;
|
||||
}
|
||||
|
||||
function escapeXml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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");
|
||||
|
|
|
|||
Loading…
Reference in New Issue