Skip to content

Commit b474059

Browse files
abel-zer0steipete
andauthored
fix(skills): preserve compact prompt descriptions (#88426)
Co-authored-by: Peter Steinberger <[email protected]>
1 parent 266ca5b commit b474059

4 files changed

Lines changed: 158 additions & 79 deletions

File tree

docs/tools/skills.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -579,9 +579,12 @@ prompt. The cost is deterministic and scales linearly per skill:
579579
- At ~4 chars/token, 97 chars ≈ 24 tokens per skill before field lengths.
580580

581581
If the rendered block would exceed the configured prompt budget
582-
(`skills.limits.maxSkillsPromptChars`), OpenClaw first drops descriptions
583-
(compact format: name + location only), then truncates the skill list and adds
584-
a note pointing at `openclaw skills check`.
582+
(`skills.limits.maxSkillsPromptChars`), OpenClaw first preserves as many skill
583+
identities (name, location, and version) as the description-free compact format
584+
can fit. It then uses any remaining budget for shortened descriptions. If no
585+
description budget remains, descriptions are omitted. The prompt includes a
586+
note pointing at `openclaw skills check` whenever compact formatting or list
587+
truncation is required.
585588

586589
Keep descriptions short and descriptive to minimize prompt overhead.
587590

src/skills/loading/compact-format.test.ts

Lines changed: 69 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,11 @@ function requireIncludedCounts(prompt: string): [included: number, total: number
6464
return [Number(match[1]), Number(match[2])];
6565
}
6666

67+
const COMPACT_OMITTED_NOTICE =
68+
"⚠️ Skills catalog using compact format (descriptions omitted). Run `openclaw skills check` to audit.";
69+
const COMPACT_SHORTENED_NOTICE =
70+
"⚠️ Skills catalog using compact format (descriptions shortened). Run `openclaw skills check` to audit.";
71+
6772
describe("formatSkillsCompact", () => {
6873
it("keeps the full-format XML output aligned with the upstream formatter for visible skills", () => {
6974
const skills = [
@@ -84,17 +89,33 @@ describe("formatSkillsCompact", () => {
8489
expect(formatSkillsCompact([])).toBe("");
8590
});
8691

87-
it("omits description, keeps name and location", () => {
92+
it("keeps compact descriptions with name, location, and version", () => {
8893
const out = formatSkillsCompact([
8994
{ ...makeSkill("weather", "Get weather data"), promptVersion: "sha256:abc123" },
9095
]);
9196
expect(out).toContain("<name>weather</name>");
97+
expect(out).toContain("<description>Get weather data</description>");
9298
expect(out).toContain("<location>/skills/weather/SKILL.md</location>");
9399
expect(out).toContain("<version>sha256:abc123</version>");
94-
expect(out).not.toContain("Get weather data");
100+
});
101+
102+
it("omits descriptions when their compact budget is zero", () => {
103+
const out = formatSkillsCompact([makeSkill("weather", "Get weather data")], {
104+
descriptionMaxChars: 0,
105+
});
106+
expect(out).toContain("<name>weather</name>");
95107
expect(out).not.toContain("<description>");
96108
});
97109

110+
it("truncates descriptions without splitting emoji surrogate pairs", () => {
111+
const out = formatSkillsCompact([makeSkill("emoji", `${"A".repeat(16)}😀 trailing`)], {
112+
descriptionMaxChars: 20,
113+
});
114+
115+
expect(out).toContain(`<description>${"A".repeat(16)}...</description>`);
116+
expect(out).not.toMatch(/[\uD800-\uDFFF]/u);
117+
});
118+
98119
it("renders all passed skills without reapplying visibility policy", () => {
99120
const hidden: Skill = { ...makeSkill("hidden"), disableModelInvocation: true };
100121
const out = formatSkillsCompact([makeSkill("visible"), hidden]);
@@ -108,11 +129,9 @@ describe("formatSkillsCompact", () => {
108129
});
109130

110131
it("is significantly smaller than full format", () => {
111-
const skills = Array.from({ length: 50 }, (_, i) =>
112-
makeSkill(`skill-${i}`, "A moderately long description that takes up space in the prompt"),
113-
);
132+
const skills = Array.from({ length: 50 }, (_, i) => makeSkill(`skill-${i}`, "A".repeat(800)));
114133
const compact = formatSkillsCompact(skills);
115-
expect(compact.length).toBeLessThan(6000);
134+
expect(compact.length).toBeLessThan(formatSkillsForPrompt(skills).length / 2);
116135
});
117136
});
118137

@@ -157,17 +176,15 @@ describe("applySkillsPromptLimits (via buildWorkspaceSkillsPrompt)", () => {
157176
});
158177

159178
it("tier 2: compact when full exceeds budget but compact fits", () => {
160-
const skills = Array.from({ length: 20 }, (_, i) => makeSkill(`skill-${i}`, "A".repeat(200)));
179+
const skills = Array.from({ length: 20 }, (_, i) => makeSkill(`skill-${i}`, "A".repeat(800)));
161180
const fullLen = formatSkillsForPrompt(skills).length;
162181
const compactLen = formatSkillsCompact(skills).length;
163-
const budget = Math.floor((fullLen + compactLen) / 2);
164-
// Verify preconditions: full exceeds budget, compact fits within overhead-adjusted budget
182+
const budget = `${COMPACT_SHORTENED_NOTICE}\n${formatSkillsCompact(skills)}`.length;
165183
expect(fullLen).toBeGreaterThan(budget);
166-
expect(compactLen + 150).toBeLessThan(budget);
184+
expect(compactLen).toBeLessThan(budget);
167185
const prompt = buildPrompt(skills, { maxChars: budget });
168-
expect(prompt).not.toContain("<description>");
169-
// All skills preserved — distinct message, no "included X of Y"
170-
expect(prompt).toContain("compact format (descriptions omitted)");
186+
expect(prompt).toContain("<description>");
187+
expect(prompt).toContain("compact format (descriptions shortened)");
171188
expect(prompt).not.toContain("included");
172189
expect(prompt).toContain("skill-0");
173190
expect(prompt).toContain("skill-19");
@@ -176,43 +193,59 @@ describe("applySkillsPromptLimits (via buildWorkspaceSkillsPrompt)", () => {
176193
it("tier 3: compact + binary search when compact also exceeds budget", () => {
177194
const skills = Array.from({ length: 100 }, (_, i) => makeSkill(`skill-${i}`, "description"));
178195
const prompt = buildPrompt(skills, { maxChars: 2000 });
179-
expect(prompt).toContain("compact format, descriptions omitted");
180-
expect(prompt).not.toContain("<description>");
196+
expect(prompt).toContain("compact format");
181197
expect(prompt).toContain("skill-0");
182198
const [included, total] = requireIncludedCounts(prompt);
183199
expect(included).toBeLessThan(total);
184200
expect(total).toBe(skills.length);
185201
expect(prompt.match(/<skill>/g)?.length ?? 0).toBe(included);
186202
});
187203

188-
it("compact preserves all skills where full format would drop some", () => {
189-
const skills = Array.from({ length: 50 }, (_, i) => makeSkill(`skill-${i}`, "A".repeat(200)));
190-
const compactLen = formatSkillsCompact(skills).length;
191-
const budget = compactLen + 250;
192-
// Verify precondition: full format must not fit so tier 2 is actually exercised
204+
it("preserves every identity before allocating description budget", () => {
205+
const skills = Array.from({ length: 50 }, (_, i) => makeSkill(`skill-${i}`, "A".repeat(800)));
206+
const identityCatalog = formatSkillsCompact(skills, { descriptionMaxChars: 0 });
207+
const budget = `${COMPACT_OMITTED_NOTICE}\n${identityCatalog}`.length;
193208
expect(formatSkillsForPrompt(skills).length).toBeGreaterThan(budget);
209+
194210
const prompt = buildPrompt(skills, { maxChars: budget });
195-
// All 50 fit in compact — no truncation, just compact notice
196-
expect(prompt).toContain("compact format");
211+
212+
expect(prompt.length).toBeLessThanOrEqual(budget);
213+
expect(prompt).toContain(COMPACT_OMITTED_NOTICE);
214+
expect(prompt).not.toContain("<description>");
197215
expect(prompt).not.toContain("included");
198216
expect(prompt).toContain("skill-0");
199217
expect(prompt).toContain("skill-49");
200218
});
201219

220+
it("uses leftover compact budget for descriptions without dropping identities", () => {
221+
const skills = Array.from({ length: 8 }, (_, i) => makeSkill(`skill-${i}`, "A".repeat(800)));
222+
const identityCatalog = formatSkillsCompact(skills, { descriptionMaxChars: 0 });
223+
const budget = `${COMPACT_OMITTED_NOTICE}\n${identityCatalog}`.length + 500;
224+
225+
const prompt = buildPrompt(skills, { maxChars: budget });
226+
227+
expect(prompt.length).toBeLessThanOrEqual(budget);
228+
expect(prompt).toContain(COMPACT_SHORTENED_NOTICE);
229+
expect(prompt).toContain("<description>");
230+
expect(prompt).not.toContain("included");
231+
expect(prompt.match(/<skill>/g)).toHaveLength(skills.length);
232+
});
233+
202234
it("count truncation + compact: shows included X of Y with compact note", () => {
203235
// 30 skills but maxCount=10, and full format of 10 exceeds budget
204-
const skills = Array.from({ length: 30 }, (_, i) => makeSkill(`skill-${i}`, "A".repeat(200)));
236+
const skills = Array.from({ length: 30 }, (_, i) => makeSkill(`skill-${i}`, "A".repeat(800)));
205237
const tenSkills = skills.slice(0, 10);
206238
const fullLen = formatSkillsForPrompt(tenSkills).length;
207-
const compactLen = formatSkillsCompact(tenSkills).length;
208-
const budget = compactLen + 200;
239+
const truncatedNotice =
240+
"⚠️ Skills truncated: included 10 of 30 (compact format, descriptions shortened). Run `openclaw skills check` to audit.";
241+
const budget = `${truncatedNotice}\n${formatSkillsCompact(tenSkills)}`.length;
209242
// Verify precondition: full format of 10 skills exceeds budget
210243
expect(fullLen).toBeGreaterThan(budget);
211244
const prompt = buildPrompt(skills, { maxChars: budget, maxCount: 10 });
212245
// Count-truncated (30→10) AND compact (full format of 10 exceeds budget)
213246
expect(prompt).toContain("included 10 of 30");
214-
expect(prompt).toContain("compact format, descriptions omitted");
215-
expect(prompt).not.toContain("<description>");
247+
expect(prompt).toContain("compact format, descriptions shortened");
248+
expect(prompt).toContain("<description>");
216249
});
217250

218251
it("extreme budget: even a single compact skill overflows", () => {
@@ -254,20 +287,6 @@ describe("applySkillsPromptLimits (via buildWorkspaceSkillsPrompt)", () => {
254287
expect(prompt).toContain("<description>");
255288
});
256289

257-
it("compact budget reserves space for the warning line", () => {
258-
// Build skills whose compact output exactly equals the char budget.
259-
// Without overhead reservation the compact block would fit, but the
260-
// warning line prepended by the caller would push the total over budget.
261-
const skills = Array.from({ length: 50 }, (_, i) => makeSkill(`s-${i}`, "A".repeat(200)));
262-
const compactLen = formatSkillsCompact(skills).length;
263-
// Set budget = compactLen + 50 — less than the 150-char overhead reserve.
264-
// The function should NOT choose compact-only because the warning wouldn't fit.
265-
const prompt = buildPrompt(skills, { maxChars: compactLen + 50 });
266-
// Should fall through to compact + binary search (some skills dropped)
267-
expect(prompt).toContain("included");
268-
expect(prompt).not.toContain("<description>");
269-
});
270-
271290
it("budget check uses compacted home-dir paths, not canonical paths", () => {
272291
// Skills with home-dir prefix get compacted (e.g. /home/user/... → ~/...).
273292
// Budget check must use the compacted length, not the longer canonical path.
@@ -277,7 +296,7 @@ describe("applySkillsPromptLimits (via buildWorkspaceSkillsPrompt)", () => {
277296
const skills = Array.from({ length: 30 }, (_, i) =>
278297
makeSkill(
279298
`skill-${i}`,
280-
"A".repeat(200),
299+
"A".repeat(800),
281300
`${home}/.openclaw/workspace/skills/skill-${i}/SKILL.md`,
282301
),
283302
);
@@ -286,13 +305,18 @@ describe("applySkillsPromptLimits (via buildWorkspaceSkillsPrompt)", () => {
286305
...s,
287306
filePath: s.filePath.replace(home, "~"),
288307
}));
289-
const compactedCompactLen = formatSkillsCompact(compactedSkills).length;
290-
const canonicalCompactLen = formatSkillsCompact(skills).length;
308+
const compactedCompactLen = formatSkillsCompact(compactedSkills, {
309+
descriptionMaxChars: 0,
310+
}).length;
311+
const canonicalCompactLen = formatSkillsCompact(skills, { descriptionMaxChars: 0 }).length;
291312
// Sanity: canonical paths are longer than compacted paths
292313
expect(canonicalCompactLen).toBeGreaterThan(compactedCompactLen);
293314
// Set budget between compacted and canonical lengths — only fits if
294315
// budget check uses compacted paths (correct) not canonical (wrong).
295-
const budget = Math.floor((compactedCompactLen + canonicalCompactLen) / 2) + 150;
316+
const budget =
317+
Math.floor((compactedCompactLen + canonicalCompactLen) / 2) +
318+
COMPACT_OMITTED_NOTICE.length +
319+
1;
296320
const prompt = buildPrompt(skills, { maxChars: budget });
297321
// All 30 skills should be preserved in compact form (tier 2, no dropping)
298322
expect(prompt).toContain("skill-0");

0 commit comments

Comments
 (0)