Let the coordinator create bots and put them in workspaces

The coordinator had no tool to add a worker, so a request to create one
fell back to update_workspace and wrote a bot that did not exist into the
workspace context. create_bot now adds a worker with its name, role and
instructions and joins it to a workspace, and add_bot_to_workspace puts an
existing worker in one. Both are coordinator-only and chat-replies-only,
and createBot still enforces the crew-manager check.

The coordinator's prompts now say how a bot gets created, and that one
which does not exist is never written into workspace context.

pick-free-hue moves to utils so the tool can pick a hue without importing
from layouts.
This commit is contained in:
2026-10-19 12:11:04 +01:00
parent e45d54d30b
commit 981a180d19
9 changed files with 190 additions and 9 deletions
+1 -1
View File
@@ -130,7 +130,7 @@ Envs are set in Settings, under Environment (super admin only), not in `.env`. T
- **Automatic picks** (`rank-auto-models`): free models that can do the job (on the setting's provider when it has one; a provider without free models needs a picked model) (`model-serves-harness`: text needs tools), by vendor `AutoModelVendorPreference` (DeepSeek, Qwen, NVIDIA, Liquid), then other vendors that look competent (`AppData.HarnessAutoMin*`), then routers; within a vendor by intelligence index, then full-size before small, newest first. Transcription puts real speech-to-text models first.
- **Calling:** `resolveHarnessCandidates` (pinned, then automatic) -> `withCandidates` tries them in order with process-wide cooldowns (`resolve/harness-cooldowns.ts`): rate limits and outages rest the model (until `Retry-After` when given), 401/402 rest the whole provider, 403/404 rest the model for an hour. Errors are `HarnessError` (`code` provider|unavailable|aborted, `retryable`), classified in `to-harness-error.ts`. Entry points: `text/generate-text.ts` (one turn), `text/run-text-loop.ts` (tool loop, sticks to one model, wraps up after `HarnessMaxToolSteps`, trims the oldest tool output before each turn to fit the model's context, `fit-transcript-to-context.ts`), `media/read-image.ts`, `media/read-media.ts` (documents locally via `fileToText`, images and audio through their harnesses), `audio/transcribe-audio.ts`, `audio/speak-text.ts`, `image/generate-image.ts`. A text model that can't take an image or audio part gets it read by the vision/transcription harness first (`prepare-messages-for-model.ts`).
- **Per-bot models:** in a workspace, its `workspace_harness_settings` row wins (`source: "workspace"`). Otherwise a worker bot inherits the defaults per harness unless it has a `bot_harness_settings` row (same fields as `harness_settings`, unique per bot and harness). `grabHarnessSetting({ harness, bot_id })` returns the bot's row (`source: "bot"`) or the default; the coordinator always gets the default. Every entry point takes `bot_id` and passes it on (text loop -> media reading/transcription), so a bot's run uses its own models throughout. Saved by `saveBotHarnessSetting` (`inherit` deletes the row); the page data is `grabBotHarnessOverview`. A bot's chat header has a ghost model icon (`(partials)/header-icon-button.tsx`; the text model and provider show under the bot's name) that opens a modal with every harness (`(partials)/bot-models-modal.tsx`); the modal and `/admin/bots/[bot_id]/models` both use `(partials)/bot-harness-form.tsx`. On the coordinator the modal edits the defaults themselves (`api/admin/harness/save-settings`, the same settings as Settings, under Models, without the providers): super admins only, the icon is disabled for everyone else. Every one of these wraps `settings/models/(partials)/harness-settings-form.tsx`: per harness a provider select first (`harness-provider-select.tsx`: any provider, one provider, or on a bot "Same as the default"), then the model select narrowed to it. Removing a provider (`deleteHarnessProvider`) puts default and bot settings that picked it or its models back on any provider (`save` prop; "Same as the default" shows when the overview item has `default`).
- **Tools:** `HarnessTool` (built per run with its bot bound): own tools validate with a zod `schema`; tools described elsewhere pass a JSON schema as `parameters` and validate themselves. `run-tool-call.ts` reports bad calls and failures back to the model as `Error: ...`. Bot tools in `src/harness/tools/bot/`: `update_memory`, `update_instructions` (`grabBotTools`, every run), `start_task` and, for the coordinator with workers, `delegate_task` (`grabBotChatTools`, chat replies only; the job's `requested_by_bot_id` is the coordinator, and `relayJobToRequester` posts the worker's result or failure in its conversation, pushed as `relay` by `pushBotNotice`), `ask_user_to_take_over` (see Take-over), plus for the coordinator `assign_computer` (grant or revoke a worker's direct access, `add/remove-bot-to/from-computer`; access through a workspace stays and the result says so). `search_computers` (`src/harness/tools/computer/search-computers-tool.ts`, chat replies and tasks) runs one read-only command on all of a bot's computers at once over the sandbox REST API (`run-computer-command.ts`), each output under its `[computer:ID]`, secrets redacted, capped by `AppData.ComputerSearch*`.
- **Tools:** `HarnessTool` (built per run with its bot bound): own tools validate with a zod `schema`; tools described elsewhere pass a JSON schema as `parameters` and validate themselves. `run-tool-call.ts` reports bad calls and failures back to the model as `Error: ...`. Bot tools in `src/harness/tools/bot/`: `update_memory`, `update_instructions` (`grabBotTools`, every run), `start_task` and, for the coordinator with workers, `delegate_task` (`grabBotChatTools`, chat replies only; the job's `requested_by_bot_id` is the coordinator, and `relayJobToRequester` posts the worker's result or failure in its conversation, pushed as `relay` by `pushBotNotice`), `ask_user_to_take_over` (see Take-over), plus for the coordinator `create_bot` (add a worker with its name, role and instructions, and join it to a workspace straight away) and, in its own chat, `add_bot_to_workspace` (put an existing worker in one of the user's workspaces) and `assign_computer` (grant or revoke a worker's direct access, `add/remove-bot-to/from-computer`; access through a workspace stays and the result says so). `search_computers` (`src/harness/tools/computer/search-computers-tool.ts`, chat replies and tasks) runs one read-only command on all of a bot's computers at once over the sandbox REST API (`run-computer-command.ts`), each output under its `[computer:ID]`, secrets redacted, capped by `AppData.ComputerSearch*`.
- **MCP:** `src/harness/mcp/`: `connectMcpServer` (Streamable HTTP, SSE fallback), `grabMcpTools` (each server tool becomes a `HarnessTool` with its JSON schema), `mcpResultToText` (text as is, images read by the vision harness, `isError` thrown). Computers are reached this way: `openComputerTools` (`src/harness/tools/computer/`) connects to the computer's AIO sandbox at `grabComputerMcpUrl` (the `MAIN_COMPUTER_URL` app setting for the main computer, for a dev server outside docker, e.g. `http://localhost:8170`, else `http://<hostname>:<port>/mcp`, or its IP address when the hostname can't be reached), leaves out `ComputerMcpExcludedTools`, and sets the computer's envs (see Envs).
- **Replies and tasks:** `run-bot-reply.ts` answers from the last `BotReplyHistoryMessages` messages (`botMessagesToHarness`) with `ChatReplyNote`, the bot's computers (`compose-computers-note.ts`: what they are and that they're reached through `start_task`) and the chat tools, at most `BotReplyMaxToolSteps` steps, and finishes silently if the bot already had the last word (`enqueueBotReply` reuses a pending reply, so a burst of messages gets one). `run-bot-task.ts` runs the brief (`compose-job-brief.ts`) with `TaskRunNote`, the bot tools and the job's computer tools, closing the MCP connection after. Retryable harness errors go back to the queue's backoff, the rest are `BotJobFatalError`.
- **API:** `api/admin/harness/` (`save-provider` also syncs, `sync-provider`, `delete-provider`, `save-settings`), super admin only, each returning the fresh `grabHarnessOverview()` page data.
+2 -1
View File
@@ -13,7 +13,7 @@ You lead a ${SiteData["SiteName"]} crew: a small team of AI worker bots that run
- **Understand the request.** If it's unclear, ask one short question. Otherwise get started.
- **Delegate.** Break the work into tasks and hand each one to the worker whose role fits best. Do it yourself only when no worker fits, or when it's a quick answer that doesn't need a task.
- **Suggest new bots.** If nothing in the crew fits and the job will come up again, suggest a new bot: a short name and its job in one line. Don't create one without the user's go-ahead.
- **Grow the crew.** If nothing in the crew fits and the job will come up again, say what's missing and propose a bot: a short name, its job in one line, and what it should handle. Once the user agrees, create it with create_bot and give it a workspace, or put it in one later with add_bot_to_workspace.
- **Track the work.** Know what every bot is doing, what's done, and what's stuck.
- **Report back** in plain words when work is finished or needs the user.
@@ -23,6 +23,7 @@ You lead a ${SiteData["SiteName"]} crew: a small team of AI worker bots that run
- Brief workers fully: the goal, the inputs, what "done" looks like, and any deadline.
- Workers report to you. You speak for the crew, but the user can also talk to any worker directly. If they do, stay in sync rather than redoing the work.
- Don't hand two bots the same job.
- A bot is on the crew only when create_bot made it. Never write a bot into a workspace's context, or say someone is on the crew, when it isn't.
## When the user is needed
+3 -2
View File
@@ -28,18 +28,19 @@ You are chatting with the user. Answer their latest messages directly and briefl
- When a message mixes both, answer the quick parts in this reply and start a task for the rest.
- When only the user can do something on a computer (log in, solve a captcha, enter a code), ask them to take over with ask_user_to_take_over and say exactly what to do. They'll tell you when they hand it back; then carry on.
- When the user tells you something worth keeping, save it with update_memory: about them in your memory of this user, never in your core memory, which every user you work for shares. When they correct how you work for everyone, update your instructions with update_instructions; a correction that's only their preference goes in your memory of them.
- To add a worker to the crew, use create_bot once the user has asked for it or agreed, and add_bot_to_workspace to place it. A bot exists only when create_bot made it: never describe one as if it did, and never write it into a workspace's context.
- Never claim you did something you didn't do.`;
/**
* Added to the coordinator's chat prompt: it hands work to the crew.
*/
export const CoordinatorReplyNote = `As the coordinator, hand work that fits a crew member's role to them with delegate_task. They post the result in their own conversation, and it's reported back here when they finish or fail, so tell the user who is on it. When a report comes back, pass on what matters to the user and follow up on anything left.`;
export const CoordinatorReplyNote = `As the coordinator, hand work that fits a crew member's role to them with delegate_task. They post the result in their own conversation, and it's reported back here when they finish or fail, so tell the user who is on it. When a report comes back, pass on what matters to the user and follow up on anything left. Once the user agrees, you can also grow the crew with create_bot, and put a worker in a workspace with add_bot_to_workspace.`;
/**
* Added to the coordinator's chat prompt instead while it has no workers,
* so it doesn't talk about a crew that doesn't exist.
*/
export const CoordinatorSoloNote = `You have no worker bots yet: you do all the work yourself, with start_task for anything long or on a computer. Never say you assigned, delegated or handed work to a worker or anyone else. If a kind of job keeps coming up, you can suggest a new bot for it.`;
export const CoordinatorSoloNote = `You have no worker bots yet: you do all the work yourself, with start_task for anything long or on a computer. Never say you assigned, delegated or handed work to a worker or anyone else. If a kind of job keeps coming up, propose a new bot for it, and create it with create_bot once the user agrees.`;
/**
* Added to a bot's prompt when it works through a queued task.
@@ -0,0 +1,80 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import { z } from "zod";
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import addBotToWorkspace from "@/src/functions/backend/db/workspaces/add-bot-to-workspace";
import type { HarnessTool } from "../../types";
type Params = {
user_id: number | string;
/**
* The user's worker bots.
*/
workers: BUN_SQLITE_COBBLE_BOTS[];
/**
* The user's live workspaces.
*/
workspaces: BUN_SQLITE_COBBLE_WORKSPACES[];
};
type Args = {
bot_id: number;
workspace_id: number;
};
/**
* Lets the coordinator put a crew member in one of the user's workspaces,
* so the user can talk to it there and it gets that workspace's context,
* computers and models. Offered in the coordinator's own chat only, where
* the whole crew and every live workspace are known.
*/
export default function addBotToWorkspaceTool({
user_id,
workers,
workspaces,
}: Params): HarnessTool<Args> {
const crew = workers.map((bot) => `${bot.id}: ${bot.name}`).join("; ");
const list = workspaces.map((w) => `${w.id}: ${w.name}`).join("; ");
return {
name: "add_bot_to_workspace",
description: `Put a crew member in one of the user's workspaces. There they work with the workspace's context, its computers and its models, and the user chats with them in it. Use it when the user asks for a bot to work in a workspace, or after create_bot made one without a place. Crew: ${crew || "none"}. Workspaces: ${list || "none"}.`,
schema: z.object({
bot_id: z.number().int(),
workspace_id: z.number().int(),
}),
run: async ({ args }) => {
const worker = workers.find((bot) => bot.id == args.bot_id);
const workspace = workspaces.find((w) => w.id == args.workspace_id);
if (!worker?.id) {
throw new Error(`No crew member has id ${args.bot_id}. Crew: ${crew || "none"}.`);
}
if (!workspace?.id) {
throw new Error(
`No workspace has id ${args.workspace_id}. Workspaces: ${list || "none"}.`,
);
}
const already =
(
await BunSQLite.sql<{ present: number }>({
sql: `SELECT 1 AS present FROM workspace_bots WHERE workspace_id = ? AND bot_id = ? LIMIT 1`,
values: [workspace.id, worker.id],
})
).payload?.[0] || null;
if (already) {
return `${worker.name} is already in ${workspace.name}.`;
}
await addBotToWorkspace({
user_id,
workspace_id: workspace.id,
bot_id: worker.id,
});
return `${worker.name} joined ${workspace.name}.`;
},
};
}
+89
View File
@@ -0,0 +1,89 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import { z } from "zod";
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
import createBot from "@/src/functions/backend/db/bots/create-bot";
import addBotToWorkspace from "@/src/functions/backend/db/workspaces/add-bot-to-workspace";
import pickFreeHue from "@/src/utils/pick-free-hue";
import type { HarnessTool } from "../../types";
type Params = {
user_id: number | string;
/**
* The user's workspaces the new bot can join.
*/
workspaces: BUN_SQLITE_COBBLE_WORKSPACES[];
/**
* The workspace the conversation is in: the one a new bot joins when
* none is named.
*/
workspace_id?: number | string | null;
};
type Args = {
name: string;
role: string;
instructions: string;
workspace_id?: number;
};
/**
* Lets the coordinator add a worker bot to the crew, and put it in one of
* the user's workspaces, so it has a place to work and be talked to.
* Creating a bot is one of the few structural changes the coordinator
* makes, so it only runs once the user has agreed.
*/
export default function createBotTool({
user_id,
workspaces,
workspace_id,
}: Params): HarnessTool<Args> {
const list = workspaces.map((w) => `${w.id}: ${w.name}`).join("; ");
const here = workspaces.find((w) => w.id == workspace_id);
return {
name: "create_bot",
description: `Add a worker bot to the crew: give it a short name, its job in one line, and the instructions it keeps (markdown: what it does, how it works, what it handles). A bot works in the workspace it joins, where the user can also talk to it; without one it has no computer or workspace until you give it either. Only do this when the user has asked for it or agreed to it.${here ? ` Leave out workspace_id for ${here.name}, the workspace you're in.` : ""} Workspaces: ${list || "none"}.`,
schema: z.object({
name: z.string().trim().min(1).max(40),
role: z.string().trim().max(120),
instructions: z.string().trim().min(1).max(AppData.PromptMaxLength),
workspace_id: z.number().int().optional(),
}),
run: async ({ args }) => {
const target = args.workspace_id
? workspaces.find((w) => w.id == args.workspace_id)
: here;
if (args.workspace_id && !target?.id) {
throw new Error(
`No workspace has id ${args.workspace_id}. Workspaces: ${list || "none"}.`,
);
}
const hues =
(
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT hue FROM bots`,
values: [],
})
).payload || [];
const bot_id = await createBot({
user_id,
name: args.name,
role: args.role,
hue: pickFreeHue({ taken: hues.map((bot) => bot.hue) }),
system_prompt: args.instructions,
});
if (target?.id) {
await addBotToWorkspace({ user_id, workspace_id: target.id, bot_id });
}
const where = target?.name ? ` and joins ${target.name}` : "";
return `${args.name} is on the crew (bot #${bot_id})${where}.${target ? "" : " It has no workspace yet: put it in one with add_bot_to_workspace, or give it a computer with assign_computer."}`;
},
};
}
+14 -4
View File
@@ -4,7 +4,9 @@ import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-comp
import type { HarnessTool } from "../../types";
import askToTakeOverTool from "../computer/ask-to-take-over-tool";
import searchComputersTool from "../computer/search-computers-tool";
import addBotToWorkspaceTool from "./add-bot-to-workspace-tool";
import assignComputerTool from "./assign-computer-tool";
import createBotTool from "./create-bot-tool";
import delegateTaskTool from "./delegate-task-tool";
import grabBotTools from "./grab-bot-tools";
import updateWorkspaceTool from "./update-workspace-tool";
@@ -43,10 +45,12 @@ type Params = {
* in a workspace switching its default computer), start_task for longer
* work, search_computers to find what's on its computers,
* ask_user_to_take_over when only the user can do something on one, and
* for the coordinator (the super admin's alone) delegate_task to every
* worker (the workspace's in a workspace) plus, in its own chat, assign_computer (which computers each
* crew member can use) or, in a workspace, set_bot_workspace_instructions.
* Anything else on a computer goes through a task, never mid-reply.
* for the coordinator (the super admin's alone) create_bot to grow the
* crew, delegate_task to every worker (the workspace's in a workspace)
* plus, in its own chat, add_bot_to_workspace and assign_computer (which
* workspace and which computers each crew member gets) or, in a
* workspace, set_bot_workspace_instructions. Anything else on a computer
* goes through a task, never mid-reply.
*/
export default async function grabBotChatTools({
bot,
@@ -83,6 +87,8 @@ export default async function grabBotChatTools({
tools.push(updateWorkspaceTool({ user_id, workspaces, workspace_id }));
}
tools.push(createBotTool({ user_id, workspaces, workspace_id }));
const workers =
(
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
@@ -125,5 +131,9 @@ export default async function grabBotChatTools({
tools.push(assignComputerTool({ user_id, workers, computers, access }));
}
if (workspaces.length) {
tools.push(addBotToWorkspaceTool({ user_id, workers, workspaces }));
}
return tools;
}
+1 -1
View File
@@ -11,7 +11,7 @@ import { BotHues, type BotHue } from "@/src/dict/bot-dict";
import setProfileImage from "@/src/functions/frontend/set-profile-image";
import postAdminApi from "../(functions)/post-admin-api";
import usePickedImage from "../(hooks)/use-picked-image";
import pickFreeHue from "../(utils)/pick-free-hue";
import pickFreeHue from "@/src/utils/pick-free-hue";
import AddFormActions from "./add-form-actions";
import BotFormPreview from "./bot-form-preview";