Skip to content
Merged
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/)

Expand Down
7 changes: 7 additions & 0 deletions docs/api-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 })`,
Expand Down
106 changes: 106 additions & 0 deletions docs/skills.md
Original file line number Diff line number Diff line change
@@ -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 # <name>/SKILL.md (Anthropic / open-harness convention)
code-review.md # <name>.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.
11 changes: 11 additions & 0 deletions src/createAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -41,6 +42,15 @@ export interface CreateAgentConfig {
* createAgent({ prompt: '...', provider, tools: [sendEmail] });
*/
tools?: readonly DefinedTool[] | Record<string, ToolDescriptor>;
/**
* 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(). */
Expand Down Expand Up @@ -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,
});
Expand Down
18 changes: 17 additions & 1 deletion src/execution/AgentExecutor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
);
}
Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
80 changes: 80 additions & 0 deletions src/skills/defineSkill.ts
Original file line number Diff line number Diff line change
@@ -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<DefineSkillOptions>);
assertName(name);
assertNonEmpty(name, 'description', description);
assertNonEmpty(name, 'content', content);
return Object.freeze({ name, description: description.trim(), content });
}
3 changes: 3 additions & 0 deletions src/skills/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { defineSkill } from './defineSkill';
export type { DefineSkillOptions, Skill } from './defineSkill';
export { loadSkills } from './loadSkills';
Loading
Loading