diff --git a/agent/transports/codex.py b/agent/transports/codex.py index 56374b875335..4b043c4cecb2 100644 --- a/agent/transports/codex.py +++ b/agent/transports/codex.py @@ -152,8 +152,9 @@ def build_kwargs( issuer_kind = self._resolve_issuer_kind(params) self._last_issuer_kind = issuer_kind - # Resolve reasoning effort + # Resolve reasoning effort and the Codex-only reasoning mode. reasoning_effort = "medium" + reasoning_mode = "" reasoning_enabled = True reasoning_config = params.get("reasoning_config") if reasoning_config and isinstance(reasoning_config, dict): @@ -161,6 +162,9 @@ def build_kwargs( reasoning_enabled = False elif reasoning_config.get("effort"): reasoning_effort = reasoning_config["effort"] + mode = str(reasoning_config.get("mode", "") or "").strip().lower() + if mode in {"standard", "pro"}: + reasoning_mode = mode _effort_clamp = {"minimal": "low"} reasoning_effort = _effort_clamp.get(reasoning_effort, reasoning_effort) @@ -285,7 +289,10 @@ def build_kwargs( if github_reasoning is not None: kwargs["reasoning"] = github_reasoning else: - kwargs["reasoning"] = {"effort": reasoning_effort, "summary": "auto"} + reasoning_payload = {"effort": reasoning_effort, "summary": "auto"} + if is_codex_backend and reasoning_mode: + reasoning_payload["mode"] = reasoning_mode + kwargs["reasoning"] = reasoning_payload kwargs["include"] = ( ["reasoning.encrypted_content"] if replay_encrypted_reasoning else [] ) diff --git a/gateway/run.py b/gateway/run.py index ccfa8e92c143..2fbe51f379c1 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -4789,11 +4789,12 @@ def _get_system_prompt_for_channel( @staticmethod def _load_reasoning_config() -> dict | None: - """Load reasoning effort from config.yaml. + """Load reasoning effort and Codex-only mode from config.yaml. Reads agent.reasoning_effort from config.yaml. Valid: "none", "minimal", "low", "medium", "high", "xhigh". Returns None to use - default (medium). + default (medium). agent.reasoning_mode ("standard"/"pro") is applied + only when model.provider is openai-codex. """ from hermes_constants import parse_reasoning_effort cfg = _load_gateway_runtime_config() @@ -4804,6 +4805,12 @@ def _load_reasoning_config() -> dict | None: result = parse_reasoning_effort(effort) if effort and str(effort).strip() and result is None: logger.warning("Unknown reasoning_effort '%s', using default (medium)", effort) + + provider = str(cfg_get(cfg, "model", "provider", default="") or "").strip().lower() + mode = str(cfg_get(cfg, "agent", "reasoning_mode", default="") or "").strip().lower() + if provider == "openai-codex" and mode in {"standard", "pro"}: + result = dict(result) if isinstance(result, dict) else {"enabled": True} + result["mode"] = mode return result @staticmethod diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 981533f15e7e..8266ad555e7e 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1019,6 +1019,9 @@ def _ensure_hermes_home_managed(home: Path): # provider hiccups on a single provider. "api_max_retries": 3, "service_tier": "", + # Codex-only Responses API reasoning mode: "standard" or "pro". + # Empty = provider default. Ignored for non-openai-codex providers. + "reasoning_mode": "", # Tool-use enforcement: injects system prompt guidance that tells the # model to actually call tools instead of describing intended actions. # Values: "auto" (default — applies to gpt/codex models), true/false diff --git a/tests/agent/transports/test_codex_transport.py b/tests/agent/transports/test_codex_transport.py index 6389890519a0..92398b772f3d 100644 --- a/tests/agent/transports/test_codex_transport.py +++ b/tests/agent/transports/test_codex_transport.py @@ -75,6 +75,36 @@ def test_reasoning_config(self, transport): ) assert kw.get("reasoning", {}).get("effort") == "high" + @pytest.mark.parametrize("mode", ["standard", "pro"]) + def test_reasoning_mode_is_sent_only_to_codex_backend(self, transport, mode): + messages = [{"role": "user", "content": "Hi"}] + reasoning_config = {"effort": "high", "mode": mode} + + codex_kwargs = transport.build_kwargs( + model="gpt-5.6-sol", + messages=messages, + tools=[], + is_codex_backend=True, + reasoning_config=reasoning_config, + ) + non_codex_kwargs = transport.build_kwargs( + model="gpt-5.6", + messages=messages, + tools=[], + is_codex_backend=False, + reasoning_config=reasoning_config, + ) + + assert codex_kwargs["reasoning"] == { + "effort": "high", + "summary": "auto", + "mode": mode, + } + assert non_codex_kwargs["reasoning"] == { + "effort": "high", + "summary": "auto", + } + def test_reasoning_disabled(self, transport): messages = [{"role": "user", "content": "Hi"}] kw = transport.build_kwargs( diff --git a/tests/gateway/test_runtime_config_env_expansion.py b/tests/gateway/test_runtime_config_env_expansion.py index 66c6cc203479..0261cb657aba 100644 --- a/tests/gateway/test_runtime_config_env_expansion.py +++ b/tests/gateway/test_runtime_config_env_expansion.py @@ -118,3 +118,30 @@ def test_gateway_runtime_loaders_expand_env_var_templates( loader = getattr(gateway_run.GatewayRunner, loader_name) assert loader() == expected + + +@pytest.mark.parametrize( + ("provider", "mode", "expected"), + [ + ( + "openai-codex", + "pro", + {"enabled": True, "effort": "high", "mode": "pro"}, + ), + ("openai", "pro", {"enabled": True, "effort": "high"}), + ("openai-codex", "turbo", {"enabled": True, "effort": "high"}), + ], +) +def test_gateway_reasoning_mode_is_validated_and_codex_scoped( + gateway_home, provider, mode, expected +): + _write_config( + gateway_home, + "model:\n" + f" provider: {provider}\n" + "agent:\n" + " reasoning_effort: high\n" + f" reasoning_mode: {mode}\n", + ) + + assert gateway_run.GatewayRunner._load_reasoning_config() == expected diff --git a/tests/hermes_cli/test_config.py b/tests/hermes_cli/test_config.py index 0b1f1404bab9..7d138b9b882d 100644 --- a/tests/hermes_cli/test_config.py +++ b/tests/hermes_cli/test_config.py @@ -113,6 +113,7 @@ def test_returns_defaults_when_no_file(self, tmp_path): config = load_config() assert config["model"] == DEFAULT_CONFIG["model"] assert config["agent"]["max_turns"] == DEFAULT_CONFIG["agent"]["max_turns"] + assert config["agent"]["reasoning_mode"] == "" assert "max_turns" not in config assert "terminal" in config assert config["terminal"]["backend"] == "local" diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 6b19000aad51..075487d4e8c1 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -17,6 +17,44 @@ from tui_gateway import server +@pytest.mark.parametrize( + ("provider", "mode", "expected"), + [ + ( + "openai-codex", + "standard", + {"enabled": True, "effort": "high", "mode": "standard"}, + ), + ("openai", "pro", {"enabled": True, "effort": "high"}), + ("openai-codex", "turbo", {"enabled": True, "effort": "high"}), + ], +) +def test_tui_reasoning_mode_is_validated_and_codex_scoped( + tmp_path, provider, mode, expected +): + home = tmp_path / ".hermes" + home.mkdir() + (home / "config.yaml").write_text( + "model:\n" + f" provider: {provider}\n" + "agent:\n" + " reasoning_effort: high\n" + f" reasoning_mode: {mode}\n", + encoding="utf-8", + ) + token = set_hermes_home_override(home) + try: + server._cfg_cache = None + server._cfg_mtime = None + server._cfg_path = None + assert server._load_reasoning_config() == expected + finally: + server._cfg_cache = None + server._cfg_mtime = None + server._cfg_path = None + reset_hermes_home_override(token) + + def test_session_create_rejects_at_active_session_limit(monkeypatch, tmp_path): home = tmp_path / ".hermes" home.mkdir() diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 4e2fa3eadeeb..495366a6579c 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2531,12 +2531,22 @@ def _display_mouse_tracking(display: dict) -> str: def _load_reasoning_config() -> dict | None: from hermes_constants import parse_reasoning_effort + cfg = _load_cfg() + agent_cfg = cfg.get("agent") or {} # Pass the raw value through — ``or ""`` would coerce a YAML boolean # False (``reasoning_effort: false``/``off``/``no``) to "", silently # re-enabling thinking for users who explicitly turned it off. - return parse_reasoning_effort( - (_load_cfg().get("agent") or {}).get("reasoning_effort", "") - ) + result = parse_reasoning_effort(agent_cfg.get("reasoning_effort", "")) + + model_cfg = cfg.get("model") or {} + provider = "" + if isinstance(model_cfg, dict): + provider = str(model_cfg.get("provider", "") or "").strip().lower() + mode = str(agent_cfg.get("reasoning_mode", "") or "").strip().lower() + if provider == "openai-codex" and mode in {"standard", "pro"}: + result = dict(result) if isinstance(result, dict) else {"enabled": True} + result["mode"] = mode + return result def _load_service_tier() -> str | None: diff --git a/web/src/components/ChatSidebar.tsx b/web/src/components/ChatSidebar.tsx index e6c6d09e0a1a..f4390f517766 100644 --- a/web/src/components/ChatSidebar.tsx +++ b/web/src/components/ChatSidebar.tsx @@ -33,6 +33,7 @@ import { ReasoningPicker } from "@/components/ReasoningPicker"; import { GatewayClient, type ConnectionState } from "@/lib/gatewayClient"; import { api, buildWsUrl } from "@/lib/api"; import { titleFromSessionInfoPayload } from "@/lib/chat-title"; +import { isCodexProvider } from "@/lib/reasoning-effort"; import { cn } from "@/lib/utils"; import { AlertCircle, ChevronDown, RefreshCw } from "lucide-react"; @@ -107,6 +108,7 @@ export function ChatSidebar({ // this card stays scoped to the PTY even if the global dashboard switcher // changes while the chat is open. const [effectiveModel, setEffectiveModel] = useState(""); + const [effectiveProvider, setEffectiveProvider] = useState(""); // Whether the effective model supports reasoning effort — gates the // ReasoningPicker. Read from the same `/api/model/info` capabilities the // (currently unused) ModelInfoCard surfaces, so the dashboard exposes a @@ -129,6 +131,7 @@ export function ChatSidebar({ .getModelInfo(profile) .then((r) => { if (r?.model) setEffectiveModel(String(r.model)); + setEffectiveProvider(String(r?.provider ?? "")); setSupportsReasoning(!!r?.capabilities?.supports_reasoning); // Bump so ReasoningPicker re-reads the saved effort for the new model. setModelRefreshKey((k) => k + 1); @@ -348,11 +351,17 @@ export function ChatSidebar({ currentModel={modelName} profile={profile} refreshKey={modelRefreshKey} + showMode={isCodexProvider(effectiveProvider)} onChanged={(effort) => setModelNotice( `Reasoning effort set to ${effort}. Run /new or refresh the page to apply it to this chat.`, ) } + onModeChanged={(mode) => + setModelNotice( + `Reasoning mode set to ${mode}. Run /new or refresh the page to apply it to this chat.`, + ) + } /> )} diff --git a/web/src/components/ReasoningPicker.test.tsx b/web/src/components/ReasoningPicker.test.tsx new file mode 100644 index 000000000000..34e125b38702 --- /dev/null +++ b/web/src/components/ReasoningPicker.test.tsx @@ -0,0 +1,66 @@ +import { readFileSync } from "node:fs"; +import type { ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +import { isCodexProvider } from "@/lib/reasoning-effort"; + +vi.mock("@nous-research/ui/ui/components/select", () => ({ + Select: ({ children }: { children?: ReactNode }) =>
{children}
, + SelectOption: ({ + children, + value, + }: { + children?: ReactNode; + value: string; + }) => {children}, +})); + +vi.mock("@/lib/api", () => ({ + api: { getConfig: vi.fn() }, +})); + +import { ReasoningPicker } from "./ReasoningPicker"; + +function renderForProvider(provider: string): string { + return renderToStaticMarkup( + , + ); +} + +describe("ReasoningPicker reasoning mode visibility", () => { + it("shows standard and pro for openai-codex", () => { + const markup = renderForProvider("openai-codex"); + + expect(markup).toContain(">mode<"); + expect(markup).toContain("Standard"); + expect(markup).toContain("Pro"); + }); + + it("hides the mode control for non-Codex providers", () => { + const markup = renderForProvider("openai"); + + expect(markup).not.toContain(">mode<"); + expect(markup).not.toContain("Standard"); + expect(markup).not.toContain("Pro"); + }); +}); + +describe("ChatSidebar reasoning mode wiring", () => { + it("uses the effective REST provider to control mode visibility", () => { + const source = readFileSync( + new URL("./ChatSidebar.tsx", import.meta.url), + "utf8", + ); + + expect(source).toMatch( + /setEffectiveProvider\(String\(r\?\.provider \?\? ""\)\)/, + ); + expect(source).toContain( + "showMode={isCodexProvider(effectiveProvider)}", + ); + }); +}); diff --git a/web/src/components/ReasoningPicker.tsx b/web/src/components/ReasoningPicker.tsx index cd45986a766c..7b4b705b79a6 100644 --- a/web/src/components/ReasoningPicker.tsx +++ b/web/src/components/ReasoningPicker.tsx @@ -1,19 +1,18 @@ /** - * ReasoningPicker — sets the main model's reasoning effort from the dashboard - * Chat sidebar, mirroring the desktop app's composer effort radio. + * ReasoningPicker — sets the main model's reasoning effort and optional + * Codex reasoning mode from the dashboard Chat sidebar. * * The dashboard previously only showed a read-only "Reasoning" capability * badge (see ModelInfoCard) with no way to actually choose the effort level — * unlike the desktop app, which exposes a radio in its model menu. This closes * that parity gap. * - * Storage: the effort persists to config.yaml at `agent.reasoning_effort` - * (the same key the TUI's `/reasoning ` command and the desktop radio - * write). We read the whole config and write it back — the established - * single-key pattern on the dashboard (see ConfigPage) — so the value lands in - * the config the agent boots a fresh chat from. As with the model picker, the - * running chat session adopts the change on the next `/new` or page reload; - * we surface that hint rather than forcing a reload here. + * Storage: effort persists at `agent.reasoning_effort`; Codex mode persists at + * `agent.reasoning_mode`. We read the whole config and write it back — the + * established single-key pattern on the dashboard (see ConfigPage) — so the + * value lands in the config a fresh chat boots from. The running chat adopts a + * change on the next `/new` or page reload; we surface that hint rather than + * forcing a reload here. * * Profile scoping: the sidebar passes the chat profile explicitly, so this * reads/writes the same config the chat PTY was launched from. @@ -26,8 +25,11 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { api } from "@/lib/api"; import { EFFORT_OPTIONS, + MODE_OPTIONS, normalizeEffort, + normalizeMode, VALID_EFFORTS, + VALID_MODES, } from "@/lib/reasoning-effort"; interface ReasoningPickerProps { @@ -38,18 +40,24 @@ interface ReasoningPickerProps { profile?: string; /** Bumped after the model picker saves, to re-read config in lockstep. */ refreshKey?: number; + /** Show the Codex-only reasoning mode control (`standard`/`pro`). */ + showMode?: boolean; /** Called after a successful change so the sidebar can show an "apply on * /new or reload" notice, matching the model-switch UX. */ onChanged?: (effort: string) => void; + onModeChanged?: (mode: string) => void; } export function ReasoningPicker({ currentModel, profile, refreshKey = 0, + showMode = false, onChanged, + onModeChanged, }: ReasoningPickerProps) { const [effort, setEffort] = useState("medium"); + const [mode, setMode] = useState("standard"); const [loaded, setLoaded] = useState(false); const [saving, setSaving] = useState(false); const lastFetchKeyRef = useRef(""); @@ -63,6 +71,7 @@ export function ReasoningPicker({ .then((cfg) => { const agent = (cfg?.agent as Record | undefined) ?? {}; setEffort(normalizeEffort(agent.reasoning_effort)); + setMode(normalizeMode(agent.reasoning_mode)); setLoaded(true); }) .catch(() => { @@ -102,24 +111,80 @@ export function ReasoningPicker({ [effort, onChanged, profile], ); + const onModeSelect = useCallback( + (next: string) => { + if (!showMode || !VALID_MODES.has(next) || next === mode) return; + const prev = mode; + setMode(next); // optimistic + setSaving(true); + void api + .getConfig(profile) + .then((cfg) => { + const base = (cfg ?? {}) as Record; + const agent = + base.agent && typeof base.agent === "object" + ? { ...(base.agent as Record) } + : {}; + agent.reasoning_mode = next; + return api.saveConfig({ ...base, agent }, profile); + }) + .then(() => { + onModeChanged?.(next); + }) + .catch(() => { + setMode(prev); // revert on failure + }) + .finally(() => setSaving(false)); + }, + [mode, onModeChanged, profile, showMode], + ); + return ( -
-
- - reasoning +
+
+
+ + reasoning +
+
- + + {showMode && ( +
+ + mode + + +
+ )}
); } diff --git a/web/src/lib/reasoning-effort.test.ts b/web/src/lib/reasoning-effort.test.ts index 3ade00347245..934d65ff7d14 100644 --- a/web/src/lib/reasoning-effort.test.ts +++ b/web/src/lib/reasoning-effort.test.ts @@ -1,8 +1,12 @@ import { describe, it, expect } from "vitest"; import { EFFORT_OPTIONS, + MODE_OPTIONS, VALID_EFFORTS, + VALID_MODES, + isCodexProvider, normalizeEffort, + normalizeMode, } from "./reasoning-effort"; describe("normalizeEffort", () => { @@ -46,3 +50,37 @@ describe("EFFORT_OPTIONS", () => { } }); }); + +describe("normalizeMode", () => { + it("accepts standard and pro case-insensitively", () => { + expect(normalizeMode("standard")).toBe("standard"); + expect(normalizeMode(" PRO ")).toBe("pro"); + }); + + it("falls back to standard for empty or unknown values", () => { + expect(normalizeMode("")).toBe("standard"); + expect(normalizeMode(null)).toBe("standard"); + expect(normalizeMode("turbo")).toBe("standard"); + }); +}); + +describe("MODE_OPTIONS", () => { + it("contains exactly the supported Codex modes", () => { + expect(MODE_OPTIONS.map((option) => option.value)).toEqual([ + "standard", + "pro", + ]); + for (const option of MODE_OPTIONS) { + expect(VALID_MODES.has(option.value)).toBe(true); + } + }); +}); + +describe("isCodexProvider", () => { + it("matches only the openai-codex provider", () => { + expect(isCodexProvider("openai-codex")).toBe(true); + expect(isCodexProvider(" OpenAI-Codex ")).toBe(true); + expect(isCodexProvider("openai")).toBe(false); + expect(isCodexProvider("")).toBe(false); + }); +}); diff --git a/web/src/lib/reasoning-effort.ts b/web/src/lib/reasoning-effort.ts index 1e8313e04891..b70968c0d6da 100644 --- a/web/src/lib/reasoning-effort.ts +++ b/web/src/lib/reasoning-effort.ts @@ -34,3 +34,29 @@ export function normalizeEffort(raw: unknown): string { if (!value) return "medium"; return VALID_EFFORTS.has(value) ? value : "medium"; } + +export type ReasoningMode = "standard" | "pro"; + +export interface ModeOption { + value: ReasoningMode; + label: string; +} + +export const MODE_OPTIONS: ReadonlyArray = [ + { value: "standard", label: "Standard" }, + { value: "pro", label: "Pro" }, +]; + +export const VALID_MODES: ReadonlySet = new Set( + MODE_OPTIONS.map((option) => option.value), +); + +/** Normalize a raw `agent.reasoning_mode` value to the provider default. */ +export function normalizeMode(raw: unknown): ReasoningMode { + const value = String(raw ?? "").trim().toLowerCase(); + return VALID_MODES.has(value) ? (value as ReasoningMode) : "standard"; +} + +export function isCodexProvider(provider: unknown): boolean { + return String(provider ?? "").trim().toLowerCase() === "openai-codex"; +}