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
14 changes: 14 additions & 0 deletions .changeset/ai-skills-initial.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@gemstack/ai-skills": minor
---

Initial release. Portable capability bundles for `@gemstack/ai-sdk` agents — load `SKILL.md` skills (instructions + tools + resources) and compose them onto an `Agent`:

- `parseSkillManifest` — parse `SKILL.md` YAML frontmatter + markdown body (matches the `boost/skills` / Anthropic Agent Skills shape).
- `loadSkill` / `loadSkills` — load a skill directory: instructions, co-located `tool()` exports, and `resources/`.
- `SkillRegistry` — discover skills by their cheap frontmatter and load the full body + tools on demand (progressive disclosure).
- `composeInstructions` / `composeTools` / `composeMiddleware` — merge skills into an agent; the agent's own declarations stay authoritative (own tools win name collisions, skill tools are namespaced as a backstop).
- `SkillfulAgent` — an `Agent` base that composes `skills()` declaratively alongside `baseInstructions()` / `baseTools()`.
- `surface` — inspect a skill's instructions/tools/resources before composing it.

Explicit trust boundary (no in-process sandbox): discovery reads only frontmatter, `loadTools: false` loads without running the tools module, and skill tools flow through the agent's existing approval/middleware path. Depends on `@gemstack/ai-sdk`.
116 changes: 116 additions & 0 deletions packages/ai-skills/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# @gemstack/ai-skills

Portable capability bundles for [`@gemstack/ai-sdk`](https://github.com/gemstack-land/gemstack/tree/main/packages/ai-sdk) agents. A **skill** is a shippable folder — instructions + tools + resources — that you compose onto an `Agent` on demand. This mirrors the [Anthropic Agent Skills](https://www.anthropic.com/news/agent-skills) shape: a skill authored for Claude loads here, and a gemstack skill ships as a plain folder.

```
my-skill/
SKILL.md # YAML frontmatter (name, description, trigger, ...) + markdown instructions
tools.ts # optional: exports ai-sdk tool() objects
resources/ # optional: reference files
```

## Installation

```bash
pnpm add @gemstack/ai-skills @gemstack/ai-sdk
```

## The skill manifest

`SKILL.md` is markdown with a YAML frontmatter block — the same convention `@gemstack/ai-sdk` ships in `boost/skills`:

```markdown
---
name: refunds
description: Issue and look up customer refunds
trigger: handling a refund request or refund status question
metadata:
author: acme
---

# Refunds

When a customer asks for a refund, look up the order first, then issue the
refund with the `issue_refund` tool. Never refund more than the order total.
```

A co-located `tools.ts` exports the skill's tools as plain `@gemstack/ai-sdk` `tool()` objects — one tool API across the framework:

```ts
import { toolDefinition } from '@gemstack/ai-sdk'
import { z } from 'zod'

export const issueRefund = toolDefinition({
name: 'issue_refund',
description: 'Issue a refund for an order',
inputSchema: z.object({ orderId: z.string(), amount: z.number() }),
}).server(async ({ orderId, amount }) => {
return await refunds.create(orderId, amount)
})
```

## Composing skills onto an agent

The ergonomic path is `SkillfulAgent`. You declare your base identity and own tools, and list the skills — skills augment, your own declarations win:

```ts
import { loadSkill, SkillfulAgent } from '@gemstack/ai-skills'

const refunds = await loadSkill('./skills/refunds')

class SupportAgent extends SkillfulAgent {
baseInstructions() { return 'You are a friendly support agent.' }
skills() { return [refunds] }
baseTools() { return [escalateTool] } // wins over a same-named skill tool
}

const reply = await new SupportAgent().prompt('I want a refund for order #123')
```

> Override the `base*` hooks, **not** `instructions()` / `tools()` / `middleware()` — those are sealed finals that merge your declarations with the skills.

### Low-level composition

If you can't extend `SkillfulAgent` (e.g. you use the anonymous `agent()` factory, or already extend another base), the same merge is available as plain functions:

```ts
import { Agent } from '@gemstack/ai-sdk'
import { composeInstructions, composeTools } from '@gemstack/ai-skills'

const skills = [refunds]

class SupportAgent extends Agent {
instructions() { return composeInstructions('You are a support agent.', skills) }
tools() { return composeTools([escalateTool], skills) }
}
```

`SkillfulAgent` is sugar over these.

## Discovery (progressive disclosure)

`SkillRegistry` indexes skills by their cheap frontmatter and loads a skill's full body + tools only when you ask for it — so you can index hundreds of skills and pay for only the ones you compose:

```ts
import { SkillRegistry } from '@gemstack/ai-skills'

const registry = new SkillRegistry()
await registry.discover('./skills') // reads frontmatter only, runs no skill code
registry.list() // [{ manifest, dir }, ...]

const refunds = await registry.load('refunds') // now imports tools.ts
```

## Trust model

A skill is code you install or author, like a Vite or ESLint plugin: **loading it runs its code** (the tools module). There is no in-process sandbox — Node's `vm` is not a security boundary. The package keeps the boundary honest instead of pretending to enforce it:

- **No auto-loading of untrusted directories.** You pass explicit paths to `loadSkill` / `discover`; nothing is scanned implicitly.
- **Surface before compose.** `discover()` reads only frontmatter (no code runs). `loadSkill(dir, { loadTools: false })` loads instructions + resources without importing the tools module. `surface(skill)` reports a skill's instructions size, tool names, and resources so you can inspect before attaching.
- **The risky moment stays gated.** Skill tools are ordinary `ai-sdk` tools, so tool execution still flows through the agent's existing approval / middleware flow.

If you need real isolation, run the app under OS/container isolation. Only load skills from sources you trust.

## License

MIT
58 changes: 58 additions & 0 deletions packages/ai-skills/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
{
"name": "@gemstack/ai-skills",
"version": "0.0.0",
"description": "Portable capability bundles for @gemstack/ai-sdk agents: load SKILL.md skills (instructions + tools + resources) and compose them onto an Agent.",
"keywords": [
"ai",
"agent",
"agents",
"skills",
"agent-skills",
"capabilities",
"tools",
"skill-md",
"gemstack"
],
"license": "MIT",
"homepage": "https://github.com/gemstack-land/gemstack/tree/main/packages/ai-skills#readme",
"bugs": {
"url": "https://github.com/gemstack-land/gemstack/issues"
},
"repository": {
"type": "git",
"url": "https://github.com/gemstack-land/gemstack",
"directory": "packages/ai-skills"
},
"type": "module",
"engines": {
"node": ">=22.12.0"
},
"files": [
"dist"
],
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "tsc -p tsconfig.build.json",
"dev": "tsc -p tsconfig.build.json --watch",
"typecheck": "tsc --noEmit",
"test": "tsc -p tsconfig.test.json && cd dist-test && node --test",
"clean": "rm -rf dist"
},
"dependencies": {
"@gemstack/ai-sdk": "workspace:^",
"yaml": "^2.5.0",
"zod": "^4.0.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.4.0"
},
"author": "Suleiman Shahbari"
}
109 changes: 109 additions & 0 deletions packages/ai-skills/src/compose.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { toolDefinition } from '@gemstack/ai-sdk'
import type { AiMiddleware } from '@gemstack/ai-sdk'
import { z } from 'zod'
import { composeInstructions, composeTools, composeMiddleware, surface } from './compose.js'
import type { LoadedSkill } from './types.js'

