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
31 changes: 31 additions & 0 deletions packages/agent-core/src/harness/skill-discovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import ignore from "ignore";

/**
* Ignore rules scoped to one directory in a resource tree. Paths are relative
* to the scan root, using forward slashes. Callers prune ignored directories
* before reading child rules, as gitignore requires.
*/
export class SkillIgnoreMatcher {
private readonly matcher = ignore();
private readonly prefix: string;
private readonly parent?: SkillIgnoreMatcher;

constructor(directory = "", parent?: SkillIgnoreMatcher) {
this.prefix = directory ? `${directory}/` : "";
this.parent = parent;
}

add(patterns: string): void {
this.matcher.add(patterns);
}

ignores(path: string): boolean {
const inherited = this.parent?.ignores(path) ?? false;
if (!path.startsWith(this.prefix)) return inherited;
const localPath = path.slice(this.prefix.length);
if (!localPath) return inherited;
const result = this.matcher.test(localPath);
if (result.unignored) return false;
return result.ignored || inherited;
}
}
54 changes: 18 additions & 36 deletions packages/agent-core/src/harness/skills.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
import ignore from "ignore";
import { parse } from "yaml";
import { SkillIgnoreMatcher } from "./skill-discovery.ts";
import { type ExecutionEnv, type FileInfo, type Result, type Skill, toError } from "./types.ts";

const MAX_NAME_LENGTH = 64;
const MAX_DESCRIPTION_LENGTH = 1024;
const IGNORE_FILE_NAMES = [".gitignore", ".ignore", ".fdignore"];

type IgnoreMatcher = ReturnType<typeof ignore>;

