Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions src/agent/skill-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.",
inputSchema: SKILL_SEARCH_INPUT_SCHEMA,
};

Expand Down Expand Up @@ -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");
},
});
Expand Down
4 changes: 2 additions & 2 deletions src/agent/use-skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
inputSchema: USE_SKILL_INPUT_SCHEMA,
};

Expand Down
11 changes: 10 additions & 1 deletion src/extensions/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down
5 changes: 3 additions & 2 deletions src/plugins/skill-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: `/<skill-name> [args]` sends the skill
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/subagent/agent-fleet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
87 changes: 87 additions & 0 deletions tests/unit/skills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,20 @@ 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 { loadSkillCommands } from "../../src/plugins/skill-commands.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");
Expand Down Expand Up @@ -261,3 +273,78 @@ describe("path-like skill refs", () => {
}
});
});

describe("description caps (CL-8854)", () => {
const MAX_DESCRIPTION_CHARS = 160;

test("primary descriptions keep the pointer; worker copies are pointer-free, all one line within the cap", () => {
const primaryDescriptions = [
skillSearchDefinition.description,
useSkillDefinition.description,
];
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,
);
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");
});

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 });
}
});
});
Loading