Let workers ask each other through the coordinator in chatrooms

A worker asks for another bot's help with ask_crew. The coordinator
reads the request, asks the right bot (in a workspace, only its
members), and sends the answer back into the asking bot's
conversation, waking it. Each exchange is kept as a read-only
chatroom: listed in the main rail after the workspaces, or in the
workspace's rail for requests made there, with live status rows for
each bot while it's open and a Stop button.

Room sessions are hidden from every conversation list, other bots'
words reach the model fenced as data, and turns, nesting and open
rooms are capped. Closed rooms are pruned after a retention period
and past a cap per place (Settings > General).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-27 18:57:34 +01:00
co-authored by Claude Opus 5.5
parent 2d3c15e48b
commit 7b762fd384
139 changed files with 4246 additions and 64 deletions
+101
View File
@@ -0,0 +1,101 @@
import { z } from "zod";
import grabBotById from "@/src/functions/backend/db/bots/grab-bot-by-id";
import chatroomAskableBots from "@/src/functions/backend/db/chatrooms/chatroom-askable-bots";
import createChatroom from "@/src/functions/backend/db/chatrooms/create-chatroom";
import chatroomRef from "@/src/utils/chatroom-ref";
import type { HarnessTool } from "../../types";
type Params = {
user_id: number | string;
/**
* The worker asking.
*/
bot_id: number | string;
/**
* The session it asks from: the answer comes back there.
*/
session_id: number | string;
/**
* Where it works: in a workspace, only its members can be asked.
*/
workspace_id?: number | string | null;
/**
* Called with the room's ref, so the reply always carries its chip.
*/
onChatroom?: (params: { ref: string }) => void;
};
type Args = {
title: string;
request: string;
bot_id?: number;
};
/**
* Lets a worker ask another bot for something, only ever through the
* coordinator: it opens a chatroom (`createChatroom`) where the
* coordinator works out who should answer, asks them, and sends the answer
* back to this conversation, which wakes the bot to act on it. Who can be
* asked is decided by the app, never the model: in a workspace its
* members, otherwise every worker the user reaches.
*/
export default function askCrewTool({ user_id, bot_id, session_id, workspace_id, onChatroom }: Params): HarnessTool<Args> {
return {
name: "ask_crew",
description: `Ask another bot for something you need (what it knows, a file it has, work in its line) through the coordinator. The coordinator reads your request, asks the bot best placed to answer and sends the answer back here as a message, which wakes you to act on it: don't wait or check on it. Write the request as a full brief: what you need, why, and what a good answer looks like. Name a bot with bot_id if you know who can help; the coordinator decides. ${
workspace_id ? "You're in a workspace, so only its members can be asked." : "Any bot the user works with can be asked."
} The request and everything said about it is kept in a chatroom the user can read.`,
schema: z.object({
title: z.string().trim().min(1).max(80).describe("A short label for the request"),
request: z.string().trim().min(1).max(8000).describe("The brief, in markdown"),
bot_id: z.number().int().optional().describe("The bot you think can help, if you know"),
}),
run: async ({ args }) => {
const bot = await grabBotById({ bot_id });
if (!bot?.id) {
throw new Error(`This bot no longer exists.`);
}
const askable = await chatroomAskableBots({
user_id,
requester_bot_id: bot.id,
workspace_id,
});
if (!askable.length) {
throw new Error(
workspace_id
? `There are no other bots in this workspace to ask.`
: `There are no other bots the user works with to ask.`,
);
}
const named = args.bot_id ? askable.find((other) => other.id == args.bot_id) : null;
if (args.bot_id && !named) {
throw new Error(
`Bot ${args.bot_id} can't be asked from here. You can ask: ${askable
.map((other) => `${other.id}: ${other.name}`)
.join("; ")}.`,
);
}
const request = named ? `${args.request}\n\n(I think ${named.name} [bot:${named.id}] can help.)` : args.request;
const room = await createChatroom({
user_id,
requester: bot,
origin_session_id: session_id,
title: args.title,
request,
});
const ref = chatroomRef({ id: room.id! });
onChatroom?.({ ref });
return `Asked the coordinator in ${ref}. The answer comes back here as a message and you'll be woken for it. Tell the user you asked; don't ask again while it's open.`;
},
};
}
@@ -71,6 +71,10 @@ type Params = {
* the reply can say so in its prompt.
*/
onWorkspaceAdmin?: (params: WorkspaceAdminTools) => void;
/**
* Called with the ref of each chatroom the bot opened (`ask_crew`).
*/
onChatroom?: (params: { ref: string }) => void;
};
/**
@@ -108,6 +112,7 @@ export default async function grabBotChatTools({
onTakeOver,
onPropose,
onWorkspaceAdmin,
onChatroom,
}: Params): Promise<HarnessTool[]> {
const bot_id = bot.id || 0;
const tools = [
@@ -121,6 +126,7 @@ export default async function grabBotChatTools({
admin_workspace_ids: powers.admin_workspace_ids,
scopes,
onPropose,
onChatroom,
})),
startTaskTool({
user_id,
+16 -1
View File
@@ -11,6 +11,7 @@ import updateInstructionsTool from "./update-instructions-tool";
import updateMemoryTool from "./update-memory-tool";
import reactToMessageTool from "./react-to-message-tool";
import setPictureTool from "./set-picture-tool";
import askCrewTool from "./ask-crew-tool";
import type { StorageScope } from "@/src/utils/storage-access";
type Params = {
@@ -52,12 +53,18 @@ type Params = {
* carries its card. Without it the bot can't propose hooks.
*/
onPropose?: (params: { ref: string }) => void;
/**
* In a chat reply: called with the ref of each chatroom the bot opened
* (`ask_crew`), so the reply carries its chip.
*/
onChatroom?: (params: { ref: string }) => void;
};
/**
* The tools a bot works with on every run, bound to that bot and where it
* works: its memory and instructions, its schedules, its hooks
* (`grabBotHookTools`), and in a workspace with several computers
* (`grabBotHookTools`), for a worker ask_crew (another bot, through the
* coordinator), and in a workspace with several computers
* switching the default one.
*/
export default async function grabBotTools({
@@ -71,6 +78,7 @@ export default async function grabBotTools({
scopes = [],
hooked,
onPropose,
onChatroom,
}: Params): Promise<HarnessTool[]> {
return [
setPictureTool({ user_id, bot_id, scopes, is_coordinator }),
@@ -82,6 +90,13 @@ export default async function grabBotTools({
deleteScheduleTool({ user_id, bot_id }),
...grabBotHookTools({ user_id, bot_id, session_id, workspace_id, computers, hooked, onPropose }),
...(session_id ? [reactToMessageTool({ bot_id, user_id, session_id })] : []),
/**
* A worker asks the crew only through the coordinator, never from
* a hook call's run, whose payload is someone else's words.
*/
...(session_id && !is_coordinator && !hooked
? [askCrewTool({ user_id, bot_id, session_id, workspace_id, onChatroom })]
: []),
...(workspace_id && computers.length > 1
? [setWorkspaceComputerTool({ user_id, workspace_id, computers })]
: []),
@@ -0,0 +1,45 @@
import { z } from "zod";
import type { BUN_SQLITE_COBBLE_CHATROOMS } from "@/db/types/db";
import closeChatroom from "@/src/functions/backend/db/chatrooms/close-chatroom";
import type { HarnessTool } from "../../types";
type Params = {
chatroom: BUN_SQLITE_COBBLE_CHATROOMS;
/**
* The turn's own job, left to finish when the room closes.
*/
job_id?: number | null;
/**
* Called once the room is closed: the turn is done.
*/
onClosed?: () => void;
};
type Args = {
answer: string;
};
/**
* The coordinator sends the answer back to the bot that asked, closing the
* room as answered (`closeChatroom`).
*/
export default function answerRequesterTool({ chatroom, job_id, onClosed }: Params): HarnessTool<Args> {
return {
name: "answer_requester",
description: `Send the answer back to the bot that asked, and close the room. Write the answer itself, complete and in markdown.`,
schema: z.object({
answer: z.string().trim().min(1).max(12000),
}),
run: async ({ args }) => {
const closed = await closeChatroom({ chatroom_id: chatroom.id!, status: "answered", outcome: args.answer, job_id });
if (!closed) {
throw new Error(`This room is already closed.`);
}
onClosed?.();
return `Sent. The room is closed.`;
},
};
}
@@ -0,0 +1,129 @@
import { z } from "zod";
import type { BUN_SQLITE_COBBLE_CHATROOMS } from "@/db/types/db";
import BunSQLite from "@moduletrace/bun-sqlite";
import pushBotNotice from "@/src/bots-queue/push-bot-notice";
import enqueueBotJob from "@/src/functions/backend/db/bots-queue/enqueue-bot-job";
import ensureBotSession from "@/src/functions/backend/db/bot-sessions/ensure-bot-session";
import insertBotMessage from "@/src/functions/backend/db/bots/insert-bot-message";
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
import chatroomAncestry from "@/src/functions/backend/db/chatrooms/chatroom-ancestry";
import chatroomAskableBots from "@/src/functions/backend/db/chatrooms/chatroom-askable-bots";
import ensureChatroomSession from "@/src/functions/backend/db/chatrooms/ensure-chatroom-session";
import pushChatroomUpdated from "@/src/functions/backend/db/chatrooms/push-chatroom-updated";
import chatroomRef from "@/src/utils/chatroom-ref";
import type { HarnessTool } from "../../types";
type Params = {
chatroom: BUN_SQLITE_COBBLE_CHATROOMS;
/**
* Called once a bot is at work on it: the turn is done.
*/
onAsked?: () => void;
};
type Args = {
bot_id: number;
title: string;
instructions: string;
};
/**
* The coordinator's way, in a chatroom, to pass the request to a bot: a
* task for that bot in its part of the room, on its computer there, for
* the room's user. Only bots the asking bot may reach (`chatroomAskableBots`,
* read fresh) and never one that is waiting on this request further up
* the chain, so two bots can't ask each other in a loop. The asked bot's
* own conversation gets a note pointing to the room.
*/
export default function askMemberTool({ chatroom, onAsked }: Params): HarnessTool<Args> {
return {
name: "ask_member",
description: `Pass the request, or a follow-up on it, to a bot. It works on it as a task and what it reports comes back to you in this room. Write the instructions as a full brief: what is needed, why, and what a good answer looks like.`,
schema: z.object({
bot_id: z.number().int(),
title: z.string().trim().min(1).max(80),
instructions: z.string().trim().min(1).max(8000),
}),
run: async ({ args }) => {
const user_id = Number(chatroom.user_id);
const workspace_id = chatroom.workspace_id ? Number(chatroom.workspace_id) : null;
const askable = await chatroomAskableBots({
user_id,
requester_bot_id: chatroom.requester_bot_id || 0,
workspace_id,
});
const bot = askable.find((other) => other.id == args.bot_id);
if (!bot?.id) {
throw new Error(
`Bot ${args.bot_id} can't be asked here. You can ask: ${
askable.map((other) => `${other.id}: ${other.name}`).join("; ") || "nobody"
}.`,
);
}
/**
* A bot waiting on this request higher up would end up asking
* itself, round and round.
*/
const chain = await chatroomAncestry({ chatroom_id: chatroom.parent_chatroom_id });
if (chain.some((room) => room.requester_bot_id == bot.id)) {
throw new Error(`${bot.name} is waiting on this request itself, so it can't be asked to answer it.`);
}
const session = await ensureChatroomSession({ chatroom, bot_id: bot.id });
const busy = (
await BunSQLite.sql({
sql: `SELECT id FROM bots_queue WHERE session_id = ? AND status IN ('pending', 'processing') LIMIT 1`,
values: [session.id!],
})
).payload?.[0];
if (busy) {
throw new Error(`${bot.name} is already working on something in this room. Wait for it.`);
}
const computers = await grabBotComputers({ bot_id: bot.id, workspace_id });
const job_id = await enqueueBotJob({
user_id,
bot_id: bot.id,
session_id: Number(session.id),
title: args.title,
instructions: `${args.instructions}\n\n(The coordinator is asking on behalf of another bot, for the user. Answer with what was asked for: your report goes back to the coordinator.)`,
computer_id: computers[0]?.id || undefined,
params: { chatroom_id: chatroom.id },
});
await BunSQLite.sql({
sql: `UPDATE chatrooms SET target_bot_id = ?, updated_at = ? WHERE id = ?`,
values: [bot.id, Date.now(), chatroom.id!],
});
await pushChatroomUpdated({ chatroom_id: chatroom.id! }).catch(() => {});
/**
* The bot's own conversation says where it's working, so the
* user finds the room from there too.
*/
const own = await ensureBotSession({ user_id, bot_id: bot.id, workspace_id });
await pushBotNotice({
bot_id: bot.id,
message: await insertBotMessage({
bot_id: bot.id,
session_id: Number(own.id),
workspace_id,
text: `The coordinator asked me to: **${args.title}** (${chatroomRef({ id: chatroom.id! })})`,
}),
});
onAsked?.();
return `${bot.name} is on it (job #${job_id}). What they report comes back to you here; end this turn now.`;
},
};
}
@@ -0,0 +1,45 @@
import { z } from "zod";
import type { BUN_SQLITE_COBBLE_CHATROOMS } from "@/db/types/db";
import closeChatroom from "@/src/functions/backend/db/chatrooms/close-chatroom";
import type { HarnessTool } from "../../types";
type Params = {
chatroom: BUN_SQLITE_COBBLE_CHATROOMS;
/**
* The turn's own job, left to finish when the room closes.
*/
job_id?: number | null;
/**
* Called once the room is closed: the turn is done.
*/
onClosed?: () => void;
};
type Args = {
reason: string;
};
/**
* The coordinator turns the request down, telling the bot that asked why,
* and closes the room as refused (`closeChatroom`).
*/
export default function refuseRequestTool({ chatroom, job_id, onClosed }: Params): HarnessTool<Args> {
return {
name: "refuse_request",
description: `Turn the request down and close the room, when no one here can answer it or it shouldn't be done. Say why, so the bot that asked can go on without it.`,
schema: z.object({
reason: z.string().trim().min(1).max(4000),
}),
run: async ({ args }) => {
const closed = await closeChatroom({ chatroom_id: chatroom.id!, status: "refused", outcome: args.reason, job_id });
if (!closed) {
throw new Error(`This room is already closed.`);
}
onClosed?.();
return `Turned down. The room is closed.`;
},
};
}