Let the coordinator create workspaces and write findings in tasks

create_workspace gives the coordinator a way to start a workspace with its shared context, and the tool list is shared, so a new id can be used by create_bot or add_bot_to_workspace in the same reply.

A coordinator's tasks now get create_workspace and update_workspace too: the research task that had the material could only report it, never write it into the workspace, so the coordinator promised an update it had no turn to make.

A workspace created from chat is pushed as server:workspace-created, from the tool and from the live relay, so the rail shows it without a reload.
This commit is contained in:
2026-10-19 14:32:47 +01:00
parent 1cafb40eef
commit 550aaeabac
12 changed files with 268 additions and 16 deletions
+38
View File
@@ -0,0 +1,38 @@
import type { WorkspaceWithActivity } from "@/src/types";
import grabWorkspaceAudience from "@/src/functions/backend/db/access/grab-workspace-audience";
import { sendToUser } from "@/src/websocket/(functions)/send-to-user";
type Params = {
workspace: WorkspaceWithActivity;
/**
* Only these users (the connected ones, for the live relay); else
* everyone who sees the workspace.
*/
users?: Set<string>;
};
/**
* Pushes `server:workspace-created` to everyone who sees a workspace that
* was just created, so it shows in their rail without a reload.
*/
export default async function pushWorkspaceCreated({ workspace, users }: Params) {
if (!workspace.id) return;
const data: WorkspaceWithActivity = {
...workspace,
unread: 0,
last_activity_at: null,
};
for (const user_id of await grabWorkspaceAudience({ workspace_id: workspace.id })) {
if (users && !users.has(String(user_id))) continue;
sendToUser({
user_id,
data: {
event: "server:workspace-created",
data: { workspace: data },
},
});
}
}
+31 -1
View File
@@ -1,9 +1,13 @@
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import { TaskRunNote, WorkspaceLearningNote } from "@/src/data/harness-prompts";
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
import grabBotPrompt from "@/src/functions/backend/db/bots/grab-bot-prompt";
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
import runTextLoop from "@/src/harness/text/run-text-loop";
import openComputerTools from "@/src/harness/tools/computer/open-computer-tools";
import grabBotTools from "@/src/harness/tools/bot/grab-bot-tools";
import createWorkspaceTool from "@/src/harness/tools/bot/create-workspace-tool";
import updateWorkspaceTool from "@/src/harness/tools/bot/update-workspace-tool";
import askToTakeOverTool from "@/src/harness/tools/computer/ask-to-take-over-tool";
import searchComputersTool from "@/src/harness/tools/computer/search-computers-tool";
import computerRef from "@/src/utils/computer-ref";
@@ -14,7 +18,7 @@ import withTakeOverRefs from "./with-take-over-refs";
import type { BotJobContext, BotJobOutcome } from "./types";
type Params = BotJobContext & {
bot: { id: number };
bot: Pick<BUN_SQLITE_COBBLE_BOTS, "kind"> & { id: number };
/**
* The user whose conversation the job is in.
*/
@@ -53,6 +57,15 @@ export default async function runBotTask({
const session_id = await jobSessionId({ job });
const prompt = await grabBotPrompt({ bot_id: bot.id, user_id, workspace_id });
const computers = await grabBotComputers({ bot_id: bot.id, workspace_id });
/**
* A coordinator's task can also change workspaces: the findings
* it was asked to record go there, not only in the report.
*/
const coordinator_workspaces =
bot.kind == "coordinator"
? (await grabUserWorkspaces({ user_id })).filter((w) => !w.archived)
: [];
const carry_over = await grabSessionCarryOver({
session_id,
bot_id: bot.id,
@@ -75,6 +88,9 @@ export default async function runBotTask({
carry_over,
TaskRunNote,
workspace_id ? WorkspaceLearningNote : "",
bot.kind == "coordinator"
? `If this brief asked you to record something in a workspace (its context, or a new one), do it yourself with update_workspace or create_workspace before you report: the report alone changes nothing.`
: "",
computer
? `You're working on the computer "${computer.name}" (a Linux sandbox with a shell, files, code runners and a browser). Use its tools; don't ask the user to run anything. When only the user can go on (a login, a captcha, a code sent to them), ask them to take over with ask_user_to_take_over.`
: workspace_id
@@ -103,6 +119,20 @@ export default async function runBotTask({
],
tools: [
...grabBotTools({ user_id, bot_id: bot.id, workspace_id, computers }),
...(bot.kind == "coordinator"
? [
createWorkspaceTool({ user_id, workspaces: coordinator_workspaces }),
...(coordinator_workspaces.length
? [
updateWorkspaceTool({
user_id,
workspaces: coordinator_workspaces,
workspace_id,
}),
]
: []),
]
: []),
...(computers.length
? [
searchComputersTool({ computers, workspace_id }),
+1
View File
@@ -14,6 +14,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.
- **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.
- **Give work a home.** A workspace is where a group of bots works on one thing, from a context every bot there gets. Create one with create_workspace when the user is starting something new, and keep any workspace's name, description or context current with update_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.
- **Lead the way.** Don't wait to be told what's next. Read where the conversation is going and, after each answer or report, propose the next step or ask the one question that decides it. Suggest one thing, specific to what the user is after.
+1 -1
View File
@@ -38,7 +38,7 @@ Be proactive. Follow where the conversation is heading and, once you've answered
/**
* 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, follow up on anything left, and propose what comes next: the next piece of work, which bot would take it, or a question that settles the direction. 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.`;
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, follow up on anything left, and propose what comes next: the next piece of work, which bot would take it, or a question that settles the direction. Once the user agrees, you can also grow the crew with create_bot, put a worker in a workspace with add_bot_to_workspace, and create or change a workspace with create_workspace and update_workspace.`;
/**
* Added to the coordinator's chat prompt instead while it has no workers,
@@ -0,0 +1,21 @@
import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
workspace_id: number | string;
};
/**
* Every user who sees a workspace (`workspacesSql`), for live updates
* about it: super admins and admins, and the members added to it
* (`workspace_users`). A new workspace has none of those yet.
*/
export default async function grabWorkspaceAudience({ workspace_id }: Params): Promise<number[]> {
const res = await BunSQLite.sql<{ user_id: number }>({
sql: `SELECT user_id FROM user_types WHERE user_type IN ('super_admin', 'admin')
UNION
SELECT user_id FROM workspace_users WHERE workspace_id = ?`,
values: [workspace_id],
});
return (res.payload || []).map((row) => Number(row.user_id)).filter(Boolean);
}
@@ -0,0 +1,90 @@
import { z } from "zod";
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
import pushWorkspaceCreated from "@/src/bots-queue/push-workspace-created";
import logEvent from "@/src/functions/backend/logger";
import createWorkspace from "@/src/functions/backend/db/workspaces/create-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 live workspaces, which the new one is also added to at
* run time so later tool calls in the same reply can use its id.
*/
workspaces: BUN_SQLITE_COBBLE_WORKSPACES[];
};
type Args = {
name: string;
description?: string;
context?: string;
};
/**
* Lets the coordinator create a workspace: a place a group of bots works
* from shared context, with its own computers and models. Creating one is
* a structural change, so it only runs once the user has asked for it or
* agreed to it.
*/
export default function createWorkspaceTool({ user_id, workspaces }: Params): HarnessTool<Args> {
const list = workspaces.map((w) => `${w.id}: ${w.name}`).join("; ");
return {
name: "create_workspace",
description: `Create a workspace: a place where a group of bots works on one thing, sharing a context every bot there gets in its prompt, its computers and its models. Give it a short name, one line on what it's for, and the context (markdown: what the work is, who it's for, what everyone there should know). It starts empty: put bots in it with add_bot_to_workspace, or pass its id to create_bot. Only do this when the user has asked for it or agreed to it.${list ? ` Workspaces that already exist: ${list}.` : ""}`,
schema: z.object({
name: z.string().trim().min(1).max(60),
description: z.string().trim().max(240).optional(),
context: z.string().trim().max(AppData.PromptMaxLength).optional(),
}),
run: async ({ args }) => {
const existing = workspaces.find(
(w) => w.name?.trim().toLowerCase() == args.name.trim().toLowerCase(),
);
if (existing?.id) {
throw new Error(
`There's already a workspace called ${existing.name} (#${existing.id}). Change it with update_workspace instead, or pick another name.`,
);
}
const workspace_id = Number(
await createWorkspace({
user_id,
name: args.name,
description: args.description,
system_prompt: args.context,
}),
);
const created = (await grabUserWorkspaces({ user_id })).find(
(workspace) => workspace.id == workspace_id,
);
/**
* The tool list is built once per reply: adding the row here
* lets create_bot and add_bot_to_workspace use the new id in
* the same turn.
*/
if (created) {
workspaces.push(created);
}
await logEvent({
title: "Workspace created",
category: "system",
user_id: Number(user_id),
plain_text: `Bot created the workspace ${args.name}.`,
metadata: { workspace_id },
});
if (created) {
await pushWorkspaceCreated({ workspace: created });
}
return `Workspace #${workspace_id} "${args.name}" is ready. The user sees it in their rail. Add bots to it with add_bot_to_workspace, or pass ${workspace_id} to create_bot; its context can change later with update_workspace.`;
},
};
}
+10 -6
View File
@@ -7,6 +7,7 @@ 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 createWorkspaceTool from "./create-workspace-tool";
import delegateTaskTool from "./delegate-task-tool";
import grabBotTools from "./grab-bot-tools";
import updateWorkspaceTool from "./update-workspace-tool";
@@ -45,12 +46,13 @@ 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) 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.
* for the coordinator (the super admin's alone) create_workspace and
* update_workspace (a workspace and its shared context), 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 +85,8 @@ export default async function grabBotChatTools({
const workspaces = (await grabUserWorkspaces({ user_id })).filter((w) => !w.archived);
tools.push(createWorkspaceTool({ user_id, workspaces }));
if (workspaces.length) {
tools.push(updateWorkspaceTool({ user_id, workspaces, workspace_id }));
}
@@ -1,7 +1,7 @@
import { useState } from "react";
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
import useServerEvent from "@/src/hooks/use-server-event";
import type { WorkspaceWithActivity } from "@/src/types";
import type { WorkspaceCreatedData, WorkspaceWithActivity } from "@/src/types";
type Params = {
workspaces: WorkspaceWithActivity[];
@@ -12,11 +12,29 @@ type Params = {
};
/**
* The user's workspaces, marked unread as bot messages arrive in them.
* The user's workspaces, marked unread as bot messages arrive in them and
* kept current when one is created (a bot can create one, so it shows
* without a reload).
*/
export default function useLiveWorkspaces({ workspaces, open_workspace_id }: Params) {
const [live, setLive] = useState(workspaces);
useServerEvent({
event: "server:workspace-created",
onData: (data: WorkspaceCreatedData) => {
setLive((prev) => {
if (prev.some((workspace) => workspace.id == data.workspace.id)) return prev;
/** The rail lists them oldest first (`grabUserWorkspaces`). */
const at = prev.findIndex((workspace) => Number(workspace.id) > Number(data.workspace.id));
if (at < 0) return [...prev, data.workspace];
return [...prev.slice(0, at), data.workspace, ...prev.slice(at)];
});
},
});
useServerEvent({
event: "server:bot-message",
onData: (message: BUN_SQLITE_COBBLE_BOT_MESSAGES) => {
+5 -3
View File
@@ -4,15 +4,16 @@ import { logError } from "@/src/functions/backend/logger";
import grabConnectedUserIds from "@/src/websocket/(functions)/grab-connected-user-ids";
import relayChangedBots from "./relay-changed-bots";
import relayCreatedBots from "./relay-created-bots";
import relayCreatedWorkspaces from "./relay-created-workspaces";
import relayNewBotMessages from "./relay-new-bot-messages";
import relayNewJobUpdates from "./relay-new-job-updates";
import type { LiveRelayCursor } from "./types";
/**
* Keeps this process's sockets current with work done anywhere: every
* `AppData.LiveRelayPollMs` it reads new bot messages, new job steps and
* changed bots from the database and pushes them to the sockets here
* of the users they're for. The process that did the work pushes at once
* `AppData.LiveRelayPollMs` it reads new bot messages, new job steps,
* new workspaces and changed bots from the database and pushes them to
* the sockets here of the users they're for. The process that did the work pushes at once
* (`pushBotNotice`); this catches the rest, e.g. a job run by another
* server on the same database. Clients ignore what they already have.
* Runs in every server process, ungated.
@@ -54,6 +55,7 @@ export default async function liveRelay() {
const bots_since = cursor.bots_at - AppData.LiveRelayPollMs;
await relayCreatedBots({ since: bots_since, users });
await relayCreatedWorkspaces({ since: bots_since, users });
await relayChangedBots({ since: bots_since, users });
cursor.bots_at = now;
} catch (error) {
@@ -0,0 +1,35 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { WorkspaceWithActivity } from "@/src/types";
import pushWorkspaceCreated from "@/src/bots-queue/push-workspace-created";
type Params = {
/**
* Unix ms: workspaces created since then are pushed.
*/
since: number;
users: Set<string>;
};
/**
* Pushes `server:workspace-created` for every workspace created since
* `since` to the connected users who see it (`pushWorkspaceCreated`), so a
* workspace another process created still shows in the rail. Pushes are
* idempotent, so overlapping windows only repeat them.
*/
export default async function relayCreatedWorkspaces({ since, users }: Params) {
if (!users.size) return;
const workspaces =
(
await BunSQLite.sql<WorkspaceWithActivity>({
sql: `SELECT w.*, 0 AS unread, NULL AS last_activity_at
FROM workspaces w
WHERE w.created_at > ? AND w.archived = 0`,
values: [since],
})
).payload || [];
for (const workspace of workspaces) {
await pushWorkspaceCreated({ workspace, users });
}
}
+13
View File
@@ -167,6 +167,14 @@ export type BotCreatedData = {
in_own_chat: boolean;
};
/**
* `server:workspace-created` data: a workspace the user can now see, as
* their rail lists it.
*/
export type WorkspaceCreatedData = {
workspace: WorkspaceWithActivity;
};
/**
* `server:bot-removed` data: the bot that was deleted.
*/
@@ -617,6 +625,11 @@ export const WebSocketEvents = [
* A bot's face or status line changed (`data`: `BotUpdatedData`)
*/
"server:bot-updated",
/**
* A workspace the user can see was created (`data`:
* `WorkspaceCreatedData`)
*/
"server:workspace-created",
/**
* A running job took a step (`data`: `BotJobUpdateData`)
*/