diff --git a/docs-site/src/content/docs/reference/configuration/agents.md b/docs-site/src/content/docs/reference/configuration/agents.md index ed106c598e..58ba51828c 100644 --- a/docs-site/src/content/docs/reference/configuration/agents.md +++ b/docs-site/src/content/docs/reference/configuration/agents.md @@ -68,6 +68,12 @@ Mode changes apply to new sessions. `maxConcurrentThreadsPerSession` is a `PUT / `config.json` key; `ocx v2 threads ` 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 diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index 0c1ff04262..96ddcc9e43 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -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; @@ -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(r, t("models.saveFailed")); diff --git a/gui/src/pages/models-shared.ts b/gui/src/pages/models-shared.ts index 502a2b91d9..f1f681dde3 100644 --- a/gui/src/pages/models-shared.ts +++ b/gui/src/pages/models-shared.ts @@ -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 { diff --git a/gui/tests/models-subagent-limit.test.tsx b/gui/tests/models-subagent-limit.test.tsx index dc833cffaa..5f967c0831 100644 --- a/gui/tests/models-subagent-limit.test.tsx +++ b/gui/tests/models-subagent-limit.test.tsx @@ -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('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(".models-v2-detail-row button")] + .find(node => node.textContent === "Apply")!; + await act(async () => { apply.click(); }); +} + const subagentRadios = () => [ ...container.querySelectorAll( '[role="radiogroup"][aria-label="Sub-agent"] button[role="radio"]'), @@ -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 }; diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index df26ddc3e4..5cfd3133c9 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -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 diff --git a/structure/subagent-capacity.md b/structure/subagent-capacity.md index 3ab5d6bdfb..ed68673a7c 100644 --- a/structure/subagent-capacity.md +++ b/structure/subagent-capacity.md @@ -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` diff --git a/tests/codex-integration/codex-agent-thread-limit.test.ts b/tests/codex-integration/codex-agent-thread-limit.test.ts index 23011ab33d..dd7fc6724e 100644 --- a/tests/codex-integration/codex-agent-thread-limit.test.ts +++ b/tests/codex-integration/codex-agent-thread-limit.test.ts @@ -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"; @@ -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;