diff --git a/AGENTS.md b/AGENTS.md index 7ec5c83..1d3805e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`. - 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`. +- **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. ### Live updates diff --git a/db/schema.ts b/db/schema.ts index f8ea852..f04d5ab 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -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`, 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: [ { diff --git a/db/types/db.ts b/db/types/db.ts index dcc7adf..11c5e12 100644 --- a/db/types/db.ts +++ b/db/types/db.ts @@ -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 */ 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 = { diff --git a/src/bots-queue/bot-messages-to-harness.test.ts b/src/bots-queue/bot-messages-to-harness.test.ts index bd5b80a..1f578e9 100644 --- a/src/bots-queue/bot-messages-to-harness.test.ts +++ b/src/bots-queue/bot-messages-to-harness.test.ts @@ -152,4 +152,49 @@ describe("botMessagesToHarness with mentions", () => { { 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" }, + ]); + }); }); diff --git a/src/bots-queue/bot-messages-to-harness.ts b/src/bots-queue/bot-messages-to-harness.ts index b704247..e3a3084 100644 --- a/src/bots-queue/bot-messages-to-harness.ts +++ b/src/bots-queue/bot-messages-to-harness.ts @@ -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 describeMessageRefs, { type MessageRefNames } from "@/src/utils/describe-message-refs"; +import { AppRecordPrefix } from "@/src/utils/strip-app-records"; import describeAttachment from "./describe-attachment"; type Params = { messages: Pick< BotMessageWithAttachments, - "id" | "author" | "text" | "attachments" | "reply_to" + "id" | "author" | "text" | "attachments" | "reply_to" | "tool_log" >[]; /** * 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 * messages from the same side are joined, since some providers reject * 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[] { const turns: Turn[] = []; @@ -78,7 +83,13 @@ function messageParts({ const text = replyContext({ message, text: mentions ? `${own}\n\n${mentions}` : own }); 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)); @@ -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 * context even when the quoted message is further back. A reply with diff --git a/src/bots-queue/process-bot-job.ts b/src/bots-queue/process-bot-job.ts index a680263..b6f4cfc 100644 --- a/src/bots-queue/process-bot-job.ts +++ b/src/bots-queue/process-bot-job.ts @@ -212,6 +212,7 @@ export default async function processBotJob({ needs_you: outcome.needs_you, silent: outcome.silent, attachments: outcome.attachments, + tool_log: outcome.tool_log, }); await pushBotNotice({ bot_id, ...completion }); diff --git a/src/bots-queue/run-bot-reply.ts b/src/bots-queue/run-bot-reply.ts index ba0c587..e314f6c 100644 --- a/src/bots-queue/run-bot-reply.ts +++ b/src/bots-queue/run-bot-reply.ts @@ -33,6 +33,7 @@ import jobSessionId from "@/src/functions/backend/db/bots-queue/job-session-id"; import loadAttachmentParts from "./load-attachment-parts"; import grabMessageRefNames from "@/src/functions/backend/db/bots/grab-message-ref-names"; import withTakeOverRefs from "./with-take-over-refs"; +import stripAppRecords from "@/src/utils/strip-app-records"; import hasUnansweredMessage from "./has-unanswered-message"; 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"; @@ -269,8 +270,12 @@ export default async function runBotReply({ } return { - ...withTakeOverRefs({ text: result.text, refs: [...take_overs, ...proposals] }), + ...withTakeOverRefs({ + text: stripAppRecords({ text: result.text }), + refs: [...take_overs, ...proposals], + }), attachments: attachments.length ? attachments : undefined, + tool_log: result.tool_uses, }; } finally { await extensions.close(); diff --git a/src/bots-queue/run-bot-task.ts b/src/bots-queue/run-bot-task.ts index 465b5f0..243fe9e 100644 --- a/src/bots-queue/run-bot-task.ts +++ b/src/bots-queue/run-bot-task.ts @@ -34,6 +34,7 @@ import composeJobBrief from "./compose-job-brief"; import grabSessionCarryOver from "./grab-session-carry-over"; import jobSessionId from "@/src/functions/backend/db/bots-queue/job-session-id"; import withTakeOverRefs from "./with-take-over-refs"; +import stripAppRecords from "@/src/utils/strip-app-records"; import ScheduledRunFailedError from "./scheduled-run-failed-error"; import type { BotJobContext, BotJobOutcome } from "./types"; 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); } + const text = stripAppRecords({ text: result.text }); + return { ...withTakeOverRefs({ - text: computer ? computer.redact(result.text) : result.text, + text: computer ? computer.redact(text) : text, refs: take_overs, }), attachments: attachments.length ? attachments : undefined, + tool_log: result.tool_uses, }; } finally { await extensions?.close(); diff --git a/src/bots-queue/types.ts b/src/bots-queue/types.ts index e77315d..818d4d3 100644 --- a/src/bots-queue/types.ts +++ b/src/bots-queue/types.ts @@ -1,5 +1,6 @@ import type { BUN_SQLITE_COBBLE_BOTS_QUEUE } from "@/db/types/db"; import type { TextLoopStep } from "@/src/harness/text/run-text-loop"; +import type { HarnessToolUse } from "@/src/harness/types"; export type BotJobContext = { job: BUN_SQLITE_COBBLE_BOTS_QUEUE; @@ -45,6 +46,11 @@ export type BotJobOutcome = { * ids), attached to the result message. */ 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; diff --git a/src/data/harness-prompts.ts b/src/data/harness-prompts.ts index a167681..2552477 100644 --- a/src/data/harness-prompts.ts +++ b/src/data/harness-prompts.ts @@ -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. - 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. -- 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.`; /** diff --git a/src/functions/backend/db/bots-queue/complete-bot-job.ts b/src/functions/backend/db/bots-queue/complete-bot-job.ts index 8db5f0b..9fd97e4 100644 --- a/src/functions/backend/db/bots-queue/complete-bot-job.ts +++ b/src/functions/backend/db/bots-queue/complete-bot-job.ts @@ -11,6 +11,7 @@ import jobSessionId from "./job-session-id"; import grabJobUserId from "./grab-job-user-id"; import grabRunningBotJobFace from "./grab-running-bot-job-face"; import withMessageUsage from "../usage/with-message-usage"; +import type { HarnessToolUse } from "@/src/harness/types"; type Params = { job_id: number | string; @@ -32,6 +33,10 @@ type Params = { * Files the bot saved and asked to send with it (`media` ids). */ 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, silent, attachments, + tool_log, }: Params) { const job = await grabLockedBotJob({ job_id, worker }); @@ -89,6 +95,7 @@ export default async function completeBotJob({ text: result, needs_you, media_ids: attachments, + tool_log, }); if (job.id && inserted?.id) { diff --git a/src/functions/backend/db/bots/insert-bot-message.ts b/src/functions/backend/db/bots/insert-bot-message.ts index 2d45c82..9d5af91 100644 --- a/src/functions/backend/db/bots/insert-bot-message.ts +++ b/src/functions/backend/db/bots/insert-bot-message.ts @@ -3,6 +3,7 @@ import type { BunSQLiteTables, } from "@/db/types/db"; import BunSQLite from "@moduletrace/bun-sqlite"; +import type { HarnessToolUse } from "@/src/harness/types"; type Params = { bot_id: number; @@ -22,6 +23,11 @@ type Params = { * generated image, a document it wrote. */ 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, interim, media_ids = [], + tool_log, }: Params): Promise { const now = Date.now(); const needs_you_flag = needs_you ? 1 : 0; const interim_flag = interim ? 1 : 0; + const tool_log_json = tool_log ? JSON.stringify(tool_log) : undefined; const res = await BunSQLite.insert< BUN_SQLITE_COBBLE_BOT_MESSAGES, @@ -55,6 +63,7 @@ export default async function insertBotMessage({ text, needs_you: needs_you_flag, interim: interim_flag, + tool_log: tool_log_json, }, ], }); @@ -86,6 +95,7 @@ export default async function insertBotMessage({ text, needs_you: needs_you_flag, interim: interim_flag, + tool_log: tool_log_json, created_at: now, updated_at: now, }; diff --git a/src/harness/text/describe-tool-uses.test.ts b/src/harness/text/describe-tool-uses.test.ts new file mode 100644 index 0000000..44ada13 --- /dev/null +++ b/src/harness/text/describe-tool-uses.test.ts @@ -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)"); + }); +}); diff --git a/src/harness/text/describe-tool-uses.ts b/src/harness/text/describe-tool-uses.ts new file mode 100644 index 0000000..9826553 --- /dev/null +++ b/src/harness/text/describe-tool-uses.ts @@ -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(); + + 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(", "); +} diff --git a/src/harness/text/run-text-loop.test.ts b/src/harness/text/run-text-loop.test.ts index f317a22..358af60 100644 --- a/src/harness/text/run-text-loop.test.ts +++ b/src/harness/text/run-text-loop.test.ts @@ -130,6 +130,13 @@ describe("runTextLoop", () => { expect(seen).toHaveLength(3); expect(seen[2]!.tools).toBe(undefined); 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.messages.at(-1)).toEqual({ 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 () => { const controller = new AbortController(); controller.abort(); diff --git a/src/harness/text/run-text-loop.ts b/src/harness/text/run-text-loop.ts index 1a83b28..4199cfc 100644 --- a/src/harness/text/run-text-loop.ts +++ b/src/harness/text/run-text-loop.ts @@ -8,8 +8,10 @@ import type { HarnessMessage, HarnessTool, HarnessToolCall, + HarnessToolUse, HarnessUsage, } from "../types"; +import describeToolUses from "./describe-tool-uses"; import fitTranscriptToContext from "./fit-transcript-to-context"; import generateText from "./generate-text"; import pushUserTurn from "./push-user-turn"; @@ -70,9 +72,21 @@ export type TextLoopResult = { steps: number; candidate: HarnessCandidate; 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 @@ -121,6 +135,7 @@ export default async function runTextLoop({ completion_tokens: 0, cost: 0, }; + const tool_uses: HarnessToolUse[] = []; let prefer_key: string | undefined; for (let index = 0; index <= max_steps; index++) { @@ -131,7 +146,7 @@ export default async function runTextLoop({ const wrap_up = index == max_steps; 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, candidate: answer.candidate, usage, + tool_uses, }; } for (const call of tool_calls) { - transcript.push({ - role: "tool", - tool_call_id: call.id, - content: await runToolCall({ call, tools, signal }), - }); + const content = await runToolCall({ call, tools, signal }); + + tool_uses.push({ name: call.name, ok: !content.startsWith("Error:") }); + transcript.push({ role: "tool", tool_call_id: call.id, content }); } await takeIncoming({ note: IncomingMessagesNote }); diff --git a/src/harness/tools/bot/grab-bot-chat-tools.ts b/src/harness/tools/bot/grab-bot-chat-tools.ts index fa411fe..1a35990 100644 --- a/src/harness/tools/bot/grab-bot-chat-tools.ts +++ b/src/harness/tools/bot/grab-bot-chat-tools.ts @@ -89,8 +89,9 @@ type Params = { * computers, and which name, role and core instructions each crew member * gets, and what goes) and the install's skills, MCP servers and custom * tools (`grabCoordinatorExtensionTools`) or, in a workspace, - * set_bot_workspace_instructions, plus set_bot_type to make a worker a - * workspace's admin. A workspace admin, in the workspace it leads, gets + * set_bot_workspace_instructions and update_bot for the workspace's crew + * (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`), * never `bots.kind`. Anything else on a computer goes through a task, * never mid-reply. @@ -182,7 +183,10 @@ export default async function grabBotChatTools({ requester_name: bot.name || "The coordinator", 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")) { tools.push(setBotTypeTool({ user_id, bot_id, workers, workspaces, workspace_id })); diff --git a/src/harness/tools/bot/set-workspace-instructions-tool.ts b/src/harness/tools/bot/set-workspace-instructions-tool.ts index c469fd0..3a60fb7 100644 --- a/src/harness/tools/bot/set-workspace-instructions-tool.ts +++ b/src/harness/tools/bot/set-workspace-instructions-tool.ts @@ -39,7 +39,7 @@ export default function setWorkspaceInstructionsTool({ return { 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({ bot_id: z.number().int(), instructions: z.string().trim().max(AppData.PromptMaxLength), diff --git a/src/harness/tools/bot/update-bot-tool.ts b/src/harness/tools/bot/update-bot-tool.ts index 09f8915..2243fd8 100644 --- a/src/harness/tools/bot/update-bot-tool.ts +++ b/src/harness/tools/bot/update-bot-tool.ts @@ -35,7 +35,7 @@ export default function updateBotTool({ user_id, workers }: Params): HarnessTool return { 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 .object({ bot_id: z.number().int(), diff --git a/src/harness/types.ts b/src/harness/types.ts index 797175b..aa50157 100644 --- a/src/harness/types.ts +++ b/src/harness/types.ts @@ -20,6 +20,15 @@ export type HarnessToolCall = { 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 = | { role: "system"; content: string } | { role: "user"; content: string | HarnessContentPart[] } diff --git a/src/utils/strip-app-records.test.ts b/src/utils/strip-app-records.test.ts new file mode 100644 index 0000000..a0ac6dd --- /dev/null +++ b/src/utils/strip-app-records.test.ts @@ -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?"); + }); +}); diff --git a/src/utils/strip-app-records.ts b/src/utils/strip-app-records.ts new file mode 100644 index 0000000..0e522ab --- /dev/null +++ b/src/utils/strip-app-records.ts @@ -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(); +}