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
2 changes: 2 additions & 0 deletions cli-config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -1675,6 +1675,8 @@ delegation:
# model: "google/gemini-3-flash-preview" # Override model for subagents (empty = inherit parent)
# provider: "openrouter" # Override provider for subagents (empty = inherit parent)
# # Resolves full credentials (base_url, api_key) automatically.
# reasoning_effort: "medium" # Child reasoning: none|minimal|low|medium|high|xhigh|max|ultra.
# # Omit to inherit the parent agent's reasoning configuration.
# # Supported: openrouter, nous, zai, kimi-coding, minimax
# # Cost tip: keep the parent on a frontier model and pin
# # delegation.model to an inexpensive one — children carry the
Expand Down
3 changes: 2 additions & 1 deletion cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3174,7 +3174,8 @@ def show_config(self):
"profile": ("_handle_profile_command", False), "toolsets": ("show_toolsets", False),
"config": ("show_config", False), "redraw": ("_cmd_redraw", True), "clear": ("_cmd_clear", True),
"history": ("show_history", False), "title": ("_cmd_title", True), "new": ("_cmd_new", True),
"model": ("_handle_model_switch", True), "codex-runtime": ("_handle_codex_runtime", True),
"model": ("_handle_model_switch", True),
"subagent": ("_handle_subagent_command", True), "codex-runtime": ("_handle_codex_runtime", True),
"retry": ("_cmd_retry", True), "prompt": ("_handle_prompt_compose_command", True),
"undo": ("_cmd_undo", True), "save": ("save_conversation", True), "skills": ("_cmd_skills", True),
"platforms": ("_show_gateway_status", False), "status": ("_show_session_status", False),
Expand Down
25 changes: 25 additions & 0 deletions hermes_cli/auth_model_picker.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,36 @@
from __future__ import annotations

import logging
from contextlib import contextmanager
from contextvars import ContextVar
from typing import Callable
import subprocess
from typing import Dict, List, Optional
from hermes_cli.auth_constants import DEFAULT_NOUS_PORTAL_URL

logger = logging.getLogger("hermes_cli.auth")

_MODEL_SELECTION_RECORDER: ContextVar[Optional[Callable[[str], None]]] = ContextVar(
"model_selection_recorder", default=None)


@contextmanager
def capture_model_selection(recorder: Callable[[str], None]):
"""Capture confirmed choices for a secondary target; restore nested captures."""
token = _MODEL_SELECTION_RECORDER.set(recorder)
try:
yield
finally:
_MODEL_SELECTION_RECORDER.reset(token)


def record_model_selection(model_id: str) -> None:
"""Notify only an active secondary-target capture after a confirmed save."""
recorder = _MODEL_SELECTION_RECORDER.get()
if recorder is not None:
recorder(model_id)


_CUSTOM_LABEL = "Enter custom model name"
_SKIP_LABEL = "Skip (keep current)"
_CURRENT_SUFFIX = " ← currently in use"
Expand Down Expand Up @@ -289,3 +313,4 @@ def _save_model_choice(model_id: str) -> None:
else:
config["model"] = {"default": model_id}
save_config(config)
record_model_selection(model_id)
173 changes: 166 additions & 7 deletions hermes_cli/cli_model_switch_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,7 @@ def _restore_session_model(self, session_meta: dict, *, quiet: bool = False) ->
else:
self._console_print(f"[dim]{_escape(msg)}[/dim]")

def _open_model_picker(self, providers: list, current_model: str, current_provider: str, user_provs=None, custom_provs=None) -> None:
def _open_model_picker(self, providers: list, current_model: str, current_provider: str, user_provs=None, custom_provs=None, *, target: str = "main") -> None:
"""Open prompt_toolkit-native /model picker modal."""
self._capture_modal_input_snapshot()
self._model_picker_state = {
Expand All @@ -421,10 +421,10 @@ def _open_model_picker(self, providers: list, current_model: str, current_provid
"current_provider": current_provider,
"user_provs": user_provs,
"custom_provs": custom_provs,
"filter": ""}
"filter": "", "target": target}
self._invalidate(min_interval=0.0)

