Skip to content

Commit cec073b

Browse files
committed
feat(effort-cap): gate caps to the v2 feature surface
- effortCapAppliesTo: multiAgentMode "v1" disables caps; v2-surface main turns qualify by tool list; child turns (which never carry collab tools) qualify via the spawned-child headers, so subagentEffortCap actually lands on the turns it targets - Dashboard: effort-cap panel hidden in v1 mode, help popup + i18n (en/ko/zh/de) explaining v2-only cap semantics - tests: child-header admission regression + mode kill-switch coverage
1 parent 5799c3b commit cec073b

8 files changed

Lines changed: 149 additions & 19 deletions

File tree

gui/src/i18n/de.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,9 @@ export const de = {
5454
"dash.injectionNone": "Keine",
5555
"dash.injectionEffortLabel": "Reasoning-Aufwand",
5656
"dash.injectionEffortNone": "Modell-Standard",
57-
"dash.effortCapLabel": "Ultra Effort-Limit",
58-
"dash.subagentEffortCapLabel": "Effort-Limit (Sub-Agents)",
57+
"dash.effortCapLabel": "V2 Ultra Effort-Limit",
58+
"dash.subagentEffortCapLabel": "V2 Sub-Agent Effort-Limit",
59+
"dash.effortCapHelp": "Begrenzt die Reasoning-Intensität für V2-Ultra-Modus-Turns. Wenn gesetzt, werden eingehende Max-Anfragen (aus dem Ultra-Modus) auf das gewählte Niveau begrenzt. Das Sub-Agent-Limit gilt nur für erzeugte Kind-Agenten. Limits senken die Intensität nur, sie erhöhen sie nie. Wenn ein Modell das gewählte Niveau nicht unterstützt, wird automatisch auf das nächste unterstützte Niveau herabgesetzt.",
5960
"dash.effortCapNone": "Kein Limit",
6061
"dash.maintenance": "Wartung",
6162
"dash.maintenanceHint": "Aktualisiere Codex’ Modellkatalog oder installiere eine neuere opencodex-Version.",

gui/src/i18n/en.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,9 @@ export const en = {
5858
"dash.injectionNone": "None",
5959
"dash.injectionEffortLabel": "Reasoning effort",
6060
"dash.injectionEffortNone": "Model default",
61-
"dash.effortCapLabel": "Ultra reasoning effort limit",
62-
"dash.subagentEffortCapLabel": "Effort cap (sub-agents)",
61+
"dash.effortCapLabel": "V2 ultra effort limit",
62+
"dash.subagentEffortCapLabel": "V2 sub-agent effort limit",
63+
"dash.effortCapHelp": "Limits the reasoning effort for V2 ultra-mode turns. When set, incoming max-effort requests (from ultra mode) are capped to the selected level. The sub-agent limit applies only to spawned child agents. Caps only lower effort, never raise it. If a model doesn't support the capped level, it snaps down to the nearest supported level.",
6364
"dash.effortCapNone": "No cap",
6465
"dash.maintenance": "Maintenance",
6566
"dash.maintenanceHint": "Refresh Codex's model catalog or install a newer opencodex release.",

gui/src/i18n/ko.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,9 @@ export const ko: Record<TKey, string> = {
5858
"dash.injectionNone": "없음",
5959
"dash.injectionEffortLabel": "추론 강도",
6060
"dash.injectionEffortNone": "모델 기본값",
61-
"dash.effortCapLabel": "ultra 추론 강도 제한",
62-
"dash.subagentEffortCapLabel": "추론 강도 상한 (서브에이전트)",
61+
"dash.effortCapLabel": "V2 ultra 추론 강도 제한",
62+
"dash.subagentEffortCapLabel": "V2 서브에이전트 추론 강도 제한",
63+
"dash.effortCapHelp": "V2 ultra 모드 턴의 추론 강도를 제한합니다. 설정하면 ultra 모드에서 들어오는 max 요청이 선택한 수준으로 내려갑니다. 서브에이전트 제한은 스폰된 자식 에이전트에만 적용됩니다. 강도를 낮추기만 하고 올리지는 않습니다. 모델이 해당 수준을 지원하지 않으면 가장 가까운 지원 수준으로 내려갑니다.",
6364
"dash.effortCapNone": "상한 없음",
6465
"dash.maintenance": "유지보수",
6566
"dash.maintenanceHint": "Codex 모델 카탈로그를 새로고침하거나 최신 opencodex 릴리스를 설치합니다.",

gui/src/i18n/zh.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,9 @@ export const zh: Record<TKey, string> = {
5858
"dash.injectionNone": "无",
5959
"dash.injectionEffortLabel": "推理强度",
6060
"dash.injectionEffortNone": "模型默认",
61-
"dash.effortCapLabel": "Ultra 推理强度限制",
62-
"dash.subagentEffortCapLabel": "推理强度上限(子代理)",
61+
"dash.effortCapLabel": "V2 ultra 推理强度限制",
62+
"dash.subagentEffortCapLabel": "V2 子代理推理强度限制",
63+
"dash.effortCapHelp": "限制 V2 ultra 模式轮次的推理强度。设置后,来自 ultra 模式的 max 请求将被限制到所选级别。子代理限制仅适用于衍生的子代理。只会降低强度,不会提高。如果模型不支持所选级别,将自动降至最近的支持级别。",
6364
"dash.effortCapNone": "无上限",
6465
"dash.maintenance": "维护",
6566
"dash.maintenanceHint": "刷新 Codex 模型目录,或安装新的 opencodex 版本。",

gui/src/pages/Dashboard.tsx

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ export default function Dashboard({ apiBase }: { apiBase: string }) {
9898
const [maMode, setMaMode] = useState<"v1" | "default" | "v2">("default");
9999
const [maBusy, setMaBusy] = useState(false);
100100
const [maHelpOpen, setMaHelpOpen] = useState(false);
101+
const [effortCapHelpOpen, setEffortCapHelpOpen] = useState(false);
101102
const [injectionModel, setInjectionModel] = useState<string>("");
102103
const [injectionEffort, setInjectionEffort] = useState<string>("");
103104
const [injectionEfforts, setInjectionEfforts] = useState<string[]>([]);
@@ -508,9 +509,43 @@ export default function Dashboard({ apiBase }: { apiBase: string }) {
508509
</div>
509510
)}
510511

511-
<div className="panel" style={{ marginBottom: 24 }}>
512-
<div className="injection-head">
513-
<span className="injection-label">{t("dash.effortCapLabel")}</span>
512+
{maMode !== "v1" && (
513+
<div className="panel" style={{ marginBottom: 24 }}>
514+
<div className="injection-head">
515+
<span className="injection-label" style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
516+
{t("dash.effortCapLabel")}
517+
<span style={{ position: "relative", display: "inline-flex" }}>
518+
<button
519+
type="button"
520+
className="btn btn-ghost btn-sm"
521+
style={{ width: 22, height: 22, minWidth: 22, padding: 0, borderRadius: 999, color: "var(--muted)" }}
522+
onClick={() => setEffortCapHelpOpen(open => !open)}
523+
aria-label={t("dash.effortCapLabel")}
524+
aria-expanded={effortCapHelpOpen}
525+
aria-haspopup="dialog"
526+
>
527+
<IconInfo width={13} height={13} aria-hidden="true" />
528+
</button>
529+
{effortCapHelpOpen && (
530+
<div
531+
role="dialog"
532+
className="help-popup"
533+
style={{ position: "absolute", top: "calc(100% + 8px)", left: 0, width: "min(360px, calc(100vw - 48px))", padding: "12px 16px", border: "1px solid var(--border)", borderRadius: 8, background: "var(--bg)", boxShadow: "0 8px 24px rgba(0, 0, 0, 0.14)", color: "var(--text)", fontSize: 13, fontWeight: 400, lineHeight: 1.5, zIndex: 10 }}
534+
>
535+
<button
536+
type="button"
537+
className="btn btn-ghost btn-icon"
538+
style={{ position: "absolute", top: 4, right: 4, width: 24, height: 24, minWidth: 24 }}
539+
onClick={() => setEffortCapHelpOpen(false)}
540+
aria-label="Close"
541+
>
542+
<IconX width={14} height={14} />
543+
</button>
544+
<div style={{ paddingRight: 16 }}>{t("dash.effortCapHelp")}</div>
545+
</div>
546+
)}
547+
</span>
548+
</span>
514549
<Select
515550
value={effortCap}
516551
options={[
@@ -563,8 +598,9 @@ export default function Dashboard({ apiBase }: { apiBase: string }) {
563598
disabled={effortCapSaving}
564599
label={t("dash.subagentEffortCapLabel")}
565600
/>
601+
</div>
566602
</div>
567-
</div>
603+
)}
568604

569605
<div className="panel" style={{ marginBottom: 24 }}>
570606
<div className="injection-head">

src/server/effort-policy.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,26 @@ export function effortCapFor(config: OcxConfig, subagent: boolean): string | und
4848
return caps.reduce((low, cap) => (codexEffortRank(cap) < codexEffortRank(low) ? cap : low));
4949
}
5050

51+
/**
52+
* Whether the effort caps apply to this turn at all. Caps are a V2-surface feature
53+
* (v1 sub-agents are pinned via explicit spawn args + injectionEffort prompting, so
54+
* the ultra-default leak this module intercepts is v2-specific):
55+
* - multiAgentMode "v1" disables caps entirely (mirrors the GUI hiding the panel).
56+
* - a main turn qualifies when its own tool list carries the v2 collab surface.
57+
* - a CHILD turn carries no collab tools (surface null) — its discriminator is the
58+
* spawned-child header pair (isSubagentRequest), so the subagent cap still lands
59+
* on exactly the turns it targets.
60+
* - a v1-surface turn never qualifies.
61+
*/
62+
export function effortCapAppliesTo(
63+
surface: "v1" | "v2" | null,
64+
headers: Headers,
65+
config: OcxConfig,
66+
): boolean {
67+
if (config.multiAgentMode === "v1") return false;
68+
return surface === "v2" || (surface === null && isSubagentRequest(headers));
69+
}
70+
5171
/**
5272
* The routed model's supported effort ladder for cap resolution, from the ROUTE's
5373
* registry-merged provider (router.ts routedProviderConfig) — the persisted

src/server/responses.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -475,14 +475,22 @@ export async function handleResponses(
475475
// ultra-tier default on bare spawns (see src/server/effort-policy.ts). Runs BEFORE the
476476
// mock-max clamp so a capped effort is what nativeness clamping then validates; rewrites
477477
// both request shapes (same dual-write contract as the clamp below).
478+
// GATE: v2 feature only (effortCapAppliesTo) — v2-surface main turns plus header-marked
479+
// child turns (children carry no collab tools, so tool sniffing alone would skip the very
480+
// turns subagentEffortCap targets); multiAgentMode "v1" disables caps entirely.
478481
{
479-
const { applyEffortCap, supportedLadderFor } = await import("./effort-policy");
480-
const capped = applyEffortCap(parsed, req.headers, config, supportedLadderFor(route));
481-
if (capped) {
482-
logCtx.requestedEffort = `${capped.from}->${capped.to}`;
483-
if (isInjectionDebugEnabled()) {
484-
console.log(`[opencodex] ${route.modelId}: effort cap applied (${capped.from} -> ${capped.to}, ${capped.subagent ? "sub-agent" : "main"} turn)`);
482+
const { applyEffortCap, effortCapAppliesTo, supportedLadderFor } = await import("./effort-policy");
483+
const surface = collabSurface(parsed);
484+
if (effortCapAppliesTo(surface, req.headers, config)) {
485+
const capped = applyEffortCap(parsed, req.headers, config, supportedLadderFor(route));
486+
if (capped) {
487+
logCtx.requestedEffort = `${capped.from}->${capped.to}`;
488+
if (isInjectionDebugEnabled()) {
489+
console.log(`[opencodex] ${route.modelId}: effort cap applied (${capped.from} -> ${capped.to}, ${capped.subagent ? "sub-agent" : "main"} turn)`);
490+
}
485491
}
492+
} else if (isInjectionDebugEnabled() && (config.effortCap || config.subagentEffortCap)) {
493+
console.log(`[opencodex] ${route.modelId}: effort cap skipped (surface=${surface ?? "none"}, v2 feature only)`);
486494
}
487495
}
488496

tests/effort-policy.test.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import { afterEach, describe, expect, test } from "bun:test";
77
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
88
import { tmpdir } from "node:os";
99
import { join } from "node:path";
10-
import { applyEffortCap, effortCapFor, isSubagentRequest, resolveCappedEffort, supportedLadderFor } from "../src/server/effort-policy";
10+
import { applyEffortCap, effortCapAppliesTo, effortCapFor, isSubagentRequest, resolveCappedEffort, supportedLadderFor } from "../src/server/effort-policy";
11+
import { collabSurface } from "../src/server/responses";
1112
import { handleManagementAPI } from "../src/server/management-api";
1213
import { routeModel } from "../src/router";
1314
import { mapReasoningEffort } from "../src/reasoning-effort";
@@ -281,6 +282,67 @@ describe("supportedLadderFor (real routeModel routes)", () => {
281282
});
282283
});
283284

285+
describe("effortCapAppliesTo (caps are a v2-feature gate)", () => {
286+
function parsedWithTools(tools: Array<{ name: string; namespace?: string }>, reasoning?: string): OcxParsedRequest {
287+
return {
288+
modelId: "gpt-5.6-sol",
289+
context: {
290+
messages: [{ role: "user", content: "hi", timestamp: 1 }],
291+
tools: tools as never,
292+
},
293+
stream: true,
294+
options: reasoning ? { reasoning: reasoning as never } : {},
295+
_rawBody: { model: "gpt-5.6-sol", reasoning: reasoning ? { effort: reasoning } : undefined },
296+
};
297+
}
298+
299+
test("v2 flat spawn_agent surface is classified v2 — caps apply", () => {
300+
const parsed = parsedWithTools([{ name: "spawn_agent" }], "max");
301+
expect(collabSurface(parsed)).toBe("v2");
302+
const config = makeConfig({ effortCap: "high" });
303+
expect(effortCapAppliesTo("v2", new Headers(), config)).toBe(true);
304+
expect(applyEffortCap(parsed, new Headers(), config)).toEqual({ from: "max", to: "high", subagent: false });
305+
});
306+
307+
test("v1 namespaced spawn + send_input is classified v1 — gate refuses", () => {
308+
const parsed = parsedWithTools([
309+
{ name: "spawn_agent", namespace: "agents" },
310+
{ name: "send_input", namespace: "agents" },
311+
], "max");
312+
expect(collabSurface(parsed)).toBe("v1");
313+
// applyEffortCap itself is surface-unaware; handleResponses consults the gate.
314+
expect(effortCapAppliesTo("v1", new Headers(), makeConfig({ effortCap: "high" }))).toBe(false);
315+
});
316+
317+
test("plain main turn (no collab tools, no child headers) — gate refuses", () => {
318+
const parsed = parsedWithTools([{ name: "shell" }], "max");
319+
expect(collabSurface(parsed)).toBeNull();
320+
expect(effortCapAppliesTo(null, new Headers(), makeConfig({ effortCap: "high", subagentEffortCap: "medium" }))).toBe(false);
321+
});
322+
323+
test("child turn carries no collab tools — the spawned-child header still admits the cap", () => {
324+
// Regression guard: children never carry spawn_agent, so a surface-only gate would
325+
// skip exactly the turns subagentEffortCap exists for.
326+
const parsed = parsedWithTools([{ name: "shell" }], "max");
327+
expect(collabSurface(parsed)).toBeNull();
328+
const config = makeConfig({ subagentEffortCap: "medium" });
329+
const childHeaders = new Headers({ "x-openai-subagent": "collab_spawn" });
330+
expect(effortCapAppliesTo(null, childHeaders, config)).toBe(true);
331+
expect(applyEffortCap(parsed, childHeaders, config)).toEqual({ from: "max", to: "medium", subagent: true });
332+
});
333+
334+
test("turn-metadata subagent_kind alone admits the cap for a child turn", () => {
335+
const headers = new Headers({ "x-codex-turn-metadata": JSON.stringify({ subagent_kind: "thread_spawn" }) });
336+
expect(effortCapAppliesTo(null, headers, makeConfig({ subagentEffortCap: "medium" }))).toBe(true);
337+
});
338+
339+
test("multiAgentMode v1 disables the gate entirely, even for v2 surfaces and child headers", () => {
340+
const config = makeConfig({ effortCap: "high", subagentEffortCap: "medium", multiAgentMode: "v1" });
341+
expect(effortCapAppliesTo("v2", new Headers(), config)).toBe(false);
342+
expect(effortCapAppliesTo(null, new Headers({ "x-openai-subagent": "collab_spawn" }), config)).toBe(false);
343+
});
344+
});
345+
284346
describe("cap composition with downstream clamps", () => {
285347
test("snapped ordinary rung passes mapReasoningEffort unchanged for a supporting provider", () => {
286348
const provider = {

0 commit comments

Comments
 (0)