feat(sdk): add skills.define + chat.skills runtime wiring
skills.define({ id, path }) registers a skill with the resource
catalog and returns a SkillHandle. SkillHandle.local() reads the
bundled SKILL.md from ./.trigger/skills/{id}/ at runtime, parses
frontmatter, and returns a ResolvedSkill ready for chat.skills.set().
chat.skills.set([...]) stores resolved skills for the current run.
chat.toStreamTextOptions() auto-injects the skills preamble into the
system prompt and merges three tools — loadSkill, readFile, bash —
scoped per-skill with path-traversal guards and output caps (64 KB
stdout/stderr, 1 MB readFile). Bash executes in the worker container
with the turn's abort signal, no sandbox — skills are developer code.
Shared packages/build/src/internal/copyFiles.ts extracted from the
additionalFiles extension so the CLI's built-in skill bundler and the
existing extension share one glob + copy implementation.
Part 2/3 of Phase 1 for the new ai.skills primitive.
This commit is contained in:
@@ -1 +1,2 @@
|
||||
export * from "./internal/additionalFiles.js";
|
||||
export * from "./internal/copyFiles.js";
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { BuildManifest } from "@trigger.dev/core/v3";
|
||||
import { BuildContext } from "@trigger.dev/core/v3/build";
|
||||
import { copyFile, mkdir } from "node:fs/promises";
|
||||
import { dirname, join, posix, relative } from "node:path";
|
||||
import { glob } from "tinyglobby";
|
||||
import {
|
||||
copyMatcherResults,
|
||||
findFilesByMatchers,
|
||||
type MatcherResult,
|
||||
} from "./copyFiles.js";
|
||||
|
||||
export type AdditionalFilesOptions = {
|
||||
files: string[];
|
||||
@@ -14,12 +16,13 @@ export async function addAdditionalFilesToBuild(
|
||||
context: BuildContext,
|
||||
manifest: BuildManifest
|
||||
) {
|
||||
// Copy any static assets to the destination
|
||||
const staticAssets = await findStaticAssetFiles(options.files ?? [], manifest.outputPath, {
|
||||
cwd: context.workingDir,
|
||||
});
|
||||
const matcherResults: MatcherResult[] = await findFilesByMatchers(
|
||||
options.files ?? [],
|
||||
manifest.outputPath,
|
||||
{ cwd: context.workingDir }
|
||||
);
|
||||
|
||||
for (const { assets, matcher } of staticAssets) {
|
||||
for (const { assets, matcher } of matcherResults) {
|
||||
if (assets.length === 0) {
|
||||
context.logger.warn(`[${source}] No files found for matcher`, matcher);
|
||||
} else {
|
||||
@@ -27,80 +30,7 @@ export async function addAdditionalFilesToBuild(
|
||||
}
|
||||
}
|
||||
|
||||
await copyStaticAssets(staticAssets, source, context);
|
||||
}
|
||||
|
||||
type MatchedStaticAssets = { source: string; destination: string }[];
|
||||
|
||||
type FoundStaticAssetFiles = Array<{
|
||||
matcher: string;
|
||||
assets: MatchedStaticAssets;
|
||||
}>;
|
||||
|
||||
async function findStaticAssetFiles(
|
||||
matchers: string[],
|
||||
destinationPath: string,
|
||||
options?: { cwd?: string; ignore?: string[] }
|
||||
): Promise<FoundStaticAssetFiles> {
|
||||
const result: FoundStaticAssetFiles = [];
|
||||
|
||||
for (const matcher of matchers) {
|
||||
const assets = await findStaticAssetsForMatcher(matcher, destinationPath, options);
|
||||
|
||||
result.push({ matcher, assets });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function findStaticAssetsForMatcher(
|
||||
matcher: string,
|
||||
destinationPath: string,
|
||||
options?: { cwd?: string; ignore?: string[] }
|
||||
): Promise<MatchedStaticAssets> {
|
||||
const result: MatchedStaticAssets = [];
|
||||
|
||||
const files = await glob({
|
||||
patterns: [matcher],
|
||||
cwd: options?.cwd,
|
||||
ignore: options?.ignore ?? [],
|
||||
onlyFiles: true,
|
||||
absolute: true,
|
||||
await copyMatcherResults(matcherResults, (pair) => {
|
||||
context.logger.debug(`[${source}] Copying ${pair.source} to ${pair.destination}`);
|
||||
});
|
||||
|
||||
let matches = 0;
|
||||
|
||||
for (const file of files) {
|
||||
matches++;
|
||||
|
||||
const pathInsideDestinationDir = relative(options?.cwd ?? process.cwd(), file)
|
||||
.split(posix.sep)
|
||||
.filter((p) => p !== "..")
|
||||
.join(posix.sep);
|
||||
|
||||
const relativeDestinationPath = join(destinationPath, pathInsideDestinationDir);
|
||||
|
||||
result.push({
|
||||
source: file,
|
||||
destination: relativeDestinationPath,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function copyStaticAssets(
|
||||
staticAssetFiles: FoundStaticAssetFiles,
|
||||
sourceName: string,
|
||||
context: BuildContext
|
||||
): Promise<void> {
|
||||
for (const { assets } of staticAssetFiles) {
|
||||
for (const { source, destination } of assets) {
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
|
||||
context.logger.debug(`[${sourceName}] Copying ${source} to ${destination}`);
|
||||
|
||||
await copyFile(source, destination);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { cp, copyFile, mkdir } from "node:fs/promises";
|
||||
import { dirname, join, posix, relative } from "node:path";
|
||||
import { glob } from "tinyglobby";
|
||||
|
||||
/**
|
||||
* A single matched asset — source file and its destination inside the
|
||||
* build output directory.
|
||||
*/
|
||||
export type CopyPair = { source: string; destination: string };
|
||||
|
||||
/**
|
||||
* Result of a single matcher's glob, grouped with the matcher that
|
||||
* produced it so callers can warn on empty matches.
|
||||
*/
|
||||
export type MatcherResult = {
|
||||
matcher: string;
|
||||
assets: CopyPair[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Glob a set of matchers relative to `cwd` and return pairs describing
|
||||
* where each matched file should be copied to under `destinationDir`.
|
||||
*
|
||||
* Relative paths are preserved under `destinationDir`. Leading `..`
|
||||
* segments (from `../shared/file.txt` style patterns) are stripped so
|
||||
* files always land inside the destination.
|
||||
*/
|
||||
export async function findFilesByMatchers(
|
||||
matchers: string[],
|
||||
destinationDir: string,
|
||||
options?: { cwd?: string; ignore?: string[] }
|
||||
): Promise<MatcherResult[]> {
|
||||
const result: MatcherResult[] = [];
|
||||
const cwd = options?.cwd ?? process.cwd();
|
||||
|
||||
for (const matcher of matchers) {
|
||||
const files = await glob({
|
||||
patterns: [matcher],
|
||||
cwd,
|
||||
ignore: options?.ignore ?? [],
|
||||
onlyFiles: true,
|
||||
absolute: true,
|
||||
});
|
||||
|
||||
const assets: CopyPair[] = files.map((file) => {
|
||||
const pathInsideDestinationDir = relative(cwd, file)
|
||||
.split(posix.sep)
|
||||
.filter((p) => p !== "..")
|
||||
.join(posix.sep);
|
||||
return {
|
||||
source: file,
|
||||
destination: join(destinationDir, pathInsideDestinationDir),
|
||||
};
|
||||
});
|
||||
|
||||
result.push({ matcher, assets });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a single file, creating parent directories as needed.
|
||||
*/
|
||||
export async function copyFileEnsuringDir(source: string, destination: string): Promise<void> {
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await copyFile(source, destination);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy every pair in the given matcher results. Parent directories are
|
||||
* created automatically. Returns the total number of files copied.
|
||||
*/
|
||||
export async function copyMatcherResults(
|
||||
matcherResults: MatcherResult[],
|
||||
onCopy?: (pair: CopyPair) => void
|
||||
): Promise<number> {
|
||||
let count = 0;
|
||||
for (const { assets } of matcherResults) {
|
||||
for (const pair of assets) {
|
||||
onCopy?.(pair);
|
||||
await copyFileEnsuringDir(pair.source, pair.destination);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively copy a directory to another location. Preserves structure;
|
||||
* overwrites existing files at the destination.
|
||||
*
|
||||
* Used by the built-in skill bundler — we copy entire skill folders as a
|
||||
* unit, not file-by-file.
|
||||
*/
|
||||
export async function copyDirectoryRecursive(source: string, destination: string): Promise<void> {
|
||||
await mkdir(destination, { recursive: true });
|
||||
await cp(source, destination, { recursive: true, force: true });
|
||||
}
|
||||
@@ -43,6 +43,10 @@ import { auth } from "./auth.js";
|
||||
import { locals } from "./locals.js";
|
||||
import { metadata } from "./metadata.js";
|
||||
import type { ResolvedPrompt } from "./prompt.js";
|
||||
import type { ResolvedSkill } from "./skill.js";
|
||||
import { spawn } from "node:child_process";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as nodePath from "node:path";
|
||||
import { streams } from "./streams.js";
|
||||
import { createTask, trigger as triggerTaskInternal } from "./shared.js";
|
||||
import { resourceCatalog } from "@trigger.dev/core/v3";
|
||||
@@ -1756,6 +1760,212 @@ function getChatPrompt(): ChatPromptValue {
|
||||
return prompt;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// chat.skills — store resolved agent skills and inject them into streamText
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** @internal */
|
||||
const chatSkillsKey = locals.create<ResolvedSkill[]>("chat.skills");
|
||||
|
||||
/** Limits applied by the auto-injected `loadSkill` / `readFile` / `bash` tools. */
|
||||
const DEFAULT_READ_FILE_BYTES = 1024 * 1024; // 1 MB
|
||||
const DEFAULT_BASH_OUTPUT_BYTES = 64 * 1024; // 64 KB
|
||||
|
||||
/**
|
||||
* Store resolved skills for the current run. Call from any hook
|
||||
* (`onPreload`, `onChatStart`, `onTurnStart`) or `run()`.
|
||||
*/
|
||||
function setChatSkills(skills: ResolvedSkill[]): void {
|
||||
locals.set(chatSkillsKey, skills);
|
||||
}
|
||||
|
||||
/** Read the stored skills. Returns `undefined` if none set. */
|
||||
function getChatSkills(): ResolvedSkill[] | undefined {
|
||||
return locals.get(chatSkillsKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the system-prompt preamble advertising available skills. Only the
|
||||
* frontmatter description surfaces here — full SKILL.md body is loaded
|
||||
* on-demand via the `loadSkill` tool.
|
||||
*/
|
||||
function buildSkillsSystemPrompt(skills: ResolvedSkill[]): string {
|
||||
if (skills.length === 0) return "";
|
||||
const lines = skills.map(
|
||||
(s) => `- ${s.frontmatter.name}: ${s.frontmatter.description}`
|
||||
);
|
||||
return [
|
||||
"Available skills (call `loadSkill` to read the full instructions before using one):",
|
||||
...lines,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function truncate(s: string, limit: number): string {
|
||||
if (s.length <= limit) return s;
|
||||
return s.slice(0, limit) + `\n…[truncated ${s.length - limit} bytes]`;
|
||||
}
|
||||
|
||||
/** Resolve a skill by its frontmatter `name`. */
|
||||
function findSkillByName(skills: ResolvedSkill[], name: string): ResolvedSkill | undefined {
|
||||
return skills.find((s) => s.frontmatter.name === name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that `candidate` resolves inside `root` — guards against path
|
||||
* traversal via `..` or absolute paths. Returns the resolved path or
|
||||
* throws.
|
||||
*/
|
||||
function safeJoinInside(root: string, relative: string): string {
|
||||
if (nodePath.isAbsolute(relative)) {
|
||||
throw new Error(`Path must be relative to the skill directory: ${relative}`);
|
||||
}
|
||||
const resolved = nodePath.resolve(root, relative);
|
||||
const normalized = nodePath.resolve(root) + nodePath.sep;
|
||||
if (resolved !== nodePath.resolve(root) && !resolved.startsWith(normalized)) {
|
||||
throw new Error(`Path escapes the skill directory: ${relative}`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the three tools we auto-inject into `streamText` when skills are
|
||||
* set: `loadSkill`, `readFile`, `bash`. Scoped per-skill by name.
|
||||
*
|
||||
* Exported so callers can use the same tools outside the auto-wired path
|
||||
* (e.g. in a `chat.createSession` loop with custom streamText).
|
||||
*/
|
||||
export function buildSkillTools(skills: ResolvedSkill[]): Record<string, Tool> {
|
||||
const loadSkill = aiTool({
|
||||
description:
|
||||
"Load the full instructions for a skill by its name. Call this first before using a skill.",
|
||||
inputSchema: jsonSchema<{ name: string }>({
|
||||
type: "object",
|
||||
properties: {
|
||||
name: {
|
||||
type: "string",
|
||||
description: "The `name` field from the skill's frontmatter.",
|
||||
},
|
||||
},
|
||||
required: ["name"],
|
||||
additionalProperties: false,
|
||||
} as JSONSchema7),
|
||||
execute: async ({ name }: { name: string }) => {
|
||||
const skill = findSkillByName(skills, name);
|
||||
if (!skill) {
|
||||
return {
|
||||
error: `Skill "${name}" not found. Available: ${skills
|
||||
.map((s) => s.frontmatter.name)
|
||||
.join(", ")}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: skill.frontmatter.name,
|
||||
description: skill.frontmatter.description,
|
||||
body: skill.body,
|
||||
path: skill.path,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const readFile = aiTool({
|
||||
description:
|
||||
"Read a file from a skill's bundled folder. Paths must be relative to the skill's root.",
|
||||
inputSchema: jsonSchema<{ skill: string; path: string }>({
|
||||
type: "object",
|
||||
properties: {
|
||||
skill: { type: "string", description: "The skill's name (from frontmatter)." },
|
||||
path: {
|
||||
type: "string",
|
||||
description: "Relative path inside the skill folder (e.g. `references/citation-style.md`).",
|
||||
},
|
||||
},
|
||||
required: ["skill", "path"],
|
||||
additionalProperties: false,
|
||||
} as JSONSchema7),
|
||||
execute: async ({ skill: skillName, path: relPath }: { skill: string; path: string }) => {
|
||||
const skill = findSkillByName(skills, skillName);
|
||||
if (!skill) {
|
||||
return { error: `Skill "${skillName}" not found.` };
|
||||
}
|
||||
let absolute: string;
|
||||
try {
|
||||
absolute = safeJoinInside(skill.path, relPath);
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message };
|
||||
}
|
||||
try {
|
||||
const content = await fs.readFile(absolute, "utf8");
|
||||
return { content: truncate(content, DEFAULT_READ_FILE_BYTES) };
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const bash = aiTool({
|
||||
description:
|
||||
"Run a bash command inside a skill's bundled folder. Use this to invoke the skill's scripts. The working directory is the skill's root.",
|
||||
inputSchema: jsonSchema<{ skill: string; command: string }>({
|
||||
type: "object",
|
||||
properties: {
|
||||
skill: { type: "string", description: "The skill's name (from frontmatter)." },
|
||||
command: {
|
||||
type: "string",
|
||||
description: "Bash command to run. Relative script paths resolve against the skill's root.",
|
||||
},
|
||||
},
|
||||
required: ["skill", "command"],
|
||||
additionalProperties: false,
|
||||
} as JSONSchema7),
|
||||
execute: async (
|
||||
{ skill: skillName, command }: { skill: string; command: string },
|
||||
{ abortSignal }: { abortSignal?: AbortSignal } = {}
|
||||
) => {
|
||||
const skill = findSkillByName(skills, skillName);
|
||||
if (!skill) {
|
||||
return { error: `Skill "${skillName}" not found.` };
|
||||
}
|
||||
return await new Promise<{
|
||||
exitCode: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
} | { error: string }>((resolvePromise) => {
|
||||
let child;
|
||||
try {
|
||||
child = spawn("bash", ["-c", command], {
|
||||
cwd: skill.path,
|
||||
signal: abortSignal,
|
||||
});
|
||||
} catch (err) {
|
||||
resolvePromise({ error: (err as Error).message });
|
||||
return;
|
||||
}
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout?.on("data", (chunk: Buffer | string) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr?.on("data", (chunk: Buffer | string) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.once("close", (code: number | null) => {
|
||||
resolvePromise({
|
||||
exitCode: code,
|
||||
stdout: truncate(stdout, DEFAULT_BASH_OUTPUT_BYTES),
|
||||
stderr: truncate(stderr, DEFAULT_BASH_OUTPUT_BYTES),
|
||||
});
|
||||
});
|
||||
child.once("error", (err: Error) => {
|
||||
resolvePromise({ error: err.message });
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return { loadSkill, readFile, bash };
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for {@link toStreamTextOptions}.
|
||||
*/
|
||||
@@ -1772,6 +1982,16 @@ export type ToStreamTextOptionsOptions = {
|
||||
* (e.g. `"openai:gpt-4o"`, `"anthropic:claude-sonnet-4-6"`).
|
||||
*/
|
||||
registry?: { languageModel(modelId: string): unknown };
|
||||
/**
|
||||
* User-defined tools to merge alongside the auto-injected skill tools
|
||||
* (`loadSkill`, `readFile`, `bash`). User tools win on name conflicts.
|
||||
*
|
||||
* If you don't pass `tools` here and skills are set, the returned options
|
||||
* will include just the skill tools — spread after any `tools` you pass
|
||||
* directly to `streamText` and they'll be replaced. Easiest: pass all
|
||||
* your tools here.
|
||||
*/
|
||||
tools?: Record<string, Tool>;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1787,12 +2007,18 @@ export type ToStreamTextOptionsOptions = {
|
||||
*/
|
||||
function toStreamTextOptions(options?: ToStreamTextOptionsOptions): Record<string, unknown> {
|
||||
const prompt = locals.get(chatPromptKey);
|
||||
const skills = locals.get(chatSkillsKey);
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
// Build the combined system prompt: stored prompt + skills preamble.
|
||||
const promptText = prompt?.text ?? "";
|
||||
const skillsText = skills && skills.length > 0 ? buildSkillsSystemPrompt(skills) : "";
|
||||
if (promptText || skillsText) {
|
||||
result.system = [promptText, skillsText].filter(Boolean).join("\n\n");
|
||||
}
|
||||
|
||||
// Prompt-related options (only if chat.prompt.set() was called)
|
||||
if (prompt) {
|
||||
result.system = prompt.text;
|
||||
|
||||
// Resolve model via registry if both are present
|
||||
if (options?.registry && prompt.model) {
|
||||
result.model = options.registry.languageModel(prompt.model);
|
||||
@@ -1808,6 +2034,15 @@ function toStreamTextOptions(options?: ToStreamTextOptionsOptions): Record<strin
|
||||
Object.assign(result, telemetry);
|
||||
}
|
||||
|
||||
// Skills: merge auto-injected tools with any user-provided tools.
|
||||
// User tools override on name conflict (though we namespace ours).
|
||||
if (skills && skills.length > 0) {
|
||||
const skillTools = buildSkillTools(skills);
|
||||
result.tools = { ...skillTools, ...(options?.tools ?? {}) };
|
||||
} else if (options?.tools) {
|
||||
result.tools = options.tools;
|
||||
}
|
||||
|
||||
// Auto-inject prepareStep for compaction, pending messages, and background context injection.
|
||||
// This runs regardless of whether a prompt is set — these features are independent.
|
||||
const taskCompaction = locals.get(chatAgentCompactionKey);
|
||||
@@ -6190,6 +6425,18 @@ export const chat = {
|
||||
* - `chat.prompt()` — read the stored prompt (throws if not set)
|
||||
*/
|
||||
prompt: Object.assign(getChatPrompt, { set: setChatPrompt }),
|
||||
/**
|
||||
* Store and retrieve resolved agent skills for the current run.
|
||||
*
|
||||
* - `chat.skills.set([...])` — store an array of `ResolvedSkill`s
|
||||
* - `chat.skills()` — read the stored skills (returns undefined if none)
|
||||
*
|
||||
* Skills set here are automatically injected into `streamText` by
|
||||
* `chat.toStreamTextOptions()`: skill descriptions land in the system
|
||||
* prompt and `loadSkill` / `readFile` / `bash` tools are added to the
|
||||
* tool set.
|
||||
*/
|
||||
skills: Object.assign(getChatSkills, { set: setChatSkills }),
|
||||
/**
|
||||
* Returns an options object ready to spread into `streamText()`.
|
||||
* Reads the stored prompt and returns `{ system, experimental_telemetry, ...config }`.
|
||||
|
||||
@@ -65,3 +65,4 @@ export type { ImportEnvironmentVariablesParams } from "./envvars.js";
|
||||
|
||||
export { configure, auth } from "./auth.js";
|
||||
export * as prompts from "./prompts.js";
|
||||
export * as skills from "./skills.js";
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { resourceCatalog } from "@trigger.dev/core/v3";
|
||||
|
||||
/**
|
||||
* Parsed `SKILL.md` frontmatter. Only `name` + `description` are required;
|
||||
* additional keys are preserved but untyped.
|
||||
*/
|
||||
export type SkillFrontmatter = {
|
||||
name: string;
|
||||
description: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* A resolved skill ready to hand to `chat.skills.set()`. Includes the parsed
|
||||
* SKILL.md content plus the on-disk path to the bundled skill folder.
|
||||
*/
|
||||
export type ResolvedSkill = {
|
||||
id: string;
|
||||
/** Skill version — `"local"` in Phase 1 until backend-managed overrides land. */
|
||||
version: number | "local";
|
||||
/** Labels applied to this version — empty in Phase 1. */
|
||||
labels: string[];
|
||||
/** Full raw `SKILL.md` content (with frontmatter). */
|
||||
skillMd: string;
|
||||
/** Parsed frontmatter fields. */
|
||||
frontmatter: SkillFrontmatter;
|
||||
/** Body of SKILL.md with the frontmatter block stripped. */
|
||||
body: string;
|
||||
/** Absolute path to the bundled skill folder (scripts, references, assets live here). */
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type SkillOptions<TIdentifier extends string = string> = {
|
||||
id: TIdentifier;
|
||||
/** Path to the skill source folder, relative to the project root. */
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type SkillHandle<TIdentifier extends string = string> = {
|
||||
id: TIdentifier;
|
||||
/**
|
||||
* Read the bundled `SKILL.md` from disk and return the resolved skill.
|
||||
*
|
||||
* This is the Phase 1 path — backend-managed overrides are not available
|
||||
* yet. Works locally (during `trigger dev`) and in the deploy image.
|
||||
*/
|
||||
local(): Promise<ResolvedSkill>;
|
||||
/**
|
||||
* Resolve the skill against the dashboard (current/override version).
|
||||
*
|
||||
* Not available in Phase 1 — throws. Use `local()` until backend-managed
|
||||
* skills ship.
|
||||
*/
|
||||
resolve(): Promise<ResolvedSkill>;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type AnySkillHandle = SkillHandle<string>;
|
||||
|
||||
/** Extract the id literal type from a SkillHandle. */
|
||||
export type SkillIdentifier<T extends AnySkillHandle> = T extends SkillHandle<infer TId>
|
||||
? TId
|
||||
: string;
|
||||
|
||||
/**
|
||||
* Bundled skills are copied to `${cwd}/.trigger/skills/{id}/` by the CLI at
|
||||
* build time. At runtime the same layout holds for both `trigger dev` (cwd
|
||||
* = dev output dir) and deploy (cwd = /app).
|
||||
*/
|
||||
function bundledSkillPath(id: string): string {
|
||||
return path.resolve(process.cwd(), ".trigger", "skills", id);
|
||||
}
|
||||
|
||||
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n*/;
|
||||
|
||||
/**
|
||||
* Parse a minimal YAML-subset frontmatter block. We only support top-level
|
||||
* string keys like `name: foo` and `description: bar`. Enough for SKILL.md
|
||||
* frontmatter without pulling in a YAML dep.
|
||||
*/
|
||||
export function parseFrontmatter(content: string): {
|
||||
frontmatter: SkillFrontmatter;
|
||||
body: string;
|
||||
} {
|
||||
const match = content.match(FRONTMATTER_RE);
|
||||
if (!match || !match[1]) {
|
||||
throw new Error(
|
||||
"Skill: SKILL.md is missing a frontmatter block. " +
|
||||
"Expected `---\\nname: ...\\ndescription: ...\\n---` at the top of the file."
|
||||
);
|
||||
}
|
||||
|
||||
const raw = match[1];
|
||||
const frontmatter: Record<string, unknown> = {};
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const idx = trimmed.indexOf(":");
|
||||
if (idx === -1) continue;
|
||||
const key = trimmed.slice(0, idx).trim();
|
||||
let value = trimmed.slice(idx + 1).trim();
|
||||
// Strip surrounding quotes if present
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
if (key) frontmatter[key] = value;
|
||||
}
|
||||
|
||||
if (typeof frontmatter.name !== "string" || !frontmatter.name) {
|
||||
throw new Error("Skill: SKILL.md frontmatter is missing required `name` field.");
|
||||
}
|
||||
if (typeof frontmatter.description !== "string" || !frontmatter.description) {
|
||||
throw new Error("Skill: SKILL.md frontmatter is missing required `description` field.");
|
||||
}
|
||||
|
||||
const body = content.slice(match[0].length);
|
||||
|
||||
return { frontmatter: frontmatter as SkillFrontmatter, body };
|
||||
}
|
||||
|
||||
async function loadLocal(id: string): Promise<ResolvedSkill> {
|
||||
const skillPath = bundledSkillPath(id);
|
||||
const skillMdPath = path.join(skillPath, "SKILL.md");
|
||||
|
||||
let skillMd: string;
|
||||
try {
|
||||
skillMd = await fs.readFile(skillMdPath, "utf8");
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Skill "${id}": could not read SKILL.md at ${skillMdPath}. ` +
|
||||
`Skills must be bundled into .trigger/skills/{id}/ — this usually means ` +
|
||||
`the CLI build step didn't run, or the skill wasn't registered via ai.defineSkill. ` +
|
||||
`Underlying error: ${(err as Error).message}`
|
||||
);
|
||||
}
|
||||
|
||||
const { frontmatter, body } = parseFrontmatter(skillMd);
|
||||
|
||||
return {
|
||||
id,
|
||||
version: "local",
|
||||
labels: [],
|
||||
skillMd,
|
||||
frontmatter,
|
||||
body,
|
||||
path: skillPath,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Define an agent skill — a developer-authored folder with a `SKILL.md` file
|
||||
* plus optional `scripts/`, `references/`, and `assets/` subfolders. Registers
|
||||
* the skill with the resource catalog so the Trigger.dev CLI can bundle it
|
||||
* into the deploy image automatically (no build extension needed).
|
||||
*
|
||||
* Call `.local()` on the returned handle to load the bundled SKILL.md at
|
||||
* runtime and use it with `chat.skills.set()`.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // trigger/skills/pdf-processing/SKILL.md
|
||||
* // trigger/skills/pdf-processing/scripts/extract.py
|
||||
* import { ai } from "@trigger.dev/sdk";
|
||||
*
|
||||
* export const pdfSkill = ai.defineSkill({
|
||||
* id: "pdf-processing",
|
||||
* path: "./skills/pdf-processing",
|
||||
* });
|
||||
*
|
||||
* export const agent = chat.agent({
|
||||
* id: "docs",
|
||||
* onChatStart: async () => {
|
||||
* chat.skills.set([await pdfSkill.local()]);
|
||||
* },
|
||||
* run: async ({ messages, signal }) => {
|
||||
* return streamText({
|
||||
* model: openai("gpt-4o"),
|
||||
* messages,
|
||||
* abortSignal: signal,
|
||||
* ...chat.toStreamTextOptions(),
|
||||
* });
|
||||
* },
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function defineSkill<TIdentifier extends string>(
|
||||
options: SkillOptions<TIdentifier>
|
||||
): SkillHandle<TIdentifier> {
|
||||
resourceCatalog.registerSkillMetadata({
|
||||
id: options.id,
|
||||
sourcePath: options.path,
|
||||
});
|
||||
|
||||
return {
|
||||
id: options.id,
|
||||
async local() {
|
||||
return loadLocal(options.id);
|
||||
},
|
||||
async resolve() {
|
||||
throw new Error(
|
||||
`Skill "${options.id}": resolve() is not available yet — backend-managed ` +
|
||||
`skills ship in Phase 2. Use skill.local() instead.`
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export { defineSkill as define } from "./skill.js";
|
||||
export type {
|
||||
AnySkillHandle,
|
||||
ResolvedSkill,
|
||||
SkillFrontmatter,
|
||||
SkillHandle,
|
||||
SkillIdentifier,
|
||||
SkillOptions,
|
||||
} from "./skill.js";
|
||||
@@ -0,0 +1,86 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { mkdtemp, mkdir, realpath, writeFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { defineSkill, parseFrontmatter } from "../src/v3/skill.js";
|
||||
|
||||
describe("parseFrontmatter", () => {
|
||||
it("parses name + description", () => {
|
||||
const { frontmatter, body } = parseFrontmatter(
|
||||
`---\nname: pdf-processing\ndescription: Extract text from PDFs.\n---\n\n# Body\n\nhello\n`
|
||||
);
|
||||
expect(frontmatter.name).toBe("pdf-processing");
|
||||
expect(frontmatter.description).toBe("Extract text from PDFs.");
|
||||
expect(body).toBe("# Body\n\nhello\n");
|
||||
});
|
||||
|
||||
it("strips surrounding quotes", () => {
|
||||
const { frontmatter } = parseFrontmatter(
|
||||
`---\nname: "quoted-name"\ndescription: 'single quoted'\n---\nbody\n`
|
||||
);
|
||||
expect(frontmatter.name).toBe("quoted-name");
|
||||
expect(frontmatter.description).toBe("single quoted");
|
||||
});
|
||||
|
||||
it("throws on missing frontmatter block", () => {
|
||||
expect(() => parseFrontmatter("# just a heading\n")).toThrow(/missing a frontmatter block/);
|
||||
});
|
||||
|
||||
it("throws on missing required name", () => {
|
||||
expect(() => parseFrontmatter(`---\ndescription: desc\n---\nbody`)).toThrow(
|
||||
/missing required `name`/
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on missing required description", () => {
|
||||
expect(() => parseFrontmatter(`---\nname: foo\n---\nbody`)).toThrow(
|
||||
/missing required `description`/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("defineSkill.local()", () => {
|
||||
const originalCwd = process.cwd();
|
||||
let workdir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
workdir = await realpath(await mkdtemp(path.join(tmpdir(), "skill-test-")));
|
||||
process.chdir(workdir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
process.chdir(originalCwd);
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("reads a bundled SKILL.md and returns a ResolvedSkill", async () => {
|
||||
const skillDir = path.join(workdir, ".trigger", "skills", "pdf");
|
||||
await mkdir(skillDir, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(skillDir, "SKILL.md"),
|
||||
`---\nname: pdf\ndescription: Extract PDF text.\n---\n\n# PDF skill\n\nUse scripts/extract.py.\n`
|
||||
);
|
||||
|
||||
const skill = defineSkill({ id: "pdf", path: "./skills/pdf" });
|
||||
const resolved = await skill.local();
|
||||
|
||||
expect(resolved.id).toBe("pdf");
|
||||
expect(resolved.version).toBe("local");
|
||||
expect(resolved.labels).toEqual([]);
|
||||
expect(resolved.frontmatter.name).toBe("pdf");
|
||||
expect(resolved.frontmatter.description).toBe("Extract PDF text.");
|
||||
expect(resolved.body).toContain("# PDF skill");
|
||||
expect(resolved.body).toContain("Use scripts/extract.py");
|
||||
expect(resolved.path).toBe(skillDir);
|
||||
});
|
||||
|
||||
it("throws a useful error when SKILL.md is missing", async () => {
|
||||
const skill = defineSkill({ id: "missing", path: "./skills/missing" });
|
||||
await expect(skill.local()).rejects.toThrow(/could not read SKILL.md/);
|
||||
});
|
||||
|
||||
it("resolve() throws with a helpful Phase 1 message", async () => {
|
||||
const skill = defineSkill({ id: "phase-2", path: "./skills/phase-2" });
|
||||
await expect(skill.resolve()).rejects.toThrow(/not available yet.*Phase 2.*local/s);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
// Import the test harness FIRST so the resource catalog is installed
|
||||
import { mockChatAgent } from "../src/v3/test/index.js";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { mkdtemp, mkdir, realpath, writeFile, rm, chmod } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import * as path from "node:path";
|
||||
import type { LanguageModelV3StreamPart } from "@ai-sdk/provider";
|
||||
import { MockLanguageModelV3 } from "ai/test";
|
||||
import { simulateReadableStream, streamText } from "ai";
|
||||
import { buildSkillTools, chat } from "../src/v3/ai.js";
|
||||
import { defineSkill } from "../src/v3/skill.js";
|
||||
|
||||
function userMessage(text: string, id?: string) {
|
||||
return {
|
||||
id: id ?? `u-${Math.random().toString(36).slice(2)}`,
|
||||
role: "user" as const,
|
||||
parts: [{ type: "text" as const, text }],
|
||||
};
|
||||
}
|
||||
|
||||
function textStream(text: string) {
|
||||
const chunks: LanguageModelV3StreamPart[] = [
|
||||
{ type: "text-start", id: "t1" },
|
||||
{ type: "text-delta", id: "t1", delta: text },
|
||||
{ type: "text-end", id: "t1" },
|
||||
{
|
||||
type: "finish",
|
||||
finishReason: { unified: "stop", raw: "stop" },
|
||||
usage: {
|
||||
inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined },
|
||||
outputTokens: { total: 10, text: 10, reasoning: undefined },
|
||||
},
|
||||
},
|
||||
];
|
||||
return simulateReadableStream({ chunks });
|
||||
}
|
||||
|
||||
const originalCwd = process.cwd();
|
||||
let workdir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
workdir = await realpath(await mkdtemp(path.join(tmpdir(), "skills-runtime-")));
|
||||
process.chdir(workdir);
|
||||
|
||||
// Bundled skill layout
|
||||
const skillDir = path.join(workdir, ".trigger", "skills", "demo");
|
||||
await mkdir(path.join(skillDir, "scripts"), { recursive: true });
|
||||
await mkdir(path.join(skillDir, "references"), { recursive: true });
|
||||
|
||||
await writeFile(
|
||||
path.join(skillDir, "SKILL.md"),
|
||||
`---\nname: demo\ndescription: Demo skill for tests.\n---\n\n# Demo\n\nUse scripts/hello.sh to say hello.\n`
|
||||
);
|
||||
|
||||
const scriptPath = path.join(skillDir, "scripts", "hello.sh");
|
||||
await writeFile(scriptPath, `#!/usr/bin/env bash\necho "hi from $1"\n`);
|
||||
await chmod(scriptPath, 0o755);
|
||||
|
||||
await writeFile(path.join(skillDir, "references", "notes.txt"), "Reference note.\n");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
process.chdir(originalCwd);
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("chat.skills runtime integration", () => {
|
||||
it("injects skills preamble into the system prompt", async () => {
|
||||
let capturedSystem: string | undefined;
|
||||
|
||||
const model = new MockLanguageModelV3({
|
||||
doStream: async (opts) => {
|
||||
const system = opts.prompt.find((m) => m.role === "system");
|
||||
capturedSystem = system ? JSON.stringify(system.content) : undefined;
|
||||
return { stream: textStream("ok") };
|
||||
},
|
||||
});
|
||||
|
||||
const skill = defineSkill({ id: "demo", path: "./skills/demo" });
|
||||
|
||||
const agent = chat.agent({
|
||||
id: "skills-runtime.system-prompt",
|
||||
onChatStart: async () => {
|
||||
chat.skills.set([await skill.local()]);
|
||||
},
|
||||
run: async ({ messages, signal }) => {
|
||||
return streamText({
|
||||
model,
|
||||
messages,
|
||||
abortSignal: signal,
|
||||
...chat.toStreamTextOptions(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const harness = mockChatAgent(agent, { chatId: "t1" });
|
||||
try {
|
||||
await harness.sendMessage(userMessage("hi"));
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(capturedSystem).toContain("Available skills");
|
||||
expect(capturedSystem).toContain("demo: Demo skill for tests");
|
||||
} finally {
|
||||
await harness.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("auto-wires loadSkill / readFile / bash tools", async () => {
|
||||
let capturedToolNames: string[] = [];
|
||||
|
||||
const model = new MockLanguageModelV3({
|
||||
doStream: async (opts) => {
|
||||
capturedToolNames = (opts.tools ?? []).map((t) => t.name);
|
||||
return { stream: textStream("ok") };
|
||||
},
|
||||
});
|
||||
|
||||
const skill = defineSkill({ id: "demo", path: "./skills/demo" });
|
||||
|
||||
const agent = chat.agent({
|
||||
id: "skills-runtime.auto-tools",
|
||||
onChatStart: async () => {
|
||||
chat.skills.set([await skill.local()]);
|
||||
},
|
||||
run: async ({ messages, signal }) => {
|
||||
return streamText({
|
||||
model,
|
||||
messages,
|
||||
abortSignal: signal,
|
||||
...chat.toStreamTextOptions(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const harness = mockChatAgent(agent, { chatId: "t2" });
|
||||
try {
|
||||
await harness.sendMessage(userMessage("hi"));
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(capturedToolNames).toEqual(expect.arrayContaining(["loadSkill", "readFile", "bash"]));
|
||||
} finally {
|
||||
await harness.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSkillTools — direct execute", () => {
|
||||
it("loadSkill returns body + path for a known skill", async () => {
|
||||
const skill = defineSkill({ id: "demo", path: "./skills/demo" });
|
||||
const resolved = await skill.local();
|
||||
const tools = buildSkillTools([resolved]);
|
||||
|
||||
const out = await (tools.loadSkill as any).execute({ name: "demo" });
|
||||
expect(out.name).toBe("demo");
|
||||
expect(out.body).toContain("# Demo");
|
||||
expect(out.path).toBe(resolved.path);
|
||||
});
|
||||
|
||||
it("loadSkill returns an error for an unknown skill", async () => {
|
||||
const skill = defineSkill({ id: "demo", path: "./skills/demo" });
|
||||
const tools = buildSkillTools([await skill.local()]);
|
||||
|
||||
const out = await (tools.loadSkill as any).execute({ name: "missing" });
|
||||
expect(out.error).toContain('Skill "missing" not found');
|
||||
});
|
||||
|
||||
it("readFile reads a bundled reference", async () => {
|
||||
const skill = defineSkill({ id: "demo", path: "./skills/demo" });
|
||||
const tools = buildSkillTools([await skill.local()]);
|
||||
|
||||
const out = await (tools.readFile as any).execute({
|
||||
skill: "demo",
|
||||
path: "references/notes.txt",
|
||||
});
|
||||
expect(out.content).toBe("Reference note.\n");
|
||||
});
|
||||
|
||||
it("readFile rejects path traversal", async () => {
|
||||
const skill = defineSkill({ id: "demo", path: "./skills/demo" });
|
||||
const tools = buildSkillTools([await skill.local()]);
|
||||
|
||||
const out = await (tools.readFile as any).execute({
|
||||
skill: "demo",
|
||||
path: "../../../../etc/passwd",
|
||||
});
|
||||
expect(out.error).toMatch(/escapes the skill directory/);
|
||||
});
|
||||
|
||||
it("readFile rejects absolute paths", async () => {
|
||||
const skill = defineSkill({ id: "demo", path: "./skills/demo" });
|
||||
const tools = buildSkillTools([await skill.local()]);
|
||||
|
||||
const out = await (tools.readFile as any).execute({
|
||||
skill: "demo",
|
||||
path: "/etc/passwd",
|
||||
});
|
||||
expect(out.error).toMatch(/must be relative/);
|
||||
});
|
||||
|
||||
it("bash runs a bundled script and captures stdout", async () => {
|
||||
const skill = defineSkill({ id: "demo", path: "./skills/demo" });
|
||||
const tools = buildSkillTools([await skill.local()]);
|
||||
|
||||
const out = await (tools.bash as any).execute({
|
||||
skill: "demo",
|
||||
command: "bash scripts/hello.sh world",
|
||||
});
|
||||
expect(out.exitCode).toBe(0);
|
||||
expect(out.stdout).toContain("hi from world");
|
||||
});
|
||||
|
||||
it("bash reports non-zero exit code", async () => {
|
||||
const skill = defineSkill({ id: "demo", path: "./skills/demo" });
|
||||
const tools = buildSkillTools([await skill.local()]);
|
||||
|
||||
const out = await (tools.bash as any).execute({
|
||||
skill: "demo",
|
||||
command: "exit 7",
|
||||
});
|
||||
expect(out.exitCode).toBe(7);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user