Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ Mode changes apply to new sessions. `maxConcurrentThreadsPerSession` is a `PUT /
`config.json` key; `ocx v2 threads <n>` writes `max_concurrent_threads_per_session` under
`[features.multi_agent_v2]` in Codex's `$CODEX_HOME/config.toml` after v2 is enabled.

The Models page's sub-agent limit counts children. A stored V2 total of 1 is root-only
and displays 0; setting one child saves a V2 total of 2. New child limits must be at least
1, and both the child count and converted API value must be safe integers (at most
9,007,199,254,740,991). With global V2 disabled, including hybrid mode, no root slot is added.
The API rejects unsafe limits with 400 before changing the feature flag or configuration.

**Always proactive delegation** in Subagents → Advanced (formerly **Ultra mode**) changes the
delegation trigger without changing reasoning effort. Its preset preserves user instructions,
authority boundaries, task scope, and tool rules. The dashboard toggle, `PUT /api/v2` field
Expand Down
9 changes: 4 additions & 5 deletions gui/src/pages/Models.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1237,11 +1237,10 @@ export default function Models({ apiBase, restartEpoch = 0, connected = false, c
: null;

const putV2Threads = async (value: number) => {
// Same guards as the flag toggle: single-flight + server-side idempotence
// (setMaxConcurrentThreads no-ops on equal value), so a re-selected current
// value or a double click can never double-write config.toml.
// Validate both child and backend units before the single-flight write.
if (!v2 || v2BusyRef.current) return;
if (!Number.isInteger(value) || value < 1) { publishFeedback(false, t("models.v2ThreadsInvalid")); return; }
const threadLimit = childCountToThreadLimit(value, v2.enabled);
if (!Number.isSafeInteger(value) || value < 1 || !Number.isSafeInteger(threadLimit)) { publishFeedback(false, t("models.v2ThreadsInvalid")); return; }
if (v2ThreadsChildren === value) return;
setV2Busy(true);
v2BusyRef.current = true;
Expand All @@ -1252,7 +1251,7 @@ export default function Models({ apiBase, restartEpoch = 0, connected = false, c
const r = await fetch(`${apiBase}/api/v2`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ maxConcurrentThreadsPerSession: childCountToThreadLimit(value, v2.enabled) }),
body: JSON.stringify({ maxConcurrentThreadsPerSession: threadLimit }),
});
try {
const data = await readJsonOrThrow<V2Status & { warnings?: string[] }>(r, t("models.saveFailed"));
Expand Down
8 changes: 4 additions & 4 deletions gui/src/pages/models-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,12 +199,12 @@ export const THREAD_OPTION_SET = new Set(THREAD_OPTIONS);
* units: the [agents] keys (v1/base/hybrid, enabled:false) count child agents, while
* the V2 storage counts the root agent's own slot too (total = children + 1). The
* control always presents the logical child count, so only the V2-total case translates
* across the root slot — the same boundary v2TotalLimitToV1ChildLimit /
* v1ChildLimitToV2TotalLimit keep in src/codex/features.ts. A stored V2 total of 1
* (root only) has no representable child count >= 1 and clamps to 1, matching upstream.
* across the root slot. A stored V2 total of 1 is root-only and displays zero
* children, even though new child-limit edits must be at least 1. This display
* conversion is independent of the backend's V2-to-V1 migration clamp.
*/
export function threadLimitToChildCount(limit: number, v2Enabled: boolean): number {
return v2Enabled ? Math.max(1, limit - 1) : limit;
return v2Enabled ? Math.max(0, limit - 1) : limit;
}

export function childCountToThreadLimit(children: number, v2Enabled: boolean): number {
Expand Down
66 changes: 66 additions & 0 deletions gui/tests/models-subagent-limit.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,20 @@ async function pickOption(label: string) {
}
const lastPut = () => v2Puts.at(-1);

async function applyCustomLimit(value: string) {
await pickOption("Custom…");
const input = container.querySelector<HTMLInputElement>('input[aria-label="Sub-agent limit"]')!;
expect(input).not.toBeNull();
await act(async () => {
Object.getOwnPropertyDescriptor(testWindow.HTMLInputElement.prototype, "value")!.set!.call(input, value);
input.dispatchEvent(new testWindow.Event("input", { bubbles: true }) as unknown as Event);
input.dispatchEvent(new testWindow.Event("change", { bubbles: true }) as unknown as Event);
});
const apply = [...container.querySelectorAll<HTMLButtonElement>(".models-v2-detail-row button")]
.find(node => node.textContent === "Apply")!;
await act(async () => { apply.click(); });
}

const subagentRadios = () => [
...container.querySelectorAll<HTMLButtonElement>(
'[role="radiogroup"][aria-label="Sub-agent"] button[role="radio"]'),
Expand Down Expand Up @@ -196,6 +210,58 @@ test("full v2 presents children (stored total minus the root slot) and PUTs tota
expect(lastPut()).toEqual({ maxConcurrentThreadsPerSession: 33 });
});

test("root-only V2 displays zero children and a custom one-child edit sends total two", async () => {
testWindow.localStorage.setItem("ocx.models.settingsOpen.v1", "1");
v2Body = { enabled: true, multiAgentMode: "v2", maxConcurrentThreadsPerSession: 1 };
await mount();
expect(limitSelect()!.textContent).toBe("0");
await applyCustomLimit("1");
expect(v2Puts).toEqual([{ maxConcurrentThreadsPerSession: 2 }]);
expect(limitSelect()!.textContent).toBe("1");
});

test("the folded root-only V2 summary displays zero children", async () => {
v2Body = { enabled: true, multiAgentMode: "v2", maxConcurrentThreadsPerSession: 1 };
await mount();
const chip = container.querySelector('.models-settings-kv[data-item="threads"]');
expect(chip?.querySelector(".models-settings-v")?.textContent).toBe("0");
});

test.each([
[false, "v1", false],
[false, "v2", true],
] as const)("%s global flag / %s surface / hybrid %s keeps a one-child edit in child units", async (enabled, multiAgentMode, keepNativeChatGptOnV1) => {
testWindow.localStorage.setItem("ocx.models.settingsOpen.v1", "1");
v2Body = { enabled, multiAgentMode, keepNativeChatGptOnV1, maxConcurrentThreadsPerSession: 16 };
await mount();
await applyCustomLimit("1");
expect(v2Puts).toEqual([{ maxConcurrentThreadsPerSession: 1 }]);
});

test.each([
[false, Number.MAX_SAFE_INTEGER + 1],
[true, Number.MAX_SAFE_INTEGER + 1],
[true, Number.MAX_SAFE_INTEGER],
] as const)("global V2 %s rejects unsafe child/backend limit %s without a PUT", async (enabled, value) => {
testWindow.localStorage.setItem("ocx.models.settingsOpen.v1", "1");
v2Body = { enabled, multiAgentMode: enabled ? "v2" : "v1", maxConcurrentThreadsPerSession: 16 };
await mount();
await applyCustomLimit(String(value));
expect(v2Puts).toHaveLength(0);
expect(container.textContent).toContain("Sub-agent limit must be an integer >= 1");
});

test.each([
[false, Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER],
[true, Number.MAX_SAFE_INTEGER - 1, Number.MAX_SAFE_INTEGER],
] as const)("global V2 %s accepts largest safe child limit %s as backend %s", async (enabled, children, total) => {
testWindow.localStorage.setItem("ocx.models.settingsOpen.v1", "1");
v2Body = { enabled, multiAgentMode: enabled ? "v2" : "v1", maxConcurrentThreadsPerSession: 16 };
await mount();
await applyCustomLimit(String(children));
expect(v2Puts).toEqual([{ maxConcurrentThreadsPerSession: total }]);
});

test("the V2 conflict warning identifies canonical agents limits as well as the legacy alias", async () => {
testWindow.localStorage.setItem("ocx.models.settingsOpen.v1", "1");
v2Body = { enabled: true, multiAgentMode: "v2", agentsMaxThreadsConflict: true, maxConcurrentThreadsPerSession: 17 };
Expand Down
4 changes: 2 additions & 2 deletions src/server/management/agent-settings-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,8 +380,8 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
if (wantsKeepNative && typeof body.keepNativeChatGptOnV1 !== "boolean") {
return jsonResponse({ error: "body.keepNativeChatGptOnV1 must be a boolean" }, 400);
}
if (wantsThreads && (typeof body.maxConcurrentThreadsPerSession !== "number" || !Number.isInteger(body.maxConcurrentThreadsPerSession) || body.maxConcurrentThreadsPerSession < 1)) {
return jsonResponse({ error: "body.maxConcurrentThreadsPerSession must be an integer >= 1" }, 400);
if (wantsThreads && (typeof body.maxConcurrentThreadsPerSession !== "number" || !Number.isSafeInteger(body.maxConcurrentThreadsPerSession) || body.maxConcurrentThreadsPerSession < 1)) {
return jsonResponse({ error: "body.maxConcurrentThreadsPerSession must be a safe integer >= 1" }, 400);
}
// Validate every new field BEFORE any write, so each 400 leaves config untouched.
// null unsets the key; "" is a meaningful value for instructions and must not be
Expand Down
5 changes: 4 additions & 1 deletion structure/subagent-capacity.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,10 @@ children automatically nor guarantees faster execution.
`GET/PUT /api/v2` retains its existing `maxConcurrentThreadsPerSession` units: with `enabled: false`, N means N
children; with `enabled: true`, N+1 includes the primary thread. The GUI subtracts one for display
and adds one on save only when that global flag is enabled. Catalog-selected v2 in hybrid mode
does not change those API units. An unset limit leaves the default to the native runtime.
does not change those API units. A stored V2 total of 1 displays zero children in both the
selector and folded summary; new child-limit edits still require at least 1. Both the child
input and converted API value must be safe integers. PUT rejects unsafe API limits with 400
before toggles or configuration writes. An unset limit leaves the default to the native runtime.
New sessions consume limit changes; a long-running App host may need a normal restart.

`src/codex/features.ts` reads and writes the canonical `[agents].max_concurrent_threads_per_session`
Expand Down
61 changes: 60 additions & 1 deletion tests/codex-integration/codex-agent-thread-limit.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { mkdtempSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { getAgentsMaxThreads, getLogicalMaxThreads, getMaxConcurrentThreads, hasAgentsMaxThreads, transitionMultiAgentV2 } from "../../src/codex/features";
Expand Down Expand Up @@ -145,6 +145,65 @@ describe("canonical [agents] thread-limit compatibility", () => {
});

describe("canonical management API thread limits", () => {
test.each([Number.MAX_SAFE_INTEGER + 1, 1e20])("unsafe API limit %s returns 400 before combined mutations", async limit => {
const original = "[features.multi_agent_v2]\nenabled = false\n\n[agents]\nmax_concurrent_threads_per_session = 16\n";
const path = fixtureConfig(original);
const oldCodexHome = process.env.CODEX_HOME;
const oldOcxHome = process.env.OPENCODEX_HOME;
const ocxHome = mkdtempSync(join(tmpdir(), "ocx-unsafe-limit-"));
process.env.CODEX_HOME = dirname(path);
process.env.OPENCODEX_HOME = ocxHome;
const config = { providers: [], multiAgentMode: "v1" } as never;
let toggles = 0;
let convergences = 0;
try {
const request = new Request("http://localhost/api/v2", {
method: "PUT", headers: { Host: "localhost", "content-type": "application/json" },
body: JSON.stringify({ enabled: true, multiAgentMode: "v2", agentsEnabled: true, maxConcurrentThreadsPerSession: limit }),
});
const response = await handleManagementAPI(request, new URL(request.url), config, {
toggleCodexMultiAgentV2: () => { toggles++; },
createManagementConvergeCodex: catalogConvergenceFactory(() => { convergences++; }),
});
expect(response?.status).toBe(400);
expect(await response?.json()).toEqual({ error: "body.maxConcurrentThreadsPerSession must be a safe integer >= 1" });
expect(toggles).toBe(0);
expect(convergences).toBe(0);
expect(config).toEqual({ providers: [], multiAgentMode: "v1" });
expect(readFileSync(path, "utf8")).toBe(original);
expect(readdirSync(dirname(path))).toEqual(["config.toml"]);
expect(readdirSync(ocxHome)).toEqual([]);
} finally {
if (oldCodexHome === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = oldCodexHome;
if (oldOcxHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = oldOcxHome;
}
});

test.each([false, true])("API accepts the largest safe backend limit with V2 enabled=%s", async enabled => {
const path = fixtureConfig(`[features.multi_agent_v2]\nenabled = ${enabled}\n`);
const oldCodexHome = process.env.CODEX_HOME;
const oldOcxHome = process.env.OPENCODEX_HOME;
process.env.CODEX_HOME = dirname(path);
process.env.OPENCODEX_HOME = mkdtempSync(join(tmpdir(), "ocx-safe-limit-"));
let toggles = 0;
try {
const request = new Request("http://localhost/api/v2", {
method: "PUT", headers: { Host: "localhost", "content-type": "application/json" },
body: JSON.stringify({ maxConcurrentThreadsPerSession: Number.MAX_SAFE_INTEGER }),
});
const response = await handleManagementAPI(request, new URL(request.url), { providers: [] } as never, {
toggleCodexMultiAgentV2: () => { toggles++; }, createManagementConvergeCodex: catalogConvergenceFactory(),
});
expect(response?.status).toBe(200);
expect(await response?.json()).toMatchObject({ enabled, maxConcurrentThreadsPerSession: Number.MAX_SAFE_INTEGER });
expect(getLogicalMaxThreads(path)).toBe(Number.MAX_SAFE_INTEGER);
expect(toggles).toBe(0);
} finally {
if (oldCodexHome === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = oldCodexHome;
if (oldOcxHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = oldOcxHome;
}
});

test("hybrid GET and limit-only PUT use canonical agents storage without enabling global V2", async () => {
const path = fixtureConfig("[features.multi_agent_v2]\nenabled = false\nmax_concurrent_threads_per_session = 17\n\n[agents]\nmax_concurrent_threads_per_session = 16\n");
const oldCodexHome = process.env.CODEX_HOME;
Expand Down
Loading