Give the coordinator working crew management tools
- Resolve the crew and workspaces fresh inside each tool run, so a bot or workspace created earlier in the same reply is usable: create_bot, assign_computer, add_bot_to_workspace, delegate_task and set_bot_workspace_instructions. - Add coordinator-only update_bot, find_bot, find_workspace, delete_bot, delete_workspace and remove_bot_from_workspace tools. - Push server:workspace-removed when a workspace is deleted, and workspace_ids on server:bot-updated so a workspace's crew drops a bot that was taken out of it.
This commit is contained in:
@@ -99,7 +99,7 @@ Inspect live data with `bun llm-code-assistant/query-db.ts "<sql>"` - a read-onl
|
|||||||
- **Prompt and memory (self-learning):** a bot has `system_prompt` (core instructions) and a separate core `memory` (markdown, general knowledge about its job, the same for every user it works for, never about one user). What it remembers about a user lives in `bot_user_memories` (one row per bot and user, seen only by that user's runs; `grab-bot-user-memory`, `update-bot-user-memory`), like workspace memory stays in its workspace. The bot rewrites them as it adapts (`update-bot-system-prompt`, `update-bot-memory` in `src/functions/backend/db/bots/`; whole-text replace, capped at `AppData.PromptMaxLength`). `grab-bot-prompt` builds the full prompt for the run's user via `composeBotPrompt` (`src/utils/compose-bot-prompt.ts`): core instructions, then (in a workspace only) `# Workspace: <name>` with its description and `system_prompt`, the bot's `# Your instructions in <name>` (`workspace_bots.system_prompt`), `# Memory`, `# Memory about the user`, then `# Memory in <name>` (`workspace_bots.memory`). `update_memory` saves to `user` by default (`workspace` in a workspace) and to `core` only for what holds for everyone. In a workspace the bot learns into the workspace's instructions and memory by default (`scope: workspace` on `update_instructions`/`update_memory`, `WorkspaceLearningNote`) and into its core ones only when told to, or after suggesting it. `src/scripts/move-bot-memory-to-owners.ts` (run once per install) moved core memory written before this into the owner's.
|
- **Prompt and memory (self-learning):** a bot has `system_prompt` (core instructions) and a separate core `memory` (markdown, general knowledge about its job, the same for every user it works for, never about one user). What it remembers about a user lives in `bot_user_memories` (one row per bot and user, seen only by that user's runs; `grab-bot-user-memory`, `update-bot-user-memory`), like workspace memory stays in its workspace. The bot rewrites them as it adapts (`update-bot-system-prompt`, `update-bot-memory` in `src/functions/backend/db/bots/`; whole-text replace, capped at `AppData.PromptMaxLength`). `grab-bot-prompt` builds the full prompt for the run's user via `composeBotPrompt` (`src/utils/compose-bot-prompt.ts`): core instructions, then (in a workspace only) `# Workspace: <name>` with its description and `system_prompt`, the bot's `# Your instructions in <name>` (`workspace_bots.system_prompt`), `# Memory`, `# Memory about the user`, then `# Memory in <name>` (`workspace_bots.memory`). `update_memory` saves to `user` by default (`workspace` in a workspace) and to `core` only for what holds for everyone. In a workspace the bot learns into the workspace's instructions and memory by default (`scope: workspace` on `update_instructions`/`update_memory`, `WorkspaceLearningNote`) and into its core ones only when told to, or after suggesting it. `src/scripts/move-bot-memory-to-owners.ts` (run once per install) moved core memory written before this into the owner's.
|
||||||
- **Queue:** every bot job goes through `bots_queue`, with `kind` (`BotJobKinds`): `reply` (answers the user in chat, no task row) or `task` (long-running work, typically on a computer). Long-running work goes through `bots_queue`, a bun-sqlite `isQueue` table worked with the native `BunSQLite.queue` API (`status` pending|processing|completed|failed, `attempts`, `available_at`, `locked_at`/`locked_by`, `last_error`; options in `AppData.BotQueue*`). Not every message needs a job: the bot replies right away that it's on it and `enqueueBotJob` queues the brief (`instructions`, `params`, optional `message_id`/`computer_id`, the computer checked against the bot's access; a `bot_tasks` row is created unless `task_id` is given). DB helpers in `src/functions/backend/db/bots-queue/`: `claimBotJobs`, `updateBotJob` (renews the lock, saves `progress`/`checkpoint`; false once lost or cancelled), `completeBotJob` (result message, task done, face done/needs-you for the job's user), `failBotJob` (backoff retry, or `fatal`; out of attempts posts a needs-you message via `notifyBotJobFailed`), `cancelBotJob` (failed with `BotJobCancelledError`, `src/dict/bot-queue-dict.ts`), `releaseWorkerBotJobs`. Every model turn of a job is a step in `bots_queue_updates` (`kind` tool|note, one-line `text`, JSON `metadata`; tool names, never arguments; `describe-job-step.ts`), written through the job context's `report` (the text loop's `onStep`), which also sets `progress` and pushes `server:bot-job-update` (`BotJobUpdateData`). Steps are deleted with their job when it's pruned. The active task's latest `AppData.BotJobUpdatesShown` steps ride on `BotTaskWithSteps.updates` (`grabBotJobUpdates`); the conversation (`bot_task` page prop, `use-task-progress`) shows the latest one under `BotThinkingRow`'s "<name> is working…" while a task runs. Step text is the first line the model wrote, else plain labels for its tools (`JobStepToolLabels` in `src/dict/bot-queue-dict.ts`; other tools in words, an MCP tool as "Using Exa search (web search exa)").
|
- **Queue:** every bot job goes through `bots_queue`, with `kind` (`BotJobKinds`): `reply` (answers the user in chat, no task row) or `task` (long-running work, typically on a computer). Long-running work goes through `bots_queue`, a bun-sqlite `isQueue` table worked with the native `BunSQLite.queue` API (`status` pending|processing|completed|failed, `attempts`, `available_at`, `locked_at`/`locked_by`, `last_error`; options in `AppData.BotQueue*`). Not every message needs a job: the bot replies right away that it's on it and `enqueueBotJob` queues the brief (`instructions`, `params`, optional `message_id`/`computer_id`, the computer checked against the bot's access; a `bot_tasks` row is created unless `task_id` is given). DB helpers in `src/functions/backend/db/bots-queue/`: `claimBotJobs`, `updateBotJob` (renews the lock, saves `progress`/`checkpoint`; false once lost or cancelled), `completeBotJob` (result message, task done, face done/needs-you for the job's user), `failBotJob` (backoff retry, or `fatal`; out of attempts posts a needs-you message via `notifyBotJobFailed`), `cancelBotJob` (failed with `BotJobCancelledError`, `src/dict/bot-queue-dict.ts`), `releaseWorkerBotJobs`. Every model turn of a job is a step in `bots_queue_updates` (`kind` tool|note, one-line `text`, JSON `metadata`; tool names, never arguments; `describe-job-step.ts`), written through the job context's `report` (the text loop's `onStep`), which also sets `progress` and pushes `server:bot-job-update` (`BotJobUpdateData`). Steps are deleted with their job when it's pruned. The active task's latest `AppData.BotJobUpdatesShown` steps ride on `BotTaskWithSteps.updates` (`grabBotJobUpdates`); the conversation (`bot_task` page prop, `use-task-progress`) shows the latest one under `BotThinkingRow`'s "<name> is working…" while a task runs. Step text is the first line the model wrote, else plain labels for its tools (`JobStepToolLabels` in `src/dict/bot-queue-dict.ts`; other tools in words, an MCP tool as "Using Exa search (web search exa)").
|
||||||
- **Queue handler:** `src/bots-queue/index.ts`, started from `server.ts` after cron and gated the same way. It releases its own cut-off jobs on startup (stable worker `AppData.BotQueueWorker`), then polls every `BotQueuePollMs`: reports jobs bun-sqlite failed on a lock timeout (`report-lost-bot-jobs`), claims per kind (`claimBotJobs` mirrors bun-sqlite's atomic claim plus `kind`, since `queue.claim` has no filter) up to `BotQueueReplyConcurrency` replies and `BotQueueConcurrency` tasks on separate slots, and runs each with `processBotJob` (heartbeat at a quarter of the lock timeout, abort on cancel/loss, one task per computer and one reply per session via `createKeyLock`, so a bot's other sessions answer side by side; a task waiting for its busy computer shows face `asleep` with `BotComputerBusyStatusLine`; face `working` once it starts, outcome pushed live; a job finishing while others for that user still run puts back the running one's face, `grabRunningBotJobFace`). The actual work is `run-bot-job.ts` (`BotJobRunner`, context in `types.ts`), which dispatches to `run-bot-reply.ts` and `run-bot-task.ts`. Throw `BotJobFatalError` for failures retrying can't fix. When every model is resting (`HarnessError.resting`, nothing was tried) the job is put back with `deferBotJob` until the first comes back, its attempt refunded and the bot's face `asleep`; other retryable harness errors retry no sooner than their `retry_at`. The `prune-bots-queue` cron deletes finished jobs after `BotQueueRetentionDays`.
|
- **Queue handler:** `src/bots-queue/index.ts`, started from `server.ts` after cron and gated the same way. It releases its own cut-off jobs on startup (stable worker `AppData.BotQueueWorker`), then polls every `BotQueuePollMs`: reports jobs bun-sqlite failed on a lock timeout (`report-lost-bot-jobs`), claims per kind (`claimBotJobs` mirrors bun-sqlite's atomic claim plus `kind`, since `queue.claim` has no filter) up to `BotQueueReplyConcurrency` replies and `BotQueueConcurrency` tasks on separate slots, and runs each with `processBotJob` (heartbeat at a quarter of the lock timeout, abort on cancel/loss, one task per computer and one reply per session via `createKeyLock`, so a bot's other sessions answer side by side; a task waiting for its busy computer shows face `asleep` with `BotComputerBusyStatusLine`; face `working` once it starts, outcome pushed live; a job finishing while others for that user still run puts back the running one's face, `grabRunningBotJobFace`). The actual work is `run-bot-job.ts` (`BotJobRunner`, context in `types.ts`), which dispatches to `run-bot-reply.ts` and `run-bot-task.ts`. Throw `BotJobFatalError` for failures retrying can't fix. When every model is resting (`HarnessError.resting`, nothing was tried) the job is put back with `deferBotJob` until the first comes back, its attempt refunded and the bot's face `asleep`; other retryable harness errors retry no sooner than their `retry_at`. The `prune-bots-queue` cron deletes finished jobs after `BotQueueRetentionDays`.
|
||||||
- **Live updates:** server push only. `sendToUser` (`src/websocket/(functions)/send-to-user.ts`) sends to one user's sockets; `pushBotNotice` sends `server:bot-message` (the message row, user messages too, for other tabs) and `server:bot-updated` (`BotUpdatedData`: face, status line, active task). The bots layout opens one socket (twui `useWebSocket`); components listen with `useServerEvent` (`src/hooks/`), and `useLiveBots` keeps the rail and header current. The conversation dedupes messages by id. Every process also runs `liveRelay` (`src/live-relay/`): every `AppData.LiveRelayPollMs` it reads new `bot_messages`, new `bots_queue_updates`, changed faces and changed bots from the database and pushes them to its own sockets, so work done by another process still shows live; clients drop what they already have. `server:bot-updated` carries the bot's `computer_id` when known, which keeps the header's computer in step across tabs. A bot that joins the crew or a workspace is pushed as `server:bot-created` (`BotCreatedData`: its rail row, the workspaces it's in, and whether it belongs in that user's own chat), from the tool that created it and from the live relay for other processes, so the rail lists it without a reload. A workspace a bot created is pushed the same way as `server:workspace-created` (`WorkspaceCreatedData`: its rail row), from the tool and from the live relay. A deleted bot is pushed as `server:bot-removed` (`BotRemovedData`) to the audience read before the delete, since its assignments go with it, so its rail row goes at once. A rail row's subtitle is the bot's status line while it has one, else (coordinator) the crew counts while a worker is busy or needs you, else its last message as plain text (`src/utils/message-preview.ts`).
|
- **Live updates:** server push only. `sendToUser` (`src/websocket/(functions)/send-to-user.ts`) sends to one user's sockets; `pushBotNotice` sends `server:bot-message` (the message row, user messages too, for other tabs) and `server:bot-updated` (`BotUpdatedData`: face, status line, active task). The bots layout opens one socket (twui `useWebSocket`); components listen with `useServerEvent` (`src/hooks/`), and `useLiveBots` keeps the rail and header current. The conversation dedupes messages by id. Every process also runs `liveRelay` (`src/live-relay/`): every `AppData.LiveRelayPollMs` it reads new `bot_messages`, new `bots_queue_updates`, changed faces and changed bots from the database and pushes them to its own sockets, so work done by another process still shows live; clients drop what they already have. `server:bot-updated` carries the bot's `computer_id` when known, which keeps the header's computer in step across tabs, and its `workspace_ids` after a membership change, so a workspace's crew drops a bot that left it. A bot that joins the crew or a workspace is pushed as `server:bot-created` (`BotCreatedData`: its rail row, the workspaces it's in, and whether it belongs in that user's own chat), from the tool that created it and from the live relay for other processes, so the rail lists it without a reload. A workspace a bot created is pushed the same way as `server:workspace-created` (`WorkspaceCreatedData`: its rail row), from the tool and from the live relay. A deleted bot is pushed as `server:bot-removed` (`BotRemovedData`) to the audience read before the delete, since its assignments go with it, so its rail row goes at once. A deleted workspace is pushed the same way as `server:workspace-removed` (`WorkspaceRemovedData`), read before the delete too. A rail row's subtitle is the bot's status line while it has one, else (coordinator) the crew counts while a worker is busy or needs you, else its last message as plain text (`src/utils/message-preview.ts`).
|
||||||
- **Workspaces:** `workspaces` (`name`, `description`, `system_prompt` shared by every bot in it, `computer_id` its default computer (not a foreign key), `archived`), with bots assigned through `workspace_bots` (plus that bot's `system_prompt` and `memory` there; the coordinator is an implicit member) and computers through `workspace_computers`. The first computer linked becomes the default; the user (workspace settings, chat header), any bot there (`set_workspace_computer`) or the coordinator can switch it. A workspace with no computer has none: tasks needing one fail. Workspace models (`workspace_harness_settings`) win over a bot's own, then the defaults. Helpers in `src/functions/backend/db/workspaces/` (`create-workspace`, `update-workspace`, `delete-workspace`, `add/remove-bot-*`, `update-workspace-bot`, `add/remove-computer-*`, `set-workspace-computer`, `grab-user-workspaces`, `grab-workspace-bots`, `grab-workspace-computers`, `grab-workspace-activity`), all access-checked against the session user, the structural ones for super admins and admins only; API in `api/admin/workspaces/`. The coordinator sets a member's instructions there with `set_bot_workspace_instructions`, creates a workspace with `create_workspace`, and changes any workspace's name, description or context with `update_workspace` (chat replies, and its own tasks so findings it was asked to record land in the workspace; a chat-created one is pushed as `server:workspace-created`).
|
- **Workspaces:** `workspaces` (`name`, `description`, `system_prompt` shared by every bot in it, `computer_id` its default computer (not a foreign key), `archived`), with bots assigned through `workspace_bots` (plus that bot's `system_prompt` and `memory` there; the coordinator is an implicit member) and computers through `workspace_computers`. The first computer linked becomes the default; the user (workspace settings, chat header), any bot there (`set_workspace_computer`) or the coordinator can switch it. A workspace with no computer has none: tasks needing one fail. Workspace models (`workspace_harness_settings`) win over a bot's own, then the defaults. Helpers in `src/functions/backend/db/workspaces/` (`create-workspace`, `update-workspace`, `delete-workspace`, `add/remove-bot-*`, `update-workspace-bot`, `add/remove-computer-*`, `set-workspace-computer`, `grab-user-workspaces`, `grab-workspace-bots`, `grab-workspace-computers`, `grab-workspace-activity`), all access-checked against the session user, the structural ones for super admins and admins only; API in `api/admin/workspaces/`. The coordinator sets a member's instructions there with `set_bot_workspace_instructions`, creates a workspace with `create_workspace`, and changes any workspace's name, description or context with `update_workspace` (chat replies, and its own tasks so findings it was asked to record land in the workspace; a chat-created one is pushed as `server:workspace-created`).
|
||||||
- **Computers:** a computer is an AIO sandbox in its own docker container on the `AppData.DockerNetworkName` network. `containers` records every docker container the app knows about except the main app container (`name` doubles as the hostname, plus `docker_id`, `image`, `network`, `port`, `status`, `metadata`). `computers` (the install's, `kind` main|sandbox, `container_id`) and `bot_computers` (direct access). The app creates and manages every computer's container over the docker socket (`src/functions/backend/docker/`; `DOCKER_SOCKET` or `AppData.DockerSocketPath`): `createComputer` records it and `provisionComputer` (in the background, status `creating`, then the container's or `failed` with `metadata.error`, pushed as `server:computer-updated`) pulls `AppData.ComputerImage`, binds the home dir and starts `<ComputerContainerPrefix>-<id>` limited to the computer's `cpus` and `memory_mb` (set at creation, default `AppData.ComputerDefaultCpus`/`ComputerDefaultMemoryMb`: 1 CPU, 2 GB), attached to the network in the same create call, with a docker health check (`AppData.ComputerHealthcheck`: the sandbox API on `ComputerPort` and its browser); the computer stays `creating` until it passes (`waitForHealthyContainer`), then records its IP address on the network (`containers.ip_address`, refreshed by `sync-computers`). `updateComputer` (`api/admin/computers/update-computer`) saves name, description and resources; new resources stop, remove and recreate the container (`provisionComputer`), the home dir kept. A computer's home (`/home/gem`) is `DATA_DIR/computers/<id>` (`AppData.ComputerDataDir`, owned by uid 1000); deleting the computer (`deleteComputer`, never the main one) removes the container and keeps that directory. Run in docker, the app resolves host paths from its own container's mounts and joins the network; outside docker (dev) it can't resolve container hostnames, so it reaches them at their IP address (`pickReachableAddress`: hostname first, then the IP, the pick cached for `AppData.ComputerAddressCacheMs`). Only super admins create, delete or retry computers (`api/admin/computers/`). The `sync-computers` cron records container states every five minutes. The install has one main computer, created at signup (`bun run seed`) and as a fallback by the super admin's `/admin/bots` via `ensureMainComputer` (`src/functions/backend/db/computers/ensure-main-computer.ts`, idempotent), which grants the coordinator access; with `MAIN_COMPUTER_URL` set (dev) it isn't provisioned. That one grant can't be removed (`isLockedComputerAccess`, enforced in `remove-bot-from-computer` and the `bot_computers` CRUD handler); every other grant can. A bot's effective computers (`grab-bot-computers`): the workspace's computers in a workspace, its default first; in its own chat, its direct grants and those of the workspaces it's attached to (the coordinator's are every computer), the one it's on first: `bots.computer_id` (not a foreign key; null or a lost grant means its main one), so `computers[0]` is where its tasks run by default. The user switches it from the chat header (`(partials)/bot-computers-modal.tsx`, a ghost icon beside the model icon; the header names the current computer and the text model and provider under the bot's name) or by clicking a computer the bot named in a message: bots write `[computer:ID]` (`src/utils/computer-ref.ts`, turned into `#computer-ID` links by `link-computer-refs.ts`, rendered by `(partials)/computer-ref-chip.tsx`), both sharing `BotComputersContext` / `use-bot-computers`, saved by `api/admin/bots/set-computer.ts` (`setBotComputer`). Enums in `src/dict/computer-dict.ts`.
|
- **Computers:** a computer is an AIO sandbox in its own docker container on the `AppData.DockerNetworkName` network. `containers` records every docker container the app knows about except the main app container (`name` doubles as the hostname, plus `docker_id`, `image`, `network`, `port`, `status`, `metadata`). `computers` (the install's, `kind` main|sandbox, `container_id`) and `bot_computers` (direct access). The app creates and manages every computer's container over the docker socket (`src/functions/backend/docker/`; `DOCKER_SOCKET` or `AppData.DockerSocketPath`): `createComputer` records it and `provisionComputer` (in the background, status `creating`, then the container's or `failed` with `metadata.error`, pushed as `server:computer-updated`) pulls `AppData.ComputerImage`, binds the home dir and starts `<ComputerContainerPrefix>-<id>` limited to the computer's `cpus` and `memory_mb` (set at creation, default `AppData.ComputerDefaultCpus`/`ComputerDefaultMemoryMb`: 1 CPU, 2 GB), attached to the network in the same create call, with a docker health check (`AppData.ComputerHealthcheck`: the sandbox API on `ComputerPort` and its browser); the computer stays `creating` until it passes (`waitForHealthyContainer`), then records its IP address on the network (`containers.ip_address`, refreshed by `sync-computers`). `updateComputer` (`api/admin/computers/update-computer`) saves name, description and resources; new resources stop, remove and recreate the container (`provisionComputer`), the home dir kept. A computer's home (`/home/gem`) is `DATA_DIR/computers/<id>` (`AppData.ComputerDataDir`, owned by uid 1000); deleting the computer (`deleteComputer`, never the main one) removes the container and keeps that directory. Run in docker, the app resolves host paths from its own container's mounts and joins the network; outside docker (dev) it can't resolve container hostnames, so it reaches them at their IP address (`pickReachableAddress`: hostname first, then the IP, the pick cached for `AppData.ComputerAddressCacheMs`). Only super admins create, delete or retry computers (`api/admin/computers/`). The `sync-computers` cron records container states every five minutes. The install has one main computer, created at signup (`bun run seed`) and as a fallback by the super admin's `/admin/bots` via `ensureMainComputer` (`src/functions/backend/db/computers/ensure-main-computer.ts`, idempotent), which grants the coordinator access; with `MAIN_COMPUTER_URL` set (dev) it isn't provisioned. That one grant can't be removed (`isLockedComputerAccess`, enforced in `remove-bot-from-computer` and the `bot_computers` CRUD handler); every other grant can. A bot's effective computers (`grab-bot-computers`): the workspace's computers in a workspace, its default first; in its own chat, its direct grants and those of the workspaces it's attached to (the coordinator's are every computer), the one it's on first: `bots.computer_id` (not a foreign key; null or a lost grant means its main one), so `computers[0]` is where its tasks run by default. The user switches it from the chat header (`(partials)/bot-computers-modal.tsx`, a ghost icon beside the model icon; the header names the current computer and the text model and provider under the bot's name) or by clicking a computer the bot named in a message: bots write `[computer:ID]` (`src/utils/computer-ref.ts`, turned into `#computer-ID` links by `link-computer-refs.ts`, rendered by `(partials)/computer-ref-chip.tsx`), both sharing `BotComputersContext` / `use-bot-computers`, saved by `api/admin/bots/set-computer.ts` (`setBotComputer`). Enums in `src/dict/computer-dict.ts`.
|
||||||
- **Opening a computer:** the app serves each computer to whoever sees it (`computersSql`) under `/computers/<id>/` (`ComputerProxyPrefix`): `src/server.ts` hands those paths to `handleComputerProxy` (`src/functions/server/computers/`: `userAuth`, access via `grabUserComputer`, then the sandbox's address from `grabComputerMcpUrl`), which fetches plain requests (app session cookies dropped, `X-Forwarded-Host/Proto/Prefix` set so the sandbox builds its links and CDP address under the prefix, framing headers and redirects fixed) and bridges websockets (`computerProxySocket`, dispatched from `src/websocket/` when `ws.data.proxy` is set). `ComputerView` (`src/components/general/computer-view.tsx`) shows a computer in one of `ComputerViewModes` (browser `/browser-ui`, terminal `/terminal`, code `/code-server/`, desktop: the sandbox's noVNC at `/vnc/vnc.html`, its socket pointed back through the proxy by the `{prefix}` placeholder that `computerViewUrl` fills in; desktop is the only view with Chrome's toolbar (extensions) and a clipboard panel, since the browser view streams pages over CDP and hides `chrome-extension://` targets; more of the sandbox's apps can be added there), keeping visited modes loaded. In the chat, `(partials)/computer-viewer-modal.tsx` shows it, opened from a computer card's open button in the computers modal or a take-over button (`openComputer` on `BotComputersContext`).
|
- **Opening a computer:** the app serves each computer to whoever sees it (`computersSql`) under `/computers/<id>/` (`ComputerProxyPrefix`): `src/server.ts` hands those paths to `handleComputerProxy` (`src/functions/server/computers/`: `userAuth`, access via `grabUserComputer`, then the sandbox's address from `grabComputerMcpUrl`), which fetches plain requests (app session cookies dropped, `X-Forwarded-Host/Proto/Prefix` set so the sandbox builds its links and CDP address under the prefix, framing headers and redirects fixed) and bridges websockets (`computerProxySocket`, dispatched from `src/websocket/` when `ws.data.proxy` is set). `ComputerView` (`src/components/general/computer-view.tsx`) shows a computer in one of `ComputerViewModes` (browser `/browser-ui`, terminal `/terminal`, code `/code-server/`, desktop: the sandbox's noVNC at `/vnc/vnc.html`, its socket pointed back through the proxy by the `{prefix}` placeholder that `computerViewUrl` fills in; desktop is the only view with Chrome's toolbar (extensions) and a clipboard panel, since the browser view streams pages over CDP and hides `chrome-extension://` targets; more of the sandbox's apps can be added there), keeping visited modes loaded. In the chat, `(partials)/computer-viewer-modal.tsx` shows it, opened from a computer card's open button in the computers modal or a take-over button (`openComputer` on `BotComputersContext`).
|
||||||
@@ -140,7 +140,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.
|
- **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`).
|
- **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`).
|
- **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 `create_workspace` and `update_workspace` (a new workspace with its shared context, or changing one; both also in its tasks, so a task that gathers material can write it into the workspace), `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*`.
|
- **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_workspace` and `update_workspace` (a new workspace with its shared context, or changing one; both also in its tasks, so a task that gathers material can write it into the workspace), `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 `remove_bot_from_workspace` (take it out, keeping its conversation there), `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), `update_bot` (change a worker's name, role or core instructions, `src/functions/backend/db/bots/update-bot.ts`), `find_bot` and `find_workspace` (look the crew, or the live workspaces, up by id, name, role/description or instructions/context), `delete_bot` (a worker with its conversations, tasks, schedules and workspace places; the coordinator can't go) and `delete_workspace` (its conversations, tasks, schedules and links; its bots and computers stay). `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).
|
- **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).
|
||||||
- **MCPs and custom tools (extensions):** installs add their own MCP servers (`mcp_servers`, Settings, under MCPs) and tools (`custom_tools`, Settings, under Tools). A bot gets the ones given to it (`bot_mcp_servers`, `bot_custom_tools`) and, in a workspace, that workspace's (`workspace_mcp_servers`, `workspace_custom_tools`); `botExtensionsSql` (`src/utils/bot-extensions-sql.ts`, tested) resolves a run's, `grabBotExtensions` reads them. `all_bots` ("every bot by default") only decides what bots are given: turning it on gives it to every bot (`attachToAllBots`, the coordinator included) and every new bot gets it (`attachDefaultExtensions`, from `createBot` and `ensureCoordinatorBot`); turning it off takes it from no one, and a bot that drops one keeps it dropped (`src/scripts/attach-every-bot-extensions.ts`, safe to rerun, moved installs from the old "reaches every bot" meaning). Super admins define them. Super admins and admins (the super admin alone for the coordinator, `assertBotExtensionManager`) give and take them per bot on the bot's settings page and under its chat's composer (`composer-tools.tsx`: chips for everything it can use there, a workspace's not removable, and an Add modal; before them `composer-computer-button.tsx` opens the bot's current computer in the viewer), and per workspace on the workspace's settings page; the settings pages share `settings/(partials)/extensions-group.tsx`, all through `useExtensions` (`src/hooks/`), `grabBotExtensionsOverview` / `grabWorkspaceExtensionsOverview` (`bot_extensions` / `workspace_extensions` page props, both on `grabExtensionsOverview`) and `api/admin/bots/` and `api/admin/workspaces/` `add-extension`, `remove-extension` (`setBotExtension`, `setWorkspaceExtension`); the MCPs and Tools pages' Access modal sets bots and workspaces in bulk (`setExtensionAccess`, an admin never touches the coordinator's or `all_bots`). Helpers in `src/functions/backend/db/extensions/`, API in `api/admin/mcps/` and `api/admin/tools/`, zod in `src/schemas/extension-schema.ts`. `openBotExtensions` (`src/harness/tools/extensions/`) turns them into harness tools for chat replies and tasks (built-ins win a name clash; `close()` in the runners' `finally`; `note` tells the bot about a server it can't use). Templates (`fillTemplate`, `src/utils/fill-template.ts`): `{{args.x}}` and `{{env.KEY}}`, envs from `grabRunEnvs` (workspace beats general, app settings never, no need to be set on computers); secret values are redacted from every result.
|
- **MCPs and custom tools (extensions):** installs add their own MCP servers (`mcp_servers`, Settings, under MCPs) and tools (`custom_tools`, Settings, under Tools). A bot gets the ones given to it (`bot_mcp_servers`, `bot_custom_tools`) and, in a workspace, that workspace's (`workspace_mcp_servers`, `workspace_custom_tools`); `botExtensionsSql` (`src/utils/bot-extensions-sql.ts`, tested) resolves a run's, `grabBotExtensions` reads them. `all_bots` ("every bot by default") only decides what bots are given: turning it on gives it to every bot (`attachToAllBots`, the coordinator included) and every new bot gets it (`attachDefaultExtensions`, from `createBot` and `ensureCoordinatorBot`); turning it off takes it from no one, and a bot that drops one keeps it dropped (`src/scripts/attach-every-bot-extensions.ts`, safe to rerun, moved installs from the old "reaches every bot" meaning). Super admins define them. Super admins and admins (the super admin alone for the coordinator, `assertBotExtensionManager`) give and take them per bot on the bot's settings page and under its chat's composer (`composer-tools.tsx`: chips for everything it can use there, a workspace's not removable, and an Add modal; before them `composer-computer-button.tsx` opens the bot's current computer in the viewer), and per workspace on the workspace's settings page; the settings pages share `settings/(partials)/extensions-group.tsx`, all through `useExtensions` (`src/hooks/`), `grabBotExtensionsOverview` / `grabWorkspaceExtensionsOverview` (`bot_extensions` / `workspace_extensions` page props, both on `grabExtensionsOverview`) and `api/admin/bots/` and `api/admin/workspaces/` `add-extension`, `remove-extension` (`setBotExtension`, `setWorkspaceExtension`); the MCPs and Tools pages' Access modal sets bots and workspaces in bulk (`setExtensionAccess`, an admin never touches the coordinator's or `all_bots`). Helpers in `src/functions/backend/db/extensions/`, API in `api/admin/mcps/` and `api/admin/tools/`, zod in `src/schemas/extension-schema.ts`. `openBotExtensions` (`src/harness/tools/extensions/`) turns them into harness tools for chat replies and tasks (built-ins win a name clash; `close()` in the runners' `finally`; `note` tells the bot about a server it can't use). Templates (`fillTemplate`, `src/utils/fill-template.ts`): `{{args.x}}` and `{{env.KEY}}`, envs from `grabRunEnvs` (workspace beats general, app settings never, no need to be set on computers); secret values are redacted from every result.
|
||||||
- **Custom tools** (`CustomToolKinds` in `src/dict/custom-tool-dict.ts`): a name (`checkCustomToolName`: snake_case, never a built-in's, `ReservedToolNames`), a description, a JSON-schema `parameters` and a `config` per kind, built by `buildCustomTool` (`src/harness/tools/custom/`): `http` (`runHttpTool`: the app fetches, arguments URL-encoded in the URL, no body template sends the arguments as JSON), `script` (`runScriptTool`: bash/python/javascript on the run's computer, the task's or in chat the bot's current one, over the sandbox REST API in a subshell, computer envs set, arguments as JSON in `COBBLE_ARGS` and on stdin; left out without a computer), `prompt` (`runPromptTool`: one `generateText` turn with the bot's models). Super admins try one from the page (`testCustomTool`, the main computer for scripts): a saved one from its row (Try), or the form as it is, saved or not (`draft`, nothing recorded), from the Test panel in its modal (`custom-tool-test-panel.tsx`). Every run (a bot's call or a try; not bad arguments or a cancel) is saved as the tool's `status`, `last_error` and `last_checked_at` (`recordCustomToolRun`); editing resets it to `untested`.
|
- **Custom tools** (`CustomToolKinds` in `src/dict/custom-tool-dict.ts`): a name (`checkCustomToolName`: snake_case, never a built-in's, `ReservedToolNames`), a description, a JSON-schema `parameters` and a `config` per kind, built by `buildCustomTool` (`src/harness/tools/custom/`): `http` (`runHttpTool`: the app fetches, arguments URL-encoded in the URL, no body template sends the arguments as JSON), `script` (`runScriptTool`: bash/python/javascript on the run's computer, the task's or in chat the bot's current one, over the sandbox REST API in a subshell, computer envs set, arguments as JSON in `COBBLE_ARGS` and on stdin; left out without a computer), `prompt` (`runPromptTool`: one `generateText` turn with the bot's models). Super admins try one from the page (`testCustomTool`, the main computer for scripts): a saved one from its row (Try), or the form as it is, saved or not (`draft`, nothing recorded), from the Test panel in its modal (`custom-tool-test-panel.tsx`). Every run (a bot's call or a try; not bad arguments or a cancel) is saved as the tool's `status`, `last_error` and `last_checked_at` (`recordCustomToolRun`); editing resets it to `untested`.
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ type Params = {
|
|||||||
* live relay). Null means its main one.
|
* live relay). Null means its main one.
|
||||||
*/
|
*/
|
||||||
computer_id?: number | null;
|
computer_id?: number | null;
|
||||||
|
/**
|
||||||
|
* The workspaces the bot is in now, for a membership change: a
|
||||||
|
* workspace's crew drops a bot that's no longer in it.
|
||||||
|
*/
|
||||||
|
workspace_ids?: number[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,7 +31,13 @@ type Params = {
|
|||||||
* (`grabBotAudience`), each with their own face, status line and active
|
* (`grabBotAudience`), each with their own face, status line and active
|
||||||
* task with it, never another user's.
|
* task with it, never another user's.
|
||||||
*/
|
*/
|
||||||
export default async function pushBotUpdated({ bot_id, user_id, users, computer_id }: Params) {
|
export default async function pushBotUpdated({
|
||||||
|
bot_id,
|
||||||
|
user_id,
|
||||||
|
users,
|
||||||
|
computer_id,
|
||||||
|
workspace_ids,
|
||||||
|
}: Params) {
|
||||||
if (!bot_id) return;
|
if (!bot_id) return;
|
||||||
|
|
||||||
const audience = user_id ? [user_id] : await grabBotAudience({ bot_id });
|
const audience = user_id ? [user_id] : await grabBotAudience({ bot_id });
|
||||||
@@ -42,6 +53,7 @@ export default async function pushBotUpdated({ bot_id, user_id, users, computer_
|
|||||||
id: Number(bot_id),
|
id: Number(bot_id),
|
||||||
...(await grabBotFace({ bot_id, user_id: viewer_id })),
|
...(await grabBotFace({ bot_id, user_id: viewer_id })),
|
||||||
...(computer_id !== undefined ? { computer_id: computer_id || null } : {}),
|
...(computer_id !== undefined ? { computer_id: computer_id || null } : {}),
|
||||||
|
...(workspace_ids !== undefined ? { workspace_ids } : {}),
|
||||||
task: await grabActiveBotTask({ bot_id, user_id: viewer_id }),
|
task: await grabActiveBotTask({ bot_id, user_id: viewer_id }),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { sendToUser } from "@/src/websocket/(functions)/send-to-user";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
workspace_id: number | string;
|
||||||
|
/**
|
||||||
|
* Everyone who saw the workspace, read before its row was deleted:
|
||||||
|
* afterwards its links are gone with it, so its audience can't be
|
||||||
|
* worked out any more.
|
||||||
|
*/
|
||||||
|
user_ids: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pushes `server:workspace-removed` to everyone who could see a workspace
|
||||||
|
* that was deleted, so its rail row goes at once and nothing opens a dead
|
||||||
|
* workspace.
|
||||||
|
*/
|
||||||
|
export default function pushWorkspaceRemoved({ workspace_id, user_ids }: Params) {
|
||||||
|
for (const user_id of user_ids) {
|
||||||
|
sendToUser({
|
||||||
|
user_id,
|
||||||
|
data: {
|
||||||
|
event: "server:workspace-removed",
|
||||||
|
data: { workspace_id: Number(workspace_id) },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
/**
|
||||||
|
* The workspace to scope them to: only its members. Left out for the
|
||||||
|
* whole crew (the coordinator's own chat).
|
||||||
|
*/
|
||||||
|
workspace_id?: number | string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every worker bot the coordinator can hand work to, by id. Read fresh on
|
||||||
|
* each call rather than carried from the start of the run, so a bot
|
||||||
|
* `create_bot` made earlier in the same reply is already there.
|
||||||
|
*/
|
||||||
|
export default async function grabCrewWorkers({
|
||||||
|
workspace_id,
|
||||||
|
}: Params = {}): Promise<BUN_SQLITE_COBBLE_BOTS[]> {
|
||||||
|
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
|
||||||
|
sql: workspace_id
|
||||||
|
? `SELECT b.* FROM bots b JOIN workspace_bots wb ON wb.bot_id = b.id AND wb.workspace_id = ?
|
||||||
|
WHERE b.kind = 'worker' ORDER BY b.id ASC`
|
||||||
|
: `SELECT * FROM bots WHERE kind = 'worker' ORDER BY id ASC`,
|
||||||
|
values: workspace_id ? [workspace_id] : [],
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.payload || [];
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@ import BunSQLite from "@moduletrace/bun-sqlite";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
||||||
import pushBotCreated from "@/src/bots-queue/push-bot-created";
|
import pushBotCreated from "@/src/bots-queue/push-bot-created";
|
||||||
|
import grabCrewWorkers from "@/src/functions/backend/db/bots/grab-crew-workers";
|
||||||
import addBotToWorkspace from "@/src/functions/backend/db/workspaces/add-bot-to-workspace";
|
import addBotToWorkspace from "@/src/functions/backend/db/workspaces/add-bot-to-workspace";
|
||||||
|
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
|
||||||
import type { HarnessTool } from "../../types";
|
import type { HarnessTool } from "../../types";
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
@@ -44,16 +46,24 @@ export default function addBotToWorkspaceTool({
|
|||||||
workspace_id: z.number().int(),
|
workspace_id: z.number().int(),
|
||||||
}),
|
}),
|
||||||
run: async ({ args }) => {
|
run: async ({ args }) => {
|
||||||
const worker = workers.find((bot) => bot.id == args.bot_id);
|
const fresh = await grabCrewWorkers();
|
||||||
const workspace = workspaces.find((w) => w.id == args.workspace_id);
|
const live = (await grabUserWorkspaces({ user_id })).filter((w) => !w.archived);
|
||||||
|
const worker = fresh.find((bot) => bot.id == args.bot_id);
|
||||||
|
const workspace = live.find((w) => w.id == args.workspace_id);
|
||||||
|
|
||||||
if (!worker?.id) {
|
if (!worker?.id) {
|
||||||
throw new Error(`No crew member has id ${args.bot_id}. Crew: ${crew || "none"}.`);
|
throw new Error(
|
||||||
|
`No crew member has id ${args.bot_id}. Crew: ${fresh
|
||||||
|
.map((bot) => `${bot.id}: ${bot.name}`)
|
||||||
|
.join("; ") || "none"}.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!workspace?.id) {
|
if (!workspace?.id) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`No workspace has id ${args.workspace_id}. Workspaces: ${list || "none"}.`,
|
`No workspace has id ${args.workspace_id}. Workspaces: ${
|
||||||
|
live.map((w) => `${w.id}: ${w.name}`).join("; ") || "none"
|
||||||
|
}.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
|
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
|
||||||
|
import grabCrewWorkers from "@/src/functions/backend/db/bots/grab-crew-workers";
|
||||||
import addBotToComputer from "@/src/functions/backend/db/computers/add-bot-to-computer";
|
import addBotToComputer from "@/src/functions/backend/db/computers/add-bot-to-computer";
|
||||||
import removeBotFromComputer from "@/src/functions/backend/db/computers/remove-bot-from-computer";
|
import removeBotFromComputer from "@/src/functions/backend/db/computers/remove-bot-from-computer";
|
||||||
import computerRef from "@/src/utils/computer-ref";
|
import computerRef from "@/src/utils/computer-ref";
|
||||||
@@ -57,11 +58,16 @@ export default function assignComputerTool({
|
|||||||
access: z.enum(["grant", "revoke"]),
|
access: z.enum(["grant", "revoke"]),
|
||||||
}),
|
}),
|
||||||
run: async ({ args }) => {
|
run: async ({ args }) => {
|
||||||
const worker = workers.find((bot) => bot.id == args.bot_id);
|
const fresh = await grabCrewWorkers();
|
||||||
|
const worker = fresh.find((bot) => bot.id == args.bot_id);
|
||||||
const computer = computers.find((c) => c.id == args.computer_id);
|
const computer = computers.find((c) => c.id == args.computer_id);
|
||||||
|
|
||||||
if (!worker?.id) {
|
if (!worker?.id) {
|
||||||
throw new Error(`No crew member has id ${args.bot_id}.`);
|
throw new Error(
|
||||||
|
`No crew member has id ${args.bot_id}. Crew: ${fresh
|
||||||
|
.map((bot) => `${bot.id}: ${bot.name}`)
|
||||||
|
.join("; ") || "none"}.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!computer?.id) {
|
if (!computer?.id) {
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export default function createBotTool({
|
|||||||
|
|
||||||
const where = target?.name ? ` and joins ${target.name}` : "";
|
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."}`;
|
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."} assign_computer, add_bot_to_workspace and delegate_task take #${bot_id} right away, in this same reply.`;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
|||||||
import pushBotNotice from "@/src/bots-queue/push-bot-notice";
|
import pushBotNotice from "@/src/bots-queue/push-bot-notice";
|
||||||
import enqueueBotJob from "@/src/functions/backend/db/bots-queue/enqueue-bot-job";
|
import enqueueBotJob from "@/src/functions/backend/db/bots-queue/enqueue-bot-job";
|
||||||
import insertBotMessage from "@/src/functions/backend/db/bots/insert-bot-message";
|
import insertBotMessage from "@/src/functions/backend/db/bots/insert-bot-message";
|
||||||
|
import grabCrewWorkers from "@/src/functions/backend/db/bots/grab-crew-workers";
|
||||||
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
|
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
|
||||||
import ensureBotSession from "@/src/functions/backend/db/bot-sessions/ensure-bot-session";
|
import ensureBotSession from "@/src/functions/backend/db/bot-sessions/ensure-bot-session";
|
||||||
import type { HarnessTool } from "../../types";
|
import type { HarnessTool } from "../../types";
|
||||||
@@ -65,10 +66,15 @@ export default function delegateTaskTool({
|
|||||||
instructions: z.string().min(1).max(8000),
|
instructions: z.string().min(1).max(8000),
|
||||||
}),
|
}),
|
||||||
run: async ({ args }) => {
|
run: async ({ args }) => {
|
||||||
const worker = workers.find((bot) => bot.id == args.bot_id);
|
const fresh = await grabCrewWorkers({ workspace_id });
|
||||||
|
const worker = fresh.find((bot) => bot.id == args.bot_id);
|
||||||
|
|
||||||
if (!worker?.id) {
|
if (!worker?.id) {
|
||||||
throw new Error(`No crew member has id ${args.bot_id}. Crew: ${crew}.`);
|
throw new Error(
|
||||||
|
`No crew member has id ${args.bot_id}. Crew: ${fresh
|
||||||
|
.map((bot) => `${bot.id}: ${bot.name}`)
|
||||||
|
.join("; ") || "none"}.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const computers = await grabBotComputers({ bot_id: worker.id, workspace_id });
|
const computers = await grabBotComputers({ bot_id: worker.id, workspace_id });
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
||||||
|
import pushBotRemoved from "@/src/bots-queue/push-bot-removed";
|
||||||
|
import grabBotAudience from "@/src/functions/backend/db/access/grab-bot-audience";
|
||||||
|
import grabCrewWorkers from "@/src/functions/backend/db/bots/grab-crew-workers";
|
||||||
|
import deleteBot from "@/src/functions/backend/db/bots/delete-bot";
|
||||||
|
import logEvent from "@/src/functions/backend/logger";
|
||||||
|
import type { HarnessTool } from "../../types";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
user_id: number | string;
|
||||||
|
/**
|
||||||
|
* The user's worker bots.
|
||||||
|
*/
|
||||||
|
workers: BUN_SQLITE_COBBLE_BOTS[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type Args = {
|
||||||
|
bot_id: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lets the coordinator delete a crew member with everything it had: its
|
||||||
|
* conversations in its own chat and in every workspace, its tasks, its
|
||||||
|
* schedules and its workspace places. Its workspaces and computers stay.
|
||||||
|
* Irreversible, so it only runs when the user asked for that exact bot to
|
||||||
|
* go.
|
||||||
|
*/
|
||||||
|
export default function deleteBotTool({ user_id, workers }: Params): HarnessTool<Args> {
|
||||||
|
const crew = workers.map((bot) => `${bot.id}: ${bot.name}`).join("; ");
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: "delete_bot",
|
||||||
|
description: `Delete a crew member and everything it had: its conversations in its own chat and in every workspace, its tasks, its schedules and its places in workspaces. Its workspaces and computers stay. This can't be undone. Only do it when the user explicitly asked for this exact bot to be deleted, never as cleanup or to make room, and say what is going with it. Crew: ${crew || "none"}.`,
|
||||||
|
schema: z.object({
|
||||||
|
bot_id: z.number().int(),
|
||||||
|
}),
|
||||||
|
run: async ({ args }) => {
|
||||||
|
const fresh = await grabCrewWorkers();
|
||||||
|
const worker = fresh.find((bot) => bot.id == args.bot_id);
|
||||||
|
|
||||||
|
if (!worker?.id) {
|
||||||
|
throw new Error(
|
||||||
|
`No crew member has id ${args.bot_id}. Crew: ${fresh
|
||||||
|
.map((bot) => `${bot.id}: ${bot.name}`)
|
||||||
|
.join("; ") || "none"}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read before the delete: its assignments and workspace places
|
||||||
|
* go with it, so who saw it can't be worked out afterwards.
|
||||||
|
*/
|
||||||
|
const audience = await grabBotAudience({ bot_id: worker.id });
|
||||||
|
const deleted = await deleteBot({ user_id, bot_id: worker.id });
|
||||||
|
|
||||||
|
pushBotRemoved({ bot_id: worker.id, user_ids: audience });
|
||||||
|
|
||||||
|
await logEvent({
|
||||||
|
title: "Bot deleted",
|
||||||
|
category: "system",
|
||||||
|
user_id: Number(user_id),
|
||||||
|
plain_text: `Bot deleted the bot ${deleted.name}.`,
|
||||||
|
metadata: { bot_id: worker.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return `${deleted.name || worker.name} is deleted, with its conversations, tasks, schedules and workspace places. The workspaces and computers it used stay.`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
||||||
|
import pushWorkspaceRemoved from "@/src/bots-queue/push-workspace-removed";
|
||||||
|
import grabWorkspaceAudience from "@/src/functions/backend/db/access/grab-workspace-audience";
|
||||||
|
import deleteWorkspace from "@/src/functions/backend/db/workspaces/delete-workspace";
|
||||||
|
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
|
||||||
|
import logEvent from "@/src/functions/backend/logger";
|
||||||
|
import type { HarnessTool } from "../../types";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
user_id: number | string;
|
||||||
|
/**
|
||||||
|
* The user's live workspaces.
|
||||||
|
*/
|
||||||
|
workspaces: BUN_SQLITE_COBBLE_WORKSPACES[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type Args = {
|
||||||
|
workspace_id: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lets the coordinator delete a workspace with its conversations, tasks,
|
||||||
|
* schedules and links. Its bots and computers stay, just not in it any
|
||||||
|
* more. Irreversible, so it only runs when the user asked for that exact
|
||||||
|
* workspace to go.
|
||||||
|
*/
|
||||||
|
export default function deleteWorkspaceTool({
|
||||||
|
user_id,
|
||||||
|
workspaces,
|
||||||
|
}: Params): HarnessTool<Args> {
|
||||||
|
const list = workspaces.map((w) => `${w.id}: ${w.name}`).join("; ");
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: "delete_workspace",
|
||||||
|
description: `Delete a workspace with its conversations, tasks, schedules and links to bots and computers. Its bots and computers stay; they're just not in it any more. This can't be undone. Only do it when the user explicitly asked for this exact workspace to be deleted, never as cleanup or to make room, and say what is going with it. Workspaces: ${list || "none"}.`,
|
||||||
|
schema: z.object({
|
||||||
|
workspace_id: z.number().int(),
|
||||||
|
}),
|
||||||
|
run: async ({ args }) => {
|
||||||
|
const live = (await grabUserWorkspaces({ user_id })).filter((w) => !w.archived);
|
||||||
|
const workspace = live.find((w) => w.id == args.workspace_id);
|
||||||
|
|
||||||
|
if (!workspace?.id) {
|
||||||
|
throw new Error(
|
||||||
|
`No workspace has id ${args.workspace_id}. Workspaces: ${
|
||||||
|
live.map((w) => `${w.id}: ${w.name}`).join("; ") || "none"
|
||||||
|
}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read before the delete: its links go with it, so who saw it
|
||||||
|
* can't be worked out afterwards.
|
||||||
|
*/
|
||||||
|
const audience = await grabWorkspaceAudience({ workspace_id: workspace.id });
|
||||||
|
const deleted = await deleteWorkspace({ user_id, workspace_id: workspace.id });
|
||||||
|
|
||||||
|
pushWorkspaceRemoved({ workspace_id: workspace.id, user_ids: audience });
|
||||||
|
|
||||||
|
await logEvent({
|
||||||
|
title: "Workspace deleted",
|
||||||
|
category: "system",
|
||||||
|
user_id: Number(user_id),
|
||||||
|
plain_text: `Bot deleted the workspace ${deleted.name}.`,
|
||||||
|
metadata: { workspace_id: workspace.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return `${deleted.name || workspace.name} is deleted, with its conversations, tasks, schedules and links. The bots and computers that were in it stay.`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import { z } from "zod";
|
||||||
|
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
||||||
|
import type { HarnessTool } from "../../types";
|
||||||
|
|
||||||
|
type Args = {
|
||||||
|
query?: string;
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lets the coordinator look up crew members in the install: by id, name,
|
||||||
|
* role or core instructions, so it can find the bot behind a job before
|
||||||
|
* changing or deleting it. Read fresh every call, so a bot another reply
|
||||||
|
* just created is already there.
|
||||||
|
*/
|
||||||
|
export default function findBotTool(): HarnessTool<Args> {
|
||||||
|
return {
|
||||||
|
name: "find_bot",
|
||||||
|
description: `Find crew members: by id, name, the one-line role, or words in their core instructions. Returns each match's id, name, kind and role. With no query it lists the crew, up to limit. The coordinator is listed too but can't be deleted.`,
|
||||||
|
schema: z.object({
|
||||||
|
query: z.string().trim().max(200).optional(),
|
||||||
|
limit: z.number().int().min(1).max(50).optional(),
|
||||||
|
}),
|
||||||
|
run: async ({ args }) => {
|
||||||
|
const all =
|
||||||
|
(
|
||||||
|
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
|
||||||
|
sql: `SELECT * FROM bots ORDER BY id ASC`,
|
||||||
|
values: [],
|
||||||
|
})
|
||||||
|
).payload || [];
|
||||||
|
|
||||||
|
const query = (args.query || "").trim().toLowerCase();
|
||||||
|
|
||||||
|
const matches = (
|
||||||
|
query
|
||||||
|
? all.filter((bot) =>
|
||||||
|
[bot.id ? String(bot.id) : "", bot.name, bot.role, bot.system_prompt].some(
|
||||||
|
(field) => (field || "").toLowerCase().includes(query),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: all
|
||||||
|
).slice(0, args.limit ?? 20);
|
||||||
|
|
||||||
|
if (!matches.length) {
|
||||||
|
return `No crew member matches "${args.query || ""}".`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches
|
||||||
|
.map(
|
||||||
|
(bot) =>
|
||||||
|
`#${bot.id} ${bot.name} (${bot.kind}${bot.role ? `: ${bot.role}` : ""})`,
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
|
||||||
|
import type { HarnessTool } from "../../types";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
user_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Args = {
|
||||||
|
query?: string;
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lets the coordinator look up the user's live workspaces: by id, name,
|
||||||
|
* description or shared context, so it can find the one to change or
|
||||||
|
* delete. Read fresh every call, so a workspace another reply just
|
||||||
|
* created is already there.
|
||||||
|
*/
|
||||||
|
export default function findWorkspaceTool({ user_id }: Params): HarnessTool<Args> {
|
||||||
|
return {
|
||||||
|
name: "find_workspace",
|
||||||
|
description: `Find a live workspace: by id, name, description, or words in its shared context. Returns each match's id, name and description. With no query it lists them, up to limit.`,
|
||||||
|
schema: z.object({
|
||||||
|
query: z.string().trim().max(200).optional(),
|
||||||
|
limit: z.number().int().min(1).max(50).optional(),
|
||||||
|
}),
|
||||||
|
run: async ({ args }) => {
|
||||||
|
const live = (await grabUserWorkspaces({ user_id })).filter((w) => !w.archived);
|
||||||
|
|
||||||
|
const query = (args.query || "").trim().toLowerCase();
|
||||||
|
|
||||||
|
const matches = (
|
||||||
|
query
|
||||||
|
? live.filter((workspace) =>
|
||||||
|
[
|
||||||
|
workspace.id ? String(workspace.id) : "",
|
||||||
|
workspace.name,
|
||||||
|
workspace.description,
|
||||||
|
workspace.system_prompt,
|
||||||
|
].some((field) => (field || "").toLowerCase().includes(query)),
|
||||||
|
)
|
||||||
|
: live
|
||||||
|
).slice(0, args.limit ?? 20);
|
||||||
|
|
||||||
|
if (!matches.length) {
|
||||||
|
return `No workspace matches "${args.query || ""}".`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches
|
||||||
|
.map(
|
||||||
|
(workspace) =>
|
||||||
|
`#${workspace.id} ${workspace.name}${workspace.description ? ` - ${workspace.description}` : ""}`,
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
|
||||||
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
|
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
|
||||||
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
|
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
|
||||||
|
import grabCrewWorkers from "@/src/functions/backend/db/bots/grab-crew-workers";
|
||||||
import type { HarnessTool } from "../../types";
|
import type { HarnessTool } from "../../types";
|
||||||
import askToTakeOverTool from "../computer/ask-to-take-over-tool";
|
import askToTakeOverTool from "../computer/ask-to-take-over-tool";
|
||||||
import searchComputersTool from "../computer/search-computers-tool";
|
import searchComputersTool from "../computer/search-computers-tool";
|
||||||
@@ -9,7 +9,13 @@ import assignComputerTool from "./assign-computer-tool";
|
|||||||
import createBotTool from "./create-bot-tool";
|
import createBotTool from "./create-bot-tool";
|
||||||
import createWorkspaceTool from "./create-workspace-tool";
|
import createWorkspaceTool from "./create-workspace-tool";
|
||||||
import delegateTaskTool from "./delegate-task-tool";
|
import delegateTaskTool from "./delegate-task-tool";
|
||||||
|
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 grabBotTools from "./grab-bot-tools";
|
import grabBotTools from "./grab-bot-tools";
|
||||||
|
import removeBotFromWorkspaceTool from "./remove-bot-from-workspace-tool";
|
||||||
|
import updateBotTool from "./update-bot-tool";
|
||||||
import updateWorkspaceTool from "./update-workspace-tool";
|
import updateWorkspaceTool from "./update-workspace-tool";
|
||||||
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
|
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
|
||||||
import setWorkspaceInstructionsTool from "./set-workspace-instructions-tool";
|
import setWorkspaceInstructionsTool from "./set-workspace-instructions-tool";
|
||||||
@@ -49,10 +55,13 @@ type Params = {
|
|||||||
* for the coordinator (the super admin's alone) create_workspace and
|
* for the coordinator (the super admin's alone) create_workspace and
|
||||||
* update_workspace (a workspace and its shared context), create_bot to
|
* update_workspace (a workspace and its shared context), create_bot to
|
||||||
* grow the crew, delegate_task to every worker (the workspace's in a
|
* grow the crew, delegate_task to every worker (the workspace's in a
|
||||||
* workspace) plus, in its own chat, add_bot_to_workspace and
|
* workspace) plus, in its own chat, find_bot and find_workspace,
|
||||||
* assign_computer (which workspace and which computers each crew member
|
* add_bot_to_workspace and remove_bot_from_workspace, assign_computer,
|
||||||
* gets) or, in a workspace, set_bot_workspace_instructions. Anything else
|
* update_bot, delete_bot and delete_workspace (which workspace, which
|
||||||
* on a computer goes through a task, never mid-reply.
|
* computers, and which name, role and core instructions each crew member
|
||||||
|
* gets, and what goes) 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({
|
export default async function grabBotChatTools({
|
||||||
bot,
|
bot,
|
||||||
@@ -93,16 +102,28 @@ export default async function grabBotChatTools({
|
|||||||
|
|
||||||
tools.push(createBotTool({ user_id, workspaces, workspace_id }));
|
tools.push(createBotTool({ user_id, workspaces, workspace_id }));
|
||||||
|
|
||||||
const workers =
|
const workers = await grabCrewWorkers({ workspace_id });
|
||||||
(
|
|
||||||
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
|
if (workspace_id) {
|
||||||
sql: workspace_id
|
if (!workers.length) return tools;
|
||||||
? `SELECT b.* FROM bots b JOIN workspace_bots wb ON wb.bot_id = b.id AND wb.workspace_id = ?
|
|
||||||
WHERE b.kind = 'worker' ORDER BY b.id ASC`
|
tools.push(delegateTaskTool({
|
||||||
: `SELECT * FROM bots WHERE kind = 'worker' ORDER BY id ASC`,
|
user_id,
|
||||||
values: workspace_id ? [workspace_id] : [],
|
coordinator_id: bot_id,
|
||||||
})
|
session_id,
|
||||||
).payload || [];
|
workspace_id,
|
||||||
|
coordinator_name: bot.name || "The coordinator",
|
||||||
|
workers,
|
||||||
|
}));
|
||||||
|
tools.push(setWorkspaceInstructionsTool({ user_id, workspace_id, workers }));
|
||||||
|
return tools;
|
||||||
|
}
|
||||||
|
|
||||||
|
tools.push(findBotTool(), findWorkspaceTool({ user_id }));
|
||||||
|
|
||||||
|
if (workspaces.length) {
|
||||||
|
tools.push(deleteWorkspaceTool({ user_id, workspaces }));
|
||||||
|
}
|
||||||
|
|
||||||
if (!workers.length) return tools;
|
if (!workers.length) return tools;
|
||||||
|
|
||||||
@@ -117,10 +138,7 @@ export default async function grabBotChatTools({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (workspace_id) {
|
tools.push(updateBotTool({ user_id, workers }), deleteBotTool({ user_id, workers }));
|
||||||
tools.push(setWorkspaceInstructionsTool({ user_id, workspace_id, workers }));
|
|
||||||
return tools;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (computers.length) {
|
if (computers.length) {
|
||||||
const access = Object.fromEntries(
|
const access = Object.fromEntries(
|
||||||
@@ -139,7 +157,10 @@ export default async function grabBotChatTools({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (workspaces.length) {
|
if (workspaces.length) {
|
||||||
tools.push(addBotToWorkspaceTool({ user_id, workers, workspaces }));
|
tools.push(
|
||||||
|
addBotToWorkspaceTool({ user_id, workers, workspaces }),
|
||||||
|
removeBotFromWorkspaceTool({ user_id, workers, workspaces }),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return tools;
|
return tools;
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
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 pushBotUpdated from "@/src/bots-queue/push-bot-updated";
|
||||||
|
import grabCrewWorkers from "@/src/functions/backend/db/bots/grab-crew-workers";
|
||||||
|
import removeBotFromWorkspace from "@/src/functions/backend/db/workspaces/remove-bot-from-workspace";
|
||||||
|
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
|
||||||
|
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 take a crew member out of one of the user's
|
||||||
|
* workspaces. The bot stays on the crew, and its conversation in that
|
||||||
|
* workspace is kept for if it's put back. Its access through the
|
||||||
|
* workspace's computers ends with it.
|
||||||
|
*/
|
||||||
|
export default function removeBotFromWorkspaceTool({
|
||||||
|
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: "remove_bot_from_workspace",
|
||||||
|
description: `Take a crew member out of one of the user's workspaces. The bot stays on the crew and can be put back with add_bot_to_workspace; its conversation there is kept. It loses that workspace's context, models and computers, and the user stops seeing it in the workspace. Crew: ${crew || "none"}. Workspaces: ${list || "none"}.`,
|
||||||
|
schema: z.object({
|
||||||
|
bot_id: z.number().int(),
|
||||||
|
workspace_id: z.number().int(),
|
||||||
|
}),
|
||||||
|
run: async ({ args }) => {
|
||||||
|
const fresh = await grabCrewWorkers();
|
||||||
|
const live = (await grabUserWorkspaces({ user_id })).filter((w) => !w.archived);
|
||||||
|
const worker = fresh.find((bot) => bot.id == args.bot_id);
|
||||||
|
const workspace = live.find((w) => w.id == args.workspace_id);
|
||||||
|
|
||||||
|
if (!worker?.id) {
|
||||||
|
throw new Error(
|
||||||
|
`No crew member has id ${args.bot_id}. Crew: ${fresh
|
||||||
|
.map((bot) => `${bot.id}: ${bot.name}`)
|
||||||
|
.join("; ") || "none"}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!workspace?.id) {
|
||||||
|
throw new Error(
|
||||||
|
`No workspace has id ${args.workspace_id}. Workspaces: ${
|
||||||
|
live.map((w) => `${w.id}: ${w.name}`).join("; ") || "none"
|
||||||
|
}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const present =
|
||||||
|
(
|
||||||
|
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 (!present) {
|
||||||
|
return `${worker.name} isn't in ${workspace.name}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await removeBotFromWorkspace({
|
||||||
|
user_id,
|
||||||
|
workspace_id: workspace.id,
|
||||||
|
bot_id: worker.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The workspaces it's in now, so its crew lists drop it at once. */
|
||||||
|
const workspace_ids = (
|
||||||
|
(
|
||||||
|
await BunSQLite.sql<{ workspace_id: number }>({
|
||||||
|
sql: `SELECT workspace_id FROM workspace_bots WHERE bot_id = ?`,
|
||||||
|
values: [worker.id],
|
||||||
|
})
|
||||||
|
).payload || []
|
||||||
|
).map((row) => Number(row.workspace_id));
|
||||||
|
|
||||||
|
await pushBotUpdated({ bot_id: worker.id, workspace_ids });
|
||||||
|
|
||||||
|
return `${worker.name} is out of ${workspace.name}. It's still on the crew, and its conversation there is kept for if it's put back. It loses that workspace's context, models and computers.`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
||||||
import { AppData } from "@/src/data/app-data";
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import grabCrewWorkers from "@/src/functions/backend/db/bots/grab-crew-workers";
|
||||||
import updateWorkspaceBot from "@/src/functions/backend/db/workspaces/update-workspace-bot";
|
import updateWorkspaceBot from "@/src/functions/backend/db/workspaces/update-workspace-bot";
|
||||||
import type { HarnessTool } from "../../types";
|
import type { HarnessTool } from "../../types";
|
||||||
|
|
||||||
@@ -37,10 +38,15 @@ export default function setWorkspaceInstructionsTool({
|
|||||||
instructions: z.string().trim().max(AppData.PromptMaxLength),
|
instructions: z.string().trim().max(AppData.PromptMaxLength),
|
||||||
}),
|
}),
|
||||||
run: async ({ args }) => {
|
run: async ({ args }) => {
|
||||||
const worker = workers.find((bot) => bot.id == args.bot_id);
|
const fresh = await grabCrewWorkers({ workspace_id });
|
||||||
|
const worker = fresh.find((bot) => bot.id == args.bot_id);
|
||||||
|
|
||||||
if (!worker?.id) {
|
if (!worker?.id) {
|
||||||
throw new Error(`No crew member here has id ${args.bot_id}. Crew here: ${crew}.`);
|
throw new Error(
|
||||||
|
`No crew member here has id ${args.bot_id}. Crew here: ${fresh
|
||||||
|
.map((bot) => `${bot.id}: ${bot.name}`)
|
||||||
|
.join("; ") || "none"}.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await updateWorkspaceBot({
|
await updateWorkspaceBot({
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import type { BotHue } from "@/src/dict/bot-dict";
|
||||||
|
import grabCrewWorkers from "@/src/functions/backend/db/bots/grab-crew-workers";
|
||||||
|
import updateBot from "@/src/functions/backend/db/bots/update-bot";
|
||||||
|
import type { HarnessTool } from "../../types";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
user_id: number | string;
|
||||||
|
/**
|
||||||
|
* The user's worker bots.
|
||||||
|
*/
|
||||||
|
workers: BUN_SQLITE_COBBLE_BOTS[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type Args = {
|
||||||
|
bot_id: number;
|
||||||
|
name?: string;
|
||||||
|
role?: string;
|
||||||
|
instructions?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lets the coordinator change an existing crew member: its name, its
|
||||||
|
* one-line job, and its core instructions (the start of its prompt in its
|
||||||
|
* own chat and in every workspace). Its hue and picture stay as they are.
|
||||||
|
* Workspace-only instructions have their own tool
|
||||||
|
* (`set_bot_workspace_instructions`).
|
||||||
|
*/
|
||||||
|
export default function updateBotTool({ user_id, workers }: Params): HarnessTool<Args> {
|
||||||
|
const crew = workers.map((bot) => `${bot.id}: ${bot.name}`).join("; ");
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: "update_bot",
|
||||||
|
description: `Change an existing crew member: name, the one-line role it's shown with, and/or its core instructions (markdown: what they do and how they work, the start of their prompt in their own chat and in every workspace). Only the fields you pass change. Instructions replace what they had, so keep what still applies, their own edits included. This is not their instructions for one workspace: use set_bot_workspace_instructions for that. Crew: ${crew || "none"}.`,
|
||||||
|
schema: z
|
||||||
|
.object({
|
||||||
|
bot_id: z.number().int(),
|
||||||
|
name: z.string().trim().min(1).max(40).optional(),
|
||||||
|
role: z.string().trim().max(120).optional(),
|
||||||
|
instructions: z.string().trim().min(1).max(AppData.PromptMaxLength).optional(),
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(args) =>
|
||||||
|
args.name !== undefined ||
|
||||||
|
args.role !== undefined ||
|
||||||
|
args.instructions !== undefined,
|
||||||
|
"Give the name, role or instructions to change.",
|
||||||
|
),
|
||||||
|
run: async ({ args }) => {
|
||||||
|
const fresh = await grabCrewWorkers();
|
||||||
|
const worker = fresh.find((bot) => bot.id == args.bot_id);
|
||||||
|
|
||||||
|
if (!worker?.id) {
|
||||||
|
throw new Error(
|
||||||
|
`No crew member has id ${args.bot_id}. Crew: ${fresh
|
||||||
|
.map((bot) => `${bot.id}: ${bot.name}`)
|
||||||
|
.join("; ") || "none"}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const changed = [
|
||||||
|
args.name !== undefined ? "name" : "",
|
||||||
|
args.role !== undefined ? "role" : "",
|
||||||
|
args.instructions !== undefined ? "core instructions" : "",
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
const saved = await updateBot({
|
||||||
|
user_id,
|
||||||
|
bot_id: worker.id,
|
||||||
|
name: args.name ?? worker.name ?? "Bot",
|
||||||
|
role: args.role ?? worker.role ?? "",
|
||||||
|
hue: (worker.hue || "ember") as BotHue,
|
||||||
|
system_prompt: args.instructions ?? worker.system_prompt ?? "",
|
||||||
|
});
|
||||||
|
|
||||||
|
return `${saved.name}'s ${changed.join(", ")} saved.${args.instructions !== undefined ? " The instructions apply from its next run, in its own chat and in every workspace." : ""}`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -63,13 +63,25 @@ export default function useLiveBots({ bots, open_bot_id, workspace_id = null }:
|
|||||||
useServerEvent({
|
useServerEvent({
|
||||||
event: "server:bot-updated",
|
event: "server:bot-updated",
|
||||||
onData: (data: BotUpdatedData) =>
|
onData: (data: BotUpdatedData) =>
|
||||||
setLive((prev) =>
|
setLive((prev) => {
|
||||||
prev.map((bot) =>
|
/**
|
||||||
|
* A membership change says which workspaces the bot is in
|
||||||
|
* now; a workspace's crew drops it when it left.
|
||||||
|
*/
|
||||||
|
if (
|
||||||
|
workspace_id &&
|
||||||
|
data.workspace_ids &&
|
||||||
|
!data.workspace_ids.includes(Number(workspace_id))
|
||||||
|
) {
|
||||||
|
return prev.filter((bot) => bot.id != data.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return prev.map((bot) =>
|
||||||
bot.id == data.id
|
bot.id == data.id
|
||||||
? { ...bot, face: data.face, status_line: data.status_line }
|
? { ...bot, face: data.face, status_line: data.status_line }
|
||||||
: bot,
|
: bot,
|
||||||
),
|
);
|
||||||
),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
useServerEvent({
|
useServerEvent({
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
|
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
|
||||||
import useServerEvent from "@/src/hooks/use-server-event";
|
import useServerEvent from "@/src/hooks/use-server-event";
|
||||||
import type { WorkspaceCreatedData, WorkspaceWithActivity } from "@/src/types";
|
import type {
|
||||||
|
WorkspaceCreatedData,
|
||||||
|
WorkspaceRemovedData,
|
||||||
|
WorkspaceWithActivity,
|
||||||
|
} from "@/src/types";
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
workspaces: WorkspaceWithActivity[];
|
workspaces: WorkspaceWithActivity[];
|
||||||
@@ -35,6 +39,12 @@ export default function useLiveWorkspaces({ workspaces, open_workspace_id }: Par
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useServerEvent({
|
||||||
|
event: "server:workspace-removed",
|
||||||
|
onData: (data: WorkspaceRemovedData) =>
|
||||||
|
setLive((prev) => prev.filter((workspace) => workspace.id != data.workspace_id)),
|
||||||
|
});
|
||||||
|
|
||||||
useServerEvent({
|
useServerEvent({
|
||||||
event: "server:bot-message",
|
event: "server:bot-message",
|
||||||
onData: (message: BUN_SQLITE_COBBLE_BOT_MESSAGES) => {
|
onData: (message: BUN_SQLITE_COBBLE_BOT_MESSAGES) => {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||||
|
import pushWorkspaceRemoved from "@/src/bots-queue/push-workspace-removed";
|
||||||
|
import grabWorkspaceAudience from "@/src/functions/backend/db/access/grab-workspace-audience";
|
||||||
import deleteWorkspace from "@/src/functions/backend/db/workspaces/delete-workspace";
|
import deleteWorkspace from "@/src/functions/backend/db/workspaces/delete-workspace";
|
||||||
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||||
import { WorkspaceIdRequestSchema } from "@/src/schemas/workspace-schema";
|
import { WorkspaceIdRequestSchema } from "@/src/schemas/workspace-schema";
|
||||||
@@ -33,8 +35,16 @@ export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({ req, b
|
|||||||
|
|
||||||
const { workspace_id } = parseWithSchema({ schema: WorkspaceIdRequestSchema, data: body });
|
const { workspace_id } = parseWithSchema({ schema: WorkspaceIdRequestSchema, data: body });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read before the delete: its links go with it, so who saw it
|
||||||
|
* can't be worked out afterwards.
|
||||||
|
*/
|
||||||
|
const audience = await grabWorkspaceAudience({ workspace_id });
|
||||||
|
|
||||||
const workspace = await deleteWorkspace({ user_id: user.id, workspace_id });
|
const workspace = await deleteWorkspace({ user_id: user.id, workspace_id });
|
||||||
|
|
||||||
|
pushWorkspaceRemoved({ workspace_id, user_ids: audience });
|
||||||
|
|
||||||
await logEvent({
|
await logEvent({
|
||||||
title: "Workspace deleted",
|
title: "Workspace deleted",
|
||||||
category: "system",
|
category: "system",
|
||||||
|
|||||||
@@ -192,6 +192,13 @@ export type WorkspaceCreatedData = {
|
|||||||
workspace: WorkspaceWithActivity;
|
workspace: WorkspaceWithActivity;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `server:workspace-removed` data: the workspace that was deleted.
|
||||||
|
*/
|
||||||
|
export type WorkspaceRemovedData = {
|
||||||
|
workspace_id: number;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `server:bot-removed` data: the bot that was deleted.
|
* `server:bot-removed` data: the bot that was deleted.
|
||||||
*/
|
*/
|
||||||
@@ -213,6 +220,11 @@ export type BotUpdatedData = {
|
|||||||
* live relay). Null means its main one.
|
* live relay). Null means its main one.
|
||||||
*/
|
*/
|
||||||
computer_id?: number | null;
|
computer_id?: number | null;
|
||||||
|
/**
|
||||||
|
* The workspaces the bot is in, when the push read them (a membership
|
||||||
|
* change). A workspace's crew drops a bot that's no longer in it.
|
||||||
|
*/
|
||||||
|
workspace_ids?: number[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -845,6 +857,11 @@ export const WebSocketEvents = [
|
|||||||
* `WorkspaceCreatedData`)
|
* `WorkspaceCreatedData`)
|
||||||
*/
|
*/
|
||||||
"server:workspace-created",
|
"server:workspace-created",
|
||||||
|
/**
|
||||||
|
* A workspace the user could see was deleted (`data`:
|
||||||
|
* `WorkspaceRemovedData`)
|
||||||
|
*/
|
||||||
|
"server:workspace-removed",
|
||||||
/**
|
/**
|
||||||
* A running job took a step (`data`: `BotJobUpdateData`)
|
* A running job took a step (`data`: `BotJobUpdateData`)
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user