Skip to content

Commit b19dec1

Browse files
committed
gui+server: subagent prompt injection-model pick (API + dashboard select)
- /api/injection-model GET/PUT: pick one routed/native model whose info is injected into the v1 ultra proactive prompt; disabled models filtered - dashboard injection-model select + i18n (en/ko/zh); server wiring - tests: multi-agent-compat coverage for the injection pick (16 pass)
1 parent e5447c5 commit b19dec1

10 files changed

Lines changed: 180 additions & 19 deletions

File tree

gui/src/i18n/en.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ export const en = {
5252
"dash.visionModelHint": "Model used to describe images for text-only routed models. Requires ChatGPT login.",
5353
"dash.sidecarSaved": "Sidecar settings saved. Applied on the next request.",
5454
"dash.sidecarSaveFailed": "Failed to save sidecar settings.",
55+
"dash.injectionLabel": "Sub-agent delegation",
56+
"dash.injectionHint": "Pick a routed model to inject into the delegation prompt. The agent will be told to use it for sub-tasks.",
57+
"dash.injectionActive": "Active",
58+
"dash.injectionNone": "None",
5559
"dash.maintenance": "Maintenance",
5660
"dash.maintenanceHint": "Refresh Codex's model catalog or install a newer opencodex release.",
5761
"dash.syncModels": "Sync models",

gui/src/i18n/ko.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,11 @@ export const ko: Record<TKey, string> = {
5252
"dash.visionModelHint": "텍스트 전용 라우팅 모델에 이미지를 설명하는 데 사용되는 모델입니다. ChatGPT 로그인 필요.",
5353
"dash.sidecarSaved": "사이드카 설정이 저장됐습니다. 다음 요청부터 적용됩니다.",
5454
"dash.sidecarSaveFailed": "사이드카 설정 저장에 실패했습니다.",
55-
"dash.maintenance": "유지보수",
55+
"dash.injectionLabel": "서브에이전트 위임",
56+
"dash.injectionHint": "위임 프롬프트에 주입할 라우팅 모델을 선택합니다. 에이전트가 서브태스크에 이 모델을 사용하도록 안내됩니다.",
57+
"dash.injectionActive": "활성",
58+
"dash.injectionNone": "없음",
59+
"dash.maintenance": "유지보수",
5660
"dash.maintenanceHint": "Codex 모델 카탈로그를 새로고침하거나 최신 opencodex 릴리스를 설치합니다.",
5761
"dash.syncModels": "모델 동기화",
5862
"dash.syncing": "동기화 중…",

gui/src/i18n/zh.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,11 @@ export const zh: Record<TKey, string> = {
5252
"dash.visionModelHint": "为纯文本路由模型描述图像的模型。需要 ChatGPT 登录。",
5353
"dash.sidecarSaved": "附属设置已保存。将在下一个请求时生效。",
5454
"dash.sidecarSaveFailed": "保存附属设置失败。",
55-
"dash.maintenance": "维护",
55+
"dash.injectionLabel": "子代理委托",
56+
"dash.injectionHint": "选择要注入委托提示的路由模型。代理将被告知在子任务中使用它。",
57+
"dash.injectionActive": "已激活",
58+
"dash.injectionNone": "无",
59+
"dash.maintenance": "维护",
5660
"dash.maintenanceHint": "刷新 Codex 模型目录,或安装新的 opencodex 版本。",
5761
"dash.syncModels": "同步模型",
5862
"dash.syncing": "同步中…",

gui/src/pages/Dashboard.tsx

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ export default function Dashboard({ apiBase }: { apiBase: string }) {
8686
const [maMode, setMaMode] = useState<"v1" | "default" | "v2">("default");
8787
const [maBusy, setMaBusy] = useState(false);
8888
const [maHelpOpen, setMaHelpOpen] = useState(false);
89+
const [injectionModel, setInjectionModel] = useState<string>("");
90+
const [injectionAvailable, setInjectionAvailable] = useState<Array<{ provider: string; model: string; namespaced: string }>>([]);
91+
const [injectionSaving, setInjectionSaving] = useState(false);
8992
const [syncResult, setSyncResult] = useState<SyncResult | null>(null);
9093
const [syncError, setSyncError] = useState<string | null>(null);
9194
const [projectConfigWarnings, setProjectConfigWarnings] = useState<ProjectCodexConfigGroup[]>([]);
@@ -124,6 +127,14 @@ export default function Dashboard({ apiBase }: { apiBase: string }) {
124127
else setMaMode("default");
125128
}
126129
} catch { /* old server */ }
130+
try {
131+
const imRes = await fetch(`${apiBase}/api/injection-model`);
132+
if (imRes.ok) {
133+
const imData = await imRes.json() as { model?: string | null; available?: Array<{ provider: string; model: string; namespaced: string }> };
134+
setInjectionModel(imData.model ?? "");
135+
setInjectionAvailable(imData.available ?? []);
136+
}
137+
} catch { /* old server */ }
127138
} catch {
128139
setError(true);
129140
}
@@ -422,6 +433,44 @@ export default function Dashboard({ apiBase }: { apiBase: string }) {
422433
</div>
423434
)}
424435

436+
<div className="panel" style={{ marginBottom: 24 }}>
437+
<div className="spread setting-row" style={{ alignItems: "flex-start" }}>
438+
<div className="setting-copy" style={{ flex: 1 }}>
439+
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
440+
<span style={{ fontWeight: 650 }}>{t("dash.injectionLabel")}</span>
441+
{injectionModel && <span className="badge badge-green" style={{ fontSize: 10 }}>{t("dash.injectionActive")}</span>}
442+
</div>
443+
<div className="muted setting-hint">{t("dash.injectionHint")}</div>
444+
</div>
445+
<Select
446+
value={injectionModel}
447+
options={[
448+
{ value: "", label: t("dash.injectionNone") },
449+
...injectionAvailable.map(m => ({ value: m.namespaced, label: `${m.provider} / ${m.model}` })),
450+
]}
451+
onChange={async (v) => {
452+
if (injectionSaving) return;
453+
setInjectionSaving(true);
454+
setInjectionModel(v);
455+
try {
456+
const res = await fetch(`${apiBase}/api/injection-model`, {
457+
method: "PUT",
458+
headers: { "Content-Type": "application/json" },
459+
body: JSON.stringify({ model: v || null }),
460+
});
461+
if (res.ok) {
462+
const data = await res.json() as { model?: string | null };
463+
setInjectionModel(data.model ?? "");
464+
}
465+
} catch { /* ignore */ }
466+
finally { setInjectionSaving(false); }
467+
}}
468+
disabled={injectionSaving}
469+
label={t("dash.injectionLabel")}
470+
/>
471+
</div>
472+
</div>
473+
425474
<div className="panel maintenance-panel" style={{ marginBottom: 24 }}>
426475
<div className="spread maintenance-head">
427476
<div>

gui/src/ui.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,15 @@ export function Notice({ tone, children }: { tone: "ok" | "err"; children: React
2323

2424
export interface SelectOption { value: string; label: React.ReactNode }
2525

26-
export function Select({ value, options, onChange, disabled, label, style }: {
26+
export function Select({ value, options, onChange, disabled, label, style, align, dropdownStyle }: {
2727
value: string;
2828
options: SelectOption[];
2929
onChange: (value: string) => void;
3030
disabled?: boolean;
3131
label?: string;
3232
style?: CSSProperties;
33+
align?: "left" | "right";
34+
dropdownStyle?: CSSProperties;
3335
}) {
3436
const [open, setOpen] = useState(false);
3537
const ref = useRef<HTMLDivElement>(null);
@@ -59,7 +61,7 @@ export function Select({ value, options, onChange, disabled, label, style }: {
5961
<IconChevron style={{ width: 12, height: 12, color: "var(--muted)", transform: open ? "rotate(90deg)" : "none", transition: "transform .12s" }} />
6062
</button>
6163
{open && (
62-
<div className="select-dropdown" role="listbox" aria-label={label}>
64+
<div className="select-dropdown" role="listbox" aria-label={label} style={{ ...(align === "right" ? { left: "auto", right: 0 } : {}), ...dropdownStyle }}>
6365
{options.map(o => (
6466
<button
6567
key={o.value}

src/server/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,11 +158,11 @@ export function startServer(port?: number) {
158158
config.subagentModels = [...DEFAULT_SUBAGENT_MODELS];
159159
saveConfig(config);
160160
}
161-
// Sidecar model migration (KST 2026-06-10 06:00 = UTC 2026-06-09 21:00): auto-migrate the old
161+
// Sidecar model migration (KST 2026-07-10 06:00 = UTC 2026-07-09 21:00): auto-migrate the old
162162
// gpt-5.4-mini default to gpt-5.6-luna for both search and vision sidecars. Only touches configs
163163
// still on the old default — explicit user choices are preserved.
164164
{
165-
const SIDECAR_MIGRATION_CUTOFF = Date.UTC(2026, 5, 9, 21, 0); // June 9 21:00 UTC = KST June 10 06:00
165+
const SIDECAR_MIGRATION_CUTOFF = Date.UTC(2026, 6, 9, 21, 0); // July 9 21:00 UTC = KST July 10 06:00
166166
if (Date.now() >= SIDECAR_MIGRATION_CUTOFF) {
167167
let migrated = false;
168168
if (config.webSearchSidecar?.model === "gpt-5.4-mini") {

src/server/management-api.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,31 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
517517
return jsonResponse({ providers: deriveProviderPresets() });
518518
}
519519

520+
// Subagent prompt injection model: single routed model whose info is dynamically
521+
// injected into the v1 ultra proactive prompt. GET returns the current pick + available
522+
// routed models; PUT sets or clears the pick.
523+
if (url.pathname === "/api/injection-model" && req.method === "GET") {
524+
const models = await fetchAllModels(config);
525+
const disabled = new Set(config.disabledModels ?? []);
526+
const { listCatalogNativeSlugs } = await import("../codex/catalog");
527+
const nativeModels = listCatalogNativeSlugs()
528+
.filter(slug => !disabled.has(slug))
529+
.map(slug => ({ provider: "openai", model: slug, namespaced: slug }));
530+
const routedModels = models
531+
.map(m => ({ provider: m.provider, model: m.id, namespaced: `${m.provider}/${m.id}` }))
532+
.filter(m => !disabled.has(m.namespaced));
533+
return jsonResponse({ model: config.injectionModel ?? null, available: [...nativeModels, ...routedModels] });
534+
}
535+
if (url.pathname === "/api/injection-model" && req.method === "PUT") {
536+
let body: { model?: unknown };
537+
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
538+
const model = typeof body.model === "string" && body.model.length > 0 ? body.model : undefined;
539+
if (model) config.injectionModel = model;
540+
else delete config.injectionModel;
541+
saveConfig(config);
542+
return jsonResponse({ ok: true, model: config.injectionModel ?? null });
543+
}
544+
520545
// Subagent model picker: which ≤5 routed models Codex's spawn_agent advertises (it shows the
521546
// first 5 routed catalog entries). PUT reorders the injected catalog so the chosen ones lead.
522547
if (url.pathname === "/api/subagent-models" && req.method === "GET") {

src/server/responses.ts

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,13 @@ export function buildToolBridgeMaps(parsed: OcxParsedRequest): {
7575
}
7676

7777
/** Verbatim upstream Proactive text (codex-rs core/src/context/multi_agent_mode_instructions.rs). */
78-
const PROACTIVE_MULTI_AGENT_MODE_TEXT =
79-
"Proactive multi-agent delegation is active. Any earlier instruction requiring an explicit user request before spawning sub-agents no longer applies. Use sub-agents when parallel work would materially improve speed or quality. This mode remains active until a later multi-agent mode developer message changes it.";
78+
const PROACTIVE_MULTI_AGENT_MODE_TEXT = [
79+
"Proactive multi-agent delegation is active.",
80+
"Any earlier instruction requiring an explicit user request before spawning sub-agents no longer applies.",
81+
"Delegate independent sub-tasks to sub-agents whenever parallel work would materially improve speed or quality — do not serialize work that can run concurrently.",
82+
"Each sub-agent runs in its own context and can use all available tools; prefer spawning specialists over doing everything yourself.",
83+
"This mode remains active until a later multi-agent mode developer message changes it.",
84+
].join(" ");
8085

8186
/**
8287
* True when this turn runs the v1 collab surface, judged from the request's own tool list
@@ -98,19 +103,39 @@ export function isV1CollabSurface(parsed: OcxParsedRequest): boolean {
98103
}
99104

100105
/**
101-
* Multi-agent guidance for this turn, or null when nothing applies. codex-rs only emits
102-
* its Proactive delegation developer message on the v2 surface, so when a v1-surface turn
103-
* arrives at the synthetic top tier (codex converts ultra -> max on the wire, so max
104-
* arrival means the user picked the top rung) the proxy supplies the same one-liner,
105-
* wrapped in codex's own <multi_agent_mode> tags (v1 turns never carry that fragment, so
106-
* there is nothing to collide with). Ultra is always advertised, so the guidance fires
107-
* regardless of the multi_agent_v2 toggle.
106+
* Multi-agent guidance for this turn, or null when nothing applies.
107+
*
108+
* codex-rs only emits its Proactive delegation developer message on the v2 surface,
109+
* so when a v1-surface turn arrives at the synthetic top tier (codex converts
110+
* ultra -> max on the wire, so max arrival means the user picked the top rung) the
111+
* proxy supplies the same one-liner, wrapped in codex's own <multi_agent_mode> tags
112+
* (v1 turns never carry that fragment, so there is nothing to collide with).
113+
* Ultra is always advertised, so the guidance fires regardless of the multi_agent_v2
114+
* toggle.
115+
*
116+
* Dynamic model injection: when the user has configured a specific injectionModel,
117+
* the prompt names it so the agent knows which routed model to delegate to.
118+
*
119+
* Effort gate relaxation: when an injectionModel is set, the prompt fires at every
120+
* effort level, not just max/ultra — the user opted into delegation.
108121
*/
109-
export async function multiAgentGuidanceText(parsed: OcxParsedRequest): Promise<string | null> {
122+
export async function multiAgentGuidanceText(parsed: OcxParsedRequest, injectionModel?: string): Promise<string | null> {
110123
if (!isV1CollabSurface(parsed)) return null;
111124
const effort = parsed.options.reasoning;
112-
if (effort !== "max" && effort !== "ultra") return null;
113-
return `<multi_agent_mode>${PROACTIVE_MULTI_AGENT_MODE_TEXT}</multi_agent_mode>`;
125+
// When the user has selected a specific injection model, fire the delegation prompt
126+
// at ANY effort level. Otherwise preserve the original gate: top tier only (max/ultra).
127+
if (!injectionModel && effort !== "max" && effort !== "ultra") return null;
128+
129+
let text = PROACTIVE_MULTI_AGENT_MODE_TEXT;
130+
131+
// Append the selected model when the user has configured a specific injection target.
132+
if (injectionModel) {
133+
text += `\n\nA routed model is configured as the preferred sub-agent: "${injectionModel}". `
134+
+ "To delegate work to it, use spawn_agent with this model id. "
135+
+ "Use it for tasks that benefit from this model's strengths.";
136+
}
137+
138+
return `<multi_agent_mode>${text}</multi_agent_mode>`;
114139
}
115140

116141
/**
@@ -322,7 +347,7 @@ export async function handleResponses(
322347
const rewritten = sanitizeEncryptedContentInPlace(raw?.input);
323348
if (rewritten > 0) console.warn(`[opencodex] ${route.modelId}: rewrote ${rewritten} plaintext encrypted_content part(s) to input_text (routed-parent spawn compatibility)`);
324349
}
325-
const guidance = await multiAgentGuidanceText(parsed);
350+
const guidance = await multiAgentGuidanceText(parsed, config.injectionModel);
326351
if (guidance) injectDeveloperMessage(parsed, guidance);
327352
}
328353

src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,7 @@ export interface OcxConfig {
244244
* Codex's spawn_agent only advertises the first 5 routed models, so this picks which 5 appear.
245245
*/
246246
subagentModels?: string[];
247+
injectionModel?: string;
247248
/**
248249
* Models hidden from Codex. Routed ids are namespaced ("<provider>/<model>") and are excluded
249250
* from the catalog + /v1/models entirely. BARE ids (no "/") are native GPT passthrough slugs:

tests/multi-agent-compat.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,53 @@ describe("multiAgentGuidanceText", () => {
7777
}));
7878
expect(text).toContain("<multi_agent_mode>");
7979
});
80+
81+
test("injectionModel is named in the dynamic section", async () => {
82+
codexHomeFixture(V2_OFF);
83+
const text = await multiAgentGuidanceText(
84+
parsedFixture({ reasoning: "max", tools: [{ name: "spawn_agent", namespace: "agents" }] }),
85+
"anthropic/claude-sonnet-5",
86+
);
87+
expect(text).toContain("Proactive multi-agent delegation is active");
88+
expect(text).toContain('"anthropic/claude-sonnet-5"');
89+
expect(text).toContain("spawn_agent");
90+
});
91+
92+
test("no injectionModel produces base prompt only at max", async () => {
93+
codexHomeFixture(V2_OFF);
94+
const text = await multiAgentGuidanceText(
95+
parsedFixture({ reasoning: "max", tools: [{ name: "spawn_agent", namespace: "agents" }] }),
96+
);
97+
expect(text).toContain("Proactive multi-agent delegation is active");
98+
expect(text).not.toContain("routed model");
99+
});
100+
101+
test("injectionModel fires prompt even at low effort", async () => {
102+
codexHomeFixture(V2_OFF);
103+
const text = await multiAgentGuidanceText(
104+
parsedFixture({ reasoning: "high", tools: [{ name: "spawn_agent", namespace: "agents" }] }),
105+
"opencode-go/glm-5.2",
106+
);
107+
expect(text).toContain("Proactive multi-agent delegation is active");
108+
expect(text).toContain('"opencode-go/glm-5.2"');
109+
});
110+
111+
test("injectionModel fires prompt even without effort set", async () => {
112+
codexHomeFixture(V2_OFF);
113+
const text = await multiAgentGuidanceText(
114+
parsedFixture({ tools: [{ name: "spawn_agent", namespace: "agents" }] }),
115+
"anthropic/claude-opus-4-6",
116+
);
117+
expect(text).toContain("Proactive multi-agent delegation is active");
118+
});
119+
120+
test("without injectionModel, low effort stays silent", async () => {
121+
codexHomeFixture(V2_OFF);
122+
const v1Tools = [{ name: "spawn_agent", namespace: "agents" }];
123+
expect(await multiAgentGuidanceText(parsedFixture({ reasoning: "high", tools: v1Tools }))).toBeNull();
124+
expect(await multiAgentGuidanceText(parsedFixture({ reasoning: "medium", tools: v1Tools }))).toBeNull();
125+
expect(await multiAgentGuidanceText(parsedFixture({ reasoning: "max", tools: v1Tools }))).not.toBeNull();
126+
});
80127
});
81128

82129
describe("injectDeveloperMessage", () => {

0 commit comments

Comments
 (0)