diff --git a/src/admin/router.ts b/src/admin/router.ts index 740e161..3708286 100644 --- a/src/admin/router.ts +++ b/src/admin/router.ts @@ -1066,6 +1066,27 @@ async function handleApi(ctx: RouteContext): Promise { return sendError(res, 405, "method_not_allowed", "use GET or PUT"); } + // GET/PUT /admin/api/super-mode — "Super Mode" debate toggle. + // When enabled, every request is first discussed by two model instances + // until they reach consensus, then a third instance executes the agreed plan. + if (pathname === "/admin/api/super-mode") { + if (req.method === "GET") { + const enabled = (() => { + try { return getSetting("codex.superMode") === "1"; } catch { return false; } + })(); + return sendJson(res, 200, { enabled }); + } + if (req.method === "PUT") { + const body = await readJsonBody<{ enabled?: unknown }>(req); + if (typeof body.enabled !== "boolean") { + return sendError(res, 400, "invalid_body", "enabled (boolean) is required"); + } + setSetting("codex.superMode", body.enabled ? "1" : "0"); + log.info("codex.superMode set to " + body.enabled + " via admin UI"); + return sendJson(res, 200, { ok: true }); + } + return sendError(res, 405, "method_not_allowed", "use GET or PUT"); + } // GET/PUT /admin/api/log-settings — quick toggle for the "model fallback // applied" rewrite log. Default is silent (suppressed). env // MIMO2CODEX_SILENT_REWRITE, when set, overrides and disables the toggle. diff --git a/src/server.ts b/src/server.ts index 78239be..0020dbd 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3,6 +3,7 @@ import type { Config } from "./config.js"; import { respToResponses } from "./translate/respToResponses.js"; import { pipeChatStreamToResponses, type StreamPipelineResult } from "./translate/streamToSse.js"; import { iterChatStreamChunks } from "./upstream/chatStream.js"; +import { runDebate } from "./upstream/debate.js"; import { maybeCompactChat, type ChatCaller } from "./translate/autoCompact.js"; import { callOpenAICompat, @@ -13,7 +14,7 @@ import { BUILTIN_PROVIDERS, PROVIDER_LIST, PROVIDERS } from "./providers/registr import type { Provider, ProviderModel, ProviderRuntime } from "./providers/types.js"; import { makeServerResponseSink } from "./util/sse.js"; import { log } from "./util/log.js"; -import type { ChatRequest, ChatResponse, ChatUsage, ResponsesRequest } from "./translate/types.js"; +import type { ChatMessage, ChatRequest, ChatResponse, ChatUsage, ResponsesRequest } from "./translate/types.js"; import { handleAdmin } from "./admin/router.js"; import { authGuard } from "./auth/middleware.js"; import { resolveRuntimeForUser } from "./auth/byok.js"; @@ -67,6 +68,17 @@ function resolveForceHighEffort(cfg: Config): boolean { // silentRewrite 解析:env (cfg.silentRewriteFromCli) > admin settings DB > true。 // 注意默认是 **静默**(true)—— admin UI 顶部「更多」里有快速开关。每请求调一次, // admin 改了立即生效,无需重启。 +// superMode 解析:admin settings DB → false。 +// 启用后,每个请求先由两个模型实例辩论达成共识,再由第三个实例执行。 +function resolveSuperMode(cfg: Config): boolean { + if (!cfg.adminEnabled) return false; + try { + return getSetting("codex.superMode") === "1"; + } catch { + return false; + } +} + function resolveSilentRewrite(cfg: Config): boolean { if (cfg.silentRewriteFromCli !== undefined) return cfg.silentRewriteFromCli; if (!cfg.adminEnabled) return true; @@ -768,6 +780,70 @@ async function handleResponses( const ac = new AbortController(); req.on("close", () => ac.abort()); + // --- Super Mode (debate) --- + // When enabled, two model instances debate the best approach before the + // executor (the normal upstream call) carries out the agreed plan. + const superModeEnabled = resolveSuperMode(cfg); + if (superModeEnabled) { + const taskParts: string[] = []; + if (payload.instructions) { + taskParts.push("[System Instructions]\n" + payload.instructions.slice(0, 2000)); + } + let charBudget = 3000; + for (let i = chat.messages.length - 1; i >= 0 && charBudget > 0; i--) { + const m = chat.messages[i]; + const text = typeof m.content === "string" ? m.content : ""; + if (!text) continue; + const snippet = text.slice(0, Math.min(text.length, charBudget)); + taskParts.unshift("[" + m.role + "]\n" + snippet); + charBudget -= snippet.length; + } + if (chat.tools && chat.tools.length > 0) { + const toolNames = chat.tools + .map((t) => ("function" in t && t.function?.name) || ("name" in t && t.name) || "") + .filter(Boolean) + .join(", "); + taskParts.push("[Available Tools]\n" + toolNames); + } + const taskSummary = taskParts.join("\n\n"); + + try { + log.info("super mode: starting debate", { + model: upstreamModel, + messages: chat.messages.length, + tools: chat.tools?.length ?? 0, + }); + const debateResult = await runDebate( + taskSummary, + { runtime, upstreamModel, userAgent: cfg.userAgent }, + ac.signal, + ); + log.info("super mode: debate complete", { + rounds: debateResult.rounds, + agreed: debateResult.agreed, + consensusLen: debateResult.consensus.length, + }); + + if (debateResult.consensus) { + const consensusMsg: ChatMessage = { + role: "system", + content: + debateResult.consensus + + "\n\n[Debate metadata: " + debateResult.rounds + " round(s), " + + (debateResult.agreed ? "consensus reached" : "best-effort summary") + "]", + }; + let insertIdx = 0; + for (let i = 0; i < chat.messages.length; i++) { + if (chat.messages[i].role === "system") insertIdx = i + 1; + } + chat.messages.splice(insertIdx, 0, consensusMsg); + } + } catch (err) { + log.warn("super mode: debate failed, proceeding without consensus", { + error: (err as Error).message, + }); + } + } // Auto-compaction: when the estimated input crosses the token trigger, // summarize the older middle of the conversation before forwarding. The diff --git a/src/upstream/debate.ts b/src/upstream/debate.ts new file mode 100644 index 0000000..e8e57c9 --- /dev/null +++ b/src/upstream/debate.ts @@ -0,0 +1,337 @@ +import type { ChatMessage, ChatRequest, ChatResponse } from "../translate/types.js"; +import type { ProviderRuntime } from "../providers/types.js"; +import { callOpenAICompat } from "./openaiCompatClient.js"; +import { log } from "../util/log.js"; + +// --------------------------------------------------------------------------- +// Debate runner — "Super Mode" +// +// Three participants: +// Debater 1 (proposer) — analyzes the task, proposes an execution plan +// Debater 2 (challenger) — critiques and refines the proposal +// Arbitrator (judge) — after each round of both debaters speaking, +// reads their outputs and decides whether they +// have reached genuine consensus +// +// The debate has no round limit. It ends when: +// - The Arbitrator judges that consensus has been reached +// - The client disconnects (signal.aborted) +// - Safety cap (100 rounds) is hit (practically unreachable) +// +// Only the Arbitrator's final consensus enters the executor's context. +// The full debate transcript is excluded from the executor to save tokens. +// --------------------------------------------------------------------------- + +export interface DebateConfig { + runtime: ProviderRuntime; + upstreamModel: string; + userAgent: string; + maxTokensPerTurn?: number; + /** Max tokens for the arbitrator's judgment. Default: 1024. */ + arbitratorMaxTokens?: number; +} + +export interface DebateResult { + /** Final consensus text to inject into the executor's context. */ + consensus: string; + /** Full transcript for logging / debugging. */ + transcript: DebateTurn[]; + /** Number of rounds actually executed. */ + rounds: number; + /** Whether the arbitrator judged that consensus was reached. */ + agreed: boolean; +} + +export interface DebateTurn { + round: number; + role: "debater1" | "debater2"; + content: string; +} + +// ─── System prompts ──────────────────────────────────────────────────────── + +const DEBATER1_SYSTEM = [ + "You are Debater 1 (Mimo-v2.5-pro1) in a strategy debate.", + "Your job: analyze the user's task and propose the best approach for executing it.", + "Focus on: what tools to call, what files to read/write, what commands to run, and in what order.", + "Be specific and actionable — your proposal will be used by an executor agent.", + "Keep responses concise and structured (bullet points preferred).", + "", + "IMPORTANT: At the end of your message, output a JSON block on its own line:", + "```json", + '{"consensus": false, "text": ""}', + "```", + 'Set "consensus" to true ONLY when you fully agree with the other debater\'s latest proposal and believe the plan is ready for execution.', +].join("\n"); + +const DEBATER2_SYSTEM = [ + "You are Debater 2 (Mimo-v2.5-pro2) in a strategy debate.", + "Your job: critically evaluate Debater 1's proposals and suggest improvements or alternatives.", + "Challenge assumptions, identify risks, and propose better approaches when warranted.", + "Be specific and actionable — your feedback will shape the final execution plan.", + "Keep responses concise and structured (bullet points preferred).", + "", + "IMPORTANT: At the end of your message, output a JSON block on its own line:", + "```json", + '{"consensus": false, "text": ""}', + "```", + 'Set "consensus" to true ONLY when you fully agree with Debater 1\'s latest proposal and believe the plan is ready for execution.', +].join("\n"); + +const ARBITRATOR_SYSTEM = [ + "You are the Arbitrator (Mimo-v2.5-pro3) in a strategy debate.", + "Your job: judge whether two debaters have reached genuine consensus on an execution plan.", + "", + "After reading both debaters' latest outputs, you must decide:", + ' - "consensus": true — their positions have converged into a coherent, actionable plan', + ' - "consensus": false — they still disagree on important details, the debate must continue', + "", + "Evaluate objectively:", + " - Are the proposed steps compatible, not contradictory?", + " - Do both debaters agree on the key decisions (which tools, which files, which approach)?", + " - Is the plan specific enough for an executor to act on without ambiguity?", + "", + 'If consensus is true, your "summary" field MUST contain the unified execution plan,', + "combining the best elements of both debaters' positions into a single actionable strategy.", + 'If consensus is false, your "summary" field should briefly note the remaining disagreement.', + "", + "Be decisive. Do not prolong the debate unnecessarily — if the proposals are broadly", + "compatible and the disagreement is minor, declare consensus and merge them.", + "", + "Output exactly one JSON block:", + "```json", + '{"consensus": true_or_false, "summary": ""}', + "```", +].join("\n"); + +// ─── Helpers ─────────────────────────────────────────────────────────────── + +/** + * Extract the last ```json ... ``` block from a message. + */ +function extractJsonBlock(content: string): T | null { + const regex = /```json\s*\n?([\s\S]*?)\n?\s*```/g; + let lastMatch: RegExpExecArray | null = null; + let m: RegExpExecArray | null; + while ((m = regex.exec(content)) !== null) { + lastMatch = m; + } + if (!lastMatch) return null; + try { + return JSON.parse(lastMatch[1].trim()) as T; + } catch { + return null; + } +} + +interface ArbitratorJudgment { + consensus: boolean; + summary: string; +} + +/** + * Ask the Arbitrator whether the debate has reached consensus. + * Reads the latest outputs from both debaters. + */ +async function askArbitrator( + round: number, + d1Latest: string, + d2Latest: string, + cfg: DebateConfig, + signal: AbortSignal, +): Promise { + const maxTokens = cfg.arbitratorMaxTokens ?? 1024; + + const prompt = [ + "Round " + round + " of the debate has concluded.", + "", + "--- Debater 1 (Mimo-v2.5-pro1) latest output ---", + d1Latest, + "", + "--- Debater 2 (Mimo-v2.5-pro2) latest output ---", + d2Latest, + "", + "Judge whether these two positions have converged into a coherent execution plan.", + ].join("\n"); + + const body: ChatRequest = { + model: cfg.upstreamModel, + messages: [ + { role: "system", content: ARBITRATOR_SYSTEM }, + { role: "user", content: prompt }, + ], + stream: false, + max_completion_tokens: maxTokens, + }; + + log.info("debate round " + round + ": arbitrator judging..."); + const res = await callOpenAICompat( + { + baseUrl: cfg.runtime.baseUrl, + apiKey: cfg.runtime.apiKey, + userAgent: cfg.userAgent, + contextOverflowMode: "passthrough", + maxRetries: 1, + }, + body, + signal, + ); + const json = (await res.json()) as ChatResponse; + const content = json.choices?.[0]?.message?.content ?? ""; + + const parsed = extractJsonBlock(content); + if (parsed && typeof parsed.consensus === "boolean") { + log.info("debate round " + round + ": arbitrator judgment", { + consensus: parsed.consensus, + summaryLen: (parsed.summary ?? "").length, + }); + return parsed; + } + + // Fallback: if the arbitrator didn't output valid JSON, treat as no consensus. + log.warn("debate round " + round + ": arbitrator returned unparseable output, treating as no consensus", { + preview: content.slice(0, 200), + }); + return { consensus: false, summary: "(arbitrator output unparseable)" }; +} + +// ─── Main entry point ────────────────────────────────────────────────────── + +/** + * Run a debate between two model instances on the given user task. + * An arbitrator judges after each round whether consensus has been reached. + */ +export async function runDebate( + taskSummary: string, + cfg: DebateConfig, + signal: AbortSignal, +): Promise { + const maxTokens = cfg.maxTokensPerTurn ?? 2048; + const SAFETY_CAP = 100; + + const transcript: DebateTurn[] = []; + const debateHistory1: ChatMessage[] = []; + const debateHistory2: ChatMessage[] = []; + + let agreed = false; + let finalConsensus = ""; + + for (let round = 1; round <= SAFETY_CAP; round++) { + if (signal.aborted) break; + + // ── Debater 1 speaks ── + const d1Prompt = round === 1 + ? "The user wants to accomplish the following task. Propose the best execution plan.\n\n---\n" + taskSummary + "\n---" + : "Here is Debater 2's latest feedback. Refine your plan or express agreement.\n\n---\n" + debateHistory2[debateHistory2.length - 1].content + "\n---"; + + debateHistory1.push({ role: "user", content: d1Prompt }); + + log.info("debate round " + round + ": debater 1 thinking..."); + const d1Res = await callOpenAICompat( + { + baseUrl: cfg.runtime.baseUrl, + apiKey: cfg.runtime.apiKey, + userAgent: cfg.userAgent, + contextOverflowMode: "passthrough", + maxRetries: 1, + }, + { + model: cfg.upstreamModel, + messages: [{ role: "system", content: DEBATER1_SYSTEM }, ...debateHistory1], + stream: false, + max_completion_tokens: maxTokens, + }, + signal, + ); + const d1Json = (await d1Res.json()) as ChatResponse; + const d1Content = d1Json.choices?.[0]?.message?.content ?? ""; + debateHistory1.push({ role: "assistant", content: d1Content }); + transcript.push({ round, role: "debater1", content: d1Content }); + log.info("debate round " + round + ": debater 1 done", { contentLen: d1Content.length }); + + // ── Debater 2 responds ── + const d2Prompt = round === 1 + ? "Here is Debater 1's proposed execution plan. Critique it and suggest improvements.\n\n---\n" + d1Content + "\n---" + : "Here is Debater 1's latest response. Evaluate if you now agree, or continue refining.\n\n---\n" + d1Content + "\n---"; + + debateHistory2.push({ role: "user", content: d2Prompt }); + + log.info("debate round " + round + ": debater 2 thinking..."); + const d2Res = await callOpenAICompat( + { + baseUrl: cfg.runtime.baseUrl, + apiKey: cfg.runtime.apiKey, + userAgent: cfg.userAgent, + contextOverflowMode: "passthrough", + maxRetries: 1, + }, + { + model: cfg.upstreamModel, + messages: [{ role: "system", content: DEBATER2_SYSTEM }, ...debateHistory2], + stream: false, + max_completion_tokens: maxTokens, + }, + signal, + ); + const d2Json = (await d2Res.json()) as ChatResponse; + const d2Content = d2Json.choices?.[0]?.message?.content ?? ""; + debateHistory2.push({ role: "assistant", content: d2Content }); + transcript.push({ round, role: "debater2", content: d2Content }); + log.info("debate round " + round + ": debater 2 done", { contentLen: d2Content.length }); + + // ── Arbitrator judges ── + const judgment = await askArbitrator(round, d1Content, d2Content, cfg, signal); + + if (judgment.consensus) { + agreed = true; + finalConsensus = judgment.summary || buildConsensusSummary(transcript); + log.info("debate: arbitrator declared consensus after round " + round); + break; + } + + log.info("debate round " + round + ": no consensus yet", { + reason: judgment.summary?.slice(0, 200), + }); + } + + if (!agreed) { + finalConsensus = buildConsensusSummary(transcript); + log.info("debate: safety cap (" + SAFETY_CAP + ") reached without consensus, using best-effort summary"); + } + + return { + consensus: finalConsensus, + transcript, + rounds: transcript.length > 0 ? transcript[transcript.length - 1].round : 0, + agreed, + }; +} + +/** + * Fallback consensus builder — used when the arbitrator doesn't provide a + * summary (e.g. safety cap reached). + */ +function buildConsensusSummary(transcript: DebateTurn[]): string { + if (transcript.length === 0) return ""; + + let lastD1 = ""; + let lastD2 = ""; + for (const t of transcript) { + if (t.role === "debater1") lastD1 = t.content; + if (t.role === "debater2") lastD2 = t.content; + } + + const d1Parsed = extractJsonBlock<{ consensus?: boolean; text?: string }>(lastD1); + const d2Parsed = extractJsonBlock<{ consensus?: boolean; text?: string }>(lastD2); + + const parts: string[] = [ + "[Debate consensus — use this as your execution strategy]", + "", + "Debater 1 final position: " + (d1Parsed?.text ?? "(no structured proposal)"), + "Debater 2 final position: " + (d2Parsed?.text ?? "(no structured proposal)"), + "", + "Execute the agreed-upon plan above. If there are remaining disagreements, combine the best elements of both proposals.", + ]; + + return parts.join("\n"); +} \ No newline at end of file diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 70a3ade..dc0390d 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -633,6 +633,10 @@ export const api = { request<{ enabled: boolean; model: string }>("GET", "/vision-fallback"), setVisionFallback: (body: { enabled?: boolean; model?: string }) => request<{ ok: boolean }>("PUT", "/vision-fallback", body), + superMode: () => + request<{ enabled: boolean }>("GET", "/super-mode"), + setSuperMode: (enabled: boolean) => + request<{ ok: boolean }>("PUT", "/super-mode", { enabled }), logSettings: () => request("GET", "/log-settings"), setSilentRewrite: (silentRewrite: boolean) => request<{ ok: boolean }>("PUT", "/log-settings", { silentRewrite }), diff --git a/web/src/i18n/locales/en-US/codexEnable.json b/web/src/i18n/locales/en-US/codexEnable.json index bf59dfb..fbf7527 100644 --- a/web/src/i18n/locales/en-US/codexEnable.json +++ b/web/src/i18n/locales/en-US/codexEnable.json @@ -85,6 +85,15 @@ "modelLabel": "Fallback model", "modelPlaceholder": "e.g. mimo-v2.5" }, + "superMode": { + "title": "Super Mode", + "switchOn": "ON", + "switchOff": "OFF", + "statusOn": "Super Mode is ON", + "statusOff": "Super Mode is OFF", + "hint": "When enabled, every request is debated by two model instances with no round limit. An arbitrator judges whether consensus is reached. A third instance then executes the consensus. Debate auto-stops on client disconnect.", + "warning": "Super Mode is ON: unlimited debate rounds until the arbitrator declares consensus. Token usage and latency depend on debate complexity. Enable only when high-quality reasoning is needed." + }, "targets": { "title": "Available combinations", "externalWarn": "Your current ~/.codex/auth.json is a real login (likely Sign in with ChatGPT). 'Write files and enable' now keeps it intact by default and only redirects the model — your ChatGPT login (and OpenAI's official Codex mobile/remote) keeps working. You can still opt to overwrite it in the confirm dialog.", diff --git a/web/src/i18n/locales/zh-CN/codexEnable.json b/web/src/i18n/locales/zh-CN/codexEnable.json index 05bb87d..926a5ad 100644 --- a/web/src/i18n/locales/zh-CN/codexEnable.json +++ b/web/src/i18n/locales/zh-CN/codexEnable.json @@ -85,6 +85,15 @@ "modelLabel": "Fallback 模型", "modelPlaceholder": "例如 mimo-v2.5" }, + "superMode": { + "title": "超能模式", + "switchOn": "开", + "switchOff": "关", + "statusOn": "超能模式已开启", + "statusOff": "超能模式已关闭", + "hint": "开启后,每个请求先由两个模型实例就最佳执行方案进行辩论,不设轮次上限。由仲裁官判断是否达成共识。共识注入第三个实例执行。请求断开时辩论自动终止。", + "warning": "超能模式已开启:辩论轮次不设上限,直到仲裁官判定共识才执行。token 消耗和延迟取决于辩论复杂度,可能显著增加。仅在需要高质量推理时开启。" + }, "targets": { "title": "可启用组合", "externalWarn": "当前 ~/.codex/auth.json 是真实登录(很可能是 Sign in with ChatGPT)。现在点「写入文件并启用」默认会保留它不动,只把模型重定向 —— 你的 ChatGPT 登录(以及 OpenAI 官方的 Codex 手机端/远程)继续可用。如确需覆盖,可在确认弹窗里勾选。", diff --git a/web/src/pages/codex/CodexEnable.tsx b/web/src/pages/codex/CodexEnable.tsx index 42e896c..ecc2019 100644 --- a/web/src/pages/codex/CodexEnable.tsx +++ b/web/src/pages/codex/CodexEnable.tsx @@ -69,6 +69,9 @@ export function CodexEnable() { const [visionFallbackEnabled, setVisionFallbackEnabled] = useState(null); const [visionFallbackModel, setVisionFallbackModel] = useState("mimo-v2.5"); const [visionFallbackSaving, setVisionFallbackSaving] = useState(false); + // superMode + const [superModeEnabled, setSuperModeEnabled] = useState(null); + const [superModeSaving, setSuperModeSaving] = useState(false); async function doProbe(target: CodexTarget) { const key = `${target.providerId}::${target.modelId}`; @@ -100,11 +103,12 @@ export function CodexEnable() { async function load() { try { setError(null); - const [s, ts, think, vf] = await Promise.all([ + const [s, ts, think, vf, sm] = await Promise.all([ api.codexState(), api.codexTargets(), api.thinkingState().catch(() => null), // 老后端没此端点时降级 api.visionFallback().catch(() => null), // 老后端没此端点时降级 + api.superMode().catch(() => null), // 老后端没此端点时降级 ]); setState(s); setTargetsResp(ts); @@ -119,6 +123,7 @@ export function CodexEnable() { } else { setVisionFallbackEnabled(false); } + setSuperModeEnabled(sm?.enabled ?? false); } catch (err) { setError((err as Error).message); } @@ -174,6 +179,19 @@ export function CodexEnable() { } } + + async function doToggleSuperMode(enabled: boolean): Promise { + setSuperModeSaving(true); + try { + await api.setSuperMode(enabled); + setSuperModeEnabled(enabled); + } catch (err) { + setError((err as Error).message); + } finally { + setSuperModeSaving(false); + } + } + useEffect(() => { void load(); }, []); @@ -448,6 +466,50 @@ export function CodexEnable() { /> + {superModeEnabled !== null && ( + + + {t("superMode.title")} + void doToggleSuperMode(enabled)} + checkedChildren={t("superMode.switchOn")} + unCheckedChildren={t("superMode.switchOff")} + /> + + {superModeEnabled + ? t("superMode.statusOn") + : t("superMode.statusOff")} + + + + {t("superMode.hint")} + + {superModeEnabled && ( + + )} + + )} +