From f8bcfb9d73f0c65645dd7f2e511172c2e3aa5760 Mon Sep 17 00:00:00 2001 From: Hung-Che Lo Date: Mon, 16 Mar 2026 22:12:15 +0800 Subject: [PATCH] feat(skills): preserve all skills in prompt via compact fallback before dropping (#47553) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 - 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 --- CHANGELOG.md | 1 + src/agents/skills/compact-format.test.ts | 230 +++++++++++++++++++++++ src/agents/skills/workspace.ts | 104 +++++++--- 3 files changed, 310 insertions(+), 25 deletions(-) create mode 100644 src/agents/skills/compact-format.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 090f6046334..30ca4b0a21c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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..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 diff --git a/src/agents/skills/compact-format.test.ts b/src/agents/skills/compact-format.test.ts new file mode 100644 index 00000000000..20f3b8a256e --- /dev/null +++ b/src/agents/skills/compact-format.test.ts @@ -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("weather"); + expect(out).toContain("/skills/weather/SKILL.md"); + expect(out).not.toContain("Get weather data"); + expect(out).not.toContain(""); + }); + + 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 { + 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(""); + 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(""); + // 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(""); + 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(""); + }); + + 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(""); + }); + + 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(""); + }); + + 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(/^~\//); + } + }); +}); diff --git a/src/agents/skills/workspace.ts b/src/agents/skills/workspace.ts index 84c8ea78df3..80624a30139 100644 --- a/src/agents/skills/workspace.ts +++ b/src/agents/skills/workspace.ts @@ -526,10 +526,47 @@ function loadSkillEntries( return skillEntries; } +function escapeXml(str: string): string { + return str + .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.", + "", + "", + ]; + for (const skill of visible) { + lines.push(" "); + lines.push(` ${escapeXml(skill.name)}`); + lines.push(` ${escapeXml(skill.filePath)}`); + lines.push(" "); + } + lines.push(""); + 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");