function tool(name: string, description = name) {
return toolDefinition({
name,
description,
inputSchema: z.object({ x: z.string() }),
}).server(async () => `${name}-result`)
}

function skill(name: string, instructions: string, tools: ReturnType<typeof tool>[] = [], extra: Partial<LoadedSkill> = {}): LoadedSkill {
return {
manifest: { name, description: `${name} skill` },
instructions,
tools,
resources: [],
...extra,
}
}

describe('composeInstructions', () => {
it('puts the agent base first and appends each skill under a header', () => {
const out = composeInstructions('You are an agent.', [
skill('alpha', 'Do alpha things.'),
skill('beta', 'Do beta things.'),
])
assert.ok(out.startsWith('You are an agent.'))
assert.ok(out.indexOf('# Skill: alpha') < out.indexOf('# Skill: beta'))
assert.ok(out.includes('Do alpha things.'))
assert.ok(out.includes('Do beta things.'))
})

it('omits skills with an empty instructions body', () => {
const out = composeInstructions('Base.', [skill('toolsonly', ' ')])
assert.equal(out, 'Base.')
})
})

describe('composeTools', () => {
it('keeps the agent own tools first and unchanged', () => {
const own = [tool('escalate')]
const out = composeTools(own, [skill('s', '', [tool('lookup')])])
assert.deepEqual(out.map(t => t.definition.name), ['escalate', 'lookup'])
})

it('namespaces a colliding skill tool instead of dropping it; the own tool wins', () => {
const own = [tool('search', 'agent search')]
const out = composeTools(own, [skill('docs', '', [tool('search', 'skill search')])])
assert.equal(out.length, 2)
// own tool retains its name + description (authoritative)
const ownTool = out.find(t => t.definition.name === 'search')!
assert.equal(ownTool.definition.description, 'agent search')
// skill tool survives, namespaced
const renamed = out.find(t => t.definition.name === 'docs__search')!
assert.equal(renamed.definition.description, 'skill search')
})

it('namespaces collisions between two skills (second skill yields)', () => {
const out = composeTools([], [
skill('a', '', [tool('run')]),
skill('b', '', [tool('run')]),
])
assert.deepEqual(out.map(t => t.definition.name).sort(), ['b__run', 'run'])
})

it('preserves a renamed tool execute fn', async () => {
const out = composeTools([tool('dup')], [skill('s', '', [tool('dup')])])
const renamed = out.find(t => t.definition.name === 's__dup')!
const result = await renamed.execute!({ x: '' }, undefined as never)
assert.equal(result, 'dup-result')
})
})