def _confirm_expensive_model_switch(self, result) -> bool:
def _confirm_expensive_model_switch(self, result, *, target: str = "main") -> bool:
"""Ask for explicit confirmation before applying costly model switches."""
if not getattr(result, "success", False):
return True
Expand All @@ -438,9 +438,12 @@ def _confirm_expensive_model_switch(self, result) -> bool:
warning = None
if warning is None:
return True
choices = [
("once", "Switch anyway", "Use this model for the current Hermes session."),
("cancel", "Cancel", "Keep the current model.")]
choices = (
[("once", "Select anyway", "Use this model for newly spawned subagents."),
("cancel", "Cancel", "Keep the current subagent model override.")]
if target == "subagent" else
[("once", "Switch anyway", "Use this model for the current Hermes session."),
("cancel", "Cancel", "Keep the current model.")])
raw = self._prompt_text_input_modal(
title=f"!!! {warning.title} !!!", detail=warning.message, choices=choices, timeout=120)
return self._normalize_slash_confirm_choice(raw, choices) == "once"
Expand Down Expand Up @@ -631,6 +634,9 @@ def _handle_model_picker_selection(self, persist_global: bool = False) -> None:
state.update(
stage="model", provider_data=provider_data, model_list=model_list,
selected=0, filter="", _filtered_pairs=None)
if state.get("target") == "subagent" and provider_data.get("slug") == state.get("current_provider"):
state["selected"] = next((i for i, mid in enumerate(model_list)
if mid == state.get("current_model")), 0)
self._invalidate(min_interval=0.0)
return
if stage == "model":
Expand All @@ -653,14 +659,21 @@ def _handle_model_picker_selection(self, persist_global: bool = False) -> None:
self._close_model_picker()
return
if 0 <= selected < back_idx:
picker_target = state.get("target", "main")
# Cursor state describes the child; credentials still belong to the
# live primary route. Let canonical explicit-provider resolution do
# the switch rather than relabeling primary credentials as child-owned.
result = _switch_model_from(
self, visible_labels[selected], is_global=persist_global,
self, visible_labels[selected], is_global=persist_global if picker_target == "main" else False,
explicit_provider=provider_data.get("slug"),
user_providers=state.get("user_provs"),
custom_providers=state.get("custom_provs"))
# Capture before close — picker state is cleared on close.
_picker_custom_provs = state.get("custom_provs")
self._close_model_picker()
if picker_target == "subagent":
_run_confirm_and_apply(self, self._confirm_and_apply_subagent_model_result, result)
return
_run_confirm_and_apply(
self, self._confirm_and_apply_model_switch_result,
result, persist_global, _picker_custom_provs)
Expand Down Expand Up @@ -811,3 +824,149 @@ def _cmd_moa(self, cmd_original: str):
self._pending_moa_disable_after_turn = True
self._pending_agent_seed = payload
_cprint(f" MoA one-shot queued with preset {preset}; previous model will be restored after this turn.")


def _confirm_and_apply_subagent_model_result(self, result) -> None:
from cli import _cprint
try:
if not result.success:
_cprint(f" ✗ {result.error_message}")
return
if not self._confirm_expensive_model_switch(result, target="subagent"):
_cprint(" Subagent model selection cancelled.")
return
from hermes_cli.subagent_model import persist_subagent_switch_result

status = persist_subagent_switch_result(result)
_cprint(f" ✓ Subagent model: {status.model}")
if status.provider:
_cprint(f" Provider: {status.provider}")
_cprint(" Saved to config.yaml (delegation.model/provider)")
except Exception as exc:
_cprint(f" ✗ Subagent model selection failed: {exc}")



def _handle_subagent_command(self, cmd_original: str) -> None:
"""Handle Classic CLI ``/subagent`` model and reasoning controls."""
from cli import _cprint
from hermes_cli.model_switch import parse_model_switch_args
from hermes_cli.subagent_model import (
get_subagent_model_status,
get_subagent_reasoning_status,
list_subagent_picker_providers,
reset_subagent_model,
reset_subagent_reasoning_effort,
set_subagent_model,
set_subagent_reasoning_effort,
)

parts = cmd_original.split(None, 1)
raw = parts[1].strip() if len(parts) > 1 else ""
if not raw:
status = get_subagent_model_status()
if status.inherits_parent:
model_label = "inherits parent"
elif status.model:
model_label = f"{status.model} ({status.provider or 'auto'})"
else:
model_label = f"provider default ({status.provider})"
reasoning = get_subagent_reasoning_status()
reasoning_label = (
"inherits parent" if reasoning.inherits_parent else reasoning.effort
)
_cprint(f" Subagent model: {model_label}")
_cprint(f" Subagent reasoning: {reasoning_label}")
_cprint(
" Usage: /subagent <model [model|reset] [--provider name] "
"| reasoning [effort|reset]>"
)
return