export type SkillDiagnosticCode =
| "file_info_failed"
| "list_failed"
Expand Down Expand Up @@ -68,7 +66,7 @@ export async function loadSkills(
}
const rootInfo = rootInfoResult.value;
if ((await resolveKind(env, rootInfo, diagnostics)) !== "directory") continue;
const result = await loadSkillsFromDirInternal(env, rootInfo.path, true, ignore(), rootInfo.path);
const result = await loadSkillsFromDirInternal(env, rootInfo.path, true, undefined, rootInfo.path, new Set());
skills.push(...result.skills);
diagnostics.push(...result.diagnostics);
}
Expand Down Expand Up @@ -105,8 +103,9 @@ async function loadSkillsFromDirInternal(
env: ExecutionEnv,
dir: string,
includeRootFiles: boolean,
ignoreMatcher: IgnoreMatcher,
parentIgnoreMatcher: SkillIgnoreMatcher | undefined,
rootDir: string,
visitedDirs: Set<string>,
): Promise<{ skills: Skill[]; diagnostics: SkillDiagnostic[] }> {
const skills: Skill[] = [];
const diagnostics: SkillDiagnostic[] = [];
Expand All @@ -126,7 +125,13 @@ async function loadSkillsFromDirInternal(
const dirInfo = dirInfoResult.value;
if ((await resolveKind(env, dirInfo, diagnostics)) !== "directory") return { skills, diagnostics };

await addIgnoreRules(env, ignoreMatcher, dir, rootDir, diagnostics);
const canonicalDir = await env.canonicalPath(dir);
const realDir = canonicalDir.ok ? canonicalDir.value : dirInfo.path;
if (visitedDirs.has(realDir)) return { skills, diagnostics };
visitedDirs.add(realDir);

const ignoreMatcher = new SkillIgnoreMatcher(relativeEnvPath(rootDir, dir), parentIgnoreMatcher);
await addIgnoreRules(env, ignoreMatcher, dir, diagnostics);

const entriesResult = await env.listDir(dir);
if (!entriesResult.ok) {
Expand Down Expand Up @@ -160,7 +165,7 @@ async function loadSkillsFromDirInternal(
if (ignoreMatcher.ignores(ignorePath)) continue;

if (kind === "directory") {
const result = await loadSkillsFromDirInternal(env, fullPath, false, ignoreMatcher, rootDir);
const result = await loadSkillsFromDirInternal(env, fullPath, false, ignoreMatcher, rootDir, visitedDirs);
skills.push(...result.skills);
diagnostics.push(...result.diagnostics);
continue;
Expand All @@ -177,14 +182,10 @@ async function loadSkillsFromDirInternal(

async function addIgnoreRules(
env: ExecutionEnv,
ig: IgnoreMatcher,
ig: SkillIgnoreMatcher,
dir: string,
rootDir: string,
diagnostics: SkillDiagnostic[],
): Promise<void> {
const relativeDir = relativeEnvPath(rootDir, dir);
const prefix = relativeDir ? `${relativeDir}/` : "";

for (const filename of IGNORE_FILE_NAMES) {
const ignorePathResult = await env.joinPath([dir, filename]);
if (!ignorePathResult.ok) {
Expand Down Expand Up @@ -215,30 +216,8 @@ async function addIgnoreRules(
diagnostics.push({ type: "warning", code: "read_failed", message: content.error.message, path: ignorePath });
continue;
}
const patterns = content.value
.split(/\r?\n/)
.map((line) => prefixIgnorePattern(line, prefix))
.filter((line): line is string => Boolean(line));
if (patterns.length > 0) ig.add(patterns);
}
}

function prefixIgnorePattern(line: string, prefix: string): string | null {
const trimmed = line.trim();
if (!trimmed) return null;
if (trimmed.startsWith("#") && !trimmed.startsWith("\\#")) return null;

let pattern = line;
let negated = false;
if (pattern.startsWith("!")) {
negated = true;
pattern = pattern.slice(1);
} else if (pattern.startsWith("\\!")) {
pattern = pattern.slice(1);
ig.add(content.value);
}
if (pattern.startsWith("/")) pattern = pattern.slice(1);
const prefixed = prefix ? `${prefix}${pattern}` : pattern;
return negated ? `!${prefixed}` : prefixed;
}

async function loadSkillFromFile(
Expand Down Expand Up @@ -324,7 +303,10 @@ function parseFrontmatter<T extends Record<string, unknown>>(
content: string,
): Result<{ frontmatter: T; body: string }, Error> {
try {
const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
const normalized = content
.replace(/^\uFEFF/, "")
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
if (!normalized.startsWith("---")) return { ok: true, value: { frontmatter: {} as T, body: normalized } };
const endIndex = normalized.indexOf("\n---", 3);
if (endIndex === -1) return { ok: true, value: { frontmatter: {} as T, body: normalized } };
Expand Down
1 change: 1 addition & 0 deletions packages/agent-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export * from "./harness/prompt-templates.ts";
// Harness
export * from "./harness/result.ts";
export * from "./harness/session/index.ts";
export { SkillIgnoreMatcher } from "./harness/skill-discovery.ts";
export * from "./harness/skills.ts";
export * from "./harness/system-prompt.ts";
export type {
Expand Down
12 changes: 8 additions & 4 deletions packages/coding-agent/docs/skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,10 @@ Discovery rules:
- In all skill locations, directories containing `SKILL.md` are discovered recursively
- In `~/.agents/skills/` and project `.agents/skills/`, root `.md` files are ignored, but nested `.md` files in grouping folders are discovered when they declare skill frontmatter
- Root Markdown files other than `SKILL.md` that do not look like skills are ignored silently
- Directory symlinks are followed once per scan, so cycles do not cause repeated traversal
- `.gitignore`, `.ignore`, and `.fdignore` rules are applied relative to the directory containing each ignore file

Disable discovery with `--no-skills` (explicit `--skill` paths still load).
Disable automatic discovery with `--no-skills`. Skills from default paths, settings, and configured packages are not scanned; explicit `--skill` paths still load.

### Using Skills from Other Harnesses

Expand All @@ -66,11 +68,13 @@ For project-level Claude Code skills, add to `.stepcode/settings.json`:

1. At startup, step scans skill locations and extracts names and descriptions
2. The system prompt includes available skills in XML format per the [specification](https://agentskills.io/integrate-skills)
3. When a task matches, the agent uses `read` to load the full SKILL.md (models don't always do this; use prompting or `/skill:name` to force it)
3. When a task matches, the agent uses the available file-reading tool (`read_file` in Step) to load the full SKILL.md (models don't always do this; use prompting or `/skill:name` to force it)
4. The agent follows the instructions, using relative paths to reference scripts and assets

This is progressive disclosure: only descriptions are always in context, full instructions load on-demand.

Built-in context compaction preserves instructions already loaded through file reads or `/skill:name`, along with their locations and any read ranges. Repeated reads are deduplicated, and a new full read replaces the previously loaded version. Instructions are carried into later compactions without reading inactive skills from disk. Extensions that provide their own compaction result remain responsible for the content they preserve.

## Skill Commands

Skills register as `/skill:name` commands:
Expand All @@ -80,7 +84,7 @@ Skills register as `/skill:name` commands:
/skill:pdf-tools extract # Load skill with arguments
```

Arguments after the command are appended to the skill content as `User: <args>`.
Any whitespace, including a newline or tab, can separate the skill name from its arguments. Arguments are appended after the skill content.

Toggle skill commands via `/settings` in interactive mode or in `settings.json`:

Expand Down Expand Up @@ -186,7 +190,7 @@ Unknown frontmatter fields are ignored.

Declared skills with missing descriptions are not loaded. Malformed `SKILL.md` files and `SKILL.md` files without a description produce warnings and are not loaded. Other Markdown files without valid skill frontmatter are ignored.

Name collisions (same name from different locations) warn and keep the first skill found.
Name collisions (same name from different locations) warn and keep the first skill found. In the default locations, project skills take precedence over user skills, including when using the `loadSkills()` SDK helper.

## Example

Expand Down
39 changes: 10 additions & 29 deletions packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,32 +116,7 @@ import { createAllToolDefinitions } from "./tools/index.ts";
import { createToolDefinitionFromAgentTool } from "./tools/tool-definition-wrapper.ts";
import { addUsageToTotals, createUsageTotals } from "./usage-totals.ts";

// ============================================================================
// Skill Block Parsing
// ============================================================================

/** Parsed skill block from a user message */
export interface ParsedSkillBlock {
name: string;
location: string;
content: string;
userMessage: string | undefined;
}

/**
* Parse a skill block from message text.
* Returns null if the text doesn't contain a skill block.
*/
export function parseSkillBlock(text: string): ParsedSkillBlock | null {
const match = text.match(/^<skill name="([^"]+)" location="([^"]+)">\n([\s\S]*?)\n<\/skill>(?:\n\n([\s\S]+))?$/);
if (!match) return null;
return {
name: match[1],
location: match[2],
content: match[3],
userMessage: match[4]?.trim() || undefined,
};
}
export { type ParsedSkillBlock, parseSkillBlock } from "../utils/skill-block.ts";

/** Session-specific events that extend the core AgentEvent */
export type AgentSessionEvent =
Expand Down Expand Up @@ -1469,7 +1444,7 @@ export class AgentSession {
private _expandSkillCommand(text: string): string {
if (!text.startsWith("/skill:")) return text;

const spaceIndex = text.indexOf(" ");
const spaceIndex = text.search(/\s/u);
const skillName = spaceIndex === -1 ? text.slice(7) : text.slice(7, spaceIndex);
const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1).trim();

Expand Down Expand Up @@ -2080,7 +2055,10 @@ export class AgentSession {
const pathEntries = this.sessionManager.getBranch();
const settings = this.settingsManager.getCompactionSettings();

const preparation = prepareCompaction(pathEntries, settings);
const preparation = prepareCompaction(pathEntries, settings, {
cwd: this._cwd,
skills: this._resourceLoader.getSkills().skills,
});
if (!preparation) {
// Check why we can't compact
const lastEntry = pathEntries[pathEntries.length - 1];
Expand Down Expand Up @@ -2384,7 +2362,10 @@ export class AgentSession {

const pathEntries = this.sessionManager.getBranch();

const preparation = prepareCompaction(pathEntries, settings);
const preparation = prepareCompaction(pathEntries, settings, {
cwd: this._cwd,
skills: this._resourceLoader.getSkills().skills,
});
if (!preparation) {
return false;
}
Expand Down
34 changes: 31 additions & 3 deletions packages/coding-agent/src/core/compaction/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ import {
type SessionEntry,
sessionEntryToContextMessages,
} from "../session-manager.ts";
import {
collectSkillInstructions,
formatSkillInstructions,
readSavedSkillInstructions,
type SkillInstruction,
type SkillInstructionContext,
stripSkillInstructions,
} from "./skill-instructions.ts";
import {
computeFileLists,
createFileOps,
Expand All @@ -41,6 +49,8 @@ import {
export interface CompactionDetails {
readFiles: string[];
modifiedFiles: string[];
/** Loaded skill instructions carried across successive compactions. */
activeSkills?: SkillInstruction[];
}

/**
Expand Down Expand Up @@ -777,13 +787,16 @@ export interface CompactionPreparation {
previousSummary?: string;
/** File operations extracted from messagesToSummarize */
fileOps: FileOperations;
/** Compaction settions from settings.jsonl */
/** Loaded instructions that must survive model-generated summaries. */
activeSkills?: SkillInstruction[];
/** Compaction settings from settings.jsonl. */
settings: CompactionSettings;
}

export function prepareCompaction(
pathEntries: SessionEntry[],
settings: CompactionSettings,
skillContext?: SkillInstructionContext,
): CompactionPreparation | undefined {
if (pathEntries.length > 0 && pathEntries[pathEntries.length - 1].type === "compaction") {
return undefined;
Expand All @@ -798,10 +811,12 @@ export function prepareCompaction(
}

let previousSummary: string | undefined;
let previousSkills: SkillInstruction[] = [];
let boundaryStart = 0;
if (prevCompactionIndex >= 0) {
const prevCompaction = pathEntries[prevCompactionIndex] as CompactionEntry;
previousSummary = prevCompaction.summary;
previousSkills = prevCompaction.fromHook ? [] : readSavedSkillInstructions(prevCompaction.details);
previousSummary = stripSkillInstructions(prevCompaction.summary, previousSkills);
const firstKeptEntryIndex = pathEntries.findIndex((entry) => entry.id === prevCompaction.firstKeptEntryId);
boundaryStart = firstKeptEntryIndex >= 0 ? firstKeptEntryIndex : prevCompactionIndex + 1;
}
Expand Down Expand Up @@ -850,6 +865,12 @@ export function prepareCompaction(
}
}

const activeSkills = collectSkillInstructions(
convertToLlm([...messagesToSummarize, ...turnPrefixMessages]),
previousSkills,
skillContext,
);

return {
firstKeptEntryId,
messagesToSummarize,
Expand All @@ -858,6 +879,7 @@ export function prepareCompaction(
tokensBefore,
previousSummary,
fileOps,
...(activeSkills.length > 0 ? { activeSkills } : {}),
settings,
};
}
Expand Down Expand Up @@ -906,6 +928,7 @@ export async function compact(
tokensBefore,
previousSummary,
fileOps,
activeSkills = [],
settings,
} = preparation;

Expand Down Expand Up @@ -978,6 +1001,7 @@ export async function compact(
// Compute file lists and append to summary
const { readFiles, modifiedFiles } = computeFileLists(fileOps);
summary += formatFileOperations(readFiles, modifiedFiles);
summary += formatSkillInstructions(activeSkills);

if (!firstKeptEntryId) {
throw new Error("First kept entry has no UUID - session may need migration");
Expand All @@ -988,7 +1012,11 @@ export async function compact(
firstKeptEntryId,
tokensBefore,
usage: summaryUsage,
details: { readFiles, modifiedFiles } as CompactionDetails,
details: {
readFiles,
modifiedFiles,
...(activeSkills.length > 0 ? { activeSkills } : {}),
} as CompactionDetails,
};
}

Expand Down
Loading
Loading