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:
@@ -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)");
|
||||
});
|
||||
});
|
||||
@@ -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(", ");
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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[] }
|
||||
|
||||
Reference in New Issue
Block a user