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:
@@ -18,13 +18,13 @@ type Props<T> = {
|
||||
/**
|
||||
* Under `/api/admin/`.
|
||||
*/
|
||||
path: "mcps/set-mcp-access" | "tools/set-tool-access";
|
||||
path: "mcps/set-mcp-access" | "tools/set-tool-access" | "skills/set-skill-access";
|
||||
onSaved: (overview: T, msg: string) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Who gets an MCP server or tool: the bots and workspaces picked here.
|
||||
* Who gets an MCP server, tool or skill: the bots and workspaces picked here.
|
||||
* "Every bot" (super admins only) gives it to every bot now and every new
|
||||
* one; each can still drop it. A workspace gives it to every bot in
|
||||
* it, there.
|
||||
|
||||
@@ -11,22 +11,35 @@ type Props = {
|
||||
};
|
||||
|
||||
/**
|
||||
* One MCP server or tool a bot or workspace has, and whether it's working.
|
||||
* The settings page each kind is managed on.
|
||||
*/
|
||||
const ExtensionHrefs = {
|
||||
mcp: "/admin/settings/mcps",
|
||||
tool: "/admin/settings/tools",
|
||||
skill: "/admin/settings/skills",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* One MCP server, tool or skill a bot or workspace has, and whether it's
|
||||
* working. A skill has nothing to connect or run, so it never reports a
|
||||
* failure.
|
||||
*/
|
||||
export default function ExtensionRow({ item, busy, onRemove }: Props) {
|
||||
const state = !item.enabled
|
||||
? "Off for every bot"
|
||||
: item.state == "error"
|
||||
? "Not working"
|
||||
: item.state == "untested"
|
||||
? "Not checked yet"
|
||||
: "";
|
||||
: item.kind == "skill"
|
||||
? ""
|
||||
: item.state == "error"
|
||||
? "Not working"
|
||||
: item.state == "untested"
|
||||
? "Not checked yet"
|
||||
: "";
|
||||
|
||||
return (
|
||||
<SettingsListRow
|
||||
avatar={<IconTile Icon={extensionIcon({ kind: item.kind })} />}
|
||||
title={item.name}
|
||||
href={item.kind == "mcp" ? "/admin/settings/mcps" : "/admin/settings/tools"}
|
||||
href={ExtensionHrefs[item.kind]}
|
||||
subtitle={[item.detail, state].filter(Boolean).join(" · ")}
|
||||
>
|
||||
{onRemove ? (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import { Plug, Wrench } from "lucide-react";
|
||||
import { BookOpen, Plug, Wrench } from "lucide-react";
|
||||
import ExtensionAddSelect from "@/src/components/general/extension-add-select";
|
||||
import useExtensions from "@/src/hooks/use-extensions";
|
||||
import type { BotExtensionsPayload } from "@/src/types";
|
||||
@@ -17,16 +17,16 @@ type Props = {
|
||||
};
|
||||
|
||||
/**
|
||||
* The MCP servers and tools a bot or a workspace has, removable, and a
|
||||
* picker to add more. On the bot and workspace settings pages.
|
||||
* The MCP servers, tools and skills a bot or a workspace has, removable,
|
||||
* and a picker to add more. On the bot and workspace settings pages.
|
||||
*/
|
||||
export default function ExtensionsGroup({ owner, owner_name, description, initial }: Props) {
|
||||
const extensions = useExtensions({ owner, owner_name, initial });
|
||||
const id = "bot_id" in owner ? `bot-${owner.bot_id}` : `workspace-${owner.workspace_id}`;
|
||||
|
||||
return (
|
||||
<SettingsGroup id="tools" title="Tools and MCP servers" description={description}>
|
||||
<SettingsList is_empty={!extensions.attached.length} empty="No tools or MCP servers yet.">
|
||||
<SettingsGroup id="tools" title="Tools, MCP servers and skills" description={description}>
|
||||
<SettingsList is_empty={!extensions.attached.length} empty="No tools, MCP servers or skills yet.">
|
||||
{extensions.attached.map((item) => (
|
||||
<ExtensionRow
|
||||
key={`${item.kind}:${item.id}`}
|
||||
@@ -73,6 +73,18 @@ export default function ExtensionsGroup({ owner, owner_name, description, initia
|
||||
>
|
||||
Tools
|
||||
</Button>
|
||||
<Button
|
||||
title="Manage skills"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="small"
|
||||
href="/admin/settings/skills"
|
||||
beforeIcon={<BookOpen size={14} aria-hidden />}
|
||||
className="gap-1!"
|
||||
>
|
||||
Skills
|
||||
</Button>
|
||||
</Row>
|
||||
<SettingsStatus status={extensions.status} />
|
||||
</SettingsGroup>
|
||||
|
||||
@@ -75,7 +75,7 @@ export default function BotSettingsSection() {
|
||||
<ExtensionsGroup
|
||||
owner={{ bot_id: bot.id }}
|
||||
owner_name={name}
|
||||
description="What it can use besides its own tools, everywhere it works. A workspace's own come on top while it works there."
|
||||
description="What it can use besides its own tools, everywhere it works: MCP servers, tools and skills. A workspace's own come on top while it works there."
|
||||
initial={pageProps?.bot_extensions}
|
||||
/>
|
||||
<ScopedEnvsGroup initial={pageProps?.bot_envs} />
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useState } from "react";
|
||||
import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions";
|
||||
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
|
||||
import Checkbox from "@moduletrace/twui/form/Checkbox";
|
||||
import Form from "@moduletrace/twui/form/Form";
|
||||
import Input from "@moduletrace/twui/form/Input";
|
||||
import Textarea from "@moduletrace/twui/form/Textarea";
|
||||
import CodeEditor from "@/src/components/general/code-editor";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||
import type { PublicSkill, SkillsOverviewPayload } from "@/src/types";
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* Edits this skill; adds one when not given.
|
||||
*/
|
||||
skill?: PublicSkill;
|
||||
onSaved: (overview: SkillsOverviewPayload, msg: string) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds or edits a skill: the name the settings page shows, the slug a bot
|
||||
* loads it by, the one line that tells the bot when it applies, and the
|
||||
* runbook itself in markdown.
|
||||
*/
|
||||
export default function SkillModal({ skill, onSaved, onCancel }: Props) {
|
||||
const [name, setName] = useState(skill?.name || "");
|
||||
const [slug, setSlug] = useState(skill?.slug || "");
|
||||
const [description, setDescription] = useState(skill?.description || "");
|
||||
const [body, setBody] = useState(skill?.body || "");
|
||||
const [enabled, setEnabled] = useState(skill?.enabled ?? true);
|
||||
const [all_bots, setAllBots] = useState(skill?.access.all_bots ?? false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
const id = `skill-${skill?.id || "new"}`;
|
||||
|
||||
const submit = async () => {
|
||||
if (loading || !window.confirm(skill ? `Save ${skill.name}?` : `Add ${name || "this skill"}?`)) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
|
||||
const res = await postAdminApi<SkillsOverviewPayload>({
|
||||
path: "skills/save-skill",
|
||||
body: { skill_id: skill?.id, name, slug, description, body, enabled, all_bots },
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (!res.success || !res.singleRes) {
|
||||
setError(res.msg || "Couldn't save the skill.");
|
||||
return;
|
||||
}
|
||||
|
||||
onSaved(res.singleRes, res.msg || "Saved.");
|
||||
};
|
||||
|
||||
return (
|
||||
<AddModalShell
|
||||
open
|
||||
setOpen={(next) => !next && onCancel()}
|
||||
title={skill ? `Edit ${skill.name}` : "Add a skill"}
|
||||
description={"Bots get it once it's attached to them."}
|
||||
>
|
||||
<Form className="w-full gap-4! items-stretch pt-4" onSubmit={submit}>
|
||||
<Input
|
||||
id={`${id}-name`}
|
||||
title="Name"
|
||||
label="Name"
|
||||
showLabel
|
||||
placeholder="Weekly SEO report"
|
||||
defaultValue={name}
|
||||
autoComplete="off"
|
||||
info="What the settings page calls it."
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
id={`${id}-slug`}
|
||||
title="Slug"
|
||||
label="Slug"
|
||||
showLabel
|
||||
placeholder="weekly-seo-report"
|
||||
defaultValue={slug}
|
||||
autoComplete="off"
|
||||
info="What a bot passes to load_skill: lowercase letters, numbers and hyphens. Unique."
|
||||
onChange={(e) => setSlug(e.target.value)}
|
||||
/>
|
||||
<Textarea
|
||||
id={`${id}-description`}
|
||||
title="When to use it"
|
||||
label="When to use it"
|
||||
showLabel
|
||||
rows={2}
|
||||
placeholder="Use for the Monday search report: rankings, traffic and what changed."
|
||||
defaultValue={description}
|
||||
info="The one line a bot reads to decide whether this skill applies. Say what job it covers."
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
<CodeEditor
|
||||
mode="markdown"
|
||||
className="h-80"
|
||||
title="Runbook"
|
||||
content={body}
|
||||
info={`Markdown. The steps, the checks, and what good looks like. A bot reads it only when it loads the skill, up to ${AppData.SkillBodyShownChars} characters of it at a time.`}
|
||||
onChange={(value) => setBody(value)}
|
||||
/>
|
||||
<Checkbox title="On" label="On: bots get it" checked={enabled} changeHandler={setEnabled} />
|
||||
<Checkbox
|
||||
title="Every bot"
|
||||
label="Every bot by default: every bot now and every new one, the coordinator included. Each can still drop it."
|
||||
checked={all_bots}
|
||||
changeHandler={setAllBots}
|
||||
/>
|
||||
<AddFormActions submit_label={skill ? "Save" : "Add skill"} loading={loading} error={error} onCancel={onCancel} />
|
||||
</Form>
|
||||
</AddModalShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useState } from "react";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { BookOpen } from "lucide-react";
|
||||
import IconTile from "@/src/components/general/icon-tile";
|
||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||
import type { ExtensionTargets, PublicSkill, SkillsOverviewPayload } from "@/src/types";
|
||||
import ExtensionAccessModal from "../../(partials)/extension-access-modal";
|
||||
import SettingsListRow from "../../(partials)/settings-list-row";
|
||||
import SettingsRowAction from "../../(partials)/settings-row-action";
|
||||
import extensionAccessLabel from "../../(utils)/extension-access-label";
|
||||
import SkillModal from "./skill-modal";
|
||||
|
||||
type Props = {
|
||||
skill: PublicSkill;
|
||||
targets: ExtensionTargets;
|
||||
onChanged: (overview: SkillsOverviewPayload, msg: string) => void;
|
||||
onError: (msg: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* One skill: its slug and when it applies, and who gets it. Edit and Access
|
||||
* open in a modal.
|
||||
*/
|
||||
export default function SkillRow({ skill, targets, onChanged, onError }: Props) {
|
||||
const [open, setOpen] = useState<"edit" | "access">();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const saved = (overview: SkillsOverviewPayload, msg: string) => {
|
||||
setOpen(undefined);
|
||||
onChanged(overview, msg);
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
if (busy || !window.confirm(`Delete ${skill.name}? Bots lose it at once.`)) return;
|
||||
|
||||
setBusy(true);
|
||||
const res = await postAdminApi<SkillsOverviewPayload>({
|
||||
path: "skills/delete-skill",
|
||||
body: { skill_id: skill.id },
|
||||
});
|
||||
setBusy(false);
|
||||
|
||||
if (res.success && res.singleRes) onChanged(res.singleRes, res.msg || "Deleted.");
|
||||
else onError(res.msg || "Couldn't delete the skill.");
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack className="w-full gap-0! items-stretch border-0 border-b border-solid border-hairline last:border-b-0">
|
||||
<SettingsListRow
|
||||
avatar={<IconTile Icon={BookOpen} />}
|
||||
title={skill.name}
|
||||
badge={skill.enabled ? undefined : "Off"}
|
||||
subtitle={[skill.slug, extensionAccessLabel({ access: skill.access }), skill.description]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
>
|
||||
{targets.can_edit ? (
|
||||
<SettingsRowAction
|
||||
title={`Edit ${skill.name}`}
|
||||
label="Edit"
|
||||
disabled={busy}
|
||||
onClick={() => setOpen(open == "edit" ? undefined : "edit")}
|
||||
/>
|
||||
) : null}
|
||||
<SettingsRowAction
|
||||
title={`Choose who gets ${skill.name}`}
|
||||
label="Access"
|
||||
disabled={busy}
|
||||
onClick={() => setOpen(open == "access" ? undefined : "access")}
|
||||
/>
|
||||
{targets.can_edit ? (
|
||||
<SettingsRowAction title={`Delete ${skill.name}`} label="Delete" disabled={busy} onClick={remove} />
|
||||
) : null}
|
||||
</SettingsListRow>
|
||||
{open == "edit" ? (
|
||||
<SkillModal skill={skill} onSaved={saved} onCancel={() => setOpen(undefined)} />
|
||||
) : null}
|
||||
{open == "access" ? (
|
||||
<ExtensionAccessModal<SkillsOverviewPayload>
|
||||
id={skill.id}
|
||||
name={skill.name}
|
||||
access={skill.access}
|
||||
targets={targets}
|
||||
path="skills/set-skill-access"
|
||||
onSaved={saved}
|
||||
onCancel={() => setOpen(undefined)}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useContext, useState } from "react";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import { Plus } from "lucide-react";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import type { SkillsOverviewPayload } from "@/src/types";
|
||||
import SettingsGroup from "../../(partials)/settings-group";
|
||||
import SettingsList from "../../(partials)/settings-list";
|
||||
import SettingsStatus from "../../(partials)/settings-status";
|
||||
import SkillModal from "../(partials)/skill-modal";
|
||||
import SkillRow from "../(partials)/skill-row";
|
||||
|
||||
type Status = { error?: boolean; success?: boolean; msg?: string };
|
||||
|
||||
/**
|
||||
* Every skill with who gets it. Super admins add, edit and delete them;
|
||||
* admins choose which bots and workspaces get each one.
|
||||
*/
|
||||
export default function SkillsSettingsSection() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const [overview, setOverview] = useState(pageProps?.skills_overview);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [status, setStatus] = useState<Status>();
|
||||
/**
|
||||
* Remounts the rows when the data changes under them.
|
||||
*/
|
||||
const [version, setVersion] = useState(0);
|
||||
|
||||
if (!overview) return null;
|
||||
|
||||
const changed = (next: SkillsOverviewPayload, msg: string) => {
|
||||
setOverview(next);
|
||||
setStatus({ success: true, msg });
|
||||
setVersion((prev) => prev + 1);
|
||||
};
|
||||
const failed = (msg: string) => setStatus({ error: true, msg });
|
||||
|
||||
return (
|
||||
<SettingsGroup
|
||||
title="Skills"
|
||||
description="A runbook for a job a bot does: the steps, the checks and what good looks like. A bot sees each skill's name and when it applies; it reads the whole thing only when it loads it, so a long one costs nothing until it's needed. Give one to a bot on its settings page or in its chat, or to a workspace to reach every bot there."
|
||||
>
|
||||
{adding ? (
|
||||
<SkillModal
|
||||
onSaved={(next, msg) => {
|
||||
setAdding(false);
|
||||
changed(next, msg);
|
||||
}}
|
||||
onCancel={() => setAdding(false)}
|
||||
/>
|
||||
) : null}
|
||||
<SettingsList is_empty={!overview.skills.length} empty="No skills yet.">
|
||||
{overview.skills.map((skill) => (
|
||||
<SkillRow
|
||||
key={`${skill.id}-${version}`}
|
||||
skill={skill}
|
||||
targets={overview}
|
||||
onChanged={changed}
|
||||
onError={failed}
|
||||
/>
|
||||
))}
|
||||
</SettingsList>
|
||||
{overview.can_edit && !adding ? (
|
||||
<Button
|
||||
title="Add a skill"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="small"
|
||||
onClick={() => setAdding(true)}
|
||||
className="self-start gap-1!"
|
||||
>
|
||||
<Plus size={14} aria-hidden />
|
||||
New skill
|
||||
</Button>
|
||||
) : null}
|
||||
<SettingsStatus status={status} />
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { PagePropsType } from "@/src/types";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
import grabSkillsOverview from "@/src/functions/backend/db/extensions/grab-skills-overview";
|
||||
import grabCrewSettingsServerProps from "../(functions)/grab-crew-settings-server-props";
|
||||
|
||||
/**
|
||||
* Super admins and admins: admins only choose who gets each skill.
|
||||
*/
|
||||
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
const settings = await grabCrewSettingsServerProps(params);
|
||||
|
||||
if (!settings.props?.person?.id) return settings;
|
||||
|
||||
return {
|
||||
props: {
|
||||
...settings.props,
|
||||
skills_overview: await grabSkillsOverview({ user_id: settings.props.person.id }),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import SkillsSettingsSection from "./(sections)/skills-settings-section";
|
||||
import SettingsShell from "../(partials)/settings-shell";
|
||||
import settingsPageMeta from "../(functions)/settings-page-meta";
|
||||
|
||||
export default function AdminSettingsSkillsPage() {
|
||||
return (
|
||||
<SettingsShell title="Skills" description="Runbooks your bots follow when a job matches one.">
|
||||
<SkillsSettingsSection />
|
||||
</SettingsShell>
|
||||
);
|
||||
}
|
||||
|
||||
export const meta = settingsPageMeta({ label: "Skills" });
|
||||
+1
-1
@@ -68,7 +68,7 @@ export default function WorkspaceOverview() {
|
||||
<ExtensionsGroup
|
||||
owner={{ workspace_id }}
|
||||
owner_name={name}
|
||||
description="Every bot working here gets these, the coordinator too, on top of its own, only while it works here."
|
||||
description="Every bot working here gets these, the coordinator too, on top of its own, only while it works here: MCP servers, tools and skills."
|
||||
initial={pageProps?.workspace_extensions}
|
||||
/>
|
||||
<ScopedEnvsGroup initial={pageProps?.workspace_envs} />
|
||||
|
||||
Reference in New Issue
Block a user