Skip to content

Commit 08f0dd2

Browse files
committed
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.
1 parent 36d5369 commit 08f0dd2

14 files changed

Lines changed: 429 additions & 46 deletions

File tree

‎src/agent/codex-tool-proxies.test.ts‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,15 @@ import {
88
allowShellFromCapabilities,
99
createCodexToolProxies,
1010
type CodexReadRawFile,
11-
type CodexRunManageTasks,
1211
type CodexRunTool,
1312
} from "./codex-tool-proxies.js";
1413
import { DOCS_TOOLS, BUILD_TOOLS } from "./directors/tool-sets.js";
15-
import { applyManageTasks, parseManageTasksArgs, type Task } from "./tasks.js";
14+
import {
15+
applyManageTasks,
16+
parseManageTasksArgs,
17+
type ManageTasksRunner,
18+
type Task,
19+
} from "./tasks.js";
1620

1721
interface Call {
1822
name: string;
@@ -67,7 +71,7 @@ function makeRecorder(initial: Record<string, string> = {}): {
6771
return { calls, files, runTool, readRawFile };
6872
}
6973

70-
const unusedManageTasks: CodexRunManageTasks = async () => ({
74+
const unusedManageTasks: ManageTasksRunner = async () => ({
7175
content: "unused",
7276
});
7377
const unusedReadRawFile: CodexReadRawFile = async () => ({ content: "unused" });
@@ -79,11 +83,11 @@ const unusedReadRawFile: CodexReadRawFile = async () => ({ content: "unused" });
7983
function makeRealManageTasks(): {
8084
calls: Record<string, unknown>[];
8185
getTasks: () => Task[];
82-
runManageTasks: CodexRunManageTasks;
86+
runManageTasks: ManageTasksRunner;
8387
} {
8488
let tasks: Task[] = [];
8589
const calls: Record<string, unknown>[] = [];
86-
const runManageTasks: CodexRunManageTasks = async (rawArgs) => {
90+
const runManageTasks: ManageTasksRunner = async (rawArgs) => {
8791
calls.push(rawArgs);
8892
const parsed = parseManageTasksArgs(rawArgs);
8993
if (parsed === null) {

‎src/agent/codex-tool-proxies.ts‎

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
parseCodexApplyPatch,
1616
type PatchOp,
1717
} from "./codex-apply-patch.js";
18-
import type { TaskStatus } from "./tasks.js";
18+
import type { ManageTasksRunner, TaskStatus } from "./tasks.js";
1919

2020
export type CodexRunTool = (
2121
name: string,
@@ -40,10 +40,6 @@ export type CodexReadRawFile = (
4040
* wired at each mount site (src/agent/tools.ts, src/subagent/run.ts) to the
4141
* exact same manage_tasks stringTool handler that site installs.
4242
*/
43-
export type CodexRunManageTasks = (
44-
args: Record<string, unknown>,
45-
) => Promise<{ content: string; isError?: boolean }>;
46-
4743
export interface CreateCodexToolProxiesOpts {
4844
isCodex: boolean;
4945
runTool: CodexRunTool;
@@ -54,7 +50,7 @@ export interface CreateCodexToolProxiesOpts {
5450
*/
5551
readRawFile: CodexReadRawFile;
5652
/** Dispatches update_plan's translated manage_tasks(action="create") call. */
57-
runManageTasks: CodexRunManageTasks;
53+
runManageTasks: ManageTasksRunner;
5854
/**
5955
* When false, Delete File and Update+Move refuse without calling `delete_file`.
6056
* Defaults to true (implement / unconstrained). Pass false when the
@@ -459,7 +455,7 @@ function codexPlanStatusToTaskStatus(
459455
return "done";
460456
}
461457

462-
function createUpdatePlanProxy(runManageTasks: CodexRunManageTasks): AgentTool {
458+
function createUpdatePlanProxy(runManageTasks: ManageTasksRunner): AgentTool {
463459
return stringTool({
464460
definition: updatePlanDefinition,
465461
handler: async (rawArgs: Record<string, unknown>): Promise<string> => {

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { test, expect } from "bun:test";
2+
3+
import { createDoomLoopCorrectiveNote } from "./doom-loop-note.js";
4+
5+
const repeat = {
6+
calls: [{ id: "c1", name: "manage_tasks", arguments: { action: "update" } }],
7+
repeatCount: 2,
8+
threshold: 3,
9+
};
10+
11+
test("the note names the repeated calls and the tools on the wire", () => {
12+
const note = createDoomLoopCorrectiveNote(() => ["read_file", "run_shell"])(
13+
repeat,
14+
);
15+
expect(note).toContain("manage_tasks");
16+
expect(note).toContain("read_file");
17+
expect(note).toContain("run_shell");
18+
});
19+
20+
test("the note offers both escape hatches and the consequence", () => {
21+
const note = createDoomLoopCorrectiveNote(() => [])(repeat);
22+
expect(note).toContain("tool_search");
23+
expect(note).toMatch(/reply to the operator/i);
24+
expect(note).toMatch(/ends this run/i);
25+
});
26+
27+
test("the note reads the wire list lazily per invocation", () => {
28+
let wire: string[] = ["read_file"];
29+
const builder = createDoomLoopCorrectiveNote(() => wire);
30+
wire = ["read_file", "newly_activated"];
31+
expect(builder(repeat)).toContain("newly_activated");
32+
});

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import type { ToolCall } from "@intx/types/runtime";
2+
3+
type Repeat = {
4+
calls: readonly ToolCall[];
5+
repeatCount: number;
6+
threshold: number;
7+
};
8+
9+
// Corrective note for the doom-loop guard's warning turn (repeat count
10+
// 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.
13+
export function createDoomLoopCorrectiveNote(
14+
wireToolNames: () => readonly string[],
15+
): (repeat: Repeat) => string {
16+
return ({ calls }) => {
17+
const repeated = [...new Set(calls.map((c) => c.name))].join(", ");
18+
const wire = wireToolNames().join(", ");
19+
return (
20+
`Loop guard: this exact call (${repeated}) already ran with the same ` +
21+
`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.`
26+
);
27+
};
28+
}

‎src/agent/tasks.ts‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,78 @@ export function parseManageTasksArgs(rawArgs: unknown): ManageTasksArgs | null {
116116
return result instanceof type.errors ? null : result;
117117
}
118118

119+
function tasksEqual(a: Task[], b: Task[]): boolean {
120+
return (
121+
a.length === b.length &&
122+
a.every((t, i) => {
123+
const o = b[i];
124+
return (
125+
o !== undefined &&
126+
t.id === o.id &&
127+
t.title === o.title &&
128+
t.status === o.status
129+
);
130+
})
131+
);
132+
}
133+
134+
// Returns the model-facing note when a call changes nothing, else null.
135+
function describeManageTasksNoOp(
136+
current: Task[],
137+
args: ManageTasksArgs,
138+
): string | null {
139+
if (args.action === "create") {
140+
return tasksEqual(current, applyManageTasks(current, args))
141+
? "No change: task list already matches."
142+
: null;
143+
}
144+
const updates = args.updates ?? [];
145+
if (updates.length === 0) return "No change: no updates supplied.";
146+
const noOps: string[] = [];
147+
let changed = false;
148+
for (const patch of updates) {
149+
const existing = current.find((t) => t.id === patch.id);
150+
if (existing === undefined) {
151+
if (patch.title !== undefined && patch.title.length > 0) changed = true;
152+
else noOps.push(`${patch.id} not found`);
153+
} else if (
154+
(patch.title ?? existing.title) === existing.title &&
155+
(patch.status ?? existing.status) === existing.status
156+
) {
157+
noOps.push(`${patch.id} already ${existing.status}`);
158+
} else {
159+
changed = true;
160+
}
161+
}
162+
return changed ? null : `No change: ${noOps.join("; ")}.`;
163+
}
164+
165+
export type ManageTasksRunner = (rawArgs: Record<string, unknown>) => Promise<{
166+
content: string;
167+
isError?: boolean;
168+
}>;
169+
170+
// Task state is owned by the director, which applies each manage_tasks call
171+
// when it observes the tool_call — so by the time this handler runs, the
172+
// authoritative list already includes the update and a diff against it would
173+
// always read as a no-op. The runner keeps its own copy so a repeat call that
174+
// changes nothing gets a distinct result instead of "Tasks updated."
175+
export function createManageTasksRunner(): ManageTasksRunner {
176+
let tasks: Task[] = [];
177+
return async (rawArgs) => {
178+
const parsed = parseManageTasksArgs(rawArgs);
179+
if (parsed === null) {
180+
return {
181+
content: "Error: manage_tasks requires action ('create' or 'update').",
182+
isError: true,
183+
};
184+
}
185+
const noOp = describeManageTasksNoOp(tasks, parsed);
186+
tasks = applyManageTasks(tasks, parsed);
187+
return { content: noOp ?? "Tasks updated." };
188+
};
189+
}
190+
119191
// Apply a parsed call to a task list, returning the full list. Completed tasks
120192
// are retained so the task view can show them checked off as work progresses.
121193
export function applyManageTasks(

‎src/agent/tools.ts‎

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ import {
8181
createInterruptAgentTool,
8282
createSendInputTool,
8383
} from "../subagent/lifecycle-tools.js";
84-
import { parseManageTasksArgs } from "./tasks.js";
84+
import { createManageTasksRunner } from "./tasks.js";
8585
import {
8686
createShellCollectTool,
8787
createSpillingBackgroundShellExitNotifier,
@@ -106,7 +106,6 @@ import { createSearchAgentsTool } from "./agent-search.js";
106106
import { createReadAgentTraceTool } from "../subagent/trace-tool.js";
107107
import {
108108
createCodexToolProxies,
109-
type CodexRunManageTasks,
110109
type CodexRunTool,
111110
} from "./codex-tool-proxies.js";
112111
import { createCodexReadRawFile } from "./codex-read-raw-file.js";
@@ -470,16 +469,7 @@ export async function createAgentToolset(
470469
// from this handler's return value. This handler only validates, so
471470
// update_plan's proxy shares it rather than forwarding through posixTools
472471
// (which has no manage_tasks handler to forward to).
473-
const runManageTasks: CodexRunManageTasks = async (rawArgs) => {
474-
const parsed = parseManageTasksArgs(rawArgs);
475-
if (parsed === null) {
476-
return {
477-
content: "Error: manage_tasks requires action ('create' or 'update').",
478-
isError: true,
479-
};
480-
}
481-
return { content: "Tasks updated." };
482-
};
472+
const runManageTasks = createManageTasksRunner();
483473

484474
// Align the advertised run_shell timeout with shell-guard (no built-in default;
485475
// advertise settings.shell.timeoutMs when set).

‎src/session/assemble-runtime.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import {
4949
} from "../agent/tool-search.js";
5050
import { normalizeToolDefinitionsForProvider } from "../agent/tool-schema-normalize.js";
5151
import { createChatDirector, type ChatDirector } from "../agent/director.js";
52+
import { createDoomLoopCorrectiveNote } from "../agent/doom-loop-note.js";
5253
import type { Task } from "../agent/tasks.js";
5354
import type { AgentToolset } from "../agent/tools.js";
5455
import { createAgentWithLiveToolDispatch } from "../agent/live-tool-dispatch.js";
@@ -531,6 +532,12 @@ export function assembleChatAgent(wiring: ChatAgentWiring): AssembledChatAgent {
531532
// transforms up from there.
532533
deps: {
533534
...wiring.inferenceDeps,
535+
doomLoopPolicy: "fail-run",
536+
doomLoopCorrectiveNote: createDoomLoopCorrectiveNote(() =>
537+
wiring
538+
.computeAdvertised(wiring.getDynamicRunner().currentDefinitions())
539+
.map((def) => def.name),
540+
),
534541
contextTransforms: [
535542
createAttachmentRehydrateTransform((key) =>
536543
storageForAgent.readBlob(key),

‎src/subagent/run-codex-proxy.test.ts‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,8 @@ import {
66
type ToolResult,
77
} from "@intx/types/runtime";
88

9-
import {
10-
createCodexToolProxies,
11-
type CodexRunManageTasks,
12-
} from "../agent/codex-tool-proxies.js";
9+
import { createCodexToolProxies } from "../agent/codex-tool-proxies.js";
10+
import type { ManageTasksRunner } from "../agent/tasks.js";
1311
import {
1412
MAX_RESULT_CHARS,
1513
truncateToolResultContent,
@@ -31,7 +29,7 @@ function fakeBlobStore() {
3129
};
3230
}
3331

34-
const unusedManageTasks: CodexRunManageTasks = async () => ({
32+
const unusedManageTasks: ManageTasksRunner = async () => ({
3533
content: "unused",
3634
});
3735

‎src/subagent/run.ts‎

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,6 @@ import {
5858
allowDeleteFromCapabilities,
5959
allowShellFromCapabilities,
6060
createCodexToolProxies,
61-
type CodexRunManageTasks,
6261
type CodexRunTool,
6362
} from "../agent/codex-tool-proxies.js";
6463
import { createCodexReadRawFile } from "../agent/codex-read-raw-file.js";
@@ -107,7 +106,10 @@ import type { CapabilityFilter } from "../agent/profiles.js";
107106
import type { Settings } from "../config/settings.js";
108107
import { toolWatchdogFromSettings } from "../config/settings.js";
109108
import { createSearchAgentsTool } from "../agent/agent-search.js";
110-
import { manageTasksDefinition, parseManageTasksArgs } from "../agent/tasks.js";
109+
import {
110+
createManageTasksRunner,
111+
manageTasksDefinition,
112+
} from "../agent/tasks.js";
111113
import { ID_PREFIX } from "../branding.js";
112114
import {
113115
appendActivitySummary,
@@ -686,17 +688,7 @@ async function runSubAgentInner(
686688
// director.ts). This handler only validates, so update_plan's proxy shares
687689
// it rather than forwarding through posixTools (which has no manage_tasks
688690
// handler to forward to).
689-
const runManageTasks: CodexRunManageTasks = async (rawArgs) => {
690-
const parsed = parseManageTasksArgs(rawArgs);
691-
if (parsed === null) {
692-
return {
693-
content:
694-
"Error: manage_tasks requires action ('create' or 'update').",
695-
isError: true,
696-
};
697-
}
698-
return { content: "Tasks updated." };
699-
};
691+
const runManageTasks = createManageTasksRunner();
700692
tools = [
701693
...tools,
702694
...createCodexToolProxies({

‎tests/unit/agent/tasks.test.ts‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { test, expect } from "bun:test";
22
import {
33
applyManageTasks,
4+
createManageTasksRunner,
45
hasActiveTasks,
56
type Task,
67
} from "../../../src/agent/tasks.js";
@@ -107,3 +108,53 @@ test("hasActiveTasks is false for an empty or all-terminal list", () => {
107108
]),
108109
).toBe(false);
109110
});
111+
112+
test("manage_tasks runner reports a repeat update that changes nothing", async () => {
113+
const run = createManageTasksRunner();
114+
await run({
115+
action: "create",
116+
tasks: [{ id: "t8", title: "Ship it", status: "doing" }],
117+
});
118+
const again = await run({
119+
action: "update",
120+
updates: [{ id: "t8", status: "doing" }],
121+
});
122+
expect(again.content).toBe("No change: t8 already doing.");
123+
});
124+
125+
test("manage_tasks runner reports a repeat create that changes nothing", async () => {
126+
const run = createManageTasksRunner();
127+
const args = { action: "create", tasks: [{ id: "t1", title: "One" }] };
128+
expect((await run(args)).content).toBe("Tasks updated.");
129+
expect((await run(args)).content).toBe(
130+
"No change: task list already matches.",
131+
);
132+
});
133+
134+
test("manage_tasks runner still reports real updates as updated", async () => {
135+
const run = createManageTasksRunner();
136+
await run({
137+
action: "create",
138+
tasks: [{ id: "t1", title: "One", status: "doing" }],
139+
});
140+
expect(
141+
(await run({ action: "update", updates: [{ id: "t1", status: "done" }] }))
142+
.content,
143+
).toBe("Tasks updated.");
144+
});
145+
146+
test("manage_tasks runner reports updates against unknown or empty input", async () => {
147+
const run = createManageTasksRunner();
148+
expect((await run({ action: "update" })).content).toBe(
149+
"No change: no updates supplied.",
150+
);
151+
expect(
152+
(await run({ action: "update", updates: [{ id: "t9" }] })).content,
153+
).toBe("No change: t9 not found.");
154+
});
155+
156+
test("manage_tasks runner rejects invalid args", async () => {
157+
const run = createManageTasksRunner();
158+
const result = await run({});
159+
expect(result.isError).toBe(true);
160+
});

0 commit comments

Comments
 (0)