Skip to content

Commit 526d243

Browse files
fix(agent): point doom-loop warning at an on-wire switch first (#1107)
* fix(agent): point doom-loop warning at an on-wire switch first * fix(agent): never name tool_search as the doom-loop example
1 parent 810fd77 commit 526d243

2 files changed

Lines changed: 66 additions & 8 deletions

File tree

‎src/agent/doom-loop-note.test.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,48 @@ test("the note offers both escape hatches and the consequence", () => {
2424
expect(note).toMatch(/ends this run/i);
2525
});
2626

27+
test("the note offers an on-wire switch before the tool_search fallback", () => {
28+
const note = createDoomLoopCorrectiveNote(() => [
29+
"manage_tasks",
30+
"read_file",
31+
"run_shell",
32+
])(repeat);
33+
// First escape names a non-looped tool already on the wire...
34+
expect(note).toMatch(/already on the wire instead \(for example read_file\)/);
35+
// ...positioned ahead of the tool_search / reply-to-operator fallback.
36+
expect(note.indexOf("for example read_file")).toBeLessThan(
37+
note.indexOf("tool_search"),
38+
);
39+
expect(note).toMatch(/reply to the operator/i);
40+
});
41+
42+
test("the note skips tool_search when another remaining tool is on the wire", () => {
43+
const note = createDoomLoopCorrectiveNote(() => [
44+
"manage_tasks",
45+
"tool_search",
46+
"read_file",
47+
])(repeat);
48+
expect(note).toMatch(/already on the wire instead \(for example read_file\)/);
49+
expect(note).not.toMatch(/for example tool_search/);
50+
});
51+
52+
test("the note uses the fallback when only tool_search remains", () => {
53+
const note = createDoomLoopCorrectiveNote(() => [
54+
"manage_tasks",
55+
"tool_search",
56+
])(repeat);
57+
expect(note).not.toMatch(/for example tool_search/);
58+
expect(note).toContain("call tool_search to discover a different tool");
59+
expect(note).not.toMatch(/already on the wire instead/);
60+
});
61+
62+
test("the note uses the fallback when every advertised name is looped", () => {
63+
const note = createDoomLoopCorrectiveNote(() => ["manage_tasks"])(repeat);
64+
expect(note).not.toMatch(/for example /);
65+
expect(note).not.toMatch(/already on the wire instead/);
66+
expect(note).toContain("call tool_search to discover a different tool");
67+
});
68+
2769
test("the note reads the wire list lazily per invocation", () => {
2870
let wire: string[] = ["read_file"];
2971
const builder = createDoomLoopCorrectiveNote(() => wire);

‎src/agent/doom-loop-note.ts‎

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,21 +8,37 @@ type Repeat = {
88

99
// Corrective note for the doom-loop guard's warning turn (repeat count
1010
// threshold−1): tells the model the exact call already ran unchanged, shows
11-
// what else is on the wire, and points at the two escape hatches. The wire
12-
// list is read lazily so tool_search activations mid-run are reflected.
11+
// what else is on the wire, and points at the escape hatches — switching to a
12+
// different tool already on the wire first, tool_search and replying to the
13+
// operator as fallback. The wire list is read lazily so tool_search
14+
// activations mid-run are reflected.
1315
export function createDoomLoopCorrectiveNote(
1416
wireToolNames: () => readonly string[],
1517
): (repeat: Repeat) => string {
1618
return ({ calls }) => {
17-
const repeated = [...new Set(calls.map((c) => c.name))].join(", ");
18-
const wire = wireToolNames().join(", ");
19+
const looped = new Set(calls.map((c) => c.name));
20+
const repeated = [...looped].join(", ");
21+
const names = wireToolNames();
22+
const wire = names.join(", ");
23+
// Name one non-looped tool already on the wire as the first escape, so
24+
// the model switches instead of repeating. tool_search stays a fallback
25+
// rather than the example — it is named in the fallback sentence.
26+
const example = names.find(
27+
(name) => !looped.has(name) && name !== "tool_search",
28+
);
29+
const escape =
30+
example === undefined
31+
? `Do not call this batch again — call tool_search to discover a ` +
32+
`different tool, or reply to the operator describing what you need.`
33+
: `Do not call this batch again — call a different tool already on ` +
34+
`the wire instead (for example ${example}). If none of those fits, ` +
35+
`call tool_search to discover a different tool or reply to the ` +
36+
`operator describing what you need.`;
1937
return (
2038
`Loop guard: this exact call (${repeated}) already ran with the same ` +
2139
`arguments and returned the same result — calling it again changes ` +
22-
`nothing. Tools currently on the wire: ${wire}. Do not call this batch ` +
23-
`again — reply to the operator describing what you need, or call ` +
24-
`tool_search to discover a different tool. The next identical repeat ` +
25-
`ends this run.`
40+
`nothing. Tools currently on the wire: ${wire}. ${escape} The next ` +
41+
`identical repeat ends this run.`
2642
);
2743
};
2844
}

0 commit comments

Comments
 (0)