diff --git a/src/agent/doom-loop-note.test.ts b/src/agent/doom-loop-note.test.ts index e27261ad5..c630514fc 100644 --- a/src/agent/doom-loop-note.test.ts +++ b/src/agent/doom-loop-note.test.ts @@ -24,6 +24,48 @@ test("the note offers both escape hatches and the consequence", () => { expect(note).toMatch(/ends this run/i); }); +test("the note offers an on-wire switch before the tool_search fallback", () => { + const note = createDoomLoopCorrectiveNote(() => [ + "manage_tasks", + "read_file", + "run_shell", + ])(repeat); + // First escape names a non-looped tool already on the wire... + expect(note).toMatch(/already on the wire instead \(for example read_file\)/); + // ...positioned ahead of the tool_search / reply-to-operator fallback. + expect(note.indexOf("for example read_file")).toBeLessThan( + note.indexOf("tool_search"), + ); + expect(note).toMatch(/reply to the operator/i); +}); + +test("the note skips tool_search when another remaining tool is on the wire", () => { + const note = createDoomLoopCorrectiveNote(() => [ + "manage_tasks", + "tool_search", + "read_file", + ])(repeat); + expect(note).toMatch(/already on the wire instead \(for example read_file\)/); + expect(note).not.toMatch(/for example tool_search/); +}); + +test("the note uses the fallback when only tool_search remains", () => { + const note = createDoomLoopCorrectiveNote(() => [ + "manage_tasks", + "tool_search", + ])(repeat); + expect(note).not.toMatch(/for example tool_search/); + expect(note).toContain("call tool_search to discover a different tool"); + expect(note).not.toMatch(/already on the wire instead/); +}); + +test("the note uses the fallback when every advertised name is looped", () => { + const note = createDoomLoopCorrectiveNote(() => ["manage_tasks"])(repeat); + expect(note).not.toMatch(/for example /); + expect(note).not.toMatch(/already on the wire instead/); + expect(note).toContain("call tool_search to discover a different tool"); +}); + test("the note reads the wire list lazily per invocation", () => { let wire: string[] = ["read_file"]; const builder = createDoomLoopCorrectiveNote(() => wire); diff --git a/src/agent/doom-loop-note.ts b/src/agent/doom-loop-note.ts index e056b7e83..201341098 100644 --- a/src/agent/doom-loop-note.ts +++ b/src/agent/doom-loop-note.ts @@ -8,21 +8,37 @@ type Repeat = { // Corrective note for the doom-loop guard's warning turn (repeat count // threshold−1): tells the model the exact call already ran unchanged, shows -// what else is on the wire, and points at the two escape hatches. The wire -// list is read lazily so tool_search activations mid-run are reflected. +// what else is on the wire, and points at the escape hatches — switching to a +// different tool already on the wire first, tool_search and replying to the +// operator as fallback. The wire list is read lazily so tool_search +// activations mid-run are reflected. export function createDoomLoopCorrectiveNote( wireToolNames: () => readonly string[], ): (repeat: Repeat) => string { return ({ calls }) => { - const repeated = [...new Set(calls.map((c) => c.name))].join(", "); - const wire = wireToolNames().join(", "); + const looped = new Set(calls.map((c) => c.name)); + const repeated = [...looped].join(", "); + const names = wireToolNames(); + const wire = names.join(", "); + // Name one non-looped tool already on the wire as the first escape, so + // the model switches instead of repeating. tool_search stays a fallback + // rather than the example — it is named in the fallback sentence. + const example = names.find( + (name) => !looped.has(name) && name !== "tool_search", + ); + const escape = + example === undefined + ? `Do not call this batch again — call tool_search to discover a ` + + `different tool, or reply to the operator describing what you need.` + : `Do not call this batch again — call a different tool already on ` + + `the wire instead (for example ${example}). If none of those fits, ` + + `call tool_search to discover a different tool or reply to the ` + + `operator describing what you need.`; return ( `Loop guard: this exact call (${repeated}) already ran with the same ` + `arguments and returned the same result — calling it again changes ` + - `nothing. Tools currently on the wire: ${wire}. Do not call this batch ` + - `again — reply to the operator describing what you need, or call ` + - `tool_search to discover a different tool. The next identical repeat ` + - `ends this run.` + `nothing. Tools currently on the wire: ${wire}. ${escape} The next ` + + `identical repeat ends this run.` ); }; }