Let the coordinator manage skills, MCP servers and tools, behind a confirm card
- Coordinator tools in its own chat (super admin only): list_extensions,
set_extension_access, delete_extension, and update_skill /
update_mcp_server / update_custom_tool for what can't change where
anything is sent (on/off, names, arguments, left-out tools)
- Adding a skill, MCP server or tool, or changing a skill's words, a
server's URL or headers or a tool's kind or config, goes through
propose_skill / propose_mcp_server / propose_custom_tool: stored in
extension_proposals with the bot's background check, shown as a
[proposal:ID] chip that opens a card with Confirm and Reject
- The card's facts are worked out by the app from what Confirm saves: the
host, every {{env.KEY}} and whether it's saved, the config; for a skill
its links, hosts, tool names, mentions of secrets and a diff against
the saved runbook
- Confirming decides once and checks again; a server or tool missing a key
is saved switched off, and the outcome is posted to the bot in chat
- Never from a bot: stdio servers, a URL whose host a template decides,
envs in a prompt tool, a key's value
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
import grabCrewRole from "@/src/functions/backend/db/access/grab-crew-role";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Throws unless the run's user is the super admin, who alone defines the
|
||||
* install's skills, MCP servers and tools. The coordinator only runs for
|
||||
* them, but the tools check again on every call, so a user who lost the
|
||||
* role mid-session can't change them.
|
||||
*/
|
||||
export default async function assertSuperAdminRun({ user_id }: Params) {
|
||||
if ((await grabCrewRole({ user_id })) != "super_admin") {
|
||||
throw new AppError(`Only the super admin can change the install's skills, MCP servers and tools.`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { z } from "zod";
|
||||
import deleteCustomTool from "@/src/functions/backend/db/extensions/delete-custom-tool";
|
||||
import deleteMcpServer from "@/src/functions/backend/db/extensions/delete-mcp-server";
|
||||
import deleteSkill from "@/src/functions/backend/db/extensions/delete-skill";
|
||||
import { ExtensionTables, type ExtensionKind } from "@/src/functions/backend/db/extensions/extension-tables";
|
||||
import logEvent from "@/src/functions/backend/logger";
|
||||
import type { HarnessTool } from "../../../types";
|
||||
import assertSuperAdminRun from "./assert-super-admin-run";
|
||||
import { ExtensionKindSchema } from "./extension-kind-schema";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
};
|
||||
|
||||
type Args = {
|
||||
kind: ExtensionKind;
|
||||
id: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lets the coordinator delete a skill, MCP server or custom tool. Every
|
||||
* bot and workspace loses it at once, so it only runs when the user asked
|
||||
* for that exact one to go.
|
||||
*/
|
||||
export default function deleteExtensionTool({ user_id }: Params): HarnessTool<Args> {
|
||||
return {
|
||||
name: "delete_extension",
|
||||
description: `Delete a skill, MCP server or custom tool by kind and id (list_extensions shows them). Every bot and workspace loses it at once and it can't be undone (an MCP server's install folder stays). Only do it when the user explicitly asked for this exact one to go; to take it from some bots use set_extension_access, to pause it save it with enabled false.`,
|
||||
schema: z.object({
|
||||
kind: ExtensionKindSchema,
|
||||
id: z.number().int().positive(),
|
||||
}),
|
||||
run: async ({ args }) => {
|
||||
await assertSuperAdminRun({ user_id });
|
||||
|
||||
const deleted =
|
||||
args.kind == "skill"
|
||||
? await deleteSkill({ skill_id: args.id })
|
||||
: args.kind == "mcp"
|
||||
? await deleteMcpServer({ mcp_server_id: args.id })
|
||||
: await deleteCustomTool({ custom_tool_id: args.id });
|
||||
|
||||
const { label } = ExtensionTables[args.kind];
|
||||
|
||||
await logEvent({
|
||||
title: `${label.charAt(0).toUpperCase()}${label.slice(1)} deleted`,
|
||||
category: "system",
|
||||
user_id: Number(user_id),
|
||||
plain_text: `The coordinator deleted the ${label} ${deleted.name} (#${deleted.id}).`,
|
||||
metadata: { kind: args.kind, id: deleted.id },
|
||||
});
|
||||
|
||||
return `The ${label} ${deleted.name} (#${deleted.id}) is deleted. No bot or workspace has it any more.`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
type Params = {
|
||||
all_bots: boolean;
|
||||
bot_ids: number[];
|
||||
workspace_ids: number[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Who gets an extension, as refs the chat renders as chips.
|
||||
*/
|
||||
export default function describeExtensionAccess({ all_bots, bot_ids, workspace_ids }: Params) {
|
||||
const bots = bot_ids.map((id) => `[bot:${id}]`).join(", ") || "none";
|
||||
const workspaces = workspace_ids.map((id) => `[workspace:${id}]`).join(", ") || "none";
|
||||
|
||||
return `${all_bots ? "every new bot gets it; " : ""}bots: ${bots}; workspaces: ${workspaces}`;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { CustomToolRecord, McpServerRecord, SkillRecord } from "@/src/types";
|
||||
import describeExtensionAccess from "./describe-extension-access";
|
||||
import describeTemplateMap from "./describe-template-map";
|
||||
|
||||
type Access = {
|
||||
bot_ids: number[];
|
||||
workspace_ids: number[];
|
||||
};
|
||||
|
||||
type Params = Access &
|
||||
(
|
||||
| { kind: "skill"; item: SkillRecord }
|
||||
| { kind: "mcp"; item: McpServerRecord }
|
||||
| { kind: "tool"; item: CustomToolRecord }
|
||||
) & {
|
||||
/**
|
||||
* Everything needed to edit it (the runbook, how a server is
|
||||
* reached, a tool's arguments and config), not just its line.
|
||||
*/
|
||||
full?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* One skill, MCP server or tool as the coordinator reads it: a line with
|
||||
* its id, name, state and who gets it, and with `full` the rest of it.
|
||||
*/
|
||||
export default function describeExtension(params: Params) {
|
||||
const access = describeExtensionAccess({ all_bots: params.item.all_bots, ...params });
|
||||
const state = params.item.enabled ? "on" : "off";
|
||||
|
||||
if (params.kind == "skill") {
|
||||
const { item } = params;
|
||||
const line = `skill #${item.id} "${item.name}" (slug ${item.slug}, ${state}): ${item.description}. Gets it: ${access}.`;
|
||||
|
||||
return params.full ? `${line}\n\nRunbook:\n${item.body}` : line;
|
||||
}
|
||||
|
||||
if (params.kind == "mcp") {
|
||||
const { item } = params;
|
||||
const status = item.status == "error" ? `error: ${item.last_error}` : item.status;
|
||||
const line = `MCP server #${item.id} "${item.name}" (slug ${item.slug}, ${item.transport}, ${state}, ${status}, ${item.tools.length} tools): ${item.description || "no description"}. Gets it: ${access}.`;
|
||||
|
||||
if (!params.full) return line;
|
||||
|
||||
const reach =
|
||||
item.transport == "remote"
|
||||
? [`URL: ${item.url}`, `Headers: ${describeTemplateMap({ map: item.headers })}`]
|
||||
: [
|
||||
`Command: ${item.command}`,
|
||||
`Setup command: ${item.setup_command || "none"}`,
|
||||
`Env: ${describeTemplateMap({ map: item.env })}`,
|
||||
];
|
||||
|
||||
return [
|
||||
line,
|
||||
...reach,
|
||||
`Left out tools: ${item.excluded_tools.join(", ") || "none"}`,
|
||||
`Tools: ${item.tools.map((tool) => tool.name).join(", ") || "none listed yet"}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
const { item } = params;
|
||||
const status = item.status == "error" ? `last run failed: ${item.last_error}` : item.status;
|
||||
const line = `tool #${item.id} ${item.name} (${item.kind}, ${state}, ${status}): ${item.description}. Gets it: ${access}.`;
|
||||
|
||||
if (!params.full) return line;
|
||||
|
||||
const config =
|
||||
item.kind == "http"
|
||||
? { ...item.config, headers: describeTemplateMap({ map: item.config.headers || {} }) }
|
||||
: item.config;
|
||||
|
||||
return [
|
||||
line,
|
||||
`Arguments schema: ${JSON.stringify(item.parameters)}`,
|
||||
`Config: ${JSON.stringify(config)}`,
|
||||
].join("\n");
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ExtensionProposalView } from "@/src/types";
|
||||
import proposalRef from "@/src/utils/proposal-ref";
|
||||
|
||||
type Params = {
|
||||
view: ExtensionProposalView;
|
||||
};
|
||||
|
||||
/**
|
||||
* What a bot is told once it proposed a server, tool or skill: the ref to put in
|
||||
* its reply, and the facts the card shows (worked out by the app), so it
|
||||
* tells the user the same thing the card does.
|
||||
*/
|
||||
export default function describeProposalForBot({ view }: Params) {
|
||||
const ref = proposalRef({ id: view.id });
|
||||
const keys = view.envs.length
|
||||
? view.envs
|
||||
.map((env) => `${env.key} (${env.where}): ${env.saved_in.length ? `saved, ${env.saved_in.join(", ")}` : "missing"}`)
|
||||
.join("; ")
|
||||
: "none";
|
||||
const missing = [...new Set(view.envs.filter((env) => !env.saved_in.length).map((env) => env.key))];
|
||||
|
||||
if (view.kind == "skill") {
|
||||
const line = (label: string) => view.details.find((detail) => detail.label == label)?.value || "none";
|
||||
|
||||
return [
|
||||
`Proposed as ${ref}. Nothing is saved yet: the user confirms or rejects it from the card ${ref} becomes in your reply, so put ${ref} in your reply.`,
|
||||
`The card shows the whole runbook${view.extension_id ? " and what changed" : ""}, and what it points bots at: hosts: ${line("Hosts")}; tool names: ${line("Tool names")}; about secrets: ${line("About secrets")}.`,
|
||||
`In your reply, say in plain words what the skill makes bots do, anything in it that sends something out or touches secrets, and where its content came from.`,
|
||||
`Don't say it's added: the user's next message says whether they confirmed it and what happened.`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
return [
|
||||
`Proposed as ${ref}. Nothing is saved yet: the user confirms or rejects it from the card ${ref} becomes in your reply, so put ${ref} in your reply.`,
|
||||
`The card shows: sends to ${view.host || "nothing from the app"}; keys: ${keys}.`,
|
||||
`In your reply, say in plain words what it is, where it sends and which keys it uses, naming the ones already saved and the ones missing${missing.length ? ` (${missing.join(", ")}: the user adds them in Settings, under Environment, and you never ask for their values in chat)` : ""}, plus what your background check found.`,
|
||||
`Don't say it's added: the user's next message says whether they confirmed it and what happened.`,
|
||||
].join("\n");
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import describeTemplateMap from "./describe-template-map";
|
||||
|
||||
describe("describeTemplateMap", () => {
|
||||
test("shows templates and hides literal values", () => {
|
||||
expect(
|
||||
describeTemplateMap({
|
||||
map: { Authorization: "{{env.API_KEY}}", "X-Token": "sk-live-123", Mixed: "Bearer {{env.KEY}}" },
|
||||
}),
|
||||
).toBe("Authorization: {{env.API_KEY}}; X-Token: (set); Mixed: Bearer {{env.KEY}}");
|
||||
});
|
||||
|
||||
test("says none for an empty map", () => {
|
||||
expect(describeTemplateMap({ map: {} })).toBe("none");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
type Params = {
|
||||
map: Record<string, string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* An MCP server's or tool's headers or env for a bot to read: a value built on templates
|
||||
* (`{{env.KEY}}`, `Bearer {{env.KEY}}`) is shown, a literal one only as
|
||||
* `(set)`, so a key typed straight into a header never reaches a
|
||||
* transcript.
|
||||
*/
|
||||
export default function describeTemplateMap({ map }: Params) {
|
||||
const entries = Object.entries(map);
|
||||
|
||||
if (!entries.length) return "none";
|
||||
|
||||
return entries
|
||||
.map(([key, value]) => `${key}: ${isTemplated({ value }) ? value : "(set)"}`)
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a value fills in at least one template and its literal rest is
|
||||
* no more than a short word like `Bearer`.
|
||||
*/
|
||||
function isTemplated({ value }: { value: string }) {
|
||||
const rest = value.replace(/\{\{[^}]*\}\}/g, "");
|
||||
|
||||
return rest.length < value.length && /^[A-Za-z\s:-]{0,12}$/.test(rest);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* The three kinds of extension a coordinator tool names: a skill, an MCP
|
||||
* server or a custom tool (`ExtensionTables`).
|
||||
*/
|
||||
export const ExtensionKindSchema = z
|
||||
.enum(["skill", "mcp", "tool"])
|
||||
.describe(`"skill" (a runbook), "mcp" (an MCP server) or "tool" (a custom tool)`);
|
||||
@@ -0,0 +1,52 @@
|
||||
import grabCrewRole from "@/src/functions/backend/db/access/grab-crew-role";
|
||||
import type { HarnessTool } from "../../../types";
|
||||
import deleteExtensionTool from "./delete-extension-tool";
|
||||
import listExtensionsTool from "./list-extensions-tool";
|
||||
import proposeCustomToolTool from "./propose-custom-tool-tool";
|
||||
import proposeMcpServerTool from "./propose-mcp-server-tool";
|
||||
import proposeSkillTool from "./propose-skill-tool";
|
||||
import setExtensionAccessTool from "./set-extension-access-tool";
|
||||
import updateCustomToolTool from "./update-custom-tool-tool";
|
||||
import updateMcpServerTool from "./update-mcp-server-tool";
|
||||
import updateSkillTool from "./update-skill-tool";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
bot_id: number | string;
|
||||
/**
|
||||
* The conversation a proposal is made in, where its card shows.
|
||||
*/
|
||||
session_id: number | string;
|
||||
workspace_id?: number | string | null;
|
||||
/**
|
||||
* Called with each proposal's ref, so the reply carries its card.
|
||||
*/
|
||||
onPropose?: (params: { ref: string }) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The coordinator's tools for the install's skills, MCP servers and custom
|
||||
* tools (what Settings has under Skills, MCPs and Tools): read them,
|
||||
* change what's safe directly (on or off, names and arguments that don't
|
||||
* change where anything is sent), propose adding one or changing what it
|
||||
* does (the user confirms from a card), delete one, and decide who gets
|
||||
* it. Only for the super admin, who alone defines
|
||||
* them; each tool checks again when it runs.
|
||||
*/
|
||||
export default async function grabCoordinatorExtensionTools(params: Params): Promise<HarnessTool[]> {
|
||||
const { user_id } = params;
|
||||
|
||||
if ((await grabCrewRole({ user_id })) != "super_admin") return [];
|
||||
|
||||
return [
|
||||
listExtensionsTool({ user_id }),
|
||||
proposeSkillTool(params),
|
||||
proposeMcpServerTool(params),
|
||||
proposeCustomToolTool(params),
|
||||
updateSkillTool({ user_id }),
|
||||
updateMcpServerTool({ user_id }),
|
||||
updateCustomToolTool({ user_id }),
|
||||
deleteExtensionTool({ user_id }),
|
||||
setExtensionAccessTool({ user_id }),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import { z } from "zod";
|
||||
import type {
|
||||
BUN_SQLITE_COBBLE_CUSTOM_TOOLS,
|
||||
BUN_SQLITE_COBBLE_MCP_SERVERS,
|
||||
BUN_SQLITE_COBBLE_SKILLS,
|
||||
} from "@/db/types/db";
|
||||
import grabCustomTool from "@/src/functions/backend/db/extensions/grab-custom-tool";
|
||||
import grabExtensionAccess from "@/src/functions/backend/db/extensions/grab-extension-access";
|
||||
import grabMcpServer from "@/src/functions/backend/db/extensions/grab-mcp-server";
|
||||
import grabAllEnvs from "@/src/functions/backend/db/envs/grab-all-envs";
|
||||
import grabSkill from "@/src/functions/backend/db/extensions/grab-skill";
|
||||
import parseCustomToolRow from "@/src/functions/backend/db/extensions/parse-custom-tool-row";
|
||||
import parseMcpServerRow from "@/src/functions/backend/db/extensions/parse-mcp-server-row";
|
||||
import parseSkillRow from "@/src/functions/backend/db/extensions/parse-skill-row";
|
||||
import type { ExtensionKind } from "@/src/functions/backend/db/extensions/extension-tables";
|
||||
import redactSecrets from "@/src/utils/redact-secrets";
|
||||
import type { HarnessTool } from "../../../types";
|
||||
import assertSuperAdminRun from "./assert-super-admin-run";
|
||||
import describeExtension from "./describe-extension";
|
||||
import { ExtensionKindSchema } from "./extension-kind-schema";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
};
|
||||
|
||||
type Args = {
|
||||
kind?: ExtensionKind;
|
||||
id?: number;
|
||||
query?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lets the coordinator read the install's skills, MCP servers and custom
|
||||
* tools: every one as a line, or one in full (the runbook, how a server is
|
||||
* reached, a tool's arguments and config) before it changes it. Literal
|
||||
* header and env values and every secret env's value stay hidden.
|
||||
*/
|
||||
export default function listExtensionsTool({ user_id }: Params): HarnessTool<Args> {
|
||||
return {
|
||||
name: "list_extensions",
|
||||
description: `List the install's skills (runbooks), MCP servers and custom tools, with their ids, whether they're on, how they're doing and which bots and workspaces get them. Give kind and id to read one in full (a skill's runbook, how an MCP server is reached, a tool's arguments and config) before you change it. query filters by name, slug or description.`,
|
||||
schema: z
|
||||
.object({
|
||||
kind: ExtensionKindSchema.optional(),
|
||||
id: z.number().int().positive().optional(),
|
||||
query: z.string().trim().max(200).optional(),
|
||||
})
|
||||
.refine((args) => !args.id || args.kind, "Give the kind with the id."),
|
||||
run: async ({ args }) => {
|
||||
await assertSuperAdminRun({ user_id });
|
||||
|
||||
const text = args.id && args.kind ? await describeOne({ kind: args.kind, id: args.id }) : await describeAll(args);
|
||||
const secrets = (await grabAllEnvs()).filter((env) => env.secret);
|
||||
|
||||
return redactSecrets({ text, secrets });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function describeOne({ kind, id }: { kind: ExtensionKind; id: number }) {
|
||||
const access = (await grabExtensionAccess({ kind }))(id);
|
||||
|
||||
if (kind == "skill") return describeExtension({ kind, item: await grabSkill({ skill_id: id }), ...access, full: true });
|
||||
if (kind == "mcp") return describeExtension({ kind, item: await grabMcpServer({ mcp_server_id: id }), ...access, full: true });
|
||||
|
||||
return describeExtension({ kind, item: await grabCustomTool({ custom_tool_id: id }), ...access, full: true });
|
||||
}
|
||||
|
||||
async function describeAll({ kind, query }: Args) {
|
||||
const [skills, servers, tools, skill_access, mcp_access, tool_access] = await Promise.all([
|
||||
BunSQLite.sql<BUN_SQLITE_COBBLE_SKILLS>({ sql: `SELECT * FROM skills ORDER BY name COLLATE NOCASE, id` }),
|
||||
BunSQLite.sql<BUN_SQLITE_COBBLE_MCP_SERVERS>({ sql: `SELECT * FROM mcp_servers ORDER BY name COLLATE NOCASE, id` }),
|
||||
BunSQLite.sql<BUN_SQLITE_COBBLE_CUSTOM_TOOLS>({ sql: `SELECT * FROM custom_tools ORDER BY name COLLATE NOCASE, id` }),
|
||||
grabExtensionAccess({ kind: "skill" }),
|
||||
grabExtensionAccess({ kind: "mcp" }),
|
||||
grabExtensionAccess({ kind: "tool" }),
|
||||
]);
|
||||
|
||||
const needle = (query || "").toLowerCase();
|
||||
const matches = (fields: string[]) => !needle || fields.some((field) => field.toLowerCase().includes(needle));
|
||||
|
||||
const lines = [
|
||||
...(!kind || kind == "skill"
|
||||
? (skills.payload || [])
|
||||
.map((row) => parseSkillRow({ row }))
|
||||
.filter((item) => matches([item.name, item.slug, item.description]))
|
||||
.map((item) => describeExtension({ kind: "skill", item, ...skill_access(item.id) }))
|
||||
: []),
|
||||
...(!kind || kind == "mcp"
|
||||
? (servers.payload || [])
|
||||
.map((row) => parseMcpServerRow({ row }))
|
||||
.filter((item) => matches([item.name, item.slug, item.description]))
|
||||
.map((item) => describeExtension({ kind: "mcp", item, ...mcp_access(item.id) }))
|
||||
: []),
|
||||
...(!kind || kind == "tool"
|
||||
? (tools.payload || [])
|
||||
.map((row) => parseCustomToolRow({ row }))
|
||||
.filter((item) => matches([item.name, item.description]))
|
||||
.map((item) => describeExtension({ kind: "tool", item, ...tool_access(item.id) }))
|
||||
: []),
|
||||
];
|
||||
|
||||
return lines.join("\n") || (query ? `Nothing matches "${query}".` : `There are none yet.`);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { CustomToolRecord } from "@/src/types";
|
||||
import mergeCustomToolChanges from "./merge-custom-tool-changes";
|
||||
|
||||
const weather: CustomToolRecord = {
|
||||
id: 4,
|
||||
name: "weather",
|
||||
description: "Today's weather for a city",
|
||||
parameters: { type: "object", properties: { city: { type: "string" } } },
|
||||
all_bots: false,
|
||||
enabled: true,
|
||||
status: "ok",
|
||||
last_error: "",
|
||||
last_checked_at: null,
|
||||
kind: "http",
|
||||
config: {
|
||||
method: "GET",
|
||||
url: "https://api.weather.example/v1?city={{args.city}}",
|
||||
headers: { Authorization: "Bearer {{env.WEATHER_KEY}}", Accept: "application/json" },
|
||||
body: "",
|
||||
},
|
||||
};
|
||||
|
||||
describe("mergeCustomToolChanges", () => {
|
||||
test("keeps the config it isn't given and changes headers one at a time", () => {
|
||||
const data = mergeCustomToolChanges({
|
||||
current: weather,
|
||||
changes: { config: { method: "POST", headers: { Accept: null, "X-Unit": "metric" } } },
|
||||
});
|
||||
|
||||
expect(data.kind).toBe("http");
|
||||
expect(data.config).toMatchObject({
|
||||
method: "POST",
|
||||
url: weather.config.url,
|
||||
headers: { Authorization: "Bearer {{env.WEATHER_KEY}}", "X-Unit": "metric" },
|
||||
});
|
||||
});
|
||||
|
||||
test("starts the config over when the kind changes", () => {
|
||||
const data = mergeCustomToolChanges({
|
||||
current: weather,
|
||||
changes: { kind: "prompt", config: { prompt: "Describe the weather in {{args.city}}" } },
|
||||
});
|
||||
|
||||
expect(data.config).toEqual({ prompt: "Describe the weather in {{args.city}}" });
|
||||
});
|
||||
|
||||
test("checks the result like the settings form", () => {
|
||||
expect(() => mergeCustomToolChanges({ current: null, changes: { name: "new_one", kind: "http" } })).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { CustomToolHttpMethods, CustomToolKind, CustomToolScriptLanguage } from "@/src/dict/custom-tool-dict";
|
||||
import { SaveCustomToolRequestSchema, type SaveCustomToolRequest } from "@/src/schemas/extension-schema";
|
||||
import type { CustomToolRecord } from "@/src/types";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import mergeTemplateMap from "./merge-template-map";
|
||||
|
||||
export type CustomToolConfigChanges = {
|
||||
method?: (typeof CustomToolHttpMethods)[number];
|
||||
url?: string;
|
||||
/**
|
||||
* Entries to set, a null removing one.
|
||||
*/
|
||||
headers?: Record<string, string | null>;
|
||||
body?: string;
|
||||
timeout_ms?: number;
|
||||
language?: CustomToolScriptLanguage;
|
||||
code?: string;
|
||||
prompt?: string;
|
||||
};
|
||||
|
||||
export type CustomToolChanges = {
|
||||
name?: string;
|
||||
description?: string;
|
||||
kind?: CustomToolKind;
|
||||
parameters?: Record<string, unknown>;
|
||||
config?: CustomToolConfigChanges;
|
||||
enabled?: boolean;
|
||||
all_bots?: boolean;
|
||||
};
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* The tool being changed, null for a new one.
|
||||
*/
|
||||
current: CustomToolRecord | null;
|
||||
changes: CustomToolChanges;
|
||||
};
|
||||
|
||||
/**
|
||||
* A custom tool as a bot's changes would leave it: every field it leaves
|
||||
* out kept (config fields too while the kind stays), an HTTP tool's
|
||||
* headers changed entry by entry, then checked like the settings page's
|
||||
* form (`SaveCustomToolRequestSchema`).
|
||||
*/
|
||||
export default function mergeCustomToolChanges({ current, changes }: Params): SaveCustomToolRequest {
|
||||
const kind = changes.kind ?? current?.kind;
|
||||
const kept: Record<string, unknown> = current && current.kind == kind ? { ...current.config } : {};
|
||||
const { headers, ...config_changes } = changes.config || {};
|
||||
const config: Record<string, unknown> = { ...kept, ...config_changes };
|
||||
|
||||
if (kind == "http") {
|
||||
config.headers = mergeTemplateMap({
|
||||
current: (kept.headers as Record<string, string>) || {},
|
||||
changes: headers,
|
||||
});
|
||||
}
|
||||
|
||||
return parseWithSchema({
|
||||
schema: SaveCustomToolRequestSchema,
|
||||
data: {
|
||||
custom_tool_id: current?.id,
|
||||
name: changes.name ?? current?.name,
|
||||
description: changes.description ?? current?.description,
|
||||
kind,
|
||||
parameters: changes.parameters ?? current?.parameters ?? { type: "object", properties: {} },
|
||||
config,
|
||||
enabled: changes.enabled ?? current?.enabled ?? true,
|
||||
all_bots: changes.all_bots ?? current?.all_bots ?? false,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { SaveMcpServerRequestSchema, type SaveMcpServerRequest } from "@/src/schemas/extension-schema";
|
||||
import type { McpServerRecord } from "@/src/types";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import mergeTemplateMap from "./merge-template-map";
|
||||
|
||||
export type McpServerChanges = {
|
||||
name?: string;
|
||||
description?: string;
|
||||
url?: string;
|
||||
/**
|
||||
* Entries to set, a null removing one.
|
||||
*/
|
||||
headers?: Record<string, string | null>;
|
||||
excluded_tools?: string[];
|
||||
enabled?: boolean;
|
||||
all_bots?: boolean;
|
||||
};
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* The server being changed, null for a new one.
|
||||
*/
|
||||
current: McpServerRecord | null;
|
||||
changes: McpServerChanges;
|
||||
};
|
||||
|
||||
/**
|
||||
* A remote MCP server as a bot's changes would leave it: every field it
|
||||
* leaves out kept, headers changed entry by entry, then checked like the
|
||||
* settings page's form (`SaveMcpServerRequestSchema`). A stdio server runs
|
||||
* a command in the app's container, so a bot never builds one.
|
||||
*/
|
||||
export default function mergeMcpServerChanges({ current, changes }: Params): SaveMcpServerRequest {
|
||||
if (current?.transport == "stdio" && (changes.url !== undefined || changes.headers !== undefined)) {
|
||||
throw new AppError(
|
||||
`${current.name} runs a command here (stdio): how it starts is changed by the user in Settings, under MCPs.`,
|
||||
);
|
||||
}
|
||||
|
||||
return parseWithSchema({
|
||||
schema: SaveMcpServerRequestSchema,
|
||||
data: {
|
||||
mcp_server_id: current?.id,
|
||||
name: changes.name ?? current?.name,
|
||||
description: changes.description ?? current?.description ?? "",
|
||||
transport: current?.transport ?? "remote",
|
||||
url: changes.url ?? current?.url ?? "",
|
||||
headers: mergeTemplateMap({ current: current?.headers || {}, changes: changes.headers }),
|
||||
command: current?.command ?? "",
|
||||
setup_command: current?.setup_command ?? "",
|
||||
env: current?.env ?? {},
|
||||
excluded_tools: changes.excluded_tools ?? current?.excluded_tools ?? [],
|
||||
enabled: changes.enabled ?? current?.enabled ?? true,
|
||||
all_bots: changes.all_bots ?? current?.all_bots ?? false,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import mergeTemplateMap from "./merge-template-map";
|
||||
|
||||
describe("mergeTemplateMap", () => {
|
||||
test("sets, replaces and removes entries, keeping the rest", () => {
|
||||
expect(
|
||||
mergeTemplateMap({
|
||||
current: { A: "1", B: "2", C: "3" },
|
||||
changes: { B: "two", C: null, D: "4" },
|
||||
}),
|
||||
).toEqual({ A: "1", B: "two", D: "4" });
|
||||
});
|
||||
|
||||
test("leaves the map alone without changes", () => {
|
||||
expect(mergeTemplateMap({ current: { A: "1" } })).toEqual({ A: "1" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
type Params = {
|
||||
current: Record<string, string>;
|
||||
/**
|
||||
* The entries to set, a null removing one.
|
||||
*/
|
||||
changes?: Record<string, string | null>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Applies a bot's header changes to what an MCP server or tool has, so it
|
||||
* changes one entry without having to repeat (or ever read) the others.
|
||||
*/
|
||||
export default function mergeTemplateMap({ current, changes }: Params) {
|
||||
const merged = { ...current };
|
||||
|
||||
for (const [key, value] of Object.entries(changes || {})) {
|
||||
if (value === null) delete merged[key];
|
||||
else merged[key] = value;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { z } from "zod";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
|
||||
/**
|
||||
* The background check every proposal carries: the bot's findings and the
|
||||
* pages it read, shown on the card as the bot's own words.
|
||||
*/
|
||||
export const ProposalResearchFields = {
|
||||
research: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(AppData.ExtensionResearchMinChars, "Write down what your background check found")
|
||||
.max(AppData.ExtensionResearchMaxChars)
|
||||
.describe(
|
||||
"What your background check found: who publishes it, whether this URL is their official one, what it needs (keys, accounts, plans), and anything that worried you",
|
||||
),
|
||||
sources: z
|
||||
.array(z.url({ protocol: /^https?$/ }))
|
||||
.min(1, "Give the pages your background check read")
|
||||
.max(AppData.ExtensionResearchSourcesMax)
|
||||
.describe("The pages you read for the check, full URLs, the publisher's own docs first"),
|
||||
};
|
||||
|
||||
/**
|
||||
* A skill's review: the bot's own read of what the runbook makes bots do,
|
||||
* and where its content came from. Sources are optional, since a bot
|
||||
* often writes a runbook itself.
|
||||
*/
|
||||
export const SkillReviewFields = {
|
||||
research: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(AppData.ExtensionResearchMinChars, "Write down your review of the runbook")
|
||||
.max(AppData.ExtensionResearchMaxChars)
|
||||
.describe(
|
||||
"Your review of the runbook: what it makes bots do step by step, any step that sends data, files or keys anywhere or reads secrets, and where its content came from (written by you, the user's words, or copied from a page)",
|
||||
),
|
||||
sources: z
|
||||
.array(z.url({ protocol: /^https?$/ }))
|
||||
.max(AppData.ExtensionResearchSourcesMax)
|
||||
.default([])
|
||||
.describe("Pages any of it was copied or adapted from, full URLs"),
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
CustomToolHttpMethods,
|
||||
CustomToolKinds,
|
||||
CustomToolScriptLanguages,
|
||||
type CustomToolKind,
|
||||
type CustomToolScriptLanguage,
|
||||
} from "@/src/dict/custom-tool-dict";
|
||||
import createExtensionProposal from "@/src/functions/backend/db/extension-proposals/create-extension-proposal";
|
||||
import grabExtensionProposalView from "@/src/functions/backend/db/extension-proposals/grab-extension-proposal-view";
|
||||
import grabCustomTool from "@/src/functions/backend/db/extensions/grab-custom-tool";
|
||||
import proposalRef from "@/src/utils/proposal-ref";
|
||||
import type { HarnessTool } from "../../../types";
|
||||
import assertSuperAdminRun from "./assert-super-admin-run";
|
||||
import describeProposalForBot from "./describe-proposal-for-bot";
|
||||
import mergeCustomToolChanges, { type CustomToolChanges } from "./merge-custom-tool-changes";
|
||||
import { ProposalResearchFields } from "./proposal-research-schema";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
bot_id: number | string;
|
||||
session_id: number | string;
|
||||
workspace_id?: number | string | null;
|
||||
/**
|
||||
* Called with the proposal's ref, so the reply always carries its card.
|
||||
*/
|
||||
onPropose?: (params: { ref: string }) => void;
|
||||
};
|
||||
|
||||
type Args = CustomToolChanges & {
|
||||
custom_tool_id?: number;
|
||||
research: string;
|
||||
sources: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Lets the coordinator propose a custom tool, or a change to what one runs
|
||||
* and where it sends (its kind and config), for the user to confirm from a
|
||||
* card in the chat. Nothing is saved until they do: the card shows the
|
||||
* request or code, every key it sends (saved or missing, worked out by
|
||||
* the app) and the bot's background check.
|
||||
*/
|
||||
export default function proposeCustomToolTool({ user_id, bot_id, session_id, workspace_id, onPropose }: Params): HarnessTool<Args> {
|
||||
return {
|
||||
name: "propose_custom_tool",
|
||||
description: `Propose adding a custom tool bots can call, or changing an existing one's kind or config (custom_tool_id), for the user to confirm. Nothing is saved until they press Confirm on the card. First run a background check with your web tools on the API or service it calls (its official docs, the right endpoint, what it needs) and give the findings in research and the pages in sources. name is snake_case and can't be a built-in tool's; description tells bots what it does; parameters is the JSON schema of its arguments (an object schema, e.g. {"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}). kind and its config: "http" (method, url with its host written out, headers, body; the app sends it, {{args.x}} fills in an argument, with no body the arguments go as JSON; for a key refer to an env as {{env.KEY}} in a header, the body or the URL's path or query, reusing a saved env when it's the same key, never its value), "script" (language bash, python or javascript, and code; runs on the bot's computer, arguments as JSON in $COBBLE_ARGS and on stdin), "prompt" (prompt; one model turn with the bot's models, no envs). Changing one keeps the config fields you leave out while the kind stays; headers change entry by entry, a null value removing one. Check list_extensions first: if the tool is already there, propose a change to it instead.`,
|
||||
schema: z.object({
|
||||
custom_tool_id: z.number().int().positive().optional(),
|
||||
name: z.string().trim().max(80).optional(),
|
||||
description: z.string().trim().max(2000).optional(),
|
||||
kind: z.enum(CustomToolKinds.map((k) => k.value) as [CustomToolKind, ...CustomToolKind[]]).optional(),
|
||||
parameters: z.record(z.string(), z.unknown()).optional().describe("JSON schema of the arguments object"),
|
||||
config: z
|
||||
.object({
|
||||
method: z.enum(CustomToolHttpMethods).optional(),
|
||||
url: z.string().trim().max(4000).optional(),
|
||||
headers: z.record(z.string().trim().min(1).max(200), z.string().max(4000).nullable()).optional(),
|
||||
body: z.string().max(20000).optional(),
|
||||
timeout_ms: z.number().int().positive().optional(),
|
||||
language: z
|
||||
.enum(CustomToolScriptLanguages.map((l) => l.value) as [CustomToolScriptLanguage, ...CustomToolScriptLanguage[]])
|
||||
.optional(),
|
||||
code: z.string().max(60000).optional(),
|
||||
prompt: z.string().trim().max(20000).optional(),
|
||||
})
|
||||
.optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
all_bots: z.boolean().optional(),
|
||||
...ProposalResearchFields,
|
||||
}),
|
||||
run: async ({ args }) => {
|
||||
await assertSuperAdminRun({ user_id });
|
||||
|
||||
const { custom_tool_id, research, sources, ...changes } = args;
|
||||
const current = custom_tool_id ? await grabCustomTool({ custom_tool_id }) : null;
|
||||
const data = mergeCustomToolChanges({ current, changes });
|
||||
|
||||
const id = await createExtensionProposal({
|
||||
user_id,
|
||||
bot_id,
|
||||
session_id,
|
||||
workspace_id,
|
||||
kind: "tool",
|
||||
data,
|
||||
research,
|
||||
sources,
|
||||
});
|
||||
|
||||
onPropose?.({ ref: proposalRef({ id }) });
|
||||
|
||||
return describeProposalForBot({ view: await grabExtensionProposalView({ proposal_id: id, user_id }) });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { z } from "zod";
|
||||
import createExtensionProposal from "@/src/functions/backend/db/extension-proposals/create-extension-proposal";
|
||||
import grabExtensionProposalView from "@/src/functions/backend/db/extension-proposals/grab-extension-proposal-view";
|
||||
import grabMcpServer from "@/src/functions/backend/db/extensions/grab-mcp-server";
|
||||
import proposalRef from "@/src/utils/proposal-ref";
|
||||
import type { HarnessTool } from "../../../types";
|
||||
import assertSuperAdminRun from "./assert-super-admin-run";
|
||||
import describeProposalForBot from "./describe-proposal-for-bot";
|
||||
import mergeMcpServerChanges, { type McpServerChanges } from "./merge-mcp-server-changes";
|
||||
import { ProposalResearchFields } from "./proposal-research-schema";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
bot_id: number | string;
|
||||
session_id: number | string;
|
||||
workspace_id?: number | string | null;
|
||||
/**
|
||||
* Called with the proposal's ref, so the reply always carries its card.
|
||||
*/
|
||||
onPropose?: (params: { ref: string }) => void;
|
||||
};
|
||||
|
||||
type Args = McpServerChanges & {
|
||||
mcp_server_id?: number;
|
||||
research: string;
|
||||
sources: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Lets the coordinator propose a remote MCP server, or a change to where
|
||||
* one connects and what it sends, for the user to confirm from a card in
|
||||
* the chat. Nothing is saved or connected until they do: the card shows
|
||||
* the host, every key it sends (saved or missing, worked out by the app)
|
||||
* and the bot's background check.
|
||||
*/
|
||||
export default function proposeMcpServerTool({ user_id, bot_id, session_id, workspace_id, onPropose }: Params): HarnessTool<Args> {
|
||||
return {
|
||||
name: "propose_mcp_server",
|
||||
description: `Propose adding a remote MCP server, or changing an existing one's url or headers (mcp_server_id), for the user to confirm. Nothing is saved or connected until they press Confirm on the card. First run a background check with your web tools: find the publisher's own docs, make sure the url is their official server, and note what it needs. Give the findings in research and the pages in sources. url is the full https URL with its host written out. headers change entry by entry (a null value removes one); for a key, refer to an env as {{env.KEY}}, e.g. "Authorization": "Bearer {{env.GITHUB_TOKEN}}", reusing a saved env when it's the same key, and never write a key's value in. Check list_extensions first: if the server is already there, propose a change to it instead. A server that runs a command here (stdio) is set up by the user in Settings, under MCPs.`,
|
||||
schema: z.object({
|
||||
mcp_server_id: z.number().int().positive().optional(),
|
||||
name: z.string().trim().max(80).optional(),
|
||||
description: z.string().trim().max(1000).optional(),
|
||||
url: z.string().trim().max(2000).optional(),
|
||||
headers: z.record(z.string().trim().min(1).max(200), z.string().max(4000).nullable()).optional(),
|
||||
excluded_tools: z.array(z.string().max(200)).max(500).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
all_bots: z.boolean().optional(),
|
||||
...ProposalResearchFields,
|
||||
}),
|
||||
run: async ({ args }) => {
|
||||
await assertSuperAdminRun({ user_id });
|
||||
|
||||
const { mcp_server_id, research, sources, ...changes } = args;
|
||||
const current = mcp_server_id ? await grabMcpServer({ mcp_server_id }) : null;
|
||||
const data = mergeMcpServerChanges({ current, changes });
|
||||
|
||||
const id = await createExtensionProposal({
|
||||
user_id,
|
||||
bot_id,
|
||||
session_id,
|
||||
workspace_id,
|
||||
kind: "mcp",
|
||||
data,
|
||||
research,
|
||||
sources,
|
||||
});
|
||||
|
||||
onPropose?.({ ref: proposalRef({ id }) });
|
||||
|
||||
return describeProposalForBot({ view: await grabExtensionProposalView({ proposal_id: id, user_id }) });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { z } from "zod";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import createExtensionProposal from "@/src/functions/backend/db/extension-proposals/create-extension-proposal";
|
||||
import grabExtensionProposalView from "@/src/functions/backend/db/extension-proposals/grab-extension-proposal-view";
|
||||
import grabSkill from "@/src/functions/backend/db/extensions/grab-skill";
|
||||
import { SaveSkillRequestSchema } from "@/src/schemas/extension-schema";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import proposalRef from "@/src/utils/proposal-ref";
|
||||
import type { HarnessTool } from "../../../types";
|
||||
import assertSuperAdminRun from "./assert-super-admin-run";
|
||||
import describeProposalForBot from "./describe-proposal-for-bot";
|
||||
import { SkillReviewFields } from "./proposal-research-schema";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
bot_id: number | string;
|
||||
session_id: number | string;
|
||||
workspace_id?: number | string | null;
|
||||
/**
|
||||
* Called with the proposal's ref, so the reply always carries its card.
|
||||
*/
|
||||
onPropose?: (params: { ref: string }) => void;
|
||||
};
|
||||
|
||||
type Args = {
|
||||
skill_id?: number;
|
||||
name?: string;
|
||||
slug?: string;
|
||||
description?: string;
|
||||
body?: string;
|
||||
enabled?: boolean;
|
||||
all_bots?: boolean;
|
||||
research: string;
|
||||
sources: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Lets the coordinator propose a skill, or a change to one's name, slug,
|
||||
* description or runbook, for the user to confirm from a card in the
|
||||
* chat. A runbook steers every bot that loads it, so nothing is saved
|
||||
* until they do: the card shows the whole runbook, what changed, what it
|
||||
* points bots at (links, hosts, tool names, anything about secrets, read
|
||||
* by the app) and the bot's review.
|
||||
*/
|
||||
export default function proposeSkillTool({ user_id, bot_id, session_id, workspace_id, onPropose }: Params): HarnessTool<Args> {
|
||||
return {
|
||||
name: "propose_skill",
|
||||
description: `Propose adding a skill (a runbook bots load with load_skill when its one-line description says it applies), or changing one's name, slug, description or body (skill_id), for the user to confirm. Nothing is saved until they press Confirm on the card. Changing one keeps every field you leave out; body replaces the whole runbook, so read it first with list_extensions and keep what still applies. A new one needs name, slug (lowercase words with hyphens, how bots load it), description and body. First review the runbook yourself and give it in research: what it makes bots do, any step that sends data, files or keys anywhere or reads secrets, and where the content came from (add the pages to sources if any of it came from one). Never put a key's value in a runbook. Check list_extensions first: if a skill already covers the job, propose a change to it instead.`,
|
||||
schema: z.object({
|
||||
skill_id: z.number().int().positive().optional(),
|
||||
name: z.string().trim().max(80).optional(),
|
||||
slug: z.string().trim().max(80).optional(),
|
||||
description: z.string().trim().max(AppData.SkillDescriptionMaxChars).optional(),
|
||||
body: z.string().max(AppData.SkillBodyMaxChars).optional().describe("The runbook, markdown"),
|
||||
enabled: z.boolean().optional(),
|
||||
all_bots: z.boolean().optional(),
|
||||
...SkillReviewFields,
|
||||
}),
|
||||
run: async ({ args }) => {
|
||||
await assertSuperAdminRun({ user_id });
|
||||
|
||||
const current = args.skill_id ? await grabSkill({ skill_id: args.skill_id }) : null;
|
||||
const data = parseWithSchema({
|
||||
schema: SaveSkillRequestSchema,
|
||||
data: {
|
||||
skill_id: current?.id,
|
||||
name: args.name ?? current?.name,
|
||||
slug: args.slug ?? current?.slug,
|
||||
description: args.description ?? current?.description,
|
||||
body: args.body ?? current?.body,
|
||||
enabled: args.enabled ?? current?.enabled ?? true,
|
||||
all_bots: args.all_bots ?? current?.all_bots ?? false,
|
||||
},
|
||||
});
|
||||
|
||||
const id = await createExtensionProposal({
|
||||
user_id,
|
||||
bot_id,
|
||||
session_id,
|
||||
workspace_id,
|
||||
kind: "skill",
|
||||
data,
|
||||
research: args.research,
|
||||
sources: args.sources,
|
||||
});
|
||||
|
||||
onPropose?.({ ref: proposalRef({ id }) });
|
||||
|
||||
return describeProposalForBot({ view: await grabExtensionProposalView({ proposal_id: id, user_id }) });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import { z } from "zod";
|
||||
import { ExtensionTables, type ExtensionKind } from "@/src/functions/backend/db/extensions/extension-tables";
|
||||
import grabExtensionAccess from "@/src/functions/backend/db/extensions/grab-extension-access";
|
||||
import setExtensionAccess from "@/src/functions/backend/db/extensions/set-extension-access";
|
||||
import logEvent from "@/src/functions/backend/logger";
|
||||
import type { HarnessTool } from "../../../types";
|
||||
import assertSuperAdminRun from "./assert-super-admin-run";
|
||||
import describeExtensionAccess from "./describe-extension-access";
|
||||
import { ExtensionKindSchema } from "./extension-kind-schema";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
};
|
||||
|
||||
type Args = {
|
||||
kind: ExtensionKind;
|
||||
id: number;
|
||||
bot_ids?: number[];
|
||||
workspace_ids?: number[];
|
||||
all_bots?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lets the coordinator decide which bots and workspaces get a skill, MCP
|
||||
* server or custom tool, as the settings pages' Access modal does
|
||||
* (`setExtensionAccess`), then reads the result back.
|
||||
*/
|
||||
export default function setExtensionAccessTool({ user_id }: Params): HarnessTool<Args> {
|
||||
return {
|
||||
name: "set_extension_access",
|
||||
description: `Set which bots and workspaces get a skill, MCP server or custom tool (by kind and id). bot_ids and workspace_ids are the complete lists it ends up with: read the current ones with list_extensions and add to or take from them; a list you leave out stays as it is. A workspace's gives it to every bot working there. all_bots true also gives it to every bot now and to every new bot; false only stops new bots getting it.`,
|
||||
schema: z.object({
|
||||
kind: ExtensionKindSchema,
|
||||
id: z.number().int().positive(),
|
||||
bot_ids: z.array(z.number().int().positive()).max(1000).optional(),
|
||||
workspace_ids: z.array(z.number().int().positive()).max(1000).optional(),
|
||||
all_bots: z.boolean().optional(),
|
||||
}),
|
||||
run: async ({ args }) => {
|
||||
await assertSuperAdminRun({ user_id });
|
||||
|
||||
const current = (await grabExtensionAccess({ kind: args.kind }))(args.id);
|
||||
|
||||
await setExtensionAccess({
|
||||
user_id,
|
||||
kind: args.kind,
|
||||
id: args.id,
|
||||
bot_ids: args.bot_ids ?? current.bot_ids,
|
||||
workspace_ids: args.workspace_ids ?? current.workspace_ids,
|
||||
all_bots: args.all_bots,
|
||||
});
|
||||
|
||||
const saved = (await grabExtensionAccess({ kind: args.kind }))(args.id);
|
||||
const { label, table } = ExtensionTables[args.kind];
|
||||
const all_bots =
|
||||
(
|
||||
await BunSQLite.sql<{ all_bots: number }>({
|
||||
sql: `SELECT all_bots FROM ${table} WHERE id = ?`,
|
||||
values: [args.id],
|
||||
})
|
||||
).payload?.[0]?.all_bots == 1;
|
||||
|
||||
await logEvent({
|
||||
title: `${label.charAt(0).toUpperCase()}${label.slice(1)} access changed`,
|
||||
category: "system",
|
||||
user_id: Number(user_id),
|
||||
plain_text: `The coordinator changed who gets the ${label} #${args.id}.`,
|
||||
metadata: { kind: args.kind, id: args.id, ...saved },
|
||||
});
|
||||
|
||||
/**
|
||||
* A bot or workspace id that doesn't exist is skipped by
|
||||
* `setExtensionAccess`, so say which never landed.
|
||||
*/
|
||||
const missing = [
|
||||
...(args.bot_ids || []).filter((id) => !saved.bot_ids.includes(id)).map((id) => `bot ${id}`),
|
||||
...(args.workspace_ids || [])
|
||||
.filter((id) => !saved.workspace_ids.includes(id))
|
||||
.map((id) => `workspace ${id}`),
|
||||
];
|
||||
|
||||
return `Saved the ${label} #${args.id}. Who gets it now: ${describeExtensionAccess({ all_bots, ...saved })}.${missing.length ? ` These don't exist, so they weren't given it: ${missing.join(", ")}.` : ""}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { z } from "zod";
|
||||
import grabCustomTool from "@/src/functions/backend/db/extensions/grab-custom-tool";
|
||||
import saveCustomTool from "@/src/functions/backend/db/extensions/save-custom-tool";
|
||||
import testCustomTool from "@/src/functions/backend/db/extensions/test-custom-tool";
|
||||
import logEvent from "@/src/functions/backend/logger";
|
||||
import type { HarnessTool } from "../../../types";
|
||||
import assertSuperAdminRun from "./assert-super-admin-run";
|
||||
import mergeCustomToolChanges from "./merge-custom-tool-changes";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
};
|
||||
|
||||
type Args = {
|
||||
custom_tool_id: number;
|
||||
name?: string;
|
||||
description?: string;
|
||||
parameters?: Record<string, unknown>;
|
||||
enabled?: boolean;
|
||||
all_bots?: boolean;
|
||||
test_args?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lets the coordinator change a custom tool's name, description, arguments
|
||||
* and whether it's on, and try it. What it runs and where it sends (its
|
||||
* kind and config) are never changed here: those go through
|
||||
* `propose_custom_tool`, which the super admin confirms.
|
||||
*/
|
||||
export default function updateCustomToolTool({ user_id }: Params): HarnessTool<Args> {
|
||||
return {
|
||||
name: "update_custom_tool",
|
||||
description: `Change a custom tool's name, description, parameters (the JSON schema of its arguments), enabled or all_bots (gives it to every bot now and every new one). Every field you leave out stays. Give test_args to run it once afterwards (a script on the main computer) and see what a bot would get back; test_args alone just tries it. To add a tool, or change its kind or config (what it runs, where it sends), use propose_custom_tool: the user confirms those.`,
|
||||
schema: z.object({
|
||||
custom_tool_id: z.number().int().positive(),
|
||||
name: z.string().trim().max(80).optional(),
|
||||
description: z.string().trim().max(2000).optional(),
|
||||
parameters: z.record(z.string(), z.unknown()).optional().describe("JSON schema of the arguments object"),
|
||||
enabled: z.boolean().optional(),
|
||||
all_bots: z.boolean().optional(),
|
||||
test_args: z.record(z.string(), z.unknown()).optional().describe("Arguments to try it with"),
|
||||
}),
|
||||
run: async ({ args }) => {
|
||||
await assertSuperAdminRun({ user_id });
|
||||
|
||||
const { custom_tool_id, test_args, ...changes } = args;
|
||||
const current = await grabCustomTool({ custom_tool_id });
|
||||
const changing = Object.values(changes).some((value) => value !== undefined);
|
||||
|
||||
if (changing) {
|
||||
await saveCustomTool({ user_id, data: mergeCustomToolChanges({ current, changes }) });
|
||||
|
||||
await logEvent({
|
||||
title: "Tool changed",
|
||||
category: "system",
|
||||
user_id: Number(user_id),
|
||||
plain_text: `The coordinator changed the tool ${changes.name || current.name} (#${custom_tool_id}).`,
|
||||
metadata: { custom_tool_id },
|
||||
});
|
||||
}
|
||||
|
||||
const saved = await grabCustomTool({ custom_tool_id });
|
||||
const head = changing
|
||||
? `Tool #${custom_tool_id} ${saved.name} is saved and ${saved.enabled ? "on" : "off"}.`
|
||||
: `Tool #${custom_tool_id} ${saved.name} is unchanged.`;
|
||||
|
||||
if (!test_args) return changing ? `${head} It wasn't tried: give test_args to try it.` : head;
|
||||
|
||||
const test = await testCustomTool({ custom_tool_id, args: test_args });
|
||||
|
||||
return `${head} Tried it: ${test.ok ? "it worked" : "it failed"}. What a bot would get back:\n${test.output}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { z } from "zod";
|
||||
import grabMcpServer from "@/src/functions/backend/db/extensions/grab-mcp-server";
|
||||
import saveMcpServer from "@/src/functions/backend/db/extensions/save-mcp-server";
|
||||
import syncMcpServer from "@/src/functions/backend/db/extensions/sync-mcp-server";
|
||||
import logEvent from "@/src/functions/backend/logger";
|
||||
import type { HarnessTool } from "../../../types";
|
||||
import assertSuperAdminRun from "./assert-super-admin-run";
|
||||
import mergeMcpServerChanges from "./merge-mcp-server-changes";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
};
|
||||
|
||||
type Args = {
|
||||
mcp_server_id: number;
|
||||
name?: string;
|
||||
description?: string;
|
||||
excluded_tools?: string[];
|
||||
enabled?: boolean;
|
||||
all_bots?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lets the coordinator change what an MCP server is called, which of its
|
||||
* tools bots get and whether it's on, then reconnects it. Where it
|
||||
* connects and what it sends are never changed here: those go through
|
||||
* `propose_mcp_server`, which the super admin confirms.
|
||||
*/
|
||||
export default function updateMcpServerTool({ user_id }: Params): HarnessTool<Args> {
|
||||
return {
|
||||
name: "update_mcp_server",
|
||||
description: `Change an MCP server's name, description, excluded_tools (the server's tools bots don't get; replaces the list), enabled or all_bots (gives it to every bot now and every new one), then reconnect it and list its tools. Every field you leave out stays. To add a server, or change its url or headers, use propose_mcp_server: the user confirms those.`,
|
||||
schema: z.object({
|
||||
mcp_server_id: z.number().int().positive(),
|
||||
name: z.string().trim().max(80).optional(),
|
||||
description: z.string().trim().max(1000).optional(),
|
||||
excluded_tools: z.array(z.string().max(200)).max(500).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
all_bots: z.boolean().optional(),
|
||||
}),
|
||||
run: async ({ args }) => {
|
||||
await assertSuperAdminRun({ user_id });
|
||||
|
||||
const { mcp_server_id, ...changes } = args;
|
||||
const current = await grabMcpServer({ mcp_server_id });
|
||||
const data = mergeMcpServerChanges({ current, changes });
|
||||
|
||||
await saveMcpServer({ user_id, data });
|
||||
|
||||
const sync = data.enabled ? await syncMcpServer({ mcp_server_id }) : null;
|
||||
const saved = await grabMcpServer({ mcp_server_id });
|
||||
|
||||
await logEvent({
|
||||
title: "MCP server changed",
|
||||
category: "system",
|
||||
user_id: Number(user_id),
|
||||
plain_text: `The coordinator changed the MCP server ${saved.name} (#${mcp_server_id}).`,
|
||||
metadata: { mcp_server_id },
|
||||
});
|
||||
|
||||
const head = `MCP server #${mcp_server_id} "${saved.name}" is saved.`;
|
||||
|
||||
if (!sync) return `${head} It's off, so it wasn't connected.`;
|
||||
|
||||
if (!sync.ok) return `${head} It didn't connect, so bots get none of its tools until it does: ${sync.error}`;
|
||||
|
||||
const names = sync.tools.map((tool) => tool.name).join(", ");
|
||||
|
||||
return `${head} It connected and lists ${sync.tools.length} ${sync.tools.length == 1 ? "tool" : "tools"}${names ? `: ${names}` : ""}.`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { z } from "zod";
|
||||
import grabSkill from "@/src/functions/backend/db/extensions/grab-skill";
|
||||
import saveSkill from "@/src/functions/backend/db/extensions/save-skill";
|
||||
import logEvent from "@/src/functions/backend/logger";
|
||||
import type { HarnessTool } from "../../../types";
|
||||
import assertSuperAdminRun from "./assert-super-admin-run";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
};
|
||||
|
||||
type Args = {
|
||||
skill_id: number;
|
||||
enabled?: boolean;
|
||||
all_bots?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lets the coordinator turn a skill on or off, or give it to every bot.
|
||||
* Its words (name, slug, description, runbook) change only through
|
||||
* `propose_skill`, which the user confirms.
|
||||
*/
|
||||
export default function updateSkillTool({ user_id }: Params): HarnessTool<Args> {
|
||||
return {
|
||||
name: "update_skill",
|
||||
description: `Turn a skill on or off (enabled), or give it to every bot now and every new one (all_bots). To give it to some bots use set_extension_access. To add a skill or change its name, slug, description or runbook, use propose_skill: the user confirms those.`,
|
||||
schema: z
|
||||
.object({
|
||||
skill_id: z.number().int().positive(),
|
||||
enabled: z.boolean().optional(),
|
||||
all_bots: z.boolean().optional(),
|
||||
})
|
||||
.refine((args) => args.enabled !== undefined || args.all_bots !== undefined, "Give enabled or all_bots."),
|
||||
run: async ({ args }) => {
|
||||
await assertSuperAdminRun({ user_id });
|
||||
|
||||
const current = await grabSkill({ skill_id: args.skill_id });
|
||||
|
||||
await saveSkill({
|
||||
user_id,
|
||||
data: {
|
||||
skill_id: current.id,
|
||||
name: current.name,
|
||||
slug: current.slug,
|
||||
description: current.description,
|
||||
body: current.body,
|
||||
enabled: args.enabled ?? current.enabled,
|
||||
all_bots: args.all_bots ?? current.all_bots,
|
||||
},
|
||||
});
|
||||
|
||||
const saved = await grabSkill({ skill_id: current.id });
|
||||
|
||||
await logEvent({
|
||||
title: "Skill changed",
|
||||
category: "system",
|
||||
user_id: Number(user_id),
|
||||
plain_text: `The coordinator changed the skill ${saved.name} (#${saved.id}, ${saved.slug}).`,
|
||||
metadata: { skill_id: saved.id, enabled: saved.enabled, all_bots: saved.all_bots },
|
||||
});
|
||||
|
||||
return `Skill #${saved.id} "${saved.name}" is ${saved.enabled ? "on" : "off"}.${saved.all_bots ? " Every bot gets it." : ""}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import deleteBotTool from "./delete-bot-tool";
|
||||
import deleteWorkspaceTool from "./delete-workspace-tool";
|
||||
import findBotTool from "./find-bot-tool";
|
||||
import findWorkspaceTool from "./find-workspace-tool";
|
||||
import grabCoordinatorExtensionTools from "./extensions/grab-coordinator-extension-tools";
|
||||
import grabBotTools from "./grab-bot-tools";
|
||||
import removeBotFromWorkspaceTool from "./remove-bot-from-workspace-tool";
|
||||
import updateBotTool from "./update-bot-tool";
|
||||
@@ -51,6 +52,11 @@ type Params = {
|
||||
* Called when the bot asks the user to take over a computer.
|
||||
*/
|
||||
onTakeOver?: (params: { ref: string }) => void;
|
||||
/**
|
||||
* Called when the coordinator proposes an MCP server or tool for the
|
||||
* user to confirm.
|
||||
*/
|
||||
onPropose?: (params: { ref: string }) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -67,7 +73,8 @@ type Params = {
|
||||
* add_bot_to_workspace and remove_bot_from_workspace, assign_computer,
|
||||
* update_bot, delete_bot and delete_workspace (which workspace, which
|
||||
* computers, and which name, role and core instructions each crew member
|
||||
* gets, and what goes) or, in a workspace,
|
||||
* gets, and what goes) and the install's skills, MCP servers and custom
|
||||
* tools (`grabCoordinatorExtensionTools`) or, in a workspace,
|
||||
* set_bot_workspace_instructions. Anything else on a computer goes through
|
||||
* a task, never mid-reply.
|
||||
*/
|
||||
@@ -80,6 +87,7 @@ export default async function grabBotChatTools({
|
||||
message_id,
|
||||
scopes = [],
|
||||
onTakeOver,
|
||||
onPropose,
|
||||
}: Params): Promise<HarnessTool[]> {
|
||||
const bot_id = bot.id || 0;
|
||||
const tools = [
|
||||
@@ -136,7 +144,11 @@ export default async function grabBotChatTools({
|
||||
return tools;
|
||||
}
|
||||
|
||||
tools.push(findBotTool(), findWorkspaceTool({ user_id }));
|
||||
tools.push(
|
||||
findBotTool(),
|
||||
findWorkspaceTool({ user_id }),
|
||||
...(await grabCoordinatorExtensionTools({ user_id, bot_id, session_id, workspace_id, onPropose })),
|
||||
);
|
||||
|
||||
if (workspaces.length) {
|
||||
tools.push(deleteWorkspaceTool({ user_id, workspaces }));
|
||||
|
||||
Reference in New Issue
Block a user