From 38669fecb8235c688b64c26da54ca8caacccaf85 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 28 Sep 2026 09:06:45 -0700 Subject: [PATCH 1/2] feat(skills): cap skill and spawn descriptions to one line --- src/agent/skill-search.ts | 7 +++-- src/agent/use-skill.ts | 4 +-- src/extensions/skills.ts | 11 +++++++- src/subagent/agent-fleet.ts | 2 +- tests/unit/skills.test.ts | 56 +++++++++++++++++++++++++++++++++++++ 5 files changed, 73 insertions(+), 7 deletions(-) diff --git a/src/agent/skill-search.ts b/src/agent/skill-search.ts index 56f883d71..372b57eab 100644 --- a/src/agent/skill-search.ts +++ b/src/agent/skill-search.ts @@ -4,6 +4,7 @@ import type { ToolDefinition } from "@intx/types/runtime"; import { type } from "arktype"; import type { SkillSummary } from "../extensions/skills.js"; +import { firstLine } from "../extensions/skills.js"; import { lexicalFields, rankAndCut, @@ -31,14 +32,14 @@ const SKILL_SEARCH_INPUT_SCHEMA = { export const skillSearchDefinition: ToolDefinition = { name: "skill_search", description: - "Look up skill details by capability. Skill names are listed in the system prompt; call this for descriptions, then use_skill to load a body. Directly callable — do not tool_search for this.", + "Look up skill details by capability, then use_skill to load a body. Directly callable. See Guidelines: Tool choice.", inputSchema: SKILL_SEARCH_INPUT_SCHEMA, }; export const workerSkillSearchDefinition: ToolDefinition = { name: "skill_search", description: - "Find a skill during prep when attached skills are not enough. Do not search on a tiny one-file fix. Directly callable — do not tool_search for this. Returns name + description; load a body with use_skill.", + "Prep lookup for a skill when attached skills are not enough; skip tiny one-file fixes. Directly callable; load via use_skill. See Guidelines: Tool choice.", inputSchema: SKILL_SEARCH_INPUT_SCHEMA, }; @@ -105,7 +106,7 @@ export function createSkillSearchTool( return `No skills matched "${query}". Try different keywords describing the capability.`; } return matches - .map((skill) => `- ${skill.name}: ${skill.description}`) + .map((skill) => `- ${skill.name}: ${firstLine(skill.description)}`) .join("\n"); }, }); diff --git a/src/agent/use-skill.ts b/src/agent/use-skill.ts index db03542dc..b320f3339 100644 --- a/src/agent/use-skill.ts +++ b/src/agent/use-skill.ts @@ -29,14 +29,14 @@ const USE_SKILL_INPUT_SCHEMA = { export const useSkillDefinition: ToolDefinition = { name: "use_skill", description: - "Load the full instructions for a skill. Names are listed under 'Skills' in the system prompt; call skill_search for descriptions, then this tool with the skill's name to load the body. The returned instructions stay in effect for the rest of the task.", + "Load the full instructions for a skill; locate details first with skill_search. See Guidelines: Tool choice.", inputSchema: USE_SKILL_INPUT_SCHEMA, }; export const workerUseSkillDefinition: ToolDefinition = { name: "use_skill", description: - "Load a skill you already know by name (brief or search). Do not reload skills listed as attached or already in context. The returned instructions stay in effect for the rest of the task.", + "Load a skill by name; do not reload attached skills or bodies already in context. See Guidelines: Tool choice.", inputSchema: USE_SKILL_INPUT_SCHEMA, }; diff --git a/src/extensions/skills.ts b/src/extensions/skills.ts index 639f1c336..f90815305 100644 --- a/src/extensions/skills.ts +++ b/src/extensions/skills.ts @@ -174,6 +174,12 @@ export async function resolveSkillBody( return undefined; } +// Registration strings and result lines stay one line: keep the first line so +// a multi-line frontmatter description cannot leak extra lines onto the wire. +export function firstLine(value: string): string { + return value.split("\n", 1)[0]?.trim() ?? ""; +} + // Discover every available skill (name + one-line description). Deduped by name: // the first base dir that provides a skill wins, so a higher-precedence dir // shadows a lower one. Descriptions feed skill_search and the slash picker; the @@ -200,7 +206,10 @@ export async function discoverSkills( // First-wins: claim the name even when skipping the listing. seen.add(entry.name); if (fm.disableModelInvocation) continue; - skills.push({ name: entry.name, description: fm.description ?? "" }); + skills.push({ + name: entry.name, + description: firstLine(fm.description ?? ""), + }); } } return skills; diff --git a/src/subagent/agent-fleet.ts b/src/subagent/agent-fleet.ts index 2da7c9b91..d5bc70b91 100644 --- a/src/subagent/agent-fleet.ts +++ b/src/subagent/agent-fleet.ts @@ -548,7 +548,7 @@ const SpawnAgentArgs = type({ export const spawnAgentToolDefinition: ToolDefinition = { name: SPAWN_AGENT_TOOL_NAME, description: - "Start a worker agent and return IMMEDIATELY with its agent_id — this never blocks on the worker's completion. Pass agent= a director/profile id returned by search_agents, or intent= (one of explore|implement|review|plan|general). The child starts blank. One focused task per worker. success_criteria is required for implement/review (and their default directors). Fire several spawn_agent calls in one turn to start independent lanes in parallel, then reply and end the turn — workers keep running while you are idle. Reports arrive as mailbox mail where mailbox delivery is mounted; where wait_agents is mounted (exec primary), collect with it instead. Do not poll. Excess fan-out is queued rather than refused.", + "Start a worker agent and return immediately with its agent_id; one focused task per worker. See Guidelines: Orchestration.", inputSchema: { type: "object", properties: { diff --git a/tests/unit/skills.test.ts b/tests/unit/skills.test.ts index 114f65887..7ca4ff4f2 100644 --- a/tests/unit/skills.test.ts +++ b/tests/unit/skills.test.ts @@ -5,8 +5,19 @@ import { test, expect, describe, beforeEach, afterEach } from "bun:test"; import { discoverSkills, + firstLine, resolveSkillBody, } from "../../src/extensions/skills.js"; +import { + createSkillSearchTool, + skillSearchDefinition, + workerSkillSearchDefinition, +} from "../../src/agent/skill-search.js"; +import { + useSkillDefinition, + workerUseSkillDefinition, +} from "../../src/agent/use-skill.js"; +import { spawnAgentToolDefinition } from "../../src/subagent/agent-fleet.js"; import { defined } from "../helpers/defined.js"; const fixtureCwd = join(import.meta.dirname, "../fixtures/skill-workspace"); @@ -261,3 +272,48 @@ describe("path-like skill refs", () => { } }); }); + +describe("description caps (CL-8854)", () => { + const MAX_DESCRIPTION_CHARS = 160; + + test("all five registration descriptions are one line within the cap", () => { + const skillDescriptions = [ + skillSearchDefinition.description, + workerSkillSearchDefinition.description, + useSkillDefinition.description, + workerUseSkillDefinition.description, + ]; + for (const description of skillDescriptions) { + expect(description).not.toContain("\n"); + expect(description.length).toBeLessThanOrEqual(MAX_DESCRIPTION_CHARS); + expect(description.endsWith("See Guidelines: Tool choice.")).toBe(true); + } + expect(spawnAgentToolDefinition.description).not.toContain("\n"); + expect(spawnAgentToolDefinition.description.length).toBeLessThanOrEqual( + MAX_DESCRIPTION_CHARS, + ); + expect( + spawnAgentToolDefinition.description.endsWith( + "See Guidelines: Orchestration.", + ), + ).toBe(true); + }); + + test("firstLine keeps only the first line", () => { + expect(firstLine("one\ntwo\nthree")).toBe("one"); + expect(firstLine(" padded \nrest")).toBe("padded"); + expect(firstLine("single")).toBe("single"); + }); + + test("a multi-line catalog description renders as a single result line", async () => { + const tool = createSkillSearchTool({ + skills: [{ name: "scribe", description: "write docs\nsecond line" }], + }); + if (tool.kind !== "string") throw new Error("expected string tool"); + const out = await tool.handler( + { query: "scribe" }, + new AbortController().signal, + ); + expect(out).toBe("- scribe: write docs"); + }); +}); From d7f89e41c978ae60e53d28a05b17725b8883ddec Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 28 Sep 2026 09:17:23 -0700 Subject: [PATCH 2/2] fix(skills): cap slash-picker line and drop dangling worker pointers --- src/agent/skill-search.ts | 2 +- src/agent/use-skill.ts | 2 +- src/plugins/skill-commands.ts | 5 +++-- tests/unit/skills.test.ts | 41 ++++++++++++++++++++++++++++++----- 4 files changed, 41 insertions(+), 9 deletions(-) diff --git a/src/agent/skill-search.ts b/src/agent/skill-search.ts index 372b57eab..23246cdf5 100644 --- a/src/agent/skill-search.ts +++ b/src/agent/skill-search.ts @@ -39,7 +39,7 @@ export const skillSearchDefinition: ToolDefinition = { export const workerSkillSearchDefinition: ToolDefinition = { name: "skill_search", description: - "Prep lookup for a skill when attached skills are not enough; skip tiny one-file fixes. Directly callable; load via use_skill. See Guidelines: Tool choice.", + "Prep lookup for a skill when attached skills are not enough; skip tiny one-file fixes. Directly callable; load via use_skill.", inputSchema: SKILL_SEARCH_INPUT_SCHEMA, }; diff --git a/src/agent/use-skill.ts b/src/agent/use-skill.ts index b320f3339..55b9b1ccf 100644 --- a/src/agent/use-skill.ts +++ b/src/agent/use-skill.ts @@ -36,7 +36,7 @@ export const useSkillDefinition: ToolDefinition = { export const workerUseSkillDefinition: ToolDefinition = { name: "use_skill", description: - "Load a skill by name; do not reload attached skills or bodies already in context. See Guidelines: Tool choice.", + "Load a skill by name; do not reload attached skills or bodies already in context.", inputSchema: USE_SKILL_INPUT_SCHEMA, }; diff --git a/src/plugins/skill-commands.ts b/src/plugins/skill-commands.ts index e9fc84462..d2d0fa39f 100644 --- a/src/plugins/skill-commands.ts +++ b/src/plugins/skill-commands.ts @@ -5,6 +5,7 @@ import { type CommandDefinition, type CommandResult, } from "../tui/commands/registry.js"; +import { firstLine } from "../extensions/skills.js"; import { COMMAND_NAME_PATTERN, splitFrontmatter } from "./frontmatter.js"; // Slash is the operator action surface: `/ [args]` sends the skill @@ -72,8 +73,8 @@ export async function loadSkillCommands( } const description = typeof frontmatter.description === "string" && - frontmatter.description.trim().length > 0 - ? frontmatter.description.trim() + firstLine(frontmatter.description).length > 0 + ? firstLine(frontmatter.description) : name; const argumentHint = argumentHintFromFrontmatter(frontmatter); diff --git a/tests/unit/skills.test.ts b/tests/unit/skills.test.ts index 7ca4ff4f2..13c4753f8 100644 --- a/tests/unit/skills.test.ts +++ b/tests/unit/skills.test.ts @@ -13,6 +13,7 @@ import { skillSearchDefinition, workerSkillSearchDefinition, } from "../../src/agent/skill-search.js"; +import { loadSkillCommands } from "../../src/plugins/skill-commands.js"; import { useSkillDefinition, workerUseSkillDefinition, @@ -276,18 +277,27 @@ describe("path-like skill refs", () => { describe("description caps (CL-8854)", () => { const MAX_DESCRIPTION_CHARS = 160; - test("all five registration descriptions are one line within the cap", () => { - const skillDescriptions = [ + test("primary descriptions keep the pointer; worker copies are pointer-free, all one line within the cap", () => { + const primaryDescriptions = [ skillSearchDefinition.description, - workerSkillSearchDefinition.description, useSkillDefinition.description, - workerUseSkillDefinition.description, ]; - for (const description of skillDescriptions) { + for (const description of primaryDescriptions) { expect(description).not.toContain("\n"); expect(description.length).toBeLessThanOrEqual(MAX_DESCRIPTION_CHARS); expect(description.endsWith("See Guidelines: Tool choice.")).toBe(true); } + // The lean worker contract ships no guidelines, so the worker copies must + // not point at them. + const workerDescriptions = [ + workerSkillSearchDefinition.description, + workerUseSkillDefinition.description, + ]; + for (const description of workerDescriptions) { + expect(description).not.toContain("\n"); + expect(description.length).toBeLessThanOrEqual(MAX_DESCRIPTION_CHARS); + expect(description).not.toContain("See Guidelines:"); + } expect(spawnAgentToolDefinition.description).not.toContain("\n"); expect(spawnAgentToolDefinition.description.length).toBeLessThanOrEqual( MAX_DESCRIPTION_CHARS, @@ -316,4 +326,25 @@ describe("description caps (CL-8854)", () => { ); expect(out).toBe("- scribe: write docs"); }); + + test("a multi-line SKILL.md frontmatter description renders as a single slash-picker line", async () => { + const plugin = await mkdtemp(join(tmpdir(), "skill-slash-multiline-")); + try { + await mkdir(join(plugin, "skills", "scribe"), { recursive: true }); + await writeFile( + join(plugin, "skills", "scribe", "SKILL.md"), + "---\nname: scribe\ndescription: |\n write docs\n second line\n---\nScribe body.\n", + "utf8", + ); + const cmds = defined(await loadSkillCommands(plugin), "skill commands"); + const scribe = defined( + cmds.find((c) => c.name === "scribe"), + "scribe command", + ); + expect(scribe.description).toBe("write docs"); + expect(scribe.description).not.toContain("\n"); + } finally { + await rm(plugin, { recursive: true, force: true }); + } + }); });