From 7174fbf725e03f7448e7784fd98a857fcddd064c Mon Sep 17 00:00:00 2001 From: vominh1919 Date: Wed, 22 Apr 2026 09:53:24 +0700 Subject: [PATCH 1/2] fix: Python 3.9 compatibility, CLI MEDIA tags, model switch for custom providers Fixes: - #13766: CLI agents no longer emit MEDIA:/path tags (CLI has no attachment channel) - #13765: Add 'from __future__ import annotations' to 64 files for Python 3.9 PEP-604 compatibility - #13764: Model switch now searches custom_providers catalog before API probe Changes: - agent/prompt_builder.py: Extend CLI platform hint to prevent MEDIA: tag emission - cli.py: Load custom_providers unconditionally (not just for picker) - hermes_cli/model_switch.py: Add _find_model_in_custom_providers() helper, insert step c2 in PATH B - hermes_cli/models.py: normalize_provider() handles custom:* slugs - 64 files: Add 'from __future__ import annotations' for Python 3.9 compatibility --- agent/anthropic_adapter.py | 2 + agent/auxiliary_client.py | 2 + agent/bedrock_adapter.py | 2 + agent/context_compressor.py | 2 + agent/display.py | 2 + agent/model_metadata.py | 2 + agent/prompt_builder.py | 33 +- agent/skill_commands.py | 2 + agent/skill_utils.py | 2 + cli.py | 1273 +++++++++++++---- cron/scheduler.py | 2 + gateway/platforms/base.py | 2 + gateway/platforms/dingtalk.py | 2 + gateway/platforms/sms.py | 2 + gateway/run.py | 2 + hermes_cli/claw.py | 2 + hermes_cli/cli_output.py | 2 + hermes_cli/clipboard.py | 2 + hermes_cli/curses_ui.py | 2 + hermes_cli/gateway.py | 2 + hermes_cli/logs.py | 2 + hermes_cli/model_switch.py | 152 +- hermes_cli/models.py | 437 +++++- hermes_cli/setup.py | 2 + hermes_cli/web_server.py | 2 + hermes_constants.py | 2 + .../domain-intel/scripts/domain_intel.py | 2 + plugins/memory/holographic/store.py | 2 + run_agent.py | 2 + scripts/release.py | 2 + .../google-workspace/scripts/google_api.py | 2 + .../powerpoint/scripts/office/pack.py | 2 + .../godmode/scripts/godmode_race.py | 2 + .../research/polymarket/scripts/polymarket.py | 2 + tests/agent/test_subagent_progress.py | 2 + tests/cli/test_cli_status_bar.py | 2 + tests/cli/test_worktree_security.py | 2 + tests/gateway/restart_test_helpers.py | 2 + .../test_background_process_notifications.py | 2 + tests/gateway/test_config_cwd_bridge.py | 2 + tests/hermes_cli/test_commands.py | 2 + tests/hermes_cli/test_plugins.py | 2 + tools/approval.py | 2 + tools/budget_config.py | 2 + tools/cronjob_tools.py | 2 + tools/environments/base.py | 2 + tools/environments/daytona.py | 2 + tools/environments/docker.py | 2 + tools/environments/file_sync.py | 2 + tools/environments/local.py | 2 + tools/environments/modal.py | 2 + tools/environments/singularity.py | 2 + tools/environments/ssh.py | 2 + tools/file_tools.py | 2 + tools/image_generation_tool.py | 2 + tools/interrupt.py | 2 + tools/mcp_oauth.py | 2 + tools/mcp_tool.py | 2 + tools/registry.py | 2 + tools/send_message_tool.py | 2 + tools/skills_guard.py | 2 + tools/skills_tool.py | 2 + tools/terminal_tool.py | 2 + tools/tirith_security.py | 2 + tools/todo_tool.py | 2 + tools/tool_result_storage.py | 2 + tools/voice_mode.py | 2 + utils.py | 2 + 68 files changed, 1680 insertions(+), 343 deletions(-) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 64b952251730..db27086a32fd 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -1,5 +1,7 @@ """Anthropic Messages API adapter for Hermes Agent. +from __future__ import annotations + Translates between Hermes's internal OpenAI-style message format and Anthropic's Messages API. Follows the same pattern as the codex_responses adapter — all provider-specific logic is isolated here. diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 4f1746166256..1b942bcc6853 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -1,5 +1,7 @@ """Shared auxiliary client router for side tasks. +from __future__ import annotations + Provides a single resolution chain so every consumer (context compression, session search, web extraction, vision analysis, browser vision) picks up the best available backend without duplicating fallback logic. diff --git a/agent/bedrock_adapter.py b/agent/bedrock_adapter.py index 9e4297581de0..b17a2cc4b93a 100644 --- a/agent/bedrock_adapter.py +++ b/agent/bedrock_adapter.py @@ -1,5 +1,7 @@ """AWS Bedrock Converse API adapter for Hermes Agent. +from __future__ import annotations + Provides native integration with Amazon Bedrock using the Converse API, bypassing the OpenAI-compatible endpoint in favor of direct AWS SDK calls. This enables full access to the Bedrock ecosystem: diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 34ec5091b1c6..185774ecfa61 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -1,5 +1,7 @@ """Automatic context window compression for long conversations. +from __future__ import annotations + Self-contained class with its own OpenAI client for summarization. Uses auxiliary model (cheap/fast) to summarize middle turns while protecting head and tail context. diff --git a/agent/display.py b/agent/display.py index 3f1341485edb..dd7ced606208 100644 --- a/agent/display.py +++ b/agent/display.py @@ -1,5 +1,7 @@ """CLI presentation -- spinner, kawaii faces, tool preview formatting. +from __future__ import annotations + Pure display functions and classes with no AIAgent dependency. Used by AIAgent._execute_tool_calls for CLI feedback. """ diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 089fd132aced..8734d455f4df 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -1,5 +1,7 @@ """Model metadata, context lengths, and token estimation utilities. +from __future__ import annotations + Pure utility functions with no AIAgent dependency. Used by ContextCompressor and run_agent.py for pre-flight context checks. """ diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index e7bb0ffc9dde..ef66442b0a20 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -1,5 +1,7 @@ """System prompt assembly -- identity, platform hints, skills index, context files. +from __future__ import annotations + All functions are stateless. AIAgent._build_system_prompt() calls these to assemble pieces, then combines them with memory and ephemeral prompts. """ @@ -152,7 +154,13 @@ def _strip_yaml_frontmatter(content: str) -> str: "Do NOT save task progress, session outcomes, completed-work logs, or temporary TODO " "state to memory; use session_search to recall those from past transcripts. " "If you've discovered a new way to do something, solved a problem that could be " - "necessary later, save it as a skill with the skill tool." + "necessary later, save it as a skill with the skill tool.\n" + "Write memories as declarative facts, not instructions to yourself. " + "'User prefers concise responses' ✓ — 'Always respond concisely' ✗. " + "'Project uses pytest with xdist' ✓ — 'Run tests with pytest -n 4' ✗. " + "Imperative phrasing gets re-read as a directive in later sessions and can " + "cause repeated work or override the user's current request. Procedures and " + "workflows belong in skills, not memory." ) SESSION_SEARCH_GUIDANCE = ( @@ -344,7 +352,11 @@ def _strip_yaml_frontmatter(content: str) -> str: ), "cli": ( "You are a CLI AI Agent. Try not to use markdown but simple text " - "renderable inside a terminal." + "renderable inside a terminal. " + "IMPORTANT: There is NO attachment channel on the CLI. " + "Do NOT emit MEDIA:/path tags — they will appear as literal text. " + "Instead, just tell the user the absolute path to any generated file " + "so they can access it directly." ), "sms": ( "You are communicating via SMS. Keep responses concise and use plain text " @@ -613,12 +625,14 @@ def build_skills_system_prompt( or get_session_env("HERMES_SESSION_PLATFORM") or "" ) + disabled = get_disabled_skill_names() cache_key = ( str(skills_dir.resolve()), tuple(str(d) for d in external_dirs), tuple(sorted(str(t) for t in (available_tools or set()))), tuple(sorted(str(ts) for ts in (available_toolsets or set()))), _platform_hint, + tuple(sorted(disabled)), ) with _SKILLS_PROMPT_CACHE_LOCK: cached = _SKILLS_PROMPT_CACHE.get(cache_key) @@ -626,8 +640,6 @@ def build_skills_system_prompt( _SKILLS_PROMPT_CACHE.move_to_end(cache_key) return cached - disabled = get_disabled_skill_names() - # ── Layer 2: disk snapshot ──────────────────────────────────────── snapshot = _load_skills_snapshot(skills_dir) @@ -654,7 +666,7 @@ def build_skills_system_prompt( ): continue skills_by_category.setdefault(category, []).append( - (skill_name, entry.get("description", "")) + (frontmatter_name, entry.get("description", "")) ) category_descriptions = { str(k): str(v) @@ -679,7 +691,7 @@ def build_skills_system_prompt( ): continue skills_by_category.setdefault(entry["category"], []).append( - (skill_name, entry["description"]) + (entry["frontmatter_name"], entry["description"]) ) # Read category-level DESCRIPTION.md files @@ -722,9 +734,10 @@ def build_skills_system_prompt( continue entry = _build_snapshot_entry(skill_file, ext_dir, frontmatter, desc) skill_name = entry["skill_name"] - if skill_name in seen_skill_names: + frontmatter_name = entry["frontmatter_name"] + if frontmatter_name in seen_skill_names: continue - if entry["frontmatter_name"] in disabled or skill_name in disabled: + if frontmatter_name in disabled or skill_name in disabled: continue if not _skill_should_show( extract_skill_conditions(frontmatter), @@ -732,9 +745,9 @@ def build_skills_system_prompt( available_toolsets, ): continue - seen_skill_names.add(skill_name) + seen_skill_names.add(frontmatter_name) skills_by_category.setdefault(entry["category"], []).append( - (skill_name, entry["description"]) + (frontmatter_name, entry["description"]) ) except Exception as e: logger.debug("Error reading external skill %s: %s", skill_file, e) diff --git a/agent/skill_commands.py b/agent/skill_commands.py index 280105daca73..1a7bc13ee902 100644 --- a/agent/skill_commands.py +++ b/agent/skill_commands.py @@ -1,5 +1,7 @@ """Shared slash command helpers for skills and built-in prompt-style modes. +from __future__ import annotations + Shared between CLI (cli.py) and gateway (gateway/run.py) so both surfaces can invoke skills via /skill-name commands and prompt-only built-ins like /plan. diff --git a/agent/skill_utils.py b/agent/skill_utils.py index f7979122e1d5..b3e07e125df2 100644 --- a/agent/skill_utils.py +++ b/agent/skill_utils.py @@ -1,5 +1,7 @@ """Lightweight skill metadata utilities shared by prompt_builder and skills_tool. +from __future__ import annotations + This module intentionally avoids importing the tool registry, CLI config, or any heavy dependency chain. It is safe to import at module level without triggering tool registration or provider resolution. diff --git a/cli.py b/cli.py index 85a7b50828d6..c0a50d60e708 100644 --- a/cli.py +++ b/cli.py @@ -1,5 +1,7 @@ #!/usr/bin/env python3 """ +from __future__ import annotations + Hermes Agent CLI - Interactive Terminal Interface A beautiful command-line interface for the Hermes Agent, inspired by Claude Code. @@ -18,11 +20,15 @@ import shutil import sys import json +import re +import concurrent.futures +import base64 import atexit import tempfile import time import uuid import textwrap +from urllib.parse import unquote, urlparse from contextlib import contextmanager from pathlib import Path from datetime import datetime @@ -63,6 +69,7 @@ format_duration_compact, format_token_count_compact, ) +from agent.account_usage import fetch_account_usage, render_account_usage_lines from hermes_cli.banner import _format_context_length, format_banner_version_label _COMMAND_SPINNER_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏") @@ -72,12 +79,83 @@ # User-managed env files should override stale shell exports on restart. from hermes_constants import get_hermes_home, display_hermes_home from hermes_cli.env_loader import load_hermes_dotenv +from utils import base_url_host_matches _hermes_home = get_hermes_home() _project_env = Path(__file__).parent / '.env' load_hermes_dotenv(hermes_home=_hermes_home, project_env=_project_env) +_REASONING_TAGS = ( + "REASONING_SCRATCHPAD", + "think", + "thinking", + "reasoning", + "thought", +) + + +def _strip_reasoning_tags(text: str) -> str: + """Remove reasoning/thinking blocks from displayed text. + + Handles every case: + * Closed pairs ```` (case-insensitive, multi-line). + * Unterminated open tags that run to end-of-text (e.g. truncated + generations on NIM/MiniMax where the close tag is dropped). + * Stray orphan close tags (``stuffanswer``) left behind by + partial-content dumps. + + Covers the variants emitted by reasoning models today: ````, + ````, ````, ````, and + ```` (Gemma 4). Must stay in sync with + ``run_agent.py::_strip_think_blocks`` and the stream consumer's + ``_OPEN_THINK_TAGS`` / ``_CLOSE_THINK_TAGS`` tuples. + """ + cleaned = text + for tag in _REASONING_TAGS: + # Closed pair — case-insensitive so is handled too. + cleaned = re.sub( + rf"<{tag}>.*?\s*", + "", + cleaned, + flags=re.DOTALL | re.IGNORECASE, + ) + # Unterminated open tag — strip from the tag to end of text. + cleaned = re.sub( + rf"<{tag}>.*$", + "", + cleaned, + flags=re.DOTALL | re.IGNORECASE, + ) + # Stray orphan close tag left behind by partial dumps. + cleaned = re.sub( + rf"\s*", + "", + cleaned, + flags=re.IGNORECASE, + ) + return cleaned.strip() + + +def _assistant_content_as_text(content: Any) -> str: + if content is None: + return "" + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [ + str(part.get("text", "")) + for part in content + if isinstance(part, dict) and part.get("type") == "text" + ] + return "\n".join(p for p in parts if p) + return str(content) + + +def _assistant_copy_text(content: Any) -> str: + return _strip_reasoning_tags(_assistant_content_as_text(content)) + + # ============================================================================= # Configuration Loading # ============================================================================= @@ -238,12 +316,6 @@ def load_cli_config() -> Dict[str, Any]: "enabled": True, # Auto-compress when approaching context limit "threshold": 0.50, # Compress at 50% of model's context limit }, - "smart_model_routing": { - "enabled": False, - "max_simple_chars": 160, - "max_simple_words": 28, - "cheap_model": {}, - }, "agent": { "max_turns": 90, # Default max tool-calling iterations (shared with subagents) "verbose": False, @@ -301,7 +373,6 @@ def load_cli_config() -> Dict[str, Any]: }, "delegation": { "max_iterations": 45, # Max tool-calling turns per child agent - "default_toolsets": ["terminal", "file", "web"], # Default toolsets for subagents "model": "", # Subagent model override (empty = inherit parent model) "provider": "", # Subagent provider override (empty = inherit parent provider) "base_url": "", # Direct OpenAI-compatible endpoint for subagents @@ -462,7 +533,6 @@ def load_cli_config() -> Dict[str, Any]: if _file_has_terminal_config or env_var not in os.environ: val = terminal_config[config_key] if isinstance(val, list): - import json os.environ[env_var] = json.dumps(val) else: os.environ[env_var] = str(val) @@ -1075,6 +1145,41 @@ def _rich_text_from_ansi(text: str) -> _RichText: return _RichText.from_ansi(text or "") +def _strip_markdown_syntax(text: str) -> str: + """Best-effort markdown marker removal for plain-text display.""" + plain = _rich_text_from_ansi(text or "").plain + plain = re.sub(r"^\s{0,3}(?:[-*_]\s*){3,}$", "", plain, flags=re.MULTILINE) + plain = re.sub(r"^\s{0,3}#{1,6}\s+", "", plain, flags=re.MULTILINE) + # Preserve blockquotes, lists, and checkboxes because they carry structure. + plain = re.sub(r"(```+|~~~+)", "", plain) + plain = re.sub(r"`([^`]*)`", r"\1", plain) + plain = re.sub(r"!\[([^\]]*)\]\([^\)]*\)", r"\1", plain) + plain = re.sub(r"\[([^\]]+)\]\([^\)]*\)", r"\1", plain) + plain = re.sub(r"\*\*\*([^*]+)\*\*\*", r"\1", plain) + plain = re.sub(r"(? Path | None: if (token.startswith('"') and token.endswith('"')) or (token.startswith("'") and token.endswith("'")): token = token[1:-1].strip() + token = token.replace('\\ ', ' ') if not token: return None - expanded = os.path.expandvars(os.path.expanduser(token)) + expanded = token + if token.startswith("file://"): + try: + parsed = urlparse(token) + if parsed.scheme == "file": + expanded = unquote(parsed.path or "") + if parsed.netloc and os.name == "nt": + expanded = f"//{parsed.netloc}{expanded}" + except Exception: + expanded = token + expanded = os.path.expandvars(os.path.expanduser(expanded)) + if os.name != "nt": + normalized = expanded.replace("\\", "/") + if len(normalized) >= 3 and normalized[1] == ":" and normalized[2] == "/" and normalized[0].isalpha(): + expanded = f"/mnt/{normalized[0].lower()}/{normalized[3:]}" path = Path(expanded) if not path.is_absolute(): base_dir = Path(os.getenv("TERMINAL_CWD", os.getcwd())) @@ -1254,16 +1374,36 @@ def _detect_file_drop(user_input: str) -> "dict | None": or stripped.startswith("~") or stripped.startswith("./") or stripped.startswith("../") + or stripped.startswith("file://") + or (len(stripped) >= 3 and stripped[1] == ":" and stripped[2] in ("\\", "/") and stripped[0].isalpha()) or stripped.startswith('"/') or stripped.startswith('"~') or stripped.startswith("'/") or stripped.startswith("'~") + or (len(stripped) >= 4 and stripped[0] in ("'", '"') and stripped[2] == ":" and stripped[3] in ("\\", "/") and stripped[1].isalpha()) ) if not starts_like_path: return None + direct_path = _resolve_attachment_path(stripped) + if direct_path is not None: + return { + "path": direct_path, + "is_image": direct_path.suffix.lower() in _IMAGE_EXTENSIONS, + "remainder": "", + } + first_token, remainder = _split_path_input(stripped) drop_path = _resolve_attachment_path(first_token) + if drop_path is None and " " in stripped and stripped[0] not in {"'", '"'}: + space_positions = [idx for idx, ch in enumerate(stripped) if ch == " "] + for pos in reversed(space_positions): + candidate = stripped[:pos].rstrip() + resolved = _resolve_attachment_path(candidate) + if resolved is not None: + drop_path = resolved + remainder = stripped[pos + 1 :].strip() + break if drop_path is None: return None @@ -1646,10 +1786,30 @@ def __init__( # streaming: stream tokens to the terminal as they arrive (display.streaming in config.yaml) self.streaming_enabled = CLI_CONFIG["display"].get("streaming", False) + self.final_response_markdown = str( + CLI_CONFIG["display"].get("final_response_markdown", "strip") + ).strip().lower() or "strip" + if self.final_response_markdown not in {"render", "strip", "raw"}: + self.final_response_markdown = "strip" # Inline diff previews for write actions (display.inline_diffs in config.yaml) self._inline_diffs_enabled = CLI_CONFIG["display"].get("inline_diffs", True) + # Submitted multiline user-message preview (display.user_message_preview in config.yaml) + _ump = CLI_CONFIG["display"].get("user_message_preview", {}) + if not isinstance(_ump, dict): + _ump = {} + try: + _ump_first_lines = int(_ump.get("first_lines", 2)) + except (TypeError, ValueError): + _ump_first_lines = 2 + try: + _ump_last_lines = int(_ump.get("last_lines", 2)) + except (TypeError, ValueError): + _ump_last_lines = 2 + self.user_message_preview_first_lines = max(1, _ump_first_lines) + self.user_message_preview_last_lines = max(0, _ump_last_lines) + # Streaming display state self._stream_buf = "" # Partial line buffer for line-buffered rendering self._stream_started = False # True once first delta arrives @@ -1707,7 +1867,7 @@ def __init__( # Match key to resolved base_url: OpenRouter URL → prefer OPENROUTER_API_KEY, # custom endpoint → prefer OPENAI_API_KEY (issue #560). # Note: _ensure_runtime_credentials() re-resolves this before first use. - if self.base_url and "openrouter.ai" in self.base_url: + if self.base_url and base_url_host_matches(self.base_url, "openrouter.ai"): self.api_key = api_key or os.getenv("OPENROUTER_API_KEY") or os.getenv("OPENAI_API_KEY") else: self.api_key = api_key or os.getenv("OPENAI_API_KEY") or os.getenv("OPENROUTER_API_KEY") @@ -1732,7 +1892,7 @@ def __init__( mcp_names = set((CLI_CONFIG.get("mcp_servers") or {}).keys()) invalid = [t for t in toolsets if not validate_toolset(t) and t not in mcp_names] if invalid: - self.console.print(f"[bold red]Warning: Unknown toolsets: {', '.join(invalid)}[/]") + self._console_print(f"[bold red]Warning: Unknown toolsets: {', '.join(invalid)}[/]") # Filesystem checkpoints: CLI flag > config cp_cfg = CLI_CONFIG.get("checkpoints", {}) @@ -1779,8 +1939,9 @@ def __init__( fb = [fb] if fb.get("provider") and fb.get("model") else [] self._fallback_model = fb - # Optional cheap-vs-strong routing for simple turns - self._smart_model_routing = CLI_CONFIG.get("smart_model_routing", {}) or {} + # Signature of the currently-initialised agent's runtime. Used to + # rebuild the agent when provider / model / base_url changes across + # turns (e.g. after /model or credential rotation). self._active_agent_route_signature = None # Agent will be initialized on first use @@ -1791,6 +1952,10 @@ def __init__( self.conversation_history: List[Dict[str, Any]] = [] self.session_start = datetime.now() self._resumed = False + # Per-prompt elapsed timer — started at the beginning of each chat turn, + # frozen when the agent thread completes, displayed in the status bar. + self._prompt_start_time: Optional[float] = None # time.time() when turn started + self._prompt_duration: float = 0.0 # frozen duration of last completed turn # Initialize SQLite session store early so /title works before first message self._session_db = None try: @@ -1867,8 +2032,7 @@ def __init__( def _invalidate(self, min_interval: float = 0.25) -> None: """Throttled UI repaint — prevents terminal blinking on slow/SSH connections.""" - import time as _time - now = _time.monotonic() + now = time.monotonic() if hasattr(self, "_app") and self._app and (now - self._last_invalidate) >= min_interval: self._last_invalidate = now self._app.invalidate() @@ -1889,6 +2053,44 @@ def _build_context_bar(self, percent_used: Optional[int], width: int = 10) -> st filled = round((safe_percent / 100) * width) return f"[{('█' * filled) + ('░' * max(0, width - filled))}]" + @staticmethod + def _format_prompt_elapsed(prompt_start_time: Optional[float], prompt_duration: float, live: bool = False) -> str: + """Format per-prompt elapsed time for the status bar. + + Always returns a string — shows 0s on fresh start before first turn. + Keeps seconds visible at all scales so it increments smoothly: + 59s → 1m → 1m 1s → ... → 1m 59s → 2m → 2m 1s → ... + 59m 59s → 1h → 1h 0m 1s → ... + 23h 59m 59s → 1d → 1d 0h 1m → ... + + Emoji prefix: ⏱ when turn is live, ⏲ when frozen or fresh start. + Uses width-1 (no variation selector) glyphs so the status bar stays + aligned in monospace terminals. + """ + if prompt_start_time is None and prompt_duration == 0.0: + return "⏲ 0s" + elapsed = time.time() - prompt_start_time if prompt_start_time is not None else prompt_duration + elapsed = max(0.0, elapsed) + + days = int(elapsed // 86400) + remaining = elapsed % 86400 + hours = int(remaining // 3600) + remaining = remaining % 3600 + minutes = int(remaining // 60) + seconds = int(remaining % 60) + + if days > 0: + time_str = f"{days}d {hours}h {minutes}m" + elif hours > 0: + time_str = f"{hours}h {minutes}m {seconds}s" if seconds else f"{hours}h {minutes}m" + elif minutes > 0: + time_str = f"{minutes}m {seconds}s" if seconds else f"{minutes}m" + else: + time_str = f"{int(elapsed)}s" + + emoji = "⏱" if live else "⏲" + return f"{emoji} {time_str}" + def _get_status_bar_snapshot(self) -> Dict[str, Any]: # Prefer the agent's model name — it updates on fallback. # self.model reflects the originally configured model and never @@ -1907,6 +2109,11 @@ def _get_status_bar_snapshot(self) -> Dict[str, Any]: "model_name": model_name, "model_short": model_short, "duration": format_duration_compact(elapsed_seconds), + "prompt_elapsed": self._format_prompt_elapsed( + getattr(self, "_prompt_start_time", None), + getattr(self, "_prompt_duration", 0.0), + live=getattr(self, "_prompt_start_time", None) is not None, + ), "context_tokens": 0, "context_length": None, "context_percent": None, @@ -2024,20 +2231,34 @@ def _agent_spacer_height(self, width: Optional[int] = None) -> int: def _spinner_widget_height(self, width: Optional[int] = None) -> int: """Return the visible height for the spinner/status text line above the status bar.""" - if not getattr(self, "_spinner_text", ""): + spinner_line = self._render_spinner_text() + if not spinner_line: return 0 if self._use_minimal_tui_chrome(width=width): return 0 - # Compute how many lines the spinner text needs when wrapped. - # The rendered text is " {emoji} {label} ({elapsed})" — about - # len(_spinner_text) + 16 chars for indent + timer suffix. width = width or self._get_tui_terminal_width() if width and width > 10: import math - text_len = len(self._spinner_text) + 16 # indent + timer - return max(1, math.ceil(text_len / width)) + text_width = self._status_bar_display_width(spinner_line) + return max(1, math.ceil(text_width / width)) return 1 + def _render_spinner_text(self) -> str: + """Return the live spinner/status text exactly as rendered in the TUI.""" + txt = getattr(self, "_spinner_text", "") + if not txt: + return "" + t0 = getattr(self, "_tool_start_time", 0) or 0 + if t0 > 0: + elapsed = time.monotonic() - t0 + if elapsed >= 60: + _m, _s = int(elapsed // 60), int(elapsed % 60) + elapsed_str = f"{_m}m {_s}s" + else: + elapsed_str = f"{elapsed:.1f}s" + return f" {txt} ({elapsed_str})" + return f" {txt}" + def _get_voice_status_fragments(self, width: Optional[int] = None): """Return the voice status bar fragments for the interactive TUI.""" width = width or self._get_tui_terminal_width() @@ -2083,6 +2304,9 @@ def _build_status_bar_text(self, width: Optional[int] = None) -> str: parts = [f"⚕ {snapshot['model_short']}", context_label, percent_label] parts.append(duration_label) + prompt_elapsed = snapshot.get("prompt_elapsed") + if prompt_elapsed: + parts.append(prompt_elapsed) return self._trim_status_bar_text(" │ ".join(parts), width) except Exception: return f"⚕ {self.model if getattr(self, 'model', None) else 'Hermes'}" @@ -2141,8 +2365,13 @@ def _get_status_bar_fragments(self): (bar_style, percent_label), ("class:status-bar-dim", " │ "), ("class:status-bar-dim", duration_label), - ("class:status-bar", " "), ] + # Position 7: per-prompt elapsed timer (live or frozen) + prompt_elapsed = snapshot.get("prompt_elapsed") + if prompt_elapsed: + frags.append(("class:status-bar-dim", " │ ")) + frags.append(("class:status-bar-dim", prompt_elapsed)) + frags.append(("class:status-bar", " ")) total_width = sum(self._status_bar_display_width(text) for _, text in frags) if total_width > width: @@ -2168,7 +2397,7 @@ def _normalize_model_for_provider(self, resolved_provider: str) -> bool: normalized_model = normalize_model_for_provider(current_model, resolved_provider) if normalized_model and normalized_model != current_model: if not self._model_is_default: - self.console.print( + self._console_print( f"[yellow]⚠️ Normalized model '{current_model}' to '{normalized_model}' for {resolved_provider}.[/]" ) self.model = normalized_model @@ -2184,7 +2413,7 @@ def _normalize_model_for_provider(self, resolved_provider: str) -> bool: canonical = normalize_copilot_model_id(current_model, api_key=self.api_key) if canonical and canonical != current_model: if not self._model_is_default: - self.console.print( + self._console_print( f"[yellow]⚠️ Normalized Copilot model '{current_model}' to '{canonical}'.[/]" ) self.model = canonical @@ -2206,7 +2435,7 @@ def _normalize_model_for_provider(self, resolved_provider: str) -> bool: canonical = normalize_opencode_model_id(resolved_provider, current_model) if canonical and canonical != current_model: if not self._model_is_default: - self.console.print( + self._console_print( f"[yellow]⚠️ Stripped provider prefix from '{current_model}'; using '{canonical}' for {resolved_provider}.[/]" ) self.model = canonical @@ -2228,7 +2457,7 @@ def _normalize_model_for_provider(self, resolved_provider: str) -> bool: if "/" in current_model: slug = current_model.split("/", 1)[1] if not self._model_is_default: - self.console.print( + self._console_print( f"[yellow]⚠️ Stripped provider prefix from '{current_model}'; " f"using '{slug}' for OpenAI Codex.[/]" ) @@ -2276,9 +2505,6 @@ def _current_reasoning_callback(self): def _emit_reasoning_preview(self, reasoning_text: str) -> None: """Render a buffered reasoning preview as a single [thinking] block.""" - import re - import textwrap - preview_text = reasoning_text.strip() if not preview_text: return @@ -2361,6 +2587,59 @@ def _flush_reasoning_preview(self, *, force: bool = False) -> None: if flush_text: self._emit_reasoning_preview(flush_text) + def _format_submitted_user_message_preview(self, user_input: str) -> str: + """Format the submitted user-message scrollback preview.""" + lines = user_input.split("\n") + if len(lines) <= 1: + return f"[bold {_accent_hex()}]●[/] [bold]{_escape(user_input)}[/]" + + first_lines = int(getattr(self, "user_message_preview_first_lines", 2)) + last_lines = int(getattr(self, "user_message_preview_last_lines", 2)) + first_lines = max(1, first_lines) + last_lines = max(0, last_lines) + head = lines[:first_lines] + remaining_after_head = max(0, len(lines) - len(head)) + tail_count = min(last_lines, remaining_after_head) + tail = lines[-tail_count:] if tail_count else [] + + hidden_middle_count = len(lines) - len(head) - len(tail) + if hidden_middle_count < 0: + hidden_middle_count = 0 + tail = [] + + preview_lines = [ + f"[bold {_accent_hex()}]●[/] [bold]{_escape(head[0])}[/]" + ] + preview_lines.extend(f"[bold]{_escape(line)}[/]" for line in head[1:]) + + if hidden_middle_count > 0: + noun = "line" if hidden_middle_count == 1 else "lines" + preview_lines.append(f"[dim]... (+{hidden_middle_count} more {noun})[/]") + + preview_lines.extend(f"[bold]{_escape(line)}[/]" for line in tail) + return "\n".join(preview_lines) + + def _expand_paste_references(self, text: str | None) -> str: + """Expand [Pasted text #N -> file] placeholders into file contents.""" + if not isinstance(text, str) or "[Pasted text #" not in text: + return text or "" + paste_ref_re = re.compile(r'\[Pasted text #\d+: \d+ lines \u2192 (.+?)\]') + + def _expand_ref(match): + path = Path(match.group(1)) + return path.read_text(encoding="utf-8") if path.exists() else match.group(0) + + return paste_ref_re.sub(_expand_ref, text) + + def _print_user_message_preview(self, user_input: str) -> None: + """Render a user message using the normal chat scrollback style.""" + ChatConsole().print(f"[{_accent_hex()}]{'─' * 40}[/]") + text = str(user_input or "") + if "\n" in text: + ChatConsole().print(self._format_submitted_user_message_preview(text)) + else: + ChatConsole().print(f"[bold {_accent_hex()}]●[/] [bold]{_escape(text)}[/]") + def _stream_reasoning_delta(self, text: str) -> None: """Stream reasoning/thinking tokens into a dim box above the response. @@ -2604,6 +2883,8 @@ def _emit_stream_text(self, text: str) -> None: _tc = getattr(self, "_stream_text_ansi", "") while "\n" in self._stream_buf: line, self._stream_buf = self._stream_buf.split("\n", 1) + if self.final_response_markdown == "strip": + line = _strip_markdown_syntax(line) _cprint(f"{_STREAM_PAD}{_tc}{line}{_RST}" if _tc else f"{_STREAM_PAD}{line}") def _flush_stream(self) -> None: @@ -2621,7 +2902,8 @@ def _flush_stream(self) -> None: if self._stream_buf: _tc = getattr(self, "_stream_text_ansi", "") - _cprint(f"{_STREAM_PAD}{_tc}{self._stream_buf}{_RST}" if _tc else f"{_STREAM_PAD}{self._stream_buf}") + line = _strip_markdown_syntax(self._stream_buf) if self.final_response_markdown == "strip" else self._stream_buf + _cprint(f"{_STREAM_PAD}{_tc}{line}{_RST}" if _tc else f"{_STREAM_PAD}{line}") self._stream_buf = "" # Close the response box @@ -2664,9 +2946,7 @@ def _slow_command_status(self, command: str) -> str: def _command_spinner_frame(self) -> str: """Return the current spinner frame for slow slash commands.""" - import time as _time - - frame_idx = int(_time.monotonic() * 10) % len(_COMMAND_SPINNER_FRAMES) + frame_idx = int(time.monotonic() * 10) % len(_COMMAND_SPINNER_FRAMES) return _COMMAND_SPINNER_FRAMES[frame_idx] @contextmanager @@ -2683,6 +2963,39 @@ def _busy_command(self, status: str): self._command_status = "" self._invalidate(min_interval=0.0) + def _open_external_editor(self, buffer=None) -> bool: + """Open the active input buffer in an external editor.""" + app = getattr(self, "_app", None) + if not app: + _cprint(f"{_DIM}External editor is only available inside the interactive CLI.{_RST}") + return False + if self._command_running: + _cprint(f"{_DIM}Wait for the current command to finish before opening the editor.{_RST}") + return False + if self._sudo_state or self._secret_state or self._approval_state or self._clarify_state: + _cprint(f"{_DIM}Finish the active prompt before opening the editor.{_RST}") + return False + target_buffer = buffer or getattr(app, "current_buffer", None) + if target_buffer is None: + _cprint(f"{_DIM}No active input buffer is available for the external editor.{_RST}") + return False + try: + existing_text = getattr(target_buffer, "text", "") + expanded_text = self._expand_paste_references(existing_text) + if expanded_text != existing_text and hasattr(target_buffer, "text"): + self._skip_paste_collapse = True + target_buffer.text = expanded_text + if hasattr(target_buffer, "cursor_position"): + target_buffer.cursor_position = len(expanded_text) + # Set skip flag (again) so the text-change event fired when the + # editor closes does not re-collapse the returned content. + self._skip_paste_collapse = True + target_buffer.open_in_editor(validate_and_handle=False) + return True + except Exception as exc: + _cprint(f"{_DIM}Failed to open external editor: {exc}{_RST}") + return False + def _ensure_runtime_credentials(self) -> bool: """ Ensure runtime credentials are resolved before agent use. @@ -2790,24 +3103,36 @@ def _ensure_runtime_credentials(self) -> bool: return True def _resolve_turn_agent_config(self, user_message: str) -> dict: - """Resolve model/runtime overrides for a single user turn.""" - from agent.smart_model_routing import resolve_turn_route + """Build the effective model/runtime config for a single user turn. + + Always uses the session's primary model/provider. If the user has + toggled `/fast` on and the current model supports Priority + Processing / Anthropic fast mode, attach `request_overrides` so the + API call is marked accordingly. + """ from hermes_cli.models import resolve_fast_mode_overrides - route = resolve_turn_route( - user_message, - self._smart_model_routing, - { - "model": self.model, - "api_key": self.api_key, - "base_url": self.base_url, - "provider": self.provider, - "api_mode": self.api_mode, - "command": self.acp_command, - "args": list(self.acp_args or []), - "credential_pool": getattr(self, "_credential_pool", None), - }, - ) + runtime = { + "api_key": self.api_key, + "base_url": self.base_url, + "provider": self.provider, + "api_mode": self.api_mode, + "command": self.acp_command, + "args": list(self.acp_args or []), + "credential_pool": getattr(self, "_credential_pool", None), + } + route = { + "model": self.model, + "runtime": runtime, + "signature": ( + self.model, + runtime["provider"], + runtime["base_url"], + runtime["api_mode"], + runtime["command"], + tuple(runtime["args"]), + ), + } service_tier = getattr(self, "service_tier", None) if not service_tier: @@ -2815,13 +3140,13 @@ def _resolve_turn_agent_config(self, user_message: str) -> dict: return route try: - overrides = resolve_fast_mode_overrides(route.get("model")) + overrides = resolve_fast_mode_overrides(route["model"]) except Exception: overrides = None route["request_overrides"] = overrides return route - def _init_agent(self, *, model_override: str = None, runtime_override: dict = None, route_label: str = None, request_overrides: dict | None = None) -> bool: + def _init_agent(self, *, model_override: str = None, runtime_override: dict = None, request_overrides: dict | None = None) -> bool: """ Initialize the agent on first use. When resuming a session, restores conversation history from SQLite. @@ -2977,7 +3302,7 @@ def show_banner(self): use_compact = self.compact or term_width < 80 if use_compact: - self.console.print(_build_compact_banner()) + self._console_print(_build_compact_banner()) self._show_status() else: # Get tools for display @@ -3002,25 +3327,25 @@ def show_banner(self): # Warn about very low context lengths (common with local servers) if ctx_len and ctx_len <= 8192: - self.console.print() - self.console.print( + self._console_print() + self._console_print( f"[yellow]⚠️ Context length is only {ctx_len:,} tokens — " f"this is likely too low for agent use with tools.[/]" ) - self.console.print( + self._console_print( "[dim] Hermes needs 16k–32k minimum. Tool schemas + system prompt alone use ~4k–8k.[/]" ) base_url = getattr(self, "base_url", "") or "" if "11434" in base_url or "ollama" in base_url.lower(): - self.console.print( + self._console_print( "[dim] Ollama fix: OLLAMA_CONTEXT_LENGTH=32768 ollama serve[/]" ) elif "1234" in base_url: - self.console.print( + self._console_print( "[dim] LM Studio fix: Set context length in model settings → reload model[/]" ) else: - self.console.print( + self._console_print( "[dim] Fix: Set model.context_length in config.yaml, or increase your server's context setting[/]" ) @@ -3029,20 +3354,20 @@ def show_banner(self): model_name = getattr(self, "model", "") or "" if is_nous_hermes_non_agentic(model_name): - self.console.print() - self.console.print( + self._console_print() + self._console_print( "[bold yellow]⚠ Nous Research Hermes 3 & 4 models are NOT agentic and are not " "designed for use with Hermes Agent.[/]" ) - self.console.print( + self._console_print( "[dim] They lack tool-calling capabilities required for agent workflows. " "Consider using an agentic model (Claude, GPT, Gemini, DeepSeek, etc.).[/]" ) - self.console.print( + self._console_print( "[dim] Switch with: /model sonnet or /model gpt5[/]" ) - self.console.print() + self._console_print() def _preload_resumed_session(self) -> bool: """Load a resumed session's history from the DB early (before first chat). @@ -3060,10 +3385,10 @@ def _preload_resumed_session(self) -> bool: session_meta = self._session_db.get_session(self.session_id) if not session_meta: - self.console.print( + self._console_print( f"[bold red]Session not found: {self.session_id}[/]" ) - self.console.print( + self._console_print( "[dim]Use a session ID from a previous CLI run " "(hermes sessions list).[/]" ) @@ -3078,7 +3403,7 @@ def _preload_resumed_session(self) -> bool: if session_meta.get("title"): title_part = f' "{session_meta["title"]}"' accent_color = _accent_hex() - self.console.print( + self._console_print( f"[{accent_color}]↻ Resumed session [bold]{self.session_id}[/bold]" f"{title_part} " f"({msg_count} user message{'s' if msg_count != 1 else ''}, " @@ -3086,7 +3411,7 @@ def _preload_resumed_session(self) -> bool: ) else: accent_color = _accent_hex() - self.console.print( + self._console_print( f"[{accent_color}]Session {self.session_id} found but has no " f"messages. Starting fresh.[/]" ) @@ -3125,21 +3450,6 @@ def _display_resumed_history(self): MAX_ASST_LEN = 200 # truncate assistant text MAX_ASST_LINES = 3 # max lines of assistant text - def _strip_reasoning(text: str) -> str: - """Remove ... blocks - from displayed text (reasoning model internal thoughts).""" - import re - cleaned = re.sub( - r".*?\s*", - "", text, flags=re.DOTALL, - ) - # Also strip unclosed reasoning tags at the end - cleaned = re.sub( - r".*$", - "", cleaned, flags=re.DOTALL, - ) - return cleaned.strip() - # Collect displayable entries (skip system, tool-result messages) entries = [] # list of (role, display_text) _last_asst_idx = None # index of last assistant entry @@ -3171,7 +3481,7 @@ def _strip_reasoning(text: str) -> str: elif role == "assistant": text = "" if content is None else str(content) - text = _strip_reasoning(text) + text = _strip_reasoning_tags(text) parts = [] full_parts = [] # un-truncated version if text: @@ -3276,7 +3586,7 @@ def _strip_reasoning(text: str) -> str: padding=(0, 1), style=_history_text_c, ) - self.console.print(panel) + self._console_print(panel) def _try_attach_clipboard_image(self) -> bool: """Check clipboard for an image and attach it if found. @@ -3510,6 +3820,26 @@ def _handle_stop_command(self): killed = process_registry.kill_all() print(f" ✅ Stopped {killed} process(es).") + def _handle_agents_command(self): + """Handle /agents — show background processes and agent status.""" + from tools.process_registry import format_uptime_short, process_registry + + processes = process_registry.list_sessions() + running = [p for p in processes if p.get("status") == "running"] + finished = [p for p in processes if p.get("status") != "running"] + + _cprint(f" Running processes: {len(running)}") + for p in running: + cmd = p.get("command", "")[:80] + up = format_uptime_short(p.get("uptime_seconds", 0)) + _cprint(f" {p.get('session_id', '?')} · {up} · {cmd}") + + if finished: + _cprint(f" Recently finished: {len(finished)}") + + agent_running = getattr(self, "_agent_running", False) + _cprint(f" Agent: {'running' if agent_running else 'idle'}") + def _handle_paste_command(self): """Handle /paste — explicitly check clipboard for an image. @@ -3535,6 +3865,61 @@ def _handle_paste_command(self): else: _cprint(f" {_DIM}(._.) No image found in clipboard{_RST}") + def _write_osc52_clipboard(self, text: str) -> None: + """Copy *text* to terminal clipboard via OSC 52.""" + payload = base64.b64encode(text.encode("utf-8")).decode("ascii") + seq = f"\x1b]52;c;{payload}\x07" + out = getattr(self, "_app", None) + output = getattr(out, "output", None) if out else None + if output and hasattr(output, "write_raw"): + output.write_raw(seq) + output.flush() + return + if output and hasattr(output, "write"): + output.write(seq) + output.flush() + return + sys.stdout.write(seq) + sys.stdout.flush() + + def _handle_copy_command(self, cmd_original: str) -> None: + """Handle /copy [number] — copy assistant output to clipboard.""" + parts = cmd_original.split(maxsplit=1) + arg = parts[1].strip() if len(parts) > 1 else "" + + assistant = [m for m in self.conversation_history if m.get("role") == "assistant"] + if not assistant: + _cprint(" Nothing to copy yet.") + return + + if arg: + try: + idx = int(arg) - 1 + except ValueError: + _cprint(" Usage: /copy [number]") + return + if idx < 0 or idx >= len(assistant): + _cprint(f" Invalid response number. Use 1-{len(assistant)}.") + return + else: + idx = len(assistant) - 1 + while idx >= 0 and not _assistant_copy_text(assistant[idx].get("content")): + idx -= 1 + if idx < 0: + _cprint(" Nothing to copy in assistant responses yet.") + return + + text = _assistant_copy_text(assistant[idx].get("content")) + if not text: + _cprint(" Nothing to copy in that assistant response.") + return + + try: + self._write_osc52_clipboard(text) + _cprint(f" Copied assistant response #{idx + 1} to clipboard") + except Exception as e: + _cprint(f" Clipboard copy failed: {e}") + def _handle_image_command(self, cmd_original: str): """Handle /image — attach a local image file for the next prompt.""" raw_args = (cmd_original.split(None, 1)[1].strip() if " " in cmd_original else "") @@ -3572,7 +3957,6 @@ def _preprocess_images_with_vision(self, text: str, images: list, *, announce: b image later with ``vision_analyze`` if needed. """ import asyncio as _asyncio - import json as _json from tools.vision_tools import vision_analyze_tool analysis_prompt = ( @@ -3592,7 +3976,7 @@ def _preprocess_images_with_vision(self, text: str, images: list, *, announce: b result_json = _asyncio.run( vision_analyze_tool(image_url=str(img_path), user_prompt=analysis_prompt) ) - result = _json.loads(result_json) + result = json.loads(result_json) if result.get("success"): description = result.get("analysis", "") enriched_parts.append( @@ -3637,14 +4021,14 @@ def _show_tool_availability_warnings(self): api_key_missing = [u for u in unavailable if u["missing_vars"]] if api_key_missing: - self.console.print() - self.console.print("[yellow]⚠️ Some tools disabled (missing API keys):[/]") + self._console_print() + self._console_print("[yellow]⚠️ Some tools disabled (missing API keys):[/]") for item in api_key_missing: tools_str = ", ".join(item["tools"][:2]) # Show first 2 tools if len(item["tools"]) > 2: tools_str += f", +{len(item['tools'])-2} more" - self.console.print(f" [dim]• {item['name']}[/] [dim italic]({', '.join(item['missing_vars'])})[/]") - self.console.print("[dim] Run 'hermes setup' to configure[/]") + self._console_print(f" [dim]• {item['name']}[/] [dim italic]({', '.join(item['missing_vars'])})[/]") + self._console_print("[dim] Run 'hermes setup' to configure[/]") except Exception: pass # Don't crash on import errors @@ -3671,7 +4055,7 @@ def _show_status(self): skin = get_active_skin() separator_color = skin.get_color("banner_dim", "#B8860B") accent_color = skin.get_color("ui_accent", "#FFBF00") - label_color = skin.get_color("ui_label", "#4dd0e1") + label_color = skin.get_color("ui_label", "#DAA520") except Exception: separator_color, accent_color, label_color = "#B8860B", "#FFBF00", "cyan" toolsets_info = "" @@ -3682,7 +4066,7 @@ def _show_status(self): if self._provider_source: provider_info += f" [dim {separator_color}]·[/] [dim]auth: {self._provider_source}[/]" - self.console.print( + self._console_print( f" {api_indicator} [{accent_color}]{model_short}[/] " f"[dim {separator_color}]·[/] [bold {label_color}]{tool_count} tools[/]" f"{toolsets_info}{provider_info}" @@ -3739,7 +4123,7 @@ def _show_session_status(self): f"Tokens: {total_tokens:,}", f"Agent Running: {'Yes' if is_running else 'No'}", ]) - self.console.print("\n".join(lines), highlight=False, markup=False) + self._console_print("\n".join(lines), highlight=False, markup=False) def _fast_command_available(self) -> bool: try: @@ -3788,6 +4172,7 @@ def show_help(self): _cprint(f"\n {_DIM}Tip: Just type your message to chat with Hermes!{_RST}") _cprint(f" {_DIM}Multi-line: Alt+Enter for a new line{_RST}") + _cprint(f" {_DIM}Draft editor: Ctrl+G{_RST}") if _is_termux_environment(): _cprint(f" {_DIM}Attach image: /image {_termux_example_image_path()} or start your prompt with a local image path{_RST}\n") else: @@ -3846,8 +4231,37 @@ def _handle_tools_command(self, cmd: str): """ import shlex from argparse import Namespace + from contextlib import redirect_stdout + from io import StringIO from hermes_cli.tools_config import tools_disable_enable_command + def _run_capture(ns: Namespace) -> None: + """Run tools_disable_enable_command, routing its ANSI-colored + print() output through _cprint when inside the interactive TUI + so escapes aren't mangled by patch_stdout's StdoutProxy into + garbled '?[32m...?[0m' text. + + Outside the TUI (standalone mode, tests), call straight through + so real stdout / pytest capture works as expected. + """ + # Standalone/tests, run as usual + if getattr(self, "_app", None) is None: + tools_disable_enable_command(ns) + return + + # Buffer reports isatty()=True so color() in hermes_cli/colors.py + # still emits ANSI escapes. StringIO.isatty() is False, which + # would otherwise strip all colors before we re-render them. + class _TTYBuf(StringIO): + def isatty(self) -> bool: + return True + + buf = _TTYBuf() + with redirect_stdout(buf): + tools_disable_enable_command(ns) + for line in buf.getvalue().splitlines(): + _cprint(line) + try: parts = shlex.split(cmd) except ValueError: @@ -3859,8 +4273,7 @@ def _handle_tools_command(self, cmd: str): return if subcommand == "list": - tools_disable_enable_command( - Namespace(tools_action="list", platform="cli")) + _run_capture(Namespace(tools_action="list", platform="cli")) return names = parts[2:] @@ -3877,8 +4290,7 @@ def _handle_tools_command(self, cmd: str): label = ", ".join(names) _cprint(f"{_ACCENT}{verb} {label}...{_RST}") - tools_disable_enable_command( - Namespace(tools_action=subcommand, names=names, platform="cli")) + _run_capture(Namespace(tools_action=subcommand, names=names, platform="cli")) # Reset session so the new tool config is picked up from a clean state from hermes_cli.tools_config import _get_platform_tools @@ -4514,6 +4926,34 @@ def _close_model_picker(self) -> None: self._restore_modal_input_snapshot() self._invalidate(min_interval=0.0) + @staticmethod + def _compute_model_picker_viewport( + selected: int, + scroll_offset: int, + n: int, + term_rows: int, + reserved_below: int = 6, + panel_chrome: int = 6, + min_visible: int = 3, + ) -> tuple[int, int]: + """Resolve (scroll_offset, visible) for the /model picker viewport. + + ``reserved_below`` matches the approval / clarify panels — input area, + status bar, and separators below the panel. ``panel_chrome`` covers + this panel's own borders + blanks + hint row. The remaining rows hold + the scrollable list, with the offset slid to keep ``selected`` on screen. + """ + max_visible = max(min_visible, term_rows - reserved_below - panel_chrome) + if n <= max_visible: + return 0, n + visible = max_visible + if selected < scroll_offset: + scroll_offset = selected + elif selected >= scroll_offset + visible: + scroll_offset = selected - visible + 1 + scroll_offset = max(0, min(scroll_offset, n - visible)) + return scroll_offset, visible + def _apply_model_switch_result(self, result, persist_global: bool) -> None: if not result.success: _cprint(f" ✗ {result.error_message}") @@ -4577,7 +5017,7 @@ def _apply_model_switch_result(self, result, persist_global: bool) -> None: pass cache_enabled = ( - ("openrouter" in (result.base_url or "").lower() and "claude" in result.new_model.lower()) + (base_url_host_matches(result.base_url or "", "openrouter.ai") and "claude" in result.new_model.lower()) or result.api_mode == "anthropic_messages" ) if cache_enabled: @@ -4677,22 +5117,21 @@ def _handle_model_switch(self, cmd_original: str): user_provs = None custom_provs = None + + # Load custom_providers unconditionally (not just for picker) + try: + from hermes_cli.config import get_compatible_custom_providers, load_config + cfg = load_config() + user_provs = cfg.get("providers") + custom_provs = get_compatible_custom_providers(cfg) + except Exception: + pass # No args at all: open prompt_toolkit-native picker modal if not model_input and not explicit_provider: model_display = self.model or "unknown" provider_display = get_label(self.provider) if self.provider else "unknown" - user_provs = None - custom_provs = None - try: - from hermes_cli.config import get_compatible_custom_providers, load_config - cfg = load_config() - user_provs = cfg.get("providers") - custom_provs = get_compatible_custom_providers(cfg) - except Exception: - pass - try: providers = list_authenticated_providers( current_provider=self.provider or "", @@ -4805,7 +5244,7 @@ def _handle_model_switch(self, cmd_original: str): # Cache notice cache_enabled = ( - ("openrouter" in (result.base_url or "").lower() and "claude" in result.new_model.lower()) + (base_url_host_matches(result.base_url or "", "openrouter.ai") and "claude" in result.new_model.lower()) or result.api_mode == "anthropic_messages" ) if cache_enabled: @@ -4836,6 +5275,30 @@ def _should_handle_model_command_inline(self, text: str, has_images: bool = Fals except Exception: return False + def _should_handle_steer_command_inline(self, text: str, has_images: bool = False) -> bool: + """Return True when /steer should be dispatched immediately while the agent is running. + + /steer MUST bypass the normal _pending_input → process_loop path when + the agent is active, because process_loop is blocked inside + self.chat() for the duration of the run. By the time the queued + command is pulled from _pending_input, _agent_running has already + flipped back to False, and process_command() takes the idle + fallback — delivering the steer as a next-turn message instead of + injecting it mid-run. Dispatching inline on the UI thread calls + agent.steer() directly, which is thread-safe (uses _pending_steer_lock). + """ + if not text or has_images or not _looks_like_slash_command(text): + return False + if not getattr(self, "_agent_running", False): + return False + try: + from hermes_cli.commands import resolve_command + base = text.split(None, 1)[0].lower().lstrip('/') + cmd = resolve_command(base) + return bool(cmd and cmd.name == "steer") + except Exception: + return False + def _show_model_and_providers(self): """Show current model + provider and list all authenticated providers. @@ -4909,8 +5372,15 @@ def _show_model_and_providers(self): print(" To change model or provider, use: hermes model") + def _output_console(self): + """Use prompt_toolkit-safe Rich rendering once the TUI is live.""" + if getattr(self, "_app", None): + return ChatConsole() + return self.console - + def _console_print(self, *args, **kwargs): + """Print through the active command-safe console.""" + self._output_console().print(*args, **kwargs) @staticmethod def _resolve_personality_prompt(value) -> str: @@ -4930,14 +5400,14 @@ def _handle_gquota_command(self, cmd_original: str) -> None: from agent.google_oauth import get_valid_access_token, GoogleOAuthError, load_credentials from agent.google_code_assist import retrieve_user_quota, CodeAssistError except ImportError as exc: - self.console.print(f" [red]Gemini modules unavailable: {exc}[/]") + self._console_print(f" [red]Gemini modules unavailable: {exc}[/]") return try: access_token = get_valid_access_token() except GoogleOAuthError as exc: - self.console.print(f" [yellow]{exc}[/]") - self.console.print(" Run [bold]/model[/] and pick 'Google Gemini (OAuth)' to sign in.") + self._console_print(f" [yellow]{exc}[/]") + self._console_print(" Run [bold]/model[/] and pick 'Google Gemini (OAuth)' to sign in.") return creds = load_credentials() @@ -4946,18 +5416,18 @@ def _handle_gquota_command(self, cmd_original: str) -> None: try: buckets = retrieve_user_quota(access_token, project_id=project_id) except CodeAssistError as exc: - self.console.print(f" [red]Quota lookup failed:[/] {exc}") + self._console_print(f" [red]Quota lookup failed:[/] {exc}") return if not buckets: - self.console.print(" [dim]No quota buckets reported (account may be on legacy/unmetered tier).[/]") + self._console_print(" [dim]No quota buckets reported (account may be on legacy/unmetered tier).[/]") return # Sort for stable display, group by model buckets.sort(key=lambda b: (b.model_id, b.token_type)) - self.console.print() - self.console.print(f" [bold]Gemini Code Assist quota[/] (project: {project_id or '(auto / free-tier)'})") - self.console.print() + self._console_print() + self._console_print(f" [bold]Gemini Code Assist quota[/] (project: {project_id or '(auto / free-tier)'})") + self._console_print() for b in buckets: pct = max(0.0, min(1.0, b.remaining_fraction)) width = 20 @@ -4967,8 +5437,8 @@ def _handle_gquota_command(self, cmd_original: str) -> None: header = b.model_id if b.token_type: header += f" [{b.token_type}]" - self.console.print(f" {header:40s} {bar} {pct_str}") - self.console.print() + self._console_print(f" {header:40s} {bar} {pct_str}") + self._console_print() def _handle_personality_command(self, cmd: str): """Handle the /personality command to set predefined personalities.""" @@ -5099,7 +5569,7 @@ def _parse_flags(tokens): print(" /cron list") print(' /cron add "every 2h" "Check server status" [--skill blogwatcher]') print(' /cron edit --schedule "every 4h" --prompt "New task"') - print(" /cron edit --skill blogwatcher --skill find-nearby") + print(" /cron edit --skill blogwatcher --skill maps") print(" /cron edit --remove-skill blogwatcher") print(" /cron edit --clear-skills") print(" /cron pause ") @@ -5416,7 +5886,7 @@ def process_command(self, command: str) -> bool: _tip_color = get_active_skin().get_color("banner_dim", "#B8860B") except Exception: _tip_color = "#B8860B" - self.console.print(f"[dim {_tip_color}]✦ Tip: {_tip}[/]") + self._console_print(f"[dim {_tip_color}]✦ Tip: {_tip}[/]") except Exception: pass elif canonical == "history": @@ -5510,7 +5980,7 @@ def process_command(self, command: str) -> bool: elif canonical == "statusbar": self._status_bar_visible = not self._status_bar_visible state = "visible" if self._status_bar_visible else "hidden" - self.console.print(f" Status bar {state}") + self._console_print(f" Status bar {state}") elif canonical == "verbose": self._toggle_verbose() elif canonical == "yolo": @@ -5525,6 +5995,8 @@ def process_command(self, command: str) -> bool: self._show_usage() elif canonical == "insights": self._show_insights(cmd_original) + elif canonical == "copy": + self._handle_copy_command(cmd_original) elif canonical == "debug": self._handle_debug_command() elif canonical == "paste": @@ -5568,6 +6040,8 @@ def process_command(self, command: str) -> bool: self._handle_snapshot_command(cmd_original) elif canonical == "stop": self._handle_stop_command() + elif canonical == "agents": + self._handle_agents_command() elif canonical == "background": self._handle_background_command(cmd_original) elif canonical == "btw": @@ -5584,6 +6058,30 @@ def process_command(self, command: str) -> bool: _cprint(f" Queued for the next turn: {payload[:80]}{'...' if len(payload) > 80 else ''}") else: _cprint(f" Queued: {payload[:80]}{'...' if len(payload) > 80 else ''}") + elif canonical == "steer": + # Inject a message after the next tool call without interrupting. + # If the agent is actively running, push the text into the agent's + # pending_steer slot — the drain hook in _execute_tool_calls_* + # will append it to the next tool result's content. If no agent + # is running, fall back to queue semantics (same as /queue). + parts = cmd_original.split(None, 1) + payload = parts[1].strip() if len(parts) > 1 else "" + if not payload: + _cprint(" Usage: /steer ") + elif self._agent_running and self.agent is not None and hasattr(self.agent, "steer"): + try: + accepted = self.agent.steer(payload) + except Exception as exc: + _cprint(f" Steer failed: {exc}") + else: + if accepted: + _cprint(f" ⏩ Steer queued — arrives after the next tool call: {payload[:80]}{'...' if len(payload) > 80 else ''}") + else: + _cprint(" Steer rejected (empty payload).") + else: + # No active run — treat as a normal next-turn message. + self._pending_input.put(payload) + _cprint(f" No agent running; queued as next turn: {payload[:80]}{'...' if len(payload) > 80 else ''}") elif canonical == "skin": self._handle_skin_command(cmd_original) elif canonical == "voice": @@ -5605,15 +6103,15 @@ def process_command(self, command: str) -> bool: ) output = result.stdout.strip() or result.stderr.strip() if output: - self.console.print(_rich_text_from_ansi(output)) + self._console_print(_rich_text_from_ansi(output)) else: - self.console.print("[dim]Command returned no output[/]") + self._console_print("[dim]Command returned no output[/]") except subprocess.TimeoutExpired: - self.console.print("[bold red]Quick command timed out (30s)[/]") + self._console_print("[bold red]Quick command timed out (30s)[/]") except Exception as e: - self.console.print(f"[bold red]Quick command error: {e}[/]") + self._console_print(f"[bold red]Quick command error: {e}[/]") else: - self.console.print(f"[bold red]Quick command '{base_cmd}' has no command defined[/]") + self._console_print(f"[bold red]Quick command '{base_cmd}' has no command defined[/]") elif qcmd.get("type") == "alias": target = qcmd.get("target", "").strip() if target: @@ -5622,9 +6120,9 @@ def process_command(self, command: str) -> bool: aliased_command = f"{target} {user_args}".strip() return self.process_command(aliased_command) else: - self.console.print(f"[bold red]Quick command '{base_cmd}' has no target defined[/]") + self._console_print(f"[bold red]Quick command '{base_cmd}' has no target defined[/]") else: - self.console.print(f"[bold red]Quick command '{base_cmd}' has unsupported type (supported: 'exec', 'alias')[/]") + self._console_print(f"[bold red]Quick command '{base_cmd}' has unsupported type (supported: 'exec', 'alias')[/]") # Check for plugin-registered slash commands elif base_cmd.lstrip("/") in _get_plugin_cmd_handler_names(): from hermes_cli.plugins import get_plugin_command_handler @@ -5803,8 +6301,7 @@ def _bg_thinking(text: str) -> None: # with the output (fixes #2718). if self._app: self._app.invalidate() - import time as _tmod - _tmod.sleep(0.05) # brief pause for refresh + time.sleep(0.05) # brief pause for refresh print() ChatConsole().print(f"[{_accent_hex()}]{'─' * 40}[/]") _cprint(f" ✅ Background task #{task_num} complete") @@ -5824,7 +6321,7 @@ def _bg_thinking(text: str) -> None: _chat_console = ChatConsole() _chat_console.print(Panel( - _rich_text_from_ansi(response), + _render_final_assistant_content(response, mode=self.final_response_markdown), title=f"[{_resp_color} bold]{label} (background #{task_num})[/]", title_align="left", border_style=_resp_color, @@ -5844,8 +6341,7 @@ def _bg_thinking(text: str) -> None: # Same TUI refresh pattern as success path (#2718) if self._app: self._app.invalidate() - import time as _tmod - _tmod.sleep(0.05) + time.sleep(0.05) print() _cprint(f" ❌ Background task #{task_num} failed: {e}") finally: @@ -5949,7 +6445,7 @@ def run_btw(): _resp_color = "#4F6D4A" ChatConsole().print(Panel( - _rich_text_from_ansi(response), + _render_final_assistant_content(response, mode=self.final_response_markdown), title=f"[{_resp_color} bold]⚕ /btw[/]", title_align="left", border_style=_resp_color, @@ -6065,7 +6561,6 @@ def _handle_browser_command(self, cmd: str): _launched = self._try_launch_chrome_debug(_port, _plat.system()) if _launched: # Wait for the port to come up - import time as _time for _wait in range(10): try: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) @@ -6075,7 +6570,7 @@ def _handle_browser_command(self, cmd: str): _already_open = True break except (OSError, socket.timeout): - _time.sleep(0.5) + time.sleep(0.5) if _already_open: print(f" ✓ Chrome launched and listening on port {_port}") else: @@ -6441,6 +6936,18 @@ def _manual_compress(self, cmd_original: str = ""): focus_topic=focus_topic or None, ) self.conversation_history = compressed + # _compress_context ends the old session and creates a new child + # session on the agent (run_agent.py::_compress_context). Sync the + # CLI's session_id so /status, /resume, exit summary, and title + # generation all point at the live continuation session, not the + # ended parent. Without this, subsequent end_session() calls target + # the already-closed parent and the child is orphaned. + if ( + getattr(self.agent, "session_id", None) + and self.agent.session_id != self.session_id + ): + self.session_id = self.agent.session_id + self._pending_title = None new_tokens = estimate_messages_tokens_rough(self.conversation_history) summary = summarize_manual_compression( original_history, @@ -6543,6 +7050,27 @@ def _show_usage(self): if cost_result.status == "unknown": print(f" Note: Pricing unknown for {agent.model}") + # Account limits -- fetched off-thread with a hard timeout so slow + # provider APIs don't hang the prompt. + provider = getattr(agent, "provider", None) or getattr(self, "provider", None) + base_url = getattr(agent, "base_url", None) or getattr(self, "base_url", None) + api_key = getattr(agent, "api_key", None) or getattr(self, "api_key", None) + account_snapshot = None + if provider: + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as _pool: + try: + account_snapshot = _pool.submit( + fetch_account_usage, provider, + base_url=base_url, api_key=api_key, + ).result(timeout=10.0) + except (concurrent.futures.TimeoutError, Exception): + account_snapshot = None + account_lines = [f" {line}" for line in render_account_usage_lines(account_snapshot)] + if account_lines: + print() + for line in account_lines: + print(line) + if self.verbose: logging.getLogger().setLevel(logging.DEBUG) for noisy in ('openai', 'openai._base_client', 'httpx', 'httpcore', 'asyncio', 'hpack', 'grpc', 'modal'): @@ -6593,7 +7121,6 @@ def _check_config_mcp_changes(self) -> None: known state. When a change is detected, triggers _reload_mcp() and informs the user so they know the tool list has been refreshed. """ - import time import yaml as _yaml CONFIG_WATCH_INTERVAL = 5.0 # seconds between config.yaml stat() calls @@ -6685,7 +7212,6 @@ def _reload_mcp(self): # Refresh the agent's tool list so the model can call new tools if self.agent is not None: - from model_tools import get_tool_definitions self.agent.tools = get_tool_definitions( enabled_toolsets=self.agent.enabled_toolsets if hasattr(self.agent, "enabled_toolsets") else None, @@ -6768,7 +7294,6 @@ def _on_tool_progress(self, event_type: str, function_name: str = None, preview: full history of tool calls (not just the current one in the spinner). """ if event_type == "tool.completed": - import time as _time self._tool_start_time = 0.0 # Print stacked scrollback line for "all" / "new" modes if function_name and self.tool_progress_mode in ("all", "new"): @@ -6797,7 +7322,6 @@ def _on_tool_progress(self, event_type: str, function_name: str = None, preview: if event_type != "tool.started": return if function_name and not function_name.startswith("_"): - import time as _time from agent.display import get_tool_emoji emoji = get_tool_emoji(function_name) label = preview or function_name @@ -6806,7 +7330,7 @@ def _on_tool_progress(self, event_type: str, function_name: str = None, preview: if _pl > 0 and len(label) > _pl: label = label[:_pl - 3] + "..." self._spinner_text = f"{emoji} {label}" - self._tool_start_time = _time.monotonic() + self._tool_start_time = time.monotonic() # Store args for stacked scrollback line on completion self._pending_tool_info.setdefault(function_name, []).append( function_args if function_args is not None else {} @@ -6881,8 +7405,7 @@ def _voice_start_recording(self): ) raise RuntimeError( "Voice mode requires sounddevice and numpy.\n" - "Install with: pip install sounddevice numpy\n" - "Or: pip install hermes-agent[voice]" + f"Install with: {sys.executable} -m pip install sounddevice numpy" ) if not reqs.get("stt_available", reqs.get("stt_key_set")): raise RuntimeError( @@ -6924,11 +7447,12 @@ def _on_silence(): self._voice_stop_and_transcribe() # Audio cue: single beep BEFORE starting stream (avoid CoreAudio conflict) - try: - from tools.voice_mode import play_beep - play_beep(frequency=880, count=1) - except Exception: - pass + if self._voice_beeps_enabled(): + try: + from tools.voice_mode import play_beep + play_beep(frequency=880, count=1) + except Exception: + pass try: self._voice_recorder.start(on_silence_stop=_on_silence) @@ -6976,11 +7500,12 @@ def _voice_stop_and_transcribe(self): wav_path = self._voice_recorder.stop() # Audio cue: double beep after stream stopped (no CoreAudio conflict) - try: - from tools.voice_mode import play_beep - play_beep(frequency=660, count=2) - except Exception: - pass + if self._voice_beeps_enabled(): + try: + from tools.voice_mode import play_beep + play_beep(frequency=660, count=2) + except Exception: + pass if wav_path is None: _cprint(f"{_DIM}No speech detected.{_RST}") @@ -7063,7 +7588,6 @@ def _voice_speak_response(self, text: str): try: from tools.tts_tool import text_to_speech_tool from tools.voice_mode import play_audio_file - import re # Strip markdown and non-speech content for cleaner TTS tts_text = text[:4000] if len(text) > 4000 else text @@ -7131,6 +7655,17 @@ def _handle_voice_command(self, command: str): _cprint(f"Unknown voice subcommand: {subcommand}") _cprint("Usage: /voice [on|off|tts|status]") + def _voice_beeps_enabled(self) -> bool: + """Return whether CLI voice mode should play record start/stop beeps.""" + try: + from hermes_cli.config import load_config + voice_cfg = load_config().get("voice", {}) + if isinstance(voice_cfg, dict): + return bool(voice_cfg.get("beep_enabled", True)) + except Exception: + pass + return True + def _enable_voice_mode(self): """Enable voice mode after checking requirements.""" if self._voice_mode: @@ -7158,8 +7693,7 @@ def _enable_voice_mode(self): _cprint(f" {_DIM}Then install/update the Termux:API Android app for microphone capture{_RST}") _cprint(f" {_BOLD}Option 2: pkg install python-numpy portaudio && python -m pip install sounddevice{_RST}") else: - _cprint(f"\n {_BOLD}Install: pip install {' '.join(reqs['missing_packages'])}{_RST}") - _cprint(f" {_DIM}Or: pip install hermes-agent[voice]{_RST}") + _cprint(f"\n {_BOLD}Install: {sys.executable} -m pip install {' '.join(reqs['missing_packages'])}{_RST}") return with self._voice_lock: @@ -7441,7 +7975,9 @@ def _handle_approval_selection(self) -> None: return selected = state.get("selected", 0) - choices = state.get("choices") or [] + choices = state.get("choices") + if not isinstance(choices, list): + choices = [] if not (0 <= selected < len(choices)): return @@ -7533,8 +8069,18 @@ def _append_blank_panel_line(lines, border_style: str, box_width: int) -> None: choice_wrapped: list[tuple[int, str]] = [] for i, choice in enumerate(choices): label = choice_labels.get(choice, choice) - prefix = '❯ ' if i == selected else ' ' - for wrapped in _wrap_panel_text(f"{prefix}{label}", inner_text_width, subsequent_indent=" "): + # Show number prefix for quick selection (1-9 for items 1-9, 0 for 10th item) + if i < 9: + num_prefix = str(i + 1) + elif i == 9: + num_prefix = '0' + else: + num_prefix = ' ' # No number for items beyond 10th + if i == selected: + prefix = f'❯ {num_prefix}. ' + else: + prefix = f' {num_prefix}. ' + for wrapped in _wrap_panel_text(f"{prefix}{label}", inner_text_width, subsequent_indent=" "): choice_wrapped.append((i, wrapped)) # Budget vertical space so HSplit never clips the command or choices. @@ -7697,7 +8243,6 @@ def chat(self, message, images: list = None) -> Optional[str]: if not self._init_agent( model_override=turn_route["model"], runtime_override=turn_route["runtime"], - route_label=turn_route["label"], request_overrides=turn_route.get("request_overrides"), ): return None @@ -7826,6 +8371,17 @@ def stream_callback(delta: str): def run_agent(): nonlocal result + # Set callbacks inside the agent thread so thread-local storage + # in terminal_tool is populated for this thread. The main thread + # registration (run() line ~9046) is invisible here because + # _callback_tls is threading.local(). Matches the pattern used + # by acp_adapter/server.py for ACP sessions. + set_sudo_password_callback(self._sudo_password_callback) + set_approval_callback(self._approval_callback) + try: + set_secret_capture_callback(self._secret_capture_callback) + except Exception: + pass agent_message = _voice_prefix + message if _voice_prefix else message # Prepend pending model switch note so the model knows about the switch _msn = getattr(self, '_pending_model_switch_note', None) @@ -7851,10 +8407,23 @@ def run_agent(): "failed": True, "error": _summary, } + finally: + # Clear thread-local callbacks so a reused thread doesn't + # hold stale references to a disposed CLI instance. + try: + set_sudo_password_callback(None) + set_approval_callback(None) + set_secret_capture_callback(None) + except Exception: + pass # Start agent in background thread (daemon so it cannot keep the # process alive when the user closes the terminal tab — SIGHUP # exits the main thread and daemon threads are reaped automatically). + # Start per-prompt elapsed timer — frozen after the agent thread + # finishes; reset on the next turn. + self._prompt_start_time = time.time() + self._prompt_duration = 0.0 agent_thread = threading.Thread(target=run_agent, daemon=True) agent_thread.start() @@ -7884,8 +8453,7 @@ def run_agent(): try: _dbg = _hermes_home / "interrupt_debug.log" with open(_dbg, "a") as _f: - import time as _t - _f.write(f"{_t.strftime('%H:%M:%S')} interrupt fired: msg={str(interrupt_msg)[:60]!r}, " + _f.write(f"{time.strftime('%H:%M:%S')} interrupt fired: msg={str(interrupt_msg)[:60]!r}, " f"children={len(self.agent._active_children)}, " f"parent._interrupt={self.agent._interrupt_requested}\n") for _ci, _ch in enumerate(self.agent._active_children): @@ -7932,6 +8500,12 @@ def run_agent(): # but guard against edge cases. agent_thread.join(timeout=30) + # Freeze per-prompt elapsed timer once the agent thread has + # exited (or been abandoned as a daemon after interrupt). + if self._prompt_start_time is not None: + self._prompt_duration = max(0.0, time.time() - self._prompt_start_time) + self._prompt_start_time = None + # Proactively clean up async clients whose event loop is dead. # The agent thread may have created AsyncOpenAI clients bound # to a per-thread event loop; if that loop is now closed, those @@ -7955,13 +8529,26 @@ def run_agent(): # buffer so tool/status lines render ABOVE our response box. # The flush pushes data into the renderer queue; the short # sleep lets the renderer actually paint it before we draw. - import time as _time sys.stdout.flush() - _time.sleep(0.15) + time.sleep(0.15) # Update history with full conversation self.conversation_history = result.get("messages", self.conversation_history) if result else self.conversation_history + # If auto-compression fired mid-turn, the agent created a new + # continuation session and mutated self.agent.session_id. Sync + # the CLI's session_id so /status, /resume, title generation, + # and the exit summary all target the live child session rather + # than the ended parent. Mirrors the gateway's post-run sync + # (gateway/run.py around line 9983). + if ( + self.agent + and getattr(self.agent, "session_id", None) + and self.agent.session_id != self.session_id + ): + self.session_id = self.agent.session_id + self._pending_title = None + # Get the final response response = result.get("final_response", "") if result else "" @@ -8051,7 +8638,7 @@ def run_agent(): else: _chat_console = ChatConsole() _chat_console.print(Panel( - _rich_text_from_ansi(response), + _render_final_assistant_content(response, mode=self.final_response_markdown), title=f"[{_resp_color} bold]{label}[/]", title_align="left", border_style=_resp_color, @@ -8110,7 +8697,15 @@ def run_agent(): else: print(f"\n⚡ Sending after interrupt: '{preview}'") self._pending_input.put(combined) - + + # If a /steer was left over (agent finished before another tool + # batch could absorb it), deliver it as the next user turn. + _leftover_steer = result.get("pending_steer") if result else None + if _leftover_steer and hasattr(self, '_pending_input'): + preview = _leftover_steer[:60] + ("..." if len(_leftover_steer) > 60 else "") + print(f"\n⏩ Delivering leftover /steer as next turn: '{preview}'") + self._pending_input.put(_leftover_steer) + return response except Exception as e: @@ -8388,7 +8983,7 @@ def run(self): except Exception: _welcome_text = "Welcome to Hermes Agent! Type your message or /help for commands." _welcome_color = "#FFF8DC" - self.console.print(f"[{_welcome_color}]{_welcome_text}[/]") + self._console_print(f"[{_welcome_color}]{_welcome_text}[/]") # Show a random tip to help users discover features try: from hermes_cli.tips import get_random_tip @@ -8397,16 +8992,16 @@ def run(self): _tip_color = _welcome_skin.get_color("banner_dim", "#B8860B") except Exception: _tip_color = "#B8860B" - self.console.print(f"[dim {_tip_color}]✦ Tip: {_tip}[/]") + self._console_print(f"[dim {_tip_color}]✦ Tip: {_tip}[/]") except Exception: pass # Tips are non-critical — never break startup if self.preloaded_skills and not self._startup_skills_line_shown: skills_label = ", ".join(self.preloaded_skills) - self.console.print( + self._console_print( f"[bold {_accent_hex()}]Activated skills:[/] {skills_label}" ) self._startup_skills_line_shown = True - self.console.print() + self._console_print() # State for async operation self._agent_running = False @@ -8528,6 +9123,7 @@ def handle_enter(event): # --- /model picker modal --- if self._model_picker_state: self._handle_model_picker_selection() + event.app.current_buffer.reset() event.app.invalidate() return @@ -8571,6 +9167,17 @@ def handle_enter(event): event.app.current_buffer.reset(append_to_history=True) return + # Handle /steer while the agent is running immediately on the + # UI thread. Queuing through _pending_input would deadlock the + # steer until after the agent loop finishes (process_loop is + # blocked inside self.chat()), which turns /steer into a + # post-run next-turn message — defeating mid-run injection. + # agent.steer() is thread-safe (holds _pending_steer_lock). + if self._should_handle_steer_command_inline(text, has_images=has_images): + self.process_command(text) + event.app.current_buffer.reset(append_to_history=True) + return + # Snapshot and clear attached images images = list(self._attached_images) self._attached_images.clear() @@ -8589,8 +9196,7 @@ def handle_enter(event): try: _dbg = _hermes_home / "interrupt_debug.log" with open(_dbg, "a") as _f: - import time as _t - _f.write(f"{_t.strftime('%H:%M:%S')} ENTER: queued interrupt msg={str(payload)[:60]!r}, " + _f.write(f"{time.strftime('%H:%M:%S')} ENTER: queued interrupt msg={str(payload)[:60]!r}, " f"agent_running={self._agent_running}\n") except Exception: pass @@ -8608,6 +9214,16 @@ def handle_ctrl_enter(event): """Ctrl+Enter (c-j) inserts a newline. Most terminals send c-j for Ctrl+Enter.""" event.current_buffer.insert_text('\n') + @kb.add( + 'c-g', + filter=Condition( + lambda: not self._clarify_state and not self._approval_state and not self._sudo_state and not self._secret_state + ), + ) + def handle_open_in_editor(event): + """Ctrl+G opens the current draft in an external editor.""" + cli_ref._open_external_editor(event.current_buffer) + @kb.add('tab', eager=True) def handle_tab(event): """Tab: accept completion, auto-suggestion, or start completions. @@ -8659,6 +9275,29 @@ def clarify_down(event): self._clarify_state["selected"] = min(max_idx, self._clarify_state["selected"] + 1) event.app.invalidate() + # Number keys for quick clarify selection (1-9, 0 for 10th item) + def _make_clarify_number_handler(idx): + def handler(event): + if self._clarify_state and not self._clarify_freetext: + choices = self._clarify_state.get("choices") or [] + # Map index to choice (treating "Other" as the last option) + if idx < len(choices): + # Select a numbered choice + self._clarify_state["response_queue"].put(choices[idx]) + self._clarify_state = None + self._clarify_freetext = False + event.app.invalidate() + elif idx == len(choices): + # Select "Other" option + self._clarify_freetext = True + event.app.invalidate() + return handler + + for _num in range(10): + # 1-9 select items 0-8, 0 selects item 9 (10thitem) + _idx = 9 if _num == 0 else _num - 1 + kb.add(str(_num), filter=Condition(lambda: bool(self._clarify_state) and not self._clarify_freetext))(_make_clarify_number_handler(_idx)) + # --- Dangerous command approval: arrow-key navigation --- @kb.add('up', filter=Condition(lambda: bool(self._approval_state))) @@ -8693,6 +9332,27 @@ def model_picker_down(event): state["selected"] = min(max_idx, state.get("selected", 0) + 1) event.app.invalidate() + @kb.add('escape', filter=Condition(lambda: bool(self._model_picker_state)), eager=True) + def model_picker_escape(event): + """ESC closes the /model picker.""" + self._close_model_picker() + event.app.current_buffer.reset() + event.app.invalidate() + + # Number keys for quick approval selection (1-9, 0 for 10th item) + def _make_approval_number_handler(idx): + def handler(event): + if self._approval_state and idx < len(self._approval_state["choices"]): + self._approval_state["selected"] = idx + self._handle_approval_selection() + event.app.invalidate() + return handler + + for _num in range(10): + # 1-9 select items 0-8, 0 selects item 9 (10th item) + _idx = 9 if _num == 0 else _num - 1 + kb.add(str(_num), filter=Condition(lambda: bool(self._approval_state)))(_make_approval_number_handler(_idx)) + # --- History navigation: up/down browse history in normal input mode --- # The TextArea is multiline, so by default up/down only move the cursor. # Buffer.auto_up/auto_down handle both: cursor movement when multi-line, @@ -8721,8 +9381,7 @@ def handle_ctrl_c(event): 2. Interrupt the running agent (first press) 3. Force exit (second press within 2s, or when idle) """ - import time as _time - now = _time.time() + now = time.time() # Cancel active voice recording. # Run cancel() in a background thread to prevent blocking the @@ -8830,12 +9489,11 @@ def handle_escape_modal(event): @kb.add('c-z') def handle_ctrl_z(event): """Handle Ctrl+Z - suspend process to background (Unix only).""" - import sys if sys.platform == 'win32': _cprint(f"\n{_DIM}Suspend (Ctrl+Z) is not supported on Windows.{_RST}") event.app.invalidate() return - import os, signal as _sig + import signal as _sig from prompt_toolkit.application import run_in_terminal from hermes_cli.skin_engine import get_active_skin agent_name = get_active_skin().get_branding("agent_name", "Hermes Agent") @@ -9052,6 +9710,7 @@ def _input_height(): _prev_text_len = [0] _prev_newline_count = [0] _paste_just_collapsed = [False] + self._skip_paste_collapse = False def _on_text_changed(buf): """Detect large pastes and collapse them to a file reference. @@ -9071,8 +9730,9 @@ def _on_text_changed(buf): text = buf.text chars_added = len(text) - _prev_text_len[0] _prev_text_len[0] = len(text) - if _paste_just_collapsed[0]: + if _paste_just_collapsed[0] or self._skip_paste_collapse: _paste_just_collapsed[0] = False + self._skip_paste_collapse = False _prev_newline_count[0] = text.count('\n') return line_count = text.count('\n') @@ -9081,12 +9741,10 @@ def _on_text_changed(buf): is_paste = chars_added > 1 or newlines_added >= 4 if line_count >= 5 and is_paste and not text.startswith('/'): _paste_counter[0] += 1 - # Save to temp file paste_dir = _hermes_home / "pastes" paste_dir.mkdir(parents=True, exist_ok=True) paste_file = paste_dir / f"paste_{_paste_counter[0]}_{datetime.now().strftime('%H%M%S')}.txt" paste_file.write_text(text, encoding="utf-8") - # Replace buffer with compact reference _paste_just_collapsed[0] = True buf.text = f"[Pasted text #{_paste_counter[0]}: {line_count + 1} lines \u2192 {paste_file}]" buf.cursor_position = len(buf.text) @@ -9149,31 +9807,29 @@ def _get_placeholder(): # extra instructions (sudo countdown, approval navigation, clarify). # The agent-running interrupt hint is now an inline placeholder above. def get_hint_text(): - import time as _time - if cli_ref._sudo_state: - remaining = max(0, int(cli_ref._sudo_deadline - _time.monotonic())) + remaining = max(0, int(cli_ref._sudo_deadline - time.monotonic())) return [ ('class:hint', ' password hidden · Enter to skip'), ('class:clarify-countdown', f' ({remaining}s)'), ] if cli_ref._secret_state: - remaining = max(0, int(cli_ref._secret_deadline - _time.monotonic())) + remaining = max(0, int(cli_ref._secret_deadline - time.monotonic())) return [ ('class:hint', ' secret hidden · Enter to skip'), ('class:clarify-countdown', f' ({remaining}s)'), ] if cli_ref._approval_state: - remaining = max(0, int(cli_ref._approval_deadline - _time.monotonic())) + remaining = max(0, int(cli_ref._approval_deadline - time.monotonic())) return [ ('class:hint', ' ↑/↓ to select, Enter to confirm'), ('class:clarify-countdown', f' ({remaining}s)'), ] if cli_ref._clarify_state: - remaining = max(0, int(cli_ref._clarify_deadline - _time.monotonic())) + remaining = max(0, int(cli_ref._clarify_deadline - time.monotonic())) countdown = f' ({remaining}s)' if cli_ref._clarify_deadline else '' if cli_ref._clarify_freetext: return [ @@ -9201,21 +9857,10 @@ def get_hint_height(): return cli_ref._agent_spacer_height() def get_spinner_text(): - txt = cli_ref._spinner_text - if not txt: + spinner_line = cli_ref._render_spinner_text() + if not spinner_line: return [] - # Append live elapsed timer when a tool is running - t0 = cli_ref._tool_start_time - if t0 > 0: - import time as _time - elapsed = _time.monotonic() - t0 - if elapsed >= 60: - _m, _s = int(elapsed // 60), int(elapsed % 60) - elapsed_str = f"{_m}m {_s}s" - else: - elapsed_str = f"{elapsed:.1f}s" - return [('class:hint', f' {txt} ({elapsed_str})')] - return [('class:hint', f' {txt}')] + return [('class:hint', spinner_line)] def get_spinner_height(): return cli_ref._spinner_widget_height() @@ -9276,14 +9921,32 @@ def _get_clarify_display(): selected = state.get("selected", 0) preview_lines = _wrap_panel_text(question, 60) for i, choice in enumerate(choices): - prefix = "❯ " if i == selected and not cli_ref._clarify_freetext else " " - preview_lines.extend(_wrap_panel_text(f"{prefix}{choice}", 60, subsequent_indent=" ")) + # Show number prefix for quick selection (1-9 for items 1-9, 0 for 10th item) + if i < 9: + num_prefix = str(i + 1) + elif i == 9: + num_prefix = '0' + else: + num_prefix = ' ' + if i == selected and not cli_ref._clarify_freetext: + prefix = f"❯ {num_prefix}. " + else: + prefix = f" {num_prefix}. " + preview_lines.extend(_wrap_panel_text(f"{prefix}{choice}", 60, subsequent_indent=" ")) + # "Other" option in preview + other_num = len(choices) + 1 + if other_num < 10: + other_num_prefix = str(other_num) + elif other_num == 10: + other_num_prefix = '0' + else: + other_num_prefix = ' ' other_label = ( - "❯ Other (type below)" if cli_ref._clarify_freetext - else "❯ Other (type your answer)" if selected == len(choices) - else " Other (type your answer)" + f"❯ {other_num_prefix}. Other (type below)" if cli_ref._clarify_freetext + else f"❯ {other_num_prefix}. Other (type your answer)" if selected == len(choices) + else f" {other_num_prefix}. Other (type your answer)" ) - preview_lines.extend(_wrap_panel_text(other_label, 60, subsequent_indent=" ")) + preview_lines.extend(_wrap_panel_text(other_label, 60, subsequent_indent=" ")) box_width = _panel_box_width("Hermes needs your input", preview_lines) inner_text_width = max(8, box_width - 2) @@ -9291,18 +9954,35 @@ def _get_clarify_display(): choice_wrapped: list[tuple[int, str]] = [] if choices: for i, choice in enumerate(choices): - prefix = '❯ ' if i == selected and not cli_ref._clarify_freetext else ' ' - for wrapped in _wrap_panel_text(f"{prefix}{choice}", inner_text_width, subsequent_indent=" "): + # Show number prefix for quick selection (1-9 for items 1-9, 0 for 10th item) + if i < 9: + num_prefix = str(i + 1) + elif i == 9: + num_prefix = '0' + else: + num_prefix = ' ' + if i == selected and not cli_ref._clarify_freetext: + prefix = f'❯ {num_prefix}. ' + else: + prefix = f' {num_prefix}. ' + for wrapped in _wrap_panel_text(f"{prefix}{choice}", inner_text_width, subsequent_indent=" "): choice_wrapped.append((i, wrapped)) # Trailing Other row(s) other_idx = len(choices) + other_num = other_idx + 1 + if other_num < 10: + other_num_prefix = str(other_num) + elif other_num == 10: + other_num_prefix = '0' + else: + other_num_prefix = ' ' if selected == other_idx and not cli_ref._clarify_freetext: - other_label_mand = '❯ Other (type your answer)' + other_label_mand = f'❯ {other_num_prefix}. Other (type your answer)' elif cli_ref._clarify_freetext: - other_label_mand = '❯ Other (type below)' + other_label_mand = f'❯ {other_num_prefix}. Other (type below)' else: - other_label_mand = ' Other (type your answer)' - other_wrapped = _wrap_panel_text(other_label_mand, inner_text_width, subsequent_indent=" ") + other_label_mand = f' {other_num_prefix}. Other (type your answer)' + other_wrapped = _wrap_panel_text(other_label_mand, inner_text_width, subsequent_indent=" ") elif cli_ref._clarify_freetext: # Freetext-only mode: the guidance line takes the place of choices. other_wrapped = _wrap_panel_text( @@ -9367,6 +10047,15 @@ def _get_clarify_display(): # "Other" option (trailing row(s), only shown when choices exist) other_idx = len(choices) + # Calculate number prefix for "Other" option + other_num = other_idx + 1 + if other_num < 10: + other_num_prefix = str(other_num) + elif other_num == 10: + other_num_prefix = '0' + else: + other_num_prefix = ' ' + if selected == other_idx and not cli_ref._clarify_freetext: other_style = 'class:clarify-selected' elif cli_ref._clarify_freetext: @@ -9474,7 +10163,8 @@ def _get_model_picker_display(): if stage == "provider": title = "⚙ Model Picker — Select Provider" choices = [] - for p in state.get("providers") or []: + _providers = state.get("providers") + for p in _providers if isinstance(_providers, list) else []: count = p.get("total_models", len(p.get("models", []))) label = f"{p['name']} ({count} model{'s' if count != 1 else ''})" if p.get("is_current"): @@ -9494,6 +10184,22 @@ def _get_model_picker_display(): box_width = _panel_box_width(title, [hint] + choices, min_width=46, max_width=84) inner_text_width = max(8, box_width - 6) + selected = state.get("selected", 0) + + # Scrolling viewport: the panel renders into a Window with no max + # height, so without limiting visible items the bottom border and + # any items past the available terminal rows get clipped on long + # provider catalogs (e.g. Ollama Cloud's 36+ models). + try: + from prompt_toolkit.application import get_app + term_rows = get_app().output.get_size().rows + except Exception: + term_rows = shutil.get_terminal_size((100, 24)).lines + scroll_offset, visible = HermesCLI._compute_model_picker_viewport( + selected, state.get("_scroll_offset", 0), len(choices), term_rows, + ) + state["_scroll_offset"] = scroll_offset + lines = [] lines.append(('class:clarify-border', '╭─ ')) lines.append(('class:clarify-title', title)) @@ -9501,8 +10207,8 @@ def _get_model_picker_display(): _append_blank_panel_line(lines, 'class:clarify-border', box_width) _append_panel_line(lines, 'class:clarify-border', 'class:clarify-hint', hint, box_width) _append_blank_panel_line(lines, 'class:clarify-border', box_width) - selected = state.get("selected", 0) - for idx, choice in enumerate(choices): + for idx in range(scroll_offset, scroll_offset + visible): + choice = choices[idx] style = 'class:clarify-selected' if idx == selected else 'class:clarify-choice' prefix = '❯ ' if idx == selected else ' ' for wrapped in _wrap_panel_text(prefix + choice, inner_text_width, subsequent_indent=' '): @@ -9715,22 +10421,20 @@ def _resize_clear_ghosts(): app._on_resize = _resize_clear_ghosts def spinner_loop(): - import time as _time - last_idle_refresh = 0.0 while not self._should_exit: if not self._app: - _time.sleep(0.1) + time.sleep(0.1) continue if self._command_running: self._invalidate(min_interval=0.1) - _time.sleep(0.1) + time.sleep(0.1) else: - now = _time.monotonic() + now = time.monotonic() if now - last_idle_refresh >= 1.0: last_idle_refresh = now self._invalidate(min_interval=1.0) - _time.sleep(0.2) + time.sleep(0.2) spinner_thread = threading.Thread(target=spinner_loop, daemon=True) spinner_thread.start() @@ -9799,49 +10503,12 @@ def process_loop(): continue # Expand paste references back to full content - import re as _re - _paste_ref_re = _re.compile(r'\[Pasted text #\d+: \d+ lines \u2192 (.+?)\]') + _paste_ref_re = re.compile(r'\[Pasted text #\d+: \d+ lines \u2192 (.+?)\]') paste_refs = list(_paste_ref_re.finditer(user_input)) if isinstance(user_input, str) else [] if paste_refs: - def _expand_ref(m): - p = Path(m.group(1)) - return p.read_text(encoding="utf-8") if p.exists() else m.group(0) - expanded = _paste_ref_re.sub(_expand_ref, user_input) - total_lines = expanded.count('\n') + 1 - n_pastes = len(paste_refs) - _user_bar = f"[{_accent_hex()}]{'─' * 40}[/]" - print() - ChatConsole().print(_user_bar) - # Show any surrounding user text alongside the paste summary - split_parts = _paste_ref_re.split(user_input) - visible_user_text = " ".join( - split_parts[i].strip() for i in range(0, len(split_parts), 2) if split_parts[i].strip() - ) - if visible_user_text: - ChatConsole().print( - f"[bold {_accent_hex()}]\u25cf[/] [bold]{_escape(visible_user_text)}[/] " - f"[dim]({n_pastes} pasted block{'s' if n_pastes > 1 else ''}, {total_lines} lines total)[/]" - ) - else: - ChatConsole().print( - f"[bold {_accent_hex()}]\u25cf[/] [bold]{_escape(f'[Pasted text: {total_lines} lines]')}[/]" - ) - user_input = expanded - else: - _user_bar = f"[{_accent_hex()}]{'─' * 40}[/]" - if '\n' in user_input: - first_line = user_input.split('\n')[0] - line_count = user_input.count('\n') + 1 - print() - ChatConsole().print(_user_bar) - ChatConsole().print( - f"[bold {_accent_hex()}]●[/] [bold]{_escape(first_line)}[/] " - f"[dim](+{line_count - 1} lines)[/]" - ) - else: - print() - ChatConsole().print(_user_bar) - ChatConsole().print(f"[bold {_accent_hex()}]●[/] [bold]{_escape(user_input)}[/]") + user_input = self._expand_paste_references(user_input) + print() + self._print_user_message_preview(user_input) # Show image attachment count if submit_images: @@ -9907,8 +10574,35 @@ def _restart_recording(): # Register signal handlers for graceful shutdown on SSH disconnect / SIGTERM def _signal_handler(signum, frame): - """Handle SIGHUP/SIGTERM by triggering graceful cleanup.""" + """Handle SIGHUP/SIGTERM by triggering graceful cleanup. + + Calls ``self.agent.interrupt()`` first so the agent daemon + thread's poll loop sees the per-thread interrupt and kills the + tool's subprocess group via ``_kill_process`` (os.killpg). + Without this, the main thread dies from KeyboardInterrupt and + the daemon thread is killed with it — before it can run one + more poll iteration to clean up the subprocess, which was + spawned with ``os.setsid`` and therefore survives as an orphan + with PPID=1. + + Grace window (``HERMES_SIGTERM_GRACE``, default 1.5 s) gives + the daemon time to: detect the interrupt (next 200 ms poll) → + call _kill_process (SIGTERM + 1 s wait + SIGKILL if needed) → + return from _wait_for_process. ``time.sleep`` releases the + GIL so the daemon actually runs during the window. + """ logger.debug("Received signal %s, triggering graceful shutdown", signum) + try: + if getattr(self, "agent", None) and getattr(self, "_agent_running", False): + self.agent.interrupt(f"received signal {signum}") + try: + _grace = float(os.getenv("HERMES_SIGTERM_GRACE", "1.5")) + except (TypeError, ValueError): + _grace = 1.5 + if _grace > 0: + time.sleep(_grace) + except Exception: + pass # never block signal handling raise KeyboardInterrupt() try: @@ -9939,8 +10633,7 @@ def _suppress_closed_loop_errors(loop, context): # uv-managed Python, fd 0 can be invalid or unregisterable with the # asyncio selector, causing "KeyError: '0 is not registered'" (#6393). try: - import os as _os - _os.fstat(0) + os.fstat(0) except OSError: print( "Error: stdin (fd 0) is not available.\n" @@ -10211,6 +10904,44 @@ def main( # Register cleanup for single-query mode (interactive mode registers in run()) atexit.register(_run_cleanup) + + # Also install signal handlers in single-query / `-q` mode. Interactive + # mode registers its own inside HermesCLI.run(), but `-q` runs + # cli.agent.run_conversation() below and AIAgent spawns worker threads + # for tools — so when SIGTERM arrives on the main thread, raising + # KeyboardInterrupt only unwinds the main thread, not the worker + # running _wait_for_process. Python then exits, the child subprocess + # (spawned with os.setsid, its own process group) is reparented to + # init and keeps running as an orphan. + # + # Fix: route SIGTERM/SIGHUP through agent.interrupt() which sets the + # per-thread interrupt flag the worker's poll loop checks every 200 ms. + # Give the worker a grace window to call _kill_process (SIGTERM to the + # process group, then SIGKILL after 1 s), then raise KeyboardInterrupt + # so main unwinds normally. HERMES_SIGTERM_GRACE overrides the 1.5 s + # default for debugging. + def _signal_handler_q(signum, frame): + logger.debug("Received signal %s in single-query mode", signum) + try: + _agent = getattr(cli, "agent", None) + if _agent is not None: + _agent.interrupt(f"received signal {signum}") + try: + _grace = float(os.getenv("HERMES_SIGTERM_GRACE", "1.5")) + except (TypeError, ValueError): + _grace = 1.5 + if _grace > 0: + time.sleep(_grace) + except Exception: + pass # never block signal handling + raise KeyboardInterrupt() + try: + import signal as _signal + _signal.signal(_signal.SIGTERM, _signal_handler_q) + if hasattr(_signal, "SIGHUP"): + _signal.signal(_signal.SIGHUP, _signal_handler_q) + except Exception: + pass # signal handler may fail in restricted environments # Handle single query mode if query or image: @@ -10233,7 +10964,6 @@ def main( if cli._init_agent( model_override=turn_route["model"], runtime_override=turn_route["runtime"], - route_label=turn_route["label"], request_overrides=turn_route.get("request_overrides"), ): cli.agent.quiet_mode = True @@ -10247,6 +10977,15 @@ def main( user_message=effective_query, conversation_history=cli.conversation_history, ) + # Sync session_id if mid-run compression created a + # continuation session. The exit line below reports + # session_id to stderr for automation wrappers; without + # this sync it would point at the ended parent. + if ( + getattr(cli.agent, "session_id", None) + and cli.agent.session_id != cli.session_id + ): + cli.session_id = cli.agent.session_id response = result.get("final_response", "") if isinstance(result, dict) else str(result) if response: print(response) diff --git a/cron/scheduler.py b/cron/scheduler.py index 9a0f561b05c1..2f5a0ce5729a 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -1,4 +1,6 @@ """ +from __future__ import annotations + Cron job scheduler - executes due jobs. Provides tick() which checks for due jobs and runs them. The gateway diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 82d09f3a820b..e9f519da64dd 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -1,4 +1,6 @@ """ +from __future__ import annotations + Base platform adapter interface. All platform adapters (Telegram, Discord, WhatsApp) inherit from this diff --git a/gateway/platforms/dingtalk.py b/gateway/platforms/dingtalk.py index 424c7af52b4a..f08e45dd2116 100644 --- a/gateway/platforms/dingtalk.py +++ b/gateway/platforms/dingtalk.py @@ -1,4 +1,6 @@ """ +from __future__ import annotations + DingTalk platform adapter using Stream Mode. Uses dingtalk-stream SDK for real-time message reception without webhooks. diff --git a/gateway/platforms/sms.py b/gateway/platforms/sms.py index 161949dab3de..e3768e95a4cd 100644 --- a/gateway/platforms/sms.py +++ b/gateway/platforms/sms.py @@ -1,5 +1,7 @@ """SMS (Twilio) platform adapter. +from __future__ import annotations + Connects to the Twilio REST API for outbound SMS and runs an aiohttp webhook server to receive inbound messages. diff --git a/gateway/run.py b/gateway/run.py index ba7ea43ad46a..591cc957f99b 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1,4 +1,6 @@ """ +from __future__ import annotations + Gateway runner - entry point for messaging platform integrations. This module provides: diff --git a/hermes_cli/claw.py b/hermes_cli/claw.py index e62efe47ea38..42a9a7ebd249 100644 --- a/hermes_cli/claw.py +++ b/hermes_cli/claw.py @@ -1,5 +1,7 @@ """hermes claw — OpenClaw migration commands. +from __future__ import annotations + Usage: hermes claw migrate # Preview then migrate (always shows preview first) hermes claw migrate --dry-run # Preview only, no changes diff --git a/hermes_cli/cli_output.py b/hermes_cli/cli_output.py index 2f07129704e8..7009fbd3595f 100644 --- a/hermes_cli/cli_output.py +++ b/hermes_cli/cli_output.py @@ -1,5 +1,7 @@ """Shared CLI output helpers for Hermes CLI modules. +from __future__ import annotations + Extracts the identical ``print_info/success/warning/error`` and ``prompt()`` functions previously duplicated across setup.py, tools_config.py, mcp_config.py, and memory_setup.py. diff --git a/hermes_cli/clipboard.py b/hermes_cli/clipboard.py index fd81ed4c8b97..520c84ade0cb 100644 --- a/hermes_cli/clipboard.py +++ b/hermes_cli/clipboard.py @@ -1,5 +1,7 @@ """Clipboard image extraction for macOS, Windows, Linux, and WSL2. +from __future__ import annotations + Provides a single function `save_clipboard_image(dest)` that checks the system clipboard for image data, saves it to *dest* as PNG, and returns True on success. No external Python dependencies — uses only OS-level diff --git a/hermes_cli/curses_ui.py b/hermes_cli/curses_ui.py index b05295f1e61d..90379e4607c7 100644 --- a/hermes_cli/curses_ui.py +++ b/hermes_cli/curses_ui.py @@ -1,5 +1,7 @@ """Shared curses-based UI components for Hermes CLI. +from __future__ import annotations + Used by `hermes tools` and `hermes skills` for interactive checklists. Provides a curses multi-select with keyboard navigation, plus a text-based numbered fallback for terminals without curses support. diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index d010a601d597..d4a6ee1587c1 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -1,4 +1,6 @@ """ +from __future__ import annotations + Gateway subcommand for hermes CLI. Handles: hermes gateway [run|start|stop|restart|status|install|uninstall|setup] diff --git a/hermes_cli/logs.py b/hermes_cli/logs.py index 9a829a4bdc54..3504877f1e3a 100644 --- a/hermes_cli/logs.py +++ b/hermes_cli/logs.py @@ -1,5 +1,7 @@ """``hermes logs`` — view and filter Hermes log files. +from __future__ import annotations + Supports tailing, following, session filtering, level filtering, component filtering, and relative time ranges. All log files live under ``~/.hermes/logs/``. diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 3d6c67c2d48c..e527c6de8aa0 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -84,6 +84,58 @@ def is_nous_hermes_non_agentic(model_name: str) -> bool: return bool(_NOUS_HERMES_NON_AGENTIC_RE.search(model_name)) +def _find_model_in_custom_providers( + model_input: str, + custom_providers: list | None, +) -> tuple[str, str, str] | None: + """Search custom_providers for a matching model. + + Args: + model_input: The model name to search for. + custom_providers: List of custom provider dicts from config. + + Returns: + (provider_slug, model_id, model_input) if found, else None. + """ + if not custom_providers: + return None + + model_lower = model_input.strip().lower() + # Normalize dots to hyphens for matching (claude-sonnet-4.6 -> claude-sonnet-4-6) + model_normalized = model_lower.replace(".", "-") + + for cp in custom_providers: + if not isinstance(cp, dict): + continue + + cp_slug = cp.get("id", "") + cp_models = cp.get("models", {}) + + # models can be a dict {model_id: model_info} or a list [model_id, ...] + if isinstance(cp_models, dict): + model_ids = list(cp_models.keys()) + elif isinstance(cp_models, list): + model_ids = cp_models + else: + continue + + for mid in model_ids: + mid_lower = str(mid).lower() + # Case-insensitive match + if mid_lower == model_lower: + return (cp_slug, mid, model_input) + # Normalized match (dots <-> hyphens) + if mid_lower.replace(".", "-") == model_normalized: + return (cp_slug, mid, model_input) + # Bare name match (for vendor/model format) + if "/" in mid_lower: + _, bare = mid_lower.split("/", 1) + if bare == model_lower or bare.replace(".", "-") == model_normalized: + return (cp_slug, mid, model_input) + + return None + + def _check_hermes_model_warning(model_name: str) -> str: """Return a warning string if *model_name* is a Nous Hermes 3/4 chat model.""" if is_nous_hermes_non_agentic(model_name): @@ -578,6 +630,19 @@ def switch_model( ), ) else: + # --- Step c2: Search custom_providers catalog --- + if custom_providers: + custom_match = _find_model_in_custom_providers(raw_input, custom_providers) + if custom_match is not None: + target_provider, new_model, _ = custom_match + logger.debug( + "Model '%s' found in custom provider '%s' as '%s'", + raw_input, target_provider, new_model, + ) + # Skip aggregator conversion and catalog search + # since we already found the model + pass + # --- Step c: On aggregator, convert vendor:model to vendor/model --- # Only convert when there's no slash — a slash means the name # is already in vendor/model format and the colon is a variant @@ -613,8 +678,11 @@ def switch_model( # --- Step e: detect_provider_for_model() as last resort --- _base = current_base_url or "" - is_custom = current_provider in ("custom", "local") or ( - "localhost" in _base or "127.0.0.1" in _base + is_custom = ( + current_provider in ("custom", "local") + or current_provider.startswith("custom:") + or "localhost" in _base + or "127.0.0.1" in _base ) if ( @@ -692,12 +760,12 @@ def switch_model( api_key=api_key, base_url=base_url, ) - except Exception: + except Exception as e: validation = { - "accepted": True, - "persist": True, + "accepted": False, + "persist": False, "recognized": False, - "message": None, + "message": f"Could not validate `{new_model}`: {e}", } if not validation.get("accepted"): @@ -1035,21 +1103,49 @@ def list_authenticated_providers( seen_slugs.add(_cp.slug.lower()) # --- 3. User-defined endpoints from config --- + # Track (name, base_url) of what section 3 emits so section 4 can skip + # any overlapping ``custom_providers:`` entries. Callers typically pass + # both (gateway/CLI invoke ``get_compatible_custom_providers()`` which + # merges ``providers:`` into the list) — without this, the same endpoint + # produces two picker rows: one bare-slug ("openrouter") from section 3 + # and one "custom:openrouter" from section 4, both labelled identically. + _section3_emitted_pairs: set = set() if user_providers and isinstance(user_providers, dict): for ep_name, ep_cfg in user_providers.items(): if not isinstance(ep_cfg, dict): continue + # Skip if this slug was already emitted (e.g. canonical provider + # with the same name) or will be picked up by section 4. + if ep_name.lower() in seen_slugs: + continue display_name = ep_cfg.get("name", "") or ep_name - api_url = ep_cfg.get("api", "") or ep_cfg.get("url", "") or "" - default_model = ep_cfg.get("default_model", "") + # ``base_url`` is Hermes's canonical write key (matches + # custom_providers and _save_custom_provider); ``api`` / ``url`` + # remain as fallbacks for hand-edited / legacy configs. + api_url = ( + ep_cfg.get("base_url", "") + or ep_cfg.get("api", "") + or ep_cfg.get("url", "") + or "" + ) + # ``default_model`` is the legacy key; ``model`` matches what + # custom_providers entries use, so accept either. + default_model = ep_cfg.get("default_model", "") or ep_cfg.get("model", "") # Build models list from both default_model and full models array models_list = [] if default_model: models_list.append(default_model) - # Also include the full models list from config + # Also include the full models list from config. + # Hermes writes ``models:`` as a dict keyed by model id + # (see hermes_cli/main.py::_save_custom_provider); older + # configs or hand-edited files may still use a list. cfg_models = ep_cfg.get("models", []) - if isinstance(cfg_models, list): + if isinstance(cfg_models, dict): + for m in cfg_models: + if m and m not in models_list: + models_list.append(m) + elif isinstance(cfg_models, list): for m in cfg_models: if m and m not in models_list: models_list.append(m) @@ -1066,6 +1162,14 @@ def list_authenticated_providers( "source": "user-config", "api_url": api_url, }) + seen_slugs.add(ep_name.lower()) + seen_slugs.add(custom_provider_slug(display_name).lower()) + _pair = ( + str(display_name).strip().lower(), + str(api_url).strip().rstrip("/").lower(), + ) + if _pair[0] and _pair[1]: + _section3_emitted_pairs.add(_pair) # --- 4. Saved custom providers from config --- # Each ``custom_providers`` entry represents one model under a named @@ -1100,13 +1204,41 @@ def list_authenticated_providers( "api_url": api_url, "models": [], } + # The singular ``model:`` field only holds the currently + # active model. Hermes's own writer (main.py::_save_custom_provider) + # stores every configured model as a dict under ``models:``; + # downstream readers (agent/models_dev.py, gateway/run.py, + # run_agent.py, hermes_cli/config.py) already consume that dict. + # The /model picker previously ignored it, so multi-model + # custom providers appeared to have only the active model. default_model = (entry.get("model") or "").strip() if default_model and default_model not in groups[slug]["models"]: groups[slug]["models"].append(default_model) + cfg_models = entry.get("models", {}) + if isinstance(cfg_models, dict): + for m in cfg_models: + if m and m not in groups[slug]["models"]: + groups[slug]["models"].append(m) + elif isinstance(cfg_models, list): + for m in cfg_models: + if m and m not in groups[slug]["models"]: + groups[slug]["models"].append(m) + for slug, grp in groups.items(): if slug.lower() in seen_slugs: continue + # Skip if section 3 already emitted this endpoint under its + # ``providers:`` dict key — matches on (display_name, base_url), + # the tuple section 4 groups by. Prevents two picker rows + # labelled identically when callers pass both ``user_providers`` + # and a compatibility-merged ``custom_providers`` list. + _pair_key = ( + str(grp["name"]).strip().lower(), + str(grp["api_url"]).strip().rstrip("/").lower(), + ) + if _pair_key[0] and _pair_key[1] and _pair_key in _section3_emitted_pairs: + continue results.append({ "slug": slug, "name": grp["name"], diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 8f23980acfc4..98cfa1f505fd 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -16,6 +16,12 @@ from pathlib import Path from typing import Any, NamedTuple, Optional +from hermes_cli import __version__ as _HERMES_VERSION + +# Identify ourselves so endpoints fronted by Cloudflare's Browser Integrity +# Check (error 1010) don't reject the default ``Python-urllib/*`` signature. +_HERMES_USER_AGENT = f"hermes-cli/{_HERMES_VERSION}" + COPILOT_BASE_URL = "https://api.githubcopilot.com" COPILOT_MODELS_URL = f"{COPILOT_BASE_URL}/models" COPILOT_EDITOR_VERSION = "vscode/1.104.1" @@ -26,7 +32,8 @@ # Fallback OpenRouter snapshot used when the live catalog is unavailable. # (model_id, display description shown in menus) OPENROUTER_MODELS: list[tuple[str, str]] = [ - ("anthropic/claude-opus-4.7", "recommended"), + ("moonshotai/kimi-k2.6", "recommended"), + ("anthropic/claude-opus-4.7", ""), ("anthropic/claude-opus-4.6", ""), ("anthropic/claude-sonnet-4.6", ""), ("qwen/qwen3.6-plus", ""), @@ -49,7 +56,6 @@ ("z-ai/glm-5.1", ""), ("z-ai/glm-5v-turbo", ""), ("z-ai/glm-5-turbo", ""), - ("moonshotai/kimi-k2.5", ""), ("x-ai/grok-4.20", ""), ("nvidia/nemotron-3-super-120b-a12b", ""), ("nvidia/nemotron-3-super-120b-a12b:free", "free"), @@ -62,6 +68,31 @@ _openrouter_catalog_cache: list[tuple[str, str]] | None = None +# Fallback Vercel AI Gateway snapshot used when the live catalog is unavailable. +# OSS / open-weight models prioritized first, then closed-source by family. +# Slugs match Vercel's actual /v1/models catalog (e.g. alibaba/ for Qwen, +# zai/ and xai/ without hyphens). +VERCEL_AI_GATEWAY_MODELS: list[tuple[str, str]] = [ + ("moonshotai/kimi-k2.6", "recommended"), + ("alibaba/qwen3.6-plus", ""), + ("zai/glm-5.1", ""), + ("minimax/minimax-m2.7", ""), + ("anthropic/claude-sonnet-4.6", ""), + ("anthropic/claude-opus-4.7", ""), + ("anthropic/claude-opus-4.6", ""), + ("anthropic/claude-haiku-4.5", ""), + ("openai/gpt-5.4", ""), + ("openai/gpt-5.4-mini", ""), + ("openai/gpt-5.3-codex", ""), + ("google/gemini-3.1-pro-preview", ""), + ("google/gemini-3-flash", ""), + ("google/gemini-3.1-flash-lite-preview", ""), + ("xai/grok-4.20-reasoning", ""), +] + +_ai_gateway_catalog_cache: list[tuple[str, str]] | None = None + + def _codex_curated_models() -> list[str]: """Derive the openai-codex curated list from codex_models.py. @@ -75,6 +106,7 @@ def _codex_curated_models() -> list[str]: _PROVIDER_MODELS: dict[str, list[str]] = { "nous": [ + "moonshotai/kimi-k2.6", "xiaomi/mimo-v2-pro", "anthropic/claude-opus-4.7", "anthropic/claude-opus-4.6", @@ -96,7 +128,6 @@ def _codex_curated_models() -> list[str]: "z-ai/glm-5.1", "z-ai/glm-5v-turbo", "z-ai/glm-5-turbo", - "moonshotai/kimi-k2.5", "x-ai/grok-4.20-beta", "nvidia/nemotron-3-super-120b-a12b", "nvidia/nemotron-3-super-120b-a12b:free", @@ -128,19 +159,14 @@ def _codex_curated_models() -> list[str]: ], "gemini": [ "gemini-3.1-pro-preview", + "gemini-3-pro-preview", "gemini-3-flash-preview", "gemini-3.1-flash-lite-preview", - "gemini-2.5-pro", - "gemini-2.5-flash", - "gemini-2.5-flash-lite", - # Gemma open models (also served via AI Studio) - "gemma-4-31b-it", - "gemma-4-26b-it", ], "google-gemini-cli": [ - "gemini-2.5-pro", - "gemini-2.5-flash", - "gemini-2.5-flash-lite", + "gemini-3.1-pro-preview", + "gemini-3-pro-preview", + "gemini-3-flash-preview", ], "zai": [ "glm-5.1", @@ -155,21 +181,38 @@ def _codex_curated_models() -> list[str]: "grok-4.20-reasoning", "grok-4-1-fast-reasoning", ], + "nvidia": [ + # NVIDIA flagship reasoning models + "nvidia/nemotron-3-super-120b-a12b", + "nvidia/nemotron-3-nano-30b-a3b", + "nvidia/llama-3.3-nemotron-super-49b-v1.5", + # Third-party agentic models hosted on build.nvidia.com + # (map to OpenRouter defaults — users get familiar picks on NIM) + "qwen/qwen3.5-397b-a17b", + "deepseek-ai/deepseek-v3.2", + "moonshotai/kimi-k2.6", + "minimaxai/minimax-m2.5", + "z-ai/glm5", + "openai/gpt-oss-120b", + ], "kimi-coding": [ - "kimi-for-coding", + "kimi-k2.6", "kimi-k2.5", + "kimi-for-coding", "kimi-k2-thinking", "kimi-k2-thinking-turbo", "kimi-k2-turbo-preview", "kimi-k2-0905-preview", ], "kimi-coding-cn": [ + "kimi-k2.6", "kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview", "kimi-k2-0905-preview", ], "moonshot": [ + "kimi-k2.6", "kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview", @@ -212,10 +255,10 @@ def _codex_curated_models() -> list[str]: "trinity-mini", ], "opencode-zen": [ + "kimi-k2.5", "gpt-5.4-pro", "gpt-5.4", "gpt-5.3-codex", - "gpt-5.3-codex-spark", "gpt-5.2", "gpt-5.2-codex", "gpt-5.1", @@ -243,34 +286,22 @@ def _codex_curated_models() -> list[str]: "glm-5", "glm-4.7", "glm-4.6", - "kimi-k2.5", "kimi-k2-thinking", "kimi-k2", "qwen3-coder", "big-pickle", ], "opencode-go": [ + "kimi-k2.6", + "kimi-k2.5", "glm-5.1", "glm-5", - "kimi-k2.5", "mimo-v2-pro", "mimo-v2-omni", "minimax-m2.7", "minimax-m2.5", - ], - "ai-gateway": [ - "anthropic/claude-opus-4.6", - "anthropic/claude-sonnet-4.6", - "anthropic/claude-sonnet-4.5", - "anthropic/claude-haiku-4.5", - "openai/gpt-5", - "openai/gpt-4.1", - "openai/gpt-4.1-mini", - "google/gemini-3-pro-preview", - "google/gemini-3-flash", - "google/gemini-2.5-pro", - "google/gemini-2.5-flash", - "deepseek/deepseek-v3.2", + "qwen3.6-plus", + "qwen3.5-plus", ], "kilocode": [ "anthropic/claude-opus-4.6", @@ -285,25 +316,26 @@ def _codex_curated_models() -> list[str]: # to https://dashscope-intl.aliyuncs.com/compatible-mode/v1 (OpenAI-compat) # or https://dashscope-intl.aliyuncs.com/apps/anthropic (Anthropic-compat). "alibaba": [ + "kimi-k2.5", "qwen3.5-plus", "qwen3-coder-plus", "qwen3-coder-next", # Third-party models available on coding-intl "glm-5", "glm-4.7", - "kimi-k2.5", "MiniMax-M2.5", ], # Curated HF model list — only agentic models that map to OpenRouter defaults. "huggingface": [ + "moonshotai/Kimi-K2.5", "Qwen/Qwen3.5-397B-A17B", "Qwen/Qwen3.5-35B-A3B", "deepseek-ai/DeepSeek-V3.2", - "moonshotai/Kimi-K2.5", "MiniMaxAI/MiniMax-M2.5", "zai-org/GLM-5", "XiaomiMiMo/MiMo-V2-Flash", "moonshotai/Kimi-K2-Thinking", + "moonshotai/Kimi-K2.6", ], # AWS Bedrock — static fallback list used when dynamic discovery is # unavailable (no boto3, no credentials, or API error). The agent @@ -323,6 +355,12 @@ def _codex_curated_models() -> list[str]: ], } +# Vercel AI Gateway: derive the bare-model-id catalog from the curated +# ``VERCEL_AI_GATEWAY_MODELS`` snapshot so both the picker (tuples with descriptions) +# and the static fallback catalog (bare ids) stay in sync from a single +# source of truth. +_PROVIDER_MODELS["ai-gateway"] = [mid for mid, _ in VERCEL_AI_GATEWAY_MODELS] + # --------------------------------------------------------------------------- # Nous Portal free-model filtering # --------------------------------------------------------------------------- @@ -480,8 +518,6 @@ def check_nous_free_tier() -> bool: Returns False (assume paid) on any error — never blocks paying users. """ global _free_tier_cache - import time - now = time.monotonic() if _free_tier_cache is not None: cached_result, cached_at = _free_tier_cache @@ -533,14 +569,16 @@ class ProviderEntry(NamedTuple): CANONICAL_PROVIDERS: list[ProviderEntry] = [ ProviderEntry("nous", "Nous Portal", "Nous Portal (Nous Research subscription)"), ProviderEntry("openrouter", "OpenRouter", "OpenRouter (100+ models, pay-per-use)"), + ProviderEntry("ai-gateway", "Vercel AI Gateway", "Vercel AI Gateway (200+ models, $5 free credit, no markup)"), ProviderEntry("anthropic", "Anthropic", "Anthropic (Claude models — API key or Claude Code)"), ProviderEntry("openai-codex", "OpenAI Codex", "OpenAI Codex"), ProviderEntry("xiaomi", "Xiaomi MiMo", "Xiaomi MiMo (MiMo-V2 models — pro, omni, flash)"), + ProviderEntry("nvidia", "NVIDIA NIM", "NVIDIA NIM (Nemotron models — build.nvidia.com or local NIM)"), ProviderEntry("qwen-oauth", "Qwen OAuth (Portal)", "Qwen OAuth (reuses local Qwen CLI login)"), ProviderEntry("copilot", "GitHub Copilot", "GitHub Copilot (uses GITHUB_TOKEN or gh auth token)"), ProviderEntry("copilot-acp", "GitHub Copilot ACP", "GitHub Copilot ACP (spawns `copilot --acp --stdio`)"), ProviderEntry("huggingface", "Hugging Face", "Hugging Face Inference Providers (20+ open models)"), - ProviderEntry("gemini", "Google AI Studio", "Google AI Studio (Gemini models — OpenAI-compatible endpoint)"), + ProviderEntry("gemini", "Google AI Studio", "Google AI Studio (Gemini models — native Gemini API)"), ProviderEntry("google-gemini-cli", "Google Gemini (OAuth)", "Google Gemini via OAuth + Code Assist (free tier supported; no API key needed)"), ProviderEntry("deepseek", "DeepSeek", "DeepSeek (DeepSeek-V3, R1, coder — direct API)"), ProviderEntry("xai", "xAI", "xAI (Grok models — direct API)"), @@ -555,7 +593,6 @@ class ProviderEntry(NamedTuple): ProviderEntry("kilocode", "Kilo Code", "Kilo Code (Kilo Gateway API)"), ProviderEntry("opencode-zen", "OpenCode Zen", "OpenCode Zen (35+ curated models, pay-as-you-go)"), ProviderEntry("opencode-go", "OpenCode Go", "OpenCode Go (open models, $10/month subscription)"), - ProviderEntry("ai-gateway", "Vercel AI Gateway", "Vercel AI Gateway (200+ models, pay-per-use)"), ProviderEntry("bedrock", "AWS Bedrock", "AWS Bedrock (Claude, Nova, Llama, DeepSeek — IAM or API key)"), ] @@ -618,6 +655,10 @@ class ProviderEntry(NamedTuple): "grok": "xai", "x-ai": "xai", "x.ai": "xai", + "nim": "nvidia", + "nvidia-nim": "nvidia", + "build-nvidia": "nvidia", + "nemotron": "nvidia", "ollama": "custom", # bare "ollama" = local; use "ollama-cloud" for cloud "ollama_cloud": "ollama-cloud", } @@ -647,6 +688,31 @@ def _openrouter_model_is_free(pricing: Any) -> bool: return False +def _openrouter_model_supports_tools(item: Any) -> bool: + """Return True when the model's ``supported_parameters`` advertise tool calling. + + hermes-agent is tool-calling-first — every provider path assumes the model + can invoke tools. Models that don't advertise ``tools`` in their + ``supported_parameters`` (e.g. image-only or completion-only models) cannot + be driven by the agent loop and would fail at the first tool call. + + **Permissive when the field is missing.** Some OpenRouter-compatible gateways + (Nous Portal, private mirrors, older catalog snapshots) don't populate + ``supported_parameters`` at all. Treat that as "unknown capability → allow" + so the picker doesn't silently empty for those users. Only hide models + whose ``supported_parameters`` is an explicit list that omits ``tools``. + + Ported from Kilo-Org/kilocode#9068. + """ + if not isinstance(item, dict): + return True + params = item.get("supported_parameters") + if not isinstance(params, list): + # Field absent / malformed / None — be permissive. + return True + return "tools" in params + + def fetch_openrouter_models( timeout: float = 8.0, *, @@ -689,6 +755,11 @@ def fetch_openrouter_models( live_item = live_by_id.get(preferred_id) if live_item is None: continue + # Hide models that don't advertise tool-calling support — hermes-agent + # requires it and surfacing them leads to immediate runtime failures + # when the user selects them. Ported from Kilo-Org/kilocode#9068. + if not _openrouter_model_supports_tools(live_item): + continue desc = "free" if _openrouter_model_is_free(live_item.get("pricing")) else "" curated.append((preferred_id, desc)) @@ -706,6 +777,93 @@ def model_ids(*, force_refresh: bool = False) -> list[str]: return [mid for mid, _ in fetch_openrouter_models(force_refresh=force_refresh)] +def _ai_gateway_model_is_free(pricing: Any) -> bool: + """Return True if an AI Gateway model has $0 input AND output pricing.""" + if not isinstance(pricing, dict): + return False + try: + return float(pricing.get("input", "0")) == 0 and float(pricing.get("output", "0")) == 0 + except (TypeError, ValueError): + return False + + +def fetch_ai_gateway_models( + timeout: float = 8.0, + *, + force_refresh: bool = False, +) -> list[tuple[str, str]]: + """Return the curated AI Gateway picker list, refreshed from the live catalog when possible.""" + global _ai_gateway_catalog_cache + + if _ai_gateway_catalog_cache is not None and not force_refresh: + return list(_ai_gateway_catalog_cache) + + from hermes_constants import AI_GATEWAY_BASE_URL + + fallback = list(VERCEL_AI_GATEWAY_MODELS) + preferred_ids = [mid for mid, _ in fallback] + + try: + req = urllib.request.Request( + f"{AI_GATEWAY_BASE_URL.rstrip('/')}/models", + headers={"Accept": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=timeout) as resp: + payload = json.loads(resp.read().decode()) + except Exception: + return list(_ai_gateway_catalog_cache or fallback) + + live_items = payload.get("data", []) + if not isinstance(live_items, list): + return list(_ai_gateway_catalog_cache or fallback) + + live_by_id: dict[str, dict[str, Any]] = {} + for item in live_items: + if not isinstance(item, dict): + continue + mid = str(item.get("id") or "").strip() + if not mid: + continue + live_by_id[mid] = item + + curated: list[tuple[str, str]] = [] + for preferred_id in preferred_ids: + live_item = live_by_id.get(preferred_id) + if live_item is None: + continue + desc = "free" if _ai_gateway_model_is_free(live_item.get("pricing")) else "" + curated.append((preferred_id, desc)) + + if not curated: + return list(_ai_gateway_catalog_cache or fallback) + + # If the live catalog offers a free Moonshot model, auto-promote it to + # position #1 as "recommended" — dynamic discovery without a PR. + free_moonshot = next( + ( + mid + for mid, item in live_by_id.items() + if mid.startswith("moonshotai/") + and _ai_gateway_model_is_free(item.get("pricing")) + ), + None, + ) + if free_moonshot: + curated = [(mid, desc) for mid, desc in curated if mid != free_moonshot] + curated.insert(0, (free_moonshot, "recommended")) + else: + first_id, _ = curated[0] + curated[0] = (first_id, "recommended") + + _ai_gateway_catalog_cache = curated + return list(curated) + + +def ai_gateway_model_ids(*, force_refresh: bool = False) -> list[str]: + """Return just the AI Gateway model-id strings.""" + return [mid for mid, _ in fetch_ai_gateway_models(force_refresh=force_refresh)] + + # --------------------------------------------------------------------------- @@ -850,6 +1008,56 @@ def fetch_models_with_pricing( return result +def fetch_ai_gateway_pricing( + timeout: float = 8.0, + *, + force_refresh: bool = False, +) -> dict[str, dict[str, str]]: + """Fetch Vercel AI Gateway /v1/models and return hermes-shaped pricing. + + Vercel uses ``input`` / ``output`` field names; hermes's picker expects + ``prompt`` / ``completion``. This translates. Cache read/write field names + already match. + """ + from hermes_constants import AI_GATEWAY_BASE_URL + + cache_key = AI_GATEWAY_BASE_URL.rstrip("/") + if not force_refresh and cache_key in _pricing_cache: + return _pricing_cache[cache_key] + + try: + req = urllib.request.Request( + f"{cache_key}/models", + headers={"Accept": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=timeout) as resp: + payload = json.loads(resp.read().decode()) + except Exception: + _pricing_cache[cache_key] = {} + return {} + + result: dict[str, dict[str, str]] = {} + for item in payload.get("data", []): + if not isinstance(item, dict): + continue + mid = item.get("id") + pricing = item.get("pricing") + if not (mid and isinstance(pricing, dict)): + continue + entry: dict[str, str] = { + "prompt": str(pricing.get("input", "")), + "completion": str(pricing.get("output", "")), + } + if pricing.get("input_cache_read"): + entry["input_cache_read"] = str(pricing["input_cache_read"]) + if pricing.get("input_cache_write"): + entry["input_cache_write"] = str(pricing["input_cache_write"]) + result[mid] = entry + + _pricing_cache[cache_key] = result + return result + + def _resolve_openrouter_api_key() -> str: """Best-effort OpenRouter API key for pricing fetch.""" return os.getenv("OPENROUTER_API_KEY", "").strip() @@ -868,7 +1076,7 @@ def _resolve_nous_pricing_credentials() -> tuple[str, str]: def get_pricing_for_provider(provider: str, *, force_refresh: bool = False) -> dict[str, dict[str, str]]: - """Return live pricing for providers that support it (openrouter, nous).""" + """Return live pricing for providers that support it (openrouter, nous, ai-gateway).""" normalized = normalize_provider(provider) if normalized == "openrouter": return fetch_models_with_pricing( @@ -876,6 +1084,8 @@ def get_pricing_for_provider(provider: str, *, force_refresh: bool = False) -> d base_url="https://openrouter.ai/api", force_refresh=force_refresh, ) + if normalized == "ai-gateway": + return fetch_ai_gateway_pricing(force_refresh=force_refresh) if normalized == "nous": api_key, base_url = _resolve_nous_pricing_credentials() if base_url: @@ -1080,7 +1290,6 @@ def detect_provider_for_model( from hermes_cli.auth import PROVIDER_REGISTRY pconfig = PROVIDER_REGISTRY.get(direct_match) if pconfig: - import os for env_var in pconfig.api_key_env_vars: if os.getenv(env_var, "").strip(): has_creds = True @@ -1160,6 +1369,9 @@ def normalize_provider(provider: Optional[str]) -> str: provider based on credentials and environment. """ normalized = (provider or "openrouter").strip().lower() + # Handle custom:* slugs -> normalize to "custom" + if normalized.startswith("custom:"): + return "custom" return _PROVIDER_ALIASES.get(normalized, normalized) @@ -1488,6 +1700,19 @@ def _fetch_github_models(api_key: Optional[str] = None, timeout: float = 5.0) -> "anthropic/claude-sonnet-4.6": "claude-sonnet-4.6", "anthropic/claude-sonnet-4.5": "claude-sonnet-4.5", "anthropic/claude-haiku-4.5": "claude-haiku-4.5", + # Dash-notation fallbacks: Hermes' default Claude IDs elsewhere use + # hyphens (anthropic native format), but Copilot's API only accepts + # dot-notation. Accept both so users who configure copilot + a + # default hyphenated Claude model don't hit HTTP 400 + # "model_not_supported". See issue #6879. + "claude-opus-4-6": "claude-opus-4.6", + "claude-sonnet-4-6": "claude-sonnet-4.6", + "claude-sonnet-4-5": "claude-sonnet-4.5", + "claude-haiku-4-5": "claude-haiku-4.5", + "anthropic/claude-opus-4-6": "claude-opus-4.6", + "anthropic/claude-sonnet-4-6": "claude-sonnet-4.6", + "anthropic/claude-sonnet-4-5": "claude-sonnet-4.5", + "anthropic/claude-haiku-4-5": "claude-haiku-4.5", } @@ -1742,7 +1967,7 @@ def probe_api_models( candidates.append((alternate_base, True)) tried: list[str] = [] - headers: dict[str, str] = {} + headers: dict[str, str] = {"User-Agent": _HERMES_USER_AGENT} if api_key: headers["Authorization"] = f"Bearer {api_key}" if normalized.startswith(COPILOT_BASE_URL): @@ -2019,8 +2244,8 @@ def validate_requested_model( ) return { - "accepted": True, - "persist": True, + "accepted": False, + "persist": False, "recognized": False, "message": message, } @@ -2033,8 +2258,8 @@ def validate_requested_model( message += f"\n If this server expects `/v1`, try base URL: `{probe.get('suggested_base_url')}`" return { - "accepted": True, - "persist": True, + "accepted": False, + "persist": False, "recognized": False, "message": message, } @@ -2067,14 +2292,58 @@ def validate_requested_model( suggestion_text = "" if suggestions: suggestion_text = "\n Similar models: " + ", ".join(f"`{s}`" for s in suggestions) + return { + "accepted": False, + "persist": False, + "recognized": False, + "message": ( + f"Model `{requested}` was not found in the OpenAI Codex model listing." + f"{suggestion_text}" + ), + } + + # MiniMax providers don't expose a /models endpoint — validate against + # the static catalog instead, similar to openai-codex. + if normalized in ("minimax", "minimax-cn"): + try: + catalog_models = provider_model_ids(normalized) + except Exception: + catalog_models = [] + if catalog_models: + # Case-insensitive lookup (catalog uses mixed case like MiniMax-M2.7) + catalog_lower = {m.lower(): m for m in catalog_models} + if requested_for_lookup.lower() in catalog_lower: + return { + "accepted": True, + "persist": True, + "recognized": True, + "message": None, + } + # Auto-correct close matches (case-insensitive) + catalog_lower_list = list(catalog_lower.keys()) + auto = get_close_matches(requested_for_lookup.lower(), catalog_lower_list, n=1, cutoff=0.9) + if auto: + corrected = catalog_lower[auto[0]] + return { + "accepted": True, + "persist": True, + "recognized": True, + "corrected_model": corrected, + "message": f"Auto-corrected `{requested}` → `{corrected}`", + } + suggestions = get_close_matches(requested_for_lookup.lower(), catalog_lower_list, n=3, cutoff=0.5) + suggestion_text = "" + if suggestions: + suggestion_text = "\n Similar models: " + ", ".join(f"`{catalog_lower[s]}`" for s in suggestions) return { "accepted": True, "persist": True, "recognized": False, "message": ( - f"Note: `{requested}` was not found in the OpenAI Codex model listing. " - f"It may still work if your account has access to it." + f"Note: `{requested}` was not found in the MiniMax catalog." f"{suggestion_text}" + "\n MiniMax does not expose a /models endpoint, so Hermes cannot verify the model name." + "\n The model may still work if it exists on the server." ), } @@ -2112,16 +2381,15 @@ def validate_requested_model( if suggestions: suggestion_text = "\n Similar models: " + ", ".join(f"`{s}`" for s in suggestions) - return { - "accepted": True, - "persist": True, - "recognized": False, - "message": ( - f"Note: `{requested}` was not found in this provider's model listing. " - f"It may still work if your plan supports it." - f"{suggestion_text}" - ), - } + return { + "accepted": False, + "persist": False, + "recognized": False, + "message": ( + f"Model `{requested}` was not found in this provider's model listing." + f"{suggestion_text}" + ), + } # api_models is None — couldn't reach API. Accept and persist, # but warn so typos don't silently break things. @@ -2161,13 +2429,70 @@ def validate_requested_model( except Exception: pass # Fall through to generic warning + # Static-catalog fallback: when the /models probe was unreachable, + # validate against the curated list from provider_model_ids() — same + # pattern as the openai-codex and minimax branches above. This fixes + # /model switches in the gateway for providers like opencode-go and + # opencode-zen whose /models endpoint returns 404 against the HTML + # marketing site. Without this block, validate_requested_model would + # reject every model on such providers, switch_model() would return + # success=False, and the gateway would never write to + # _session_model_overrides. provider_label = _PROVIDER_LABELS.get(normalized, normalized) + try: + catalog_models = provider_model_ids(normalized) + except Exception: + catalog_models = [] + + if catalog_models: + catalog_lower = {m.lower(): m for m in catalog_models} + if requested_for_lookup.lower() in catalog_lower: + return { + "accepted": True, + "persist": True, + "recognized": True, + "message": None, + } + catalog_lower_list = list(catalog_lower.keys()) + auto = get_close_matches( + requested_for_lookup.lower(), catalog_lower_list, n=1, cutoff=0.9 + ) + if auto: + corrected = catalog_lower[auto[0]] + return { + "accepted": True, + "persist": True, + "recognized": True, + "corrected_model": corrected, + "message": f"Auto-corrected `{requested}` → `{corrected}`", + } + suggestions = get_close_matches( + requested_for_lookup.lower(), catalog_lower_list, n=3, cutoff=0.5 + ) + suggestion_text = "" + if suggestions: + suggestion_text = "\n Similar models: " + ", ".join( + f"`{catalog_lower[s]}`" for s in suggestions + ) + return { + "accepted": True, + "persist": True, + "recognized": False, + "message": ( + f"Note: `{requested}` was not found in the {provider_label} curated catalog " + f"and the /models endpoint was unreachable.{suggestion_text}" + f"\n The model may still work if it exists on the provider." + ), + } + + # No catalog available — accept with a warning, matching the comment's + # stated intent ("Accept and persist, but warn"). return { "accepted": True, "persist": True, "recognized": False, "message": ( - f"Could not reach the {provider_label} API to validate `{requested}`. " + f"Note: could not reach the {provider_label} API to validate `{requested}`. " f"If the service isn't down, this model may not be valid." ), } diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index b5efb52a8821..69e529fb5d1c 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -1,4 +1,6 @@ """ +from __future__ import annotations + Interactive setup wizard for Hermes Agent. Modular wizard with independently-runnable sections: diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 0a7657f33756..4e7bed40ce34 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1,4 +1,6 @@ """ +from __future__ import annotations + Hermes Agent — Web UI server. Provides a FastAPI backend serving the Vite/React frontend and REST API diff --git a/hermes_constants.py b/hermes_constants.py index 3bc56d4f7874..6293dced9e3f 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -1,5 +1,7 @@ """Shared constants for Hermes Agent. +from __future__ import annotations + Import-safe module with no dependencies — can be imported from anywhere without risk of circular imports. """ diff --git a/optional-skills/research/domain-intel/scripts/domain_intel.py b/optional-skills/research/domain-intel/scripts/domain_intel.py index 1a69f6528f21..4236468779ee 100644 --- a/optional-skills/research/domain-intel/scripts/domain_intel.py +++ b/optional-skills/research/domain-intel/scripts/domain_intel.py @@ -1,5 +1,7 @@ #!/usr/bin/env python3 """ +from __future__ import annotations + Domain Intelligence — Passive OSINT via Python stdlib. Usage: diff --git a/plugins/memory/holographic/store.py b/plugins/memory/holographic/store.py index 3dc66d68648c..f9193d63bd17 100644 --- a/plugins/memory/holographic/store.py +++ b/plugins/memory/holographic/store.py @@ -1,4 +1,6 @@ """ +from __future__ import annotations + SQLite-backed fact store with entity resolution and trust scoring. Single-user Hermes memory store plugin. """ diff --git a/run_agent.py b/run_agent.py index 325df9beb1d6..0c6662aad29e 100644 --- a/run_agent.py +++ b/run_agent.py @@ -1,5 +1,7 @@ #!/usr/bin/env python3 """ +from __future__ import annotations + AI Agent Runner with Tool Calling This module provides a clean, standalone agent that can execute AI models diff --git a/scripts/release.py b/scripts/release.py index 42baf5b7cccb..0746cabc6b0d 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -1,6 +1,8 @@ #!/usr/bin/env python3 """Hermes Agent Release Script +from __future__ import annotations + Generates changelogs and creates GitHub releases with CalVer tags. Usage: diff --git a/skills/productivity/google-workspace/scripts/google_api.py b/skills/productivity/google-workspace/scripts/google_api.py index 6504c098ba08..08f54bc9bf83 100644 --- a/skills/productivity/google-workspace/scripts/google_api.py +++ b/skills/productivity/google-workspace/scripts/google_api.py @@ -1,6 +1,8 @@ #!/usr/bin/env python3 """Google Workspace API CLI for Hermes Agent. +from __future__ import annotations + Uses the Google Workspace CLI (`gws`) when available, but preserves the existing Hermes-facing JSON contract and falls back to the Python client libraries if `gws` is not installed. diff --git a/skills/productivity/powerpoint/scripts/office/pack.py b/skills/productivity/powerpoint/scripts/office/pack.py index db29ed8b1c36..f6c32d608dd3 100644 --- a/skills/productivity/powerpoint/scripts/office/pack.py +++ b/skills/productivity/powerpoint/scripts/office/pack.py @@ -1,5 +1,7 @@ """Pack a directory into a DOCX, PPTX, or XLSX file. +from __future__ import annotations + Validates with auto-repair, condenses XML formatting, and creates the Office file. Usage: diff --git a/skills/red-teaming/godmode/scripts/godmode_race.py b/skills/red-teaming/godmode/scripts/godmode_race.py index dbc4510308a6..01cd3e1e181b 100644 --- a/skills/red-teaming/godmode/scripts/godmode_race.py +++ b/skills/red-teaming/godmode/scripts/godmode_race.py @@ -1,5 +1,7 @@ #!/usr/bin/env python3 """ +from __future__ import annotations + ULTRAPLINIAN Multi-Model Racing Engine Ported from G0DM0D3 (elder-plinius/G0DM0D3). diff --git a/skills/research/polymarket/scripts/polymarket.py b/skills/research/polymarket/scripts/polymarket.py index 417e0b1747ea..8bf12df59275 100644 --- a/skills/research/polymarket/scripts/polymarket.py +++ b/skills/research/polymarket/scripts/polymarket.py @@ -1,6 +1,8 @@ #!/usr/bin/env python3 """Polymarket CLI helper — query prediction market data. +from __future__ import annotations + Usage: python3 polymarket.py search "bitcoin" python3 polymarket.py trending [--limit 10] diff --git a/tests/agent/test_subagent_progress.py b/tests/agent/test_subagent_progress.py index 99375d6bd6a8..1050a764ed93 100644 --- a/tests/agent/test_subagent_progress.py +++ b/tests/agent/test_subagent_progress.py @@ -1,4 +1,6 @@ """ +from __future__ import annotations + Tests for subagent progress relay (issue #169). Verifies that: diff --git a/tests/cli/test_cli_status_bar.py b/tests/cli/test_cli_status_bar.py index eabcd0f9624e..e26ffb9106ce 100644 --- a/tests/cli/test_cli_status_bar.py +++ b/tests/cli/test_cli_status_bar.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from datetime import datetime, timedelta from types import SimpleNamespace from unittest.mock import MagicMock, patch diff --git a/tests/cli/test_worktree_security.py b/tests/cli/test_worktree_security.py index 73a242e0fdaa..4c2aded808a5 100644 --- a/tests/cli/test_worktree_security.py +++ b/tests/cli/test_worktree_security.py @@ -1,5 +1,7 @@ """Security-focused integration tests for CLI worktree setup.""" +from __future__ import annotations + import subprocess from pathlib import Path diff --git a/tests/gateway/restart_test_helpers.py b/tests/gateway/restart_test_helpers.py index 75665325b627..80e04561a496 100644 --- a/tests/gateway/restart_test_helpers.py +++ b/tests/gateway/restart_test_helpers.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import asyncio from unittest.mock import AsyncMock, MagicMock diff --git a/tests/gateway/test_background_process_notifications.py b/tests/gateway/test_background_process_notifications.py index 7351854a2c4d..1d7173298183 100644 --- a/tests/gateway/test_background_process_notifications.py +++ b/tests/gateway/test_background_process_notifications.py @@ -1,5 +1,7 @@ """Tests for configurable background process notification modes. +from __future__ import annotations + The gateway process watcher pushes status updates to users' chats when background terminal commands run. ``display.background_process_notifications`` controls verbosity: off | result | error | all (default). diff --git a/tests/gateway/test_config_cwd_bridge.py b/tests/gateway/test_config_cwd_bridge.py index 7f6a75750013..6338ae735210 100644 --- a/tests/gateway/test_config_cwd_bridge.py +++ b/tests/gateway/test_config_cwd_bridge.py @@ -1,5 +1,7 @@ """Tests for the config.yaml → env var bridge logic in gateway/run.py. +from __future__ import annotations + Specifically tests that top-level `cwd:` and `backend:` in config.yaml are correctly bridged to TERMINAL_CWD / TERMINAL_ENV env vars as convenience aliases for `terminal.cwd` / `terminal.backend`. diff --git a/tests/hermes_cli/test_commands.py b/tests/hermes_cli/test_commands.py index 8b35970969dd..f75608dbeed0 100644 --- a/tests/hermes_cli/test_commands.py +++ b/tests/hermes_cli/test_commands.py @@ -1,5 +1,7 @@ """Tests for the central command registry and autocomplete.""" +from __future__ import annotations + from prompt_toolkit.completion import CompleteEvent from prompt_toolkit.document import Document diff --git a/tests/hermes_cli/test_plugins.py b/tests/hermes_cli/test_plugins.py index a97340df5803..c153265c06da 100644 --- a/tests/hermes_cli/test_plugins.py +++ b/tests/hermes_cli/test_plugins.py @@ -1,5 +1,7 @@ """Tests for the Hermes plugin system (hermes_cli.plugins).""" +from __future__ import annotations + import logging import os import sys diff --git a/tools/approval.py b/tools/approval.py index d9fcf51a88d3..0e87ebe7ae38 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -1,5 +1,7 @@ """Dangerous command approval -- detection, prompting, and per-session state. +from __future__ import annotations + This module is the single source of truth for the dangerous command system: - Pattern detection (DANGEROUS_PATTERNS, detect_dangerous_command) - Per-session approval state (thread-safe, keyed by session_key) diff --git a/tools/budget_config.py b/tools/budget_config.py index 577e59442ee2..9d05bd694d00 100644 --- a/tools/budget_config.py +++ b/tools/budget_config.py @@ -1,5 +1,7 @@ """Configurable budget constants for tool result persistence. +from __future__ import annotations + Overridable at the RL environment level via HermesAgentEnvConfig fields. Per-tool resolution: pinned > config overrides > registry > default. """ diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index 8a685a8ccbfe..7cbb4c7df980 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -1,4 +1,6 @@ """ +from __future__ import annotations + Cron job management tools for Hermes Agent. Expose a single compressed action-oriented tool to avoid schema/context bloat. diff --git a/tools/environments/base.py b/tools/environments/base.py index 8e990792369f..062cb8e1caf7 100644 --- a/tools/environments/base.py +++ b/tools/environments/base.py @@ -1,5 +1,7 @@ """Base class for all Hermes execution environment backends. +from __future__ import annotations + Unified spawn-per-call model: every command spawns a fresh ``bash -c`` process. A session snapshot (env vars, functions, aliases) is captured once at init and re-sourced before each command. CWD persists via in-band stdout markers (remote) diff --git a/tools/environments/daytona.py b/tools/environments/daytona.py index 6eff002ae072..8a96312e4f45 100644 --- a/tools/environments/daytona.py +++ b/tools/environments/daytona.py @@ -1,5 +1,7 @@ """Daytona cloud execution environment. +from __future__ import annotations + Uses the Daytona Python SDK to run commands in cloud sandboxes. Supports persistent sandboxes: when enabled, sandboxes are stopped on cleanup and resumed on next creation, preserving the filesystem across sessions. diff --git a/tools/environments/docker.py b/tools/environments/docker.py index d2ea5c964cf6..4704b8c056c8 100644 --- a/tools/environments/docker.py +++ b/tools/environments/docker.py @@ -1,5 +1,7 @@ """Docker execution environment for sandboxed command execution. +from __future__ import annotations + Security hardened (cap-drop ALL, no-new-privileges, PID limits), configurable resource limits (CPU, memory, disk), and optional filesystem persistence via bind mounts. diff --git a/tools/environments/file_sync.py b/tools/environments/file_sync.py index 0a54cbb85d02..0a073932bd96 100644 --- a/tools/environments/file_sync.py +++ b/tools/environments/file_sync.py @@ -1,5 +1,7 @@ """Shared file sync manager for remote execution backends. +from __future__ import annotations + Tracks local file changes via mtime+size, detects deletions, and syncs to remote environments transactionally. Used by SSH, Modal, and Daytona. Docker and Singularity use bind mounts (live host FS diff --git a/tools/environments/local.py b/tools/environments/local.py index a1ab676d3034..0c460e1ca37f 100644 --- a/tools/environments/local.py +++ b/tools/environments/local.py @@ -1,5 +1,7 @@ """Local execution environment — spawn-per-call with session snapshot.""" +from __future__ import annotations + import os import platform import shutil diff --git a/tools/environments/modal.py b/tools/environments/modal.py index 4b7e9db0cd60..a64b389786c8 100644 --- a/tools/environments/modal.py +++ b/tools/environments/modal.py @@ -1,5 +1,7 @@ """Modal cloud execution environment using the native Modal SDK directly. +from __future__ import annotations + Uses ``Sandbox.create()`` + ``Sandbox.exec()`` instead of the older runtime wrapper, while preserving Hermes' persistent snapshot behavior across sessions. """ diff --git a/tools/environments/singularity.py b/tools/environments/singularity.py index 16d1013fed8c..1db0e9009397 100644 --- a/tools/environments/singularity.py +++ b/tools/environments/singularity.py @@ -1,5 +1,7 @@ """Singularity/Apptainer persistent container environment. +from __future__ import annotations + Security-hardened with --containall, --no-home, capability dropping. Supports configurable resource limits and optional filesystem persistence via writable overlay directories that survive across sessions. diff --git a/tools/environments/ssh.py b/tools/environments/ssh.py index 568112b2c8f2..430b5e97249c 100644 --- a/tools/environments/ssh.py +++ b/tools/environments/ssh.py @@ -1,5 +1,7 @@ """SSH remote execution environment with ControlMaster connection persistence.""" +from __future__ import annotations + import logging import os import shlex diff --git a/tools/file_tools.py b/tools/file_tools.py index ca2118c33e29..a9473df93ab1 100644 --- a/tools/file_tools.py +++ b/tools/file_tools.py @@ -1,6 +1,8 @@ #!/usr/bin/env python3 """File Tools Module - LLM agent file manipulation tools.""" +from __future__ import annotations + import errno import json import logging diff --git a/tools/image_generation_tool.py b/tools/image_generation_tool.py index cf1003d12b04..88be573b82cc 100644 --- a/tools/image_generation_tool.py +++ b/tools/image_generation_tool.py @@ -1,5 +1,7 @@ #!/usr/bin/env python3 """ +from __future__ import annotations + Image Generation Tools Module Provides image generation via FAL.ai. Multiple FAL models are supported and diff --git a/tools/interrupt.py b/tools/interrupt.py index 9bc8b83ae4fa..86c198496af3 100644 --- a/tools/interrupt.py +++ b/tools/interrupt.py @@ -1,5 +1,7 @@ """Per-thread interrupt signaling for all tools. +from __future__ import annotations + Provides thread-scoped interrupt tracking so that interrupting one agent session does not kill tools running in other sessions. This is critical in the gateway where multiple agents run concurrently in the same process. diff --git a/tools/mcp_oauth.py b/tools/mcp_oauth.py index 6e1d7f5fb060..135a4e3579d7 100644 --- a/tools/mcp_oauth.py +++ b/tools/mcp_oauth.py @@ -1,5 +1,7 @@ #!/usr/bin/env python3 """ +from __future__ import annotations + MCP OAuth 2.1 Client Support Implements the browser-based OAuth 2.1 authorization code flow with PKCE diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index e5e856d0bb5e..29eb1d3043be 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -1,5 +1,7 @@ #!/usr/bin/env python3 """ +from __future__ import annotations + MCP (Model Context Protocol) Client Support Connects to external MCP servers via stdio or HTTP/StreamableHTTP transport, diff --git a/tools/registry.py b/tools/registry.py index e6d554e2bb7b..d8d6931534cb 100644 --- a/tools/registry.py +++ b/tools/registry.py @@ -1,5 +1,7 @@ """Central registry for all hermes-agent tools. +from __future__ import annotations + Each tool file calls ``registry.register()`` at module level to declare its schema, handler, toolset membership, and availability check. ``model_tools.py`` queries the registry instead of maintaining its own parallel data structures. diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 37a16f78c09e..a1167dcaed18 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -1,5 +1,7 @@ """Send Message Tool -- cross-channel messaging via platform APIs. +from __future__ import annotations + Sends a message to a user or channel on any connected messaging platform (Telegram, Discord, Slack). Supports listing available targets and resolving human-friendly channel names to IDs. Works in both CLI and gateway contexts. diff --git a/tools/skills_guard.py b/tools/skills_guard.py index 3513f46f0468..549d5db75eb8 100644 --- a/tools/skills_guard.py +++ b/tools/skills_guard.py @@ -1,5 +1,7 @@ #!/usr/bin/env python3 """ +from __future__ import annotations + Skills Guard — Security scanner for externally-sourced skills. Every skill downloaded from a registry passes through this scanner before diff --git a/tools/skills_tool.py b/tools/skills_tool.py index ed8c8cfb08a1..73c90c431f15 100644 --- a/tools/skills_tool.py +++ b/tools/skills_tool.py @@ -1,5 +1,7 @@ #!/usr/bin/env python3 """ +from __future__ import annotations + Skills Tool Module This module provides tools for listing and viewing skill documents. diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 69832cc1c7a9..3cb55bddeee3 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -1,5 +1,7 @@ #!/usr/bin/env python3 """ +from __future__ import annotations + Terminal Tool Module A terminal tool that executes commands in local, Docker, Modal, SSH, Singularity, and Daytona environments. diff --git a/tools/tirith_security.py b/tools/tirith_security.py index 44710ee60881..39c62ff3cb6d 100644 --- a/tools/tirith_security.py +++ b/tools/tirith_security.py @@ -1,5 +1,7 @@ """Tirith pre-exec security scanning wrapper. +from __future__ import annotations + Runs the tirith binary as a subprocess to scan commands for content-level threats (homograph URLs, pipe-to-interpreter, terminal injection, etc.). diff --git a/tools/todo_tool.py b/tools/todo_tool.py index b0d38a234266..ec4c4485e9df 100644 --- a/tools/todo_tool.py +++ b/tools/todo_tool.py @@ -1,5 +1,7 @@ #!/usr/bin/env python3 """ +from __future__ import annotations + Todo Tool Module - Planning & Task Management Provides an in-memory task list the agent uses to decompose complex tasks, diff --git a/tools/tool_result_storage.py b/tools/tool_result_storage.py index 43422644825b..598448fa13f6 100644 --- a/tools/tool_result_storage.py +++ b/tools/tool_result_storage.py @@ -1,5 +1,7 @@ """Tool result persistence -- preserves large outputs instead of truncating. +from __future__ import annotations + Defense against context-window overflow operates at three levels: 1. **Per-tool output cap** (inside each tool): Tools like search_files diff --git a/tools/voice_mode.py b/tools/voice_mode.py index 50515fc6903f..20c94e6170d0 100644 --- a/tools/voice_mode.py +++ b/tools/voice_mode.py @@ -1,5 +1,7 @@ """Voice Mode -- Push-to-talk audio recording and playback for the CLI. +from __future__ import annotations + Provides audio capture via sounddevice, WAV encoding via stdlib wave, STT dispatch via tools.transcription_tools, and TTS playback via sounddevice or system audio players. diff --git a/utils.py b/utils.py index cf2582853f59..c0bcda690fb3 100644 --- a/utils.py +++ b/utils.py @@ -1,5 +1,7 @@ """Shared utility functions for hermes-agent.""" +from __future__ import annotations + import json import logging import os From c64fe97f458ee4590936539890e3bee3f26e0c6b Mon Sep 17 00:00:00 2001 From: vominh1919 Date: Sat, 25 Apr 2026 14:44:25 +0700 Subject: [PATCH 2/2] fix: close file descriptor in LocalEnvironment._update_cwd The bare open(self._cwd_file).read() call leaks a file descriptor on every terminal command execution. Use a with-statement so the fd is released promptly, preventing fd exhaustion in long sessions. --- tools/environments/local.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/environments/local.py b/tools/environments/local.py index 0c460e1ca37f..d431e69afa11 100644 --- a/tools/environments/local.py +++ b/tools/environments/local.py @@ -298,7 +298,8 @@ def _kill_process(self, proc): def _update_cwd(self, result: dict): """Read CWD from temp file (local-only, no round-trip needed).""" try: - cwd_path = open(self._cwd_file).read().strip() + with open(self._cwd_file) as f: + cwd_path = f.read().strip() if cwd_path: self.cwd = cwd_path except (OSError, FileNotFoundError):