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
@@ -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[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();
+22 -7
View File
@@ -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 });