From 08f0dd2260149522ae5bc6763cd2d76d1289dbe9 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 10 Sep 2026 21:59:36 -0700 Subject: [PATCH] Warn on repeated tool batches before the doom loop trips A model repeating an always-successful call saw only plain results until the guard killed the whole reactor, so the primary session wedged on the operator's next message. The second identical batch now gets a corrective note naming the repeated call, the tools on the wire, and the escape hatches; a tripped guard under doomLoopPolicy "fail-run" fails only the message run. manage_tasks answers repeat no-op updates distinctly. --- src/agent/codex-tool-proxies.test.ts | 14 ++- src/agent/codex-tool-proxies.ts | 10 +- src/agent/doom-loop-note.test.ts | 32 ++++++ src/agent/doom-loop-note.ts | 28 +++++ src/agent/tasks.ts | 72 ++++++++++++ src/agent/tools.ts | 14 +-- src/session/assemble-runtime.ts | 7 ++ src/subagent/run-codex-proxy.test.ts | 8 +- src/subagent/run.ts | 18 +-- tests/unit/agent/tasks.test.ts | 51 ++++++++ vendor/intx-inference/PATCHES.md | 18 +++ vendor/intx-inference/src/harness.ts | 13 +++ vendor/intx-inference/src/reactor.test.ts | 134 ++++++++++++++++++++++ vendor/intx-inference/src/reactor.ts | 56 ++++++++- 14 files changed, 429 insertions(+), 46 deletions(-) create mode 100644 src/agent/doom-loop-note.test.ts create mode 100644 src/agent/doom-loop-note.ts diff --git a/src/agent/codex-tool-proxies.test.ts b/src/agent/codex-tool-proxies.test.ts index f913350f6..306789643 100644 --- a/src/agent/codex-tool-proxies.test.ts +++ b/src/agent/codex-tool-proxies.test.ts @@ -8,11 +8,15 @@ import { allowShellFromCapabilities, createCodexToolProxies, type CodexReadRawFile, - type CodexRunManageTasks, type CodexRunTool, } from "./codex-tool-proxies.js"; import { DOCS_TOOLS, BUILD_TOOLS } from "./directors/tool-sets.js"; -import { applyManageTasks, parseManageTasksArgs, type Task } from "./tasks.js"; +import { + applyManageTasks, + parseManageTasksArgs, + type ManageTasksRunner, + type Task, +} from "./tasks.js"; interface Call { name: string; @@ -67,7 +71,7 @@ function makeRecorder(initial: Record = {}): { return { calls, files, runTool, readRawFile }; } -const unusedManageTasks: CodexRunManageTasks = async () => ({ +const unusedManageTasks: ManageTasksRunner = async () => ({ content: "unused", }); const unusedReadRawFile: CodexReadRawFile = async () => ({ content: "unused" }); @@ -79,11 +83,11 @@ const unusedReadRawFile: CodexReadRawFile = async () => ({ content: "unused" }); function makeRealManageTasks(): { calls: Record[]; getTasks: () => Task[]; - runManageTasks: CodexRunManageTasks; + runManageTasks: ManageTasksRunner; } { let tasks: Task[] = []; const calls: Record[] = []; - const runManageTasks: CodexRunManageTasks = async (rawArgs) => { + const runManageTasks: ManageTasksRunner = async (rawArgs) => { calls.push(rawArgs); const parsed = parseManageTasksArgs(rawArgs); if (parsed === null) { diff --git a/src/agent/codex-tool-proxies.ts b/src/agent/codex-tool-proxies.ts index 20ae4e1bd..9f0852c8a 100644 --- a/src/agent/codex-tool-proxies.ts +++ b/src/agent/codex-tool-proxies.ts @@ -15,7 +15,7 @@ import { parseCodexApplyPatch, type PatchOp, } from "./codex-apply-patch.js"; -import type { TaskStatus } from "./tasks.js"; +import type { ManageTasksRunner, TaskStatus } from "./tasks.js"; export type CodexRunTool = ( name: string, @@ -40,10 +40,6 @@ export type CodexReadRawFile = ( * wired at each mount site (src/agent/tools.ts, src/subagent/run.ts) to the * exact same manage_tasks stringTool handler that site installs. */ -export type CodexRunManageTasks = ( - args: Record, -) => Promise<{ content: string; isError?: boolean }>; - export interface CreateCodexToolProxiesOpts { isCodex: boolean; runTool: CodexRunTool; @@ -54,7 +50,7 @@ export interface CreateCodexToolProxiesOpts { */ readRawFile: CodexReadRawFile; /** Dispatches update_plan's translated manage_tasks(action="create") call. */ - runManageTasks: CodexRunManageTasks; + runManageTasks: ManageTasksRunner; /** * When false, Delete File and Update+Move refuse without calling `delete_file`. * Defaults to true (implement / unconstrained). Pass false when the @@ -459,7 +455,7 @@ function codexPlanStatusToTaskStatus( return "done"; } -function createUpdatePlanProxy(runManageTasks: CodexRunManageTasks): AgentTool { +function createUpdatePlanProxy(runManageTasks: ManageTasksRunner): AgentTool { return stringTool({ definition: updatePlanDefinition, handler: async (rawArgs: Record): Promise => { diff --git a/src/agent/doom-loop-note.test.ts b/src/agent/doom-loop-note.test.ts new file mode 100644 index 000000000..e27261ad5 --- /dev/null +++ b/src/agent/doom-loop-note.test.ts @@ -0,0 +1,32 @@ +import { test, expect } from "bun:test"; + +import { createDoomLoopCorrectiveNote } from "./doom-loop-note.js"; + +const repeat = { + calls: [{ id: "c1", name: "manage_tasks", arguments: { action: "update" } }], + repeatCount: 2, + threshold: 3, +}; + +test("the note names the repeated calls and the tools on the wire", () => { + const note = createDoomLoopCorrectiveNote(() => ["read_file", "run_shell"])( + repeat, + ); + expect(note).toContain("manage_tasks"); + expect(note).toContain("read_file"); + expect(note).toContain("run_shell"); +}); + +test("the note offers both escape hatches and the consequence", () => { + const note = createDoomLoopCorrectiveNote(() => [])(repeat); + expect(note).toContain("tool_search"); + expect(note).toMatch(/reply to the operator/i); + expect(note).toMatch(/ends this run/i); +}); + +test("the note reads the wire list lazily per invocation", () => { + let wire: string[] = ["read_file"]; + const builder = createDoomLoopCorrectiveNote(() => wire); + wire = ["read_file", "newly_activated"]; + expect(builder(repeat)).toContain("newly_activated"); +}); diff --git a/src/agent/doom-loop-note.ts b/src/agent/doom-loop-note.ts new file mode 100644 index 000000000..e056b7e83 --- /dev/null +++ b/src/agent/doom-loop-note.ts @@ -0,0 +1,28 @@ +import type { ToolCall } from "@intx/types/runtime"; + +type Repeat = { + calls: readonly ToolCall[]; + repeatCount: number; + threshold: number; +}; + +// 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. +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(", "); + 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.` + ); + }; +} diff --git a/src/agent/tasks.ts b/src/agent/tasks.ts index 2b919a37c..7c8048cc3 100644 --- a/src/agent/tasks.ts +++ b/src/agent/tasks.ts @@ -116,6 +116,78 @@ export function parseManageTasksArgs(rawArgs: unknown): ManageTasksArgs | null { return result instanceof type.errors ? null : result; } +function tasksEqual(a: Task[], b: Task[]): boolean { + return ( + a.length === b.length && + a.every((t, i) => { + const o = b[i]; + return ( + o !== undefined && + t.id === o.id && + t.title === o.title && + t.status === o.status + ); + }) + ); +} + +// Returns the model-facing note when a call changes nothing, else null. +function describeManageTasksNoOp( + current: Task[], + args: ManageTasksArgs, +): string | null { + if (args.action === "create") { + return tasksEqual(current, applyManageTasks(current, args)) + ? "No change: task list already matches." + : null; + } + const updates = args.updates ?? []; + if (updates.length === 0) return "No change: no updates supplied."; + const noOps: string[] = []; + let changed = false; + for (const patch of updates) { + const existing = current.find((t) => t.id === patch.id); + if (existing === undefined) { + if (patch.title !== undefined && patch.title.length > 0) changed = true; + else noOps.push(`${patch.id} not found`); + } else if ( + (patch.title ?? existing.title) === existing.title && + (patch.status ?? existing.status) === existing.status + ) { + noOps.push(`${patch.id} already ${existing.status}`); + } else { + changed = true; + } + } + return changed ? null : `No change: ${noOps.join("; ")}.`; +} + +export type ManageTasksRunner = (rawArgs: Record) => Promise<{ + content: string; + isError?: boolean; +}>; + +// Task state is owned by the director, which applies each manage_tasks call +// when it observes the tool_call — so by the time this handler runs, the +// authoritative list already includes the update and a diff against it would +// always read as a no-op. The runner keeps its own copy so a repeat call that +// changes nothing gets a distinct result instead of "Tasks updated." +export function createManageTasksRunner(): ManageTasksRunner { + let tasks: Task[] = []; + return async (rawArgs) => { + const parsed = parseManageTasksArgs(rawArgs); + if (parsed === null) { + return { + content: "Error: manage_tasks requires action ('create' or 'update').", + isError: true, + }; + } + const noOp = describeManageTasksNoOp(tasks, parsed); + tasks = applyManageTasks(tasks, parsed); + return { content: noOp ?? "Tasks updated." }; + }; +} + // Apply a parsed call to a task list, returning the full list. Completed tasks // are retained so the task view can show them checked off as work progresses. export function applyManageTasks( diff --git a/src/agent/tools.ts b/src/agent/tools.ts index f8fbb51c6..18d329440 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -81,7 +81,7 @@ import { createInterruptAgentTool, createSendInputTool, } from "../subagent/lifecycle-tools.js"; -import { parseManageTasksArgs } from "./tasks.js"; +import { createManageTasksRunner } from "./tasks.js"; import { createShellCollectTool, createSpillingBackgroundShellExitNotifier, @@ -106,7 +106,6 @@ import { createSearchAgentsTool } from "./agent-search.js"; import { createReadAgentTraceTool } from "../subagent/trace-tool.js"; import { createCodexToolProxies, - type CodexRunManageTasks, type CodexRunTool, } from "./codex-tool-proxies.js"; import { createCodexReadRawFile } from "./codex-read-raw-file.js"; @@ -470,16 +469,7 @@ export async function createAgentToolset( // from this handler's return value. This handler only validates, so // update_plan's proxy shares it rather than forwarding through posixTools // (which has no manage_tasks handler to forward to). - const runManageTasks: CodexRunManageTasks = async (rawArgs) => { - const parsed = parseManageTasksArgs(rawArgs); - if (parsed === null) { - return { - content: "Error: manage_tasks requires action ('create' or 'update').", - isError: true, - }; - } - return { content: "Tasks updated." }; - }; + const runManageTasks = createManageTasksRunner(); // Align the advertised run_shell timeout with shell-guard (no built-in default; // advertise settings.shell.timeoutMs when set). diff --git a/src/session/assemble-runtime.ts b/src/session/assemble-runtime.ts index 8ca57bd3e..7359c47d9 100644 --- a/src/session/assemble-runtime.ts +++ b/src/session/assemble-runtime.ts @@ -49,6 +49,7 @@ import { } from "../agent/tool-search.js"; import { normalizeToolDefinitionsForProvider } from "../agent/tool-schema-normalize.js"; import { createChatDirector, type ChatDirector } from "../agent/director.js"; +import { createDoomLoopCorrectiveNote } from "../agent/doom-loop-note.js"; import type { Task } from "../agent/tasks.js"; import type { AgentToolset } from "../agent/tools.js"; import { createAgentWithLiveToolDispatch } from "../agent/live-tool-dispatch.js"; @@ -531,6 +532,12 @@ export function assembleChatAgent(wiring: ChatAgentWiring): AssembledChatAgent { // transforms up from there. deps: { ...wiring.inferenceDeps, + doomLoopPolicy: "fail-run", + doomLoopCorrectiveNote: createDoomLoopCorrectiveNote(() => + wiring + .computeAdvertised(wiring.getDynamicRunner().currentDefinitions()) + .map((def) => def.name), + ), contextTransforms: [ createAttachmentRehydrateTransform((key) => storageForAgent.readBlob(key), diff --git a/src/subagent/run-codex-proxy.test.ts b/src/subagent/run-codex-proxy.test.ts index bb3051234..158d606f3 100644 --- a/src/subagent/run-codex-proxy.test.ts +++ b/src/subagent/run-codex-proxy.test.ts @@ -6,10 +6,8 @@ import { type ToolResult, } from "@intx/types/runtime"; -import { - createCodexToolProxies, - type CodexRunManageTasks, -} from "../agent/codex-tool-proxies.js"; +import { createCodexToolProxies } from "../agent/codex-tool-proxies.js"; +import type { ManageTasksRunner } from "../agent/tasks.js"; import { MAX_RESULT_CHARS, truncateToolResultContent, @@ -31,7 +29,7 @@ function fakeBlobStore() { }; } -const unusedManageTasks: CodexRunManageTasks = async () => ({ +const unusedManageTasks: ManageTasksRunner = async () => ({ content: "unused", }); diff --git a/src/subagent/run.ts b/src/subagent/run.ts index 928277ccc..c0c7a488e 100644 --- a/src/subagent/run.ts +++ b/src/subagent/run.ts @@ -58,7 +58,6 @@ import { allowDeleteFromCapabilities, allowShellFromCapabilities, createCodexToolProxies, - type CodexRunManageTasks, type CodexRunTool, } from "../agent/codex-tool-proxies.js"; import { createCodexReadRawFile } from "../agent/codex-read-raw-file.js"; @@ -107,7 +106,10 @@ import type { CapabilityFilter } from "../agent/profiles.js"; import type { Settings } from "../config/settings.js"; import { toolWatchdogFromSettings } from "../config/settings.js"; import { createSearchAgentsTool } from "../agent/agent-search.js"; -import { manageTasksDefinition, parseManageTasksArgs } from "../agent/tasks.js"; +import { + createManageTasksRunner, + manageTasksDefinition, +} from "../agent/tasks.js"; import { ID_PREFIX } from "../branding.js"; import { appendActivitySummary, @@ -686,17 +688,7 @@ async function runSubAgentInner( // director.ts). This handler only validates, so update_plan's proxy shares // it rather than forwarding through posixTools (which has no manage_tasks // handler to forward to). - const runManageTasks: CodexRunManageTasks = async (rawArgs) => { - const parsed = parseManageTasksArgs(rawArgs); - if (parsed === null) { - return { - content: - "Error: manage_tasks requires action ('create' or 'update').", - isError: true, - }; - } - return { content: "Tasks updated." }; - }; + const runManageTasks = createManageTasksRunner(); tools = [ ...tools, ...createCodexToolProxies({ diff --git a/tests/unit/agent/tasks.test.ts b/tests/unit/agent/tasks.test.ts index 447d852fd..9117605e3 100644 --- a/tests/unit/agent/tasks.test.ts +++ b/tests/unit/agent/tasks.test.ts @@ -1,6 +1,7 @@ import { test, expect } from "bun:test"; import { applyManageTasks, + createManageTasksRunner, hasActiveTasks, type Task, } from "../../../src/agent/tasks.js"; @@ -107,3 +108,53 @@ test("hasActiveTasks is false for an empty or all-terminal list", () => { ]), ).toBe(false); }); + +test("manage_tasks runner reports a repeat update that changes nothing", async () => { + const run = createManageTasksRunner(); + await run({ + action: "create", + tasks: [{ id: "t8", title: "Ship it", status: "doing" }], + }); + const again = await run({ + action: "update", + updates: [{ id: "t8", status: "doing" }], + }); + expect(again.content).toBe("No change: t8 already doing."); +}); + +test("manage_tasks runner reports a repeat create that changes nothing", async () => { + const run = createManageTasksRunner(); + const args = { action: "create", tasks: [{ id: "t1", title: "One" }] }; + expect((await run(args)).content).toBe("Tasks updated."); + expect((await run(args)).content).toBe( + "No change: task list already matches.", + ); +}); + +test("manage_tasks runner still reports real updates as updated", async () => { + const run = createManageTasksRunner(); + await run({ + action: "create", + tasks: [{ id: "t1", title: "One", status: "doing" }], + }); + expect( + (await run({ action: "update", updates: [{ id: "t1", status: "done" }] })) + .content, + ).toBe("Tasks updated."); +}); + +test("manage_tasks runner reports updates against unknown or empty input", async () => { + const run = createManageTasksRunner(); + expect((await run({ action: "update" })).content).toBe( + "No change: no updates supplied.", + ); + expect( + (await run({ action: "update", updates: [{ id: "t9" }] })).content, + ).toBe("No change: t9 not found."); +}); + +test("manage_tasks runner rejects invalid args", async () => { + const run = createManageTasksRunner(); + const result = await run({}); + expect(result.isError).toBe(true); +}); diff --git a/vendor/intx-inference/PATCHES.md b/vendor/intx-inference/PATCHES.md index 3fed7ea14..1947c2602 100644 --- a/vendor/intx-inference/PATCHES.md +++ b/vendor/intx-inference/PATCHES.md @@ -346,6 +346,24 @@ persist/stage/commit/replaceTurns. **Re-carry:** new patch on this pin; expect a three-way against `executeCompact` and `commitCycle` on the next sync. +## reactor-ts-doom-loop-warning-turn + +`harness.ts`, `reactor.ts` — `deps.doomLoopCorrectiveNote` builds a note +appended to each tool result when a repeated batch reaches +`doomLoopThreshold - 1`, so the model sees the exact call already ran before +the fatal trip. Unset appends nothing, same as upstream. **Disposition:** +Re-carryable — sits inside the doom-loop accounting block in `executeTools`. + +## reactor-ts-doom-loop-fail-run + +`harness.ts`, `reactor.ts` — `deps.doomLoopPolicy: "fail-run"` makes a +tripped guard close the message run and purge the doomed batch's queued +continuations instead of shutting the reactor down; the next inbound message +opens a fresh run. Upstream's fatal exit wedges a long-lived session — +`deliver` drops every later message. Unset is `"shutdown"`, same as +upstream; workers keep it. **Disposition:** Re-carryable — sits at the +doom-loop fatal break in the main loop. + ## sse-ts-max-line-length `sse.ts` — `MAX_LINE_LENGTH` (16 MiB) caps the unterminated SSE line buffer diff --git a/vendor/intx-inference/src/harness.ts b/vendor/intx-inference/src/harness.ts index 7d4c71495..adeb0536c 100644 --- a/vendor/intx-inference/src/harness.ts +++ b/vendor/intx-inference/src/harness.ts @@ -151,6 +151,19 @@ export type Dependencies = { * Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-doom-loop-poll-exemption */ readonly isPollOnlyPendingBatch?: PollBatchLivenessPredicate; + // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-doom-loop-warning-turn + // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-doom-loop-fail-run + /** Note appended to each tool result when a batch repeat hits threshold−1. */ + readonly doomLoopCorrectiveNote?: (repeat: { + calls: readonly ToolCall[]; + repeatCount: number; + threshold: number; + }) => string | undefined; + /** + * `"fail-run"` closes the tripped message run and returns the reactor to + * idle instead of shutting down. Unset is `"shutdown"`, same as upstream. + */ + readonly doomLoopPolicy?: "shutdown" | "fail-run"; readonly [HarnessId]?: symbol; }; diff --git a/vendor/intx-inference/src/reactor.test.ts b/vendor/intx-inference/src/reactor.test.ts index 23cdf194b..420017318 100644 --- a/vendor/intx-inference/src/reactor.test.ts +++ b/vendor/intx-inference/src/reactor.test.ts @@ -1793,6 +1793,140 @@ describe("createReactor — doom-loop detection", () => { "doom_loop", ); }); + + test("appends the corrective note once, on the batch before the trip", async () => { + const contents: string[] = []; + const noteCalls: number[] = []; + const { reactor, events, waitFor } = createTestReactor({ + deps: { + ...createDefaultDependencies(), + doomLoopCorrectiveNote: ({ calls, repeatCount }) => { + noteCalls.push(repeatCount); + return `loop note: ${calls.map((c) => c.name).join(",")}`; + }, + }, + toolRunner: makeToolRunner(async (call) => ({ + callId: call.id, + content: "spun", + })), + director: (() => { + let turn = 0; + const batch = () => [ + { id: `c${turn}`, name: "spin", arguments: { q: 1 } }, + ]; + return directorFromTable( + { + "message.received": (_e, _s, caps) => caps.executeTools(batch()), + "tool.done": (e, _s, caps) => { + contents.push( + typeof e.result.content === "string" + ? e.result.content + : JSON.stringify(e.result.content), + ); + turn += 1; + return turn < 8 ? caps.executeTools(batch()) : caps.done(); + }, + }, + "wait", + ); + })(), + }); + + reactor.start(); + reactor.deliver(makeInboundMessage()); + await waitFor("reactor.done"); + + // One warning turn at repeat count 2 (threshold 3 − 1): only that + // batch's result carried the note; the first ran clean and the third + // tripped the guard before its result could be consumed. + expect(noteCalls).toEqual([2]); + expect(contents).toEqual(["spun", "spun\n\nloop note: spin"]); + expect(getEvent(events, "message.run.ended").data.error?.kind).toBe( + "doom_loop", + ); + }); + + test("a threshold of 2 leaves no room for a warning turn", async () => { + let noteCalls = 0; + const { reactor, events, waitFor } = createTestReactor({ + doomLoopThreshold: 2, + deps: { + ...createDefaultDependencies(), + doomLoopCorrectiveNote: () => { + noteCalls += 1; + return "note"; + }, + }, + director: createBatchLoopDirector((turn) => + turn < 8 + ? [{ id: `c${turn}`, name: "spin", arguments: { q: 1 } }] + : null, + ), + }); + + reactor.start(); + reactor.deliver(makeInboundMessage()); + await waitFor("reactor.done"); + + expect(noteCalls).toBe(0); + expect(getEvent(events, "message.run.ended").data.error?.kind).toBe( + "doom_loop", + ); + }); + + test("fail-run policy ends the run but keeps the reactor for the next message", async () => { + const seen: string[] = []; + const { reactor, events, waitFor } = createTestReactor({ + deps: { + ...createDefaultDependencies(), + doomLoopPolicy: "fail-run", + }, + director: (() => { + let turn = 0; + const batch = () => [ + { id: `c${turn}`, name: "spin", arguments: { q: 1 } }, + ]; + return directorFromTable( + { + "message.received": (_e, _s, caps) => caps.executeTools(batch()), + "tool.done": (e, _s, caps) => { + seen.push(String(e.result.content)); + turn += 1; + return caps.executeTools(batch()); + }, + }, + "wait", + ); + })(), + }); + + reactor.start(); + reactor.deliver(makeInboundMessage()); + await waitFor("message.run.ended"); + + const ended = getEvent(events, "message.run.ended"); + expect(ended.data.status).toBe("failed"); + expect(ended.data.error?.kind).toBe("doom_loop"); + expect(getEvent(events, "reactor.error").data.fatal).toBe(true); + // The doomed batch's queued tool.done events were purged, not consumed: + // the director saw only the two repeats that ran before the trip. + expect(seen.length).toBe(2); + expect(events.some((e) => e.type === "reactor.done")).toBe(false); + + // The next inbound message opens a fresh run — which doom-loops again + // and fails the same way, proving the reactor kept working. + reactor.deliver(makeInboundMessage()); + await waitForEvent( + events, + (e) => + e.type === "message.run.ended" && + events.filter((x) => x.type === "message.run.ended").length >= 2, + ); + expect(events.filter((e) => e.type === "message.run.started").length).toBe( + 2, + ); + expect(events.some((e) => e.type === "reactor.done")).toBe(false); + }); }); describe("createReactor — doom-loop poll exemption", () => { diff --git a/vendor/intx-inference/src/reactor.ts b/vendor/intx-inference/src/reactor.ts index 61f3422a0..d77954aa7 100644 --- a/vendor/intx-inference/src/reactor.ts +++ b/vendor/intx-inference/src/reactor.ts @@ -261,6 +261,11 @@ export function createReactor(config: ReactorConfig): Reactor { const isPollOnlyPendingBatch = config.isPollOnlyPendingBatch ?? deps.isPollOnlyPendingBatch; + // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-doom-loop-warning-turn + // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-doom-loop-fail-run + const doomLoopCorrectiveNote = deps.doomLoopCorrectiveNote; + const doomLoopPolicy = deps.doomLoopPolicy ?? "shutdown"; + // Monotonic sequence counter, scoped to this session. let seq = 0; function nextSeq(): number { @@ -999,13 +1004,40 @@ export function createReactor(config: ReactorConfig): Reactor { lastToolBatchNames = ranCalls.map((call) => call.name); } - cycleToolCallsExecuted += results.length; + // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-doom-loop-warning-turn + // One warning turn before the fatal trip: a repeat at count threshold-1 + // gets a corrective note appended so the model can see it is looping. + // `>= 2` keeps threshold 2 from annotating a batch's first execution. + let annotatedResults = results; + if ( + doomLoopThreshold !== null && + toolBatchRepeatCount >= 2 && + toolBatchRepeatCount === doomLoopThreshold - 1 && + doomLoopCorrectiveNote !== undefined + ) { + const note = doomLoopCorrectiveNote({ + calls: ranCalls, + repeatCount: toolBatchRepeatCount, + threshold: doomLoopThreshold, + }); + if (note !== undefined && note.length > 0) { + annotatedResults = results.map((result) => ({ + ...result, + content: + typeof result.content === "string" + ? `${result.content}\n\n${note}` + : { ...result.content, doom_loop_warning: note }, + })); + } + } + + cycleToolCallsExecuted += annotatedResults.length; - if (addToHistory && stateManager !== null && results.length > 0) { - stateManager.appendTurn(createToolResultTurn(results)); + if (addToHistory && stateManager !== null && annotatedResults.length > 0) { + stateManager.appendTurn(createToolResultTurn(annotatedResults)); } - for (const result of results) { + for (const result of annotatedResults) { enqueue({ type: "tool.done", result }); } @@ -1595,6 +1627,22 @@ export function createReactor(config: ReactorConfig): Reactor { `${String(doomLoopThreshold)} times consecutively`; emitError(message, true); closeMessageRun("failed", { message, kind: "doom_loop" }); + // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-doom-loop-fail-run + if (doomLoopPolicy === "fail-run") { + // The run is dead but the reactor is not: drop the doomed batch's + // queued cycle events so the director never sees a tool.done that + // would re-infer, then return to idle. Non-cycle events already + // queued (inbound mail, gate clears) still process normally, and + // the next message.received opens a fresh run bracket. + for (let i = queue.length - 1; i >= 0; i--) { + const queued = queue[i]; + if (queued !== undefined && CYCLE_EVENT_TYPES.has(queued.type)) { + queue.splice(i, 1); + } + } + pendingContinuations = 0; + continue; + } done = true; await initiateShutdown(); break;