Skip to content
4 changes: 4 additions & 0 deletions changes/unreleased/4456.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<!-- changes-log: packages/coding-agent/CHANGELOG.md -->
### Changed

- A large skill catalog (over 100 skills or 16 KiB of descriptions) is listed one line per skill with the shortest location that still resolves and a description of at most 160 characters that keeps its when-to-use sentence ("Use when ...", "MUST USE for ...", "Triggers: ..."); a catalog that still overflows the 48 KiB section cap tries 100-character lines before dropping descriptions. On a real 97-skill library the catalog shrinks from about 7,600 to about 4,400 tokens per request while the whole when-to-use sentence survives for 62 of 89 skills instead of 19 ([#4456](https://github.com/code-yeongyu/senpi/pull/4456))
2 changes: 1 addition & 1 deletion packages/coding-agent/docs/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ Set `PI_SKIP_VERSION_CHECK=1` to disable the senpi version update check. Use `--

Files receive budget nearest-first: the current directory, its ancestors, then the global file. A cut prefers a newline in the last quarter of the available content budget; otherwise it uses a UTF-8 character boundary. An open backtick or tilde fence is closed with the same marker before the truncation note; the closing marker's bytes count against the budget. Files with less than 1024 bytes of budget left are skipped when they would need truncation, with a warning distinguishing an undersized per-file limit from budget used by nearer files. Both limits accept positive numbers (rounded down); invalid values use the defaults.

The skills section has a fixed 48 KiB rendered UTF-8 byte cap, including its roots table and markup. Above 100 visible skills or 64 KiB of descriptions, descriptions become one line of at most 160 characters. If needed to meet the section cap, smaller catalogs also use compact descriptions, then all descriptions are dropped. If names and locations still exceed the cap, only the first skills that fit are listed, with the omitted count inside the section. Skills remain available for explicit invocation.
The skills section has a fixed 48 KiB rendered UTF-8 byte cap, including its roots table and markup. Above 100 visible skills or 16 KiB of descriptions, the catalog lists one line per skill, `- name [location]: description`, where the location is the shortest path that still resolves against the roots table and the description is cut to at most 160 characters. A cut keeps the description's when-to-use sentence ("Use when ...", "MUST USE for ...", "Triggers: ...") and fills the remaining room with the text around it. If needed to meet the section cap, smaller catalogs also use the one-line form, then all descriptions are dropped (`- name [location]`). If names and locations still exceed the cap, only the first skills that fit are listed, with the omitted count inside the section. Skills remain available for explicit invocation.

Context cuts, skipped files, and compacted or omitted skills share the "Context size limits" resource notice in interactive mode and appear once on stderr at startup in print, JSON, and RPC modes, with the affected path when present. Ordinary skill validation warnings keep their existing diagnostic behavior and are not printed to stderr by this size check.

Expand Down
90 changes: 79 additions & 11 deletions packages/coding-agent/src/core/skills-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,16 @@ import type { ResourceDiagnostic } from "./diagnostics.ts";
import type { Skill } from "./skills.ts";

export const COMPACT_SKILL_CATALOG_COUNT = 100;
export const COMPACT_SKILL_CATALOG_DESCRIPTION_BYTES = 64 * 1024;
export const COMPACT_SKILL_CATALOG_DESCRIPTION_BYTES = 16 * 1024;
export const MAX_SKILL_CATALOG_BYTES = 48 * 1024;
const ONE_LINE_DESCRIPTION_MAX_CHARS = 160;
// A catalog whose 160-character lines overflow the section cap tries shorter lines before dropping descriptions.
const TIGHT_ONE_LINE_DESCRIPTION_MAX_CHARS = 100;
const MIN_FILL_CHARS = 24;
// The when-to-use clause ("Use when...", "MUST USE for...", "Triggers: ...") is what the model selects a skill on.
const TRIGGER_CLAUSE =
/(?:^|(?<=[.!?;:)]\s)|(?<=\s[-\u2013\u2014]\s))(?:MUST (?:USE|READ|use|read)\b|USE ONLY WHEN\b|(?:Use|Used|Read|Load|Invoke|Apply|Run|Call)\b[^.!?]{0,40}?\b(?:when|whenever|for|before|after|if|only)\b|(?:Korean )?[Tt]riggers?(?::| -| on\b)|This skill should be used\b|Use this\b|When the user\b)/;
const SENTENCE_END = /[.!?](?=\s|$)/g;

/** A catalog this large is listed by name plus a one-line description, since it rides on every request. */
export function isLargeSkillCatalog(skills: readonly Skill[]): boolean {
Expand All @@ -30,13 +37,27 @@ export function warnIfLargeSkillCatalog(skills: readonly Skill[]): ResourceDiagn
function buildSkillCatalog(skills: readonly Skill[], fileReadTool: "read" | "bash") {
const visible = skills.filter((skill) => !skill.disableModelInvocation);
const compact = isLargeSkillCatalog(visible);
const options = { fileReadTool, compact, namesOnly: false, omitted: 0 };
const options = {
fileReadTool,
compact,
namesOnly: false,
omitted: 0,
descriptionChars: ONE_LINE_DESCRIPTION_MAX_CHARS,
};
const prompt = renderSkills(visible, options);
if (Buffer.byteLength(prompt) <= MAX_SKILL_CATALOG_BYTES) return { prompt, compact, namesOnly: false, omitted: 0 };
const compactPrompt = compact ? prompt : renderSkills(visible, { ...options, compact: true });
if (Buffer.byteLength(compactPrompt) <= MAX_SKILL_CATALOG_BYTES) {
return { prompt: compactPrompt, compact: true, namesOnly: false, omitted: 0 };
}
const tightPrompt = renderSkills(visible, {
...options,
compact: true,
descriptionChars: TIGHT_ONE_LINE_DESCRIPTION_MAX_CHARS,
});
if (Buffer.byteLength(tightPrompt) <= MAX_SKILL_CATALOG_BYTES) {
return { prompt: tightPrompt, compact: true, namesOnly: false, omitted: 0 };
}
const minimal = { ...options, namesOnly: true };
const namesPrompt = renderSkills(visible, minimal);
if (Buffer.byteLength(namesPrompt) <= MAX_SKILL_CATALOG_BYTES) {
Expand All @@ -59,11 +80,35 @@ function buildSkillCatalog(skills: readonly Skill[], fileReadTool: "read" | "bas
};
}

function oneLineDescription(description: string): string {
const firstLine = description.trim().split("\n", 1)[0]?.trim() ?? "";
return firstLine.length <= ONE_LINE_DESCRIPTION_MAX_CHARS
? firstLine
: `${firstLine.slice(0, ONE_LINE_DESCRIPTION_MAX_CHARS - 1).trimEnd()}…`;
// A cut keeps the whole when-to-use sentence (or its start, when that sentence alone is too long) and spends the
// remaining room on the leading what-it-does text, then on whatever follows the trigger sentence.
function oneLineDescription(description: string, maxChars: number): string {
const text = description.trim().replace(/\s+/g, " ");
if (text.length <= maxChars) return text;
const trigger = TRIGGER_CLAUSE.exec(text);
if (!trigger) return truncate(text, maxChars);
SENTENCE_END.lastIndex = trigger.index;
const end = SENTENCE_END.exec(text)?.index;
const clauseEnd = end === undefined ? text.length : end + 1;
const clause = text.slice(trigger.index, clauseEnd);
if (clause.length >= maxChars) return truncate(clause, maxChars);
let room = maxChars - clause.length;
const fill = (part: string): string => {
if (part.length === 0 || room - 1 < MIN_FILL_CHARS) return "";
const kept = truncate(part, room - 1);
room -= kept.length + 1;
return kept;
};
const lead = fill(text.slice(0, trigger.index).trim());
const rest = fill(text.slice(clauseEnd).trim());
return [lead, clause, rest].filter((part) => part.length > 0).join(" ");
}

function truncate(text: string, maxChars: number): string {
if (text.length <= maxChars) return text;
const head = text.slice(0, maxChars - 1);
const wordEnd = head.lastIndexOf(" ");
return `${(wordEnd > maxChars / 2 ? head.slice(0, wordEnd) : head).replace(/[\s,;:]+$/, "")}…`;
}

/**
Expand Down Expand Up @@ -91,6 +136,7 @@ function renderSkills(
readonly compact: boolean;
readonly namesOnly: boolean;
readonly omitted: number;
readonly descriptionChars: number;
},
): string {
if (visibleSkills.length === 0 && options.omitted === 0) {
Expand All @@ -115,23 +161,28 @@ function renderSkills(
for (const [dir, alias] of rootByPath) {
lines.push(` <${alias}>${escapeXml(dir)}</${alias}>`);
}
const oneLine = options.compact || options.namesOnly;
lines.push(
"</skill_roots>",
"A location's `rN/` prefix expands to the matching root above.",
oneLine
? `Each line is \`- name [location]${options.namesOnly ? "" : ": when to use it"}\`. A location \`rN\` means the file \`rN/<name>/SKILL.md\`; a longer location is the file's path under that root.`
: "A location's `rN/` prefix expands to the matching root above.",
"",
"<available_skills>",
);

for (const skill of visibleSkills) {
if (oneLine) {
lines.push(compactSkillLine(skill, rootByPath, options.namesOnly ? undefined : options.descriptionChars));
continue;
}
const dir = skill.filePath.split("/").slice(0, -2).join("/");
const alias = rootByPath.get(dir);
const relative = skill.filePath.startsWith(`${dir}/`) ? skill.filePath.slice(dir.length + 1) : skill.filePath;
lines.push(" <skill>");
lines.push(` <name>${escapeXml(skill.name)}</name>`);
if (!options.namesOnly) {
lines.push(
` <description>${escapeXml(options.compact ? oneLineDescription(skill.description) : skill.description)}</description>`,
);
lines.push(` <description>${escapeXml(skill.description)}</description>`);
}
lines.push(` <location>${escapeXml(`${alias}/${relative}`)}</location>`);
lines.push(" </skill>");
Expand All @@ -143,6 +194,23 @@ function renderSkills(
return lines.join("\n");
}

// A large catalog rides on every request, so each skill is one line: its name, the shortest location that still
// resolves (the root alias alone when the file is `<root>/<name>/SKILL.md`), and a one-line description.
function compactSkillLine(
skill: Skill,
rootByPath: ReadonlyMap<string, string>,
descriptionChars: number | undefined,
): string {
const dir = skill.filePath.split("/").slice(0, -2).join("/");
const alias = rootByPath.get(dir);
const relative = skill.filePath.startsWith(`${dir}/`) ? skill.filePath.slice(dir.length + 1) : skill.filePath;
const location = relative === `${skill.name}/SKILL.md` ? alias : `${alias}/${relative}`;
const head = `- ${escapeXml(skill.name)} [${escapeXml(location ?? relative)}]`;
return descriptionChars === undefined
? head
: `${head}: ${escapeXml(oneLineDescription(skill.description, descriptionChars))}`;
}

function escapeXml(str: string): string {
return str
.replace(/&/g, "&amp;")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,16 +196,16 @@ describe("issue #3460: a large skills catalog is listed compactly", () => {
const prompt = formatSkillsForPrompt(skills);
// Then
expect(Buffer.byteLength(prompt)).toBeLessThanOrEqual(48 * 1024);
expect(prompt).toContain("<name>skill-0</name>");
expect(prompt).toContain("- skill-0 [r0]");
expect(prompt).not.toContain("<description>");
const listed = [...prompt.matchAll(/<name>/g)].length;
const listed = [...prompt.matchAll(/^- skill-/gm)].length;
if (listed < count) expect(prompt).toContain(`<omitted_skills>${count - listed}</omitted_skills>`);
expect(warnIfLargeSkillCatalog(skills)).toContainEqual(expect.objectContaining({ type: "warning" }));
});

it("keeps 70 descriptions below 64 KiB out of compact mode", () => {
it("keeps 70 descriptions below 16 KiB out of compact mode", () => {
// Given
const skills = Array.from({ length: 70 }, (_, index) => skill(index, "d".repeat(480)));
const skills = Array.from({ length: 70 }, (_, index) => skill(index, "d".repeat(200)));
// When
const compact = isLargeSkillCatalog(skills);
// Then
Expand All @@ -216,7 +216,7 @@ describe("issue #3460: a large skills catalog is listed compactly", () => {

const prompt = formatSkillsForPrompt(skills);

expect(prompt).toContain("<name>skill-399</name>");
expect(prompt).toContain("- skill-399 [r0]: Use this skill when the task involves the workflow");
expect(prompt).not.toContain("retry rules");
expect(prompt.length).toBeLessThan(100_000);
});
Expand Down
Loading
Loading