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:
@@ -1,5 +1,6 @@
|
||||
import grabBotExtensions from "@/src/functions/backend/db/extensions/grab-bot-extensions";
|
||||
import grabRunEnvs from "@/src/functions/backend/db/extensions/grab-run-envs";
|
||||
import type { SkillRecord } from "@/src/types";
|
||||
import mcpToolName from "@/src/utils/mcp-tool-name";
|
||||
import redactSecrets from "@/src/utils/redact-secrets";
|
||||
import mcpHarnessTool from "../../mcp/mcp-harness-tool";
|
||||
@@ -30,6 +31,11 @@ export type OpenBotExtensions = {
|
||||
* with no tools listed), so the bot can say so. Empty otherwise.
|
||||
*/
|
||||
note: string;
|
||||
/**
|
||||
* The skills the run has, for `openBotSkills`: read from the same query
|
||||
* as the MCP servers and tools.
|
||||
*/
|
||||
skills: SkillRecord[];
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -47,9 +53,11 @@ export default async function openBotExtensions({
|
||||
taken = [],
|
||||
signal,
|
||||
}: Params): Promise<OpenBotExtensions> {
|
||||
const { servers, tools } = await grabBotExtensions({ bot_id, workspace_id });
|
||||
const { servers, tools, skills } = await grabBotExtensions({ bot_id, workspace_id });
|
||||
|
||||
if (!servers.length && !tools.length) return { tools: [], note: "", close: async () => {} };
|
||||
if (!servers.length && !tools.length) {
|
||||
return { tools: [], note: "", skills, close: async () => {} };
|
||||
}
|
||||
|
||||
const envs = await grabRunEnvs({ bot_id, workspace_id });
|
||||
const secrets = envs.filter((env) => env.secret);
|
||||
@@ -98,6 +106,7 @@ export default async function openBotExtensions({
|
||||
|
||||
return {
|
||||
tools: result,
|
||||
skills,
|
||||
note: notes.length
|
||||
? `These MCP servers were set up for you but you can't use them right now; if the user needs them, say so (an admin fixes them in Settings, under MCPs):\n${notes.join("\n")}`
|
||||
: "",
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import type { SkillRecord } from "@/src/types";
|
||||
import openBotSkills from "./open-bot-skills";
|
||||
|
||||
function skill(overrides: Partial<SkillRecord> & { slug: string }): SkillRecord {
|
||||
return {
|
||||
id: 1,
|
||||
name: overrides.slug,
|
||||
description: `When ${overrides.slug} applies.`,
|
||||
body: `${overrides.slug} body`,
|
||||
all_bots: false,
|
||||
enabled: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("openBotSkills", () => {
|
||||
test("no skills means no tool and no note", () => {
|
||||
expect(openBotSkills({ skills: [] })).toEqual({ tools: [], note: "" });
|
||||
});
|
||||
|
||||
test("a disabled skill is left out entirely", () => {
|
||||
const opened = openBotSkills({ skills: [skill({ slug: "seo", enabled: false })] });
|
||||
|
||||
expect(opened.tools).toHaveLength(0);
|
||||
expect(opened.note).toBe("");
|
||||
});
|
||||
|
||||
test("one tool, named load_skill, whose description lists the slugs", () => {
|
||||
const opened = openBotSkills({ skills: [skill({ slug: "seo" }), skill({ slug: "inbox", id: 2 })] });
|
||||
|
||||
expect(opened.tools).toHaveLength(1);
|
||||
expect(opened.tools[0]!.name).toBe("load_skill");
|
||||
expect(opened.tools[0]!.description).toContain("- seo:");
|
||||
expect(opened.tools[0]!.description).toContain("- inbox:");
|
||||
expect(opened.note).toContain("# Skills");
|
||||
});
|
||||
|
||||
test("a built-in that took the name wins", () => {
|
||||
expect(openBotSkills({ skills: [skill({ slug: "seo" })], taken: ["load_skill"] })).toEqual({
|
||||
tools: [],
|
||||
note: "",
|
||||
});
|
||||
});
|
||||
|
||||
test("the tool returns the body of the slug it's given", async () => {
|
||||
const opened = openBotSkills({ skills: [skill({ slug: "seo", body: "1. Pull rankings" })] });
|
||||
const output = await opened.tools[0]!.run!({ args: { slug: "seo" }, signal: AbortSignal.timeout(1000) });
|
||||
|
||||
expect(output).toBe("1. Pull rankings");
|
||||
});
|
||||
|
||||
test("an unknown slug fails with the slugs it does have", async () => {
|
||||
const opened = openBotSkills({ skills: [skill({ slug: "seo" })] });
|
||||
|
||||
await expect(opened.tools[0]!.run!({ args: { slug: "nope" }, signal: AbortSignal.timeout(1000) })).rejects.toThrow(
|
||||
/no skill called "nope".*seo/,
|
||||
);
|
||||
});
|
||||
|
||||
test("a long body is cut at the limit and says so", async () => {
|
||||
const opened = openBotSkills({
|
||||
skills: [skill({ slug: "seo", body: "x".repeat(AppData.SkillBodyShownChars + 500) })],
|
||||
});
|
||||
const output = await opened.tools[0]!.run!({ args: { slug: "seo" }, signal: AbortSignal.timeout(1000) });
|
||||
|
||||
expect(output).toContain("Cut at");
|
||||
expect(output.length).toBeLessThan(AppData.SkillBodyShownChars + 200);
|
||||
});
|
||||
|
||||
test("an empty body says it is empty rather than returning nothing", async () => {
|
||||
const opened = openBotSkills({ skills: [skill({ slug: "seo", body: "" })] });
|
||||
const output = await opened.tools[0]!.run!({ args: { slug: "seo" }, signal: AbortSignal.timeout(1000) });
|
||||
|
||||
expect(output).toContain("is empty");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { z } from "zod";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import { SkillsNote } from "@/src/data/harness-prompts";
|
||||
import { LoadSkillToolName } from "@/src/dict/skill-dict";
|
||||
import type { SkillRecord } from "@/src/types";
|
||||
import type { HarnessTool } from "../../types";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* The skills the run reaches (`grabBotExtensions`).
|
||||
*/
|
||||
skills: SkillRecord[];
|
||||
/**
|
||||
* Names the run's built-in tools already use: they win.
|
||||
*/
|
||||
taken?: string[];
|
||||
};
|
||||
|
||||
export type OpenBotSkills = {
|
||||
tools: HarnessTool[];
|
||||
/**
|
||||
* For the system prompt: one line per skill, and that the body is read
|
||||
* with `load_skill`. Empty when the run has none.
|
||||
*/
|
||||
note: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The skills a run has, as one `load_skill` tool plus the lines that say
|
||||
* which ones exist. A skill's body costs nothing until the bot asks for it,
|
||||
* so a run that never needs a long runbook never pays for it.
|
||||
*/
|
||||
export default function openBotSkills({ skills, taken = [] }: Params): OpenBotSkills {
|
||||
const enabled = skills.filter((skill) => skill.enabled);
|
||||
|
||||
if (!enabled.length || taken.includes(LoadSkillToolName)) return { tools: [], note: "" };
|
||||
|
||||
const by_slug = new Map(enabled.map((skill) => [skill.slug, skill]));
|
||||
const slugs = enabled.map((skill) => skill.slug);
|
||||
|
||||
const list = enabled.map((skill) => `- ${skill.slug}: ${skill.description}`).join("\n");
|
||||
|
||||
const tool: HarnessTool<{ slug: string }> = {
|
||||
name: LoadSkillToolName,
|
||||
description: `Reads one of your skills in full. Pass its slug. The skills you have, and when each applies:\n${list}`,
|
||||
schema: z.object({ slug: z.string().trim().min(1, "Pass the skill's slug") }),
|
||||
run: async ({ args }) => {
|
||||
const skill = by_slug.get(args.slug);
|
||||
|
||||
if (!skill) {
|
||||
throw new Error(
|
||||
`You have no skill called "${args.slug}". Load one of: ${slugs.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const body = skill.body || "";
|
||||
|
||||
if (!body) return `The skill ${skill.name} (${skill.slug}) is empty.`;
|
||||
|
||||
if (body.length <= AppData.SkillBodyShownChars) return body;
|
||||
|
||||
return `${body.slice(0, AppData.SkillBodyShownChars)}\n\n[Cut at ${AppData.SkillBodyShownChars} characters. The skill continues: ask the user to split it if you need the rest.]`;
|
||||
},
|
||||
};
|
||||
|
||||
return { tools: [tool], note: SkillsNote };
|
||||
}
|
||||
Reference in New Issue
Block a user