verb, _, command_args = raw.partition(" ")
if verb.lower() == "reasoning":
reasoning_arg = command_args.strip()
if not reasoning_arg:
status = get_subagent_reasoning_status()
label = "inherits parent" if status.inherits_parent else status.effort
_cprint(f" Subagent reasoning: {label}")
return
if reasoning_arg.lower() in {"reset", "clear", "default", "inherit"}:
reset_subagent_reasoning_effort()
_cprint(" ✓ Subagent reasoning reset: inherits parent")
return
try:
status = set_subagent_reasoning_effort(reasoning_arg)
except ValueError as exc:
_cprint(f" ✗ {exc}")
return
_cprint(f" ✓ Subagent reasoning: {status.effort}")
_cprint(" Saved to config.yaml (delegation.reasoning_effort)")
_cprint(" Applies to newly spawned subagents")
return

if verb.lower() != "model":
_cprint(
" Usage: /subagent <model [model|reset] [--provider name] "
"| reasoning [effort|reset]>"
)
return

model_args = command_args.strip()
if model_args.lower() in {"reset", "clear", "default", "inherit"}:
reset_subagent_model()
_cprint(" ✓ Subagent model reset: inherits parent")
return

parsed = parse_model_switch_args(model_args)
if parsed.errors:
_cprint(f" ✗ {parsed.error_messages()[0]}")
return
if not parsed.target:
status = get_subagent_model_status()
from hermes_cli.inventory import load_picker_context

context = load_picker_context()
current_provider = (
parsed.explicit_provider
or status.provider
or context.current_provider
or "unknown"
)
current_model = (
status.model or context.current_model or "unknown"
if not parsed.explicit_provider
else (
(status.model or "unknown")
if status.provider == parsed.explicit_provider
else "unknown"
)
)
providers = list_subagent_picker_providers(refresh=parsed.force_refresh)
if not providers:
_cprint(" No authenticated providers found.")
return
for row in providers:
row["is_current"] = str(row.get("slug") or "") == current_provider
self._open_model_picker(
providers,
current_model,
current_provider,
user_provs=context.user_providers,
custom_provs=context.custom_providers,
target="subagent",
)
return

try:
status = set_subagent_model(
parsed.target,
provider=parsed.explicit_provider or None,
)
except ValueError as exc:
_cprint(f" ✗ {exc}")
return
_cprint(f" ✓ Subagent model: {status.model}")
if status.provider:
_cprint(f" Provider: {status.provider}")
_cprint(" Saved to config.yaml (delegation.model/provider)")
5 changes: 3 additions & 2 deletions hermes_cli/cli_tui_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -616,8 +616,9 @@ def _get_model_picker_display_fragments(self):
state = self._model_picker_state
if not state:
return []
target_label = "Subagent Model" if state.get("target") == "subagent" else "Model"
if state.get("stage", "provider") == "provider":
title = "⚙ Model Picker — Select Provider"
title = f"⚙ {target_label} Picker — Select Provider"
choices = []
_providers = state.get("providers")
for p in _providers if isinstance(_providers, list) else []:
Expand All @@ -633,7 +634,7 @@ def _get_model_picker_display_fragments(self):
else:
provider_data = state.get("provider_data") or {}
model_list = state.get("model_list") or []
title = f"⚙ Model Picker — {provider_data.get('name', provider_data.get('slug', 'Provider'))}"
title = f"⚙ {target_label} Picker — {provider_data.get('name', provider_data.get('slug', 'Provider'))}"
# Fuzzy filter narrows the concrete list; selection still resolves to a real entry via
# the filtered_pairs index mapping, so this never makes model resolution ambiguous.
_query = state.get("filter", "") or ""
Expand Down
3 changes: 3 additions & 0 deletions hermes_cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,9 @@ class CommandDef:
CommandDef("model", "Switch model (session-scoped; --global to persist)", "Configuration",
args_hint="[model] [--provider name] [--global|--session] [--refresh]",
busy_policy="reject", busy_handler="model", desktop="hidden"),
CommandDef("subagent", "Configure delegated subagent model and reasoning", "Configuration",
args_hint="<model [model|reset] [--provider name]|reasoning [effort|reset]>",
busy_policy="reject", cli_only=True, desktop="hidden"),
CommandDef("codex-runtime", "Toggle codex app-server runtime for OpenAI/Codex models",
"Configuration", aliases=("codex_runtime",), args_hint="[auto|codex_app_server]",
busy_policy="reject", busy_handler="codex-runtime"),
Expand Down
16 changes: 12 additions & 4 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,7 @@ def _suppress_mouse_residue_early() -> None:
from hermes_cli.subcommands.gateway import build_gateway_parser
from hermes_cli.subcommands.profile import build_profile_parser
from hermes_cli.subcommands.model import build_model_parser
from hermes_cli.subcommands.subagent import build_subagent_parser
from hermes_cli.subcommands.setup import build_setup_parser

