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 }) =>