Skip to content

Commit f8bcfb9

Browse files
snesefrankekn
andauthored
feat(skills): preserve all skills in prompt via compact fallback before dropping (openclaw#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 <[email protected]>
1 parent 1f1a93a commit f8bcfb9

File tree

3 files changed

+312
-27
lines changed

3 files changed

+312
-27
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai
2929
- 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.
3030
- secrets: harden read-only SecretRef command paths and diagnostics. (#47794) Thanks @joshavant.
3131
- 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.
32+
- 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.
3233

3334
### Breaking
3435

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
import os from "node:os";
2+
import { formatSkillsForPrompt, type Skill } from "@mariozechner/pi-coding-agent";
3+
import { describe, expect, it } from "vitest";
4+
import type { SkillEntry } from "./types.js";
5+
import {
6+
formatSkillsCompact,
7+
buildWorkspaceSkillsPrompt,
8+
buildWorkspaceSkillSnapshot,
9+
} from "./workspace.js";
10+
11+
function makeSkill(name: string, desc = "A skill", filePath = `/skills/${name}/SKILL.md`): Skill {
12+
return {
13+
name,
14+
description: desc,
15+
filePath,
16+
baseDir: `/skills/${name}`,
17+
source: "workspace",
18+
disableModelInvocation: false,
19+
};
20+
}
21+
22+
function makeEntry(skill: Skill): SkillEntry {
23+
return { skill, frontmatter: {} };
24+
}
25+
26+
function buildPrompt(
27+
skills: Skill[],
28+
limits: { maxChars?: number; maxCount?: number } = {},
29+
): string {
30+
return buildWorkspaceSkillsPrompt("/fake", {
31+
entries: skills.map(makeEntry),
32+
config: {
33+
skills: {
34+
limits: {
35+
...(limits.maxChars !== undefined && { maxSkillsPromptChars: limits.maxChars }),
36+
...(limits.maxCount !== undefined && { maxSkillsInPrompt: limits.maxCount }),
37+
},
38+
},
39+
} as any,
40+
});
41+
}
42+
43+
describe("formatSkillsCompact", () => {
44+
it("returns empty string for no skills", () => {
45+
expect(formatSkillsCompact([])).toBe("");
46+
});
47+
48+
it("omits description, keeps name and location", () => {
49+
const out = formatSkillsCompact([makeSkill("weather", "Get weather data")]);
50+
expect(out).toContain("<name>weather</name>");
51+
expect(out).toContain("<location>/skills/weather/SKILL.md</location>");
52+
expect(out).not.toContain("Get weather data");
53+
expect(out).not.toContain("<description>");
54+
});
55+
56+
it("filters out disableModelInvocation skills", () => {
57+
const hidden: Skill = { ...makeSkill("hidden"), disableModelInvocation: true };
58+
const out = formatSkillsCompact([makeSkill("visible"), hidden]);
59+
expect(out).toContain("visible");
60+
expect(out).not.toContain("hidden");
61+
});
62+
63+
it("escapes XML special characters", () => {
64+
const out = formatSkillsCompact([makeSkill("a<b&c")]);
65+
expect(out).toContain("a&lt;b&amp;c");
66+
});
67+
68+
it("is significantly smaller than full format", () => {
69+
const skills = Array.from({ length: 50 }, (_, i) =>
70+
makeSkill(`skill-${i}`, "A moderately long description that takes up space in the prompt"),
71+
);
72+
const compact = formatSkillsCompact(skills);
73+
expect(compact.length).toBeLessThan(6000);
74+
});
75+
});
76+
77+
describe("applySkillsPromptLimits (via buildWorkspaceSkillsPrompt)", () => {
78+
it("tier 1: uses full format when under budget", () => {
79+
const skills = [makeSkill("weather", "Get weather data")];
80+
const prompt = buildPrompt(skills, { maxChars: 50_000 });
81+
expect(prompt).toContain("<description>");
82+
expect(prompt).toContain("Get weather data");
83+
expect(prompt).not.toContain("⚠️");
84+
});
85+
86+
it("tier 2: compact when full exceeds budget but compact fits", () => {
87+
const skills = Array.from({ length: 20 }, (_, i) => makeSkill(`skill-${i}`, "A".repeat(200)));
88+
const fullLen = formatSkillsForPrompt(skills).length;
89+
const compactLen = formatSkillsCompact(skills).length;
90+
const budget = Math.floor((fullLen + compactLen) / 2);
91+
// Verify preconditions: full exceeds budget, compact fits within overhead-adjusted budget
92+
expect(fullLen).toBeGreaterThan(budget);
93+
expect(compactLen + 150).toBeLessThan(budget);
94+
const prompt = buildPrompt(skills, { maxChars: budget });
95+
expect(prompt).not.toContain("<description>");
96+
// All skills preserved — distinct message, no "included X of Y"
97+
expect(prompt).toContain("compact format (descriptions omitted)");
98+
expect(prompt).not.toContain("included");
99+
expect(prompt).toContain("skill-0");
100+
expect(prompt).toContain("skill-19");
101+
});
102+
103+
it("tier 3: compact + binary search when compact also exceeds budget", () => {
104+
const skills = Array.from({ length: 100 }, (_, i) => makeSkill(`skill-${i}`, "description"));
105+
const prompt = buildPrompt(skills, { maxChars: 2000 });
106+
expect(prompt).toContain("compact format, descriptions omitted");
107+
expect(prompt).not.toContain("<description>");
108+
expect(prompt).toContain("skill-0");
109+
const match = prompt.match(/included (\d+) of (\d+)/);
110+
expect(match).toBeTruthy();
111+
expect(Number(match![1])).toBeLessThan(Number(match![2]));
112+
expect(Number(match![1])).toBeGreaterThan(0);
113+
});
114+
115+
it("compact preserves all skills where full format would drop some", () => {
116+
const skills = Array.from({ length: 50 }, (_, i) => makeSkill(`skill-${i}`, "A".repeat(200)));
117+
const compactLen = formatSkillsCompact(skills).length;
118+
const budget = compactLen + 250;
119+
// Verify precondition: full format must not fit so tier 2 is actually exercised
120+
expect(formatSkillsForPrompt(skills).length).toBeGreaterThan(budget);
121+
const prompt = buildPrompt(skills, { maxChars: budget });
122+
// All 50 fit in compact — no truncation, just compact notice
123+
expect(prompt).toContain("compact format");
124+
expect(prompt).not.toContain("included");
125+
expect(prompt).toContain("skill-0");
126+
expect(prompt).toContain("skill-49");
127+
});
128+
129+
it("count truncation + compact: shows included X of Y with compact note", () => {
130+
// 30 skills but maxCount=10, and full format of 10 exceeds budget
131+
const skills = Array.from({ length: 30 }, (_, i) => makeSkill(`skill-${i}`, "A".repeat(200)));
132+
const tenSkills = skills.slice(0, 10);
133+
const fullLen = formatSkillsForPrompt(tenSkills).length;
134+
const compactLen = formatSkillsCompact(tenSkills).length;
135+
const budget = compactLen + 200;
136+
// Verify precondition: full format of 10 skills exceeds budget
137+
expect(fullLen).toBeGreaterThan(budget);
138+
const prompt = buildPrompt(skills, { maxChars: budget, maxCount: 10 });
139+
// Count-truncated (30→10) AND compact (full format of 10 exceeds budget)
140+
expect(prompt).toContain("included 10 of 30");
141+
expect(prompt).toContain("compact format, descriptions omitted");
142+
expect(prompt).not.toContain("<description>");
143+
});
144+
145+
it("extreme budget: even a single compact skill overflows", () => {
146+
const skills = [makeSkill("only-one", "desc")];
147+
// Budget so small that even one compact skill can't fit
148+
const prompt = buildPrompt(skills, { maxChars: 10 });
149+
expect(prompt).not.toContain("only-one");
150+
const match = prompt.match(/included (\d+) of (\d+)/);
151+
expect(match).toBeTruthy();
152+
expect(Number(match![1])).toBe(0);
153+
});
154+
155+
it("count truncation only: shows included X of Y without compact note", () => {
156+
const skills = Array.from({ length: 20 }, (_, i) => makeSkill(`skill-${i}`, "short"));
157+
const prompt = buildPrompt(skills, { maxChars: 50_000, maxCount: 5 });
158+
expect(prompt).toContain("included 5 of 20");
159+
expect(prompt).not.toContain("compact");
160+
expect(prompt).toContain("<description>");
161+
});
162+
163+
it("compact budget reserves space for the warning line", () => {
164+
// Build skills whose compact output exactly equals the char budget.
165+
// Without overhead reservation the compact block would fit, but the
166+
// warning line prepended by the caller would push the total over budget.
167+
const skills = Array.from({ length: 50 }, (_, i) => makeSkill(`s-${i}`, "A".repeat(200)));
168+
const compactLen = formatSkillsCompact(skills).length;
169+
// Set budget = compactLen + 50 — less than the 150-char overhead reserve.
170+
// The function should NOT choose compact-only because the warning wouldn't fit.
171+
const prompt = buildPrompt(skills, { maxChars: compactLen + 50 });
172+
// Should fall through to compact + binary search (some skills dropped)
173+
expect(prompt).toContain("included");
174+
expect(prompt).not.toContain("<description>");
175+
});
176+
177+
it("budget check uses compacted home-dir paths, not canonical paths", () => {
178+
// Skills with home-dir prefix get compacted (e.g. /home/user/... → ~/...).
179+
// Budget check must use the compacted length, not the longer canonical path.
180+
// If it used canonical paths, it would overestimate and potentially drop
181+
// skills that actually fit after compaction.
182+
const home = os.homedir();
183+
const skills = Array.from({ length: 30 }, (_, i) =>
184+
makeSkill(
185+
`skill-${i}`,
186+
"A".repeat(200),
187+
`${home}/.openclaw/workspace/skills/skill-${i}/SKILL.md`,
188+
),
189+
);
190+
// Compute compacted lengths (what the prompt will actually contain)
191+
const compactedSkills = skills.map((s) => ({
192+
...s,
193+
filePath: s.filePath.replace(home, "~"),
194+
}));
195+
const compactedCompactLen = formatSkillsCompact(compactedSkills).length;
196+
const canonicalCompactLen = formatSkillsCompact(skills).length;
197+
// Sanity: canonical paths are longer than compacted paths
198+
expect(canonicalCompactLen).toBeGreaterThan(compactedCompactLen);
199+
// Set budget between compacted and canonical lengths — only fits if
200+
// budget check uses compacted paths (correct) not canonical (wrong).
201+
const budget = Math.floor((compactedCompactLen + canonicalCompactLen) / 2) + 150;
202+
const prompt = buildPrompt(skills, { maxChars: budget });
203+
// All 30 skills should be preserved in compact form (tier 2, no dropping)
204+
expect(prompt).toContain("skill-0");
205+
expect(prompt).toContain("skill-29");
206+
expect(prompt).not.toContain("included");
207+
expect(prompt).toContain("compact format");
208+
// Verify paths in output are compacted
209+
expect(prompt).toContain("~/");
210+
expect(prompt).not.toContain(home);
211+
});
212+
213+
it("resolvedSkills in snapshot keeps canonical paths, not compacted", () => {
214+
const home = os.homedir();
215+
const skills = Array.from({ length: 5 }, (_, i) =>
216+
makeSkill(`skill-${i}`, "A skill", `${home}/.openclaw/workspace/skills/skill-${i}/SKILL.md`),
217+
);
218+
const snapshot = buildWorkspaceSkillSnapshot("/fake", {
219+
entries: skills.map(makeEntry),
220+
});
221+
// Prompt should use compacted paths
222+
expect(snapshot.prompt).toContain("~/");
223+
// resolvedSkills should preserve canonical (absolute) paths
224+
expect(snapshot.resolvedSkills).toBeDefined();
225+
for (const skill of snapshot.resolvedSkills!) {
226+
expect(skill.filePath).toContain(home);
227+
expect(skill.filePath).not.toMatch(/^~\//);
228+
}
229+
});
230+
});

src/agents/skills/workspace.ts

Lines changed: 81 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -526,42 +526,89 @@ function loadSkillEntries(
526526
return skillEntries;
527527
}
528528

529+
function escapeXml(str: string): string {
530+
return str
531+
.replace(/&/g, "&amp;")
532+
.replace(/</g, "&lt;")
533+
.replace(/>/g, "&gt;")
534+
.replace(/"/g, "&quot;")
535+
.replace(/'/g, "&apos;");
536+
}
537+
538+
/**
539+
* Compact skill catalog: name + location only (no description).
540+
* Used as a fallback when the full format exceeds the char budget,
541+
* preserving awareness of all skills before resorting to dropping.
542+
*/
543+
export function formatSkillsCompact(skills: Skill[]): string {
544+
const visible = skills.filter((s) => !s.disableModelInvocation);
545+
if (visible.length === 0) return "";
546+
const lines = [
547+
"\n\nThe following skills provide specialized instructions for specific tasks.",
548+
"Use the read tool to load a skill's file when the task matches its name.",
549+
"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.",
550+
"",
551+
"<available_skills>",
552+
];
553+
for (const skill of visible) {
554+
lines.push(" <skill>");
555+
lines.push(` <name>${escapeXml(skill.name)}</name>`);
556+
lines.push(` <location>${escapeXml(skill.filePath)}</location>`);
557+
lines.push(" </skill>");
558+
}
559+
lines.push("</available_skills>");
560+
return lines.join("\n");
561+
}
562+
563+
// Budget reserved for the compact-mode warning line prepended by the caller.
564+
const COMPACT_WARNING_OVERHEAD = 150;
565+
529566
function applySkillsPromptLimits(params: { skills: Skill[]; config?: OpenClawConfig }): {
530567
skillsForPrompt: Skill[];
531568
truncated: boolean;
532-
truncatedReason: "count" | "chars" | null;
569+
compact: boolean;
533570
} {
534571
const limits = resolveSkillsLimits(params.config);
535572
const total = params.skills.length;
536573
const byCount = params.skills.slice(0, Math.max(0, limits.maxSkillsInPrompt));
537574

538575
let skillsForPrompt = byCount;
539576
let truncated = total > byCount.length;
540-
let truncatedReason: "count" | "chars" | null = truncated ? "count" : null;
541-
542-
const fits = (skills: Skill[]): boolean => {
543-
const block = formatSkillsForPrompt(skills);
544-
return block.length <= limits.maxSkillsPromptChars;
545-
};
546-
547-
if (!fits(skillsForPrompt)) {
548-
// Binary search the largest prefix that fits in the char budget.
549-
let lo = 0;
550-
let hi = skillsForPrompt.length;
551-
while (lo < hi) {
552-
const mid = Math.ceil((lo + hi) / 2);
553-
if (fits(skillsForPrompt.slice(0, mid))) {
554-
lo = mid;
555-
} else {
556-
hi = mid - 1;
577+
let compact = false;
578+
579+
const fitsFull = (skills: Skill[]): boolean =>
580+
formatSkillsForPrompt(skills).length <= limits.maxSkillsPromptChars;
581+
582+
// Reserve space for the warning line the caller prepends in compact mode.
583+
const compactBudget = limits.maxSkillsPromptChars - COMPACT_WARNING_OVERHEAD;
584+
const fitsCompact = (skills: Skill[]): boolean =>
585+
formatSkillsCompact(skills).length <= compactBudget;
586+
587+
if (!fitsFull(skillsForPrompt)) {
588+
// Full format exceeds budget. Try compact (name + location, no description)
589+
// to preserve awareness of all skills before dropping any.
590+
if (fitsCompact(skillsForPrompt)) {
591+
compact = true;
592+
// No skills dropped — only format downgraded. Preserve existing truncated state.
593+
} else {
594+
// Compact still too large — binary search the largest prefix that fits.
595+
compact = true;
596+
let lo = 0;
597+
let hi = skillsForPrompt.length;
598+
while (lo < hi) {
599+
const mid = Math.ceil((lo + hi) / 2);
600+
if (fitsCompact(skillsForPrompt.slice(0, mid))) {
601+
lo = mid;
602+
} else {
603+
hi = mid - 1;
604+
}
557605
}
606+
skillsForPrompt = skillsForPrompt.slice(0, lo);
607+
truncated = true;
558608
}
559-
skillsForPrompt = skillsForPrompt.slice(0, lo);
560-
truncated = true;
561-
truncatedReason = "chars";
562609
}
563610

564-
return { skillsForPrompt, truncated, truncatedReason };
611+
return { skillsForPrompt, truncated, compact };
565612
}
566613

567614
export function buildWorkspaceSkillSnapshot(
@@ -620,17 +667,24 @@ function resolveWorkspaceSkillPromptState(
620667
);
621668
const remoteNote = opts?.eligibility?.remote?.note?.trim();
622669
const resolvedSkills = promptEntries.map((entry) => entry.skill);
623-
const { skillsForPrompt, truncated } = applySkillsPromptLimits({
624-
skills: resolvedSkills,
670+
// Derive prompt-facing skills with compacted paths (e.g. ~/...) once.
671+
// Budget checks and final render both use this same representation so the
672+
// tier decision is based on the exact strings that end up in the prompt.
673+
// resolvedSkills keeps canonical paths for snapshot / runtime consumers.
674+
const promptSkills = compactSkillPaths(resolvedSkills);
675+
const { skillsForPrompt, truncated, compact } = applySkillsPromptLimits({
676+
skills: promptSkills,
625677
config: opts?.config,
626678
});
627679
const truncationNote = truncated
628-
? `⚠️ Skills truncated: included ${skillsForPrompt.length} of ${resolvedSkills.length}. Run \`openclaw skills check\` to audit.`
629-
: "";
680+
? `⚠️ Skills truncated: included ${skillsForPrompt.length} of ${resolvedSkills.length}${compact ? " (compact format, descriptions omitted)" : ""}. Run \`openclaw skills check\` to audit.`
681+
: compact
682+
? `⚠️ Skills catalog using compact format (descriptions omitted). Run \`openclaw skills check\` to audit.`
683+
: "";
630684
const prompt = [
631685
remoteNote,
632686
truncationNote,
633-
formatSkillsForPrompt(compactSkillPaths(skillsForPrompt)),
687+
compact ? formatSkillsCompact(skillsForPrompt) : formatSkillsForPrompt(skillsForPrompt),
634688
]
635689
.filter(Boolean)
636690
.join("\n");

0 commit comments

Comments
 (0)