from hermes_cli.subcommands.whatsapp import build_whatsapp_parser, build_whatsapp_cloud_parser
Expand Down Expand Up @@ -1794,6 +1795,7 @@ def _cmd(args):
cmd_mcp = _forward_command("cmd_mcp", "hermes_cli.mcp_config", "mcp_command")
cmd_claw = _forward_command("cmd_claw", "hermes_cli.claw", "claw_command")
cmd_import_agent = _forward_command("cmd_import_agent", "hermes_cli.agent_import", "import_agent_command")
cmd_subagent = _forward_command("cmd_subagent", "hermes_cli.subcommands.subagent", "cmd_subagent", forward_return=True)


def cmd_model(args):
Expand Down Expand Up @@ -1926,7 +1928,7 @@ def _pick_provider(config, active, provider_labels, custom_provider_map):
return None if member_idx is None else selected_members[member_idx]


def select_provider_and_model(args=None):
def select_provider_and_model(args=None, *, initial_model=None, initial_provider=None):
"""Core provider selection + model picking logic.

Shared by ``cmd_model`` (``hermes model``) and the setup wizard
Expand All @@ -1939,16 +1941,19 @@ def select_provider_and_model(args=None):
config = load_config()
model_cfg = config.get("model")
current_model = model_cfg.get("default", "") if isinstance(model_cfg, dict) else model_cfg
current_model = current_model or "(not set)"
initial_model_cursor = str(initial_model or "").strip()
current_model = initial_model_cursor or current_model or "(not set)"

# Effective provider the same way the CLI resolves it at startup:
# config.yaml model.provider > env var > auto-detect
config_provider = model_cfg.get("provider") if isinstance(model_cfg, dict) else None
effective_provider = config_provider or os.getenv("HERMES_INFERENCE_PROVIDER") or "auto"
effective_provider = str(initial_provider or "").strip() or config_provider or os.getenv("HERMES_INFERENCE_PROVIDER") or "auto"

# User-defined custom providers from config.yaml: key → {name, base_url, api_key}
_custom_provider_map = _named_custom_provider_map(config)
active = _resolve_active_provider(config, model_cfg, effective_provider, _custom_provider_map)
if initial_model_cursor and active in _custom_provider_map:
_custom_provider_map[active] = {**_custom_provider_map[active], "model": initial_model_cursor}

from hermes_cli.models import _PROVIDER_LABELS

Expand Down Expand Up @@ -1988,6 +1993,8 @@ def select_provider_and_model(args=None):
"It may have been removed from config.yaml. No change."
)
return
if initial_model_cursor and selected_provider == active:
provider_info = {**provider_info, "model": initial_model_cursor}
_model_flow_named_custom(config, provider_info)
elif selected_provider == "remove-custom":
_remove_custom_provider(config)
Expand Down Expand Up @@ -2602,7 +2609,7 @@ def cmd_console(args):
"project", "proxy",
"prompt-size",
"resume",
"send", "sessions", "setup",
"send", "sessions", "setup", "subagent",
"skin", "skills", "slack", "status", "sync", "tools", "uninstall", "update",
"webhook", "whatsapp", "whatsapp-cloud", "worktree", "chat", "secrets", "security",
"browser",
Expand Down Expand Up @@ -3161,6 +3168,7 @@ def _build_cli_parser():
chat_parser.set_defaults(func=cmd_chat)

build_model_parser(subparsers, cmd_model=cmd_model)
build_subagent_parser(subparsers, cmd_subagent=cmd_subagent)
build_moa_parser(subparsers)
build_fallback_parser(subparsers)
build_worktree_parser(subparsers)
Expand Down
2 changes: 2 additions & 0 deletions hermes_cli/model_setup_flows_azure.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,8 @@ def _model_flow_azure_foundry(config, current_model=""):
if ctx_len:
model["context_length"] = ctx_len
_commit_model_config(cfg)
from hermes_cli.auth_model_picker import record_model_selection
record_model_selection(effective_model)
config["model"] = dict(model)

# Clear conflicting env vars so auxiliary clients don't pick up a stale OpenAI base URL / key.
Expand Down
Loading