Tell every bot its own id, in every run

The bot's id never reached its prompt: `composeBotPrompt` only took its
instructions, the appendix, its memory and its workspace, and
`grabBotPrompt` never passed `bot.id` or `bot.name` on. A bot could only
learn its id by accident, from a tool description that named it, so
"which bot am I" was unanswerable in most contexts.

- Open the prompt with who the bot is (`composeIdentity`): its name,
  whether it's the coordinator or a worker, its role, and its id, said
  to be how tools and refs point back at it. `grabBotPrompt` now passes
  the bot, which covers chat replies, tasks, routines and delegated
  tasks, since both runners build their prompt there.
- Turn the notes' example refs into functions bound to the running bot:
  `chatReplyNote`, `botFilesNote` and `coordinatorReplyNote` take its id,
  so the folder-ref example no longer points at a hardcoded `owner_id: 5`
  (another bot's folder) nor `[bot:3]`. ComputerFilesNote keeps the
  computer it's given.
- Stop `create_task` from hiding the worker's own id in its own chat.

Tests: four new `composeBotPrompt` cases pin the identity block (worker,
coordinator, unnamed, no bot at all), and a new `harness-prompts.test.ts`
checks each note's own-folder and self refs point at exactly the running
bot, for three ids.
This commit is contained in:
2026-10-23 17:40:28 +01:00
parent 0166f31cfc
commit 4261535741
8 changed files with 187 additions and 23 deletions
+6 -6
View File
@@ -2,10 +2,10 @@ import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
import {
ActionCheckNote,
BotFilesNote,
ChatReplyNote,
botFilesNote,
chatReplyNote,
ComputerFilesNote,
CoordinatorReplyNote,
coordinatorReplyNote,
CoordinatorSoloNote,
TasksNote,
TaskReminderNote,
@@ -138,7 +138,7 @@ export default async function runBotReply({
bot.kind != "coordinator"
? ""
: tools.some((tool) => tool.name == "delegate_task")
? CoordinatorReplyNote
? coordinatorReplyNote({ bot_id: bot.id })
: CoordinatorSoloNote;
try {
@@ -146,8 +146,8 @@ export default async function runBotReply({
system: [
prompt,
carry_over,
ChatReplyNote,
BotFilesNote,
chatReplyNote({ bot_id: bot.id }),
botFilesNote({ bot_id: bot.id }),
ComputerFilesNote({ computer_id: computers[0]?.id || 0, can_import: false }),
TasksNote,
TaskReminderNote,
+2 -2
View File
@@ -1,7 +1,7 @@
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import {
ActionCheckNote,
BotFilesNote,
botFilesNote,
ComputerFilesNote,
ScheduledRunNote,
TasksNote,
@@ -191,7 +191,7 @@ export default async function runBotTask({
carry_over,
TaskRunNote,
scheduled ? ScheduledRunNote : "",
BotFilesNote,
botFilesNote({ bot_id: bot.id }),
ComputerFilesNote({ computer_id: job.computer_id || 0 }),
TasksNote,
ActionCheckNote,
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, test } from "bun:test";
import {
botFilesNote,
chatReplyNote,
ComputerFilesNote,
coordinatorReplyNote,
TasksNote,
} from "./harness-prompts";
/**
* Every note a bot reads names it by its own id, so a bot copying an
* example never points at another bot's folder, conversation or task.
*/
const bot_ids = [1, 5, 42] as const;
/**
* The bot ids a note's own-folder examples point at, in order.
*/
function folderOwners(note: string) {
return [...note.matchAll(/\[folder:bot:(\d+):/g)].map((match) => match[1]);
}
describe("harness prompts", () => {
for (const bot_id of bot_ids) {
test(`chat reply note names bot ${bot_id}'s own folder`, () => {
const owners = folderOwners(chatReplyNote({ bot_id }));
expect(owners).toEqual([String(bot_id)]);
});
test(`files note names bot ${bot_id}'s own folder`, () => {
const owners = folderOwners(botFilesNote({ bot_id }));
expect(owners).toEqual([String(bot_id)]);
});
test(`coordinator note names itself as bot ${bot_id}`, () => {
const note = coordinatorReplyNote({ bot_id });
expect(note).toContain(`[bot:${bot_id}]`);
expect(note).not.toContain("[bot:3]");
});
}
test("a note that already names the running bot is unchanged by its id", () => {
expect(botFilesNote({ bot_id: 5 })).toContain("[folder:bot:5:reports]");
});
test("the computer files note keeps naming the computer it was given", () => {
expect(ComputerFilesNote({ computer_id: 8 })).toContain("[folder:computer:8:seo/coderank/raw]");
});
test("notes with nothing bot-specific carry no ref example", () => {
expect(TasksNote).not.toContain("[folder:bot:");
expect(TasksNote).not.toContain("[bot:");
});
});
+20 -7
View File
@@ -19,9 +19,19 @@ export const ReadImagePrompt = `Describe this image for someone who can't see it
export const MediaReadNote = `Some attachments in this conversation were read for you by another model; you see its reading in [brackets] instead of the file.`;
/**
* Added to a bot's prompt when it answers the user in chat.
* The folder-ref example a note shows, always over the running bot's own
* folder: a literal owner id in the prompt teaches the bot to name another
* bot's folder as if it were its own.
*/
export const ChatReplyNote = `# Replying in chat
function ownFolderRef({ bot_id }: { bot_id: number | string }) {
return folderRef({ volume: "bot", owner_id: bot_id, path: "reports" });
}
/**
* Added to a bot's prompt when it answers the user in chat. `bot_id` is the
* bot itself: the examples it reads are its own refs.
*/
export const chatReplyNote = ({ bot_id }: { bot_id: number | string }) => `# Replying in chat
You are chatting with the user. Answer their latest messages directly and briefly, in markdown.
@@ -33,7 +43,7 @@ You are chatting with the user. Answer their latest messages directly and briefl
- To add a worker to the crew, use create_bot once the user has asked for it or agreed, and add_bot_to_workspace to place it. A bot exists only when create_bot made it: never describe one as if it did, and never write it into a workspace's context.
- Never claim you did something you didn't do.
- The user can mention a bot as [bot:ID] and a workspace as [workspace:ID]; a [Mentions: ...] line after their message says who and what those are. Write the same refs when you name them back.
- Documents, images and reports you make are files: save them with write_file, make_pdf or generate_image, and send one to the user with it ("attach"). When you name a file, write its [file:ID] ref, e.g. [file:12], so the user can open it. When you name a folder, write a folder ref instead, e.g. ${folderRef({ volume: "bot", owner_id: 5, path: "reports" })}: that opens the folder in Files and downloads nothing.
- Documents, images and reports you make are files: save them with write_file, make_pdf or generate_image, and send one to the user with it ("attach"). When you name a file, write its [file:ID] ref, e.g. [file:12], so the user can open it. When you name a folder, write a folder ref instead, e.g. ${ownFolderRef({ bot_id })}: that opens the folder in Files and downloads nothing.
## Keep the work moving
@@ -41,9 +51,10 @@ Be proactive. Follow where the conversation is heading and, once you've answered
/**
* Added to every run, chat replies and tasks alike: where its files live,
* that they persist, and how to search them.
* that they persist, and how to search them. `bot_id` is the bot itself,
* so the folder-ref example it reads is its own folder.
*/
export const BotFilesNote = `# Your files
export const botFilesNote = ({ bot_id }: { bot_id: number | string }) => `# Your files
You have a folder of your own, kept with the app (not on a computer), and it stays between runs and conversations.
@@ -53,7 +64,7 @@ You have a folder of your own, kept with the app (not on a computer), and it sta
- A picture can be set from a file: set_picture makes a file your own avatar, and update_workspace sets a workspace's. Both take the [file:ID] ref, so a logo the user sent you is usually ready to go: list_files for it, then set it. An SVG or another kind of file is turned into a PNG first. Its picture is free to change and never removes your file.
- A file and a folder are two different refs, and you never guess which one from the name:
- A file you saved is a [file:ID] ref (from write_file, list_files, search_files or import_file). The user sees a chip that opens the file.
- A folder is a folder ref, e.g. ${folderRef({ volume: "bot", owner_id: 5, path: "reports" })}: the user sees a chip that opens that folder in Files, and nothing is downloaded. Use the volume of the folder you mean ("bot" for your own, "workspace" for a workspace's shared one).
- A folder is a folder ref, e.g. ${ownFolderRef({ bot_id })}: the user sees a chip that opens that folder in Files, and nothing is downloaded. Use the volume of the folder you mean ("bot" for your own, "workspace" for a workspace's shared one).
- Never write a path as plain text and expect the user to find it; never write a [file:ID] ref for something that is a folder.
- A file you only described is not saved. Save it first, then say it's saved.
- Save what the user will want before the long tail of a run. A step limit ends a run where it stands: write the report first with write_file (or copy it off the computer with import_file), then carry on verifying.
@@ -114,8 +125,10 @@ Every action counts here: saving memory or instructions, creating or changing a
/**
* Added to the coordinator's chat prompt: it hands work to the crew.
* `bot_id` is the coordinator itself, so the ref example it copies is its
* own rather than a worker's.
*/
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 and where: a task with no workspace_id runs in that bot's own chat, on its own memory, files and directly assigned computers; one with workspace_id runs in the workspace, on the workspace's context, files and computers as well as the bot's, and lands in that project's conversation. Never swap one for the other without saying so. 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. When you name a crew member, write its ref instead of its name, e.g. ${botRef({ id: 3 })}: the user sees it as the bot's name, linked to its conversation.`;
export const coordinatorReplyNote = ({ bot_id }: { bot_id: number | string }) => `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 and where: a task with no workspace_id runs in that bot's own chat, on its own memory, files and directly assigned computers; one with workspace_id runs in the workspace, on the workspace's context, files and computers as well as the bot's, and lands in that project's conversation. Never swap one for the other without saying so. 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. When you name a crew member, write its ref instead of its name, e.g. ${botRef({ id: bot_id })} for yourself: the user sees it as the bot's name, linked to its conversation.`;
/**
* Added to the coordinator's chat prompt instead while it has no workers,
@@ -19,10 +19,10 @@ type Params = {
};
/**
* The full prompt for one of the user's bots (`composeBotPrompt`): its
* core instructions, the install-wide appendix, its memory, what it
* remembers about this user, and in a workspace that workspace's context
* and what the bot has for it.
* The full prompt for one of the user's bots (`composeBotPrompt`): who it
* is (its name and id), its core instructions, the install-wide appendix,
* its memory, what it remembers about this user, and in a workspace that
* workspace's context and what the bot has for it.
*/
export default async function grabBotPrompt({ bot_id, user_id, workspace_id }: Params) {
const bot = await grabUserBot({ bot_id, user_id });
@@ -40,6 +40,7 @@ export default async function grabBotPrompt({ bot_id, user_id, workspace_id }: P
}
return composeBotPrompt({
bot: { id: bot.id, name: bot.name, kind: bot.kind, role: bot.role },
system_prompt: bot.system_prompt,
appendix: await grabSystemPromptAppend(),
memory: bot.memory,
@@ -101,7 +101,7 @@ export default function createTaskTool({
name: "create_task",
description: is_coordinator
? `Add a task to a list: a worker's own (${crew || "no workers yet"}), a workspace's shared one (${places || "no workspaces yet"}), or your own (leave both ids out). Write the description as a brief: what to do, where, and how you'll know it's done. A workspace's tasks are visible to every bot working there.`
: `Add a task to a list: ${workspace_id ? "this workspace's shared one (leave the ids out), or your own (bot_id: " + bot_id + ")" : "your own (leave the ids out)"}. Write the description as a brief: what to do, where, and how you'll know it's done.`,
: `Add a task to a list: ${workspace_id ? `this workspace's shared one (leave the ids out), or your own (bot_id: ${bot_id})` : `your own, bot_id ${bot_id} (or leave the id out, since it defaults to yours)`}. Write the description as a brief: what to do, where, and how you'll know it's done.`,
schema: z.object({
title: z.string().trim().min(1).max(AppData.TaskTitleMaxLength).describe(`A short label`),
description: z
@@ -111,7 +111,7 @@ export default function createTaskTool({
bot_id: z.number().int().optional().describe(
is_coordinator
? `Whose own list, when it isn't a workspace's. Leave out for your own.`
: `Your own id (${bot_id}), to keep it in your list rather than the workspace's.`,
: `Your own id (${bot_id}), to keep it in your list rather than the workspace's; leaving it out has the same effect here.`,
),
workspace_id: z
.number()
+45
View File
@@ -2,9 +2,47 @@ import { describe, expect, test } from "bun:test";
import composeBotPrompt from "./compose-bot-prompt";
describe("composeBotPrompt", () => {
test("opens with who the bot is, its id included", () => {
expect(composeBotPrompt({ bot: { id: 7, name: "Scout", kind: "worker", role: "Finds things" } })).toBe(
[
"# You are Scout",
"You are a worker bot on this crew. Your role: Finds things.",
"Your bot id is 7. That id is you: when a tool takes a bot id for the bot it is about (your own tasks, your own picture), this is it, and when you name yourself in a message write the ref `[bot:7]`, never another bot's.",
].join("\n\n"),
);
});
test("knows the coordinator by its kind and keeps an unknown name", () => {
expect(composeBotPrompt({ bot: { id: "1", kind: "coordinator" } })).toBe(
[
"# You are A bot",
"You are the coordinator on this crew.",
"Your bot id is 1. That id is you: when a tool takes a bot id for the bot it is about (your own tasks, your own picture), this is it, and when you name yourself in a message write the ref `[bot:1]`, never another bot's.",
].join("\n\n"),
);
});
test("says nothing about identity when the caller gives no bot", () => {
expect(composeBotPrompt({ bot: { id: null, name: "Scout" }, system_prompt: "Core." })).toBe(
"Core.",
);
});
test("puts the identity above the core instructions", () => {
expect(composeBotPrompt({ bot: { id: 4, name: "Scout" }, system_prompt: "Core." })).toBe(
[
"# You are Scout",
"You are a worker bot on this crew.",
"Your bot id is 4. That id is you: when a tool takes a bot id for the bot it is about (your own tasks, your own picture), this is it, and when you name yourself in a message write the ref `[bot:4]`, never another bot's.",
"Core.",
].join("\n\n"),
);
});
test("orders core instructions, the workspace, then memory", () => {
expect(
composeBotPrompt({
bot: { id: 7, name: "Scout" },
system_prompt: "# You are Scout\n\nFind things.",
memory: "- Cite sources.",
user_memory: "- The user prefers short replies.",
@@ -20,6 +58,9 @@ describe("composeBotPrompt", () => {
}),
).toBe(
[
"# You are Scout",
"You are a worker bot on this crew.",
"Your bot id is 7. That id is you: when a tool takes a bot id for the bot it is about (your own tasks, your own picture), this is it, and when you name yourself in a message write the ref `[bot:7]`, never another bot's.",
"# You are Scout\n\nFind things.",
`# Workspace: Launch\n\nYou're working in the workspace "Launch". Follow its context and your instructions for it below, over your general habits where they differ.\n\nThe workspace's context, which the user sets:\n\nThe spring launch.\n\nShip by Friday.`,
"# Your instructions in Launch\n\nOwn the changelog.",
@@ -33,6 +74,7 @@ describe("composeBotPrompt", () => {
test("puts the install-wide appendix right after the core instructions", () => {
expect(
composeBotPrompt({
bot: { id: 4, name: "Scout" },
system_prompt: "Core.",
appendix: "Never invent sources.",
workspace: { name: "Launch", description: "The spring launch." },
@@ -40,6 +82,9 @@ describe("composeBotPrompt", () => {
}),
).toBe(
[
"# You are Scout",
"You are a worker bot on this crew.",
"Your bot id is 4. That id is you: when a tool takes a bot id for the bot it is about (your own tasks, your own picture), this is it, and when you name yourself in a message write the ref `[bot:4]`, never another bot's.",
"Core.",
"# Install-wide instructions\n\nNever invent sources.",
`# Workspace: Launch\n\nYou're working in the workspace "Launch". Follow its context and your instructions for it below, over your general habits where they differ.\n\nThe workspace's context, which the user sets:\n\nThe spring launch.`,
+50 -2
View File
@@ -9,7 +9,27 @@ type WorkspaceBotPrompt = {
memory?: string | null;
};
type BotPrompt = {
/**
* The bot's id: how it names itself, and how the user, the crew tools
* and every ref in a message point at it.
*/
id?: number | string | null;
name?: string | null;
kind?: string | null;
/**
* The one-line role it does that job as, when it has one.
*/
role?: string | null;
};
type Params = {
/**
* The bot itself. Its id is always in the prompt: it is how the bot
* names itself and how everything else (crew tools, refs, tasks,
* files) points back at it, in chat replies, tasks and routines alike.
*/
bot?: BotPrompt | null;
system_prompt?: string | null;
/**
* The install-wide text every bot gets, right after its own core
@@ -32,14 +52,16 @@ type Params = {
};
/**
* The full prompt a bot runs with (markdown): its own core instructions,
* then the install-wide appendix, then, in a workspace, the workspace's
* The full prompt a bot runs with (markdown): who it is (its name and id,
* so it always knows which bot it is), its own core instructions, then the
* install-wide appendix, then, in a workspace, the workspace's
* context and the bot's instructions there, then its core memory, its
* memory about the user and its memory in the workspace (said to be the
* bot's own notes, not the workspace's context). Empty parts are left out;
* the workspace heading always shows, so the bot knows where it is.
*/
export default function composeBotPrompt({
bot,
system_prompt,
appendix,
memory,
@@ -49,6 +71,11 @@ export default function composeBotPrompt({
}: Params) {
const parts: string[] = [];
const name = workspace?.name?.trim() || "Untitled";
const identity = composeIdentity({ bot });
if (identity) {
parts.push(identity);
}
if (system_prompt?.trim()) {
parts.push(system_prompt.trim());
@@ -95,3 +122,24 @@ export default function composeBotPrompt({
return parts.join("\n\n");
}
/**
* Who the bot is, said once at the top of its prompt: its name, kind and
* id, and that the id is how it is named. Empty when the caller didn't say
* which bot it is.
*/
function composeIdentity({ bot }: { bot?: BotPrompt | null }) {
const id = bot?.id === null || bot?.id === undefined ? "" : String(bot.id).trim();
if (!id) return "";
const name = bot?.name?.trim() || "A bot";
const kind = bot?.kind == "coordinator" ? "the coordinator" : "a worker bot";
const role = bot?.role?.trim();
return [
`# You are ${name}`,
`You are ${kind} on this crew.${role ? ` Your role: ${role}.` : ""}`,
`Your bot id is ${id}. That id is you: when a tool takes a bot id for the bot it is about (your own tasks, your own picture), this is it, and when you name yourself in a message write the ref \`[bot:${id}]\`, never another bot's.`,
].join("\n\n");
}