diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 687badff7d5fa..2c9c2380dd003 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -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 diff --git a/cli.py b/cli.py index 9fe19f1296f5e..7af6a28482a72 100644 --- a/cli.py +++ b/cli.py @@ -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), diff --git a/hermes_cli/auth_model_picker.py b/hermes_cli/auth_model_picker.py index 1eda39aad0081..c306a9dd5ae7c 100644 --- a/hermes_cli/auth_model_picker.py +++ b/hermes_cli/auth_model_picker.py @@ -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" @@ -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) diff --git a/hermes_cli/cli_model_switch_mixin.py b/hermes_cli/cli_model_switch_mixin.py index 5176c75c15f59..3b5a50de4e17f 100644 --- a/hermes_cli/cli_model_switch_mixin.py +++ b/hermes_cli/cli_model_switch_mixin.py @@ -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 = { @@ -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 @@ -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" @@ -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": @@ -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) @@ -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 " + ) + 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 " + ) + 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)") diff --git a/hermes_cli/cli_tui_mixin.py b/hermes_cli/cli_tui_mixin.py index 10512f74e716a..e99621d0613c0 100644 --- a/hermes_cli/cli_tui_mixin.py +++ b/hermes_cli/cli_tui_mixin.py @@ -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 []: @@ -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 "" diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 838aa5a109548..18df32e5ad4e5 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -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="", + 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"), diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 6cb431dae7b8a..5bdae1a0ff556 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -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 @@ -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): @@ -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 @@ -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 @@ -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) @@ -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", @@ -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) diff --git a/hermes_cli/model_setup_flows_azure.py b/hermes_cli/model_setup_flows_azure.py index 84df04787c262..76d6c0fd2e89b 100644 --- a/hermes_cli/model_setup_flows_azure.py +++ b/hermes_cli/model_setup_flows_azure.py @@ -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. diff --git a/hermes_cli/subagent_model.py b/hermes_cli/subagent_model.py new file mode 100644 index 0000000000000..1becae8efcbda --- /dev/null +++ b/hermes_cli/subagent_model.py @@ -0,0 +1,395 @@ +"""Shared subagent model and reasoning semantics for Hermes CLI surfaces. + +The profile-scoped overrides live under ``delegation``. Model/provider +resolution is not reimplemented here: direct values and both CLI pickers go +through the same ``model_switch.switch_model`` or full provider-setup pipeline +as the primary model controls, so aliases, credentials, catalog validation, +and provider-specific normalization cannot drift. Reasoning values use the +runtime's canonical ``parse_reasoning_effort`` contract so CLI state and child +construction agree. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Optional + + +_MISSING_ACTIVE_PROVIDER = object() + + +@dataclass(frozen=True) +class SubagentModelStatus: + """Persisted override; no model and no provider means inherit the parent.""" + + model: Optional[str] + provider: Optional[str] + inherits_parent: bool + + +@dataclass(frozen=True) +class SubagentReasoningStatus: + """Persisted child reasoning override; no effort means inherit parent.""" + + effort: Optional[str] + inherits_parent: bool + + +def _reasoning_status_from_config(config: dict[str, Any]) -> SubagentReasoningStatus: + delegation = config.get("delegation") + if not isinstance(delegation, dict) or "reasoning_effort" not in delegation: + return SubagentReasoningStatus(None, True) + + from hermes_constants import parse_reasoning_effort + + parsed = parse_reasoning_effort(delegation.get("reasoning_effort")) + if parsed is None: + # Match child construction: an empty/invalid legacy value has no + # override effect and therefore inherits the parent's reasoning. + return SubagentReasoningStatus(None, True) + if not parsed.get("enabled", True): + return SubagentReasoningStatus("none", False) + return SubagentReasoningStatus(str(parsed["effort"]), False) + + +def _status_from_config(config: dict[str, Any]) -> SubagentModelStatus: + delegation = config.get("delegation") + if not isinstance(delegation, dict): + delegation = {} + model = str(delegation.get("model") or "").strip() or None + provider = str(delegation.get("provider") or "").strip() or None + return SubagentModelStatus(model, provider, not model and not provider) + + +def get_subagent_model_status() -> SubagentModelStatus: + from hermes_cli.config import load_config + + return _status_from_config(load_config()) + + +def get_subagent_reasoning_status() -> SubagentReasoningStatus: + from hermes_cli.config import load_config + + return _reasoning_status_from_config(load_config()) + + +def _mutate_config(mutator): + """Apply one delegation mutation through the existing config API.""" + from hermes_cli.config import load_config, save_config + + config = load_config() + result = mutator(config) + save_config(config) + return result + + +def _persist_override( + model: Optional[str], provider: Optional[str] +) -> SubagentModelStatus: + """Atomically persist (or clear) the two-key delegation override.""" + + def apply(config): + delegation = config.get("delegation") + if not isinstance(delegation, dict): + delegation = {} + else: + delegation = dict(delegation) + + if model: + delegation["model"] = str(model).strip() + if provider: + delegation["provider"] = str(provider).strip() + else: + delegation.pop("provider", None) + else: + delegation.pop("model", None) + delegation.pop("provider", None) + + if delegation: + config["delegation"] = delegation + else: + config.pop("delegation", None) + return _status_from_config(config) + + return _mutate_config(apply) + + +def persist_subagent_switch_result(result: Any) -> SubagentModelStatus: + """Commit a successful shared ``ModelSwitchResult`` to delegation config.""" + + if not getattr(result, "success", False): + message = str(getattr(result, "error_message", "") or "Invalid subagent model") + raise ValueError(message) + model = str(getattr(result, "new_model", "") or "").strip() + provider = str(getattr(result, "target_provider", "") or "").strip() + if not model: + raise ValueError("Model selection resolved to an empty model") + return _persist_override(model, provider or None) + + +def resolve_subagent_model(model: str, *, provider: Optional[str] = None): + """Resolve and validate through the canonical model-switch pipeline.""" + + raw_model = str(model or "").strip() + if not raw_model: + raise ValueError("model is required") + + from hermes_cli.config import load_config + from hermes_cli.inventory import load_picker_context + from hermes_cli.model_switch import switch_model + + context = load_picker_context() + current_override = _status_from_config(load_config()) + target_provider = str(provider or current_override.provider or "").strip() + result = switch_model( + raw_input=raw_model, + current_provider=context.current_provider, + current_model=context.current_model, + current_base_url=context.current_base_url, + current_api_key="", + is_global=False, + explicit_provider=target_provider, + user_providers=context.user_providers, + custom_providers=context.custom_providers, + ) + if not result.success: + raise ValueError(result.error_message or "Invalid subagent model") + return result + + +def set_subagent_model( + model: str, *, provider: Optional[str] = None +) -> SubagentModelStatus: + """Resolve, validate, normalize, then persist a subagent override.""" + + return persist_subagent_switch_result( + resolve_subagent_model(model, provider=provider) + ) + + +def reset_subagent_model() -> SubagentModelStatus: + """Remove only model/provider; preserve every other delegation setting.""" + + return _persist_override(None, None) + + +def _persist_reasoning(effort: Optional[str]) -> SubagentReasoningStatus: + """Persist or clear only the child reasoning override.""" + + def apply(config): + delegation = config.get("delegation") + if not isinstance(delegation, dict): + delegation = {} + else: + delegation = dict(delegation) + + if effort is None: + delegation.pop("reasoning_effort", None) + else: + delegation["reasoning_effort"] = effort + + if delegation: + config["delegation"] = delegation + else: + config.pop("delegation", None) + return _reasoning_status_from_config(config) + + return _mutate_config(apply) + + +def set_subagent_reasoning_effort(effort: str) -> SubagentReasoningStatus: + """Validate, canonicalize, and persist child reasoning effort.""" + + from hermes_constants import parse_reasoning_effort + + raw = str(effort or "").strip() + parsed = parse_reasoning_effort(raw) + if parsed is None: + raise ValueError( + "Invalid subagent reasoning effort. Expected one of: " + "none, minimal, low, medium, high, xhigh, max, ultra" + ) + canonical = "none" if not parsed.get("enabled", True) else str(parsed["effort"]) + return _persist_reasoning(canonical) + + +def reset_subagent_reasoning_effort() -> SubagentReasoningStatus: + """Make future children inherit the parent's reasoning configuration.""" + + return _persist_reasoning(None) + + +def list_subagent_picker_providers(*, refresh: bool = False) -> list[dict[str, Any]]: + """Return the same authenticated provider/model inventory as model pickers.""" + + if refresh: + try: + from hermes_cli.models import clear_provider_models_cache + + clear_provider_models_cache() + except Exception: + pass + + from hermes_cli.inventory import build_models_payload, load_picker_context + + context = load_picker_context() + return list( + build_models_payload( + context, + probe_custom_providers=refresh, + probe_current_custom_provider=not refresh, + ).get("providers") + or [] + ) + + +def _canonical_picker_provider(model_config: Any, full_config: dict[str, Any]) -> str: + """Return the runtime-addressable provider selected by the full picker. + + The manual custom-endpoint flow temporarily writes ``model.provider=custom`` + plus ``model.base_url`` and then saves a named ``custom_providers`` entry. + Delegation must persist that entry's canonical ``custom:`` slug; bare + ``custom`` is ambiguous when more than one endpoint exists. + """ + + if not isinstance(model_config, dict): + return "" + provider = str(model_config.get("provider") or "").strip() + if provider != "custom": + return provider + + from hermes_cli.config import get_compatible_custom_providers + from hermes_cli.providers import custom_provider_slug + from hermes_cli.route_identity import normalize_route_base_url + + selected_url = normalize_route_base_url(model_config.get("base_url")) + if not selected_url: + raise ValueError("Custom model selection did not persist an endpoint URL") + for entry in get_compatible_custom_providers(full_config): + if not isinstance(entry, dict): + continue + if normalize_route_base_url(entry.get("base_url")) != selected_url: + continue + identity = str(entry.get("provider_key") or entry.get("name") or "").strip() + if identity: + return custom_provider_slug(identity) + raise ValueError( + "Custom endpoint was selected but no matching saved custom provider was found" + ) + + +def _read_auth_active_provider() -> Any: + """Read the primary auth provider before the picker mutates it.""" + from hermes_cli.auth import _auth_store_lock, _load_auth_store + + with _auth_store_lock(): + store = _load_auth_store() + return store.get("active_provider", _MISSING_ACTIVE_PROVIDER) + + +def _restore_primary_route(model_before: Any, active_provider_before: Any) -> None: + """Restore both primary-route stores and report partial cleanup.""" + import copy + + from hermes_cli.auth import _auth_store_lock, _load_auth_store, _save_auth_store + from hermes_cli.config import load_config, save_config + + restore_errors: list[BaseException] = [] + try: + config = load_config() + if model_before is None: + config.pop("model", None) + else: + config["model"] = copy.deepcopy(model_before) + save_config(config) + except BaseException as exc: + restore_errors.append(exc) + + try: + with _auth_store_lock(): + store = _load_auth_store() + if active_provider_before is _MISSING_ACTIVE_PROVIDER: + store.pop("active_provider", None) + else: + store["active_provider"] = active_provider_before + _save_auth_store(store) + except BaseException as exc: + restore_errors.append(exc) + + if restore_errors: + details = "; ".join(str(exc) for exc in restore_errors) + raise RuntimeError(f"Could not restore the primary model/auth route: {details}") + + +def select_subagent_model_interactively( + *, refresh: bool = False, initial_provider: Optional[str] = None +) -> Optional[SubagentModelStatus]: + """Run the complete ``hermes model`` flow for the delegation target. + + Provider logins, credentials, custom-provider additions, and auxiliary + configuration are deliberately retained. The temporary primary route is + restored before the confirmed selection is committed under ``delegation.*``. + """ + import copy + import sys + + from hermes_cli.auth_model_picker import capture_model_selection + from hermes_cli.config import load_config + from hermes_cli.main import select_provider_and_model + + if refresh: + try: + from hermes_cli.models import clear_provider_models_cache + + clear_provider_models_cache() + except Exception: + pass + + before_config = load_config() + initial_status = _status_from_config(before_config) + picker_provider = initial_provider or initial_status.provider + picker_model = ( + initial_status.model + if not initial_provider or initial_provider == initial_status.provider + else None + ) + model_before = copy.deepcopy(before_config.get("model")) + active_provider_before = _read_auth_active_provider() + selected_config: Optional[dict[str, Any]] = None + selections: list[str] = [] + + print() + print(" Select the provider + model to use for subagents.") + print(" This is the full `hermes model` setup flow: provider login and custom") + print(" provider additions are kept; your active primary model is unchanged.") + print() + + try: + with capture_model_selection(selections.append): + select_provider_and_model( + initial_model=picker_model, + initial_provider=picker_provider, + ) + if selections: + selected_config = copy.deepcopy(load_config()) + finally: + active_error = sys.exc_info()[1] + try: + _restore_primary_route(model_before, active_provider_before) + except Exception as restore_error: + message = ( + "Could not restore the primary model/auth route after subagent " + f"selection: {restore_error}" + ) + if active_error is not None: + active_error.add_note(message) + else: + raise RuntimeError(message) from restore_error + + if not selections or selected_config is None: + return None + + model_config = selected_config.get("model") + selected_model = selections[-1] + selected_provider = _canonical_picker_provider(model_config, selected_config) + return set_subagent_model(selected_model, provider=selected_provider or None) diff --git a/hermes_cli/subcommands/subagent.py b/hermes_cli/subcommands/subagent.py new file mode 100644 index 0000000000000..e84d49345f366 --- /dev/null +++ b/hermes_cli/subcommands/subagent.py @@ -0,0 +1,188 @@ +"""``hermes subagent`` model and reasoning parser. + +Wired from ``hermes_cli/main.py``. Provides the shell-facing entry point +for subagent model selection: + + hermes subagent # status + hermes subagent model # interactive picker + hermes subagent model # validated direct selection + hermes subagent model reset # inherit parent + hermes subagent model --reset # inherit parent (flag form) + hermes subagent reasoning high # fixed child reasoning effort + hermes subagent reasoning reset # inherit parent reasoning +""" + +from __future__ import annotations + +import sys +from typing import Callable + + +def build_subagent_parser(subparsers, *, cmd_subagent: Callable) -> None: + """Attach the ``subagent`` subcommand to ``subparsers``.""" + subagent_parser = subparsers.add_parser( + "subagent", + help="Inspect or configure the subagent model and reasoning", + description=( + "Show the current subagent model selection. When no override " + "is configured, subagents inherit the parent model." + ), + ) + subparsers_sub = subagent_parser.add_subparsers(dest="subagent_command") + + # subagent (no subcommand) → status + subagent_parser.set_defaults(func=cmd_subagent) + + # subagent model → status / select / reset + model_parser = subparsers_sub.add_parser( + "model", + help="Select or reset the subagent model", + description=( + "Pin all subagents to a specific model, or reset to inherit " + "the parent model. With no model argument, opens the complete " + "`hermes model` provider setup flow, including login and custom " + "endpoint creation. Shared provider additions are retained while " + "the active primary model remains unchanged. The delegation " + "provider/model is read on every child spawn — no restart needed." + ), + ) + model_parser.add_argument( + "model", + nargs="?", + help="Model to pin, or 'reset' to inherit the parent model", + ) + model_parser.add_argument( + "--provider", + default=None, + help="Provider to route subagents through (e.g. 'openrouter', 'nous')", + ) + model_parser.add_argument( + "--reset", + action="store_true", + help="Remove the subagent model/provider override (inherit parent)", + ) + model_parser.add_argument( + "--refresh", + action="store_true", + help="Refresh provider model catalogs before opening the full setup picker", + ) + model_parser.set_defaults(func=cmd_subagent) + + reasoning_parser = subparsers_sub.add_parser( + "reasoning", + help="Inspect, set, or reset subagent reasoning effort", + description=( + "Set the reasoning effort used by newly spawned subagents. " + "Without an override, children inherit the parent agent's " + "reasoning configuration. Changes are read on every child spawn." + ), + ) + reasoning_parser.add_argument( + "effort", + nargs="?", + help="none|minimal|low|medium|high|xhigh|max|ultra, or 'reset'", + ) + reasoning_parser.add_argument( + "--reset", + action="store_true", + help="Remove the subagent reasoning override (inherit parent)", + ) + reasoning_parser.set_defaults(func=cmd_subagent) + + +def cmd_subagent(args): + """Inspect or configure the subagent model and reasoning.""" + from hermes_cli.main import _require_tty + from hermes_cli.subagent_model import ( + get_subagent_model_status, + get_subagent_reasoning_status, + reset_subagent_model, + reset_subagent_reasoning_effort, + select_subagent_model_interactively, + set_subagent_model, + set_subagent_reasoning_effort, + ) + + sub = getattr(args, "subagent_command", None) + if sub in {None, ""}: + _print_subagent_status(get_subagent_model_status()) + _print_subagent_reasoning_status(get_subagent_reasoning_status()) + return + + if sub == "model": + model_arg = getattr(args, "model", None) + positional_reset = ( + isinstance(model_arg, str) and model_arg.strip().lower() == "reset" + ) + if getattr(args, "reset", False) or positional_reset: + _print_subagent_status(reset_subagent_model(), action="Reset") + return + if model_arg: + try: + status = set_subagent_model( + model_arg, + provider=getattr(args, "provider", None) or None, + ) + except ValueError as exc: + print(f" ✗ {exc}", file=sys.stderr) + return 2 + _print_subagent_status(status, action="Pinned") + return + _require_tty("subagent model") + status = select_subagent_model_interactively( + refresh=bool(getattr(args, "refresh", False)), + initial_provider=getattr(args, "provider", None) or None, + ) + if status is None: + print(" Subagent model selection cancelled.") + return + _print_subagent_status(status, action="Selected") + return + + if sub == "reasoning": + effort_arg = getattr(args, "effort", None) + positional_reset = ( + isinstance(effort_arg, str) + and effort_arg.strip().lower() in {"clear", "default", "inherit", "reset"} + ) + if getattr(args, "reset", False) or positional_reset: + _print_subagent_reasoning_status( + reset_subagent_reasoning_effort(), action="Reset" + ) + return + if effort_arg: + try: + status = set_subagent_reasoning_effort(effort_arg) + except ValueError as exc: + print(f" ✗ {exc}", file=sys.stderr) + return 2 + _print_subagent_reasoning_status(status, action="Set") + return + _print_subagent_reasoning_status(get_subagent_reasoning_status()) + return + + print( + "usage: hermes subagent " + "[model [|--reset|--refresh] | reasoning [|--reset]]" + ) + + + +def _print_subagent_status(status, action=None): + if status.inherits_parent: + label = "inherits parent" + elif status.model: + label = status.model + if status.provider: + label = f"{label} (provider: {status.provider})" + else: + label = f"provider default (provider: {status.provider})" + prefix = f"{action} " if action else "" + print(f" {prefix}subagent model: {label}") + + + +def _print_subagent_reasoning_status(status, action=None): + label = "inherits parent" if status.inherits_parent else (status.effort or "(none)") + prefix = f"{action} " if action else "" + print(f" {prefix}subagent reasoning: {label}") diff --git a/tests/cli/test_subagent_command.py b/tests/cli/test_subagent_command.py new file mode 100644 index 0000000000000..f7d21819156ee --- /dev/null +++ b/tests/cli/test_subagent_command.py @@ -0,0 +1,330 @@ +from types import SimpleNamespace + +import cli as cli_mod +from cli import HermesCLI +from hermes_cli import subagent_model +from hermes_cli.commands import resolve_command +from hermes_cli.model_switch import ModelSwitchResult + + +def _stub_cli(): + obj = HermesCLI.__new__(HermesCLI) + obj.model = "primary-model" + obj.provider = "primary-provider" + obj.base_url = "https://primary.example/v1" + obj.api_key = "primary-key" + obj._app = None + return obj + + +def test_subagent_command_is_classic_cli_only(): + command = resolve_command("/subagent") + assert command is not None + assert command.cli_only is True + assert command.gateway_only is False + + +def test_classic_subagent_direct_model_uses_shared_core(monkeypatch): + calls = [] + output = [] + expected = subagent_model.SubagentModelStatus( + model="canonical-model", + provider="target-provider", + inherits_parent=False, + ) + monkeypatch.setattr( + subagent_model, + "set_subagent_model", + lambda model, provider=None: calls.append((model, provider)) or expected, + ) + monkeypatch.setattr(cli_mod, "_cprint", output.append) + + cli = _stub_cli() + cli._handle_subagent_command("/subagent model alias --provider target-provider") + + assert calls == [("alias", "target-provider")] + assert cli.model == "primary-model" + assert cli.provider == "primary-provider" + assert any("canonical-model" in line for line in output) + + +def test_classic_provider_only_status_names_provider_default(monkeypatch): + output = [] + monkeypatch.setattr( + subagent_model, + "get_subagent_model_status", + lambda: subagent_model.SubagentModelStatus(None, "openrouter", False), + ) + monkeypatch.setattr( + subagent_model, + "get_subagent_reasoning_status", + lambda: subagent_model.SubagentReasoningStatus(None, True), + ) + monkeypatch.setattr(cli_mod, "_cprint", output.append) + + _stub_cli()._handle_subagent_command("/subagent") + + assert any("provider default (openrouter)" in line for line in output) + assert all("None" not in line for line in output) + + +def test_classic_subagent_reasoning_set_and_reset_are_independent(monkeypatch): + calls = [] + output = [] + explicit = subagent_model.SubagentReasoningStatus( + effort="high", + inherits_parent=False, + ) + inherited = subagent_model.SubagentReasoningStatus( + effort=None, + inherits_parent=True, + ) + monkeypatch.setattr( + subagent_model, + "set_subagent_reasoning_effort", + lambda effort: calls.append(("set", effort)) or explicit, + ) + monkeypatch.setattr( + subagent_model, + "reset_subagent_reasoning_effort", + lambda: calls.append(("reset", None)) or inherited, + ) + monkeypatch.setattr(cli_mod, "_cprint", output.append) + + cli = _stub_cli() + cli._handle_subagent_command("/subagent reasoning high") + cli._handle_subagent_command("/subagent reasoning reset") + + assert calls == [("set", "high"), ("reset", None)] + assert any("reasoning: high" in line for line in output) + assert any("inherits parent" in line for line in output) + + +def test_classic_subagent_model_opens_existing_picker_for_subagent_target(monkeypatch): + context = SimpleNamespace( + current_model="parent-model", + current_provider="parent-provider", + user_providers={"target-provider": {}}, + custom_providers=[], + ) + status = subagent_model.SubagentModelStatus( + model="child-model", + provider="target-provider", + inherits_parent=False, + ) + providers = [ + { + "slug": "target-provider", + "name": "Target Provider", + "models": ["child-model", "other-model"], + "total_models": 2, + "is_current": False, + } + ] + monkeypatch.setattr( + "hermes_cli.inventory.load_picker_context", + lambda: context, + ) + monkeypatch.setattr( + subagent_model, + "get_subagent_model_status", + lambda: status, + ) + monkeypatch.setattr( + subagent_model, + "list_subagent_picker_providers", + lambda refresh=False: providers, + ) + + captured = {} + cli = _stub_cli() + cli._open_model_picker = lambda *args, **kwargs: captured.update( + args=args, + kwargs=kwargs, + ) + cli._handle_subagent_command("/subagent model") + + assert captured["args"][1:] == ("child-model", "target-provider") + assert captured["kwargs"]["target"] == "subagent" + assert captured["args"][0][0]["is_current"] is True + assert cli.model == "primary-model" + assert cli.provider == "primary-provider" + + +def test_classic_provider_only_opens_picker_on_requested_provider(monkeypatch): + context = SimpleNamespace( + current_model="parent-model", + current_provider="parent-provider", + user_providers={"requested-provider": {}}, + custom_providers=[], + ) + status = subagent_model.SubagentModelStatus( + model="old-child-model", + provider="old-provider", + inherits_parent=False, + ) + providers = [ + {"slug": "old-provider", "models": ["old-child-model"]}, + {"slug": "requested-provider", "models": ["new-child-model"]}, + ] + monkeypatch.setattr("hermes_cli.inventory.load_picker_context", lambda: context) + monkeypatch.setattr(subagent_model, "get_subagent_model_status", lambda: status) + monkeypatch.setattr( + subagent_model, "list_subagent_picker_providers", lambda refresh=False: providers + ) + + captured = {} + cli = _stub_cli() + cli._open_model_picker = lambda *args, **kwargs: captured.update( + args=args, kwargs=kwargs + ) + cli._handle_subagent_command("/subagent model --provider requested-provider") + + assert captured["args"][2] == "requested-provider" + assert captured["args"][1] == "unknown" + assert captured["args"][0][1]["is_current"] is True + assert captured["kwargs"]["target"] == "subagent" + + +def test_subagent_picker_opens_on_configured_delegation_model(): + cli = _stub_cli() + cli._model_picker_state = { + "stage": "provider", + "selected": 0, + "providers": [ + { + "slug": "target-provider", + "models": ["first-model", "child-model", "third-model"], + } + ], + "current_model": "child-model", + "current_provider": "target-provider", + "target": "subagent", + } + cli._invalidate = lambda min_interval=0.25: None + + cli._handle_model_picker_selection() + + assert cli._model_picker_state["stage"] == "model" + assert cli._model_picker_state["selected"] == 1 + + +def test_model_picker_routes_subagent_target_without_main_switch(monkeypatch): + result = ModelSwitchResult( + success=True, + new_model="child-model", + target_provider="target-provider", + ) + switch_kwargs = {} + + def fake_switch_model(**kwargs): + switch_kwargs.update(kwargs) + return result + + monkeypatch.setattr( + "hermes_cli.model_switch.switch_model", + fake_switch_model, + ) + + routed = [] + cli = _stub_cli() + cli._model_picker_state = { + "stage": "model", + "selected": 0, + "provider_data": {"slug": "target-provider"}, + "model_list": ["child-model"], + "current_model": "old-child-model", + "current_provider": "target-provider", + "custom_provs": [], + "user_provs": {}, + "target": "subagent", + } + cli._close_model_picker = lambda: setattr(cli, "_model_picker_state", None) + cli._confirm_and_apply_subagent_model_result = routed.append + cli._confirm_and_apply_model_switch_result = lambda *_args: (_ for _ in ()).throw( + AssertionError("subagent selection must not apply to the primary runtime") + ) + + cli._handle_model_picker_selection(persist_global=True) + + assert routed == [result] + assert switch_kwargs["is_global"] is False + assert switch_kwargs["current_provider"] == "primary-provider" + assert switch_kwargs["explicit_provider"] == "target-provider" + assert cli.model == "primary-model" + assert cli.provider == "primary-provider" + + +def test_model_picker_main_target_preserves_runtime_route(monkeypatch): + result = ModelSwitchResult( + success=True, + new_model="next-primary-model", + target_provider="primary-provider", + ) + captured = {} + + def fake_switch_model(**kwargs): + captured.update(kwargs) + return result + + monkeypatch.setattr("hermes_cli.model_switch.switch_model", fake_switch_model) + + routed = [] + cli = _stub_cli() + cli._model_picker_state = { + "stage": "model", + "selected": 0, + "provider_data": {"slug": "primary-provider"}, + "model_list": ["next-primary-model"], + # The primary picker stores a human-facing label here. It must not be + # passed back as the canonical current provider. + "current_model": "primary-model", + "current_provider": "Primary Provider", + "custom_provs": [], + "user_provs": {}, + "target": "main", + } + cli._close_model_picker = lambda: setattr(cli, "_model_picker_state", None) + monkeypatch.setattr( + cli, + "_confirm_and_apply_model_switch_result", + lambda value, persist, custom_providers=None: routed.append( + (value, persist, custom_providers) + ), + ) + + cli._handle_model_picker_selection(persist_global=True) + + assert captured["current_provider"] == "primary-provider" + assert captured["current_model"] == "primary-model" + assert routed == [(result, True, [])] + + +def test_subagent_picker_persists_override_without_mutating_primary(monkeypatch): + result = ModelSwitchResult( + success=True, + new_model="child-model", + target_provider="target-provider", + ) + expected = subagent_model.SubagentModelStatus( + model="child-model", + provider="target-provider", + inherits_parent=False, + ) + calls = [] + output = [] + monkeypatch.setattr( + subagent_model, + "persist_subagent_switch_result", + lambda value: calls.append(value) or expected, + ) + monkeypatch.setattr(cli_mod, "_cprint", output.append) + + cli = _stub_cli() + cli._confirm_expensive_model_switch = lambda _result, **_kwargs: True + before = (cli.model, cli.provider, cli.base_url, cli.api_key) + cli._confirm_and_apply_subagent_model_result(result) + + assert calls == [result] + assert (cli.model, cli.provider, cli.base_url, cli.api_key) == before + assert any("delegation.model/provider" in line for line in output) diff --git a/tests/hermes_cli/test_subagent_model.py b/tests/hermes_cli/test_subagent_model.py new file mode 100644 index 0000000000000..0a8d85826e668 --- /dev/null +++ b/tests/hermes_cli/test_subagent_model.py @@ -0,0 +1,808 @@ +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any, cast + +import pytest + +from hermes_cli import auth_model_picker, subagent_model +from hermes_cli.subcommands import subagent as subagent_cmd + + +def test_shell_provider_only_status_names_provider_default(capsys): + from hermes_cli import main as hermes_main + + subagent_cmd._print_subagent_status( + subagent_model.SubagentModelStatus(None, "openrouter", False) + ) + + output = capsys.readouterr().out + assert "provider default (provider: openrouter)" in output + assert "None" not in output + + +def test_status_preserves_provider_only_runtime_override(monkeypatch): + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: {"delegation": {"provider": "openrouter", "max_spawn_depth": 2}}, + ) + + status = subagent_model.get_subagent_model_status() + + assert status.inherits_parent is False + assert status.model is None + assert status.provider == "openrouter" + + monkeypatch.setattr( + "hermes_cli.runtime_provider.resolve_runtime_provider", + lambda **_kwargs: { + "provider": "openrouter", + "model": "provider-default-model", + "api_key": "test-key", + "base_url": "https://openrouter.ai/api/v1", + }, + ) + from tools.delegate_tool_config import _resolve_delegation_credentials + + runtime = _resolve_delegation_credentials( + {"provider": "openrouter"}, parent_agent=None + ) + assert runtime["provider"] == status.provider + assert runtime["model"] == "provider-default-model" + + +def test_reset_preserves_unrelated_delegation_settings(monkeypatch): + config = { + "delegation": { + "model": "old-model", + "provider": "old-provider", + "max_spawn_depth": 3, + "max_concurrent_children": 2, + } + } + saved = [] + monkeypatch.setattr("hermes_cli.config.load_config", lambda: config.copy()) + monkeypatch.setattr( + "hermes_cli.config.save_config", lambda value: saved.append(value) + ) + + status = subagent_model.reset_subagent_model() + + assert status.inherits_parent is True + assert saved == [ + {"delegation": {"max_spawn_depth": 3, "max_concurrent_children": 2}} + ] + + +def test_set_uses_canonical_switch_pipeline_then_saves_normalized_pair(monkeypatch): + context = SimpleNamespace( + current_provider="nous", + current_model="Hermes-4", + current_base_url="https://inference.example/v1", + user_providers={"local": {}}, + custom_providers={"custom": {}}, + ) + calls = [] + saved = [] + monkeypatch.setattr("hermes_cli.inventory.load_picker_context", lambda: context) + + def fake_switch_model(**kwargs): + calls.append(kwargs) + return SimpleNamespace( + success=True, + new_model="anthropic/claude-sonnet-4", + target_provider="openrouter", + error_message=None, + ) + + monkeypatch.setattr("hermes_cli.model_switch.switch_model", fake_switch_model) + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: {"delegation": {"max_spawn_depth": 2}}, + ) + monkeypatch.setattr( + "hermes_cli.config.save_config", lambda value: saved.append(value) + ) + + status = subagent_model.set_subagent_model("sonnet", provider="openrouter") + + assert status == subagent_model.SubagentModelStatus( + model="anthropic/claude-sonnet-4", + provider="openrouter", + inherits_parent=False, + ) + assert calls == [ + { + "raw_input": "sonnet", + "current_provider": "nous", + "current_model": "Hermes-4", + "current_base_url": "https://inference.example/v1", + "current_api_key": "", + "is_global": False, + "explicit_provider": "openrouter", + "user_providers": {"local": {}}, + "custom_providers": {"custom": {}}, + } + ] + assert saved == [ + { + "delegation": { + "max_spawn_depth": 2, + "model": "anthropic/claude-sonnet-4", + "provider": "openrouter", + } + } + ] + + +def test_failed_resolution_does_not_write_config(monkeypatch): + context = SimpleNamespace( + current_provider="nous", + current_model="Hermes-4", + current_base_url="", + user_providers={}, + custom_providers={}, + ) + monkeypatch.setattr("hermes_cli.inventory.load_picker_context", lambda: context) + monkeypatch.setattr("hermes_cli.config.load_config", lambda: {}) + monkeypatch.setattr( + "hermes_cli.model_switch.switch_model", + lambda **_kwargs: SimpleNamespace( + success=False, + error_message="Provider is not authenticated", + ), + ) + monkeypatch.setattr( + "hermes_cli.config.save_config", + lambda _value: pytest.fail("failed model selection must not persist"), + ) + + with pytest.raises(ValueError, match="not authenticated"): + subagent_model.set_subagent_model("private-model", provider="missing") + + +def test_direct_model_reuses_existing_subagent_provider_when_omitted(monkeypatch): + context = SimpleNamespace( + current_provider="parent-provider", + current_model="parent-model", + current_base_url="https://parent.example/v1", + user_providers={}, + custom_providers={}, + ) + config = { + "delegation": { + "model": "old-child-model", + "provider": "child-provider", + } + } + calls = [] + monkeypatch.setattr("hermes_cli.inventory.load_picker_context", lambda: context) + monkeypatch.setattr("hermes_cli.config.load_config", lambda: config) + monkeypatch.setattr( + "hermes_cli.model_switch.switch_model", + lambda **kwargs: ( + calls.append(kwargs) + or SimpleNamespace( + success=True, + new_model="new-child-model", + target_provider="child-provider", + error_message=None, + ) + ), + ) + monkeypatch.setattr("hermes_cli.config.save_config", lambda _value: None) + + subagent_model.set_subagent_model("new-child-model") + + assert calls[0]["current_provider"] == "parent-provider" + assert calls[0]["current_model"] == "parent-model" + assert calls[0]["explicit_provider"] == "child-provider" + + +def test_full_picker_selection_capture_is_thread_local(): + from concurrent.futures import ThreadPoolExecutor + from threading import Barrier + + from hermes_cli.auth_model_picker import capture_model_selection, record_model_selection + + barrier = Barrier(2) + + def capture_one(model_id: str) -> list[str]: + selections: list[str] = [] + with capture_model_selection(selections.append): + barrier.wait() + record_model_selection(model_id) + barrier.wait() + return list(selections) + + with ThreadPoolExecutor(max_workers=2) as pool: + results = list(pool.map(capture_one, ("terra-model", "moon-model"))) + + assert results == [["terra-model"], ["moon-model"]] + + +def _memory_config(monkeypatch, initial): + import copy + + state = copy.deepcopy(initial) + + def load_config(): + return copy.deepcopy(state) + + def save_config(value): + state.clear() + state.update(copy.deepcopy(value)) + + monkeypatch.setattr("hermes_cli.config.load_config", load_config) + monkeypatch.setattr("hermes_cli.config.save_config", save_config) + return state + + +def _stub_auth_restore(monkeypatch): + import copy + + restored = [] + monkeypatch.setattr( + subagent_model, "_read_auth_active_provider", lambda: "parent-auth" + ) + + def restore(model_before, active_provider_before): + from hermes_cli.config import load_config, save_config + + config = load_config() + if model_before is None: + config.pop("model", None) + else: + config["model"] = copy.deepcopy(model_before) + save_config(config) + restored.append(active_provider_before) + + monkeypatch.setattr(subagent_model, "_restore_primary_route", restore) + return restored + + +@pytest.mark.parametrize( + ("delegation", "expected_initial"), + [ + ( + {"model": "sub-model", "provider": "custom:sub-endpoint"}, + ("sub-model", "custom:sub-endpoint"), + ), + ({"model": "sub-model"}, ("sub-model", None)), + ({"provider": "openrouter"}, (None, "openrouter")), + ({"max_spawn_depth": 2}, (None, None)), + ], + ids=("full-override", "model-only", "provider-only", "inherits-parent"), +) +def test_full_picker_starts_from_target_selection( + monkeypatch, delegation, expected_initial +): + from hermes_cli import main as hermes_main + + primary = {"default": "parent-model", "provider": "openrouter"} + state = _memory_config( + monkeypatch, + {"model": primary, "delegation": delegation}, + ) + _stub_auth_restore(monkeypatch) + initial: list[tuple[str | None, str | None]] = [] + + def fake_full_picker( + *, initial_model: str | None = None, initial_provider: str | None = None + ): + initial.append((initial_model, initial_provider)) + + monkeypatch.setattr(hermes_main, "select_provider_and_model", fake_full_picker) + + assert subagent_model.select_subagent_model_interactively() is None + assert initial == [expected_initial] + assert state["model"] == primary + + +def test_shared_full_picker_uses_initial_provider_and_model(monkeypatch): + import copy + + from hermes_cli import main as hermes_main + + config = { + "model": {"default": "parent-model", "provider": "openrouter"}, + } + monkeypatch.setattr("hermes_cli.config.load_config", lambda: copy.deepcopy(config)) + monkeypatch.setattr( + "hermes_cli.config.read_raw_config", lambda: copy.deepcopy(config) + ) + + provider_defaults: list[tuple[str, bool]] = [] + selected_models: list[str] = [] + + def fake_prompt(choices, *, default=0, title="Select provider:"): + provider_defaults.append((title, "currently active" in choices[default])) + return default + + monkeypatch.setattr(hermes_main, "_prompt_provider_choice", fake_prompt) + monkeypatch.setattr( + hermes_main, + "_model_flow_nous", + lambda _config, current_model, args=None: selected_models.append(current_model), + ) + monkeypatch.setattr(hermes_main, "_clear_stale_openai_base_url", lambda: None) + + hermes_main.select_provider_and_model( + initial_model="sub-model", + initial_provider="nous", + ) + + assert provider_defaults == [("Select provider:", True)] + assert selected_models == ["sub-model"] + + +def test_shared_full_picker_uses_named_custom_initial_provider(monkeypatch): + import copy + + from hermes_cli import main as hermes_main + + config = { + "model": {"default": "parent-model", "provider": "openrouter"}, + "custom_providers": [ + { + "name": "Sub Endpoint", + "base_url": "https://sub.example/v1", + "model": "provider-default", + } + ], + } + monkeypatch.setattr("hermes_cli.config.load_config", lambda: copy.deepcopy(config)) + monkeypatch.setattr( + "hermes_cli.config.read_raw_config", lambda: copy.deepcopy(config) + ) + selected_labels: list[str] = [] + selected_provider_models: list[str] = [] + + def fake_prompt(choices, *, default=0, title="Select provider:"): + selected_labels.append(choices[default]) + return default + + monkeypatch.setattr(hermes_main, "_prompt_provider_choice", fake_prompt) + monkeypatch.setattr( + hermes_main, + "_model_flow_named_custom", + lambda _config, provider_info: selected_provider_models.append( + provider_info["model"] + ), + ) + + hermes_main.select_provider_and_model( + initial_model="sub-model", + initial_provider="custom:sub-endpoint", + ) + + assert len(selected_labels) == 1 + assert selected_labels[0].startswith("Sub Endpoint (sub.example/v1) — sub-model") + assert "provider-default" not in selected_labels[0] + assert "currently active" in selected_labels[0] + assert selected_provider_models == ["sub-model"] + + +def test_restore_primary_route_attempts_auth_after_model_restore_interrupt(monkeypatch): + from contextlib import nullcontext + + auth_saves = [] + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: (_ for _ in ()).throw(KeyboardInterrupt("config restore interrupted")), + ) + monkeypatch.setattr("hermes_cli.auth._auth_store_lock", nullcontext) + monkeypatch.setattr( + "hermes_cli.auth._load_auth_store", + lambda: {"active_provider": "temporary-provider"}, + ) + monkeypatch.setattr( + "hermes_cli.auth._save_auth_store", + lambda store: auth_saves.append(dict(store)), + ) + + with pytest.raises(RuntimeError, match="config restore interrupted"): + subagent_model._restore_primary_route("old-model", "old-provider") + + assert auth_saves == [{"active_provider": "old-provider"}] + + +def test_restore_primary_route_preserves_absent_active_provider(monkeypatch): + from contextlib import nullcontext + + store = {"version": 1, "providers": {}} + + def save_auth(value): + store.clear() + store.update(value) + + monkeypatch.setattr("hermes_cli.config.load_config", lambda: {}) + monkeypatch.setattr("hermes_cli.config.save_config", lambda _config: None) + monkeypatch.setattr("hermes_cli.auth._auth_store_lock", nullcontext) + monkeypatch.setattr("hermes_cli.auth._load_auth_store", lambda: dict(store)) + monkeypatch.setattr("hermes_cli.auth._save_auth_store", save_auth) + + before = subagent_model._read_auth_active_provider() + subagent_model._restore_primary_route(None, before) + + assert "active_provider" not in store + + +def test_full_picker_keeps_setup_side_effects_and_restores_primary(monkeypatch): + import copy + + from hermes_cli import main as hermes_main + + primary = {"default": "same-model", "provider": "parent-provider"} + state = _memory_config( + monkeypatch, + {"model": primary, "delegation": {"max_spawn_depth": 2}}, + ) + restored_auth = _stub_auth_restore(monkeypatch) + set_calls = [] + pinned = subagent_model.SubagentModelStatus("same-model", "openrouter", False) + + def fake_full_picker(**_kwargs): + # Explicitly selecting the same model still counts as a selection. + auth_model_picker._save_model_choice("same-model") + updated = copy.deepcopy(state) + updated["model"]["provider"] = "openrouter" + updated["providers"] = {"new-provider": {"api_key": "${NEW_KEY}"}} + from hermes_cli.config import save_config + + save_config(updated) + + monkeypatch.setattr(hermes_main, "select_provider_and_model", fake_full_picker) + monkeypatch.setattr( + subagent_model, + "set_subagent_model", + lambda model, provider=None: set_calls.append((model, provider)) or pinned, + ) + + assert subagent_model.select_subagent_model_interactively() == pinned + assert set_calls == [("same-model", "openrouter")] + assert state["model"] == primary + assert state["providers"] == {"new-provider": {"api_key": "${NEW_KEY}"}} + assert restored_auth == ["parent-auth"] + + +def test_full_picker_cancel_keeps_setup_changes_without_pinning(monkeypatch): + import copy + + from hermes_cli import main as hermes_main + + primary = {"default": "parent-model", "provider": "parent-provider"} + state = _memory_config(monkeypatch, {"model": primary}) + restored_auth = _stub_auth_restore(monkeypatch) + + def fake_cancelled_picker(**_kwargs): + updated = copy.deepcopy(state) + updated["providers"] = {"authenticated-only": {"key_env": "AUTH_KEY"}} + from hermes_cli.config import save_config + + save_config(updated) + + monkeypatch.setattr(hermes_main, "select_provider_and_model", fake_cancelled_picker) + assert subagent_model.select_subagent_model_interactively() is None + assert state["model"] == primary + assert state["providers"] == {"authenticated-only": {"key_env": "AUTH_KEY"}} + assert restored_auth == ["parent-auth"] + + +def test_full_picker_adds_custom_provider_and_pins_canonical_slug(monkeypatch): + import copy + + from hermes_cli import main as hermes_main + + primary = {"default": "parent-model", "provider": "parent-provider"} + state = _memory_config(monkeypatch, {"model": primary}) + restored_auth = _stub_auth_restore(monkeypatch) + set_calls = [] + pinned = subagent_model.SubagentModelStatus( + "moon-model", "custom:terra-to-moon", False + ) + + def fake_custom_picker(**_kwargs): + auth_model_picker._save_model_choice("moon-model") + updated = copy.deepcopy(state) + updated["model"].update({ + "provider": "custom", + "base_url": "https://moon.example/v1/", + "api_mode": "chat_completions", + }) + updated["custom_providers"] = [ + { + "name": "Terra to Moon", + "base_url": "https://moon.example/v1", + "model": "moon-model", + } + ] + from hermes_cli.config import save_config + + save_config(updated) + + monkeypatch.setattr(hermes_main, "select_provider_and_model", fake_custom_picker) + monkeypatch.setattr( + subagent_model, + "set_subagent_model", + lambda model, provider=None: set_calls.append((model, provider)) or pinned, + ) + + assert subagent_model.select_subagent_model_interactively() == pinned + assert set_calls == [("moon-model", "custom:terra-to-moon")] + assert state["model"] == primary + custom_providers = cast(list[dict[str, Any]], state["custom_providers"]) + assert custom_providers[0]["name"] == "Terra to Moon" + assert restored_auth == ["parent-auth"] + + +def test_shell_interactive_cancel_reports_cancelled(monkeypatch, capsys): + from hermes_cli import main as hermes_main + + monkeypatch.setattr(hermes_main, "_require_tty", lambda _command: None) + monkeypatch.setattr( + subagent_model, + "select_subagent_model_interactively", + lambda **_kwargs: None, + ) + + hermes_main.cmd_subagent( + SimpleNamespace( + subagent_command="model", + model=None, + provider=None, + reset=False, + refresh=False, + ) + ) + + output = capsys.readouterr().out + assert "selection cancelled" in output + assert "Selected subagent model" not in output + + +def test_shell_provider_only_starts_picker_on_requested_provider(monkeypatch): + from hermes_cli import main as hermes_main + + calls = [] + monkeypatch.setattr(hermes_main, "_require_tty", lambda _command: None) + monkeypatch.setattr( + subagent_model, + "select_subagent_model_interactively", + lambda **kwargs: calls.append(kwargs), + ) + + hermes_main.cmd_subagent( + SimpleNamespace( + subagent_command="model", + model=None, + provider="openrouter", + reset=False, + refresh=True, + ) + ) + + assert calls == [{"refresh": True, "initial_provider": "openrouter"}] + + +def test_shell_positional_reset_restores_parent_inheritance(monkeypatch, capsys): + from hermes_cli import main as hermes_main + + inherited = subagent_model.SubagentModelStatus( + model=None, + provider=None, + inherits_parent=True, + ) + reset_calls = [] + monkeypatch.setattr( + subagent_model, + "reset_subagent_model", + lambda: reset_calls.append(True) or inherited, + ) + monkeypatch.setattr( + subagent_model, + "set_subagent_model", + lambda *_args, **_kwargs: pytest.fail("'reset' must not be pinned as a model"), + ) + + hermes_main.cmd_subagent( + SimpleNamespace( + subagent_command="model", + model="reset", + provider=None, + reset=False, + refresh=False, + ) + ) + + assert reset_calls == [True] + assert "inherits parent" in capsys.readouterr().out + + +def test_reasoning_status_distinguishes_explicit_off_from_inheritance(monkeypatch): + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: {"delegation": {"reasoning_effort": "none"}}, + ) + + assert subagent_model.get_subagent_reasoning_status() == ( + subagent_model.SubagentReasoningStatus( + effort="none", + inherits_parent=False, + ) + ) + + +def test_set_reasoning_canonicalizes_alias_and_preserves_model_override(monkeypatch): + state = _memory_config( + monkeypatch, + { + "delegation": { + "model": "sub-model", + "provider": "custom:sub-endpoint", + "max_spawn_depth": 2, + } + }, + ) + + status = subagent_model.set_subagent_reasoning_effort("disabled") + + assert status == subagent_model.SubagentReasoningStatus( + effort="none", + inherits_parent=False, + ) + assert state == { + "delegation": { + "model": "sub-model", + "provider": "custom:sub-endpoint", + "max_spawn_depth": 2, + "reasoning_effort": "none", + } + } + + +def test_reset_reasoning_preserves_model_provider_and_other_delegation(monkeypatch): + state = _memory_config( + monkeypatch, + { + "delegation": { + "model": "sub-model", + "provider": "openrouter", + "reasoning_effort": "high", + "max_concurrent_children": 3, + } + }, + ) + + status = subagent_model.reset_subagent_reasoning_effort() + + assert status.inherits_parent is True + assert status.effort is None + assert state == { + "delegation": { + "model": "sub-model", + "provider": "openrouter", + "max_concurrent_children": 3, + } + } + + +def test_invalid_reasoning_effort_does_not_write_config(monkeypatch): + monkeypatch.setattr( + "hermes_cli.config.save_config", + lambda _value: pytest.fail("invalid reasoning effort must not persist"), + ) + + with pytest.raises(ValueError, match="Invalid subagent reasoning effort"): + subagent_model.set_subagent_reasoning_effort("turbo") + + +def test_shell_reasoning_set_and_reset_are_independent(monkeypatch, capsys): + from hermes_cli import main as hermes_main + + explicit = subagent_model.SubagentReasoningStatus("high", False) + inherited = subagent_model.SubagentReasoningStatus(None, True) + calls: list[tuple[str, str | None]] = [] + monkeypatch.setattr( + subagent_model, + "set_subagent_reasoning_effort", + lambda effort: calls.append(("set", effort)) or explicit, + ) + monkeypatch.setattr( + subagent_model, + "reset_subagent_reasoning_effort", + lambda: calls.append(("reset", None)) or inherited, + ) + + hermes_main.cmd_subagent( + SimpleNamespace(subagent_command="reasoning", effort="high", reset=False) + ) + hermes_main.cmd_subagent( + SimpleNamespace(subagent_command="reasoning", effort="inherit", reset=False) + ) + + assert calls == [("set", "high"), ("reset", None)] + output = capsys.readouterr().out + assert "Set subagent reasoning: high" in output + assert "Reset subagent reasoning: inherits parent" in output + + +def test_shell_invalid_reasoning_returns_nonzero(monkeypatch, capsys): + from hermes_cli import main as hermes_main + + monkeypatch.setattr( + subagent_model, + "set_subagent_reasoning_effort", + lambda _effort: (_ for _ in ()).throw(ValueError("invalid reasoning")), + ) + + rc = hermes_main.cmd_subagent( + SimpleNamespace( + subagent_command="reasoning", + effort="turbo", + reset=False, + ) + ) + + assert rc == 2 + assert "invalid reasoning" in capsys.readouterr().err + + +def test_picker_exception_restores_primary_and_attempts_auth(monkeypatch): + import copy + + from hermes_cli import main as hermes_main + + primary = {"default": "parent-model", "provider": "parent-provider"} + state = _memory_config(monkeypatch, {"model": primary}) + auth_restores = _stub_auth_restore(monkeypatch) + + def failing_picker(**_kwargs): + updated = copy.deepcopy(state) + updated["model"] = {"default": "temporary-model", "provider": "temporary"} + from hermes_cli.config import save_config + + save_config(updated) + raise RuntimeError("picker failed") + + monkeypatch.setattr(hermes_main, "select_provider_and_model", failing_picker) + + with pytest.raises(RuntimeError, match="picker failed"): + subagent_model.select_subagent_model_interactively() + + assert state["model"] == primary + assert auth_restores == ["parent-auth"] + + +def test_cleanup_failure_does_not_mask_picker_failure(monkeypatch): + import copy + + from hermes_cli import main as hermes_main + + state = _memory_config( + monkeypatch, + {"model": {"default": "parent-model", "provider": "parent-provider"}}, + ) + monkeypatch.setattr( + subagent_model, "_read_auth_active_provider", lambda: "parent-auth" + ) + monkeypatch.setattr( + subagent_model, + "_restore_primary_route", + lambda *_args: (_ for _ in ()).throw(OSError("restore exploded")), + ) + + def failing_picker(**_kwargs): + updated = copy.deepcopy(state) + updated["model"] = {"default": "picker-model", "provider": "picker-provider"} + from hermes_cli.config import save_config + + save_config(updated) + raise LookupError("picker exploded") + + monkeypatch.setattr(hermes_main, "select_provider_and_model", failing_picker) + + with pytest.raises(LookupError, match="picker exploded") as exc_info: + subagent_model.select_subagent_model_interactively() + + assert any("restore exploded" in note for note in exc_info.value.__notes__) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index ec28bc1191f88..ddf468e2b268a 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -11348,6 +11348,7 @@ def test_commands_catalog_filters_gateway_only_commands_and_keeps_status_visible assert "/approve" not in pairs assert "/deny" not in pairs assert "/sethome" not in pairs + assert "/subagent" not in pairs assert "/update" in pairs assert canon["/update"] == "/update" @@ -11356,6 +11357,52 @@ def test_commands_catalog_filters_gateway_only_commands_and_keeps_status_visible assert "/approve" not in canon assert "/deny" not in canon assert "/set-home" not in canon + assert "/subagent" not in canon + assert "/subagent" not in resp["result"]["sub"] + + +def test_tui_slash_exec_rejects_cli_only_subagent_command(): + server._sessions["sid"] = _session() + try: + resp = server.handle_request( + { + "id": "1", + "method": "slash.exec", + "params": { + "command": "/subagent reasoning high", + "session_id": "sid", + }, + } + ) + finally: + server._sessions.pop("sid", None) + + assert resp["error"]["code"] == 4003 + assert "not available in TUI" in resp["error"]["message"] + + cli_resp = server.handle_request( + { + "id": "2", + "method": "cli.exec", + "params": {"argv": ["subagent", "reasoning", "high"]}, + } + ) + assert cli_resp["result"]["blocked"] is True + + for method, params in ( + ("command.resolve", {"name": "subagent"}), + ("command.dispatch", {"name": "subagent", "arg": "reasoning high"}), + ): + blocked = server.handle_request({"id": method, "method": method, "params": params}) + assert blocked["error"]["code"] == 4011 + + completion = server.handle_request( + {"id": "3", "method": "complete.slash", "params": {"text": "/suba"}} + ) + assert all( + item["text"].lstrip("/").lower() != "subagent" + for item in completion["result"]["items"] + ) def test_commands_catalog_includes_desktop_meta_without_skills(): diff --git a/tui_gateway/methods_complete.py b/tui_gateway/methods_complete.py index a7e5decc1161d..1200d318e822e 100644 --- a/tui_gateway/methods_complete.py +++ b/tui_gateway/methods_complete.py @@ -245,7 +245,8 @@ def to_items(doc: Document) -> list[dict]: "text": c.text, "display": to_plain_text(c.display) if c.display else c.text, "meta": to_plain_text(c.display_meta) if c.display_meta else "", "kind": "skill" if c.text.strip().lstrip("/").lower() in skill_names else "command"} - for c in completer.get_completions(doc, None)] + for c in completer.get_completions(doc, None) + if c.text.strip().lstrip("/").split(" ", 1)[0].lower() not in _TUI_EXEC_BLOCKED] items = to_items(Document(text, len(text))) # Rank + bound while a `/token` is under the cursor (the one stage skills are # offered at); an argument stage (`/personality `) keeps its command's order. diff --git a/tui_gateway/methods_tools.py b/tui_gateway/methods_tools.py index 9d9e457f75c28..df496bb37d3a0 100644 --- a/tui_gateway/methods_tools.py +++ b/tui_gateway/methods_tools.py @@ -352,6 +352,8 @@ def add(self, key: str, desc: str, cat: str) -> None: def _catalog_registry(cat: _Catalog) -> None: commands = _tools_mod("hermes_cli.commands") for cmd in commands.COMMAND_REGISTRY: + if cmd.name in _TUI_EXEC_BLOCKED: + continue meta = commands.command_desktop_meta(cmd) cat.commands.update({f"/{key}": dict(meta) for key in (cmd.name, *cmd.aliases)}) if cmd.name in _TUI_HIDDEN or cmd.gateway_only: @@ -424,7 +426,8 @@ def _(rid, params: dict) -> dict: except Exception as e: warning = f"skill discovery unavailable: {e}" return _ok(rid, { - "pairs": cat.pairs, "sub": {k: v[:] for k, v in _tools_mod("hermes_cli.commands").SUBCOMMANDS.items()}, + "pairs": cat.pairs, "sub": {k: v[:] for k, v in _tools_mod("hermes_cli.commands").SUBCOMMANDS.items() + if k.lstrip("/").lower() not in _TUI_EXEC_BLOCKED}, "canon": cat.canon, "commands": cat.commands, "categories": [{"name": c, "pairs": rows} for c, rows in cat.cat_map.items()], @@ -453,7 +456,7 @@ def _(rid, params: dict) -> dict: @_rpc("command.resolve", 5012) def _(rid, params: dict) -> dict: r = _tools_mod("hermes_cli.commands").resolve_command(params.get("name", "")) - if r: + if r and r.name not in _TUI_EXEC_BLOCKED: return _ok(rid, {"canonical": r.name, "description": r.description, "category": r.category}) return _err(rid, 4011, f"unknown command: {params.get('name')}") @@ -814,6 +817,8 @@ def _cmd_compress(rid, params, session, name, arg): @method("command.dispatch") def _(rid, params: dict) -> dict: name, arg = _resolve_name(params.get("name", "").lstrip("/")), params.get("arg", "") + if name.lower() in _TUI_EXEC_BLOCKED: + return _err(rid, 4011, f"unknown command: {name}") session = _sessions.get(params.get("session_id", "")) # Stage order is load-bearing: quick > plugin > bundle > skill > built-in. @@ -837,6 +842,8 @@ def _(rid, params: dict) -> dict: # commands also bypass it but return normal slash.exec output (TUI keeps the pager path). parts = cmd.lstrip("/").split(maxsplit=1) base = (parts[0] if parts else "").lower() + if _resolve_name(base) in _TUI_EXEC_BLOCKED: + return _err(rid, 4003, f"command not available in TUI: /{base}") arg = parts[1] if len(parts) > 1 else "" sid = params.get("session_id", "") live_output = _live_slash_command_output(sid, session, base, arg) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 49266de5ef959..ade7bbacd12c5 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -3105,7 +3105,8 @@ def _finish_reload(rid, params: dict, *, coalesced: bool) -> dict: return _ok(rid, {"status": "reloaded", "loaded_rev": _mcp_reload_loaded_rev, **({"coalesced": True} if coalesced else {})}) -_TUI_HIDDEN: frozenset[str] = frozenset({"sethome", "set-home", "commands", "approve", "deny"}) +_TUI_HIDDEN: frozenset[str] = frozenset({"sethome", "set-home", "commands", "approve", "deny", "subagent"}) +_TUI_EXEC_BLOCKED: frozenset[str] = frozenset({"subagent"}) _TUI_EXTRA: list[tuple[str, str, str]] = [ ("/density", "Toggle compact display mode", "TUI"), @@ -3165,6 +3166,7 @@ def name_of(item: dict) -> str: # argv shapes that must not run headless in the gateway process → user hint. _CLI_EXEC_BLOCKED = { + ("subagent",): "`hermes subagent` is CLI-only — run it in another terminal", ("setup",): "`hermes setup` needs a full terminal — run it outside the TUI", ("gateway",): "`hermes gateway` is long-running — run it in another terminal", ("sessions", "browse"): "`hermes sessions browse` is interactive — use /resume here, or run browse in another terminal", diff --git a/website/docs/user-guide/features/delegation.md b/website/docs/user-guide/features/delegation.md index a4ce8aba3b32e..db444a06aec41 100644 --- a/website/docs/user-guide/features/delegation.md +++ b/website/docs/user-guide/features/delegation.md @@ -171,9 +171,30 @@ You can configure a different model for subagents via `config.yaml` — useful f delegation: model: "google/gemini-flash-2.0" # Cheaper model for subagents provider: "openrouter" # Optional: route subagents to a different provider + reasoning_effort: "high" # Optional: independent child reasoning level ``` -If omitted, subagents use the same model as the parent. +If omitted, subagents use the same model and reasoning policy as the parent. +You can manage these overrides without editing YAML: + +- `hermes subagent` shows the effective override state. +- `hermes subagent model` opens the complete `hermes model` setup flow, + including provider login and custom endpoint creation. Shared setup additions + are retained, while the active primary model and auth route remain unchanged. +- `hermes subagent model --provider ` validates a direct + selection through the same model resolver. `hermes subagent model reset` + restores parent-model inheritance. +- `hermes subagent reasoning ` sets an independent child reasoning + policy. Valid levels are `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, + `max`, and `ultra`; `none` explicitly disables reasoning. `reasoning reset` + restores parent-reasoning inheritance. +- In the Classic CLI, the equivalent in-session commands are `/subagent`, + `/subagent model`, `/subagent model reset`, and `/subagent reasoning ...`. + +Model/provider and reasoning resets are independent. Changes apply to newly +spawned children; already-running subagents keep the configuration they started +with. Provider/model selections are saved atomically under +`delegation.provider` and `delegation.model`. ### Cost strategy: frontier planner, inexpensive workers