Give bots skills: runbooks they load when a job matches
A skill is a runbook: a name, a unique slug, a one-line description of when it applies, and the markdown body. A run only ever sees the slug and description (one load_skill tool carries them), and reads the whole body only when it calls the tool, so a long runbook costs nothing until needed. Skills are the third extension kind beside MCP servers and custom tools, on the same botExtensionsSql path, so per-bot, per-workspace and all-bots grants work the same way, and attachDefaultExtensions hands an every-bot skill to new bots. load_skill is a reserved tool name, so a custom tool cannot shadow it, and a built-in wins a clash. Managed on Settings > Skills; ExtensionRow links each kind to its own page and a skill never reports a failure, since nothing connects or runs.
This commit is contained in:
@@ -8,6 +8,9 @@ beforeAll(() => {
|
||||
db.run(`CREATE TABLE custom_tools (id INTEGER PRIMARY KEY, all_bots INTEGER, enabled INTEGER)`);
|
||||
db.run(`CREATE TABLE bot_custom_tools (bot_id INTEGER, custom_tool_id INTEGER)`);
|
||||
db.run(`CREATE TABLE workspace_custom_tools (workspace_id INTEGER, custom_tool_id INTEGER)`);
|
||||
db.run(`CREATE TABLE skills (id INTEGER PRIMARY KEY, all_bots INTEGER, enabled INTEGER)`);
|
||||
db.run(`CREATE TABLE bot_skills (bot_id INTEGER, skill_id INTEGER)`);
|
||||
db.run(`CREATE TABLE workspace_skills (workspace_id INTEGER, skill_id INTEGER)`);
|
||||
/**
|
||||
* 1: every bot (given to 5 and 6). 2: bot 5. 3: workspace 9. 4: off,
|
||||
* given to 5. 5: nobody. 6: every bot, but 6 dropped it.
|
||||
@@ -15,6 +18,12 @@ beforeAll(() => {
|
||||
db.run(`INSERT INTO custom_tools VALUES (1, 1, 1), (2, 0, 1), (3, 0, 1), (4, 1, 0), (5, 0, 1), (6, 1, 1)`);
|
||||
db.run(`INSERT INTO bot_custom_tools VALUES (5, 1), (6, 1), (5, 2), (5, 4), (5, 6)`);
|
||||
db.run(`INSERT INTO workspace_custom_tools VALUES (9, 3)`);
|
||||
/**
|
||||
* The same shape, so skills reach a bot the same way.
|
||||
*/
|
||||
db.run(`INSERT INTO skills VALUES (1, 1, 1), (2, 0, 1), (3, 0, 1), (4, 1, 0), (5, 0, 1), (6, 1, 1)`);
|
||||
db.run(`INSERT INTO bot_skills VALUES (5, 1), (6, 1), (5, 2), (5, 4), (5, 6)`);
|
||||
db.run(`INSERT INTO workspace_skills VALUES (9, 3)`);
|
||||
});
|
||||
|
||||
function ids(params: Parameters<typeof botExtensionsSql>[0]) {
|
||||
@@ -37,4 +46,11 @@ describe("botExtensionsSql", () => {
|
||||
expect(ids({ kind: "tool", bot_id: 5, workspace_id: 9 })).toEqual([1, 2, 3, 6]);
|
||||
expect(ids({ kind: "tool", bot_id: 5, workspace_id: 8 })).toEqual([1, 2, 6]);
|
||||
});
|
||||
|
||||
test("skills reach a bot the same way", () => {
|
||||
expect(ids({ kind: "skill", bot_id: 5 })).toEqual([1, 2, 6]);
|
||||
expect(ids({ kind: "skill", bot_id: 6 })).toEqual([1]);
|
||||
expect(ids({ kind: "skill", bot_id: 7 })).toEqual([]);
|
||||
expect(ids({ kind: "skill", bot_id: 6, workspace_id: 9 })).toEqual([1, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
type Params = {
|
||||
/**
|
||||
* Which kind: MCP servers or custom tools.
|
||||
* Which kind: MCP servers, custom tools or skills.
|
||||
*/
|
||||
kind: "mcp" | "tool";
|
||||
kind: "mcp" | "tool" | "skill";
|
||||
bot_id: number | string;
|
||||
/**
|
||||
* The workspace the run is in: what's attached to it counts too.
|
||||
@@ -23,10 +23,16 @@ const Tables = {
|
||||
workspace_link: "workspace_custom_tools",
|
||||
column: "custom_tool_id",
|
||||
},
|
||||
skill: {
|
||||
table: "skills",
|
||||
bot_link: "bot_skills",
|
||||
workspace_link: "workspace_skills",
|
||||
column: "skill_id",
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The enabled MCP servers or custom tools a bot gets on one run: those
|
||||
* The enabled MCP servers, custom tools or skills a bot gets on one run: those
|
||||
* attached to the bot, and in a workspace those attached to the
|
||||
* workspace. "Every bot" only decides what a bot is given (now and when
|
||||
* it's created, `attachToAllBots`, `attachDefaultExtensions`); one it
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import checkSkillSlug from "./check-skill-slug";
|
||||
|
||||
describe("checkSkillSlug", () => {
|
||||
test("accepts lowercase words joined by single hyphens", () => {
|
||||
expect(checkSkillSlug({ slug: "seo" })).toBeNull();
|
||||
expect(checkSkillSlug({ slug: "weekly-seo-report" })).toBeNull();
|
||||
expect(checkSkillSlug({ slug: "report2024" })).toBeNull();
|
||||
});
|
||||
|
||||
test("refuses capitals, spaces, underscores and dots", () => {
|
||||
expect(checkSkillSlug({ slug: "Weekly" })).not.toBeNull();
|
||||
expect(checkSkillSlug({ slug: "weekly seo" })).not.toBeNull();
|
||||
expect(checkSkillSlug({ slug: "weekly_seo" })).not.toBeNull();
|
||||
expect(checkSkillSlug({ slug: "weekly.seo" })).not.toBeNull();
|
||||
});
|
||||
|
||||
test("refuses a hyphen at either end and doubled hyphens", () => {
|
||||
expect(checkSkillSlug({ slug: "-weekly" })).not.toBeNull();
|
||||
expect(checkSkillSlug({ slug: "weekly-" })).not.toBeNull();
|
||||
expect(checkSkillSlug({ slug: "weekly--seo" })).not.toBeNull();
|
||||
});
|
||||
|
||||
test("refuses slugs shorter than 2 or longer than 64 characters", () => {
|
||||
expect(checkSkillSlug({ slug: "a" })).not.toBeNull();
|
||||
expect(checkSkillSlug({ slug: "a".repeat(65) })).not.toBeNull();
|
||||
expect(checkSkillSlug({ slug: "a".repeat(64) })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { SkillSlugPattern } from "@/src/dict/skill-dict";
|
||||
|
||||
type Params = {
|
||||
slug: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Why a skill can't take this slug, or null when it can: lowercase letters,
|
||||
* numbers and single hyphens, 2 to 64 characters, never starting or ending
|
||||
* with one. It's what a bot writes in `load_skill`.
|
||||
*/
|
||||
export default function checkSkillSlug({ slug }: Params): string | null {
|
||||
if (slug.length < 2 || slug.length > 64) {
|
||||
return `Use 2 to 64 characters.`;
|
||||
}
|
||||
|
||||
if (!SkillSlugPattern.test(slug)) {
|
||||
return `Use lowercase letters, numbers and hyphens, e.g. weekly-seo-report.`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import extensionNoun, { extensionLogTitle } from "./extension-noun";
|
||||
|
||||
describe("extensionNoun", () => {
|
||||
test("names each kind, with or without its article", () => {
|
||||
expect(extensionNoun({ kind: "mcp" })).toBe("MCP server");
|
||||
expect(extensionNoun({ kind: "tool" })).toBe("tool");
|
||||
expect(extensionNoun({ kind: "skill" })).toBe("skill");
|
||||
expect(extensionNoun({ kind: "skill", article: true })).toBe("the skill");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extensionLogTitle", () => {
|
||||
test("capitalizes the noun and adds what happened", () => {
|
||||
expect(extensionLogTitle({ kind: "mcp", verb: "added" })).toBe("MCP server added");
|
||||
expect(extensionLogTitle({ kind: "tool", verb: "removed" })).toBe("Tool removed");
|
||||
expect(extensionLogTitle({ kind: "skill", verb: "added" })).toBe("Skill added");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { BotExtension } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
kind: BotExtension["kind"];
|
||||
/**
|
||||
* With the article, for a sentence: "the tool", "the MCP server".
|
||||
*/
|
||||
article?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* What an extension kind is called in copy: a tool, an MCP server or a
|
||||
* skill.
|
||||
*/
|
||||
export default function extensionNoun({ kind, article }: Params) {
|
||||
const noun = kind == "mcp" ? "MCP server" : kind == "skill" ? "skill" : "tool";
|
||||
|
||||
return article ? `the ${noun}` : noun;
|
||||
}
|
||||
|
||||
type TitleParams = {
|
||||
kind: BotExtension["kind"];
|
||||
/**
|
||||
* What happened, in one word: "added", "removed".
|
||||
*/
|
||||
verb: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A log entry's title: "MCP server added", "Skill removed".
|
||||
*/
|
||||
export function extensionLogTitle({ kind, verb }: TitleParams) {
|
||||
const noun = extensionNoun({ kind });
|
||||
|
||||
return `${noun.charAt(0).toUpperCase()}${noun.slice(1)} ${verb}`;
|
||||
}
|
||||
@@ -7,11 +7,20 @@ describe("extensionOptionValue", () => {
|
||||
kind: "mcp",
|
||||
id: 3,
|
||||
});
|
||||
expect(parseExtensionOptionValue({ value: extensionOptionValue({ kind: "tool", id: 4 }) })).toEqual({
|
||||
kind: "tool",
|
||||
id: 4,
|
||||
});
|
||||
expect(parseExtensionOptionValue({ value: extensionOptionValue({ kind: "skill", id: 5 }) })).toEqual({
|
||||
kind: "skill",
|
||||
id: 5,
|
||||
});
|
||||
});
|
||||
|
||||
test("refuses anything else", () => {
|
||||
expect(parseExtensionOptionValue({ value: "" })).toBeNull();
|
||||
expect(parseExtensionOptionValue({ value: "bot:2" })).toBeNull();
|
||||
expect(parseExtensionOptionValue({ value: "tool:x" })).toBeNull();
|
||||
expect(parseExtensionOptionValue({ value: "skill:0" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { BotExtension } from "@/src/types";
|
||||
|
||||
/**
|
||||
* An MCP server or tool as one select value (`mcp:3`), and back.
|
||||
* An MCP server, tool or skill as one select value (`mcp:3`), and back.
|
||||
*/
|
||||
export function extensionOptionValue({ kind, id }: { kind: BotExtension["kind"]; id: number }) {
|
||||
return `${kind}:${id}`;
|
||||
@@ -10,7 +10,7 @@ export function extensionOptionValue({ kind, id }: { kind: BotExtension["kind"];
|
||||
export function parseExtensionOptionValue({ value }: { value: string }) {
|
||||
const [kind, id] = value.split(":");
|
||||
|
||||
if ((kind != "mcp" && kind != "tool") || !Number(id)) return null;
|
||||
if ((kind != "mcp" && kind != "tool" && kind != "skill") || !Number(id)) return null;
|
||||
|
||||
return { kind: kind as BotExtension["kind"], id: Number(id) };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user