diff --git a/README.md b/README.md index dcf664f3f..d27812fbc 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ dashboard — any provider, any deploy target, no lock-in. - **Human-in-the-loop** — flag a tool `needsApproval` and pause execution until a human approves or rejects it, then `resumeAfterApproval()` from any process - **Durable execution** — pass a `sessionId` + `checkpointStore` and a crash mid-conversation resumes instead of restarting - **Cancellation** — pass an `AbortSignal` (`agent.send(input, { signal })`) to stop a run; it resolves with `finishReason: 'aborted'` and the transcript so far, and the signal reaches the provider, tools and delegated agents +- **Skills** — `defineSkill()` / `loadSkills('./skills')`: only each skill's name and description sit in the system prompt; the model loads the full markdown on demand through an auto-registered `load_skill` tool - **Multi-agent delegation** — wrap a child agent as a tool with `createDelegateTool()`, with a `maxDepth` guard against delegation loops - **Pre/post hooks** — a `HookRegistry` of `AgentHook`s that can inspect or mutate a tool call/LLM generate step, or throw to abort it, run sandboxed by Agent Forge's hook editor - **Guardrails** — fail-closed, concurrently-run checks (secret scan, diff size, test/lint commands) that gate a fixer agent's patch before it's used @@ -196,6 +197,7 @@ console.log(result.usage.totalTokens, result.finishReason, result.steps); - [Deployment](docs/deployment.md) - `loushy build` targets: Node server, Docker, Cloudflare Workers - [API Overview](docs/api-overview.md) - the main exports; `npm run docs:build` generates the full TypeDoc reference - [Testing](docs/testing.md) - unit-test agents deterministically with the scripted `mockModel` +- [Skills](docs/skills.md) - on-demand instructions: `defineSkill()`, `loadSkills()`, how they save context - [Agent Forge](docs/agent-forge.md) - the visual dashboard (`loushy studio`): quickstart, first-agent walkthrough, hook authoring - Full guides site: [linuxdevil.github.io/agent-sdk-docs](https://linuxdevil.github.io/agent-sdk-docs/) diff --git a/docs/api-overview.md b/docs/api-overview.md index 4bf32b7bf..e5bffd1ac 100644 --- a/docs/api-overview.md +++ b/docs/api-overview.md @@ -19,6 +19,13 @@ npm run docs:build # writes docs/api/index.html | `resumeAfterApproval()` | Resume an execution paused for human approval. | | `createDelegateTool()` | Wrap a child agent as a tool for multi-agent delegation. | +### Skills + +Pass `skills: [defineSkill({ name, description, content }), ...(await loadSkills(dir))]` to +`createAgent()` or `AgentExecutor.execute()`: only names and descriptions go in +the system prompt and the model loads bodies through an auto-registered +`load_skill` tool. See [Skills](./skills.md). + ### Cancellation Pass an `AbortSignal` to stop a run: `agent.send(input, { signal })`, diff --git a/docs/skills.md b/docs/skills.md new file mode 100644 index 000000000..a2f0cd8fc --- /dev/null +++ b/docs/skills.md @@ -0,0 +1,106 @@ +# Skills + +A skill is a named chunk of instructions (a markdown document) that the model +loads **only when it needs it**. This is progressive disclosure: instead of +pasting every playbook into the system prompt, you list skills by name and +one-line description, and the model pulls in the full text on demand. + +How it works when an agent has skills: + +1. The system prompt gets a compact **Available skills** block: one line per + skill (`- name: description`) plus one sentence telling the model to call + `load_skill` before doing a task a skill covers. +2. A `load_skill` tool (input `{ name }`) is registered automatically. It + returns the skill's full content as the tool result. +3. Skill bodies are **not** in the prompt until loaded. An agent with twenty + long skills pays roughly one line per skill on every request, and the body + cost only on the runs that use it. + +An unknown name is a tool error that lists the valid skill names, so the model +can correct itself. + +## Skills in code + +```ts +import { createAgent, defineSkill } from '@loushy/build-ai-agent'; + +const agent = createAgent({ + prompt: 'You are a release engineer.', + provider, + skills: [ + defineSkill({ + name: 'changelog', + description: 'How to write a changelog entry', + content: '# Changelog entries\n\nUse the imperative mood and link the PR.', + }), + ], +}); +``` + +`defineSkill({ name, description, content })` validates its input. `name` must +match `^[a-z0-9][a-z0-9-_]{0,63}$`; `description` and `content` must be +non-empty. Errors say what is wrong and how to fix it. + +The full executor API takes the same option: + +```ts +import { AgentExecutor, defineSkill } from '@loushy/build-ai-agent'; + +const result = await AgentExecutor.execute({ + agent, + input: 'Write the changelog for 1.2.0', + provider, + skills: [defineSkill({ name: 'changelog', description: 'Changelog style', content: '...' })], +}); +``` + +If you already registered a tool named `load_skill`, passing `skills` throws: +rename your tool or drop the option. + +## Skills from disk + +`loadSkills(dir)` reads skill files, so non-developers can edit them without +touching code. Two layouts are supported and can be mixed in one directory: + +``` +skills/ + changelog/SKILL.md # /SKILL.md (Anthropic / open-harness convention) + code-review.md # .md (eve convention) +``` + +Each file starts with YAML frontmatter; the rest is the skill's content: + +```markdown +--- +description: How to write a changelog entry +--- + +# Changelog entries + +Use the imperative mood and link the PR. +``` + +- `description` is required. +- `name` is optional and defaults to the folder name (`SKILL.md` layout) or the + file name without `.md`. +- Folders without a `SKILL.md` are ignored. +- Skills come back sorted by name, so prompts are deterministic. +- Errors name the file and the problem: missing description, invalid YAML, + invalid name, an unreadable directory, or two skills with the same name + (both paths are listed). + +```ts +import { createAgent, loadSkills } from '@loushy/build-ai-agent'; + +const agent = createAgent({ + prompt: 'You are a release engineer.', + provider, + skills: [...(await loadSkills('./skills'))], +}); +``` + +## Testing skills + +Use [`mockModel`](./testing.md) to assert the model sees descriptions but not +bodies until it loads one: the first request's system prompt lists the skills, +and the request after a `load_skill` call contains the body in the tool result. diff --git a/src/createAgent.ts b/src/createAgent.ts index c7214fd70..6c74e856c 100644 --- a/src/createAgent.ts +++ b/src/createAgent.ts @@ -22,6 +22,7 @@ import { LLMProvider } from './providers/llm'; import { ToolRegistry } from './tools/ToolRegistry'; import { ToolDescriptor } from './types'; import type { DefinedTool } from './tools/defineTool'; +import type { Skill } from './skills/defineSkill'; /** * Configuration for createAgent(). Tools are keyed by the name the agent @@ -41,6 +42,15 @@ export interface CreateAgentConfig { * createAgent({ prompt: '...', provider, tools: [sendEmail] }); */ tools?: readonly DefinedTool[] | Record; + /** + * Optional skills (LOU-Y2): only name + description go in the system + * prompt; the model loads a skill's full content with the auto-registered + * `load_skill` tool. Build them with `defineSkill()` or `loadSkills()`. + * + * @example + * createAgent({ prompt: '...', provider, skills: await loadSkills('./skills') }); + */ + skills?: readonly Skill[]; /** Optional agent name; defaults to 'agent'. */ name?: string; /** Optional maxSteps passed through to AgentExecutor.execute(). */ @@ -91,6 +101,7 @@ export function createAgent(config: CreateAgentConfig): SimpleAgent { input: message, provider: config.provider, toolRegistry, + skills: config.skills, maxSteps: config.maxSteps, signal: options.signal, }); diff --git a/src/execution/AgentExecutor.ts b/src/execution/AgentExecutor.ts index 31be10e8d..bf8a1406b 100644 --- a/src/execution/AgentExecutor.ts +++ b/src/execution/AgentExecutor.ts @@ -3,6 +3,8 @@ * Executes agents with streaming support and tool calling */ +import type { Skill } from '../skills/defineSkill'; +import { withSkills } from '../skills/withSkills'; import { nanoid } from 'nanoid'; import { LLMProvider, Message, ToolCall, GenerateOptions, GenerateResult, ToolDefinition } from '../providers'; import { AgentConfig } from '../types'; @@ -103,6 +105,16 @@ export interface ExecuteOptions { input: string | Message[]; provider: LLMProvider; toolRegistry?: ToolRegistry; + /** + * Skills (LOU-Y2): instructions the model loads on demand. Their names and + * descriptions are appended to the system prompt and a `load_skill` tool is + * registered; bodies only enter the conversation when the model loads them. + * Throws if a tool named `load_skill` is already registered. + * + * @example + * AgentExecutor.execute({ agent, input, provider, skills: [defineSkill({ name, description, content })] }); + */ + skills?: readonly Skill[]; streaming?: boolean; maxSteps?: number; temperature?: number; @@ -331,7 +343,11 @@ export class AgentExecutor { exporter, 'agent.run', { input: typeof input === 'string' ? input : JSON.stringify(input) }, - async (agentSpan) => this.runAgentLoop(options, agentSpan.id), + async (agentSpan) => + this.runAgentLoop( + { ...options, ...withSkills(options.agent, options.toolRegistry, options.skills) }, + agentSpan.id + ), undefined ); } diff --git a/src/index.ts b/src/index.ts index 68fdad37e..18e5470a1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -50,6 +50,9 @@ export * from './utils'; // createAgent() convenience API (LOU-H1) export * from './createAgent'; +// Skills: progressive disclosure of instructions (LOU-Y2) +export * from './skills'; + // Declarative agent spec file format (LOU-H9) export * from './spec'; diff --git a/src/skills/defineSkill.ts b/src/skills/defineSkill.ts new file mode 100644 index 000000000..fcb525840 --- /dev/null +++ b/src/skills/defineSkill.ts @@ -0,0 +1,80 @@ +/** Skill names are file/tool friendly: lowercase, digits, `-` and `_`, 1-64 characters. */ +const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-_]{0,63}$/; + +/** Options accepted by {@link defineSkill}. */ +export interface DefineSkillOptions { + /** Name the model loads the skill by. Must match `^[a-z0-9][a-z0-9-_]{0,63}$`. */ + name: string; + /** One line telling the model when the skill applies. Always in the system prompt. */ + description: string; + /** The full instructions (markdown). Only sent to the model once it calls `load_skill`. */ + content: string; +} + +/** + * A named bundle of instructions the model loads on demand. Only `name` and + * `description` sit in the system prompt; `content` is returned by the + * auto-registered `load_skill` tool. Create one with {@link defineSkill} or + * {@link loadSkills}. + */ +export interface Skill { + readonly name: string; + readonly description: string; + readonly content: string; +} + +function fail(problem: string, fix: string): never { + throw new Error(`defineSkill: ${problem}. ${fix}`); +} + +function assertNonEmpty(name: string, field: 'description' | 'content', value: unknown): void { + if (typeof value === 'string' && value.trim() !== '') return; + const hint = + field === 'description' + ? 'a one-line summary the model uses to decide when to load the skill' + : 'the markdown instructions the model receives when it loads the skill'; + fail( + `skill '${name}' is missing a non-empty '${field}' (${hint})`, + `Example: defineSkill({ name: '${name}', description: 'When to use it', content: '# Steps...' })` + ); +} + +function assertName(name: unknown): asserts name is string { + if (typeof name !== 'string' || name === '') { + fail("'name' is required", "Example: defineSkill({ name: 'changelog', ... })"); + } + if (!SKILL_NAME_PATTERN.test(name)) { + const suggestion = + name + .toLowerCase() + .replace(/[^a-z0-9-_]+/g, '-') + .replace(/^[-_]+/, '') + .slice(0, 64) || 'my-skill'; + fail( + `invalid skill name ${JSON.stringify(name)} (use 1-64 characters: lowercase a-z, 0-9, '-' and '_', starting with a letter or digit)`, + `Rename it, e.g. ${JSON.stringify(suggestion)}` + ); + } +} + +/** + * Define a skill: instructions the model pulls in only when it needs them + * (progressive disclosure). Pass skills to `createAgent({ skills })` or + * `AgentExecutor.execute({ skills })`. + * + * @example + * ```ts + * const changelog = defineSkill({ + * name: 'changelog', + * description: 'How to write a changelog entry', + * content: '# Changelog entries\n\nUse the imperative mood and link the PR.', + * }); + * ``` + */ +export function defineSkill(opts: DefineSkillOptions): Skill { + const { name, description, content } = opts ?? ({} as Partial); + assertName(name); + assertNonEmpty(name, 'description', description); + assertNonEmpty(name, 'content', content); + return Object.freeze({ name, description: description.trim(), content }); +} diff --git a/src/skills/index.ts b/src/skills/index.ts new file mode 100644 index 000000000..f0119db9c --- /dev/null +++ b/src/skills/index.ts @@ -0,0 +1,3 @@ +export { defineSkill } from './defineSkill'; +export type { DefineSkillOptions, Skill } from './defineSkill'; +export { loadSkills } from './loadSkills'; diff --git a/src/skills/loadSkills.ts b/src/skills/loadSkills.ts new file mode 100644 index 000000000..b127c8eed --- /dev/null +++ b/src/skills/loadSkills.ts @@ -0,0 +1,125 @@ +import { promises as fs, type Dirent } from 'node:fs'; +import path from 'node:path'; +import { parse as parseYaml } from 'yaml'; +import { defineSkill, type Skill } from './defineSkill'; + +const FRONTMATTER = /^?---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/; + +interface SkillSource { + /** Skill name implied by the folder / file name. */ + defaultName: string; + file: string; +} + +function problem(file: string, message: string): Error { + return new Error(`loadSkills: ${file}: ${message}`); +} + +function parseFrontmatter(file: string, raw: string): { meta: Record; body: string } { + const match = FRONTMATTER.exec(raw); + if (!match) return { meta: {}, body: raw }; + let meta: unknown; + try { + meta = parseYaml(match[1]); + } catch (error) { + throw problem( + file, + `invalid YAML frontmatter (${(error as Error).message}). Fix the YAML between the leading '---' lines.` + ); + } + if (meta !== null && meta !== undefined && (typeof meta !== 'object' || Array.isArray(meta))) { + throw problem(file, "frontmatter must be a YAML mapping such as 'description: ...'."); + } + return { meta: (meta ?? {}) as Record, body: raw.slice(match[0].length) }; +} + +async function readSkill(source: SkillSource): Promise { + let raw: string; + try { + raw = await fs.readFile(source.file, 'utf8'); + } catch (error) { + throw problem(source.file, `cannot read file (${(error as Error).message})`); + } + const { meta, body } = parseFrontmatter(source.file, raw); + if (typeof meta.description !== 'string' || meta.description.trim() === '') { + throw problem( + source.file, + "missing 'description' in the frontmatter. Add a block at the top of the file:\n---\ndescription: When the model should use this skill\n---" + ); + } + const name = typeof meta.name === 'string' && meta.name !== '' ? meta.name : source.defaultName; + try { + return defineSkill({ name, description: meta.description, content: body.trim() }); + } catch (error) { + throw problem(source.file, (error as Error).message.replace(/^defineSkill: /, '')); + } +} + +async function isFile(file: string): Promise { + return fs.stat(file).then( + (s) => s.isFile(), + () => false + ); +} + +async function sourceOf(dir: string, entry: Dirent): Promise { + if (entry.isDirectory()) { + const file = path.join(dir, entry.name, 'SKILL.md'); + return (await isFile(file)) ? { defaultName: entry.name, file } : undefined; + } + if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) { + return { defaultName: entry.name.slice(0, -'.md'.length), file: path.join(dir, entry.name) }; + } + return undefined; +} + +async function listSources(dir: string): Promise { + let entries: Dirent[]; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch (error) { + throw new Error( + `loadSkills: cannot read skills directory '${dir}' (${(error as Error).message}). ` + + `Check the path, or create it with a skill inside, e.g. ${path.join(dir, 'my-skill', 'SKILL.md')}.` + ); + } + const sources = await Promise.all(entries.map((entry) => sourceOf(dir, entry))); + return sources.filter((s): s is SkillSource => s !== undefined); +} + +/** + * Load skills from a directory. Two layouts are understood and can be mixed: + * + * - `dir//SKILL.md` (Anthropic / open-harness convention) + * - `dir/.md` (eve convention) + * + * Each file has YAML frontmatter with a required `description` and an + * optional `name` (defaults to the folder / file name); the markdown body is + * the skill's content. Folders without a `SKILL.md` are ignored. The result is + * sorted by name. Errors name the offending file. + * + * @example + * ```ts + * const agent = createAgent({ + * prompt: 'You are a release engineer.', + * provider, + * skills: await loadSkills('./skills'), + * }); + * ``` + */ +export async function loadSkills(dir: string): Promise { + const sources = await listSources(dir); + const byName = new Map(); + for (const source of sources) { + const skill = await readSkill(source); + const earlier = byName.get(skill.name); + if (earlier) { + throw new Error( + `loadSkills: duplicate skill name '${skill.name}' in ${earlier.file} and ${source.file}. ` + + `Rename one file/folder or set a different 'name' in its frontmatter.` + ); + } + byName.set(skill.name, { skill, file: source.file }); + } + return [...byName.values()].map((v) => v.skill).sort((a, b) => (a.name < b.name ? -1 : 1)); +} diff --git a/src/skills/skills.test.ts b/src/skills/skills.test.ts new file mode 100644 index 000000000..40168171e --- /dev/null +++ b/src/skills/skills.test.ts @@ -0,0 +1,223 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { z } from 'zod'; +import { createAgent } from '../createAgent'; +import { AgentExecutor } from '../execution/AgentExecutor'; +import { AgentBuilder } from '../core/AgentBuilder'; +import { AgentType } from '../types'; +import { defineTool } from '../tools/defineTool'; +import { ToolRegistry } from '../tools/ToolRegistry'; +import { mockModel } from '../testing'; +import { defineSkill, loadSkills } from './index'; + +const changelog = defineSkill({ + name: 'changelog', + description: 'How to write a changelog entry', + content: '# Changelog\nSECRET-CHANGELOG-BODY: use the imperative mood.', +}); +const review = defineSkill({ + name: 'code_review', + description: 'How to review a pull request', + content: 'SECRET-REVIEW-BODY', +}); + +const systemOf = (call: { messages: readonly { role: string; content: unknown }[] }): string => + String(call.messages.find((m) => m.role === 'system')?.content); + +describe('defineSkill', () => { + it('returns the skill', () => { + expect(changelog).toEqual({ + name: 'changelog', + description: 'How to write a changelog entry', + content: '# Changelog\nSECRET-CHANGELOG-BODY: use the imperative mood.', + }); + }); + + it('rejects bad names with a suggested fix', () => { + expect(() => defineSkill({ name: 'My Skill!', description: 'd', content: 'c' })).toThrow( + /invalid skill name "My Skill!".*e\.g\. "my-skill-"/ + ); + expect(() => defineSkill({ name: '', description: 'd', content: 'c' })).toThrow(/'name' is required/); + expect(() => defineSkill({ name: 'a'.repeat(65), description: 'd', content: 'c' })).toThrow(/invalid skill name/); + expect(() => defineSkill({ name: '-x', description: 'd', content: 'c' })).toThrow(/invalid skill name/); + }); + + it('rejects empty description and content', () => { + expect(() => defineSkill({ name: 'x', description: ' ', content: 'c' })).toThrow( + /skill 'x' is missing a non-empty 'description'.*Example/ + ); + expect(() => defineSkill({ name: 'x', description: 'd', content: '' })).toThrow(/non-empty 'content'/); + expect(() => defineSkill(undefined as never)).toThrow(/'name' is required/); + }); +}); + +describe('skills wiring (createAgent)', () => { + it('lists skills by description without bodies, then returns the body via load_skill', async () => { + const model = mockModel([ + { toolCalls: [{ name: 'load_skill', args: { name: 'changelog' } }] }, + 'Done.', + ]); + const agent = createAgent({ + prompt: 'You are a release engineer.', + provider: model, + skills: [changelog, review], + }); + + const result = await agent.send('write a changelog'); + + expect(result.text).toBe('Done.'); + const first = model.calls[0]; + const system = systemOf(first); + expect(system).toContain('You are a release engineer.'); + expect(system).toContain('- changelog: How to write a changelog entry'); + expect(system).toContain('- code_review: How to review a pull request'); + expect(system).toContain('`load_skill`'); + expect(system).not.toContain('SECRET-CHANGELOG-BODY'); + expect(system).not.toContain('SECRET-REVIEW-BODY'); + expect(first.tools?.map((t) => t.function.name)).toEqual(['load_skill']); + + const second = model.calls[1]; + const toolMessage = second.messages.find((m) => m.role === 'tool'); + expect(toolMessage?.content).toContain('SECRET-CHANGELOG-BODY'); + expect(systemOf(second)).not.toContain('SECRET-CHANGELOG-BODY'); + expect(JSON.stringify(second.messages)).not.toContain('SECRET-REVIEW-BODY'); + }); + + it('returns a tool error listing valid names for an unknown skill', async () => { + const model = mockModel([ + { toolCalls: [{ name: 'load_skill', args: { name: 'nope' } }] }, + 'ok', + ]); + await createAgent({ prompt: 'p', provider: model, skills: [changelog, review] }).send('go'); + + const toolMessage = model.calls[1].messages.find((m) => m.role === 'tool'); + expect(toolMessage).toMatchObject({ isError: true, toolName: 'load_skill' }); + expect(toolMessage?.content).toContain("Unknown skill 'nope'"); + expect(toolMessage?.content).toContain('changelog, code_review'); + }); + + it('keeps the user tools alongside load_skill', async () => { + const ping = defineTool({ name: 'ping', description: 'Ping', input: z.object({}), execute: () => 'pong' }); + const model = mockModel(['hi']); + await createAgent({ prompt: 'p', provider: model, tools: [ping], skills: [changelog] }).send('x'); + expect(model.calls[0].tools?.map((t) => t.function.name).sort()).toEqual(['load_skill', 'ping']); + }); + + it('adds no skills block or tool when no skills are given', async () => { + const model = mockModel(['hi']); + await createAgent({ prompt: 'plain', provider: model, skills: [] }).send('x'); + expect(systemOf(model.calls[0])).toBe('plain'); + expect(model.calls[0].tools ?? []).toEqual([]); + }); + + it('throws when the user already registered load_skill', () => { + const own = defineTool({ name: 'load_skill', description: 'mine', input: z.object({}), execute: () => 1 }); + const agent = createAgent({ prompt: 'p', provider: mockModel(['x']), tools: [own], skills: [changelog] }); + return expect(agent.send('x')).rejects.toThrow(/'load_skill' is already registered/); + }); + + it('throws on duplicate skill names', async () => { + const agent = createAgent({ prompt: 'p', provider: mockModel(['x']), skills: [changelog, changelog] }); + await expect(agent.send('x')).rejects.toThrow(/duplicate skill name 'changelog'/); + }); +}); + +describe('skills wiring (AgentExecutor.execute)', () => { + it('supports the skills option without mutating the caller registry or agent', async () => { + const model = mockModel(['hi']); + const agent = AgentBuilder.create().setType(AgentType.SmartAssistant).setName('a').setPrompt('base').build(); + const registry = new ToolRegistry(); + + await AgentExecutor.execute({ agent, input: 'x', provider: model, toolRegistry: registry, skills: [review] }); + + expect(systemOf(model.calls[0])).toContain('- code_review: How to review a pull request'); + expect(agent.prompt).toBe('base'); + expect(registry.has('load_skill')).toBe(false); + }); + + it('uses the block alone when the agent has no prompt', async () => { + const model = mockModel(['hi']); + const agent = AgentBuilder.create().setType(AgentType.SmartAssistant).setName('a').build(); + await AgentExecutor.execute({ agent, input: 'x', provider: model, skills: [review] }); + expect(systemOf(model.calls[0]).startsWith('## Available skills')).toBe(true); + }); +}); + +describe('loadSkills', () => { + let dir: string; + const write = async (rel: string, content: string) => { + const file = path.join(dir, rel); + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, content); + return file; + }; + + beforeEach(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'skills-')); + }); + afterEach(async () => { + await fs.rm(dir, { recursive: true, force: true }); + }); + + it('loads both layouts sorted by name, defaulting names from folder/file', async () => { + await write('zeta/SKILL.md', '---\ndescription: Zeta skill\n---\n\nZeta body\n'); + await write('alpha.md', '---\ndescription: Alpha skill\n---\nAlpha body'); + await write('mid/SKILL.md', '---\r\nname: renamed\r\ndescription: "Mid: skill"\r\n---\r\nMid body'); + await write('notes/README.txt', 'not a skill'); + await write('empty-dir/.gitkeep', ''); + + const skills = await loadSkills(dir); + + expect(skills).toEqual([ + { name: 'alpha', description: 'Alpha skill', content: 'Alpha body' }, + { name: 'renamed', description: 'Mid: skill', content: 'Mid body' }, + { name: 'zeta', description: 'Zeta skill', content: 'Zeta body' }, + ]); + }); + + it('names the file when the description is missing', async () => { + const file = await write('a.md', '# no frontmatter at all'); + await expect(loadSkills(dir)).rejects.toThrow( + new RegExp(`${file.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&')}: missing 'description'`) + ); + await write('a.md', '---\nname: a\n---\nbody'); + await expect(loadSkills(dir)).rejects.toThrow(/missing 'description'/); + }); + + it('names the file for invalid YAML, non-mapping frontmatter and invalid names', async () => { + await write('a.md', '---\ndescription: [unclosed\n---\nbody'); + await expect(loadSkills(dir)).rejects.toThrow(/a\.md: invalid YAML frontmatter/); + await write('a.md', '---\n- a\n- b\n---\nbody'); + await expect(loadSkills(dir)).rejects.toThrow(/a\.md: frontmatter must be a YAML mapping/); + await write('a.md', '---\nname: Bad Name\ndescription: d\n---\nbody'); + await expect(loadSkills(dir)).rejects.toThrow(/a\.md: invalid skill name "Bad Name"/); + await write('a.md', '---\ndescription: d\n---\n'); + await expect(loadSkills(dir)).rejects.toThrow(/a\.md: skill 'a' is missing a non-empty 'content'/); + }); + + it('reports duplicate names with both paths', async () => { + const a = await write('dup/SKILL.md', '---\ndescription: one\n---\nA'); + const b = await write('dup2.md', '---\nname: dup\ndescription: two\n---\nB'); + const error = await loadSkills(dir).then( + () => undefined, + (e: Error) => e + ); + expect(error?.message).toMatch(/duplicate skill name 'dup'/); + expect(error?.message).toContain(a); + expect(error?.message).toContain(b); + }); + + it('reports an unreadable directory', async () => { + await expect(loadSkills(path.join(dir, 'missing'))).rejects.toThrow(/cannot read skills directory/); + }); + + it('loaded skills work end to end', async () => { + await write('release/SKILL.md', '---\ndescription: Cut a release\n---\nRELEASE-BODY'); + const model = mockModel([{ toolCalls: [{ name: 'load_skill', args: { name: 'release' } }] }, 'ok']); + await createAgent({ prompt: 'p', provider: model, skills: await loadSkills(dir) }).send('go'); + expect(systemOf(model.calls[0])).toContain('- release: Cut a release'); + expect(model.calls[1].messages.find((m) => m.role === 'tool')?.content).toContain('RELEASE-BODY'); + }); +}); diff --git a/src/skills/withSkills.ts b/src/skills/withSkills.ts new file mode 100644 index 000000000..1b62e0b2d --- /dev/null +++ b/src/skills/withSkills.ts @@ -0,0 +1,82 @@ +import { z } from 'zod'; +import type { AgentConfig } from '../types'; +import { defineTool } from '../tools/defineTool'; +import { ToolRegistry } from '../tools/ToolRegistry'; +import type { Skill } from './defineSkill'; + +/** Name of the tool the model uses to load a skill's full content. */ +const LOAD_SKILL_TOOL = 'load_skill'; + +function skillsPromptBlock(skills: readonly Skill[]): string { + const lines = skills.map((s) => `- ${s.name}: ${s.description.replace(/\s+/g, ' ').trim()}`); + return [ + '## Available skills', + '', + `Before doing a task that one of these skills covers, call the \`${LOAD_SKILL_TOOL}\` tool with its name to read the full instructions.`, + '', + ...lines, + ].join('\n'); +} + +function createLoadSkillTool(skills: readonly Skill[]) { + const byName = new Map(skills.map((s) => [s.name, s])); + return defineTool({ + name: LOAD_SKILL_TOOL, + description: + 'Load the full instructions of a skill listed under "Available skills". Call this before doing a task the skill covers.', + input: z.object({ name: z.string().describe('The skill name, exactly as listed') }), + execute: ({ name }) => { + const skill = byName.get(name); + if (!skill) { + throw new Error(`Unknown skill '${name}'. Valid skills: ${[...byName.keys()].join(', ')}.`); + } + return skill.content; + }, + }); +} + +function assertUsable(skills: readonly Skill[], agent: AgentConfig, registry?: ToolRegistry): void { + if (registry?.has(LOAD_SKILL_TOOL) || agent.tools?.[LOAD_SKILL_TOOL]) { + throw new Error( + `skills: a tool named '${LOAD_SKILL_TOOL}' is already registered, but agents with skills get one automatically. ` + + `Rename your tool, or remove the 'skills' option.` + ); + } + const seen = new Set(); + for (const { name } of skills) { + if (seen.has(name)) { + throw new Error(`skills: duplicate skill name '${name}'. Skill names must be unique; rename one.`); + } + seen.add(name); + } +} + +/** + * Applies skills to an agent run: appends the "Available skills" block to the + * system prompt and adds a `load_skill` tool. Inputs are not mutated; with no + * skills they are returned as is. + */ +export function withSkills( + agent: AgentConfig, + toolRegistry: ToolRegistry | undefined, + skills: readonly Skill[] | undefined +): { agent: AgentConfig; toolRegistry: ToolRegistry | undefined } { + if (!skills || skills.length === 0) return { agent, toolRegistry }; + assertUsable(skills, agent, toolRegistry); + + const registry = new ToolRegistry(); + for (const [name, descriptor] of Object.entries(toolRegistry?.getAll() ?? {})) { + registry.register(name, descriptor); + } + registry.register(createLoadSkillTool(skills)); + + const block = skillsPromptBlock(skills); + return { + agent: { + ...agent, + prompt: agent.prompt ? `${agent.prompt}\n\n${block}` : block, + tools: { ...agent.tools, [LOAD_SKILL_TOOL]: { tool: LOAD_SKILL_TOOL } }, + }, + toolRegistry: registry, + }; +}