Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions src/agent/codex-tool-proxies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -67,7 +71,7 @@ function makeRecorder(initial: Record<string, string> = {}): {
return { calls, files, runTool, readRawFile };
}

const unusedManageTasks: CodexRunManageTasks = async () => ({
const unusedManageTasks: ManageTasksRunner = async () => ({
content: "unused",
});
const unusedReadRawFile: CodexReadRawFile = async () => ({ content: "unused" });
Expand All @@ -79,11 +83,11 @@ const unusedReadRawFile: CodexReadRawFile = async () => ({ content: "unused" });
function makeRealManageTasks(): {
calls: Record<string, unknown>[];
getTasks: () => Task[];
runManageTasks: CodexRunManageTasks;
runManageTasks: ManageTasksRunner;
} {
let tasks: Task[] = [];
const calls: Record<string, unknown>[] = [];
const runManageTasks: CodexRunManageTasks = async (rawArgs) => {
const runManageTasks: ManageTasksRunner = async (rawArgs) => {
calls.push(rawArgs);
const parsed = parseManageTasksArgs(rawArgs);
if (parsed === null) {
Expand Down
10 changes: 3 additions & 7 deletions src/agent/codex-tool-proxies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<string, unknown>,
) => Promise<{ content: string; isError?: boolean }>;

export interface CreateCodexToolProxiesOpts {
isCodex: boolean;
runTool: CodexRunTool;
Expand All @@ -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
Expand Down Expand Up @@ -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<string, unknown>): Promise<string> => {
Expand Down
32 changes: 32 additions & 0 deletions src/agent/doom-loop-note.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
28 changes: 28 additions & 0 deletions src/agent/doom-loop-note.ts
Original file line number Diff line number Diff line change
@@ -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.`
);
};
}
72 changes: 72 additions & 0 deletions src/agent/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) => 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(
Expand Down
14 changes: 2 additions & 12 deletions src/agent/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -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).
Expand Down
7 changes: 7 additions & 0 deletions src/session/assemble-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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),
Expand Down
8 changes: 3 additions & 5 deletions src/subagent/run-codex-proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -31,7 +29,7 @@ function fakeBlobStore() {
};
}

const unusedManageTasks: CodexRunManageTasks = async () => ({
const unusedManageTasks: ManageTasksRunner = async () => ({
content: "unused",
});

Expand Down
18 changes: 5 additions & 13 deletions src/subagent/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand Down
51 changes: 51 additions & 0 deletions tests/unit/agent/tasks.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { test, expect } from "bun:test";
import {
applyManageTasks,
createManageTasksRunner,
hasActiveTasks,
type Task,
} from "../../../src/agent/tasks.js";
Expand Down Expand Up @@ -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);
});
Loading
Loading