Make routines act on their own and check every bot action

Scheduled runs are pre-approved: they carry out their brief instead of
drafting for confirmation. A run that can't do its brief calls
report_run_failed and is retried 5 and 10 minutes later; only then does
the user get one "failed after N tries" message, and the routine keeps
why in last_error.

Every reply and task now gets ActionCheckNote: look for an existing
record before creating one, and verify an action took effect before
saying it's done. Memory and instructions saves are read back, and
create_bot, create_schedule, start_task and delegate_task refuse
duplicates.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-20 08:45:49 +01:00
co-authored by Claude Opus 5.5
parent 6c57fae444
commit 535adb6e16
25 changed files with 448 additions and 16 deletions
+26
View File
@@ -0,0 +1,26 @@
import grabBotSavedText, {
type BotSavedTextKind,
} from "@/src/functions/backend/db/bots/grab-bot-saved-text";
type Params = {
kind: BotSavedTextKind;
bot_id: number | string;
user_id?: number | string;
workspace_id?: number | string | null;
/**
* What the bot asked to save.
*/
expected: string;
};
/**
* Reads a saved memory or instructions back and throws unless it holds
* what the bot sent, so the bot never reports a save that didn't take.
*/
export default async function checkSavedText({ expected, ...params }: Params) {
const stored = await grabBotSavedText(params);
if (stored.trim() != expected.trim()) {
throw new Error(`The save didn't take: what's stored doesn't match what you sent. Try again.`);
}
}
+13
View File
@@ -63,6 +63,19 @@ export default function createBotTool({
);
}
const twin = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT id, name FROM bots WHERE lower(trim(name)) = lower(trim(?)) LIMIT 1`,
values: [args.name],
})
).payload?.[0];
if (twin?.id) {
throw new Error(
`There's already a bot called ${twin.name} (${botRef({ id: twin.id })}). Change it with update_bot, or pick another name if it's really a different job.`,
);
}
const hues =
(
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
+18 -2
View File
@@ -2,6 +2,8 @@ import { z } from "zod";
import type { BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
import createCronJob from "@/src/functions/backend/db/cron-jobs/create-cron-job";
import grabCronJobs from "@/src/functions/backend/db/cron-jobs/grab-cron-jobs";
import findDuplicateCronJob from "@/src/utils/find-duplicate-cron-job";
import describeCronJob from "@/src/utils/describe-cron-job";
import type { HarnessTool } from "../../types";
@@ -44,7 +46,7 @@ export default function createScheduleTool({ user_id, bot_id, workspace_id, comp
computers.length
? `Runs use the computer you're on when they start unless you pick one: ${listed}.`
: `You have no computer here, so runs can't use one.`
} Write the instructions as a full brief for a future you with no memory of this chat.`,
} Write the instructions as a full brief for a future you with no memory of this chat. Runs carry the brief out without asking the user, so state the action itself ("send the summary to ..."), not a draft for them to approve, unless they asked to approve it.`,
schema: z.object({
title: z.string().min(1).max(80).describe(`A short label, e.g. "Morning inbox digest"`),
instructions: z.string().min(1).max(8000),
@@ -53,6 +55,20 @@ export default function createScheduleTool({ user_id, bot_id, workspace_id, comp
max_runs: z.number().int().positive().optional().describe("Stop after this many runs; leave out to run until stopped"),
}),
run: async ({ args }) => {
const twin = findDuplicateCronJob({
cron_jobs: await grabCronJobs({ user_id, bot_id }),
title: args.title,
instructions: args.instructions,
schedule: args.schedule,
workspace_id,
});
if (twin) {
throw new Error(
`This looks like a schedule you already have: ${describeCronJob({ cron_job: twin })}. Change it with update_schedule instead, or give the new one a different title if it really is separate work.`,
);
}
const cron_job = await createCronJob({
user_id,
bot_id,
@@ -64,7 +80,7 @@ export default function createScheduleTool({ user_id, bot_id, workspace_id, comp
max_runs: args.max_runs,
});
return `Schedule saved: ${describeCronJob({ cron_job })}. Tell the user when it runs, in their time, and that they can pause or change it under Routines.`;
return `Schedule saved: ${describeCronJob({ cron_job })}. Check it with list_schedules before you confirm it to the user. Tell the user when it runs, in their time, and that they can pause or change it under Routines.`;
},
};
}
@@ -2,6 +2,7 @@ import { z } from "zod";
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
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 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";
@@ -79,6 +80,17 @@ export default function delegateTaskTool({
const computers = await grabBotComputers({ bot_id: worker.id, workspace_id });
const worker_session = await ensureBotSession({ user_id, bot_id: worker.id, workspace_id });
const open = await grabOpenBotTask({
bot_id: worker.id,
session_id: Number(worker_session.id),
title: args.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.`,
);
}
const job_id = await enqueueBotJob({
user_id,
@@ -0,0 +1,21 @@
import { describe, expect, test } from "bun:test";
import reportRunFailedTool from "./report-run-failed-tool";
const signal = new AbortController().signal;
describe("reportRunFailedTool", () => {
test("passes the reason on", async () => {
const reasons: string[] = [];
const tool = reportRunFailedTool({ onFail: ({ reason }) => reasons.push(reason) });
await tool.run({ args: { reason: " SMTP refused the login. " }, signal });
expect(reasons).toEqual(["SMTP refused the login."]);
});
test("rejects an empty reason", () => {
const tool = reportRunFailedTool({ onFail: () => {} });
expect(tool.schema?.safeParse({ reason: " " }).success).toBe(false);
});
});
@@ -0,0 +1,34 @@
import { z } from "zod";
import type { HarnessTool } from "../../types";
type Params = {
/**
* Gets why the run failed. The task runner throws it after the run, so
* the queue retries the job.
*/
onFail: (params: { reason: string }) => void;
};
type Args = {
reason: string;
};
/**
* Lets a scheduled run say it couldn't do what its brief asks (a send
* that failed, a service that's down), so it's retried later instead of
* posting a report. Only given to scheduled runs.
*/
export default function reportRunFailedTool({ onFail }: Params): HarnessTool<Args> {
return {
name: "report_run_failed",
description: `Call this when this scheduled run couldn't do what its brief asks (a tool or service failed, something it needs is down). The run is tried again later, and the user is told only if every try fails. Say why in one or two sentences. Don't call it for something only the user can do; ask them to take over instead.`,
schema: z.object({
reason: z.string().trim().min(1).max(1000).describe("Why the run failed, for the user"),
}),
run: async ({ args }) => {
onFail({ reason: args.reason.trim() });
return `Recorded. Stop here: the run will be tried again later. Don't write a report.`;
},
};
}
+9
View File
@@ -1,6 +1,7 @@
import { z } from "zod";
import type { BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
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 type { HarnessTool } from "../../types";
type Params = {
@@ -54,6 +55,14 @@ export default function startTaskTool({
computer_id: z.number().int().optional(),
}),
run: async ({ args }) => {
const open = await grabOpenBotTask({ bot_id, session_id, title: args.title });
if (open) {
throw new Error(
`Task #${open.id} "${args.title}" is already ${open.status == "pending" ? "queued" : "under way"}. Don't start it again; its result will be posted here.`,
);
}
const computer_id = args.computer_id ?? computers[0]?.id ?? undefined;
const job_id = await enqueueBotJob({
@@ -3,6 +3,7 @@ import { AppData } from "@/src/data/app-data";
import updateBotSystemPrompt from "@/src/functions/backend/db/bots/update-bot-system-prompt";
import updateWorkspaceBot from "@/src/functions/backend/db/workspaces/update-workspace-bot";
import type { HarnessTool } from "../../types";
import checkSavedText from "./check-saved-text";
type Params = {
user_id: number | string;
@@ -47,7 +48,13 @@ export default function updateInstructionsTool({
bot_id,
system_prompt: args.instructions,
});
return `Instructions for this workspace saved. They apply from your next run here.`;
await checkSavedText({
kind: "workspace_instructions",
bot_id,
workspace_id,
expected: args.instructions,
});
return `Instructions for this workspace saved and checked. They apply from your next run here.`;
}
await updateBotSystemPrompt({
@@ -55,7 +62,8 @@ export default function updateInstructionsTool({
bot_id,
system_prompt: args.instructions,
});
return `Core instructions saved. They apply from your next run, everywhere.`;
await checkSavedText({ kind: "core_instructions", bot_id, expected: args.instructions });
return `Core instructions saved and checked. They apply from your next run, everywhere.`;
},
};
}
+7 -3
View File
@@ -4,6 +4,7 @@ import updateBotMemory from "@/src/functions/backend/db/bots/update-bot-memory";
import updateBotUserMemory from "@/src/functions/backend/db/bots/update-bot-user-memory";
import updateWorkspaceBot from "@/src/functions/backend/db/workspaces/update-workspace-bot";
import type { HarnessTool } from "../../types";
import checkSavedText from "./check-saved-text";
type Params = {
/**
@@ -52,16 +53,19 @@ export default function updateMemoryTool({
run: async ({ args }) => {
if (args.scope == "core") {
await updateBotMemory({ user_id, bot_id, memory: args.memory });
return `Core memory saved.`;
await checkSavedText({ kind: "core_memory", bot_id, expected: args.memory });
return `Core memory saved and checked.`;
}
if (workspace_id && args.scope == "workspace") {
await updateWorkspaceBot({ user_id, workspace_id, bot_id, memory: args.memory });
return `Memory for this workspace saved.`;
await checkSavedText({ kind: "workspace_memory", bot_id, workspace_id, expected: args.memory });
return `Memory for this workspace saved and checked.`;
}
await updateBotUserMemory({ user_id, bot_id, memory: args.memory });
return `Memory about the user saved.`;
await checkSavedText({ kind: "user_memory", bot_id, user_id, expected: args.memory });
return `Memory about the user saved and checked.`;
},
};
}