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 });
|
||||
|
||||
Reference in New Issue
Block a user