describe('composeMiddleware', () => {
it('runs agent middleware before skill middleware', () => {
const mwOwn: AiMiddleware = { name: 'own' }
const mwSkill: AiMiddleware = { name: 'skill' }
const out = composeMiddleware([mwOwn], [skill('s', '', [], { middleware: [mwSkill] })])
assert.deepEqual(out.map(m => m.name), ['own', 'skill'])
})

it('returns a copy when no skill contributes middleware', () => {
const own = [{ name: 'own' } as AiMiddleware]
const out = composeMiddleware(own, [skill('s', 'x')])
assert.deepEqual(out.map(m => m.name), ['own'])
assert.notEqual(out, own)
})
})

describe('surface', () => {
it('summarizes a skill without composing it', () => {
const s = skill('refunds', 'Refund instructions.', [tool('issue_refund')], {
manifest: { name: 'refunds', description: 'Refunds', trigger: 'a refund request' },
resources: [{ name: 'policy.md', path: '/x/policy.md' }],
})
const summary = surface(s)
assert.equal(summary.name, 'refunds')
assert.equal(summary.trigger, 'a refund request')
assert.deepEqual(summary.toolNames, ['issue_refund'])
assert.deepEqual(summary.resourceNames, ['policy.md'])
assert.equal(summary.instructionChars, 'Refund instructions.'.length)
})
})
92 changes: 92 additions & 0 deletions packages/ai-skills/src/compose.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import type { AnyTool, AiMiddleware } from '@gemstack/ai-sdk'
import type { LoadedSkill, SkillSurface } from './types.js'

/**
* Compose an agent's base identity with the instructions from its skills.
*
* The agent's own `base` comes first and is authoritative — it is the identity
* the rest layers under. Each skill's body follows in declaration order under a
* `# Skill: <name>` header so the model can tell capabilities apart. Skills with
* an empty body contribute tools/resources only and add nothing here.
*/
export function composeInstructions(base: string, skills: LoadedSkill[]): string {
const blocks = skills
.filter(s => s.instructions.trim().length > 0)
.map(s => `# Skill: ${s.manifest.name}\n\n${s.instructions.trim()}`)
return [base.trim(), ...blocks].filter(s => s.length > 0).join('\n\n')
}

/**
* Union an agent's own tools with the tools from its skills.
*
* Precedence is unambiguous: the agent's own tools come first and win every
* name collision. A skill tool whose name is already taken (by an own tool or an
* earlier skill) is kept but **namespaced** as `<skill>__<tool>` so nothing is
* silently dropped — the loader's namespacing is the backstop the agent's
* authority rests on.
*/
export function composeTools(own: AnyTool[], skills: LoadedSkill[]): AnyTool[] {
const out: AnyTool[] = [...own]
const used = new Set(own.map(t => t.definition.name))

for (const skill of skills) {
for (const tool of skill.tools) {
const name = tool.definition.name
if (!used.has(name)) {
used.add(name)
out.push(tool)
continue
}
// Collision — namespace it rather than drop it.
let candidate = `${sanitize(skill.manifest.name)}__${name}`
let n = 2
while (used.has(candidate)) candidate = `${sanitize(skill.manifest.name)}__${name}__${n++}`
used.add(candidate)
out.push(renameTool(tool, candidate))
}
}
return out
}

/**
* Append the middleware contributed by skills to the agent's own middleware.
* Agent middleware runs first. Most skills contribute none.
*/
export function composeMiddleware(own: AiMiddleware[], skills: LoadedSkill[]): AiMiddleware[] {
const skillMw = skills.flatMap(s => s.middleware ?? [])
return skillMw.length > 0 ? [...own, ...skillMw] : [...own]
}

/**
* Summarize what a skill would add to an agent — instructions size, tool names,
* resource names — without composing it. Use this to report a skill's surface
* before attaching it (the "surface-before-compose" half of the trust model).
*/
export function surface(skill: LoadedSkill): SkillSurface {
const s: SkillSurface = {
name: skill.manifest.name,
description: skill.manifest.description,
instructionChars: skill.instructions.length,
toolNames: skill.tools.map(t => t.definition.name),
resourceNames: skill.resources.map(r => r.name),
}
if (skill.manifest.trigger !== undefined) s.trigger = skill.manifest.trigger
return s
}

/** Surface a list of skills. */
export function surfaceAll(skills: LoadedSkill[]): SkillSurface[] {
return skills.map(surface)
}

// ─── Internals ───────────────────────────────────────────────────

/** Constrain a name to the `[a-zA-Z0-9_-]` set providers accept for tool names. */
function sanitize(name: string): string {
return name.replace(/[^a-zA-Z0-9_-]/g, '_')
}

/** Clone a tool with a new advertised name, preserving execute + modelOutput. */
function renameTool(tool: AnyTool, name: string): AnyTool {
return { ...tool, definition: { ...tool.definition, name } }
}
Loading
Loading