Keep a record of each run's tool calls so bots can't claim work they never did

The wrap-up turn now gets the run's actual tool calls, each bot message
keeps them as bot_messages.tool_log, and history follows recorded bot
messages with an [App record] line. The coordinator also gets update_bot
in workspaces, so it can fill a bot's core prompt from there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-25 11:32:56 +01:00
co-authored by Claude Opus 5.5
parent 93896646c7
commit d7f88d8dd3
22 changed files with 293 additions and 19 deletions
+1
View File
@@ -113,6 +113,7 @@ Enables SQLite foreign keys (`enableForeignKeys`), `bunextInit()`, `ensureDocker
- Kinds (`BotJobKinds`): `reply` (chat answer, no task row) and `task` (long work, usually on a computer). bun-sqlite `isQueue` table. Helpers in `src/functions/backend/db/bots-queue/`: `enqueueBotJob`, `claimBotJobs` (kind-filtered atomic claim), `updateBotJob` (false once lost/cancelled), `completeBotJob`, `failBotJob` (backoff or fatal), `deferBotJob` (all models resting: attempt refunded, face `asleep`), `cancelBotJob`, `stop-bot-jobs`. - Kinds (`BotJobKinds`): `reply` (chat answer, no task row) and `task` (long work, usually on a computer). bun-sqlite `isQueue` table. Helpers in `src/functions/backend/db/bots-queue/`: `enqueueBotJob`, `claimBotJobs` (kind-filtered atomic claim), `updateBotJob` (false once lost/cancelled), `completeBotJob`, `failBotJob` (backoff or fatal), `deferBotJob` (all models resting: attempt refunded, face `asleep`), `cancelBotJob`, `stop-bot-jobs`.
- Handler `src/bots-queue/index.ts`: separate reply/task slots, heartbeat, per-attempt deadline (`AppData.BotJobAttemptTimeoutMs`), one task per computer and one reply per session (`createKeyLock`). Work in `run-bot-job.ts` -> `run-bot-reply.ts` / `run-bot-task.ts`. Throw `BotJobFatalError` for failures retrying can't fix. - Handler `src/bots-queue/index.ts`: separate reply/task slots, heartbeat, per-attempt deadline (`AppData.BotJobAttemptTimeoutMs`), one task per computer and one reply per session (`createKeyLock`). Work in `run-bot-job.ts` -> `run-bot-reply.ts` / `run-bot-task.ts`. Throw `BotJobFatalError` for failures retrying can't fix.
- Every model turn is a step in `bots_queue_updates` (tool names, never arguments; `describe-job-step.ts`, labels in `JobStepToolLabels`), pushed as `server:bot-job-update`. - Every model turn is a step in `bots_queue_updates` (tool names, never arguments; `describe-job-step.ts`, labels in `JobStepToolLabels`), pushed as `server:bot-job-update`.
- **A bot can't vouch for itself:** a run's tool calls (`tool_uses`, names and ok, never arguments) go to the wrap-up turn when steps run out and onto the result message as `bot_messages.tool_log`; history follows each recorded bot message with an `[App record: ...]` line (`botMessagesToHarness`), and copies of that line in an answer are stripped (`stripAppRecords`).
- **Replies take in messages sent mid-run** (`incoming`, `IncomingMessagesNote`, redraft); separate requests are acknowledged with `send_message` (interim). A queued reply finishes silently only when a finished reply already read every user message (`hasUnansweredMessage`), never by who wrote last. - **Replies take in messages sent mid-run** (`incoming`, `IncomingMessagesNote`, redraft); separate requests are acknowledged with `send_message` (interim). A queued reply finishes silently only when a finished reply already read every user message (`hasUnansweredMessage`), never by who wrote last.
### Live updates ### Live updates
+5
View File
@@ -452,6 +452,11 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
fieldDescription: `The message this one replies to, quoted above it. Not a foreign key, so deleting the quoted message only drops the quote`, fieldDescription: `The message this one replies to, quoted above it. Not a foreign key, so deleting the quoted message only drops the quote`,
dataType: "INTEGER", dataType: "INTEGER",
}, },
{
fieldName: "tool_log",
fieldDescription: `JSON \`[{ name, ok }]\`: every tool call the run behind a bot message made (names only, never arguments), told to the bot with the message in later turns so it can't build on work it never did. Null: not recorded (user messages, interim ones, older messages)`,
dataType: "TEXT",
},
], ],
indexes: [ indexes: [
{ {
+4
View File
@@ -319,6 +319,10 @@ export type BUN_SQLITE_COBBLE_BOT_MESSAGES = {
* The message this one replies to, quoted above it. Not a foreign key, so deleting the quoted message only drops the quote * The message this one replies to, quoted above it. Not a foreign key, so deleting the quoted message only drops the quote
*/ */
reply_to_message_id?: number | ""; reply_to_message_id?: number | "";
/**
* JSON `[{ name, ok }]`: every tool call the run behind a bot message made (names only, never arguments), told to the bot with the message in later turns so it can't build on work it never did. Null: not recorded (user messages, interim ones, older messages)
*/
tool_log?: string;
} }
export type BUN_SQLITE_COBBLE_BOT_MESSAGE_REACTIONS = { export type BUN_SQLITE_COBBLE_BOT_MESSAGE_REACTIONS = {
@@ -152,4 +152,49 @@ describe("botMessagesToHarness with mentions", () => {
{ role: "assistant", content: "[bot:3] is on it" }, { role: "assistant", content: "[bot:3] is on it" },
]); ]);
}); });
test("follows a recorded bot message with the app's record of its tools", () => {
expect(
botMessagesToHarness({
messages: [
message("user", "fill in its prompt"),
{
...message("bot", "Done."),
tool_log: JSON.stringify([
{ name: "fetch_url", ok: true },
{ name: "fetch_url", ok: false },
]),
},
message("user", "and the other one?"),
{ ...message("bot", "Saved too."), tool_log: "[]" },
],
}),
).toEqual([
{ role: "user", content: "fill in its prompt" },
{
role: "assistant",
content: "Done.\n\n[App record: this message's run called fetch_url x2 (1 failed).]",
},
{ role: "user", content: "and the other one?" },
{
role: "assistant",
content: "Saved too.\n\n[App record: this message's run called no tools.]",
},
]);
});
test("adds no record for messages whose run wasn't recorded", () => {
expect(
botMessagesToHarness({
messages: [
message("user", "hi"),
{ ...message("bot", "hello"), tool_log: undefined },
{ ...message("bot", "again"), tool_log: "not json" },
],
}),
).toEqual([
{ role: "user", content: "hi" },
{ role: "assistant", content: "hello\n\nagain" },
]);
});
}); });
+37 -4
View File
@@ -1,12 +1,14 @@
import type { HarnessContentPart, HarnessMessage } from "@/src/harness/types"; import describeToolUses from "@/src/harness/text/describe-tool-uses";
import type { HarnessContentPart, HarnessMessage, HarnessToolUse } from "@/src/harness/types";
import type { BotMessageWithAttachments } from "@/src/types"; import type { BotMessageWithAttachments } from "@/src/types";
import describeMessageRefs, { type MessageRefNames } from "@/src/utils/describe-message-refs"; import describeMessageRefs, { type MessageRefNames } from "@/src/utils/describe-message-refs";
import { AppRecordPrefix } from "@/src/utils/strip-app-records";
import describeAttachment from "./describe-attachment"; import describeAttachment from "./describe-attachment";
type Params = { type Params = {
messages: Pick< messages: Pick<
BotMessageWithAttachments, BotMessageWithAttachments,
"id" | "author" | "text" | "attachments" | "reply_to" "id" | "author" | "text" | "attachments" | "reply_to" | "tool_log"
>[]; >[];
/** /**
* Loaded files by message id (`loadAttachmentParts`). Files of other * Loaded files by message id (`loadAttachmentParts`). Files of other
@@ -27,7 +29,10 @@ type Turn = { role: "user" | "assistant"; parts: HarnessContentPart[] };
* (with their files) as user turns, the bot's as its own. Back-to-back * (with their files) as user turns, the bot's as its own. Back-to-back
* messages from the same side are joined, since some providers reject * messages from the same side are joined, since some providers reject
* repeated roles. Leading bot messages are dropped so the conversation * repeated roles. Leading bot messages are dropped so the conversation
* starts with the user. A turn that is only text stays a string. * starts with the user. A turn that is only text stays a string. A bot
* message whose run was recorded (`tool_log`) is followed by the app's
* record of the tools it called, so the bot can tell what its past
* messages actually did from what they say.
*/ */
export default function botMessagesToHarness({ messages, parts, refs }: Params): HarnessMessage[] { export default function botMessagesToHarness({ messages, parts, refs }: Params): HarnessMessage[] {
const turns: Turn[] = []; const turns: Turn[] = [];
@@ -78,7 +83,13 @@ function messageParts({
const text = replyContext({ message, text: mentions ? `${own}\n\n${mentions}` : own }); const text = replyContext({ message, text: mentions ? `${own}\n\n${mentions}` : own });
const content: HarnessContentPart[] = text ? [{ type: "text", text }] : []; const content: HarnessContentPart[] = text ? [{ type: "text", text }] : [];
if (role == "assistant" || !message.attachments?.length) return content; if (role == "assistant") {
const record = text ? appRecord({ tool_log: message.tool_log }) : "";
return record ? [...content, { type: "text", text: record }] : content;
}
if (!message.attachments?.length) return content;
const loaded = parts?.get(Number(message.id)); const loaded = parts?.get(Number(message.id));
@@ -93,6 +104,28 @@ function messageParts({
]; ];
} }
/**
* The app's line after a bot message naming the tools its run called, or
* nothing when the run wasn't recorded.
*/
function appRecord({ tool_log }: { tool_log?: string | null }) {
if (!tool_log) return "";
let uses: HarnessToolUse[];
try {
uses = JSON.parse(tool_log);
} catch {
return "";
}
if (!Array.isArray(uses)) return "";
const called = uses.length ? describeToolUses({ uses }) : "no tools";
return `${AppRecordPrefix} this message's run called ${called}.]`;
}
/** /**
* Names the message a user turn answers, so the bot reads the reply in * Names the message a user turn answers, so the bot reads the reply in
* context even when the quoted message is further back. A reply with * context even when the quoted message is further back. A reply with
+1
View File
@@ -212,6 +212,7 @@ export default async function processBotJob({
needs_you: outcome.needs_you, needs_you: outcome.needs_you,
silent: outcome.silent, silent: outcome.silent,
attachments: outcome.attachments, attachments: outcome.attachments,
tool_log: outcome.tool_log,
}); });
await pushBotNotice({ bot_id, ...completion }); await pushBotNotice({ bot_id, ...completion });
+6 -1
View File
@@ -33,6 +33,7 @@ import jobSessionId from "@/src/functions/backend/db/bots-queue/job-session-id";
import loadAttachmentParts from "./load-attachment-parts"; import loadAttachmentParts from "./load-attachment-parts";
import grabMessageRefNames from "@/src/functions/backend/db/bots/grab-message-ref-names"; import grabMessageRefNames from "@/src/functions/backend/db/bots/grab-message-ref-names";
import withTakeOverRefs from "./with-take-over-refs"; import withTakeOverRefs from "./with-take-over-refs";
import stripAppRecords from "@/src/utils/strip-app-records";
import hasUnansweredMessage from "./has-unanswered-message"; import hasUnansweredMessage from "./has-unanswered-message";
import grabAnsweredMessageId from "@/src/functions/backend/db/bots-queue/grab-answered-message-id"; import grabAnsweredMessageId from "@/src/functions/backend/db/bots-queue/grab-answered-message-id";
import markReplyAnswered from "@/src/functions/backend/db/bots-queue/mark-reply-answered"; import markReplyAnswered from "@/src/functions/backend/db/bots-queue/mark-reply-answered";
@@ -269,8 +270,12 @@ export default async function runBotReply({
} }
return { return {
...withTakeOverRefs({ text: result.text, refs: [...take_overs, ...proposals] }), ...withTakeOverRefs({
text: stripAppRecords({ text: result.text }),
refs: [...take_overs, ...proposals],
}),
attachments: attachments.length ? attachments : undefined, attachments: attachments.length ? attachments : undefined,
tool_log: result.tool_uses,
}; };
} finally { } finally {
await extensions.close(); await extensions.close();
+5 -1
View File
@@ -34,6 +34,7 @@ import composeJobBrief from "./compose-job-brief";
import grabSessionCarryOver from "./grab-session-carry-over"; import grabSessionCarryOver from "./grab-session-carry-over";
import jobSessionId from "@/src/functions/backend/db/bots-queue/job-session-id"; import jobSessionId from "@/src/functions/backend/db/bots-queue/job-session-id";
import withTakeOverRefs from "./with-take-over-refs"; import withTakeOverRefs from "./with-take-over-refs";
import stripAppRecords from "@/src/utils/strip-app-records";
import ScheduledRunFailedError from "./scheduled-run-failed-error"; import ScheduledRunFailedError from "./scheduled-run-failed-error";
import type { BotJobContext, BotJobOutcome } from "./types"; import type { BotJobContext, BotJobOutcome } from "./types";
import computerRunDirs from "@/src/utils/computer-work-dirs"; import computerRunDirs from "@/src/utils/computer-work-dirs";
@@ -272,12 +273,15 @@ export default async function runBotTask({
throw new ScheduledRunFailedError(computer ? computer.redact(failure) : failure); throw new ScheduledRunFailedError(computer ? computer.redact(failure) : failure);
} }
const text = stripAppRecords({ text: result.text });
return { return {
...withTakeOverRefs({ ...withTakeOverRefs({
text: computer ? computer.redact(result.text) : result.text, text: computer ? computer.redact(text) : text,
refs: take_overs, refs: take_overs,
}), }),
attachments: attachments.length ? attachments : undefined, attachments: attachments.length ? attachments : undefined,
tool_log: result.tool_uses,
}; };
} finally { } finally {
await extensions?.close(); await extensions?.close();
+6
View File
@@ -1,5 +1,6 @@
import type { BUN_SQLITE_COBBLE_BOTS_QUEUE } from "@/db/types/db"; import type { BUN_SQLITE_COBBLE_BOTS_QUEUE } from "@/db/types/db";
import type { TextLoopStep } from "@/src/harness/text/run-text-loop"; import type { TextLoopStep } from "@/src/harness/text/run-text-loop";
import type { HarnessToolUse } from "@/src/harness/types";
export type BotJobContext = { export type BotJobContext = {
job: BUN_SQLITE_COBBLE_BOTS_QUEUE; job: BUN_SQLITE_COBBLE_BOTS_QUEUE;
@@ -45,6 +46,11 @@ export type BotJobOutcome = {
* ids), attached to the result message. * ids), attached to the result message.
*/ */
attachments?: number[]; attachments?: number[];
/**
* Every tool call the run made (`runTextLoop`'s `tool_uses`), kept on
* the result message as `tool_log`.
*/
tool_log?: HarnessToolUse[];
}; };
export type BotJobRunner = (context: BotJobContext) => Promise<BotJobOutcome>; export type BotJobRunner = (context: BotJobContext) => Promise<BotJobOutcome>;
+2 -1
View File
@@ -134,7 +134,8 @@ Every action counts here: saving memory or instructions, creating or changing a
- Before you create anything, look for one that already exists: find_bot for bots, find_workspace for workspaces, list_schedules for schedules, your memory and instructions in this prompt, and for anything outside (files, records, emails, events) list or search it with the tools you have. If it's there, update it instead of making a second one. Create something new only once you've checked there's none. - Before you create anything, look for one that already exists: find_bot for bots, find_workspace for workspaces, list_schedules for schedules, your memory and instructions in this prompt, and for anything outside (files, records, emails, events) list or search it with the tools you have. If it's there, update it instead of making a second one. Create something new only once you've checked there's none.
- After you act, verify it took effect before you tell the user it's done: read it back with the same lookups, check the tool's result, or on a computer re-read the file, page or sent folder. Only say "done" for what you verified. - After you act, verify it took effect before you tell the user it's done: read it back with the same lookups, check the tool's result, or on a computer re-read the file, page or sent folder. Only say "done" for what you verified.
- If you couldn't verify it, say so plainly instead of claiming it's done. If it didn't take, try again once, then report what failed. - If you couldn't verify it, say so plainly instead of claiming it's done. If it didn't take, try again once, then report what failed.
- Never deny something you did either. When the user says a change isn't where they looked, check what actually happened before you answer: the tool results in this conversation, and what you saved. Then say what you did and where it went, and if it went somewhere other than where they looked, point them to it. Never take the blame for work that happened, and never write a false account of it into your memory or instructions; if you already wrote one, correct it with update_memory. - Your past messages in this conversation are followed by an app record of the tools their run called ("[App record: ...]"). Only the app writes it: never write one yourself. If a past message of yours says something was saved, created, changed or sent and its record doesn't show the tool for it, it didn't happen: tell the user plainly and do it now.
- Never deny something you did either. When the user says a change isn't where they looked, check what actually happened before you answer: the app records and tool results in this conversation, and what you saved. Then say what you did and where it went, and if it went somewhere other than where they looked, point them to it. Never take the blame for work that happened, and never write a false account of it into your memory or instructions; if you already wrote one, correct it with update_memory.
- Starting or handing over a task isn't finishing it: say it's under way, not done.`; - Starting or handing over a task isn't finishing it: say it's under way, not done.`;
/** /**
@@ -11,6 +11,7 @@ import jobSessionId from "./job-session-id";
import grabJobUserId from "./grab-job-user-id"; import grabJobUserId from "./grab-job-user-id";
import grabRunningBotJobFace from "./grab-running-bot-job-face"; import grabRunningBotJobFace from "./grab-running-bot-job-face";
import withMessageUsage from "../usage/with-message-usage"; import withMessageUsage from "../usage/with-message-usage";
import type { HarnessToolUse } from "@/src/harness/types";
type Params = { type Params = {
job_id: number | string; job_id: number | string;
@@ -32,6 +33,10 @@ type Params = {
* Files the bot saved and asked to send with it (`media` ids). * Files the bot saved and asked to send with it (`media` ids).
*/ */
attachments?: number[]; attachments?: number[];
/**
* Every tool call the run made, kept on the message.
*/
tool_log?: HarnessToolUse[];
}; };
/** /**
@@ -50,6 +55,7 @@ export default async function completeBotJob({
needs_you, needs_you,
silent, silent,
attachments, attachments,
tool_log,
}: Params) { }: Params) {
const job = await grabLockedBotJob({ job_id, worker }); const job = await grabLockedBotJob({ job_id, worker });
@@ -89,6 +95,7 @@ export default async function completeBotJob({
text: result, text: result,
needs_you, needs_you,
media_ids: attachments, media_ids: attachments,
tool_log,
}); });
if (job.id && inserted?.id) { if (job.id && inserted?.id) {
@@ -3,6 +3,7 @@ import type {
BunSQLiteTables, BunSQLiteTables,
} from "@/db/types/db"; } from "@/db/types/db";
import BunSQLite from "@moduletrace/bun-sqlite"; import BunSQLite from "@moduletrace/bun-sqlite";
import type { HarnessToolUse } from "@/src/harness/types";
type Params = { type Params = {
bot_id: number; bot_id: number;
@@ -22,6 +23,11 @@ type Params = {
* generated image, a document it wrote. * generated image, a document it wrote.
*/ */
media_ids?: number[]; media_ids?: number[];
/**
* Every tool call the run behind it made, told to the bot with this
* message in later turns (`botMessagesToHarness`).
*/
tool_log?: HarnessToolUse[];
}; };
/** /**
@@ -36,10 +42,12 @@ export default async function insertBotMessage({
needs_you, needs_you,
interim, interim,
media_ids = [], media_ids = [],
tool_log,
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_MESSAGES> { }: Params): Promise<BUN_SQLITE_COBBLE_BOT_MESSAGES> {
const now = Date.now(); const now = Date.now();
const needs_you_flag = needs_you ? 1 : 0; const needs_you_flag = needs_you ? 1 : 0;
const interim_flag = interim ? 1 : 0; const interim_flag = interim ? 1 : 0;
const tool_log_json = tool_log ? JSON.stringify(tool_log) : undefined;
const res = await BunSQLite.insert< const res = await BunSQLite.insert<
BUN_SQLITE_COBBLE_BOT_MESSAGES, BUN_SQLITE_COBBLE_BOT_MESSAGES,
@@ -55,6 +63,7 @@ export default async function insertBotMessage({
text, text,
needs_you: needs_you_flag, needs_you: needs_you_flag,
interim: interim_flag, interim: interim_flag,
tool_log: tool_log_json,
}, },
], ],
}); });
@@ -86,6 +95,7 @@ export default async function insertBotMessage({
text, text,
needs_you: needs_you_flag, needs_you: needs_you_flag,
interim: interim_flag, interim: interim_flag,
tool_log: tool_log_json,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}; };
@@ -0,0 +1,22 @@
import { describe, expect, test } from "bun:test";
import describeToolUses from "./describe-tool-uses";
describe("describeToolUses", () => {
test("says none when no tool ran", () => {
expect(describeToolUses({ uses: [] })).toBe("none");
});
test("groups calls by tool, in the order first called, and marks failures", () => {
expect(
describeToolUses({
uses: [
{ name: "fetch_url", ok: true },
{ name: "update_memory", ok: true },
{ name: "fetch_url", ok: false },
{ name: "fetch_url", ok: true },
{ name: "set_bot_workspace_instructions", ok: false },
],
}),
).toBe("fetch_url x3 (1 failed), update_memory, set_bot_workspace_instructions (failed)");
});
});
+34
View File
@@ -0,0 +1,34 @@
import type { HarnessToolUse } from "../types";
type Params = {
uses: HarnessToolUse[];
};
/**
* A run's tool calls in one line, grouped by tool in the order first
* called: `update_memory, fetch_url x3 (1 failed)`, or `none`. The app's
* own record of what happened, told to the model so it can't report work
* it never did.
*/
export default function describeToolUses({ uses }: Params) {
const counts = new Map<string, { calls: number; failed: number }>();
for (const use of uses) {
const count = counts.get(use.name) || { calls: 0, failed: 0 };
count.calls++;
if (!use.ok) count.failed++;
counts.set(use.name, count);
}
if (!counts.size) return "none";
return [...counts]
.map(([name, { calls, failed }]) => {
const times = calls > 1 ? ` x${calls}` : "";
const failures = !failed ? "" : failed == calls ? " (failed)" : ` (${failed} failed)`;
return `${name}${times}${failures}`;
})
.join(", ");
}
+28
View File
@@ -130,6 +130,13 @@ describe("runTextLoop", () => {
expect(seen).toHaveLength(3); expect(seen).toHaveLength(3);
expect(seen[2]!.tools).toBe(undefined); expect(seen[2]!.tools).toBe(undefined);
expect(seen[2]!.messages.at(-1)).toMatchObject({ role: "user" }); expect(seen[2]!.messages.at(-1)).toMatchObject({ role: "user" });
expect(seen[2]!.messages.at(-1)!.content).toContain(
"The tools you actually called this run: remember x2.",
);
expect(result.tool_uses).toEqual([
{ name: "remember", ok: true },
{ name: "remember", ok: true },
]);
expect(result.text).toBe("Ran out of room; here's where I got to."); expect(result.text).toBe("Ran out of room; here's where I got to.");
expect(result.messages.at(-1)).toEqual({ expect(result.messages.at(-1)).toEqual({
role: "assistant", role: "assistant",
@@ -137,6 +144,27 @@ describe("runTextLoop", () => {
}); });
}); });
test("records failed tool calls, and tells the wrap up so", async () => {
const { generate, seen } = scripted([
{ tool_calls: [{ id: "c", name: "missing_tool", arguments: "{}" }] },
{ text: "Couldn't do it." },
]);
const result = await runTextLoop({
system: "s",
messages: [{ role: "user", content: "go" }],
tools: [remember],
max_steps: 1,
generate,
resolveCandidates,
});
expect(result.tool_uses).toEqual([{ name: "missing_tool", ok: false }]);
expect(seen[1]!.messages.at(-1)!.content).toContain(
"The tools you actually called this run: missing_tool (failed).",
);
});
test("stops when cancelled", async () => { test("stops when cancelled", async () => {
const controller = new AbortController(); const controller = new AbortController();
controller.abort(); controller.abort();
+22 -7
View File
@@ -8,8 +8,10 @@ import type {
HarnessMessage, HarnessMessage,
HarnessTool, HarnessTool,
HarnessToolCall, HarnessToolCall,
HarnessToolUse,
HarnessUsage, HarnessUsage,
} from "../types"; } from "../types";
import describeToolUses from "./describe-tool-uses";
import fitTranscriptToContext from "./fit-transcript-to-context"; import fitTranscriptToContext from "./fit-transcript-to-context";
import generateText from "./generate-text"; import generateText from "./generate-text";
import pushUserTurn from "./push-user-turn"; import pushUserTurn from "./push-user-turn";
@@ -70,9 +72,21 @@ export type TextLoopResult = {
steps: number; steps: number;
candidate: HarnessCandidate; candidate: HarnessCandidate;
usage: HarnessUsage; usage: HarnessUsage;
/**
* Every tool call the run made, in order, and whether it went through.
*/
tool_uses: HarnessToolUse[];
}; };
const WrapUpPrompt = `You've used every step this run allows. Don't call any more tools. Report what you did, what is left, and anything you need from the user.`; /**
* Told on the last step, with the app's record of the tools the run
* called (`describeToolUses`): out of steps, a model tends to report the
* work it planned as done.
*/
export const wrapUpPrompt = ({ uses }: { uses: HarnessToolUse[] }) =>
`You've used every step this run allows. Don't call any more tools. Report what you did, what is left, and anything you need from the user.
The tools you actually called this run: ${describeToolUses({ uses })}. Nothing else happened. Don't say you saved, created, changed or sent anything this list doesn't show; what you meant to do and didn't is still left, so say so.`;
/** /**
* Told with what the user wrote while the run worked. Whether a message * Told with what the user wrote while the run worked. Whether a message
@@ -121,6 +135,7 @@ export default async function runTextLoop({
completion_tokens: 0, completion_tokens: 0,
cost: 0, cost: 0,
}; };
const tool_uses: HarnessToolUse[] = [];
let prefer_key: string | undefined; let prefer_key: string | undefined;
for (let index = 0; index <= max_steps; index++) { for (let index = 0; index <= max_steps; index++) {
@@ -131,7 +146,7 @@ export default async function runTextLoop({
const wrap_up = index == max_steps; const wrap_up = index == max_steps;
if (wrap_up) { if (wrap_up) {
pushUserTurn({ transcript, content: WrapUpPrompt }); pushUserTurn({ transcript, content: wrapUpPrompt({ uses: tool_uses }) });
} }
/** /**
@@ -194,15 +209,15 @@ export default async function runTextLoop({
steps: index + 1, steps: index + 1,
candidate: answer.candidate, candidate: answer.candidate,
usage, usage,
tool_uses,
}; };
} }
for (const call of tool_calls) { for (const call of tool_calls) {
transcript.push({ const content = await runToolCall({ call, tools, signal });
role: "tool",
tool_call_id: call.id, tool_uses.push({ name: call.name, ok: !content.startsWith("Error:") });
content: await runToolCall({ call, tools, signal }), transcript.push({ role: "tool", tool_call_id: call.id, content });
});
} }
await takeIncoming({ note: IncomingMessagesNote }); await takeIncoming({ note: IncomingMessagesNote });
+7 -3
View File
@@ -89,8 +89,9 @@ type Params = {
* computers, and which name, role and core instructions each crew member * computers, and which name, role and core instructions each crew member
* gets, and what goes) and the install's skills, MCP servers and custom * gets, and what goes) and the install's skills, MCP servers and custom
* tools (`grabCoordinatorExtensionTools`) or, in a workspace, * tools (`grabCoordinatorExtensionTools`) or, in a workspace,
* set_bot_workspace_instructions, plus set_bot_type to make a worker a * set_bot_workspace_instructions and update_bot for the workspace's crew
* workspace's admin. A workspace admin, in the workspace it leads, gets * (a bot's core prompt is asked for from wherever the user is), plus
* set_bot_type to make a worker a workspace's admin. A workspace admin, in the workspace it leads, gets
* `grabWorkspaceAdminTools`. Every gate reads the bot's types (`powers`), * `grabWorkspaceAdminTools`. Every gate reads the bot's types (`powers`),
* never `bots.kind`. Anything else on a computer goes through a task, * never `bots.kind`. Anything else on a computer goes through a task,
* never mid-reply. * never mid-reply.
@@ -182,7 +183,10 @@ export default async function grabBotChatTools({
requester_name: bot.name || "The coordinator", requester_name: bot.name || "The coordinator",
workers, workers,
})); }));
tools.push(setWorkspaceInstructionsTool({ user_id, workspace_id, workers })); tools.push(
setWorkspaceInstructionsTool({ user_id, workspace_id, workers }),
updateBotTool({ user_id, workers }),
);
if (powers.can("set_bot_types")) { if (powers.can("set_bot_types")) {
tools.push(setBotTypeTool({ user_id, bot_id, workers, workspaces, workspace_id })); tools.push(setBotTypeTool({ user_id, bot_id, workers, workspaces, workspace_id }));
@@ -39,7 +39,7 @@ export default function setWorkspaceInstructionsTool({
return { return {
name: "set_bot_workspace_instructions", name: "set_bot_workspace_instructions",
description: `Replace a crew member's instructions for this workspace with this full text (markdown): how they should do their job here, on top of their own instructions. Use it when the user tells you how someone should work in this workspace. It replaces what they had for it, so keep what still applies. Crew here: ${crew}.`, description: `Replace a crew member's instructions for this workspace with this full text (markdown): how they should do their job here, on top of their own instructions. Use it when the user tells you how someone should work in this workspace. It replaces what they had for it, so keep what still applies. This is not the bot's own prompt: when the user means who the bot is and what it does everywhere ("its prompt", "its instructions", an empty prompt to fill), use update_bot where you have it. Crew here: ${crew}.`,
schema: z.object({ schema: z.object({
bot_id: z.number().int(), bot_id: z.number().int(),
instructions: z.string().trim().max(AppData.PromptMaxLength), instructions: z.string().trim().max(AppData.PromptMaxLength),
+1 -1
View File
@@ -35,7 +35,7 @@ export default function updateBotTool({ user_id, workers }: Params): HarnessTool
return { return {
name: "update_bot", name: "update_bot",
description: `Change an existing crew member: name, the one-line role it's shown with, and/or its core instructions (markdown: what they do and how they work, the start of their prompt in their own chat and in every workspace). Only the fields you pass change. Instructions replace what they had, so keep what still applies, their own edits included. This is not their instructions for one workspace: use set_bot_workspace_instructions for that. Crew: ${crew || "none"}.`, description: `Change an existing crew member: name, the one-line role it's shown with, and/or its core instructions (markdown: what they do and how they work, the start of their prompt in their own chat and in every workspace). Only the fields you pass change. Instructions replace what they had, so keep what still applies, their own edits included. This is what "its prompt" means: use it when the user asks for a bot's prompt or instructions to be written or filled in. It is not their instructions for one workspace: use set_bot_workspace_instructions only for how they should work there. Crew: ${crew || "none"}.`,
schema: z schema: z
.object({ .object({
bot_id: z.number().int(), bot_id: z.number().int(),
+9
View File
@@ -20,6 +20,15 @@ export type HarnessToolCall = {
arguments: string; arguments: string;
}; };
/**
* One tool call a run made, and whether it went through (a result that
* isn't `Error: ...`). Names only, never arguments.
*/
export type HarnessToolUse = {
name: string;
ok: boolean;
};
export type HarnessMessage = export type HarnessMessage =
| { role: "system"; content: string } | { role: "system"; content: string }
| { role: "user"; content: string | HarnessContentPart[] } | { role: "user"; content: string | HarnessContentPart[] }
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, test } from "bun:test";
import stripAppRecords from "./strip-app-records";
describe("stripAppRecords", () => {
test("leaves an answer without records as it is", () => {
expect(stripAppRecords({ text: " Done. [Not a record] " })).toBe(" Done. [Not a record] ");
});
test("drops record lines the bot copied, wherever they are", () => {
expect(
stripAppRecords({
text: "Saved.\n\n[App record: this message's run called update_bot]\n\nAnything else?\n [App record: none]",
}),
).toBe("Saved.\n\nAnything else?");
});
});
+24
View File
@@ -0,0 +1,24 @@
type Params = {
text: string;
};
/**
* The opening of the line the app adds after a bot's past message, naming
* the tools its run called (`botMessagesToHarness`).
*/
export const AppRecordPrefix = "[App record:";
/**
* Drops every app record line a bot copied into its own answer: only the
* app writes them, so a bot can't vouch for itself in the next turn.
*/
export default function stripAppRecords({ text }: Params) {
if (!text.includes(AppRecordPrefix)) return text;
return text
.split("\n")
.filter((line) => !line.trim().startsWith(AppRecordPrefix))
.join("\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}