Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions agent/transports/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,15 +152,19 @@ 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):
if reasoning_config.get("enabled") is False:
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)
Expand Down Expand Up @@ -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 []
)
Expand Down
11 changes: 9 additions & 2 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions tests/agent/transports/test_codex_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
27 changes: 27 additions & 0 deletions tests/gateway/test_runtime_config_env_expansion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions tests/hermes_cli/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
38 changes: 38 additions & 0 deletions tests/test_tui_gateway_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
16 changes: 13 additions & 3 deletions tui_gateway/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 9 additions & 0 deletions web/src/components/ChatSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand Down Expand Up @@ -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.`,
)
}
/>
</Card>
)}
Expand Down
66 changes: 66 additions & 0 deletions web/src/components/ReasoningPicker.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => <div>{children}</div>,
SelectOption: ({
children,
value,
}: {
children?: ReactNode;
value: string;
}) => <span data-value={value}>{children}</span>,
}));

vi.mock("@/lib/api", () => ({
api: { getConfig: vi.fn() },
}));

import { ReasoningPicker } from "./ReasoningPicker";

function renderForProvider(provider: string): string {
return renderToStaticMarkup(
<ReasoningPicker
currentModel="gpt-5.6-sol"
showMode={isCodexProvider(provider)}
/>,
);
}

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)}",
);
});
});
Loading