Skip to content

Commit 4408243

Browse files
committed
feat(multi-agent): add multiAgentGuidanceEnabled kill switch (#300)
A supported boolean now disables proxy-authored multi-agent guidance for both v1 and v2 without touching the collaboration surface, roster, routing, or effort caps. Default-on preserves current behavior. Also fixes PUT /api/injection-model to be a true partial update: absent model/effort/prompt keys now preserve stored values (previously a flag-only request would have cleared them), with validate-before- mutate semantics and a 400 guard for non-object payloads.
1 parent 95b8717 commit 4408243

14 files changed

Lines changed: 439 additions & 74 deletions

File tree

gui/src/i18n/de.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ export const de = {
6969
"dash.sidecarSaveFailed": "Sidecar-Einstellungen konnten nicht gespeichert werden.",
7070
"dash.injectionLabel": "Sub-Agent-Delegation",
7171
"dash.injectionHint": "Wähle ein geroutetes Modell für den Delegations-Prompt. Der Agent wird angewiesen, es für Teilaufgaben zu nutzen.",
72+
"dash.multiAgentGuidance": "OpenCodex-Multi-Agent-Anleitung",
73+
"dash.multiAgentGuidanceHint": "Fügt von OpenCodex erstellte Delegationshinweise hinzu. Beim Ausschalten bleiben v1/v2-Oberfläche, Sub-Agent-Liste, Routing und Aufwandsgrenzen unverändert.",
7274
"dash.injectionActive": "Aktiv",
7375
"dash.injectionNone": "Keine",
7476
"dash.injectionEffortLabel": "Reasoning-Aufwand",

gui/src/i18n/en.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ export const en = {
7878
"dash.sidecarSaveFailed": "Failed to save sidecar settings.",
7979
"dash.injectionLabel": "Sub-agent delegation",
8080
"dash.injectionHint": "Pick a routed model to inject into the delegation prompt. The agent will be told to use it for sub-tasks.",
81+
"dash.multiAgentGuidance": "OpenCodex multi-agent guidance",
82+
"dash.multiAgentGuidanceHint": "Adds OpenCodex-authored delegation instructions. Turning this off keeps the v1/v2 surface, sub-agent roster, routing, and effort caps unchanged.",
8183
"dash.injectionActive": "Active",
8284
"dash.injectionNone": "None",
8385
"dash.injectionEffortLabel": "Reasoning effort",

gui/src/i18n/ja.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ export const ja: Record<TKey, string> = {
7878
"dash.sidecarSaveFailed": "サイドカー設定の保存に失敗しました。",
7979
"dash.injectionLabel": "サブエージェント委任",
8080
"dash.injectionHint": "委任プロンプトに注入するルーティングモデルを選択します。エージェントはサブタスクにそれを使うよう指示されます。",
81+
"dash.multiAgentGuidance": "OpenCodex マルチエージェントガイダンス",
82+
"dash.multiAgentGuidanceHint": "OpenCodex が作成する委任指示を追加します。オフにしても v1/v2 サーフェス、サブエージェントロスター、ルーティング、effort 上限は変わりません。",
8183
"dash.injectionActive": "アクティブ",
8284
"dash.injectionNone": "なし",
8385
"dash.injectionEffortLabel": "推論負荷",

gui/src/i18n/ko.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ export const ko: Record<TKey, string> = {
7373
"dash.sidecarSaveFailed": "사이드카 설정 저장에 실패했습니다.",
7474
"dash.injectionLabel": "서브에이전트 위임",
7575
"dash.injectionHint": "위임 프롬프트에 주입할 라우팅 모델을 선택합니다. 에이전트가 서브태스크에 이 모델을 사용하도록 안내됩니다.",
76+
"dash.multiAgentGuidance": "OpenCodex 멀티 에이전트 가이던스",
77+
"dash.multiAgentGuidanceHint": "OpenCodex가 작성한 위임 안내를 추가합니다. 꺼도 v1/v2 표면, 서브에이전트 로스터, 라우팅, effort 상한은 바뀌지 않습니다.",
7678
"dash.injectionActive": "활성",
7779
"dash.injectionNone": "없음",
7880
"dash.injectionEffortLabel": "추론 강도",

gui/src/i18n/ru.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ export const ru: Record<TKey, string> = {
7878
"dash.sidecarSaveFailed": "Не удалось сохранить настройки сайдкара.",
7979
"dash.injectionLabel": "Делегирование подагентам",
8080
"dash.injectionHint": "Выберите маршрутизируемую модель для внедрения в промпт делегирования. Агенту будет указано использовать её для подзадач.",
81+
"dash.multiAgentGuidance": "Мультиагентное руководство OpenCodex",
82+
"dash.multiAgentGuidanceHint": "Добавляет инструкции делегирования от OpenCodex. Отключение не меняет поверхность v1/v2, список подагентов, маршрутизацию и пределы effort.",
8183
"dash.injectionActive": "Активно",
8284
"dash.injectionNone": "Нет",
8385
"dash.injectionEffortLabel": "Уровень рассуждений",

gui/src/i18n/zh.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ export const zh: Record<TKey, string> = {
7373
"dash.sidecarSaveFailed": "保存附属设置失败。",
7474
"dash.injectionLabel": "子代理委托",
7575
"dash.injectionHint": "选择要注入委托提示的路由模型。代理将被告知在子任务中使用它。",
76+
"dash.multiAgentGuidance": "OpenCodex 多代理指引",
77+
"dash.multiAgentGuidanceHint": "添加由 OpenCodex 编写的委派指令。关闭后仍保留 v1/v2 界面、子代理清单、路由和 effort 上限。",
7678
"dash.injectionActive": "已激活",
7779
"dash.injectionNone": "无",
7880
"dash.injectionEffortLabel": "推理强度",

gui/src/pages/Dashboard.tsx

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ export default function Dashboard({ apiBase }: { apiBase: string }) {
196196
const [injectionEfforts, setInjectionEfforts] = useState<string[]>([]);
197197
const [injectionAvailable, setInjectionAvailable] = useState<Array<{ provider: string; model: string; namespaced: string }>>([]);
198198
const [injectionSaving, setInjectionSaving] = useState(false);
199+
const [multiAgentGuidanceEnabled, setMultiAgentGuidanceEnabled] = useState(true);
199200
const [effortCap, setEffortCap] = useState<string>("");
200201
const [subagentEffortCap, setSubagentEffortCap] = useState<string>("");
201202
const [effortCapSaving, setEffortCapSaving] = useState(false);
@@ -261,7 +262,8 @@ export default function Dashboard({ apiBase }: { apiBase: string }) {
261262
try {
262263
const imRes = await fetch(`${apiBase}/api/injection-model`);
263264
if (imRes.ok) {
264-
const imData = await imRes.json() as { model?: string | null; effort?: string | null; efforts?: string[]; available?: Array<{ provider: string; model: string; namespaced: string }> };
265+
const imData = await imRes.json() as { multiAgentGuidanceEnabled?: boolean; model?: string | null; effort?: string | null; efforts?: string[]; available?: Array<{ provider: string; model: string; namespaced: string }> };
266+
setMultiAgentGuidanceEnabled(imData.multiAgentGuidanceEnabled !== false);
265267
setInjectionModel(imData.model ?? "");
266268
setInjectionEffort(imData.effort ?? "");
267269
setInjectionEfforts(imData.efforts ?? []);
@@ -730,6 +732,37 @@ export default function Dashboard({ apiBase }: { apiBase: string }) {
730732
)}
731733

732734
<div className="panel" style={{ marginBottom: 24 }}>
735+
<div className="spread setting-row">
736+
<div className="setting-copy" style={{ flex: 1 }}>
737+
<div className="font-semibold">{t("dash.multiAgentGuidance")}</div>
738+
<div className="muted setting-hint">{t("dash.multiAgentGuidanceHint")}</div>
739+
</div>
740+
<button
741+
type="button"
742+
className={`switch ${multiAgentGuidanceEnabled ? "on" : ""}`}
743+
onClick={async () => {
744+
if (injectionSaving) return;
745+
setInjectionSaving(true);
746+
try {
747+
const res = await fetch(`${apiBase}/api/injection-model`, {
748+
method: "PUT",
749+
headers: { "Content-Type": "application/json" },
750+
body: JSON.stringify({ multiAgentGuidanceEnabled: !multiAgentGuidanceEnabled }),
751+
});
752+
if (res.ok) {
753+
const data = await res.json() as { multiAgentGuidanceEnabled?: boolean };
754+
setMultiAgentGuidanceEnabled(data.multiAgentGuidanceEnabled !== false);
755+
}
756+
} catch { /* keep current value */ }
757+
finally { setInjectionSaving(false); }
758+
}}
759+
disabled={injectionSaving}
760+
aria-label={t("dash.multiAgentGuidance")}
761+
aria-pressed={multiAgentGuidanceEnabled}
762+
>
763+
<span className="knob" />
764+
</button>
765+
</div>
733766
<div className="injection-head">
734767
<span className="injection-label">{t("dash.injectionLabel")}</span>
735768
<Select
@@ -755,7 +788,7 @@ export default function Dashboard({ apiBase }: { apiBase: string }) {
755788
} catch { /* ignore */ }
756789
finally { setInjectionSaving(false); }
757790
}}
758-
disabled={injectionSaving}
791+
disabled={injectionSaving || !multiAgentGuidanceEnabled}
759792
label={t("dash.injectionLabel")}
760793
/>
761794
{injectionModel && injectionEfforts.length > 0 && (
@@ -782,11 +815,11 @@ export default function Dashboard({ apiBase }: { apiBase: string }) {
782815
} catch { /* ignore */ }
783816
finally { setInjectionSaving(false); }
784817
}}
785-
disabled={injectionSaving}
818+
disabled={injectionSaving || !multiAgentGuidanceEnabled}
786819
label={t("dash.injectionEffortLabel")}
787820
/>
788821
)}
789-
{injectionModel && <span className="badge badge-green text-micro">{t("dash.injectionActive")}</span>}
822+
{multiAgentGuidanceEnabled && injectionModel && <span className="badge badge-green text-micro">{t("dash.injectionActive")}</span>}
790823
</div>
791824
<div className="muted text-control" style={{ marginTop: 6 }}>{t("dash.injectionHint")}</div>
792825
</div>

src/config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,7 @@ const configSchema = z.object({
429429
openaiProviderTierVersion: z.union([z.literal(1), z.literal(2)]).optional(),
430430
providerContextCaps: z.record(z.string(), z.number().int().positive()).optional(),
431431
contextCapValue: z.number().int().positive().optional(),
432+
multiAgentGuidanceEnabled: z.boolean().optional(),
432433
}).passthrough().superRefine((config, ctx) => {
433434
for (const name of Object.keys(config.providers)) {
434435
if (!isValidProviderName(name)) {
@@ -749,6 +750,12 @@ export function codexAutoStartEnabled(config: Pick<OcxConfig, "codexAutoStart">)
749750
return config.codexAutoStart !== false;
750751
}
751752

753+
export function multiAgentGuidanceEnabled(
754+
config: Pick<OcxConfig, "multiAgentGuidanceEnabled">,
755+
): boolean {
756+
return config.multiAgentGuidanceEnabled !== false;
757+
}
758+
752759
export function getDefaultConfig(): OcxConfig {
753760
// Fresh-install default: works out of the box with Codex's ChatGPT OAuth (no API key).
754761
// gpt-* requests forward the caller's incoming OAuth headers to the ChatGPT backend.
@@ -769,6 +776,7 @@ export function getDefaultConfig(): OcxConfig {
769776
},
770777
defaultProvider: "openai",
771778
subagentModels: [...DEFAULT_SUBAGENT_MODELS],
779+
multiAgentGuidanceEnabled: true,
772780
websockets: false,
773781
codexAutoStart: true,
774782
};

src/server/management-api.ts

Lines changed: 57 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
codexAutoStartEnabled,
88
hasOwnProvider,
99
isValidProviderName,
10+
multiAgentGuidanceEnabled,
1011
providerBaseUrlConfigError,
1112
providerHeadersConfigError,
1213
saveConfig,
@@ -1105,6 +1106,7 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
11051106
stored === m.namespaced || slugEquals(stored, m.provider, m.model)
11061107
)));
11071108
return jsonResponse({
1109+
multiAgentGuidanceEnabled: multiAgentGuidanceEnabled(config),
11081110
model: config.injectionModel ?? null,
11091111
effort: config.injectionEffort ?? null,
11101112
prompt: config.injectionPrompt ?? null,
@@ -1113,34 +1115,69 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
11131115
});
11141116
}
11151117
if (url.pathname === "/api/injection-model" && req.method === "PUT") {
1116-
let body: { model?: unknown; effort?: unknown; prompt?: unknown };
1117-
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
1118+
let parsedBody: unknown;
1119+
try { parsedBody = await req.json(); } catch {
1120+
return jsonResponse({ error: "invalid JSON body" }, 400);
1121+
}
1122+
if (!parsedBody || typeof parsedBody !== "object" || Array.isArray(parsedBody)) {
1123+
return jsonResponse({ error: "body must be a JSON object" }, 400);
1124+
}
1125+
const body = parsedBody as {
1126+
multiAgentGuidanceEnabled?: unknown;
1127+
model?: unknown;
1128+
effort?: unknown;
1129+
prompt?: unknown;
1130+
};
11181131
const { isCodexReasoningEffort } = await import("../reasoning-effort");
1119-
const model = typeof body.model === "string" && body.model.length > 0 ? body.model : undefined;
1120-
let effort = config.injectionEffort;
1121-
// `effort` key semantics: absent -> unchanged; null/"" -> clear; ladder value -> set;
1122-
// anything else -> 400. Clearing the model always clears the effort (it is meaningless alone).
1132+
1133+
let nextEnabled = config.multiAgentGuidanceEnabled;
1134+
let nextModel = config.injectionModel;
1135+
let nextEffort = config.injectionEffort;
1136+
let nextPrompt = config.injectionPrompt;
1137+
1138+
if ("multiAgentGuidanceEnabled" in body) {
1139+
if (typeof body.multiAgentGuidanceEnabled !== "boolean") {
1140+
return jsonResponse({ error: "multiAgentGuidanceEnabled must be a boolean" }, 400);
1141+
}
1142+
nextEnabled = body.multiAgentGuidanceEnabled;
1143+
}
1144+
if ("model" in body) {
1145+
if (body.model === null || body.model === "") nextModel = undefined;
1146+
else if (typeof body.model === "string" && body.model.length > 0) nextModel = body.model;
1147+
else return jsonResponse({ error: "model must be a non-empty string or null" }, 400);
1148+
}
11231149
if ("effort" in body) {
1124-
const requestedEffort = typeof body.effort === "string" && body.effort.length > 0 ? body.effort : undefined;
1125-
if (requestedEffort !== undefined && !isCodexReasoningEffort(requestedEffort)) {
1126-
return jsonResponse({ error: `unknown reasoning effort "${requestedEffort}"` }, 400);
1150+
if (body.effort === null || body.effort === "") nextEffort = undefined;
1151+
else if (typeof body.effort === "string" && isCodexReasoningEffort(body.effort)) {
1152+
nextEffort = body.effort;
1153+
} else {
1154+
return jsonResponse({ error: `unknown reasoning effort "${String(body.effort)}"` }, 400);
11271155
}
1128-
effort = requestedEffort;
11291156
}
1130-
if (!model) effort = undefined;
1131-
if (model) config.injectionModel = model;
1132-
else delete config.injectionModel;
1133-
if (effort) config.injectionEffort = effort;
1134-
else delete config.injectionEffort;
1135-
// `prompt` key semantics mirror `effort`: absent -> unchanged; null/"" -> clear;
1136-
// non-empty string -> set (custom <multi_agent_mode> body, {{model}}/{{effort}}/{{roster}} placeholders).
11371157
if ("prompt" in body) {
1138-
if (typeof body.prompt === "string" && body.prompt.trim().length > 0) config.injectionPrompt = body.prompt;
1139-
else if (body.prompt === null || body.prompt === "") delete config.injectionPrompt;
1158+
if (typeof body.prompt === "string" && body.prompt.trim().length > 0) nextPrompt = body.prompt;
1159+
else if (body.prompt === null || body.prompt === "") nextPrompt = undefined;
11401160
else return jsonResponse({ error: "prompt must be a string or null" }, 400);
11411161
}
1162+
// Clearing the model always clears the effort (it is meaningless alone).
1163+
if (!nextModel) nextEffort = undefined;
1164+
1165+
config.multiAgentGuidanceEnabled = nextEnabled;
1166+
if (nextModel) config.injectionModel = nextModel;
1167+
else delete config.injectionModel;
1168+
if (nextEffort) config.injectionEffort = nextEffort;
1169+
else delete config.injectionEffort;
1170+
if (nextPrompt) config.injectionPrompt = nextPrompt;
1171+
else delete config.injectionPrompt;
1172+
11421173
saveConfig(config);
1143-
return jsonResponse({ ok: true, model: config.injectionModel ?? null, effort: config.injectionEffort ?? null, prompt: config.injectionPrompt ?? null });
1174+
return jsonResponse({
1175+
ok: true,
1176+
multiAgentGuidanceEnabled: multiAgentGuidanceEnabled(config),
1177+
model: config.injectionModel ?? null,
1178+
effort: config.injectionEffort ?? null,
1179+
prompt: config.injectionPrompt ?? null,
1180+
});
11441181
}
11451182

11461183
// Hard reasoning-effort caps (devlog/260710_subagent_effort_intercept): a global ceiling and a

0 commit comments

Comments
 (0)