Let the coordinator and workspace admins hand over tasks and routines

delegate_task takes a task_id to hand over a task already on a list: it
moves to the worker (a workspace's task stays on its list), goes to
doing, and the job is linked back so its end is noted on the task.
hand_over_schedule moves a bot's own routine to another bot, keeping its
workspace. Users get Hand over in the Tasks UI and both routine lists.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-25 11:23:26 +01:00
co-authored by Claude Opus 5.5
parent b4283b607e
commit 93896646c7
36 changed files with 1334 additions and 94 deletions
@@ -85,4 +85,27 @@ describe("delegateTaskTool", () => {
tool.run({ args: { bot_id: 8, title: "Dig", instructions: "x" }, signal }),
).rejects.toThrow(/No crew member has id 8 in workspace 3. Crew: 5: Researcher/);
});
test("takes the id of a task already on a list in place of a brief", () => {
const tool = coordinatorTool();
expect(tool.schema?.safeParse({ bot_id: 5, task_id: 12 }).success).toBe(true);
expect(tool.schema?.safeParse({ bot_id: 5, task_id: 12, instructions: "Start with the intro." }).success).toBe(
true,
);
expect(tool.description).toContain("task_id");
});
test("without a task_id, still needs a title and instructions", async () => {
const tool = delegateTaskTool({
user_id: 1,
requester_id: 1,
session_id: 10,
requester_name: "Coordinator",
workers: [worker],
grabTargets: async () => [worker],
});
await expect(tool.run({ args: { bot_id: 5 }, signal })).rejects.toThrow(/Give a title and instructions/);
});
});
+78 -63
View File
@@ -1,12 +1,11 @@
import { z } from "zod";
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import pushBotCreated from "@/src/bots-queue/push-bot-created";
import pushBotNotice from "@/src/bots-queue/push-bot-notice";
import enqueueBotJob from "@/src/functions/backend/db/bots-queue/enqueue-bot-job";
import grabOpenBotTask from "@/src/functions/backend/db/bots-queue/grab-open-bot-task";
import addBotToWorkspace from "@/src/functions/backend/db/workspaces/add-bot-to-workspace";
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
import grabWorkspaceBots from "@/src/functions/backend/db/workspaces/grab-workspace-bots";
import ensureBotInWorkspace from "@/src/functions/backend/db/workspaces/ensure-bot-in-workspace";
import grabRunTask from "@/src/functions/backend/db/tasks/grab-run-task";
import handOverTask from "@/src/functions/backend/db/tasks/hand-over-task";
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";
@@ -48,12 +47,24 @@ type Params = {
* nothing to pick: the task runs there.
*/
workspaces?: BUN_SQLITE_COBBLE_WORKSPACES[];
/**
* The requester's reach over the task tracker (`grabBotRunPowers`), so
* `task_id` only hands over a task its run reaches.
*/
is_coordinator?: boolean;
admin_workspace_ids?: number[];
};
type Args = {
bot_id: number;
title: string;
instructions: string;
/**
* A task already on a list (`list_tasks`) to hand over instead of a
* new brief: it moves to the worker (or stays on its workspace's list)
* and the worker works that same task.
*/
task_id?: number;
title?: string;
instructions?: string;
/**
* The workspace the worker runs the task in, from the coordinator's own
* chat. Left out, the worker runs it in its own chat, on its own
@@ -69,7 +80,8 @@ type Args = {
* The worker runs it on the computer it's on there (if it has one) and
* posts the result in that conversation, where a note says who asked; the
* result (or failure) is also reported back in the coordinator's
* conversation.
* conversation. Given a `task_id`, it hands over a task already on a list
* instead (`handOverTask`): the worker works that same task.
*/
export default function delegateTaskTool({
user_id,
@@ -80,6 +92,8 @@ export default function delegateTaskTool({
workers,
workspaces = [],
grabTargets = ({ workspace_id }) => grabCrewWorkers({ workspace_id }),
is_coordinator = false,
admin_workspace_ids = [],
}: Params): HarnessTool<Args> {
const crew = workers
.map((bot) => `${bot.id}: ${bot.name}${bot.role ? ` (${bot.role})` : ""}`)
@@ -90,15 +104,16 @@ export default function delegateTaskTool({
return {
name: "delegate_task",
description: `Hand a task to a crew member. They run it in the background and post the result in their own conversation; it's also reported back here when they finish or fail. Write the instructions as a full brief: what to do, where, and how they'll know it's done. Crew: ${crew}.${
description: `Hand a task to a crew member. They run it in the background and post the result in their own conversation; it's also reported back here when they finish or fail. Write the instructions as a full brief: what to do, where, and how they'll know it's done. To hand over a task already on a list (list_tasks), pass its task_id instead of writing a new one: it moves to the crew member's list (a workspace's task stays on the workspace's list and runs there), goes to doing, and they work that same task; title and instructions are then optional, the task's brief is used and yours is added to it. Crew: ${crew}.${
workspace_id
? ` You are working in a workspace, so the task runs there: in this workspace's context.`
: ` Leave workspace_id out to run it in the crew member's own chat: only its own memory, files and directly assigned computers apply there, and the user finds the result by talking to it on its own. Add workspace_id to run it in one of the user's workspaces instead: the workspace's shared context, the member's instructions and memory there, its files and its computers (the workspace's own and the member's) all apply, and the result lands in its conversation in that workspace, beside the project it belongs to. Pick a workspace when the work belongs to that project; the member is added to it when it isn't in it yet. Workspaces: ${places || "none"}.`
}`,
schema: z.object({
bot_id: z.number().int(),
title: z.string().min(1).max(80),
instructions: z.string().min(1).max(8000),
task_id: z.number().int().optional().describe("An open task (list_tasks) to hand over as it is"),
title: z.string().min(1).max(80).optional(),
instructions: z.string().min(1).max(8000).optional(),
workspace_id: z
.number()
.int()
@@ -120,11 +135,31 @@ export default function delegateTaskTool({
);
}
/**
* A task on a list is read first: one on a workspace's list is
* worked in that workspace, so the crew comes from there.
*/
const task = args.task_id
? await grabRunTask({ task_id: args.task_id, bot_id: requester_id, is_coordinator, admin_workspace_ids })
: null;
const asked_workspace_id = workspace_id || args.workspace_id;
if (task?.workspace_id && asked_workspace_id && Number(task.workspace_id) != Number(asked_workspace_id)) {
throw new Error(
`Task #${task.id} is on workspace ${task.workspace_id}'s list, so it's worked there. Leave workspace_id out${
workspace_id ? ", and hand it over from that workspace or your own chat" : ""
}.`,
);
}
const target_workspace_id = workspace_id
? Number(workspace_id)
: args.workspace_id
? Number(args.workspace_id)
: null;
: task?.workspace_id
? Number(task.workspace_id)
: args.workspace_id
? Number(args.workspace_id)
: null;
const fresh = await grabTargets({ workspace_id: target_workspace_id });
const worker = fresh.find((bot) => bot.id == args.bot_id);
@@ -139,8 +174,32 @@ export default function delegateTaskTool({
);
}
if (task) {
const handed = await handOverTask({
user_id,
task_id: Number(task.id),
to_bot_id: Number(worker.id),
workspace_id: target_workspace_id,
instructions: args.instructions,
handed_by: requester_name,
requested_by_bot_id: requester_id,
requested_by_session_id: session_id,
});
return `Task #${task.id} "${task.title}" is with ${worker.name} now (job #${handed.job_id})${
handed.workspace_id ? ` in workspace ${handed.workspace_id}` : " in their own chat"
}, marked doing.${handed.joined} They work that same task; the result is reported back here.`;
}
if (!args.title?.trim() || !args.instructions?.trim()) {
throw new Error(`Give a title and instructions, or the task_id of a task to hand over.`);
}
const title = args.title;
const instructions = args.instructions;
const joined = target_workspace_id
? await ensureWorkerInWorkspace({ user_id, worker, target_workspace_id })
? await ensureBotInWorkspace({ user_id, bot: worker, workspace_id: target_workspace_id })
: "";
const computers = await grabBotComputers({
@@ -155,12 +214,12 @@ export default function delegateTaskTool({
const open = await grabOpenBotTask({
bot_id: worker.id,
session_id: Number(worker_session.id),
title: args.title,
title,
});
if (open) {
throw new Error(
`${worker.name} already has task #${open.id} "${args.title}" ${open.status == "pending" ? "queued" : "under way"}. Don't hand it over again; it's reported back here when they finish.`,
`${worker.name} already has task #${open.id} "${title}" ${open.status == "pending" ? "queued" : "under way"}. Don't hand it over again; it's reported back here when they finish.`,
);
}
@@ -168,8 +227,8 @@ export default function delegateTaskTool({
user_id,
bot_id: worker.id,
session_id: Number(worker_session.id),
title: args.title,
instructions: args.instructions,
title,
instructions,
computer_id: computers[0]?.id || undefined,
requested_by_bot_id: requester_id,
requested_by_session_id: session_id,
@@ -181,7 +240,7 @@ export default function delegateTaskTool({
bot_id: worker.id,
session_id: Number(worker_session.id),
workspace_id: target_workspace_id,
text: `${requester_name} asked me to: **${args.title}**`,
text: `${requester_name} asked me to: **${title}**`,
}),
});
@@ -191,47 +250,3 @@ export default function delegateTaskTool({
},
};
}
type EnsureParams = {
user_id: number | string;
worker: BUN_SQLITE_COBBLE_BOTS;
target_workspace_id: number;
};
/**
* A worker can only run a task in a workspace it's in, and a workspace's
* members were already listed by `grabCrewWorkers`. Anyone else is added
* first, since delegation is exactly the moment the broker decides who
* does the work where.
*/
async function ensureWorkerInWorkspace({
user_id,
worker,
target_workspace_id,
}: EnsureParams) {
if (!worker.id) return "";
const workspaces = (await grabUserWorkspaces({ user_id })).filter((w) => !w.archived);
const workspace = workspaces.find((w) => w.id == target_workspace_id);
if (!workspace) {
throw new Error(
`No workspace has id ${target_workspace_id}. Workspaces: ${
workspaces.map((w) => `${w.id}: ${w.name}`).join("; ") || "none"
}. Hand the task over without workspace_id to run it in the crew member's own chat.`,
);
}
const members = await grabWorkspaceBots({ user_id, workspace_id: target_workspace_id });
if (members.some((bot) => bot.id == worker.id)) return "";
await addBotToWorkspace({
user_id,
workspace_id: target_workspace_id,
bot_id: Number(worker.id),
});
await pushBotCreated({ bot: worker });
return ` ${worker.name} wasn't in "${workspace.name}", so it was added to it first.`;
}
+19 -1
View File
@@ -10,6 +10,7 @@ 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 handOverScheduleTool from "./hand-over-schedule-tool";
import deleteBotTool from "./delete-bot-tool";
import deleteWorkspaceTool from "./delete-workspace-tool";
import findBotTool from "./find-bot-tool";
@@ -81,7 +82,7 @@ type Params = {
* 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, where the task runs; from its own chat, in the worker's own
* chat or in a workspace it names) plus, in its own chat, find_bot and
* chat or in a workspace it names) and hand_over_schedule beside it, plus, in its own chat, find_bot and
* find_workspace,
* add_bot_to_workspace and remove_bot_from_workspace, assign_computer,
* update_bot, delete_bot and delete_workspace (which workspace, which
@@ -172,6 +173,14 @@ export default async function grabBotChatTools({
workspace_id,
requester_name: bot.name || "The coordinator",
workers,
is_coordinator: true,
}));
tools.push(handOverScheduleTool({
user_id,
bot_id,
workspace_id,
requester_name: bot.name || "The coordinator",
workers,
}));
tools.push(setWorkspaceInstructionsTool({ user_id, workspace_id, workers }));
@@ -203,6 +212,15 @@ export default async function grabBotChatTools({
requester_name: bot.name || "The coordinator",
workers,
workspaces,
is_coordinator: true,
}),
handOverScheduleTool({
user_id,
bot_id,
workspace_id,
requester_name: bot.name || "The coordinator",
workers,
workspaces,
}),
);
@@ -6,6 +6,7 @@ import grabUserWorkspace from "@/src/functions/backend/db/workspaces/grab-user-w
import type { StorageScope } from "@/src/utils/storage-access";
import type { HarnessTool } from "../../types";
import delegateTaskTool from "./delegate-task-tool";
import handOverScheduleTool from "./hand-over-schedule-tool";
import setWorkspaceInstructionsTool from "./set-workspace-instructions-tool";
import updateWorkspaceTool from "./update-workspace-tool";
@@ -33,8 +34,8 @@ export type WorkspaceAdminTools = {
/**
* The tools a workspace admin leads its workspace with, each only while
* its types allow it there (`BotRunPowers.can`): delegate_task and
* set_bot_workspace_instructions for the members it may direct
* its types allow it there (`BotRunPowers.can`): delegate_task (with
* hand_over_schedule beside it) and set_bot_workspace_instructions for the members it may direct
* (`grabDirectableBots`), and update_workspace for this workspace alone,
* only when the user the run works for may change it too (a crew manager).
*/
@@ -67,6 +68,15 @@ export default async function grabWorkspaceAdminTools({
requester_name: bot.name || "The workspace's admin",
workers: members,
grabTargets,
admin_workspace_ids: powers.admin_workspace_ids,
}),
handOverScheduleTool({
user_id,
bot_id,
workspace_id,
requester_name: bot.name || "The workspace's admin",
workers: members,
grabTargets,
}),
);
}
@@ -0,0 +1,41 @@
import { describe, expect, test } from "bun:test";
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import handOverScheduleTool from "./hand-over-schedule-tool";
const worker = { id: 5, name: "Researcher", kind: "worker" } as BUN_SQLITE_COBBLE_BOTS;
const workspace = { id: 3, name: "Launch", archived: 0 } as BUN_SQLITE_COBBLE_WORKSPACES;
/**
* What needs no database: the arguments it takes and what it tells the
* bot about where a handed-over schedule runs.
*/
describe("handOverScheduleTool", () => {
test("takes a schedule, a crew member and, from its own chat, a workspace", () => {
const tool = handOverScheduleTool({
user_id: 1,
bot_id: 1,
requester_name: "Coordinator",
workers: [worker],
workspaces: [workspace],
});
expect(tool.name).toBe("hand_over_schedule");
expect(tool.schema?.safeParse({ schedule_id: 4, bot_id: 5 }).success).toBe(true);
expect(tool.schema?.safeParse({ schedule_id: 4, bot_id: 5, workspace_id: 3 }).success).toBe(true);
expect(tool.description).toContain("5: Researcher");
expect(tool.description).toContain("3: Launch");
});
test("in a workspace, says only the schedules that run there go", () => {
const tool = handOverScheduleTool({
user_id: 1,
bot_id: 7,
workspace_id: 3,
requester_name: "Lead",
workers: [worker],
});
expect(tool.description).toContain("only the schedules that run here");
expect(tool.description).not.toContain("Workspaces:");
});
});
@@ -0,0 +1,113 @@
import { z } from "zod";
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import handOverCronJob from "@/src/functions/backend/db/cron-jobs/hand-over-cron-job";
import grabUserCronJob from "@/src/functions/backend/db/cron-jobs/grab-user-cron-job";
import grabCrewWorkers from "@/src/functions/backend/db/bots/grab-crew-workers";
import describeCronJob from "@/src/utils/describe-cron-job";
import handOverPlace from "@/src/utils/hand-over-place";
import type { HarnessTool } from "../../types";
type Params = {
user_id: number | string;
/**
* The bot handing its routine over (the coordinator, or a workspace's
* admin).
*/
bot_id: number | string;
/**
* The workspace the run is in: only routines that run there can be
* handed over, and they stay there.
*/
workspace_id?: number | string | null;
requester_name: string;
/**
* The bots it can hand routines to (in a workspace, its members).
*/
workers: BUN_SQLITE_COBBLE_BOTS[];
/**
* Reads who it may hand to when it does, as `delegate_task` does.
*/
grabTargets?: (params: { workspace_id: number | null }) => Promise<BUN_SQLITE_COBBLE_BOTS[]>;
/**
* The user's live workspaces, for a routine of the bot's own chat.
*/
workspaces?: BUN_SQLITE_COBBLE_WORKSPACES[];
};
type Args = {
schedule_id: number;
bot_id: number;
workspace_id?: number;
};
/**
* Lets the coordinator or a workspace admin give one of its own routines
* to a crew member (`handOverCronJob`): a routine in a workspace keeps
* running there, one in its own chat moves to the member's own chat or a
* workspace it names.
*/
export default function handOverScheduleTool({
user_id,
bot_id,
workspace_id,
requester_name,
workers,
workspaces = [],
grabTargets = ({ workspace_id }) => grabCrewWorkers({ workspace_id }),
}: Params): HarnessTool<Args> {
const crew = workers.map((bot) => `${bot.id}: ${bot.name}`).join("; ");
const places = workspaces.map((w) => `${w.id}: ${w.name}`).join("; ");
return {
name: "hand_over_schedule",
description: `Give one of your own schedules (routines, list_schedules) to a crew member: from then on it runs as theirs, on the same schedule, and its results land in their conversation. Use it instead of deleting a schedule and asking them to make a new one. A schedule that runs in a workspace keeps running there.${
workspace_id
? ` You are in a workspace, so only the schedules that run here can be handed over.`
: ` For one that runs in your own chat, leave workspace_id out to move it to the crew member's own chat, or add it to run it in one of the user's workspaces (the member is added to it when it isn't in it yet). Workspaces: ${places || "none"}.`
} Crew: ${crew}.`,
schema: z.object({
schedule_id: z.number().int(),
bot_id: z.number().int(),
workspace_id: z
.number()
.int()
.optional()
.describe("For a schedule of your own chat: the workspace it runs in from now on"),
}),
run: async ({ args }) => {
const cron_job = await grabUserCronJob({ user_id, cron_job_id: args.schedule_id, bot_id });
if (workspace_id && Number(cron_job.workspace_id || 0) != Number(workspace_id)) {
throw new Error(`Schedule #${cron_job.id} doesn't run in this workspace. Only the ones that run here can be handed over from here.`);
}
const target_workspace_id = handOverPlace({
workspace_id: cron_job.workspace_id,
asked_workspace_id: workspace_id || args.workspace_id,
label: `Schedule #${cron_job.id}`,
});
const fresh = await grabTargets({ workspace_id: workspace_id ? Number(workspace_id) : target_workspace_id });
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 handed = await handOverCronJob({
user_id,
cron_job_id: Number(cron_job.id),
from_bot_id: bot_id,
to_bot_id: Number(worker.id),
workspace_id: args.workspace_id,
handed_by: requester_name,
});
return `Handed over to ${worker.name}: ${describeCronJob({ cron_job: handed.cron_job })}.${handed.joined}${
handed.computer_dropped ? ` Its computer isn't one ${worker.name} reaches there, so it runs on their default one.` : ""
}`;
},
};
}